Это вторая часть моих копаний во внутренностях MySQL. В первой части [habr] были затронуты запись страниц данных на диск (с промежуточной записью в DoubleWrite buffer) и запись бинлогов (с батчингом в виде group commit). В этой части я расскажу про redo log и как все части MySQL координируются для достижения надежной работы.
Оглавление
Double Write buffer и Binlogs [https://habr.com/ru/post/684474/]
Redo logs и общая картина [эта статья]
Disclaimer: автор не является разработчиком MySQL, все нижеописанное может не совпадать с реальным положением дел.
Часть 3: Redo logs и Checkpointing
Одна пользовательская транзакция может изменить различные строки в разных страницах с данными. Но как мы уже знаем из первой половины статьи, невозможно атомарно записать несколько страниц на диск. Для создания видимости атомарной записи 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 проходит следующие этапы:
В памяти создаются 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 и данные можно писать на диск, а второй используется для трекинга сохранения “грязных” страниц.
Запись в 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
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 и flush_notifier
Поток 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. Что может значительно ускорить первоначальную загрузку данных.
MySQL Checkpoint Age
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 (производительность базы деградирует еще сильнее).
Часть 4: Собираем все вместе
Архитектура 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-операций