После CHECKPOINT первые 10 000 UPDATE по таблице в 345 страниц пишут в WAL 3,08 МБ, а с full_page_writes = off — 1,61 МБ: разница в 347 полных образов страниц, которые PostgreSQL кладёт в журнал при первом изменении страницы после контрольной точки. Платят за это ради одного сбоя: запись страницы в 8192 байта может оборваться на середине, и на диске окажется смесь старого и нового. Запись WAL описывает только изменение строки и такую страницу не починит, а контрольная сумма из заголовка страницы лишь сообщит о порче. InnoDB решает ту же задачу иначе: каждая сбрасываемая страница сначала пишется в doublewrite-файл.
Разорванная страница: образ в WAL чинит, без него redo падает
redo перезаписывает страницу целиком и применяет HOT_UPDATE. Без образа он читает испорченный блок с диска, контрольная сумма не сходится, и сервер не стартует.Стенд
docker run -d --name dba-full-page-writes-pg -e POSTGRES_PASSWORD=pg postgres:18
docker run -d --name dba-full-page-writes-my -e MYSQL_ROOT_PASSWORD=pg mysql:8.4
docker exec dba-full-page-writes-pg psql -U postgres -Atc "select version()" \
-c "show data_checksums" -c "show full_page_writes" -c "show wal_compression" -c "show block_size"
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
on
on
off
8192
data_checksums = on: в PostgreSQL 18 initdb включает суммы по умолчанию (заметки о выпуске). wal_compression = off.
Цена: образ страницы после контрольной точки
Правило записано в XLogRecordAssemble. Для каждого блока, который затрагивает запись, сервер сравнивает LSN страницы с положением начала последней контрольной точки (RedoRecPtr):
else if (!doPageWrites)
needs_backup = false;
else
{
/*
* We assume page LSN is first data on *every* page that can be
* passed to XLogInsert, whether it has the standard page layout
* or not.
*/
XLogRecPtr page_lsn = PageGetLSN(regbuf->page);
needs_backup = (page_lsn <= RedoRecPtr);
Источник: xloginsert.c, строки 609–620, тег REL_18_6. Если страница последний раз менялась до контрольной точки (page_lsn <= RedoRecPtr), запись получает полный образ блока; при full_page_writes = off условие doPageWrites ложно, и образов нет. Одна и та же страница получает образ один раз на контрольную точку, потому что после него её LSN уже новее.
Замер: таблица в 345 страниц с fillfactor = 50, десять тысяч UPDATE по одному в отдельных транзакциях, CHECKPOINT перед проходом. synchronous_commit = off только ускоряет цикл на медленном диске и на размер WAL не влияет. Счётчики pg_stat_wal сбрасываются из процесса лениво, поэтому перед чтением стоит pg_stat_force_next_flush():
CREATE PROCEDURE bump() LANGUAGE plpgsql AS $$ BEGIN
FOR i IN 1..10000 LOOP UPDATE t SET v = v + 1 WHERE id = i; COMMIT; END LOOP;
END $$;
CREATE TABLE t (id int PRIMARY KEY, v int, pad text) WITH (fillfactor = 50, autovacuum_enabled = false);
INSERT INTO t SELECT g, 0, repeat('x', 100) FROM generate_series(1, 10000) g;
VACUUM t;
CHECKPOINT;
SET synchronous_commit = off;
SELECT pg_stat_force_next_flush(); SELECT 1;
SELECT pg_stat_reset_shared('wal');
SELECT pg_current_wal_insert_lsn() AS l0 \gset
CALL bump();
SELECT pg_current_wal_insert_lsn() AS l1 \gset
SELECT pg_stat_force_next_flush(); SELECT 1;
SELECT wal_records, wal_fpi, wal_bytes, pg_wal_lsn_diff(:'l1', :'l0') AS lsn_bytes FROM pg_stat_wal;
Проход 1 выполнен сразу после CHECKPOINT, проход 2 — повторный вызов bump() без новой контрольной точки. Всё это при full_page_writes = on, затем при off:
вывод
== on
pass 1|29637|347|3081976|3221288
pass 2|29981|0|1667560|1764056
== off
pass 1|29638|0|1611818|1744752
pass 2|29982|0|1667594|1764120
== on, CHECKPOINT и снова bump()
pass 3 (after CHECKPOINT)|345|3182579
Колонки: wal_records, wal_fpi, wal_bytes, lsn_bytes; в последней строке только wal_fpi и wal_bytes. Последняя — сдвиг позиции вставки в WAL, контроль счётчика: она больше wal_bytes на выравнивание записей по 8 байт и заголовки страниц WAL. Записей в обоих режимах поровну (29 637 и 29 638), а байтов с образами в 1,9 раза больше: 3 081 976 против 1 611 818. Разница 1 470 158 байт на 347 образов — около 4,2 КБ на образ, а не 8. Контрольная точка возвращает образы: повторив bump() сразу после CHECKPOINT, получаем wal_fpi = 345 и 3 182 579 байт. Чем реже контрольные точки, тем реже страница получает образ повторно: full_page_writes в документации прямо советует растягивать интервалы.
Одну такую запись видно в pg_waldump -b. Образ взяла не сама HOT_UPDATE, а предшествующая FPI_FOR_HINT: при включённых контрольных суммах первое же изменение хинт-битов на странице пишет её в журнал (разбор хинт-битов). Сама запись HOT_UPDATE после неё заняла 71 байт и в вывод не включена.
вывод
rmgr: XLOG len (rec/tot): 49/ 4389, tx: 0, lsn: 0/027BA168, prev 0/027BA0F0, desc: FPI_FOR_HINT
blkref #0: rel 1663/5/16393 fork main blk 310 (FPW); hole: offset: 260, length: 3852
hole: offset: 260, length: 3852 — свободное место между pd_lower и pd_upper в образ не попадает: 8192 − 3852 = 4340 байт образа плюс 49 байт заголовка дают 4389. Страницы, заполненные плотнее, стоят ближе к 8 КБ.
Разрыв страницы: что делает redo
Настоящий обрыв записи на уровне диска воспроизвести нельзя, поэтому он имитируется: после CHECKPOINT и UPDATE вторая половина блока 0 в файле таблицы заменяется на 0xFF, процесс убивается docker kill (SIGKILL всему контейнеру) и стартует заново. Страница на диске остаётся испорченной, а UPDATE лежит только в WAL. Это обрыв процесса, а не питания: страницы из кэша ОС не пропадают. Команды psql и dd выполняются внутри контейнера (docker exec -it dba-full-page-writes-pg bash, там задан PGDATA), docker kill и docker start — на хосте.
psql -c "ALTER SYSTEM SET full_page_writes = on" -c "select pg_reload_conf()" # или off
psql -c "CREATE TABLE torn AS SELECT g AS id, 'old' AS v FROM generate_series(1,100) g" -c "CHECKPOINT"
F=$(psql -Atc "SELECT pg_relation_filepath('torn')"); psql -c "UPDATE torn SET v = 'new' WHERE id = 1"
head -c 4096 /dev/zero | tr '\0' '\377' | dd of=$PGDATA/$F bs=4096 seek=1 count=1 conv=notrunc
docker kill dba-full-page-writes-pg && docker start dba-full-page-writes-pg
С full_page_writes = on сервер поднялся, а в окне redo pg_waldump -b показывает образ нужного блока:
вывод (журнал сервера без меток времени)
LOG: database system was not properly shut down; automatic recovery in progress
LOG: redo starts at 0/2849340
LOG: invalid record length at 0/284D588: expected at least 24, got 0
LOG: redo done at 0/284D560 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
LOG: database system is ready to accept connections
вывод (pg_waldump -b -s 0/2849340 -e 0/284D560, только записи по таблице torn)
rmgr: XLOG len (rec/tot): 49/ 3673, tx: 0, lsn: 0/0284C6B8, prev 0/0284B188, desc: FPI_FOR_HINT
blkref #0: rel 1663/5/16451 fork main blk 0 (FPW); hole: offset: 424, length: 4568
rmgr: Heap len (rec/tot): 71/ 71, tx: 50776, lsn: 0/0284D518, prev 0/0284C6B8, desc: HOT_UPDATE old_xmax: 50776, old_off: 1, old_infobits: [], flags: 0x20, new_xmax: 0, new_off: 101
SELECT id, v FROM torn WHERE id IN (1, 2) вернул 2|old и 1|new.
Образ (8192 − 4568 = 3624 байта плюс заголовок 49 дают 3673) лёг на блок 0 целиком, испорченная половина не читалась, HOT_UPDATE применился. Строка invalid record length ... got 0 — обычный конец WAL, redo на нём останавливается.
Тот же сценарий при full_page_writes = off заканчивается остановкой сервера:
вывод (журнал сервера без меток времени)
LOG: database system was not properly shut down; automatic recovery in progress
LOG: redo starts at 0/28580C0
LOG: page verification failed, calculated checksum 7332 but expected 6651
CONTEXT: WAL redo at 0/2858220 for Heap/HOT_UPDATE: old_xmax: 50779, old_off: 1, old_infobits: [], flags: 0x20, new_xmax: 0, new_off: 101; blkref #0: rel 1663/5/24576, blk 0
LOG: invalid page in block 0 of relation "base/5/24576"
FATAL: invalid page in block 0 of relation "base/5/24576"
LOG: startup process (PID 38) exited with exit code 1
LOG: shutting down due to startup process failure
Контейнер вышел с кодом 1. HOT_UPDATE описывает только изменение строки и предполагает целую страницу на диске: redo прочитал блок 0, контрольная сумма не сошлась, и идти дальше некуда. Здесь data_checksums = on остановил сервер на ошибке; без сумм этот опыт не ставился, а документация предупреждает о неисправимом повреждении данных при выключенном параметре.
Контрольные суммы находят порчу, но не чинят
Сумма считается при записи страницы и проверяется при чтении в PageIsVerified:
if (DataChecksumsEnabled())
{
checksum = pg_checksum_page(page, blkno);
if (checksum != p->pd_checksum)
{
checksum_failure = true;
if (checksum_failure_p)
*checksum_failure_p = true;
}
}
Источник: bufpage.c, строки 110–120, тег REL_18_6. Несовпадение даёт checksum_failure, и функция возвращает false: страница в буфер не попадает. Опыт на остановленном кластере: pg_checksums --check до порчи, затем один байт 5000 в блоке 0 таблицы torn заменяется на 0xFF, затем проверка и запрос.
docker stop dba-full-page-writes-pg
R() { docker run --rm --volumes-from dba-full-page-writes-pg -u postgres postgres:18 "$@"; }
D=/var/lib/postgresql/18/docker
R pg_checksums --check -D $D
R bash -c "printf '\xff' | dd of=$D/base/5/16451 bs=1 seek=5000 count=1 conv=notrunc"
R pg_checksums --check -D $D
docker start dba-full-page-writes-pg
psql -c "SELECT count(*) FROM torn"
psql -Atc "SELECT checksum_failures, checksum_last_failure IS NOT NULL FROM pg_stat_database WHERE datname = 'postgres'"
вывод (строки Checksum operation, Files и Blocks scanned опущены)
Bad checksums: 0
pg_checksums: error: checksum verification failed in file "/var/lib/postgresql/18/docker/base/5/16451", block 0: calculated checksum E738 but block contains 36CE
Bad checksums: 1
ERROR: invalid page in block 0 of relation "base/5/16451"
1|t
В журнале сервера то же сообщение в десятичном виде: calculated checksum 59192 but expected 14030 (0xE738 и 0x36CE). Запрос упал с ERROR, а checksum_failures в pg_stat_database стал 1 и появилась метка времени последней ошибки. Сумма точно находит порчу даже одного байта, но данные остаются испорченными: восстановить блок может только образ из WAL, копия или реплика.
InnoDB: каждая страница пишется дважды
Страница InnoDB — 16 КБ (innodb_page_size = 16384), и образы в redo-лог не кладутся. Перед записью в файл данных страницы сбрасываются в doublewrite-файлы в каталоге данных, и после сбоя разорванную копию заменяют целой из этих файлов. Порядок задан в Double_write::write_pages:
void Double_write::write_pages(buf_flush_t flush_type) noexcept {
ut_ad(mutex_own(&m_mutex));
ut_a(!m_buffer.empty());
const uint16_t batch_id = write_dblwr_pages(flush_type);
write_data_pages(flush_type, batch_id);
}
Источник: buf0dblwr.cc, строки 2249–2255, тег mysql-8.4.13. Сначала write_dblwr_pages, потом write_data_pages. Замер: 50 000 строк, UPDATE d.t SET v = v + 1, разность счётчиков через 15 секунд, чтобы очиститель страниц успел сбросить грязные страницы.
SELECT version() AS version, @@innodb_doublewrite AS doublewrite, @@innodb_page_size AS page_size;
SET GLOBAL innodb_doublewrite = OFF;
-- счётчики Innodb_pages_written, Innodb_dblwr_pages_written, Innodb_dblwr_writes до и после:
UPDATE d.t SET v = v + 1;
DO SLEEP(15);
вывод
+---------+-------------+-----------+
| version | doublewrite | page_size |
+---------+-------------+-----------+
| 8.4.13 | ON | 16384 |
+---------+-------------+-----------+
ERROR 1210 (HY000) at line 1: Incorrect arguments to InnoDB: cannot change doublewrite mode to OFF if doublewrite is enabled. Please shutdown and change value to OFF
+---------------+-------------+--------------+
| pages_written | dblwr_pages | dblwr_writes |
+---------------+-------------+--------------+
| 552 | 552 | 7 |
+---------------+-------------+--------------+
Каждая из 552 страниц, записанных в файлы данных (Innodb_pages_written), прошла и через doublewrite (Innodb_dblwr_pages_written): 552 против 552 за 7 пакетных записей. Это 552 × 16 КБ ≈ 8,6 МиБ записи сверху. Платит InnoDB не объёмом журнала, а второй записью каждой страницы, независимо от того, давно ли была контрольная точка. Отключается doublewrite только перезапуском сервера: SET GLOBAL вернул ошибку 1210.
Что из этого следует
PostgreSQL платит за защиту от разрыва в WAL и только пока страницы «свежие» после контрольной точки: 347 образов на 345 страниц в первом проходе и ноль во втором. Поэтому всплеск WAL сразу после CHECKPOINT при той же нагрузке — не утечка, а образы; их долю даёт wal_fpi в pg_stat_wal, а реже контрольные точки — меньше образов.
full_page_writes = off безопасен только там, где слой хранения гарантирует атомарную запись всей страницы 8 КБ. Опыт выше показывает цену ошибки в этом допущении: при разорванной странице без образа сервер не поднимается. Параметр wal_compression мог бы уменьшить образы, но в опыте он был выключен и не измерялся.
Чек-лист
- Проверь
SHOW data_checksums: на кластере без сумм порча читается как данные. Включить их можноpg_checksums --enableна остановленном кластере (документация). - Не выключай
full_page_writesиinnodb_doublewriteбез документально подтверждённой атомарной записи страницы на твоём хранилище; проверка «стало быстрее» её не заменяет. - После
CHECKPOINTожидай ростаwal_fpi; сравнивайwal_bytesв окнах с одинаковым числом контрольных точек. - Следи за
pg_stat_database.checksum_failures: ненулевое значение — повод проверить диск и бэкапы, а не перезапуск.