WAL в PostgreSQL: 4. Настройка журнала

?v=1

Итак, мы познакомились с устройством буферного кеша и на его примере поняли, что когда при сбое пропадает содержимое оперативной памяти, для восстановления необходим журнал предзаписи. Размер необходимых файлов журнала и время восстановления ограничены благодаря периодически выполняемой контрольной точке.

В предыдущих статьях мы уже посмотрели на довольно большое число важных настроек, так или иначе относящихся к журналу. В этой статье (последней в этом цикле) мы рассмотрим те вопросы настройки, которые еще не обсуждались: уровни журнала и их назначение, а также надежность и производительность журналирования.


Основная задача журнала предзаписи — обеспечить возможность восстановления после сбоя. Но, если уж все равно приходится вести журнал, его можно приспособить и для других задач, добавив в него некоторое количество дополнительной информации. Есть несколько уровней журналирования. Они задаются параметром wal_level и организованы так, что журнал каждого следующего уровня включает в себя все, что попадает в журнал предыдущего уровня, плюс еще что-то новое.

Minimal


Минимально возможный уровень задается значением wal_level = minimal и гарантирует только восстановление после сбоя. Для экономии места операции, связанные с массовой обработкой данных (такие, как CREATE TABLE AS SELECT или CREATE INDEX), не записываются в журнал. Вместо этого необходимые данные сразу пишутся на диск, а новый объект добавляется в системный каталог и становится видимым при фиксации транзакции. Если сбой происходит в процессе выполнения операции, уже записанные данные остаются невидимыми и не нарушают согласованности. Если же сбой происходит после того, как операция завершилась, все необходимое уже попало на диск и не нуждается в журналировании.

Посмотрим. Сначала установим необходимый уровень (для этого потребуется также изменить другой параметр — max_wal_senders).

=> ALTER SYSTEM SET wal_level = minimal;
=> ALTER SYSTEM SET max_wal_senders = 0;
student$ sudo pg_ctlcluster 11 main restart


Обратите внимание, что изменение уровня требует перезапуска сервера.

Запомним текущую позицию в журнале:

=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 0/353927BC
(1 row)


Теперь выполним создание таблицы (CREATE TABLE AS SELECT) и снова запишем позицию в журнале. Объем данных, выбираемый оператором SELECT, в данном случае не играет роли, поэтому мы ограничимся одной строкой.

=> CREATE TABLE wallevel AS
  SELECT 1 AS n;
=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 0/353A7DFC
(1 row)


Уже знакомой утилитой pg_waldump посмотрим на журнальные записи.

postgres$ /usr/lib/postgresql/11/bin/pg_waldump -p /var/lib/postgresql/11/main/pg_wal -s 0/353927BC -e 0/353A7DFC


Некоторые детали, конечно, могут отличаться от запуска к запуску, а в данном случае получилось вот что. Запись менеджера Heap2 относится к очистке, здесь это внутристраничная очистка одной из таблиц системного каталога (системные объекты легко отличаются невооруженным взглядом по «короткому» номеру в rel):

rmgr: Heap2       len (rec/tot):     59/  7587, tx:          0, lsn: 0/353927BC, prev 0/35392788, desc: CLEAN remxid 101126, blkref #0: rel 1663/16386/1247 blk 8 FPW


Затем идет запись о получении очередного номера OID для таблицы, которую мы собираемся создавать:

rmgr: XLOG        len (rec/tot):     30/    30, tx:          0, lsn: 0/35394574, prev 0/353927BC, desc: NEXTOID 82295


Теперь собственно создание таблицы:

rmgr: Storage     len (rec/tot):     42/    42, tx:          0, lsn: 0/35394594, prev 0/35394574, desc: CREATE base/16386/74103


Однако вставка данных в таблицу не журналируется. Дальше идут многочисленные записи о вставке строк в разные таблицы и индексы — это PostgreSQL прописывает созданную таблицу в системном каталоге (привожу в сокращенном виде):

