MAATRIX / Блог / Что происходит в момент COMMIT: fsync, журнал и обещание, данное диску

Что происходит в момент COMMIT: fsync, журнал и обещание, данное диску

MAATRIX

Вы отправляете COMMIT, СУБД отвечает COMMIT — и кажется, что на этом всё закончилось: транзакция зафиксирована, можно жить дальше. На деле в этот миг происходит вполне конкретное действие с диском, и пока оно не завершится, ответа не будет. Разберём, что скрывается за этим коротким словом и почему медленный диск — это не абстрактная жалоба, а прямое ограничение скорости ваших транзакций.

Что означает "зафиксировано" на самом деле

Буква D в ACID — durability, долговечность. Она обещает: если СУБД ответила клиенту COMMIT, то после этого момента изменения переживут что угодно — перезагрузку, падение процесса, обрыв питания сервера. Не "переживут с высокой вероятностью", а переживут гарантированно, если только физический носитель не разрушен целиком.

Это обещание нельзя дать на словах — файловая система и ОС не самом деле буферизуют запись. Когда приложение вызывает write(), данные обычно попадают в page cache в оперативной памяти ядра, а не сразу на пластины или NAND-чипы. Если сервер выключится сразу после write(), эти данные пропадут — их никто не успел записать физически. Подробнее о том, что происходит между write() и реальным диском, я разбирал в статье про то, что реально делает sync — здесь коротко: write() — это "готов записать", а не "записал".

Поэтому СУБД не может считать транзакцию зафиксированной, пока данные не долетели до диска в буквальном, физическом смысле. Возврат COMMIT клиенту — это не факт "запрос обработан", это факт "я лично удостоверилась, что эти байты переживут отключение питания". Между этими двумя формулировками — вся разница.

Зачем нужен WAL и почему пишут дважды

Наивная реализация durability выглядела бы так: при COMMIT брать все изменённые страницы таблиц и индексов и сбрасывать их на диск. Проблема в том, что транзакция может задеть десятки страниц, разбросанных по всему файлу данных — случайную запись в произвольные места диска. Даже на NVMe это ощутимо медленнее последовательной записи, а на сетевых дисках виртуальных серверов разница может быть кратной.

Решение, до которого практика дошла ещё в System R и которое сегодня использует и PostgreSQL, и MySQL/InnoDB, и большинство серьёзных СУБД — write-ahead log, WAL, журнал упреждающей записи. Идея простая: вместо того чтобы гарантировать запись изменённых страниц данных при каждом коммите, СУБД гарантирует запись компактной записи об изменении в журнал — линейный, дозаписываемый файл. Страницы данных потом сбрасываются на диск отдельно, лениво, во время checkpoint, без спешки и без привязки к моменту коммита конкретной транзакции.

Правило простое и жёсткое: запись в журнал об изменении должна физически лечь на диск раньше, чем соответствующая изменённая страница данных. Отсюда и название — "упреждающая" запись. Если журнал добрался до диска, а страница данных — ещё нет, ничего страшного: при восстановлении после сбоя СУБД прочитает журнал и доиграет (replay) те изменения, которых не хватает в файле данных. Если бы было наоборот — страница обновилась, а журнал ещё нет — восстановиться после сбоя было бы нечем, потому что нет записи о том, что вообще произошло.

Я подробно разбирал устройство журнала в статье что такое WAL и зачем писать всё дважды — здесь важно зафиксировать связь: WAL — это не самостоятельная фича "для надёжности", это конкретный технический механизм, которым обеспечивается D в ACID через COMMIT. Без журнала команду COMMIT пришлось бы либо делать значительно медленнее (полная запись всех страниц), либо давать более слабые гарантии.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Момент COMMIT пошагово

Возьмём типичный сценарий на PostgreSQL, чтобы увидеть последовательность действий буквально.

  1. Клиент выполняет операции внутри транзакции (INSERT, UPDATE, DELETE). Каждое изменение сразу формирует WAL-запись и складывается в буфер журнала в разделяемой памяти сервера (wal_buffers). Страницы данных при этом меняются в памяти (в shared buffers), но на диск пока не идут.
  2. Клиент отправляет COMMIT.
  3. Сервер БД формирует финальную WAL-запись о фиксации транзакции (commit record) и инициирует запись накопленного буфера журнала на диск через системный вызов, соответствующий требуемой надёжности — обычно это fsync() над файлом журнала, либо запись открыта в режиме, который просит ядро и накопитель не буферизовать данные (O_DSYNC/аналоги — конкретный механизм зависит от параметра wal_sync_method).
  4. Только когда этот вызов вернул управление без ошибки — то есть ядро подтвердило, что данные физически ушли на носитель, а не просто легли в кеш ОС — сервер БД отправляет клиенту ответ COMMIT.
  5. Изменённые страницы данных (heap, индексы) остаются в памяти и будут сброшены на диск позже, во время checkpoint — асинхронно, без участия клиента и без влияния на задержку конкретного COMMIT.

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

