Когда приложение делает INSERT или UPDATE, данные не сразу летят на диск. PostgreSQL сначала записывает изменение в специальный файл — WAL. Именно он обеспечивает надёжность: если сервер упадёт, база восстановится по этому журналу. Но WAL — не просто «страховка»: от его размера зависит скорость записи, расход диска и то, насколько быстро работает репликация.
Разберём, что происходит внутри и какие решения разработчика напрямую влияют на объём журнала.
Журнал только дописывается в конец — это последовательная запись, самая дешёвая для диска. Страницы самой таблицы уходят на диск позже, на контрольной точке. Поэтому после сбоя на диске лежит состояние на момент последней контрольной точки, а разницу PostgreSQL добирает повтором записей журнала.
Как PostgreSQL записывает данные
Представьте записную книжку, в которую шеф-повар пишет каждое изменение рецепта до того, как применить его на кухне. Если кухня сгорит — книжка поможет восстановить всё с последней записи. WAL — это такая книжка для базы данных.
Когда вы выполняете COMMIT, происходит следующее:
INSERT INTO orders ...; COMMIT;
1. PostgreSQL изменяет страницу в памяти (shared buffers).
2. Записывает изменение в WAL-буфер (тоже память).
3. На COMMIT — сбрасывает WAL-буфер на диск (fsync).
4. Только после fsync отвечает клиенту «OK».
5. Позже, на контрольной точке (checkpoint), изменённая страница
сбрасывается на диск отдельно.
Ключевой момент: fsync происходит на каждый COMMIT. Это синхронная операция — пока диск не подтвердил запись, база не отвечает. Скорость этого fsync и есть верхняя граница скорости записи.
Контрольная точка: когда журнал перестаёт быть нужен
Журнал не может расти вечно — иначе восстановление после сбоя читало бы его с самого начала времён. Поэтому база периодически делает контрольную точку: сбрасывает на диск все изменённые страницы данных и записывает в журнал отметку «до этого места всё уже в таблицах». Восстановление после аварии начинается с последней такой отметки, а журнал до неё можно удалять или архивировать.
Отсюда все настройки вокруг неё, и их всего три важных.
checkpoint_timeout (по умолчанию 5 минут) — как часто контрольная точка делается по времени. max_wal_size (по умолчанию 1 ГБ) — сколько журнала накопится, прежде чем она случится по объёму. Что произойдёт раньше, то и сработает, и вот здесь прячется главная беда: если под нагрузкой гигабайт журнала набирается за полминуты, контрольные точки идут каждые полминуты вместо пяти, и каждая заново сбрасывает на диск все горячие страницы. Такая контрольная точка называется вынужденной, и в журнале сервера её видно прямо: checkpoints_req растёт быстрее checkpoints_timed, а в логе появляется предупреждение «checkpoints are occurring too frequently».
Третья настройка растягивает работу во времени. checkpoint_completion_target (по умолчанию 0.9) говорит, за какую долю интервала записать все страницы: не одним рывком в начале, а равномерно почти до следующей точки. Именно поэтому на графиках записи вместо пиков видна ровная полка.
Лечение почти всегда одно: увеличить max_wal_size так, чтобы контрольные точки шли по времени, а не по объёму. Ценой становится более долгое восстановление после сбоя (читать придётся больше журнала) и больше места под сам журнал — и это тот компромисс, который выбирают осознанно.
SELECT checkpoints_timed, checkpoints_req, buffers_checkpoint FROM pg_stat_checkpointer;
В PostgreSQL 16 и старше те же счётчики лежат в pg_stat_bgwriter. Соотношение checkpoints_req к checkpoints_timed — первое, на что смотрят, когда «база периодически тормозит на ровном месте».
wal_level: сколько подробностей писать в журнал
Объём журнала зависит не только от нагрузки, но и от настройки wal_level, то есть от того, кому он нужен кроме восстановления.
replica — значение по умолчанию: журнала хватает и на восстановление после сбоя, и на физическую репликацию, и на восстановление на момент времени.
logical нужен для логической репликации и для чтения изменений сторонними инструментами (CDC — захват изменений данных). Он добавляет в журнал сведения, по которым изменение можно разобрать построчно, и объём растёт — обычно умеренно, но растёт.
minimal пишет только то, что нужно для восстановления после сбоя, и запрещает репликацию и архив. Зато даёт приём, ради которого его иногда включают: если таблица создана или полностью перезаписана в той же транзакции, что и загрузка в неё данных, при minimal эта загрузка не пишется в журнал вовсе — база и так знает, что при откате файл целиком выбрасывается. Массовая загрузка из-за этого ускоряется кратно.
BEGIN;
CREATE TABLE report_tmp (LIKE orders INCLUDING ALL);
COPY report_tmp FROM '/data/orders.csv' CSV; -- при wal_level = minimal журнал почти не растёт
COMMIT;
И отдельная строчка, о которой узнают дорого: REPLICA IDENTITY FULL. Эту настройку ставят на таблицу без первичного ключа, чтобы логическая репликация могла опознать изменённую строку, — и тогда в журнал пишется вся старая версия строки при каждом UPDATE и DELETE. На широкой таблице это кратный рост журнала. Правильный ответ почти всегда другой: завести первичный ключ или уникальный индекс и указать его через REPLICA IDENTITY USING INDEX.
Сколько WAL генерирует каждая операция
Диск под журнал заполняется быстрее, чем под данные, а реплика отстаёт: пора считать, сколько журнала рождает каждая операция, и первым удивляет UPDATE. Из-за MVCC каждое обновление — это новый физический экземпляр строки, а не заплатка поверх старого: в таблице появляется вторая версия строки целиком.
Но в журнал эта вторая версия попадает целиком не всегда. Если новая версия легла на ту же страницу, что и старая, PostgreSQL сравнивает их и записывает только изменившуюся середину: одинаковое начало и одинаковый хвост он выбрасывает. Замер на PostgreSQL 17, строка около 640 байт, fillfactor = 40, полные образы страниц уже выписаны:
- обновили одну колонку
int— 92 байта журнала на строку; - обновили текстовую колонку в 300 байт — 396 байт на строку;
- та же таблица с
fillfactor = 100, новая версия уезжает на другую страницу — 830 байт на строку.
Вот последняя строчка и есть «новая версия целиком»: сравнивать не с чем, обрезать нечего, плюс приходится править индексы. Поэтому правило звучит так: чем чаще обновление остаётся на своей странице, тем меньше журнала оно пишет — и это ровно то, чем управляет fillfactor в следующем разделе.
Примерный размер записей в WAL:
| Операция | Что пишется в WAL |
|---|---|
INSERT одной строки | ~50–200 байт + значения колонок |
UPDATE на другую страницу | ~50 байт + новая версия строки целиком + правки индексов |
UPDATE внутри страницы | ~50 байт + только изменившийся кусок строки |
UPDATE с HOT | меньше всего: изменившийся кусок и ни одной записи об индексах |
DELETE | ~50 байт + идентификатор строки |
CREATE INDEX без CONCURRENTLY | весь индекс целиком |
VACUUM | список освобождённых версий строк |
Полные образы страниц — главный источник объёма
Всё, что выше, — это десятки и сотни байт. А теперь главное слагаемое, которое обычно больше их всех вместе.
PostgreSQL пишет на диск страницами по 8 КБ, а операционная система и диск — блоками поменьше. Если питание пропадёт ровно посреди такой записи, на диске останется полустраница: часть новая, часть старая. Восстановить её по обычной журнальной записи «поменяй байты с 200-го по 240-й» нельзя — неизвестно, что там сейчас лежит. Поэтому PostgreSQL страхуется: первое изменение каждой страницы после контрольной точки пишет в журнал не правку, а целиком всю страницу, 8 КБ. Это и есть full-page write, а в статистике — колонка wal_fpi.
После контрольной точки первая правка каждой страницы уходит в журнал целиком, все 8 КБ, а следующие правки той же страницы пишут только изменившийся кусок; отсюда и пила на графике объёма журнала.
Отсюда две вещи, которые иначе выглядят загадочно:
- «пила» на графике записи в журнал: сразу после контрольной точки объём подскакивает, потом спадает — страницы уже выписаны, и дальше идут маленькие правки;
- объём журнала зависит не только от нагрузки, но и от
checkpoint_timeoutиmax_wal_size. Чем чаще контрольные точки, тем чаще каждая горячая страница выписывается заново целиком.
Параметр full_page_writes включён по умолчанию, и выключать его нельзя — это прямой риск испортить базу при сбое питания. А вот сжимать эти образы можно, и по умолчанию этого никто не делает:
-- по умолчанию off; lz4 и zstd доступны с PostgreSQL 15
ALTER SYSTEM SET wal_compression = 'lz4';
SELECT pg_reload_conf();
wal_compression сжимает именно полные образы страниц, и на базе с широкими строками он режет журнал в разы — ценой процессорного времени на сжатие. Это самый дешёвый рычаг из всех, что есть в этой статье, и он почти всегда выключен просто потому, что о нём не знают.
HOT и fillfactor: главный рычаг для update-нагрузки
Обычный UPDATE создаёт новую версию строки, и если она не поместилась на ту же страницу, PostgreSQL обновляет все индексы таблицы, даже когда индексированные колонки не менялись: индекс ссылается на физическое место строки, а оно сменилось. Это двойная работа: и WAL больше, и индексы замедляются.
HOT (Heap-Only Tuple) — оптимизация, при которой новая версия строки ссылается на старую прямо внутри страницы, без участия индексов. Условия:
- Новая версия строки помещается на ту же страницу памяти.
- Изменение не затронуло ни одну индексируемую колонку.
Если оба условия выполнены, PostgreSQL не трогает индексы — а вместе с ними из журнала исчезают и записи об их обновлении. На таблице с несколькими индексами именно они и составляют бо́льшую часть журнала обновления.
Проблема: при fillfactor = 100 (значение по умолчанию) страница заполнена под завязку, и новой версии строки нет места рядом со старой. HOT не срабатывает.
Одно обновление без HOT и с HOT: во втором случае новая версия остаётся на своей странице, индексы не переписываются, и записи об их обновлении не попадают в журнал.
Решение — fillfactor 80–90: оставить 10–20% страницы свободными специально для обновлений.
-- при создании таблицы
CREATE TABLE orders (...) WITH (fillfactor = 90);
-- для существующей таблицы
ALTER TABLE orders SET (fillfactor = 80);
COPY вместо цикла INSERT: 10–100x быстрее при массовой загрузке
Допустим, нужно загрузить миллион строк. Интуитивно пишут цикл из INSERT:
INSERT INTO orders (id, customer_id, total) VALUES (...);
-- повторяется миллион раз
Если такой цикл идёт в режиме автокоммита — а он включён по умолчанию и в psql, и в JDBC, — каждый INSERT становится отдельной транзакцией: отдельный fsync, отдельный сетевой обмен. Миллион строк — миллион fsync. Это очень медленно.
Намного эффективнее: объединить строки в пакет внутри одной транзакции. Ещё эффективнее — использовать COPY, специально созданный для массовой загрузки:
COPY orders (id, customer_id, total) FROM STDIN;
COPY передаёт данные одним потоком, укладывает в одну запись журнала сразу пачку строк вместо записи на каждую и обходится одним fsync на всю загрузку.
С чем сравнивать — важно. Разрыв в 10–100 раз получается против цикла из отдельных INSERT в автокоммите, где на каждую строку приходится свой fsync и свой сетевой обмен. Если те же INSERT сложить в пакеты по несколько тысяч внутри одной транзакции, вы уже заберёте бо́льшую часть выигрыша, и COPY будет быстрее такого пакета в разы, а не в десятки раз. Журнала при COPY тоже меньше, и тем заметнее, чем короче строка.
Длинные транзакции: тихий убийца
Открытая транзакция мешает VACUUM: он не может убрать старые версии строк, которые эта транзакция ещё может увидеть. Таблицы разбухают, запросы по ним замедляются, и чем дольше висит транзакция, тем хуже.
Отдельно стоит развеять распространённое заблуждение: сами по себе открытые транзакции WAL не удерживают. Журнал заполняет диск по другим причинам — не сработавший вовремя контрольный сброс данных (max_wal_size), сломанная команда архивации, из-за которой сегменты копятся в ожидании копирования, и отставшая реплика со своим слотом. Это разные проблемы с разными симптомами, и лечатся они по-разному.
Типичные причины длинных транзакций:
- транзакция охватывает сетевой вызов (HTTP-запрос к другому сервису, запись в S3, отправка в очередь);
- разработчик открыл транзакцию в консоли и забыл закрыть;
- пакетная задача обрабатывает миллион строк без промежуточных коммитов.
Посмотреть текущие долгие транзакции:
живой пример
SELECT pid,
age(now(), xact_start) AS xact_age,
state,
query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_age DESC
LIMIT 10;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Правило: транзакция должна занимать секунды, не минуты. Настройте алерт на xact_age > 5 min.
Replication slot: WAL может копиться бесконечно
Replication slot — это механизм, который гарантирует, что PostgreSQL не удалит WAL до тех пор, пока подписчик (реплика или логический потребитель) его не прочитает. Это удобно, но опасно.
Если реплика отвалилась или логический потребитель остановился — WAL будет копиться бесконечно, пока не закончится место на диске.
Проверить состояние слотов:
живой пример
SELECT slot_name,
active,
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS lag_bytes
FROM pg_replication_slots
ORDER BY lag_bytes DESC;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Настройте алерт: lag_bytes > 10 ГБ или active = false дольше часа.
В PostgreSQL 13+ есть параметр max_slot_wal_keep_size: если слот отстал больше заданного предела, PostgreSQL удаляет WAL (слот сломается, но кластер не упадёт). По умолчанию он равен -1 — то есть предела нет и защита выключена. Её надо включить руками, поставив разумный потолок вроде max_slot_wal_keep_size = '64GB'.
Глубже: Восстановление по журналу на маленькой программерасширенное
Порядок «сначала журнал, потом страницы» из раздела о записи данных стоит увидеть руками. Почему такого порядка достаточно для надёжности, видно на маленькой программе: журнал здесь — список записей, страницы — словарь в памяти, а на диск состояние попадает только в контрольной точке:
живой пример
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class WalDemo {
record Change(String key, String value) {}
static final List<Change> wal = new ArrayList<>();
static final Map<String, String> buffers = new LinkedHashMap<>();
static Map<String, String> disk = new LinkedHashMap<>();
public static void main(String[] args) {
commit(new Change("order-1", "NEW"));
commit(new Change("order-1", "PAID"));
int checkpoint = checkpoint();
commit(new Change("order-2", "NEW"));
commit(new Change("order-1", "SHIPPED"));
System.out.println("в памяти до сбоя: " + buffers);
System.out.println("на диске: " + disk);
Map<String, String> recovered = new LinkedHashMap<>(disk);
for (Change change : wal.subList(checkpoint, wal.size())) {
recovered.put(change.key(), change.value());
}
System.out.println("после повтора WAL: " + recovered);
System.out.println("потерь нет: " + recovered.equals(buffers));
}
static void commit(Change change) {
wal.add(change);
buffers.put(change.key(), change.value());
}
static int checkpoint() {
disk = new LinkedHashMap<>(buffers);
return wal.size();
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Цена такого устройства — время восстановления: оно тем больше, чем больше записей накопилось после последней контрольной точки.
Глубже: fillfactor на готовой таблице и доля HOTрасширенное
fillfactor выставлен — теперь его надо донести до уже лежащих данных и убедиться, что HOT действительно срабатывает.
На уже лежащие данные новый fillfactor не действует — он влияет только на страницы, которые база заполняет после команды. Чтобы применить его ко всей таблице, её надо переписать. VACUUM FULL orders это сделает, но возьмёт ACCESS EXCLUSIVE: на большой orders таблица будет недоступна ни для чтения, ни для записи, и на десятках гигабайт это часы. На живой базе для такой перераскладки берут pg_repack — он перестраивает таблицу в фоне:
pg_repack -d mydb -t orders
Проверить, как часто срабатывает HOT:
живой пример
SELECT relname,
n_tup_upd,
n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 1) AS hot_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 1000
ORDER BY n_tup_upd DESC
LIMIT 10;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Если hot_pct ниже 50% на активно обновляемой таблице — стоит снизить fillfactor. Цена — 10–20% дополнительного места на диске, выигрыш — кратное снижение WAL и нагрузки на индексы.
Важное ограничение: HOT не работает, если UPDATE меняет колонку, по которой есть индекс. Если такая колонка меняется часто, а индекс слабоселективный — рассмотрите удаление этого индекса.
Глубже: COPY из кода и загрузка без индексоврасширенное
COPY выбран — остаётся вызвать его из кода и снять с загрузки лишнюю работу индексов.
Как использовать COPY из кода:
import java.io.Reader;
import java.io.StringReader;
import java.sql.Connection;
import org.postgresql.copy.CopyManager;
import org.postgresql.core.BaseConnection;
void copyInsert(Connection conn, String csvData) throws Exception {
CopyManager cm = new CopyManager((BaseConnection) conn);
try (Reader reader = new StringReader(csvData)) {
cm.copyIn("COPY orders (id, customer_id, total) FROM STDIN WITH CSV", reader);
}
}
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// pgx.CopyFrom транслируется в COPY STDIN
func bulkInsert(ctx context.Context, pool *pgxpool.Pool, orders []Order) (int64, error) {
rows := pgx.CopyFromSlice(len(orders), func(i int) ([]any, error) {
return []any{orders[i].ID, orders[i].CustomerID, orders[i].Total}, nil
})
return pool.CopyFrom(ctx,
pgx.Identifier{"orders"},
[]string{"id", "customer_id", "total"},
rows,
)
}
import { Pool } from 'pg';
import { from as copyFrom } from 'pg-copy-streams';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function copyInsert(csvData: string): Promise<void> {
const client = await pool.connect();
try {
const stream = client.query(
copyFrom('COPY orders (id, customer_id, total) FROM STDIN WITH CSV'),
);
await pipeline(Readable.from([csvData]), stream);
} finally {
client.release();
}
}
import asyncpg
async def bulk_insert(pool: asyncpg.Pool, orders: list[dict]) -> None:
records = [(o["id"], o["customer_id"], o["total"]) for o in orders]
async with pool.acquire() as conn:
await conn.copy_records_to_table(
"orders",
records=records,
columns=["id", "customer_id", "total"],
)
Если COPY по каким-то причинам не подходит — хотя бы объединяйте строки в пакеты по 1–10 тысяч штук внутри одной транзакции.
Ещё один приём при массовой загрузке: удалить индексы до загрузки, загрузить, восстановить индексы. Каждая вставка с активным индексом пишет в WAL не только строку таблицы, но и обновление каждого индекса.
DROP INDEX CONCURRENTLY ix_orders_customer;
COPY orders FROM '/tmp/data.csv';
CREATE INDEX CONCURRENTLY ix_orders_customer ON orders (customer_id);
CONCURRENTLY тут не только у создания, но и у удаления: обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу и на время своей работы не пускает к ней никого. Если таблица в этот момент под нагрузкой — это остановка сервиса ради подготовки к загрузке. Обе команды с CONCURRENTLY нельзя выполнять внутри транзакции, и пока индекса нет, запросы по customer_id пойдут перебором — на живой таблице приём годится в окно, когда таких запросов мало.
Глубже: UNLOGGED таблицы: нулевой WAL для временных данныхрасширенное
Некоторые данные не требуют надёжности. Кеш сессий, промежуточные результаты ETL, очереди задач с коротким временем жизни — если сервер упадёт, их несложно восстановить.
Для таких данных PostgreSQL предлагает UNLOGGED-таблицы: изменения в них вообще не пишутся в WAL.
CREATE UNLOGGED TABLE session_cache (
session_id uuid PRIMARY KEY,
payload jsonb NOT NULL,
expires_at timestamptz NOT NULL
);
Обратная сторона: такие таблицы не реплицируются, и при аварийном завершении PostgreSQL их содержимое обнуляется. Для бизнес-данных — неприемлемо. Для кешей и временных таблиц — отличный выбор.
Три вещи про такие таблицы, которые стоит знать до того, как они появятся в схеме. Первое: данные обнуляются при любом нештатном останове — не только при аварии сервера, но и при kill -9, и при переключении на резерв; после перезапуска таблица существует и пуста. Второе: на реплике её не прочитать вовсе — раз в журнал ничего не пишется, реплике неоткуда взять содержимое, и запрос к ней вернёт ошибку. Третье: превратить такую таблицу в обычную можно (ALTER TABLE … SET LOGGED), но это перепишет её целиком и запишет всё содержимое в журнал — то есть операция по стоимости равна полной перезаписи таблицы, и на большой таблице её планируют, а не делают между делом.
Глубже: TOAST: почему большие поля не всегда дорогорасширенное
Обновили счётчик просмотров, а рядом в той же строке лежит 50 КБ текста статьи, и кажется, что каждый такой UPDATE переписывает все 50 КБ. Не переписывает: большие значения (длинные тексты, JSON) PostgreSQL автоматически хранит в отдельной TOAST-таблице, и если при UPDATE большое значение не изменилось, оно не переписывается ни в таблице, ни в WAL.
-- обновляем только счётчик просмотров
UPDATE article SET view_count = view_count + 1 WHERE id = ?;
-- поле body (50 КБ текста) хранится в TOAST и НЕ пишется в WAL
Это аргумент против хранения всего подряд в одном большом jsonb-поле: если обновить один маленький ключ внутри JSONB, PostgreSQL не знает, что остальное не изменилось, и записывает весь объект в WAL.
Глубже: synchronous_commit: скорость vs надёжностьрасширенное
По умолчанию PostgreSQL ждёт подтверждения от диска перед каждым COMMIT. Это гарантирует, что данные не потеряются даже при аварии.
Параметр synchronous_commit = off убирает это ожидание: база отвечает «OK» немедленно, а данные попадают на диск чуть позже. Насколько позже, задаётся параметром wal_writer_delay (по умолчанию 200 мс), и в худшем случае окно риска — три таких интервала, то есть около 0,6 секунды. Это заметно увеличивает пропускную способность записи.
Цена: если база упадёт внутри этого окна — последние закоммиченные транзакции могут потеряться. Согласованность базы при этом не нарушается, теряются только данные этого окна.
-- включить только для конкретной операции
BEGIN;
SET LOCAL synchronous_commit = off;
INSERT INTO metrics_event ...;
COMMIT;
Применимо для метрик, аналитики, логов. Никогда — для финансовых операций и заказов.
Между «ждать» и «не ждать» есть промежуточные значения, и каждое отвечает на свою задачу. synchronous_commit = local ждёт записи журнала на своём сервере, но не ждёт реплик — полезно, когда синхронная реплика настроена, а конкретная операция может обойтись без её подтверждения. remote_write ждёт, пока реплика примет журнал и отдаст его операционной системе (переживает падение процесса на реплике, но не падение машины). on при синхронной реплике ждёт, пока реплика запишет журнал на диск. А remote_apply ждёт, пока реплика ещё и применит изменения, — и это прямой ответ на задачу «записал на основном сервере и сразу читаю с реплики»: после такого коммита данные на реплике уже видны. Платят за это задержкой каждого коммита, поэтому remote_apply включают не глобально, а на те транзакции, которым это нужно.
Ещё два параметра из этой же области, без которых картина «скорость fsync и есть потолок» неполна. wal_buffers — буфер журнала в памяти (по умолчанию вычисляется от shared_buffers, обычно 16 МБ); слишком маленький заставляет писать журнал чаще, чем нужно. А групповой коммит размазывает ту самую границу: пока один процесс ждёт сброса на диск, подоспевшие следом транзакции попадают в тот же сброс, и один fsync подтверждает десятки коммитов сразу. Управляют этим commit_delay и commit_siblings — они заставляют ждать чуть-чуть дольше в расчёте набрать группу. На нагруженной базе группировка происходит и без настройки, а сами параметры трогают редко и осторожно.
Глубже: Мониторинг WALрасширенное
Несколько запросов, которые стоит добавить в мониторинг:
-- объём директории pg_wal/
SELECT pg_size_pretty(sum(size)) FROM pg_ls_waldir();
-- статистика записи в WAL (появилась в PostgreSQL 14)
SELECT * FROM pg_stat_wal;
-- PostgreSQL 14-17: wal_records, wal_fpi, wal_bytes, wal_buffers_full,
-- wal_write, wal_sync, wal_write_time, wal_sync_time, stats_reset
В PostgreSQL 18 четыре колонки про время и число операций записи и синхронизации из этой вьюхи убрали — теперь они в pg_stat_io. На PostgreSQL 13 и ниже pg_stat_wal нет вовсе.
На что обращать внимание:
- размер
pg_wal/— алерт при превышенииmax_wal_sizeвдвое: дальше это уже слот репликации, неработающий архив или слишком редкие контрольные точки; - рост
wal_bytes— признак новой нагрузки; - доля
wal_fpiв общем объёме: если полных образов страниц много, помогутwal_compressionи более редкие контрольные точки (checkpoint_timeout,max_wal_size); - рост
wal_buffers_full— сигнал увеличить параметрwal_buffers; - HOT-ratio ниже 50% на горячих таблицах;
- транзакции дольше 5 минут;
- отставание replication slot:
lag_bytesбольше 10 ГБ илиactive = falseдольше часа.
И про то, где журнал физически лежит: каталог pg_wal внутри каталога данных, файлами по 16 МБ. Его иногда выносят на отдельный диск (символической ссылкой или параметром --waldir при создании кластера), и причина не в объёме, а в характере нагрузки: журнал пишется строго последовательно и синхронно, а данные — вразнобой. Когда они делят один диск, случайные чтения таблиц мешают последовательной записи журнала, и коммиты начинают ждать. На отдельном устройстве журнал пишется своим темпом.
Из той же серии предупреждение: pg_wal нельзя чистить руками. Удалённый файл журнала, который ещё нужен для восстановления или реплике, означает, что кластер больше не поднимется. Место в pg_wal освобождает сама база после контрольной точки — а если не освобождает, причина в одном из трёх: слот репликации, который никто не читает, неработающий архив (archive_command падает, и база держит журнал до последнего) или слишком большой max_wal_size.
Коротко
- WAL — журнал изменений, который пишется до ответа на
COMMIT: на каждыйCOMMITприходитсяfsync, и его скорость — верхняя граница скорости записи. Страницы самих таблиц уходят на диск позже, на контрольной точке. - Главное слагаемое объёма — полные образы страниц (
wal_fpi): первое изменение страницы после контрольной точки пишет в журнал все её 8 КБ;full_page_writesне выключать. Сама контрольная точка идёт поcheckpoint_timeoutили поmax_wal_size, и еслиcheckpoints_reqобгоняетcheckpoints_timed, база тормозит рывками — лечится увеличениемmax_wal_sizeценой более долгого восстановления. wal_compression = 'lz4'сжимает именно эти образы и режет журнал в разы; по умолчанию онoff— самый дешёвый рычаг из всех.UPDATEвнутри одной страницы пишет только изменившийся кусок строки (92 байта против 830 в замере); новая версия целиком уходит в журнал, когда она не поместилась рядом со старой.- HOT убирает из журнала ещё и обновление индексов: нужно свободное место на странице и неизменные индексируемые колонки. Включается через
fillfactor 80–90, к готовой таблице применяется через pg_repack, а неVACUUM FULL;hot_pctниже 50% на горячей таблице — сигнал снизитьfillfactor. COPYбыстрее циклаINSERTв автокоммите в 10–100 раз: одна запись журнала на пачку строк и одинfsyncна всю загрузку. Против пакетныхINSERTв одной транзакции разрыв — в разы; еслиCOPYне подходит, пакеты по 1–10 тысяч строк в одной транзакции.UNLOGGED-таблицы не пишут WAL вовсе, но не читаются с реплики, обнуляются при любом нештатном останове, аSET LOGGEDпереписывает их целиком.synchronous_commitне двоичный:offдаёт окно риска около 0,6 с,local,remote_writeиonразличаются тем, чего ждёт коммит, аremote_apply— ответ на «записал и сразу читаю с реплики».- Длинные транзакции мешают
VACUUMи раздувают таблицы, но WAL не удерживают: диск под журналом забивают не сработавший вовремяmax_wal_size, сломанная архивация и отставшая реплика со слотом. - Replication slot копит WAL бесконечно, пока подписчик его не прочитает: нужен алерт на
lag_bytesиactive = falseи явныйmax_slot_wal_keep_size(по умолчанию-1, предела нет). Самpg_walруками не чистят — место освобождает контрольная точка, а держат его слот, неработающий архив или большойmax_wal_size. wal_levelрешает, сколько писать:logicalдобавляет объём,REPLICA IDENTITY FULLпишет всю старую строку, аminimalпозволяет загрузить данные в таблицу, созданную в той же транзакции, почти без журнала.
Что почитать дальше
- VACUUM и bloat — связь с HOT и долгими транзакциями.
- Уровни изоляции —
idle_in_transaction_session_timeoutпротив долгих транзакций. - Индексы — лишние индексы увеличивают WAL.
- Миграции —
CREATE INDEX CONCURRENTLYи WAL.