rmgr: Heap        len (rec/tot):    203/   203, tx:     101127, lsn: 0/353945C0, prev 0/35394594, desc: INSERT off 71, blkref #0: rel 1663/16386/1247 blk 8
rmgr: Btree       len (rec/tot):     53/   685, tx:     101127, lsn: 0/3539468C, prev 0/353945C0, desc: INSERT_LEAF off 37, blkref #0: rel 1663/16386/2703 blk 2 FPW
...
rmgr: Btree       len (rec/tot):     53/  2393, tx:     101127, lsn: 0/353A747C, prev 0/353A6788, desc: INSERT_LEAF off 10, blkref #0: rel 1664/0/1233 blk 1 FPW


Ну и наконец фиксация транзакции:

rmgr: Transaction len (rec/tot):     34/    34, tx:     101127, lsn: 0/353A7DD8, prev 0/353A747C, desc: COMMIT 2019-07-23 18:59:34.923124 MSK


Replica


Когда мы восстанавливаем систему из резервной копии, мы начинаем с некоторого состояния файловой системы и постепенно доводим данные до целевой точки восстановления, проигрывая заархивированные журнальные записи. Количество таких записей может быть весьма велико (например, несколько дней), то есть период восстановления будет охватывать не одну контрольную точку, а множество. Поэтому понятно, что минимального уровня журнала недостаточно — если какая-то операция не журналируется, мы просто не узнаем, что ее надо повторить. Для восстановления из резервной копии в журнал должны попадать все операции.

То же самое верно и для репликации — все, что не журналируется, не будет передано на реплику и не будет воспроизведено. Но, если мы хотим выполнять на реплике запросы, все еще усложняется.

Во-первых, нам нужна информация об исключительных блокировках, возникающих на основном сервере, поскольку они могут конфликтовать с запросами на реплике. Такие блокировки записываются в журнал и применяются на реплике (от имени процесса startup).

Во-вторых, нужно уметь строить снимки данных, а для этого, как мы помним, необходима информация о выполняющихся транзакциях. В случае реплики речь идет не только о локальных транзакциях, но и о транзакциях на основном сервере. Единственный способ эту информацию передать — периодически записывать ее в журнал (это происходит раз в 15 секунд).

Уровень журнала, гарантирующий как возможность восстановления из резервной копии, так и возможность физической репликации, задается значением wal_level = replica. (До версии 9.6 было два отдельных уровня archive и hot_standby, но потом их объединили в один общий.)

Начиная с версии PostgreSQL 10 именно этот уровень установлен по умолчанию (а до этого был minimal). Поэтому просто сбросим параметры в умолчательные значения:

=> ALTER SYSTEM RESET wal_level;
=> ALTER SYSTEM RESET max_wal_senders;
student$ sudo pg_ctlcluster 11 main restart


Удаляем таблицу и повторяем ровно ту же последовательность действий, что и в прошлый раз:

=> DROP TABLE wallevel;
=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 0/353AF21C
(1 row)
=> CREATE TABLE wallevel AS
  SELECT 1 AS n;
=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 0/353BE51C
(1 row)


Теперь проверим журнальные записи.

postgres$ /usr/lib/postgresql/11/bin/pg_waldump -p /var/lib/postgresql/11/main/pg_wal -s 0/353AF21C -e 0/353BE51C


Очистка, получение OID, создание таблицы и регистрация в системном каталоге — пока все как было:

rmgr: Heap2       len (rec/tot):     58/    58, tx:          0, lsn: 0/353AF21C, prev 0/353AF044, desc: CLEAN remxid 101128, blkref #0: rel 1663/16386/1247 blk 8
rmgr: XLOG        len (rec/tot):     30/    30, tx:          0, lsn: 0/353AF258, prev 0/353AF21C, desc: NEXTOID 82298
rmgr: Storage     len (rec/tot):     42/    42, tx:          0, lsn: 0/353AF278, prev 0/353AF258, desc: CREATE base/16386/74106
rmgr: Heap        len (rec/tot):    203/   203, tx:     101129, lsn: 0/353AF2A4, prev 0/353AF278, desc: INSERT off 73, blkref #0: rel 1663/16386/1247 blk 8
rmgr: Btree       len (rec/tot):     53/   717, tx:     101129, lsn: 0/353AF370, prev 0/353AF2A4, …
rmgr: Btree       len (rec/tot):     53/  2413, tx:     101129, lsn: 0/353BD954, prev 0/353BCC44, desc: INSERT_LEAF off 10, blkref #0: rel 1664/0/1233 blk 1 FPW


