WAL (Write-Ahead Log) — это журнал всех изменений в БД. До того как PostgreSQL запишет изменение в табличный файл, он пишет его в WAL. Это даёт три ключевых свойства: durability (после COMMIT транзакция переживёт падение), репликация (реплики проигрывают WAL), PITR (Point-In-Time Recovery).
WAL невидим разработчику в обычной работе, но при росте нагрузки он становится узким местом: WAL пишется до того, как ответ на COMMIT уйдёт клиенту — значит, скорость дисковой записи WAL = верхняя граница пропускной способности на запись.
Эта статья — про то, как разработчик уменьшает объём WAL и латентность коммитов, не трогая инфраструктурные настройки.
1. Жизненный цикл одной записи
Когда клиент делает INSERT INTO orders ...; COMMIT;:
- PG модифицирует страницу таблицы
ordersв shared buffers (память). - Записывает запись об изменении в WAL buffer (память).
- На
COMMIT—fsync-ит WAL buffer на диск (pg_wal/000000010000000000000001). - Только после успешного
fsync— отвечает клиенту OK. - Позже (при checkpoint) — изменённая страница
ordersсбрасывается на диск (table file).
PG-W-001 fsync WAL — синхронная операция в каждом коммите. Это и есть «цена durability». Влиять можно либо реже коммитить (батчинг), либо разрешить async-коммит (synchronous_commit = off, опасно), либо группировать коммиты.
2. Что генерирует WAL
| Операция | Размер WAL |
|---|---|
INSERT одной строки | ~50–200 байт + длина значений |
UPDATE строки (полное переписывание) | ~50 байт + новая строка целиком |
UPDATE с HOT | ~50 байт + дельта (только изменённые поля, без индексов) |
DELETE | ~50 байт + ID строки |
CREATE INDEX | весь индекс целиком |
VACUUM | список освобождённых tuple'ов |
TOAST-апдейт | если TOAST-значение не изменилось — оно НЕ пишется в WAL повторно |
PG-W-002 UPDATE пишет в WAL новую строку целиком, не дельту — кроме случая HOT (см. §4). Если в строке 30 колонок и обновили 1 из них, в WAL уйдёт вся строка.
PG-W-003 CREATE INDEX без CONCURRENTLY — мощный генератор WAL. На больших таблицах это может удвоить размер WAL за минуты, и реплика лагнёт.
3. Bulk-операции: COPY vs INSERT, batch
PG-W-010 Для массовой вставки — COPY, не цикл INSERT.
-- плохо: 100K транзакций, 100K WAL-fsync, 100K round-trip
for (var row : rows) {
jdbcTemplate.update("INSERT INTO orders ... VALUES (?, ?, ...)", ...);
}
-- лучше: batch — одна транзакция, меньше WAL-fsync
jdbcTemplate.batchUpdate("INSERT INTO orders ... VALUES (?, ?, ...)", batch);
-- ещё лучше: COPY — минимальный overhead на каждую строку
copyManager.copyIn("COPY orders (col1, col2) FROM STDIN", reader);
Разница на 1M строк может быть 10–100x по времени и в 5–10x по объёму WAL.
PG-W-011 Один большой COMMIT с 10K вставок дешевле 10K маленьких COMMIT. Меньше fsync-ов. Но: длинная транзакция блокирует autovacuum (см. §8). Оптимум — батчи по 1–10K строк.
PG-W-012 Перед массовой загрузкой в пустую таблицу — дроп индексов, загрузка, восстановление.
DROP INDEX ix_orders_customer;
DROP INDEX ix_orders_status_created;
COPY orders FROM '/tmp/data.csv';
CREATE INDEX CONCURRENTLY ix_orders_customer ON orders (customer_id);
CREATE INDEX CONCURRENTLY ix_orders_status_created ON orders (status, created_at);
Каждая вставка с активным индексом пишет в WAL обновление и таблицы, и каждого индекса. Без индексов — только таблицы.
4. HOT и fillfactor — самый недооценённый рычаг
HOT (Heap-Only Tuple) — оптимизация PG для UPDATE: если новая версия строки помещается на ту же страницу И не изменены индексируемые колонки, PG не обновляет индексы и не пишет полные строки в WAL дважды. Просто кладёт новую версию рядом со старой и связывает их chain-ом.
PG-W-020 HOT работает только если на странице есть свободное место для новой версии строки.
Если таблица заполнена под завязку (fillfactor=100, дефолт), UPDATE почти никогда не получит HOT — пойдёт на новую страницу, обновит все индексы, в WAL уйдёт новая строка + изменения индексов.
-- таблицы с частыми UPDATE'ами:
CREATE TABLE orders (...) WITH (fillfactor = 90); -- 10% страницы оставлено под HOT-апдейты
ALTER TABLE orders SET (fillfactor = 80); -- задним числом, для существующей таблицы
-- + VACUUM FULL orders; чтобы переразложить таблицу под новый fillfactor
PG-W-021 Для интенсивно обновляемых таблиц — fillfactor = 80–90. Для статичных (read-mostly, например, справочники) — оставь 100. Цена — 10–20% больше места на диске, выигрыш — кратное уменьшение WAL и износа индексов.
PG-W-022 HOT отключается, если UPDATE меняет индексируемую колонку. Даже если у строки была HOT-возможность, UPDATE поля, по которому есть индекс, всегда обновляет индекс. Если поле меняется часто (например, last_seen_at), а индекс по нему слабоселективный — индекс может вообще не нужен, удали его.
Проверка HOT-ratio:
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.
5. TOAST и большие значения
PG-W-030 Большое значение в колонке (text/jsonb/bytea) попадает в TOAST. Если при UPDATE оно не изменилось — TOAST не переписывается, в WAL не идёт.
UPDATE article SET view_count = view_count + 1 WHERE id = ?;
-- view_count маленькое поле, body (text 50KB) в TOAST — НЕ пишется в WAL.
Это ещё один аргумент против «всё в один большой data jsonb»: при обновлении одного маленького ключа PG не знает, что внутри JSONB не изменилось, и пишет всё содержимое в WAL.
PG-W-031 Если в строке есть и часто-обновляемые маленькие поля, и редко-меняющиеся большие — большие должны быть либо в TOAST (длинный text/bytea), либо в отдельной таблице. TOAST это делает автоматически для значений > ~2KB.
6. UNLOGGED таблицы — нулевой WAL
PG-W-040 Для данных, которые можно потерять при crash, — UNLOGGED таблица. Нет WAL, нет реплики, при падении сервера данные обнуляются.
CREATE UNLOGGED TABLE session_cache (
session_id uuid PRIMARY KEY,
payload jsonb NOT NULL,
expires_at timestamptz NOT NULL
);
Подходит для:
- Кеши, которые легко прогреть заново.
- Промежуточные данные ETL (загрузили, обработали, переложили).
- Очереди задач с TTL, которые не страшно потерять.
- Тесты, где БД пересоздаётся каждый прогон.
Не подходит для бизнес-данных (даже «не очень важных» — упасть может в самый неудобный момент).
PG-W-041 UNLOGGED → LOGGED через ALTER TABLE переписывает всю таблицу с генерацией полного WAL. Если планируется смена статуса — это разовая операция, не делай в hot path.
7. synchronous_commit — ускорение коммитов
PG-W-050 synchronous_commit = off отключает ожидание fsync WAL — коммит возвращает OK без подтверждения дискa.
Это даёт большой прирост throughput на коммит-heavy нагрузке, но ценой: при крахе сервера в окне 200мс перед падением закоммиченные транзакции могут потеряться. Согласованность БД сохраняется (транзакции либо все пройдут, либо ни одна), но клиент думал что закоммитил, а на самом деле нет.
Применимо для:
- Аналитики, метрик, логов — где потеря секунды событий не критична.
- Тестовых окружений.
Не применять для:
- Финансы, заказы, всё что должно быть «гарантированно записано».
PG-W-051 Можно регулировать на уровне сессии/транзакции:
SET LOCAL synchronous_commit = off;
INSERT INTO metrics_event ...;
COMMIT;
Полезно, если в одном сервисе есть и критичные данные (по умолчанию on), и метрики (явно off для batch'ей).
8. Длинные транзакции — тихий убийца
PG-W-060 Открытая долгая транзакция блокирует освобождение WAL и работу autovacuum.
PostgreSQL не может удалить WAL-файлы, в которых есть данные, нужные хоть какой-то открытой транзакции (для MVCC). Открытая транзакция от часа держит часы WAL — диск заполняется до отказа, кластер встаёт.
То же с VACUUM: он не может почистить мёртвые tuple'ы, видимые открытой транзакцией → таблица распухает (bloat) → запросы медленнее.
PG-W-061 Не делай долгие транзакции в коде. Типичные ошибки:
- Spring
@Transactionalна методе, который ходит во внешний HTTP / в Kafka / в S3 (десятки секунд = открытая транзакция). - Долгий debug в IDE с открытой PgAdmin-транзакцией.
- Batch jobs без коммитов внутри.
Правило: транзакция — секунды, не минуты. Если нужно долго — режь на куски, коммить чаще.
PG-W-062 Мониторинг долгих транзакций:
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 — это всегда баг или брошенная сессия.
9. Replication slot — ещё один тихий убийца WAL
PG-W-070 Висящий replication slot не даёт PG удалить WAL. Если реплика отвалилась или logical-consumer не читает — 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 > 10GB или active = false дольше часа — почти всегда сломанный consumer / реплика.
PG-W-071 max_slot_wal_keep_size (PG13+) ограничивает запас WAL для слота. Если слот отстал больше — PG удаляет WAL, слот ломается, но кластер не падает.
10. Мониторинг WAL
PG-W-080 Базовые метрики:
-- размер pg_wal/
SELECT pg_size_pretty(sum(size)) FROM pg_ls_waldir();
-- скорость генерации WAL
SELECT now(), pg_current_wal_lsn();
-- замерь дважды через минуту, посчитай разницу
-- статистика записи
SELECT * FROM pg_stat_wal;
-- wal_records, wal_bytes, wal_buffers_full, wal_write, wal_sync, wal_write_time, wal_sync_time
PG-W-081 Что мониторить в проде:
pg_wal/size — алёрт на > N GB.- WAL throughput (bytes/sec) — рост указывает на новую нагрузку.
wal_buffers_full— растёт → увеличитьwal_buffers.- HOT-ratio в
pg_stat_user_tables< 50% на горячих таблицах. - Долгие транзакции > 5 мин.
- Replication slot lag > N GB.
11. Чек-лист «тормозит запись»
- Это write-нагрузка? Если read — WAL ни при чём, см. Чтение EXPLAIN.
synchronous_commit= on (дефолт)? — да, это правильно для бизнеса. Если нет — починить, если транзакции критичные.- Bulk-вставки идут через
COPYили batch? Не циклINSERTпо одной. - Индексы на горячей таблице — все нужны? Каждый индекс = +WAL на каждую вставку.
- HOT-ratio > 80% на интенсивно-обновляемых таблицах? Если нет — снизить
fillfactorдо 80–90. - Долгие транзакции > 5 мин? Если есть — найти и починить, autovacuum/WAL заблокированы.
- Replication slot лагает? Если да — починить consumer или временно дропнуть слот.
- Большие JSONB или text часто обновляются целиком? Разделить на маленькие колонки или вынести в отдельную таблицу.
Чек-лист на ревью кода
- [ ] Spring
@Transactional— только вокруг короткой DB-логики, не вокруг HTTP/Kafka/S3. - [ ] Bulk-импорты — через
COPY(или JDBCbatchUpdate), не цикл одиночныхINSERT. - [ ] Длинные транзакции (> 5 сек) — режутся на куски с промежуточными
COMMIT-ами. - [ ] Кеши и временные данные —
UNLOGGEDтаблицы, не обычные. - [ ] Часто-обновляемые таблицы —
fillfactor = 80–90в DDL. - [ ] Не вешать индексы на колонки, которые обновляются почти на каждом UPDATE и редко используются в WHERE — это ломает HOT.
- [ ] Не складывать в один
data jsonbи горячие, и тяжёлые поля — UPDATE одного байта пишет всё в WAL. - [ ] Метрики/логи — рассмотреть
synchronous_commit = offна уровне сессии для batch'а.
Связанные
- Composite-индексы — каждый индекс = +WAL на каждую запись.
- JSONB — полное переписывание при апдейте.
- Антипаттерны.