MySQL с InnoDB устроен аналогично по сути, хотя термины другие: свой redo log, свой набор параметров (innodb_flush_log_at_trx_commit управляет тем, насколько строго логика синхронизации привязана к каждому коммиту), но принцип тот же — сначала журнал физически на диске, потом ответ клиенту.

fsync: что именно он гарантирует, а что — нет

fsync() — системный вызов, который просит операционную систему сбросить все изменённые (dirty) страницы конкретного файла из кеша ОС на физический носитель и не возвращать управление, пока это не подтверждено. Это именно тот механизм, через который СУБД превращает "данные в буфере ядра" в "данные, которые переживут отключение питания".

Важно понимать границы этой гарантии:

  • fsync() отвечает за данные конкретного файлового дескриптора (и, в зависимости от системы, за метаданные файла). Он ничего не знает про другие файлы и про то, что происходит с диском в целом.
  • Гарантия fsync() опирается на честность слоя под ним — драйвера, контроллера, самого накопителя. Если накопитель сообщает "записано", хотя данные ещё лежат в собственном кеше записи устройства, а не на энергонезависимой памяти — ОС физически не может об этом узнать. Это не гипотетическая проблема: у RAID-контроллеров есть режим write-back с кешем, который в норме защищён батареей (BBU) или конденсатором. Я разбирал похожий случай, когда контроллер честно переключился в write-through из-за севшей батареи и запись резко замедлилась именно потому, что он перестал врать о завершении записи. Будь батарея неисправна незаметно, контроллер продолжал бы отвечать "готово" из незащищённого кеша — и при внезапном отключении питания данные пропали бы, хотя СУБД была уверена, что COMMIT прошёл честно.
  • Дешёвые потребительские SSD и виртуализованные диски в некоторых конфигурациях тоже могут игнорировать флаги принудительной синхронизации ради красивых цифр в бенчмарках. Отдельная больная тема — так называемые барьеры записи, механизм, который должен гарантировать порядок физической записи на носитель. Я разбирал это подробно в статье как кеш диска может врать о сохранности данных.

Практический вывод: durability вашей СУБД настолько же надёжна, насколько честен весь стек под ней — ядро, драйвер, контроллер, сам накопитель. СУБД делает свою часть корректно (вызывает fsync в нужный момент), но дальше она вынуждена доверять ответу нижележащего слоя.

Почему COMMIT физически не может быть быстрее диска

Из всей цепочки следует простое, но часто недооцениваемое следствие: латентность одного синхронного COMMIT ограничена снизу временем одной гарантированной записи на диск. Не пропускной способностью, не IOPS в объявленных характеристиках тарифа — именно временем одного акта fsync до конкретного носителя.

На вращающихся жёстких дисках это время определяется механикой — позиционированием головки и вращением пластины. На NVMe SSD с прямым доступом порядок величины кардинально другой за счёт отсутствия механики, но конкретное число зависит от модели накопителя, глубины очереди, загрузки контроллера и слоя виртуализации — здесь я намеренно не привожу цифр как ориентир "у вас будет так же", реальное значение стоит мерить на своём железе (например, через fio), а не брать из чужой статьи.

Отсюда практическое следствие: если приложение делает много мелких транзакций с частыми коммитами (построчная вставка в цикле, каждая строка в своей транзакции), суммарная задержка становится суммой латентностей отдельных синхронных записей. Это одна из главных причин, почему батчинг — объединение множества строк в одну транзакцию с одним COMMIT в конце — даёт кратный прирост производительности: вместо N синхронных сбросов журнала на диск происходит один.

Отсюда же и роль SSD/NVMe в производительности БД — узкое место не пропускная способность диска в мегабайтах в секунду, а латентность одной операции синхронной записи, которая ограничивает частоту коммитов сверху.

Компромиссы: когда можно (и когда нельзя) ослабить гарантию

Строгий синхронный fsync на каждый COMMIT — самый безопасный режим, но не единственно возможный. У большинства СУБД есть настройки, позволяющие сознательно обменять часть durability на скорость.

В PostgreSQL это параметр synchronous_commit. При значении off сервер отвечает клиенту COMMIT сразу после записи в WAL-буфер, не дожидаясь физического fsync — фоновый процесс walwriter досбросит журнал на диск чуть позже (интервал задаётся wal_writer_delay). Риск понятен: если сервер упадёт в этот короткий промежуток, последние подтверждённые клиенту транзакции могут быть потеряны при восстановлении, хотя целостность самой базы не пострадает.

-- на уровне сессии, для конкретной транзакции с некритичными данными
SET synchronous_commit = off;
BEGIN;
INSERT INTO events_log (payload) VALUES ('...');
COMMIT;