А вот что-то новое. Запись об исключительной блокировке, относящаяся к менеджеру Standby — в данном случае это блокировка номера транзакции (зачем она нужна, мы подробно поговорим в следующем цикле статей):

rmgr: Standby     len (rec/tot):     42/    42, tx:     101129, lsn: 0/353BE2D8, prev 0/353BD954, desc: LOCK xid 101129 db 16386 rel 74106


А это — запись о вставке строк в нашу таблицу (сравните номер файла rel с тем, что указан выше в записи CREATE):

rmgr: Heap        len (rec/tot):     59/    59, tx:     101129, lsn: 0/353BE304, prev 0/353BE2D8, desc: INSERT+INIT off 1, blkref #0: rel 1663/16386/74106 blk 0


Запись о фиксации:

rmgr: Transaction len (rec/tot):    421/   421, tx:     101129, lsn: 0/353BE340, prev 0/353BE304, desc: COMMIT 2019-07-23 18:59:37.870333 MSK; inval msgs: catcache 74 catcache 73 catcache 74 catcache 73 catcache 50 catcache 49 catcache 7 catcache 6 catcache 7 catcache 6 catcache 7 catcache 6 catcache 7 catcache 6 catcache 7 catcache 6 catcache 7 catcache 6 catcache 7 catcache 6 snapshot 2608 relcache 74106 snapshot 1214


И еще одна запись, которая возникает периодически и не привязана к завершившейся транзакции, относится к менеджеру Standby и сообщает о выполняющихся в данный момент транзакциях:

rmgr: Standby     len (rec/tot):     50/    50, tx:          0, lsn: 0/353BE4E8, prev 0/353BE340, desc: RUNNING_XACTS nextXid 101130 latestCompletedXid 101129 oldestRunningXid 101130


Logical


Наконец, последний уровень задается значением параметра wal_level = logical и обеспечивает возможность работы логического декодирования и логической репликации. Он должен быть включен на публикующем сервере.

С точки зрения журнальных записей этот уровень практически не отличается от replica — добавляются записи, относящиеся к источникам репликации (replication origins), и произвольные логические записи, которые могут добавлять в журнал приложения. В основном же логическое декодирование зависит от информации о выполняющихся транзакциях, поскольку требуется строить снимок данных для отслеживания изменений системного каталога.

Сейчас мы не будем вдаваться в подробности работы резервного копирования и репликации — это большая тема для отдельного цикла статей.


Понятно, что механизм журналирования должен быть надежным и давать гарантии возможности восстановления в любых ситуациях (не связанных, конечно, с повреждением носителя данных). На надежность влияют много факторов, из которых мы рассмотрим кеширование, повреждение данных и атомарность записи.

Кеширование


На пути данных к энергонезависимому хранилищу (такому, как пластина жесткого диска), стоят многочисленные кеши.

Когда программа (любая, но в нашем случае PostgreSQL) просит операционную систему записать что-либо на диск, операционная система переносит данные в свой кеш в оперативной памяти. Фактическая запись происходит асинхронно, в зависимости от настроек планировщика ввода-вывода операционной системы.

Когда ОС решает записать данные, они попадают в кеш накопителя (жесткого диска). Электроника накопителя тоже может отложить запись, например, собирая данные в группы, которые более выгодно записать одновременно. А если используется RAID-контроллер, между ОС и диском появляется еще один уровень кеширования.

Таким образом, если не предпринять специальных мер, совершенно непонятно, когда же данные действительно будут надежным образом сохранены. Обычно это и не важно, но есть критические места, в которых PostgreSQL должен быть уверен в том, что данные записаны надежно. В первую очередь это журналирование (если журнальная запись не дошла до диска, она пропадет вместе с остальным содержимым оперативной памяти) и контрольная точка (должна быть уверенность, что грязные страницы действительно записаны на диск). Но есть и другие ситуации, скажем, выполнение нежурналируемых операций на уровне minimal и др.

Операционная система предоставляет средства, которые должны гарантировать немедленную запись данных в энергонезависимую память. Есть несколько вариантов, но они сводятся к двум основным: либо после записи дается команда синхронизации (fsync, fdatasync), либо при открытии файла (или записи в него) указывается специальный флаг необходимости синхронизации или даже прямой записи, минуя кеш ОС.

