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

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

Уровни журнала


Основная задача журнала предзаписи — обеспечить возможность восстановления после сбоя. Но, если уж все равно приходится вести журнал, его можно приспособить и для других задач, добавив в него некоторое количество дополнительной информации. Есть несколько уровней журналирования. Они задаются параметром 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: <builtin: TPC-B (sort of)>
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: <builtin: TPC-B (sort of)>
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: <builtin: TPC-B (sort of)>
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: <builtin: TPC-B (sort of)>
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. Разумеется, в реальной системе под реальной нагрузкой соотношение будет другим, но видно, что при коротких транзакциях эффект может быть весьма значительным.

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

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

Комментарии (9)


  1. DmitryKoterov
    27.07.2019 21:34
    +2

    Материал мирового уровня, спасибо! Про блокировки — это, конечно, хорошо, но реквестую про логическую репликацию, слоты и подписку! А также про то, насколько можно вычитывание потока репликации распараллелить (и можно ли). Юзкейсы — а) из постгреса в эластиксёрч при большом потоке записи без единого разрыва, и б) гарантированная инвалидация ключей memcache при изменении данных в постгресе, если вдруг обычная синхронная инвалидация при самой записи сбойнула, и надо ее проиграть снова.


    1. erogov Автор
      27.07.2019 22:45

      Рад, что понравилось! Дойдем и до репликации постепенно.


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


      А расскажите в трех словах про Эластик — ему можно поток изменений скармливать, а не сами данные? Или там вокруг придется еще много всякого нагородить? Не слишком сложно?


      1. DmitryKoterov
        28.07.2019 01:27

        Вроде бы, судя по документации, можно подписываться на изменения отдельно по разным таблицам? Т.е. если шардить/партицировать таблицу (как Скайп делал), то можно параллельно читать, как я надеюсь.

        Про Эластик — конечно всякого придется городить, если нагрузка большая и данных много. Как мне видится, в прямом перекладывании данных из постгреса в эластиксерч смысла немного. Вместо этого хочется постгрес использовать как единый source of truth изменений (чтобы гарантированно ничего не потерять), а при проигрывании этих изменений в эластиксерч нанизывать на «скелет», взятый из постгреса, данные в том числе из других источников (например, большие тексты в постгресе слишком дорого хранить в этом случае, лучше только метаданные, а тексты в отдельном дешевом key-value storage).

        Для простых случаев, я слышал, народ использует github.com/appbaseio/abc, а для сложных пишут лог изменений в Kafka (но вот если напрямую из постгреса проигрывать без всяких кафк, это упрощает картину).

        Фейсбук так делает с MySQL. Source of truth — в MySQL, а во все остальные места (инвалидация кэша, полнотекстовый индекс, инверсные индексы для дуг графа) проигрываются из его binlog-а, так что данные гарантированно доедут в конечном итоге, что бы ни произошло.


        1. erogov Автор
          28.07.2019 10:40

          Вроде бы, судя по документации, можно подписываться на изменения отдельно по разным таблицам? Т.е. если шардить/партицировать таблицу (как Скайп делал), то можно параллельно читать, как я надеюсь.

          Так, конечно, можно получить несколько потоков, но, боюсь, это будет менее эффективно, чем просто читать в один поток. Да и с точки зрения поддержки неудобно.


          Посмотрел на ABC — да, они используют логическую репликацию и преобразуют поток сознания Постгреса во что-то удобоваримое для Эластика.


  1. akhkmed
    27.07.2019 22:52
    +1

    Большое спасибо за статью. Расскажите, пожалуйста, как правильно проверить, что какой бы то ни было метод синхронизации работает корректно и не приведёт к потере данных из-за непредвиденного выключения сервера?


    1. erogov Автор
      28.07.2019 00:12

      Всегда пожалуйста.


      Как проверить — тестировать, только так. Верить никому нельзя (:
      То есть надо реально отключать питание и смотреть, что получится. Документация предлагает diskchecker.pl в помощь.


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


      1. oller
        29.07.2019 07:23

        А может есть какая-то утилита, которая делает анализ производительности и выводит список рекомендаций по настройке основных параметров?


        1. erogov Автор
          29.07.2019 11:16

          Куда ж без них. Есть древний pgtune, вот postgres-checkup активно развивается, а если погуглить, то и другие найдутся.


          Но учитывайте, что


          1. ряд параметров зависит от задачи, которую никто, кроме вас, не знает (например, synchronous_commit),
          2. многие важные параметры зависят не только от конфигурации, но и от данных и от нагрузки (например, shared_buffers, work_mem), и для их настройки нужна обратная связь от мониторинга.

          По уму СУБД должна уметь самоподстраиваться под нагрузку, и это направление активно исследуется (например, Энди Павло), но до этого PostgreSQL пока не дожил.


          1. oller
            29.07.2019 20:22

            Pgtune используем, но в нем лишь 10к параметров, а в постгресе их 100ни
            А вот про postgres-checkup слышу впервые, обязательно попробую.
            Очень надеюсь, что версии к 15 максимум будут встроенные механизмы.
            Самая серьёзная проблема была связана с обменом памяти-процессор и с raid контроллером на ssd дисках.