Это вторая часть моих копаний во внутренностях MySQL. В первой части [habr] были затронуты запись страниц данных на диск (с промежуточной записью в DoubleWrite buffer) и запись бинлогов (с батчингом в виде group commit). В этой части я расскажу про redo log и как все части MySQL координируются для достижения надежной работы. Double Write buffer и Binlogs [https://habr.com/ru/post/684474/] Redo logs и общая картина [эта статья] Disclaimer: автор не является разработчиком MySQL, все нижеописанное может не совпадать с реальным положением дел. Одна пользовательская транзакция может изменить различные строки в разных страницах с данными. Но как мы уже знаем из первой половины статьи, невозможно атомарно записать несколько страниц на диск. Для создания видимости атомарной записи InnoDB сначала пишет лог изменений - redo log, и уже в фоне(!) сохраняет сами страницы. В других базах данных такой лог обычно называют Write Ahead Log. Именно Redo Log гарантирует что изменения не будут утеряны даже в случае crash-а MySQL. В процессе crash-recovery InnoDB просто применит изменения из Redo Log к страницам, которые не были сохранены на диск - т.е. к страницам чьи LSN меньше LSN-на который происходит восстановление. Fun Facts: В процессе обычной работы InnoDB никогда не читает redo log. Но его могут читать сторонние приложения, например, xtrabackup. Так как успешная запись в Redo Log гарантирует, что данные не будут утеряны, клиентские транзакции ждут только записи в Redo Log (и binlog), и не ожидают пока все страницы с данными будут надежно записаны. Из этого следует, что для производительности базы данных критически важно писать в Redo Log быстро. Для этого записи (records) в Redo log сделаны довольно компактными: они представляют собой ID Си-функции и ее аргументы. Необходимость переживать crash приводит к тому, что функции из redo log не могут полагаться на in-memory состояние процесса или внешние данные (другими словами функции должны быть “чистым”) и идемпотентными. Из-за требований обратной совместимости сигнатуры этих функций не изменяют. Группы таких записей оборачиваются в микро-транзакции (называемые mtr), которые при mtr_commit() пишутся сначала в log buffer, а потом в redo log. Гарантируется что во время crash-recovery транзакция или полностью применяется или не будет применена вовсе. Fun Facts: redo log не содержит информации о текущем состоянии страниц с данными, и поэтому его можно применять только “вперед” (откатить mtr-транзакции нельзя). PostgreSQL, для сравнения, периодически пишет текущее состояние страниц в WAL. PostgreSQL разменивает размер WAL-а на защиту от неполной записи страницы (тем самым делая ненужным doublewrite buffer). InnoDB может писать в redo log текущее состояние сжатых страниц на случай если изменения recovery будут производиться InnoDB с другой версией zlib (см innodb_log_compressed_pages). Запись в redo log проходит следующие этапы: В памяти создаются redo log записи: Сначала вызывается mtr_start(), чтобы создать новую mtr-транзакцию.  mtr содержит динамический массив байт m_log [src], куда далее будут писаться все изменения производимые со страницами. Когда mtr-транзакция завершается - mtr_commit() копирует [src] все данные из m_log в log buffer (in-memory структуру данных) и добавляет все измененные страницы с данными во flush list. поток log-writer обходит log buffer в поисках диапазона блоков готовых к записи: в log buffer могут быть “дыры”, т.к. пользовательские потоки могут с разной скоростью писать свои mtr-транзакции.  log-writer переносит готовые записи в файлы redo log. После того как log-writer записал данные, log-flusher вызывает fsync чтобы гарантированно сбросить на диск redo записи. Потоки log-write-notifier и log-flush-notifier уведомляют пользовательские потоки, что запись и flush прошли успешно. потоки log-checkpointer-а сохраняют измененные пользовательские страницы на диск. Как только все страницы старее определенного LSN записаны на диск, InnoDB может отбросить записи в redo log-е до этого значения LSN (тем самым освободить место под новые записи). Fun Facts: redo log-writer-ы были добавлены в MySQL 8.0.11, до этого записью занимались пользовательские потоки. В 8.0.22 log-writer-threads можно отключить настройкой  innodb_log_writer_threads - для баз данных с низкой concurrency можно будет выжать еще немного перформанса За координацию потоков redo log отвечают три структуры данных log buffer, recent write buffer и recent closed buffer, а также несколько переменных-указателей на позиции в этих буферах. Log buffer - кольцевой буфер [def] фиксированного размера (innodb_log_buffer_size). Состоит из log-блоков по  512 байт (OS_FILE_LOG_BLOCK_SIZE), что за вычетом заголовка и футера - дает нам 498 байт полезного пространства в каждом блоке. В log buffer хранятся данные, которые будут записаны на диск. recent_written и  recent_closed - ещё два кольцевых буфера. С помощью первого можно отследить, когда пользовательский поток завершил запись в log buffer и данные можно писать на диск, а второй используется для трекинга сохранения “грязных” страниц. Каждый пользовательский поток пишет лог событий в свою локальную микро транзакцию. По завершению транзакции вызывается mtr_commit . Этот метод сначала резервирует место в общем log buffer, вызывая log_buffer_reserve . Далее поток ожидает когда нужная позиция в кольцевом буфере будет доступна для записи и после этот копирует [src] лог из m_log потока в log buffer. В этом месте непрерывный поток байт лога группируется в log блоки по 512 байт. По окончанию записи, пользовательский поток атомарно добавляет link в recent_written buffer. Link это запись в recent_written buffer по offset-у LSN, содержащая значение LSN+size(mtr). Эти ссылки будет использовать поток log-writer, чтобы найти данные, которые были успешно записаны в log buffer и готовые к записи на диск. Микро транзакции кроме лога redo-событий отслеживают какие страницы с данными были изменены и при коммите этот список должен попасть [src] во flush list Buffer Pool-а. Для этого сначала ожидается место в recent closed buffer, потом страницы добавляются в buffer pool dirty page list, и после этого в recent closed buffer добавляется link от LSN до LSN+size(mtr) . Этот link будет дальше использоваться для определения LSN на который можно освобождать место в log buffer. До версии 8.0.30 InnoDB по-умолчанию создавал два redo log файла: ib_logfile0 и ib_logfile1. Запись велась в эти файлы по кругу. При таком подходе InnoDB избегал накладных расходов  создания файлов. Для поддержки динамического изменения размера redo log, MySQL начиная с версии 8.0.30 пишет redo log в постоянной создаваемые последовательно названные файлы (по умолчанию - InnoDB держит 32 файла размером innodb_redo_log_capacity / 32) - часть из них пустые (spare), готовые к использованию, остальные уже с mtr-записями. Такой подход позволяет после смены настройки innodb_redo_log_capacity начать создавать файлы уже нового размера. Кроме того, такой подход позволяет вытеснять из кэша старые файлы (некоторые файловые системы не спешат вытеснять из своего кэша большие файлы если в них пишут [src]) log-writer [src] определяет какие блоки в log buffer готовы к записи на диск. Для этого он обходит путь, образующийся из ссылок в recent_written buffer  и останавливается когда встречает незаполненую ссылку. В этот момент он понимает что дальнейшие данные еще не готовы для записи на диск. Для каждого блока обновляются header и footer: например, заполняется checksum и data len. Если последний блок не полный - например, если другие пользовательские потоки не записали достаточно данных - блок все равно будет записан, но пространство блока без данных должно быть обнулено [src]. Для этого последний блок будет скопирован во write-ahead buffer, и уже там пустое пространство блока будет обнулено. Таким образом log writer не затрет данные другого потока. “read-on-write” issue — это явление, когда операционная система вынуждена прочитать блок файловой системы при записи на диск. Делается это в случае, когда пишутся данные размером меньшим блока файловой системы. Если в page cache операционной системы не оказывается нужного блока, ОС вынуждена прочитать блок с диска, чтобы модифицировать его и записать на диск. Чуть подробнее об этом: https://zhuanlan.zhihu.com/p/61002228 InnoDB использует знание что redo log никогда не читается и поэтому можно обойти read-on-write issue если писать блоками кратными размеру блока файловой системы. Поток log writer пишет все доступные блоки redo log-а (по 512 байт), а если их недостаточно - добавляет write ahead данные (нули) [src]. Благодаря этому получается избежать паразитных чтений с диска. DBA могут указать размер блока файловой системы, выставив настройку  innodb_log_write_ahead_size. Затем log writer пишет данные на диск - напрямую из log buffer или из write-ahead buffer. После успешной записи, полные боки добавляются в очередь redo log archiver-а, откуда по запросу утилит создания физического бэкапа могут быть сохранены в отдельном каталоге. Продвигается write_lsn и все заинтересованные потоки уведомляются об успешном окончании записи. Поток log write_notifier [src] уведомляет все пользовательские потоки когда write_lsn >= lsn Поток log flush_notifier [src ] уведомляет все пользовательские потоки когда flushed_to_disk_lsn >= lsn Пользовательские транзакции могут ожидать уведомлений об успешной записи на диск от одного из этих потоков в зависимости от настройки innodb_flush_log_at_trx_commit. Fun Fact: MySQL начиная с версии 8.0.21 позволяет отключить redo log и double buffer командой ALTER INSTANCE DISABLE INNODB REDO_LOG. Что может значительно ускорить первоначальную загрузку данных. InnoDB должен в конечном счете сохранять “грязные” страницы из Buffer Pool на диск. Этот процесс называется flushing-ом страниц. Этим занимается специальные фоновые потоки (innodb_page_cleaners). Потоки помнят максимальный LSN страниц, который они записали на диск. Этот LSN называется Checkpoint. Поток занимающийся flushing-ом “грязных” страниц из Buffer Pool на диск продвигает “хвост” redo log-а, тем самым освобождая место под новые записи. Для того чтобы можно было логически удалить (отбросить) запись из redo log с LSN = x надо убедиться, что все страницы с LSN ≤ x записаны (flushed) на диск. Иначе, в случае crash recovery мы не сможем “проиграть” redo log. Checkpoint Age - это разница (в байтах) между head LSN и end LSN redo log-а. Redo log  ограничен в размере и в случае когда запись в redo log происходит быстрее, чем flush - есть риск что в кольцевом буфере Head догонит End  и тогда всю запись надо будет остановить до тех пор, пока страницы не будут сброшены на диск и end LSN не будет увеличен. Это отличное свойство напоминает механизм back-presasure между записью и flush-ем! Но InnoDB идет еще дальше и вводит мягкий порог async flush point (75%) - при котором база пытается отдавать flush-у страниц как можно больший приоритет (производительность базы заметно падает) и sync flush point (87.5%) - InnoDB перестает добавлять новые записи в Redo Log (производительность базы деградирует еще сильнее). Архитектура MySQL поддерживает различные storage engines и умеет реплицировать эти изменения на другие узлы. Для того, чтобы вся эта красота переживала crash, MySQL должен уметь поддерживать binlog  и redo log всех движков в согласованном состоянии. MySQL использует XA (eXtended Architecture) для поддержания ACID для транзакций использующей различные (XA-capable engines). XA под капотом использует two-phase-commit [wiki] - когда запись производится в два этапа: prepare и commit. XA состоит из Transaction Manager, который управляет Resource Manager-ами. Transaction Manager назначает XID каждой транзакции.  В MySQL код отвечающий за запись бинлога является еще и transaction manager-ом. А Storage Engines являются Resource Manager-ами. Когда клиент выполняет COMMIT - MySQL сервер начинает работать как координатор транзакций. Сначала вызывается MYSQL_BIN_LOG::prepare [src], который прости каждый storage engine закоммитить ресурсы в транзакцию и убедиться что транзакции не может завершиться ошибкой. В InnoDB вызывается   innobase_xa_prepare [src] где InnoDB убеждается что транзакция полностью записана в redo log и redo log сброшен на диск. В этот момент транзакция считается “prepared”. Если MySQL упадет в этот момент - транзакция будет откачена. После получения подтверждения от всех движков задействованных в транзакции MYSQL_BIN_LOG::commit [src] будет вызвана, и транзакции будут записаны в бинлог. Только после этого движкам хранения разрешается закоммитить изменения. В InnoDB вызывается  innobase_commit [src], который коммитит транзакции. Что же случится если MySQL упадет после записи в binlog и до вызова innobase_commit? В этом случае при старте MySQL обнаружит что binlog не был safely closed. MySQL соберет список всех id-ников XA транзакций (Xid) из бинлога и попросить все storage engines закоммитить транзакции (если они еще не были закомичены). Это безопасно, т.к. MySQL уже получил подтверждение от storage engines, что redo log надежно записан. Для достижения надежной и быстрой записи на диск создателям баз данных приходится прикладывать огромные усилия и ухищрения. За недостатки существующего мира приходится расплачиваться бОльшим количеством записей на диск. Amazon уверяет что на одну транзакцию уходит до 7.4 IO-операций. На примере MySQL мы видим как одинаковые подходы применяются для ускорения операций записи: Batching (group commit) позволяет группировать группы транзакций и за один IO записывать несколько транзакций. Этот подход кратно повышает производительность базы данных. Предпочетать последовательную запись - binlogs, redo log, doublewrite buffer - все пишется последовательно. Облачные вендоры использую полный контроль над железом и софтом пытаются выжать максимально-возможное из MySQL: инженеры из Google героически патчат ядро и драйвера [video], чтобы сэкономить часть IO на doublewrite buffer Amazon Aurora MySQL полностью пересматривают архитектуру базы данных - выбрасывают из hot-path практически весь IO, кроме записи redo log тем самым (на hot-path) приходится в 6 раз меньше IO-операций