Что касается журнала, утилита pg_test_fsync позволяет выбрать способ, наиболее подходящий для конкретной ОС и конкретной файловой системы, а устанавливается он в конфигурационном параметре wal_sync_method. Обычные файлы всегда синхронизируется с помощью fsync.

Тонкий момент состоит в том, что при выборе метода надо учитывать характеристики аппаратуры. Например, если используется контроллер, поддержанный батареей резервного питания, нет резона не использовать его кеш, поскольку батарея позволит сохранить данные в случае сбоя электропитания.

Документация содержит много подробностей на эту тему.


В любом случае синхронизация стоит дорого и выполняется не чаще, чем абсолютно необходимо (к этому вопросу мы еще вернемся чуть ниже, когда будет говорить о производительности).

Вообще говоря, синхронизацию можно отключить (за это отвечает параметр fsync), но в этом случае про надежность хранения следует забыть. Отключая fsync, вы соглашаетесь с тем, что данные могут быть безвозвратно потеряны в любой момент. Наверное, единственный разумный вариант использования этого параметра — временное увеличение производительности, когда данные можно легко восстановить из другого источника (например, при начальной миграции).

Повреждение данных


Аппаратура несовершенна и данные могут быть повреждены на носителе, при передаче данных по интерфейсным кабелям и т. п. Часть таких ошибок обрабатывается на аппаратном уровне, но часть — нет.

Чтобы вовремя обнаружить возникшую проблему, журнальные записи всегда снабжаются контрольными суммами.

Страницы данных также можно защитить контрольными суммами. Пока это можно сделать только при инициализации кластера, но в версии PostgreSQL 12 их можно будет включать и выключать с помощью утилиты pg_checksums (правда, пока не «на лету», а только при остановленном сервере).

В производственной среде контрольные суммы обязательно должны быть включены, несмотря на накладные расходы на их вычисление и контроль. Это уменьшает вероятность того, что сбой не будет вовремя обнаружен.

Уменьшает, но не устраняет.
Во-первых, контрольные суммы проверяются только при обращении к странице — поэтому повреждение может остаться незамеченным до момента, когда оно попадет во все резервные копии. Именно поэтому pg_probackup проверяет при резервном копировании контрольные суммы всех страниц кластера.
Во-вторых, страница, заполненная нулями, считается корректной — если файловая система по ошибке «занулит» файл, это может остаться незамеченным.
В-третьих, контрольные суммы защищают только основной слой файлов данных. Остальные слои и остальные файлы (например, статусы транзакций XACT) ничем не защищены.
Увы.


Посмотрим, как это работает. Для начала убедимся, что контрольные суммы включены (имейте в виду, что при установке пакета в Debian-подобных системах по умолчанию это не так):

=> SHOW data_checksums;
 data_checksums
----------------
 on
(1 row)


Параметр data_checksums доступен только для чтения.

Вот в каком файле располагается наша таблица:

=> SELECT pg_relation_filepath('wallevel');
 pg_relation_filepath
----------------------
 base/16386/24890
(1 row)


Остановим сервер и поменяем несколько байтов в нулевой странице, например сотрем из заголовка LSN последней журнальной записи.

student$ sudo pg_ctlcluster 11 main stop
postgres$ dd if=/dev/zero of=/var/lib/postgresql/11/main/base/16386/24890 oflag=dsync conv=notrunc bs=1 count=8
8+0 records in
8+0 records out
8 bytes copied, 0,0083022 s, 1,0 kB/s


В принципе сервер можно было бы и не останавливать. Достаточно, чтобы страница записалась на диск и была вытеснена из кеша (иначе сервер так и будет работать со страницей из кеша). Но такой сценарий сложнее воспроизвести.

Теперь запускаем сервер и пробуем прочитать таблицу.

student$ sudo pg_ctlcluster 11 main start
=> SELECT * FROM wallevel;
WARNING:  page verification failed, calculated checksum 23222 but expected 50884
ERROR:  invalid page in block 0 of relation base/16386/24890


Но что делать, если данные невозможно восстановить из резервной копии? Параметр ignore_checksum_failure позволяет попробовать прочитать таблицу, естественно с риском получить искаженные данные.