В MySQL/InnoDB похожую роль играет innodb_flush_log_at_trx_commit: значение 1 — строгий fsync на каждый коммит (по умолчанию и единственно безопасный вариант для честной durability), 2 — запись в файл ОС при каждом коммите, но fsync раз в секунду (переживает падение процесса MySQL, но не падение всей ОС), 0 — сброс из буфера InnoDB в ОС и на диск тоже раз в секунду (самый быстрый и самый рискованный режим).

Когда ослаблять гарантию оправданно: журналы событий, аналитика, метрики, кеши сессий — данные, потеря последних секунд которых не критична, а объём вставок велик. Когда нельзя: финансовые операции, заказы, всё, что связано с деньгами или юридически значимыми действиями — там строгий fsync на каждый коммит не обсуждается, это цена корректности.

Отдельно стоит батарейная/конденсаторная защита кеша контроллера — если она реально исправна, можно безопасно держать write-back кеш включённым и получать честное ускорение без потери durability. Проверять это стоит регулярно, а не один раз при установке — как показал случай с севшей батарейкой RAID-контроллера, деградация происходит незаметно.

Как проверить и не наступить на грабли

Несколько практических шагов, которые стоит держать в голове при развёртывании СУБД на сервере.

  • Убедитесь, что wal_sync_method (PostgreSQL) или innodb_flush_method (MySQL) выбран осознанно, а не оставлен по умолчанию без понимания. На современных Linux с ext4/XFS обычно достаточно значений по умолчанию, но при использовании сетевых томов стоит перепроверить, что выбранный метод действительно выполняет синхронную запись.
  • Если под СУБД виртуальный диск в облаке — уточните у провайдера, какая модель кеша используется на уровне гипервизора и подтверждает ли он fsync только после физической записи на нижележащее хранилище. Это едва ли не единственный параметр инфраструктуры, который напрямую определяет, работает ли обещание durability на практике.
  • Не смешивайте WAL/redo log и файлы данных на одном физическом диске без необходимости при высокой частоте коммитов — журнал пишется последовательно, а конкуренция со случайным вводом-выводом по данным увеличивает латентность того самого fsync, от которого зависит скорость COMMIT.
  • Периодически тестируйте реальное восстановление после сбоя (kill -9 процессу СУБД, а на тестовом стенде — жёсткое отключение питания виртуальной машины) и убеждайтесь, что журнал доигрывается и данные на месте. Теоретическая гарантия durability стоит немного, если её никогда не проверяли на практике.
  • Мониторьте латентность коммитов отдельно от латентности чтения — рост времени COMMIT при стабильной нагрузке на чтение почти всегда указывает на деградацию синхронной записи.

Для нагрузок с частыми короткими транзакциями — биллинг, обработка заказов, очереди задач с подтверждением — латентность диска под журналом становится прямым фактором пропускной способности приложения, и здесь оправданно смотреть в сторону выделенного сервера с NVMe под управлением, а не делить диск с соседями по виртуализации без гарантий IOPS.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему COMMIT иногда выполняется быстро, а иногда заметно медленнее на одном и том же сервере?

Чаще всего это конкуренция за диск — параллельные транзакции от других процессов, checkpoint, который в этот момент сбрасывает страницы данных, или деградация write-back кеша RAID-контроллера при севшей батарее. Латентность синхронной записи не гарантированно постоянна даже на одном железе под переменной нагрузкой.

Если использовать SSD вместо HDD, гарантия durability становится сильнее?

Нет, гарантия та же самая — она определяется корректностью fsync и честностью стека под ним, а не типом носителя. SSD обычно даёт меньшую латентность одной операции синхронной записи, то есть более быстрый COMMIT, но не более надёжный по сути — если контроллер SSD врёт о завершении записи, проблема будет ровно та же, что и на HDD с нечестным кешем.

Можно ли вообще обойтись без fsync, если сервер стоит в надёжном дата-центре с ИБП?

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

В чём разница между fsync всего файла и fdatasync?

fdatasync() синхронизирует содержимое файла, но не обязательно все метаданные (например, время модификации), что на некоторых файловых системах чуть быстрее полного fsync(). Для журнала транзакций СУБД это иногда используется как оптимизация, но выбор конкретного вызова остаётся внутренней деталью реализации, а не тем, что нужно настраивать вручную в обычной эксплуатации.

Почему после COMMIT данные в таблице всё равно не сразу видны в файле данных, если посмотреть на диск напрямую?

Потому что физически на диске гарантированно актуален журнал, а не сама таблица — страницы данных сбрасываются позже, во время checkpoint. Это нормально и не противоречит durability: при сбое до checkpoint СУБД восстановит недостающие изменения из журнала при старте.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →