В корпоративной разработке любят бенчмарки. Они просты, наглядны и хорошо выглядят в презентациях: вот TPS, вот latency, вот столбик выше, чем у остальных. За каждой такой цифрой обычно скрывается набор настроек, компромиссов и ограничений, о которых не всегда рассказывают на слайде с графиками.

Чем выше TPS и ниже latency, тем убедительнее выглядит результат в сравнительной таблице или отчёте по PoC. Но пользователь СУБД покупает не только скорость. Он рассчитывает, что операция, которую система уже подтвердила, не исчезнет после сбоя питания, падения операционной системы или жёсткой перезагрузки сервера. В корпоративных системах это могут быть многомиллионные платежи, заказы, данные учёта, изменения статусов, сообщения и любые другие действия, которые приложение уже успело показать пользователю как выполненные.
Высокий TPS и низкая latency могут быть результатом качественной оптимизации, производительного оборудования или удачной конфигурации. Однако сами по себе эти показатели не отвечают на вопросы, какие гарантии получает приложение в момент фиксации транзакции и как система поведёт себя при нештатной остановке.
В этой статье разберём, почему заявленных настроек и итогового значения TPS недостаточно для такой оценки. Покажем, как отдельные параметры влияют на фиксацию WAL, и дадим последовательность практических проверок для тестового стенда. Начнём с того, что именно должна сделать СУБД до того, как подтвердит клиенту успешный COMMIT.
Как устроен COMMIT и роль fsync
При штатной работе СУБД (например, PostgreSQL или его форки) запись изменений происходит по принципу Write-Ahead Logging (WAL, журнал предзаписи). Прежде чем изменения попадут в файлы таблиц, информация о них фиксируется в WAL.
Когда клиентское приложение — платформа 1С или учётная система — отправляет команду COMMIT, происходит следующая цепочка событий:
-
СУБД формирует запись фиксации в журнале транзакций.
-
Процесс базы данных передаёт эти данные операционной системе (вызов write / pwrite64). На этом этапе данные ещё не обязательно оказываются на диске: ядро старается как можно реже обращаться к дисковой подсистеме и намеренно накапливает изменения в страничном кэше (page cache), чтобы объединить несколько записей в одну и сбросить их на диск за один проход, особенно если приложение часто пишет в одни и те же файлы, как это происходит с WAL.
-
СУБД отправляет ядру операционной системы команду fdatasync или fsync. Этот вызов принудительно заставляет ОС и контроллер сбросить данные из оперативной памяти непосредственно на энергонезависимый накопитель (долговременную память диска).
-
Только после подтверждения от дисковой подсистемы о завершении физической записи клиенту возвращается результат: транзакция успешно зафиксирована.
Что происходит при выключенном или неработающем fsync?
Если параметр fsync отключен (fsync = off) либо вызов по какой-то причине игнорируется:
-
Запись завершается на этапе попадания в кэш операционной системы.
-
Операционная система сама выбирает момент, когда записать накопившиеся в кэше данные на диск. Это может произойти через несколько секунд, а при высокой нагрузке — и позднее.
-
СУБД моментально возвращает клиенту ответ об успешной фиксации.
Скорость операций INSERT и UPDATE при этом возрастает многократно, а задержки на шаг COMMIT падают до микросекунд. Но это иллюзия производительности.
Как задержка выдает проблему с fsync
Когда СУБД тестируется в конкурентном многопоточном режиме (например, на 64 или 128 клиентах), общая задержка маскируется очередями, буферизацией и параллелизмом. Чтобы увидеть реальное поведение дисковой подсистемы, тест запускают строго в один поток (-c 1).
pgbench -U postgres --max-tries=10 -c 1 -j 4 -T 30 -r -P2
В однопоточном режиме СУБД не может выполнять транзакции параллельно: каждая следующая транзакция ждёт полного физического завершения предыдущей. Производительность системы упирается исключительно в задержку (latency) последовательных этапов.
Сравним два детальных отчета pgbench (пошаговая разбивка времени выполнения операций внутри транзакции):
Сценарий корректно работающей синхронизации (fsync включён)
statement latencies in milliseconds: 0.048 BEGIN; 0.135 UPDATE pgbench_accounts ...; 0.082 SELECT abalance ...; 0.095 UPDATE pgbench_tellers ...; 0.080 UPDATE pgbench_branches ...; 0.068 INSERT INTO pgbench_history ...; 0.223 END; <--- 223 микросекунды (с синхронной репликой — до 450 мкс)
Сценарий, когда синхронизация отсутствует или отключена
0.046 BEGIN;0.128 UPDATE pgbench_accounts ...;0.077 SELECT abalance ...;0.091 UPDATE pgbench_tellers ...;0.076 UPDATE pgbench_branches ...;0.064 INSERT INTO pgbench_history ...;0.060 END; <--- 60 микросекунд
Обратите внимание на колоссальный разрыв на шаге завершения транзакции (END; / COMMIT): 223 мкс против 60 мкс.
Чтобы понять природу этой аномалии, проследим путь команды сброса на физическом уровне:
-
СУБД записывает очередную порцию WAL в кэш операционной системы.
-
При выполнении fdatasync() она требует не откладывать запись.
-
Ядро ОС передаёт данные в подсистему хранения и ждёт, пока накопитель подтвердит их сохранение.
-
Только после этого fdatasync() завершается, а СУБД может подтвердить клиенту успешный COMMIT.
Для современных корпоративных NVMe-накопителей физический цикл выполнения команды синхронизации составляет порядка 100–150 микросекунд на уровне самого «железа». Например, в независимом тестировании Percona для одного из flagship-накопителей корпоративного класса (Intel P3700) частота успешных fsync-вызовов составила около 7 380 операций в секунду, что соответствует средней задержке примерно 135 микросекунд на одну операцию синхронизации. Это один из лучших результатов среди протестированных дисков, потребительские NVMe показывали задержку в разы, а иногда и на порядки, выше. Если прибавить к этому накладные расходы ядра ОС, переключение контекста и передачу сетевых пакетов клиенту, то реальное время честного COMMIT в промышленной СУБД физически не может быть меньше 150–250 микросекунд.
Задержка на 60 микросекунд означает одно: за это время данные физически не могли покинуть оперативную память и попасть на диск. Даже если команда fdatasync формально была вызвана, ни она сама, ни последующий цикл записи на NVMe-накопитель просто не успевают уложиться в 60 мкс. На на такой скорости накопитель ещё не записал данные.
В масштабе одной транзакции разница между 223 мкс и 60 мкс кажется незначительной. Однако при суммировании всех шагов транзакции картина меняется:
-
при честном fsync общая длина транзакции составляет ~0.73 мс (потолок одного потока — около 1 370 TPS).
-
при отключенном сбросе длина транзакции сокращается до ~0.52 мс (потолок одного потока уже 1 920 TPS, прирост на 40% на ровном месте).
Когда этот тест запускается в сотни потоков на мощном многоядерном сервере, полное устранение дискового «бутылочного горлышка» даёт взрывной рост синтетической производительности — с 45 000 до 60 000+ TPS. Такой прирост производительности выглядит впечатляюще. Но он поднимает важные вопросы: за счёт каких именно операций сократилось время COMMIT и сохраняется ли при этом гарантия долговечности подтверждённых транзакций?
Как проверить, что WAL действительно сохраняется на диск
Низкая задержка COMMIT — только повод насторожиться. Она может быть связана с быстрым накопителем, особенностями кэширования, групповой фиксацией транзакций или иной организацией работы СУБД. Чтобы понять, обеспечивается ли реальная долговечность данных, нужно проверить систему несколькими независимыми способами.
Наша задача не в том, чтобы по одному признаку доказать конкретную причину проблемы. В цепочке между PostgreSQL и накопителем слишком много компонентов: файловая система, ядро ОС, драйверы, RAID, виртуализация, контроллер хранения и сам диск.
Практический вопрос проще и важнее: если приложение получило успешный ответ на COMMIT, а сервер сразу после этого аварийно выключился, сможет ли кластер восстановиться, а подтверждённые данные останутся ли на месте?
Начинать стоит с системных вызовов.
Шаг 1. Ищем синхронизацию WAL через strace
strace позволяет увидеть, какие системные вызовы выполняют процессы PostgreSQL в момент записи WAL и завершения транзакции.
Сначала определим процессы PostgreSQL:
ps -ef | grep '[p]ostgres'
В тестовом стенде нас интересуют клиентские backend-процессы, процессы записи WAL и процессы репликации. Затем подключаем strace к нужному процессу:
sudo strace -tt -T \ -e trace=write,pwrite64,fsync,fdatasync \ -p <PID>
Где <PID> — идентификатор процесса PostgreSQL, который участвует в обработке транзакций или записи WAL.
Если требуется одновременно наблюдать несколько процессов, можно передать несколько параметров -p:
sudo strace -tt -T \ -e trace=write,pwrite64,fsync,fdatasync \ -p <PID_1> \ -p <PID_2> \ -p <PID_3>
После подключения strace выполните несколько транзакций в отдельной сессии:
BEGIN;UPDATE pgbench_accounts SET abalance = abalance + 1 WHERE aid = 1;COMMIT;
Во время исследования была получена последовательность вызовов:
recvfrom(11, "Q\0\0\0\nEND;\0", 8192, 0, NULL, NULL) = 10pwrite64(122, "\4\360\5\0...", 8192, 4227072) = 8192kill(3023612, SIGURG) = 0epoll_wait(5, [{EPOLLIN, ...}], 1, -1) = 1read(3, "...", 1024) = 128sendto(11, "C\0\0\0\vCOMMIT\0Z\0\0\0\5I", 18, 0, NULL, 0) = 18
Разберём этот листинг.
-
recvfrom— процесс PostgreSQL получил от клиента командуEND;, то есть запрос на завершение транзакции. -
pwrite64— данные WAL переданы ядру ОС. -
killиepoll_wait— внутреннее взаимодействие процессов СУБД, в том числе связанное с работой репликации. -
sendto— клиенту отправлен ответ об успешномCOMMIT.
Между pwrite64 и sendto в конфигурации, где WAL должен синхронно сохраняться на накопитель, следует искать fdatasync() или fsync() для соответствующего файла журнала.
Если вызов виден, это полезный признак, однако он не отменяет последующую проверку аварийного восстановления.
Если же при повторных замерах клиент получает успешный COMMIT, но ожидаемых fdatasync() или fsync() для WAL не видно, это серьёзное основание продолжить расследование. Важно сначала убедиться, что strace подключён к нужным процессам и охватывает период активной записи. Одного короткого фрагмента недостаточно, чтобы утверждать, что синхронизация отсутствует всегда. В проведённых испытаниях отсутствие ожидаемых sync-вызовов наблюдалось как на первичном узле, так и на реплике.
Шаг 2. Сверяемся с iostat
Теперь нужно посмотреть, согласуется ли трассировка с тем, что происходит на уровне хранилища. Утилита iostat с флагом -x под нагрузкой показывает столбец f/s — количество команд принудительного сброса кэша на диск в секунду. Если при активной записи это значение стабильно держится на нуле, значит физического сброса журнала на диск не происходит вообще, независимо от того что показывают TPS-графики.
Команда выводит статистику по дисковым устройствам каждую секунду. При активной записи среди прочих показателей в некоторых конфигурациях полезно смотреть на f/s — количество flush-операций в секунду.
Пример вывода:
Device r/s rkB/s w/s wkB/s ... f/s f_await %utildm-11 45.88 478.85 2838.25 117428.86 ... 0.00 0.00 12.97dm-18 46.02 478.84 2862.65 117428.86 ... 0.00 0.00 12.80sdac 11.40 118.97 707.92 29320.72 ... 0.00 0.00 7.21
В приведённом примере устройство активно принимает запись: это видно по ненулевым w/s и wkB/s. Но f/s остаётся нулевым.
Такой результат не стоит трактовать как самостоятельное доказательство отсутствия синхронизации. Виртуальные диски, RAID-контроллеры, SAN, облачные хранилища и разные драйверы могут по-разному отражать flush-операции в системной статистике.
Но если одновременно наблюдаются три признака:
-
COMMITвыглядит аномально быстрым; -
straceне показывает ожидаемые вызовы синхронизации WAL; -
iostatво время активной записи не показывает ожидаемой flush-активности,
то это уже сильная техническая гипотеза, которую нужно проверить аварийным тестом. В материалах испытаний именно такая комбинация наблюдений стала основанием для дальнейшей проверки.
Шаг 3. При необходимости используем FUSE
Для дополнительной проверки можно использовать перехватывающую файловую систему на базе FUSE. Она монтируется поверх каталога данных PostgreSQL и позволяет фиксировать обращения процессов СУБД к операциям синхронизации.
Ключевая часть обработчика выглядит так:
class LyingSyncFS(Operations): ... def fsync(self, path, fdatasync, fh): with open(self.log_file, "a") as log: log.write( f"{time.time()}: fsync on {path} " f"(fdatasync={fdatasync})\n" ) os.fsync(fh) return 0
Если система выполняет fdatasync() для журнала транзакций, в логе ожидаются записи для файлов из каталога pg_wal/:
fsync on /pgdata/pg_wal/000000010000000000000001 (fdatasync=1)
Это означает, что СУБД обратилась к операции синхронизации для сегмента WAL.
А вот такие записи сами по себе ещё не подтверждают синхронизацию журнала транзакций:
fsync on /pgdata/postmaster.pid (fdatasync=0)fsync on /pgdata/global/pg_control (fdatasync=0)fsync on /pgdata/base/16384/PG_VERSION (fdatasync=0)
Они относятся к служебным файлам кластера, а не к текущим сегментам WAL.
FUSE полезен как дополнительный инструмент наблюдения. Однако не следует строить окончательный вывод исключительно на его логе: конкретная реализация файловой системы, режимы flush() и особенности работы FUSE могут влиять на полноту картины. Поэтому результаты нужно сопоставлять с strace, iostat и аварийным тестом.
Шаг 4. Проверяем систему аварией
Финальная и главная проверка — crash-тест.
Здесь проверяется не отдельный системный вызов и не интерпретация одной метрики. Проверяется итог: что произошло с данными после того, как система уже подтвердила транзакции клиенту, а сервер внезапно потерял питание.
Не запускайте этот сценарий на production-сервере, рабочем стенде или виртуальной машине с ценными данными. Команда echo b > /proc/sysrq-trigger инициирует немедленную жёсткую перезагрузку ядра: PostgreSQL не завершит работу штатно, а несохранённые данные могут быть потеряны. Используйте отдельный одноразовый стенд.
Скрипт подготавливает чистый кластер, записывает 10 000 строк, фиксирует начальное состояние, обновляет 1 000 строк и после успешного UPDATE принудительно перезагружает сервер:
#!/bin/bashset -e# 1. Разворачиваем чистый кластерinitdb -U postgres -D "$PGDATA"pg_ctl -l logfile start# 2. Создаем таблицу и наполняем ее (10 000 записей)createdb test_fsyncpsql -d test_fsync -c "CREATE TABLE crash_test (id serial, data text);"psql -d test_fsync -c "INSERT INTO crash_test (data) SELECT 'x' FROM generate_series(1,10000);"psql -d test_fsync -c "CHECKPOINT;"# 3. Принудительно гарантируем сохранность начального состоянияsyncecho 3 | sudo tee /proc/sys/vm/drop_cachesecho s | sudo tee /proc/sysrq-trigger# 4. Выполняем критическую бизнес-транзакциюpsql -d test_fsync -c "UPDATE crash_test SET data = 'y' WHERE id % 10 = 0;"# Фиксируем состояние до аварииpsql -d test_fsync -c "SELECT count(*), data FROM crash_test GROUP BY data;"# 5. Эмулируем внезапную аппаратную смерть сервера (Hard Reset)echo 1 | sudo tee /proc/sys/kernel/sysrqecho b | sudo tee /proc/sysrq-trigger
Перед перезагрузкой результат должен быть таким:
count | data -------+------ 1000 | y 9000 | x(2 rows)
После загрузки ОС нужно запустить кластер и проверить содержимое таблицы:
pg_ctl -l logfile startpsql -d test_fsync -c " SELECT count(*), data FROM crash_test GROUP BY data;"
При корректном crash recovery должны сохраниться все подтверждённые обновления:
count | data -------+------ 1000 | y 9000 | x(2 rows)
Если же после запуска вывод выглядит так:
count | data -------+------ 10000 | x(1 row)
значит изменения, которые до аварии были успешно подтверждены клиенту, после восстановления не сохранились.
В более тяжёлом сценарии, например при жёсткой перезагрузке во время непрерывной нагрузки pgbench, последствия могут оказаться серьёзнее. Реплика может не восстановиться из-за повреждённого сегмента WAL
LOG: invalid magic number 0000 in WAL segment00000001000000000000005F,LSN 0/5F000000, offset 0
Это означает, что PostgreSQL при восстановлении ожидал увидеть корректную структуру WAL, но встретил нулевые данные. Реплика не понимает, как продолжить восстановление, и не может вернуться в строй.
Мастер при этом иногда способен запуститься, но сам запуск процесса ещё не означает, что данные в порядке. Поэтому после аварии следует выполнить pg_amcheck:
pg_amcheck -d test
Если утилита сообщает об ошибке вида:
heap table "test.public.pgbench_accounts", block 23, offset 33: xmax 31574 equals or exceeds next valid transaction ID 31457
это означает, что в таблице найдена строка со служебной информацией, не согласующейся с текущим состоянием кластера после восстановления.
Что показывают эти сообщения?
На реплике PostgreSQL пытается прочитать журнал изменений, чтобы восстановить своё состояние после аварии. Но вместо корректной служебной информации в WAL-сегменте он встречает нулевые данные и не может продолжить восстановление. Поэтому реплика не запускается и перестаёт участвовать в обеспечении отказоустойчивости.
На мастере ситуация не выглядит безопасной только потому, что процесс СУБД смог стартовать. pg_amcheck находит записи с некорректными служебными идентификаторами транзакций. Для администратора это означает, что данным в затронутой таблице уже нельзя безоговорочно доверять: база может отвечать на запросы, но часть информации внутри неё находится в несогласованном состоянии.
Практический план действий в такой ситуации: остановить использование повреждённого контура, зафиксировать артефакты аварии, оценить объём повреждений и восстановить реплику из заведомо корректного источника. Для мастера может потребоваться восстановление из резервной копии с последующим применением доступных WAL-архивов либо переключение на заранее подготовленный исправный узел. То есть речь идёт уже не о кратком откате последних операций, а о полноценном аварийном восстановлении.
Когда можно отключить ожидание синхронизации WAL
Если перед проектом действительно стоит задача снизить задержку записи ценой части данных, разработчики PostgreSQL предусмотрели безопасный штатный механизм. Важно понимать разницу между двумя параметрами:
-
fsync = off
Полностью отключает системный вызов сброса данных на диск для всей СУБД. При падении ОС повреждаются системные каталоги, нарушается целостность страниц таблиц и сегментов WAL. Вероятность успешного автоматического подъёма базы крайне мала. Допустимо исключительно для одноразовой заливки начальных данных на этапе разработки. -
synchronous_commit = off
В этом режиме fsync не отключается. Разница в том, что PostgreSQL не заставляет клиентскую транзакцию ждать, пока WAL будет гарантированно сохранён на диске. Эту работу продолжает выполнять фоновый процесс WAL writer.
Частота его запуска регулируется параметром wal_writer_delay, который по умолчанию равен 200 мс. Но это не жёсткая гарантия, что данные попадут на диск через 200 мс: в периоды активной записи PostgreSQL может откладывать полный flush, поэтому документация указывает максимальное окно риска до 3 × wal_writer_delay. При стандартных настройках оно достигает 600 мс.
Сколько именно транзакций может потеряться за это время, зависит от нагрузки. На малонагруженной базе это могут быть единичные операции, а на системе с десятками тысяч TPS — уже значительный объём подтверждённых изменений.
Иногда ждать синхронизации WAL действительно не нужно. Это осмысленно в сценариях, где последние записи можно потерять без катастрофы:
-
система собирает логи, метрики или телеметрию;
-
данные нужны для аналитики, но существуют в исходной системе;
-
выполняется временная обработка или построение кэша;
-
идёт тестовая нагрузка;
-
выполняется одноразовая загрузка, которую можно полностью повторить.
Выводы и мораль
У этой истории нет морали в духе «быстрый COMMIT — это плохо». Наоборот, уменьшать задержку транзакций нужно и можно. Вопрос в том, чтобы понимать, за счёт чего получен результат и что произойдёт с подтверждёнными данными при аварии.
Приведённые выше команды и скрипты позволяют самостоятельно поэкспериментировать на тестовом стенде: измерить latency, посмотреть вызовы fsync и fdatasync, проверить активность хранилища, принудительно перезагрузить сервер и оценить состояние кластера после восстановления.
Памятка для архитекторов и DBA:
-
Не верьте «чудесам» на бенчмарках. Если latency транзакции на порядки ниже времени доступа к физическому диску, синхронизация, скорее всего, не работает.
-
Всегда включайте crash-тесты в методику испытаний (PoC). Проверка с echo b > /proc/sysrq-trigger на нагруженном стенде моментально показывает реальное поведение системы при аварии.
-
Контролируйте ядро. Инструменты strace, iostat (со счётчиком f/s) и перехватывающие модули на базе FUSE позволяют достоверно узнать, доходят ли данные до физических носителей.
-
Разделяйте осознанный компромисс и скрытую потерю надёжности. Параметр synchronous_commit = off — легитимный инструмент, если вы понимаете и принимаете риск потери последних секунд транзакций (например, для сбора логов или временных данных). Принципиальная разница заключается в том, кто держит руку на рубильнике: вы сами, причём осознанно, или конфигурация системы делает это за вас, не предупреждая.
-
Чем выше цена потери или повреждения данных при сбое, тем важнее не полагаться на заявленные настройки и красивые цифры TPS в презентации, а самостоятельно прогнать crash-тест и посмотреть, что происходит с журналом транзакций на самом деле.
ссылка на оригинал статьи https://habr.com/ru/articles/1086788/