=> SET ignore_checksum_failure = on;
=> SELECT * FROM wallevel;
WARNING:  page verification failed, calculated checksum 23222 but expected 50884
 n
---
 1
(1 row)


Конечно, в данном случае все проходит успешно, потому что мы испортили только заголовок страницы, а не сами данные.

И еще один момент. При включенных контрольных суммах в журнал записываются биты подсказок (мы рассматривали их ранее), поскольку изменение любого, даже несущественного, бита приводит и к изменению контрольной суммы. При выключенных контрольных суммах за запись в журнал битов подсказок отвечает параметр wal_log_hints.

Изменения битов подсказок всегда журналируется в виде полного образа страницы (FPI, full page image), что порядком увеличивает размер журнала. В этом случае имеет смысл включить сжатие полных образов с помощью параметра wal_compression (этот параметр появился в версии 9.5). Чуть ниже мы посмотрим на конкретные цифры.

Атомарность записи


Ну и наконец существует проблема атомарности записи. Страница базы данных занимает не менее 8 Кб (может быть 16 или 32 Кб), а на низком уровне запись происходит блоками, которые обычно имеют меньший размер (как правило 512 байт или 4 Кб). Поэтому при сбое питания страница данных может записаться частично. Понятно, что при восстановлении бессмысленно применять к такой странице обычные журнальные записи.

Для защиты PostgreSQL позволяет записывать в журнал полный образ страницы при первом ее изменении после начала контрольной точки (такой же образ записывается и при изменении битов подсказок). Этим управляет параметр full_page_writes, и он включен по умолчанию.

Если при восстановлении в журнале встречается образ страницы, он безусловно (без проверки LSN) записывается на диск: к нему больше доверия, поскольку, как и всякая журнальная запись, он защищен контрольной суммой. И уже к этому гарантированно корректному образу дальше применяются обычные журнальные записи.

Хотя PostgreSQL исключает из полного образа страницы незанятое место (ранее мы рассматривали структуру блока), все же объем генерируемых журнальных записей существенно увеличивается. Как уже говорилось, ситуацию можно улучшить за счет сжатия полных образов (параметр wal_compression).

Чтобы как-то почувствовать изменение размера журнала, проведем простой эксперимент с помощью утилиты pgbench. Выполним инициализацию:

student$ pgbench -i test
dropping old tables...
creating tables...
generating data...
100000 of 100000 tuples (100%) done (elapsed 0.15 s, remaining 0.00 s)
vacuuming...
creating primary keys...
done.


Параметр full_page_writes включен:

=> SHOW full_page_writes;
 full_page_writes
------------------
 on
(1 row)


Выполним контрольную точку и сразу же запустим тест на 30 секунд.

=> CHECKPOINT;
=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 0/38E04A08
(1 row)
student$ pgbench -T 30 test
starting vacuum...end.
transaction type: 
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
duration: 30 s
number of transactions actually processed: 26851
latency average = 1.117 ms
tps = 895.006720 (including connections establishing)
tps = 895.095229 (excluding connections establishing)
=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 0/3A69C478
(1 row)


Размер журнальных записей:

=> SELECT pg_size_pretty('0/3A69C478'::pg_lsn - '0/38E04A08'::pg_lsn);
 pg_size_pretty
----------------
 25 MB
(1 row)


Теперь выключим параметр full_page_writes:

=> ALTER SYSTEM SET full_page_writes = off;
=> SELECT pg_reload_conf();


И повторим эксперимент.

=> CHECKPOINT;
=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 0/3A69C530
(1 row)
student$ pgbench -T 30 test
starting vacuum...end.
transaction type: 
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
duration: 30 s
number of transactions actually processed: 27234
latency average = 1.102 ms
tps = 907.783080 (including connections establishing)
tps = 907.895326 (excluding connections establishing)
=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 0/3BE87658
(1 row)


Размер журнальных записей:

=> SELECT pg_size_pretty('0/3BE87658'::pg_lsn - '0/3A69C530'::pg_lsn);
 pg_size_pretty
----------------
 24 MB
(1 row)


Да, размер уменьшился, но совсем не так существенно, как можно было бы ожидать.

Причина в том, что кластер инициализирован с контрольными суммами в страницах данных, и поэтому в журнал все равно приходится записывать полные образы страниц при изменении битов подсказок. Эти данные (в нашем случае) составляют примерно половину всего объема, в чем можно убедиться, посмотрев статистику:

postgres$ /usr/lib/postgresql/11/bin/pg_waldump --stats -p /var/lib/postgresql/11/main/pg_wal -s 0/3A69C530 -e 0/3BE87658
Type              N      (%)   Record size      (%)   FPI size      (%)   
----              -      ---   -----------      ---   --------      ---   
XLOG           1721 (  1,03)         84329 (  0,77)   13916104 (100,00)        
Transaction   27235 ( 16,32)        926070 (  8,46)          0 (  0,00)          
Storage           1 (  0,00)            42 (  0,00)          0 (  0,00)              
CLOG              1 (  0,00)            30 (  0,00)          0 (  0,00)              
Standby           4 (  0,00)           240 (  0,00)          0 (  0,00)             
Heap2         27522 ( 16,49)       1726352 ( 15,76)          0 (  0,00)         
Heap         109691 ( 65,71)       8169121 ( 74,59)          0 (  0,00)         
Btree           756 (  0,45)         45380 (  0,41)          0 (  0,00)           
           --------               --------            --------                 
Total        166931               10951564 [44,04%]   13916104 [55,96%]  


Из таблицы я для компактности убрал нулевые строки. Обратите внимание на итоговую строку (Total) и сравните размер полных образов (FPI size) с размером обычных записей (Record size).

Параметр full_page_writes можно отключать только в том случае, если используемая файловая система и аппаратура сами по себе гарантируют атомарность записи. Но, как мы видим, в этом нет большого резона (предполагая, что контрольные суммы включены).

Теперь давайте посмотрим, как помогает сжатие.

=> ALTER SYSTEM SET full_page_writes = on;
=> ALTER SYSTEM SET wal_compression = on;
=> SELECT pg_reload_conf();


Повторяем тот же эксперимент.

=> CHECKPOINT;
=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 0/3BE87710
(1 row)
student$ pgbench -T 30 test
starting vacuum...end.
transaction type: 
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
duration: 30 s
number of transactions actually processed: 26833
latency average = 1.118 ms
tps = 894.405027 (including connections establishing)
tps = 894.516845 (excluding connections establishing)
=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 0/3CBD3EA8
(1 row)


Размер журнальных записей:

=> SELECT pg_size_pretty('0/3CBD3EA8'::pg_lsn - '0/3BE87710'::pg_lsn);
 pg_size_pretty
----------------
 13 MB
(1 row)


Вывод: при наличии большого числа полных образов страниц (из-за контрольных сумм или full_page_writes, то есть почти всегда), скорее всего имеет смысл воспользоваться сжатием несмотря на то, что это нагружает процессор.
При обычной работе сервера происходит постоянная последовательная запись журнальных файлов. Поскольку отсутствует случайный доступ, с этой задачей справляются и обычные HDD-диски. Но такой характер нагрузки существенно отличается от того, как происходит доступ к файлам данных.

Поэтому обычно выгодно размещать журнал на отдельном физическом диске (или дисковом массиве), примонтированных к файловой системе сервера. Вместо каталога $PGDATA/pg_wal нужно создать символьную ссылку на соответствующий каталог.

Есть пара ситуаций, при которой журнальные файлы необходимо не только писать, но и читать. Первая — понятный случай восстановления после сбоя. Вторая менее тривиальна. Она возникает, если используется потоковая репликация, и реплика не успевает получать журнальные записи, пока они еще находятся в буферах оперативной памяти основного сервера. Тогда процессу walsender приходится читать нужные данные с диска. Подробней мы поговорим об этом, когда дойдем до репликации.


Запись журнала происходит в одном из двух режимов:

  • синхронном — при фиксации транзакции продолжение работы невозможно до тех пор, пока все журнальные записи об этой транзакции не окажутся на диске;
  • асинхронном — транзакция завершается немедленно, а журнал записывается в фоновом режиме.


Синхронный режим определяется параметром synchronous_commit и включен по умолчанию.

Поскольку синхронизация связана с реальным (то есть медленным) вводом-выводом, выгодно выполнять ее как можно реже. Для этого обслуживающий процесс, завершающий транзакцию и записывающий журнал, делает небольшую паузу, определяемую параметром commit_delay. Но происходит это только в том случае, если в системе имеется не менее commit_siblings активных транзакций. Ставка тут делается на то, что за время ожидания некоторые транзакции успеют завершиться и можно будет синхронизировать их записи за один прием. Это похоже на то, как вы придерживаете двери лифта, чтобы кто-то успел заскочить в кабину.

По умолчанию параметр commit_siblings = 5, а commit_delay = 0, так что фактически ожидания не происходит. Изменять commit_delay имеет смысл только в системах, выполняющих большое количество коротких OLTP-транзакций.

Затем процесс выполняет сброс журнала на диск до необходимого LSN (или несколько больше, если за время ожидания добавились новые записи). После этого транзакция считается завершенной.

При синхронной записи гарантируется долговечность (буква D в аббревиатуре ACID) — если транзакция зафиксирована, то все ее журнальные записи уже на диске и не будут потеряны. Обратная сторона состоит в том, что синхронная запись увеличивает время отклика (команда COMMIT не возвращает управление до окончания синхронизации) и уменьшает производительность системы.

Асинхронную запись можно получить, установ synchronous_commit = off (или local).

При асинхронной записи сброс журнальных записей выполняет процесс wal writer, чередуя циклы работы с ожиданием (которое устанавливается параметром wal_writer_delay = 200ms по умолчанию).

Проснувшись после очередного ожидания, процесс проверяет, появились ли с прошлого раза полностью заполненные страницы WAL. Если появились, то процесс игнорирует текущую, недозаполненную, страницу, а записывает только полностью заполненные. (Правда, не всегда все сразу: запись останавливается, дойдя до конца кеша, и продолжается с начала кеша уже в следующий раз.)

Если же ни одна страница не заполнилась, то процесс записывает текущую (не полностью заполненную) страницу журнала — не зря же просыпался?

Этот алгоритм нацелен на то, чтобы по возможности не синхронизировать одну и ту же страницу несколько раз, что важно при большом потоке изменений.

Асинхронная запись эффективнее синхронной — фиксация изменений не ждет записи. Однако надежность уменьшается: зафиксированные данные могут пропасть в случае сбоя, если между фиксацией и сбоем прошло менее 3 × wal_writer_delay времени (что при настройке по умолчанию составляет чуть больше полсекунды).

Непростой выбор — эффективность или надежность — остается за администратором системы.

Обратите внимание: в отличие от выключения синхронизации (fsync = off), асинхронный режим не приводит к невозможности восстановления. В случае сбоя система все равно восстановит согласованное состояние, но, возможно, часть последних транзакций будет в нем отсутствовать.

Параметр synchronous_commit можно устанавливать в рамках отдельных транзакций. Это позволяет увеличивать производительность, жертвуя надежностью только части транзакций. Скажем, финансовые операции всегда нужно фиксировать синхронно, а сообщениями в чате иной раз можно и пренебречь.

В реальности оба этих режима работают совместно. Даже при синхронной фиксации журнальные записи долгой транзакции будут записываться асинхронно, чтобы освободить буферы WAL. А если при сбросе страницы из буферного кеша окажется, что соответствующая журнальная запись еще не на диске, она тут же будет сброшена в синхронном режиме.

Чтобы получить какое-то представление о том, какой выигрыш дает асинхронная фиксация, попробуем повторить в таком режиме тест pgbench.

=> ALTER SYSTEM SET synchronous_commit = off;
=> SELECT pg_reload_conf();
student$ pgbench -T 30 test
starting vacuum...end.
transaction type: 
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
duration: 30 s
number of transactions actually processed: 45439
latency average = 0.660 ms
tps = 1514.561710 (including connections establishing)
tps = 1514.710558 (excluding connections establishing)


При синхронной фиксации мы получали примерно 900 транзакций в секунду (tps), при асинхронной — 1500. Разумеется, в реальной системе под реальной нагрузкой соотношение будет другим, но видно, что при коротких транзакциях эффект может быть весьма значительным.

На этом цикл статей про журналирование подошел к концу. Если что-то важное осталось за кадром, не сочтите за труд написать в комментариях. Всем спасибо!

А дальше нас ждут увлекательные приключения в мире блокировок, но это уже другая история.

© Habrahabr.ru