InnoDB 的 Redo Log 分析
- Redo Log
- redo log 的数据结构
- mini-transaction
- redo log buffer
- redo log 模块的线程
- recent_written 与 recent_closed 的作用
- redo log 中 Record 的格式
- Q & A
Redo Log
MySQL 版本: 8.0.15
数据库系统在运行期间, 对于一个事务中的每一个 SQL 操作都不是瞬时能完成的. 当操作涉及数据的修改时, 意味着数据的一致性状态在发生变迁. 为了保证数据变化过程中的原子性, 需要记录每一次数据操作, 而 redo log 用来记录每次数据操作,用于 Crash 之后做 Recover 恢复操作,而每一条 redo log 都是由 mini-transaction 原子提交的.
在数据库运行期间, redo log 被要求先于数据页到达磁盘, 这样才能在系统故障发生后, 在系统恢复阶段保证事务的原子性.
redo log 的数据结构
这并不是单个 redo log 的数据结构,而是管理 redo log 元信息,redo log buffer 等操作的系统单元, 具体的对象为 log_sys
.
1 | include/log0types.h |
mini-transaction
mini-transaction 具体流程
1 | mtr_t mtr |
mini-transaction 的数据结构
1 | struct mtr_t { |
其中m_memo
中元素是mtr_memo_slot_t
, 记录加锁的对象和加锁的类型.
1 | /** mini-transaction memo stack slot. */ |
mini-transaction 的start()
1 | /** 启动一个 mini-transaction. */ |
不同的 mini-transaction 如何互斥?
在操作数据前,会根据锁类型,加不同类型的锁,之后将object
和锁类型存入m_memo
:
1 | mtr_memo_push(mtr, object, type); |
mini transaction commit 完成之后调用release_latches(RELEASE_ALL)
将数据上的锁释放.
mini-transaction 插入数据
byte *mlog_open(mtr_t *mtr, ulint size)
: 打开mtr
的m_log
mlog_write_initial_log_record_low()
函数向m_log
中写入type
,space id
,page no
,并增加m_n_log_recs
的数量mtr->get_log()->push()
按不同的类型写数据mlog_close()
: 更新 m_log 中的位置
mini-transaction 的 commit 过程
commit 过程将 mini-transaction 的m_log
数据拷贝到 redo log buffer 中, m_state
设置为MTR_STATE_COMMITTING
后,具体流程是调用 mtr_t::Command::execute()
:
mtr_t::Command::execute()
prepare_write()
: 根据 mtr 的类型m_impl->m_log_mode
, 计算 redo log 的长度. 假如 redo log 记录数目n_recs
为 1 时,设置Flag 为MLOG_SINGLE_REC_FLAG
, log记录不止一条时,Flag置为MLOG_MULTI_REC_END
.假如 redo log 的长度不为0时,
log_buffer_reserve()
:自增
log_sys
中的全局sn
, 由sn_lock
锁保护.sn
是一个全局维护的递增序列号, 具体含义是不包括 redo log Block 头部和尾部的序列号.获得 handler,计算写入 redo log 的
start_lsn
和end_lsn
,即实际写入的数据大小,lsn
代表包括LOG_BLOCK_HDR_SIZE和
LOG_BLOCK_TRL_SIZE`的 redo log 序号, 而 sn 仅考虑 redo log 数据内容部分. 转换关系如下:1
2
3
4constexpr inline lsn_t log_translate_sn_to_lsn(lsn_t sn) {
return (sn / LOG_BLOCK_DATA_SIZE * OS_FILE_LOG_BLOCK_SIZE +
sn % LOG_BLOCK_DATA_SIZE + LOG_BLOCK_HDR_SIZE);
}假如需要扩展 redo log buffer 的空间长度, 即
end_lsn
大于sn_limit_for_end
.log_wait_for_space_after_reserving()
会进行扩展以及一系列的参数检查.因为全局的 redo log buffer 是环形的,假如待写的 redo log 长度超过了目前 redo log buffer 的剩余空闲长度则会出现回环后的覆盖写的问题,所以需要
log_wait_for_space_in_log_buf(log, start_sn)
触发写入部分长度的 redo log, 以确保这条 redo log 能完整的写入 redo log buffer,而且回环后不会覆盖尚未写入磁盘的 redo log.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29void log_wait_for_space_in_log_buf(log_t &log, sn_t end_sn) {
lsn_t lsn;
Wait_stats wait_stats;
/* 当前已经写入文件的 sn.*/
const sn_t write_sn = log_translate_lsn_to_sn(log.write_lsn.load());
LOG_SYNC_POINT("log_wait_for_space_in_buf_middle");
/* redo log buffer 的长度转换为 sn 后的长度. (去掉 Header 和 tailer). */
const sn_t buf_size_sn = log.buf_size_sn.load();
if (end_sn + OS_FILE_LOG_BLOCK_SIZE <= write_sn + buf_size_sn) {
return;
}
/* We preserve this counter for backward compatibility with 5.7. */
srv_stats.log_waits.inc();
lsn = log_translate_sn_to_lsn(end_sn + OS_FILE_LOG_BLOCK_SIZE - buf_size_sn);
/* 等待 lsn 之前的 redo log 被写入. */
wait_stats = log_write_up_to(log, lsn, false);
MONITOR_INC_WAIT_STATS(MONITOR_LOG_ON_BUFFER_SPACE_, wait_stats);
ut_a(end_sn + OS_FILE_LOG_BLOCK_SIZE <=
log_translate_lsn_to_sn(log.write_lsn.load()) + buf_size_sn);
}这里可能会和 redo log buffer 允许空洞产生歧义,需要注意的是 redo log buffer 允许的空洞是
write_lsn
之后的 redo log buffer 允许空洞,现在的情况是因为一条 redo log 的长度超过了 redo log buffer 的剩余长度需要回环,所以在此之前的 redo log 必须保证写入完成.log_write_up_to()
需要 wait 在log_t
中的write_events
. 当log.write_lsn.load() >= lsn
, 即对应于 redo log buffer 中的 slot 的 redo log 已经完成了写入并被唤醒.对于长度大于当前整个 redo log buffer 的 redo log, 需要调用
log_buffer_resize_low()
来 Resize 设置 redo log buffer 的长度, 过程是释放旧长度的 redo log buffer 空间,重新分配新长度的 redo log buffer 空间,并且重新拷贝 redo log 内容.对m_log中的每一个 512 字节的 Block 调用
mtr_write_log_t()
(需要注意的是mtr_write_log_t()是运算符()
的重载)log_buffer_write()
使用memcpy()
写 redo log buffer.log_buffer_write_completed()
更新log_t
中的recent_written
,即(start_lsn
,end_lsn
)组成的list
.
调用
add_dirty_blocks_to_flush_list()
将产生的脏页 dirty page 插入 Buffer Pool 中的flush_list
.
log_wait_for_space_in_log_recent_closed()
查看recent_closed
链表是否存在符合该 redo log 规则的 space.假如 redo log 的长度为0时:
- 直接调用
add_dirty_blocks_to_flush_list()
.
- 直接调用
add_dirty_blocks_to_flush_list()
:假如产生了 redo log,则将数据页的
newest_modification
修改为end_lsn
.假如该 Block 是第一次被修改,就需要插入 Buffer Pool 的
flush_list
. 将涉及修改的数据页添加到 Buffer Pool 的flush_list
(buf_flush_insert_into_flush_list()
).(利用block->page.oldest_modification
来判断是否为第一次修改)
log_buffer_close()
: 将对应的 Page 插入 Buffer Pool 中的flush_list
后更新log_t
中的recent_closed
链表.release_resources()
释放资源, 将m_state
置为MTR_STATE_COMMITTED
redo log buffer
redo log buffer 是一段内存区域用来存放需要写入ib_logfile
的数据. redo log buffer 的大小buf_size
可以通过innodb_log_buffer_size
来控制, 默认16MB.
redo log buffer 的 resize 过程
redo log buffer 是我们通常所说的回环 Buffer, 而在 Resize 的过程中将log.write_lsn
和end_lsn
直接的 redo log 拷贝至一个临时的 Buffer,然后新建一个new_size
的 Buffer, 将tmp_buf
的数据原路拷贝.
redo log 模块的线程
log_writer
完成 redo log buffer 的写入, 即写入ib_logfile
文件. (log/log0write.cc
)
log_writer
线程 wait 在一个限定的 condition,即直到满足log.write_lsn.load() < log.recent_written.tail()
时调用log_writer_write_buffer()
进行 redo 写入. 指定的condition
函数会递增log.recent_written.tail
.log.write_lsn
代表当前写入的 lsn 位置,log.recent_written.tail()
返回的是 redo log buffer 中最大的不存在空洞的 lsn.具体写入流程在
log_files_write_buffer()
, 首先计算写入在文件的真实偏移:1
2
3/* start_lsn为该次写入的起始lsn */
const auto real_offset =
log.current_file_real_offset + (start_lsn - log.current_file_lsn);计算目前的
ib_logfile
文件是否有足够的空间满足该条 redo log 的写入:假如目前的
ib_logfile
已经写满,则需要调用start_next_file()
直接切换下一个文件.假如目前的
ib_logfile
还拥有空闲的空间,则需要将 redo log 分两次写入,但本次写入仅填充目前的ib_logfile
的剩余空间.
MySQL8.0 在写入 redo log 的过程中引入了
write ahead buffer
避免小于 512 bytes 的 IO 造成read on write
现象:write_blocks()
来调用fil_redo_io()
来完成文件写入(写入操作系统的Page Cache), 每次写入都是512 Bytes对齐(OS_FILE_LOG_BLOCK_SIZE
)更新
log.write_lsn
调用
notify_about_advanced_write_lsn()
唤醒对应 slot 正在 wait 的线程, 这里其实就是 mini-transaction 的 commit 阶段写入 redo log buffer 中,需要等待log.write_lsn.load() >= lsn
的部分.唤醒 redo log 的 Flusher 线程(
os_event_set(log.flusher_event)
)
上图表示 redo log buffer,这里需要考虑的是log.recent_written.tail()
也是由log_writer
线程来更新的,因为 mtr 的 commit 过程根据lsn
计算拷贝至 redo log buffer 的位置,这里是允许空洞的,所以为了保证 Flush 至文件时能 Batch 无空洞写入,这里使用log.recent_written
的tail
来保证在tail
之前的 redo log buffer 是不存在空洞的.
write_ahead_buffer
引入write_ahead_buffer
的目的是为了避免小 IO 造成的read-on-write
. Linux 下的写操作会先将对应的文件内容读入 Page Cache, 修改完成后,通过 fsync 来决定是否持久化至文件. 为了避免读取至 Page Cache 的这次 IO, InnoDB 使用write-ahead buffer
来解决这个问题,这个策略的前提条件是: a.写入的目的偏移是 Page 对齐, b.写入的大小是 Page 大小的的整数倍. 在这样的前提条件下, 文件系统可以避免一次 IO 读.
write-ahead buffer
的核心代码如下: 通过compute_how_much_to_write()
首先判断是否使用write_ahead_buffer
:
1 | /* write_from_log_buffer 为 bool 变量,记录后续 log_writer 的写操作需要将 redo log 拷贝至 |
上图举例write_ahead_buffer
的一次写入过程: 假如需要在real_offset
即34274304
位置开始写入buffer_size
为437
大小的数据,假定目前write_ahead_buffer
已经被上一次的写入写满,所以本次写入需要重新滑动.
将
real_offset
向下取整srv_log_write_ahead_size
求得last_wa
和next_wa
的位置, 即last_wa
和next_wa
的区间为本次的write_ahead_buffer
.将数据
buffer
后面的部分均以0x00
填充直到next_wa
, 即本次写入的数据为437 + 75 + 512 = 1024
大小。75 + 512
的部分为log_writer
的预写(write ahead).write_ahead_buffer
重新滑动后,写入完成后会更新log.write_ahead_end_offset
:
1 | static inline void update_current_write_ahead(log_t &log, uint64_t real_offset, size_t write_size) { |
所以log_writer
线程的每次写入都是OS_FILE_LOG_BLOCK_SIZE
对齐写入,并且大小不会超过srv_log_write_ahead_size
.
log_flusher
将 redo log buffer 中的日志进行 Flush, 这里进行的是 redo log 的刷脏,与数据脏页的 Flush 无关,数据脏页的 Flush 由 Buffer Pool 刷脏线程处理.
log_fluser
根据srv_flush_log_at_trx_commit
来选择不同的wait方式:- 假如
srv_flush_log_at_trx_commit=1
即1
os_event_wait_time_low(log.flusher_event, flush_every_us - time_elapsed_us, 0);
- 否则:
1
const auto wait_stats = os_event_wait_for(log.flusher_event, max_spins, srv_log_flusher_timeout, stop_condition);
- 假如
假如
last_flush_lsn < log.write_lsn.load()
,即需要进行刷盘.fil_system->flush_file_redo()
进行文件刷盘.更新
log.flushed_to_disk_lsn
.唤醒 wait 在该
slot[last_flush_lsn, flush_up_to_lsn]
的用户线程.
log_closer
更新log.recent_closed
的tail
.
log_checkpointer
进行 checkpoint 的线程
更新
available_for_checkpoint_lsn
, 即目前可以安全进行 checkpoint 的lsn.扫描所有 Buffer Pool 的
flush_list
,获取最旧的一条 redo log 的 lsn(bpage = UT_LIST_GET_LAST(buf_pool->flush_list)
. 这里最旧的 lsn 并不代表 lsn 是最小的,因为插入flush_list
是允许并发插入的.flush_list
中最旧的 lsn 减去recent_closed
的长度,然后与上次 checkpoint 的 lsn 进行比较, 选较大的lsn_t lwm_lsn = (std::max(checkpoint_lsn, lsn - lag))
.与
log.recent_closed.tail
比较(const lsn_t dpa_lsn = log.recent_written.tail())
,选较小的(lwm_lsn = std::min(lwm_lsn, dpa_lsn)
.与
log.flushed_to_disk_lsn
比较,选较小的(std::min(lwm_lsn, flushed_lsn))
.更新
log.available_for_checkpoint_lsn
.计算
current_lsn
, 与log.available_for_checkpoint_lsn
比较,假如在此期间又有脏页被刷入flush list
, 则一并进行预 Flush. 之后更新log.available_for_checkpoint_lsn
.检查是否需要checkpoint
log_checkpoint(log)
进行 checkpoint, 其中就是调用接口将 checkpoint 的信息写入指定的文件.
recent_written 与 recent_closed 的作用
recent_written
- MySQL 8.0 通过直接计算每一条 redo 在 redo log buffer 的 offset 来并发插入 redo log buffer, 所以这里是允许 redo log buffer 存在空洞的,而写入
ib_logfile
不允许,所以利用recent_written.tail
来保证在此 lsn 之前的 redo log buffer 是不存在空洞的,从而完成ib_logfile
的完整写入.
recent_closed
- 为了能安全的进行 checkpoint, 需要选择一个数据已经被 Flush 的 redo log 的lsn, 因为可以并发的将脏页插入 Buffer Pool 中的
flush_list
, 所以选择所有 Buffer Pool 的flush_list
中头部最小的一个 Dirty Page 的 lsn, 再与log.recent_closed.tail()
比对选择一个较小的 lsn, 可以认为是一个安全的checkpoint_lsn
.log.recent_closed
记录着并行插入flush_list
的 Page lsn.
redo log 中 Record 的格式
MLOG_REC_INSERT 的 redo 格式
Q & A
- redo log 文件大小设置过小造成性能抖动?
假如 redo log 文件大小设置过小,会导致 redo log 文件的空闲空间不足, 造成频繁的 checkpoint, 而 checkpoint 的推进前提是对应 Buffer Pool 的脏页已经完全落盘,所以 redo log 的落盘会间接推动脏页的 Buffer Pool 的落盘, 从而加剧系统的 IO,可能造成性能抖动.