Справочник DBA: базы данных изнутри читается по порядку

Статья 3.2 · раздел 3 «WAL, буферы и надёжность записи» · 17 из 109

Torn page в PostgreSQL: full_page_writes, doublewrite InnoDB и контрольные суммы

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнере postgres:18, data_checksums = on; MySQL 8.4.13 в контейнере mysql:8.4; опыт выполнен 27 октября 2026
содержание решать задачи SQL на собеседовании

После CHECKPOINT первые 10 000 UPDATE по таблице в 345 страниц пишут в WAL 3,08 МБ, а с full_page_writes = off — 1,61 МБ: разница в 347 полных образов страниц, которые PostgreSQL кладёт в журнал при первом изменении страницы после контрольной точки. Платят за это ради одного сбоя: запись страницы в 8192 байта может оборваться на середине, и на диске окажется смесь старого и нового. Запись WAL описывает только изменение строки и такую страницу не починит, а контрольная сумма из заголовка страницы лишь сообщит о порче. InnoDB решает ту же задачу иначе: каждая сбрасываемая страница сначала пишется в doublewrite-файл.

DBA / 01

Разорванная страница: образ в WAL чинит, без него redo падает

Один сбой, два режима full_page_writes Вверху блок 0 таблицы на диске: байты 0–4095 как были, байты 4096–8191 заменены на 0xFF. Рядом запись WAL FPI_FOR_HINT длиной 3673 байта с образом этого блока. Ниже два одинаковых сбоя. При full_page_writes = on образ из WAL ложится на блок 0, HOT_UPDATE применяется, SELECT возвращает 1 и new. При full_page_writes = off образа нет, redo читает блок 0 с диска, контрольная сумма 7332 не равна ожидаемой 6651, процесс завершается с FATAL invalid page и кодом 1. блок 0 на диске: 8192 байта байты 0–4095 как были на диске байты 4096–8191 заменены на 0xFF WAL от контрольной точки FPI_FOR_HINT: 3673 байта образ 3624 + заголовок 49 kill -9, docker start: crash recovery читает WAL с redo starts at 0/2849340 full_page_writes = on образ блока в WAL redo образ ложится на блок 0 результат SELECT → 1|new full_page_writes = off образа блока нет redo читает диск сумма 7332, ждали 6651 FATAL invalid page, код 1
Один и тот же обрыв блока 0 и два исхода. С образом в WAL 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 мог бы уменьшить образы, но в опыте он был выключен и не измерялся.

Чек-лист

  1. Проверь SHOW data_checksums: на кластере без сумм порча читается как данные. Включить их можно pg_checksums --enable на остановленном кластере (документация).
  2. Не выключай full_page_writes и innodb_doublewrite без документально подтверждённой атомарной записи страницы на твоём хранилище; проверка «стало быстрее» её не заменяет.
  3. После CHECKPOINT ожидай роста wal_fpi; сравнивай wal_bytes в окнах с одинаковым числом контрольных точек.
  4. Следи за pg_stat_database.checksum_failures: ненулевое значение — повод проверить диск и бэкапы, а не перезапуск.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru