InnoDB 作为目前 MySQL 的主要存储引擎,其中 record 细节信息繁琐,这里仅做整理以便查阅. 版本 MySQL-8.0.25.
1 | /** Structure for an SQL data tuple of fields (logical record) */ |
MySQL SQL 层的 record 可以通过row_sel_convert_mysql_key_to_innobase()
转换为 InnoDB 可识别的dtuple_t
结构.
index->fields: 记录当前索引 column 的描述信息, 列名,长度, 顺序 or 倒序
对于主键索引 leaf node:
对于主键索引 non-leaf node:
对于二级索引 leaf node:
对于二级索引 non-leaf node:
使用dict_index_build_node_ptr()
构建 non-leaf node.
offsets 数组由rec_get_offsets()
, 数组大小由 n_fields + 1 + REC_OFFS_HEADER_SIZE 决定.
rec_t
可以直接通过cmp_dtuple_rec_with_match_low()
与dtuple_t
比较:
rec_t
可以通过 offsets 数组分别获取对应的 filed 字段, 再与((dfield_t *)tuple->fields + n)
直接进行比较.
btr_pcur_t
是在 search 或者 modify 过程中用来定位的游标, 其中记录定位信息, 可以直接通过store_position()
来保存,通过restore_position()
可以直接恢复上一次保存的位置信息.
1 | struct btr_pcur_t { |
store_position()
保存位置信息, 并释放 page 的 mutex, restore_position()
先尝试乐观加锁,即直接判断m_modify_clock
是否变化,假如 b+ tree 发生了 SMO, 需要进行悲观加锁的方式,即通过btr_cur_search_to_nth_level()
重新 search 加锁.
store_position()
会记录buf_block_t
, 在乐观恢复中直接通过尝试对buf_block_t
加锁,当前的 Buffer Pool 支持动态 resize, 这部分的内存可能会被释放, 所以 InnoDB 会首先判断这个buf_block_t
指针是否存在于 Buffer Pool 的 chunk 中:
1 | void Block_hint::buffer_fix_block_if_still_valid() { |
MySQL 8.0.25
数据库内核月报 InnoDB 事务锁系统简介对 InnoDB 的事务锁系统: record lock 和 table lock 做了具体的介绍, 而 InnoDB 事务 sharded 锁系统优化 介绍了 MySQL 官方团队针对 InnoDB 事务锁系统进行的拆分优化. InnoDB 采用 2PL + MVCC 的并发控制方式, 以此来提高读写性能. 两阶段加锁(2PL)将事务锁的申请与释放拆为两步: 1.在事务过程中统一加锁, 2. 在事务提交或回滚后统一放锁, 除非事务提交或者回滚, 否则不会在事务的中间状态释放锁. 所以在事务申请 lock 的过程中, 需要判断是否与其他事务持有的 lock 冲突, 对于冲突情况需要进入 waiting 队列, 而在持有 lock 的事务提交或者回滚之后, 都会释放持有的事务锁, 从而选择等待队列里的事务进行 grant lock. 选择合适的等待事务可以有效的提高事务的并发性能, 所以事务锁调度算法的关键是如何选择合适的等待事务. 当存在多个事务请求同一个对象的锁时, 哪个事务, 或者哪些事务应当最先获得锁?
在 8.0.3 之前的 MySQL 版本, 采用的是 FCFS 的调度算法, 原理也相对简单. 在事务执行阶段向对应的 record 进行加锁行为, 通过 lock_sys 记录的 record lock 来判断是否存在冲突, 因为两阶段加锁的限制, 对于冲突的 lock 我们将其放入等待队列, 当持有的事务提交或者回滚时, 逐一释放其持有的 lock时, 会检查相应的等待队列,并按 FCFS 顺序检查是否可以将锁授予等待事务.
CATS 的全称是 Contention-Aware Transaction Scheduling (竞争感知), 在 MySQL 8.0.20 开始已经作为默认的事务调度算法, 不仅仅只在低冲突场景才会使用. 事务锁调度最常见的策略就是 FCFS 策略, 先到先得, 这种朴素的调度策略实现也较为简单, 但存在的问题是例如某个等待事务持有较多的 lock 并且阻塞了其他的事务的进行,但因为先到先得的策略无法立即获得 lock, 从而致使整个数据库的 TPS 减慢. 这是 FCFS 策略无法解决的问题, 所以我们最好对事务本身进行感知, 比如所有事务的等待关系等. CATS 相关的论文有两篇: Identifying the Major Sources of Variance in Transaction Latencies: Towards More Predictable Databases, Contention-Aware Lock Scheduling for Transactional Databases.
论文[Contention-Aware Lock Scheduling for Transactional Databases]介绍了几种调度策略, 并逐步引申出 CATS 算法.
在 FCFS 策略后, 我们可以讨论以锁持有的数量来判断优先级, 例如下图:
事务 t1 和事务 t2 都在等待对象 O1 的锁, t1 事务本身持有的锁数量是 4 个, 而 t2 事务持有的锁数量是 2 个, 假如以”锁持有的数量”为标准, 那事务 t2 应该获得 lock, 但在事务的等待关系中, 有 3 个事务等待在 t2 上,而仅有 1 个事务等待在 t1.
假定以等待事务阻塞事务数量来判断优先级, 例如下图:
事务 t1 和事务 t2 都在等待对象 O1 的锁, t1 事务持有的锁只有一个阻塞了事务 t3, 而 t2 事务持有的锁却阻塞了两个事务, 假如以等待事务阻塞的事务数量来判断优先级, O1 的锁会被授予 t2, 但需要注意的是 t3 事务却阻塞了 3 个其他事务. 所以假如我们想提高事务的并发度, 最好的选择是将 O1 锁授予 t1.
假定以等待事务关系图的深度来判断优先级, 例如下图:
虽然 t1 事务有更深的依赖关系, 而 t2 事务同时阻塞两个事务, 但假如将锁授予 t1, 势必影响整个 DB 的事务并发度.
真正的事务等待关系应该是有向图, 所以计算权重不应该考虑子树, 而是子图. 所以最后提出了一种 Largest-Dependency-Set-First (LDSF) 的算法, 根据计算等待事务所有的等待关系权重来决定锁的调度优先级.
InnoDB 根据 LDSF 在原有的事务锁基础上实现了基于竞争感知的事务锁调度算法, 主要两个 patch 分别是 WL#10793: InnoDB: Use CATS for scheduling lock release under high load, WL#13468: Improved CATS implementation.
MySQL 8.0.18 版本针对死锁检测进行了优化, 将原先的死锁检测机制交由 background thread: lock_wait_timeout_thread() 来处理, 思路是将当前的事务锁 lock 信息打一份快照, 由这份快照判断是否存在回环, 假如存在死锁即唤醒等待事务. 因为这个过程可以感知所有的锁等待关系, 所以 InnoDB 也基于这份快照来计算权重.
lock_wait_timeout_thread 线程除了检查等待超时以外, 也会更新全局等待事务的权重和死锁检测, 具体的函数是lock_wait_update_schedule_and_check_for_deadlocks()
:
1 | static void lock_wait_update_schedule_and_check_for_deadlocks() { |
在获取了所有的等待事务关系图后,需要根据其阻塞的事务数量开始计算权重, 过程如下:
1 | /* WEIGHT_BOOST 设置成等待事务的数量或者 1e9. */ |
lock_wait_compute_incoming_count(): 更新事务等待关系图中的入度情况, 即一个事务阻塞了多少个事务.
lock_wait_accumulate_weights(): 计算每个等待事务的权重, 其策略是累加等待事务阻塞的事务权重, 例如事务 t1 阻塞了事务 t2, t3, t5, 则 t1 事务的权重为:
1 | t1_weight = t1_weight + t2_weight + t3_weight + t5_weight; |
事务在提交或者回滚之后都会释放其持有的 lock: lock_release()
. 将其持有的锁授予哪个事务的顺序是, 第一顺位是高优先级的事务, 其次是事务的权重排序, 权重为 1 或者 0 ( lock.schedule_weight 的默认值)的事务依照 FCFS 的顺序.
本文介绍了 InnoDB 在锁调度策略的最新优化, 该算法在锁冲突严重的场景效果明显, 计算权重的重要参考指标是等待事务的等待时间 (lock_wait_table_reservations) 和其阻塞的事务权重之和. InnoDB 目前的实现没有区分读/写事务, 例如当多个读事务等待同一个锁, 选择读事务较多的子图, 可以有效的提高事务并发度. 关于 CATS 的策略方面后续可以加入更多的指标, 在计算的复杂度和判断的有效性采用折中的方案, 既不影响权重的计算, 也有效的提高数据库的事务并发度.
]]>innodb_old_blocks_pct: 在 Buffer Pool 的 LRU list 中 old 部分所占的比例.
innodb_old_blocks_time: 当一个 Page 距第一次被访问的时间大于等于 innodb_old_blocks_time 时,再次被访问的时候,会被移动到 LRU list 的头部.
InnoDB 的 Buffer Pool 使用 LRU 算法管理数据 Page, 为了防止全表扫描或者范围查询造成对 LRU 链表的污染, InnoDB 将 LRU 分为两个部分: young / old :
young 区域代表经常访问的数据 Page.
old 区域代表不常访问的数据 Page.
上图显示了 Buffer Pool 的布局.
5/8 的 “young” 区域和 3/8 的 “old” 区域划分是参数 innodb_old_blocks_pct 的默认值 37 决定的,这个参数可以动态调整.
InnoDB 在启动时针对 Buffer Pool 进行初始化, 完成 Buffer Pool 的初始化后使用 100 * 3 / 8 = 37 来调整 LRU list 的 young 和 old 的区域.
当我们需要从从 Buffer Pool 中读取一个 Page, 并且这个 Page 需要从文件中进行读取时buf_page_init_for_read()
, 我们会从 Buffer Pool 中申请一个 Free Page, 之后需要插入 LRU 的 old 的头部区域buf_LRU_add_block()
, 即 old->head:
1 | 新读取的 Page 插入位置 |
LRU 区分了 young 和 old 区域,所以需要适时的将 old 区域的 Page 根据需求移动至 young 区域, 操作过程也比较简单,直接从 LRU 的 old 区域摘除然后插入 young 区域即可buf_page_make_young()
:
以下是插入 LRU young 区域的时机:
btr_search_guess_on_hash():
buf_page_optimistic_get():
buf_page_get_known_nowait():
Buf_fetchbuf_page_get_gen()
且 mode 不是 Page_fetch::SCAN 和 Page_fetch::PEEK_IF_IN_POOL 这两种的都会将 Page 插入 LRU list 的 young 区域.
Buffer Pool 的容量是有限的,为了用户的写入读取能获取 Free Page, Buffer Pool 要不停的从 LRU list 置换 “old” Page: 策略是从 Buffer Pool 的 old list 的尾部扫描合适的 Page 换出.
1 | LRU evict 起始位置 |
以下是 LRU evict 数据 Page 的时机:
buf_page_io_complete(): 当从 LRU list 刷脏完成后,会将 Page 从 LRU list 中移除.
buf_flush_LRU_list_batch(): 扫描 LRU list 时,将满足条件的 Pagebuf_flush_ready_for_replace()
换出.
buf_flush_single_page_from_LRU(): 当用户需要获取空闲 Page 而 LRU List 暂时没有 Free Page 时, 会选择一个 Page 直接换出buf_flush_ready_for_replace()
或者buf_flush_ready_for_flush()
刷入磁盘.
当一个 Page 从 disk 读入 Buffer Pool 后, 先插入 old 区域起始位置, 后续的非 scan mode 的读则会调整插入 young 区域. 在 young 区域的 page 假如再次被读到,会通过buf_page_peek_if_young()
判断是否接近被 evict, 否则在 young 中是不会调整 page 的顺序的.
MySQL 版本: 8.0.25
数据库系统中关于事务有 4 个重要特性 ACID, 其中 A 代表的原子性: 一个事务必须被视为一个不可分割的最小工作单元,整个事务中的所有操作要么全部提交成功,要么全部失败回滚,对于一个事务来说,不可能只执行其中的一部分操作,这就是事务的原子性. 对于 InnoDB 来说, 针对意外崩溃情况,也需要保证事务满足原子性,即在崩溃前提交的事务需要保证重启后可读, 尚未提交的事务需要正确的回滚.
关于 Redo Log 在之前的文章 InnoDB 的 Redo Log 分析已经详细介绍过, InnoDB 利用 Redo Log 来记录所有的数据和其他的文件操作. InnoDB 在对应操作的 Redo Log 落盘后就会给用户返回操作成功, 此时对应的数据 Page 可能还在 Buffer Pool 中尚未落盘, 这里可以加快的写入的速度, 但也需要在意外崩溃后能使数据库的数据 Page 恢复到一个正确的状态.
InnoDB 使用 MVCC + Undo Log 来实现不同的事务隔离级别, 在数据库正常的运行时,用户可以通过 Undo Log 来在不同的隔离级别下读取相应正确的数据, 其中在意外崩溃后,InnoDB 需要使用 Undo Log 来回滚尚未提交的事务.
1 | mysqld_main() -> init_server_components() -> |
在 MySQL 8.0 新建了一个独立的线程log_checkpointer
来执行 Checkpoint 任务, 当 InnoDB 执行一次 Checkpoint 时, 会将指定 lsn 位置的数据 Page 刷入磁盘, 这就保证了在此 lsn 之前的数据均以持久化. log_checkpointer
在执行 Checkpoint 后会写入 Checkpoint 信息至ib_logfile0
, InnoDB 设计在 offset 512 bytes 和 1536 bytes 轮流写 Checkpoint 信息,防止某次写入 Checkpoint 失败导致故障恢复无法找到上次的位点.
当 MySQL 启动后,无论之前是否发生 crash 都会尝试进行 recover (recv_recovery_from_checkpoint_start()
):
读取 Checkpoint 信息,找到记录的最新的 Checkpoint (recv_find_max_checkpoint()
).
将 Checkpoint 之后的 Redo Log 重新进行 apply, 保证数据 Page 的正确性 (recv_apply_hashed_log_recs()
).
针对不完整的 mtr 的 redo log 情况下:
InnoDB 针对 Undo Tablespace 的回滚段进行事务的重建(trx_sys_init_at_db_start() --> trx_rsegs_init()
).
重建回滚段后恢复当前事务列表(trx_lists_init_at_db_start()
). (事务信息记录在回滚段中的 undo log segment, InnoDB 可以借此恢复事务信息).
恢复 table id, 用以在数据字段恢复时重新加锁(srv_dict_recover_on_restart()
).
针对事务中存在 DDL 的操作, 采用同步回滚的方式innobase_dict_recover() --> srv_dict_recover_on_restart()
.
针对不涉及数据字典操作的普通事务, InnoDB 采用异步事务回滚的方式, 通过新启一个线程trx_recovery_rollback_thread
来回滚恢复出来的事务.
事务的故障恢复重要的一个关键点是如何恢复意外 crash 前的事务状态信息, InnoDB 使用的 Undo Log 结构里为每个事务都会分配的 Undo Log Segment 持久化记录了事务的状态信息, 即使 Undo Page 尚未刷盘,也可以通过 Redo Log 也可以恢复了 Undo Page, Redo Log + Undo Log 保证了 InnoDB 关于事务实现的可靠性.
]]>MySQL 版本: 8.0.23
Change Buffer 是 InnoDB 系统表空间(space id = 0) 的一个 B+ tree 索引, 它的作用是为满足指定条件下而数据 Page 不在 Buffer Pool 的二级索引操作进行缓存, 包括一开始的 INSERT 和后来加入的 UPDATE, DELETE. 聚簇索引的顺序插入,可能体现在二级索引中字段并不是顺序的, 所以存在大量的随机读取和写入, 将二级索引的数据操作顺序写入 Change Buffer 的 B+ tree, 以此达到与聚簇索引一致的顺序写入. 当我们需要读取时,会将对应的数据 Page 从磁盘读取至 Buffer Pool 并与 Change Buffer 中对应的 records 进行 merge 操作.
innodb_change_buffering: 设置缓存的操作类型, 包括none
, all
, inserts
, deletes
, changes
, purges
.
innodb_change_buffer_max_size: 设置 Change Buffer 所占 Buffer Pool 的大小, 默认 25%, 最大50%. (假如超过了阈值,会阻止进行 Change Buffer 写入,转而使用通常的写入方式,然后进行主动 merge, 即将数据 Page 读至 Buffer Pool, 并将 Change Buffer 的 records 与数据 Page 进行合并)
除了设置上述的参数打开 Change Buffer 以外,真正使用 Change Buffer 还需要经过一些条件判断:
1 | ibool ibuf_should_try(dict_index_t *index, /*!< in: index where to insert */ |
innodb_change_buffering
不为IBUF_USE_NONE
.innodb_change_buffer_max_size
不为0.所以我们在打开 Change Buffer 的同时也需要判断以上的条件是否符合. 对于唯一索引和写入立即需要读取的数据并不适合打开 Change Buffer.
Change Buffer 的调用逻辑是当我们需要进行支持的 DML 操作时,尝试从 Buffer Pool 读取 Page 时,假如 Page 不在 Buffer Pool 中并符合上述的触发条件, 会通过ibuf_insert()
来针对不同类型的操作进行 Change Buffer 的缓存.
Change Buffer 最初的功能只有缓存 INSERT 操作,所以也作 ibuf, 代码中均使用 ibuf 代替 Change Buffer.
Change Buffer 中有几个重要的概念:
Change Buffer 的 Page 缓存对应二级索引的 DML 操作, 使用<space_id, page_no, counter>
作为 key, 当需要查找的时, 使用<space_id, page_no>
就可以定位到具体的 record, 而 counter 作为一个递增的值,记录着 DML 的操作顺序.
在每个 Tablespace 中 Extent 的第二个 Page 会作为 Change Buffer 的元信息 Page, 即为 ibuf bitmap page, bitmap page 会使用 4 bits 来记录 Tablespace 中关于数据 Page 的 Change Buffer 信息, 以下方法可以计算 bitmap page no:
1 | /* #define FSP_IBUF_BITMAP_OFFSET 1 */ |
其中包括以下几个信息:
IBUF_BITMAP_FREE: 长度 2 bit, 记录该 Page 空闲空间, 使用 2个 bit 来描述空闲空间大小,以 16KB 的 page size 为例,能表示的空闲空间范围为0 (0 bytes)、1 (512 bytes)、2 (1024 bytes)、3 (2048 bytes). 注意此处2048 bytes 意为用户的累计插入 records 长度不能超过 2048 bytes, 并不单单指一次插入, 假如累积缓存的 record 长度超过了 2048bytes, 就会触发 ibuf merge 操作.
IBUF_BITMAP_BUFFERED: 长度 1 bit, 代表该 Page 上存在被缓存了的 DML 操作.
IBUF_BITMAP_IBUF: 长度 1 bit, 代表该 Page 属于 ibuf 类型, 供 AIO 线程判断.
使用函数ibuf_index_page_calc_free_from_bits()
可以计算 Page 的空闲空间:1
2
3
4if (ibuf_code == 3) {
ibuf_code = 4;
}
free_space = ibuf_code * (page_size / IBUF_PAGE_SIZE_PER_FREE_SPACE);
在正常的 DML 操作成功后会更新对应数据 Page 的IBUF_BITMAP_BUFFERED
, IBUF_BITMAP_BUFFERED
并不是准确的记录数据 Page 的空闲空间, 最大只能记录 2kb, 所以用户在写入 record 导致 Page 的剩余空闲空间小于 2kb 之后才会更新. 而 Change Buffer 的缓存操作也通过IBUF_BITMAP_BUFFERED
最大缓存 2kb 的 records.
1 | static MY_ATTRIBUTE((warn_unused_result)) dberr_t |
有以下几个场景会触发 Change Buffer 的 merge 操作, 即将 ibuf Page 的 records 和原数据 Page 进行合并操作 (ibuf_merge_or_delete_for_page()
):
ibuf_insert_low()
中存在部分判断逻辑会导致无法使用 Change Buffer 写入,从而触发 ibuf merge.
当二级索引数据 Page 从磁盘读入至 Buffer Pool 之后,会触发 merge 操作(buf_page_get_gen()).
ibuf_merge_in_background()
会在后台触发 ibuf Page 进行 merge.
在 Recover 阶段会对 ibuf Page 的 Records 和数据 Page 进行 merge.
当执行 slow shutdown 时,会强制做一次全部的ibuf merge.
ibuf 的 merge 操作原理比较简单,就是根据操作类型将 records 从 ibuf Page 合并至数据 Page (ibuf_insert_to_index_page()/ibuf_set_del_mark()/ibuf_delete()
)
InnoDB 的删除逻辑是先删聚簇索引, 再删除二级索引(标记删除), 所以当主键索引发现
DB_RECORD_NOT_FOUND
就会返回, 所以不会触发缓存不存在的索引数据.
InnoDB 实现了 Change buffer 来优化用户在二级索引上的随机写入问题, 用户可以根据自己的需求结合 Change buffer 的一些条件来判断是否启用 Change buffer, 但需要注意的是 Change buffer 的阈值只有 2kb,假如在一个二级索引的数据 Page 写入的 record 长度超过 2kb, 就会触发 ibuf merge, 从而使后续的 ibuf 缓存条件失效, 但这也符合 IO-bound 的场景需求. 本文也介绍了 Change buffer 如何使用 Bitmap Page 跟踪数据 Page 的空闲空间.
]]>某天收到一封读者的邮件,询问我一个关于 InnoDB 死锁的问题, 他在 MySQL 5.7 可以复现这个问题, MySQL 8.0.22 却无法复现, 他询问其死锁的原因. 经过一系列的排查,我后来发现是 InnoDB 内部实现的一个 Bug,目前这个 Bug 已经在 8.0.18 版本进行了修复, 所以也可以通过 8.0.17 vs 8.0.18 来验证这个问题.
整个 SQL 流程如下:
1 | /* 1. 表结构 */ |
我们按照顺序执行分别在 MySQL 8.0.17 和 MySQL 8.0.18 执行,可以看到在 8.0.17 版本事务 t2 因为死锁检测而被视为victim_trx
进行了回滚,而 8.0.18 却不会回滚事务 t2.
基于 MySQL 8.0.17
1 | MySQL [sbtest]> select * from t where account_id = '1' and type =1 for update; |
我们基于问题版本 8.0.17 来分析 Bug 的真正原因.
通过表结构我们可以看到整个表有两个索引, PRIMARY INDEX 和 UNIQUE INDEX uk_account. 因为是死锁问题, 所以我们要逐条分析 SQL 语句加的 record lock 分别是什么:
t1-1
t1-1 是一条 SELECT FROM UPDATE 的语句, 而account_id
和type
是一组唯一索引字段, 所以只需要加一个主键索引的 X record lock 和唯一索引 uk_account 的 X record lock.
t2
t2 语句与 t1-1 相同, 加锁一致,也是一个主键索引的 X record lock 和 唯一索引 uk_account 的 X record lock.
t1-2
t1-2 注意 t1-2 的查询条件只有where account_id = ‘1’, 这与 t1-1 的查询条件是不同的, 所以在 RR 隔离级别下,为了避免出现可能的幻读, 这需要加一个 Next-key lock, 另外需要对 record (2,’2’,1,100,1) 加一个 GAP lock, 防止在此之前的插入造成幻读.
为了验证我们对于 SQL 的分析, 我们可以通过set global innodb_status_output_locks = on;
打开锁状态输出, 然后show engine innodb status\G
, 来查看锁信息, 这里我们为了验证分析正确,跳过执行 t2 语句, 因为 t2 的加锁类型一定是与 t1-1 一致的:
1 | > begin; |
通过show engine innodb status\G
我们可以看到当前的事务的锁持有信息, 事务 t1 分别执行 t1-1 和 t1-2 语句后持有的锁分别有:
一个 Next-key record lock, InnoDB 为了明确 Next-key lock 和普通的 record lock 的区别,分别用不同的 mode 来区分:
普通的 X record lock:
Next-key lock:
一个 GAP record lock
既然 t2 被死锁检测回滚, 我们就需要检查当时是什么锁关系导致了死锁.
官方在 8.0.18 版本对死锁检测进行了优化, 将原先的死锁检测机制 MySQL 死锁检测源码分析 交给了 background thread 来处理, 具体的 Patch 链接: MySQL-8.0.18 死锁检测优化. 具体的思路是将当前事务系统的 lock 信息打一份快照, 由这份快照判断是否存在回环, 假如存在死锁即唤醒等待事务.
而在 8.0.17 版本依然采用旧的死锁检测方法, 具体细节可以参考这篇文章: MySQL 死锁检测源码分析: 每次申请 lock 失败进入 wait 状态后触发一下死锁检测, 所以我们通过 gdb 调试的方法来梳理当时的锁依赖关系, 当我们执行完成 t1-1, 继而执行 t2 后, 事务 t2 进入了 wait 状态,当执行 t1-2 后 t2 回滚,说明触发 t2 回滚的死锁检测是由 t1-2 发起的, 我们 break 在死锁检测的路径上,然后 print 整个锁信息 (代码基于 8.0.17):
我们设置断点在死锁检测的路径上,因为可以明确是 t1-2 的死锁检测触发了 t2 的回滚,所以我们可以明确哪次 break 是我们想要的断点位置.
1 | (gdb) b storage/innobase/lock/lock0lock.cc:7125 |
通过上述分析我们可以得出结论 t1 事务的 t1-2 语句触发了死锁检测,选择的 victim_trx 是事务 t2, 我们需要明确以下几个问题:
根据最近的 Release Note, 我二分验证 8.0.16 - 8.0.22 的版本, 发现在 8.0.17 存在问题, 8.0.18 不存在, 所以根据现象我仔细查看了 8.0.18 的 Release Note, 发现了疑似这个现象的 Bugfix:
- InnoDB: A deadlock was possible when a transaction tries to upgrade a record lock to a next key lock. (Bug #23755664, Bug #82127)
根据 Bug ID, 可以通过 Github 的 MySQL 提交记录来查找这个 Patch:
Bug #23755664 DEADLOCK WITH 3 CONCURRENT DELETES BY UNIQUE KEY
PROBLEM:
A deadlock was possible when a transaction tried to “upgrade” an already held Record Lock to Next Key Lock.SOLUTION:
This patch is based on observations that:
(1) a Next Key Lock is equivalent to Record Lock combined with Gap Lock
(2) a GAP Lock never has to wait for any other lock
In case we request a Next Key Lock, we check if we already own a Record Lock of equal or stronger mode,
and if so, then we either upgrade it to Next Key Lock, or if it is not possible (because the single lock_t
struct is shared by more than one row) we change the requested lock type to GAP Lock, which we either already
have, or can be granted immediately.
(I don’t consider Insert Intention Locks a Gap Lock in above statements).Reviewed-by: Debarun Banerjee debarun.banerjee@oracle.com
RB:19879
经过验证确实是这个 Patch 修复了这个死锁的问题.
这个 Patch 具体的原理是当尝试获取 Next-key record lock 时,不再与旧的逻辑一样,旧的逻辑是先直接尝试申请 Next-key lock, 现在改为先判断当前 trx 是否持有 X record lock, 假如持有就复用这个 X record lock, 从而直接申请 GAP lock, 以达到 Next-key Lock 的效果.
所以在我们上面的例子中,申请 Next-key record lock 时跳过申请 X record lock, 就不会进入等待队列,也不会产生死锁的回环.
1 | /* 使用 8.0.26 最新版分析 lock 持有情况. |
根据例子我们分析了一个 InnoDB 的死锁场景, 以及 Bug 产生的原因. 通过 gdb 调试的方式分析 InnoDB 的死锁原因,最主要任务就是梳理整个锁的等待依赖关系, 这能帮助我们更直观的分析真正的原因. 这是一个 X record lock “升级” 至 Next-key record lock 的 Bug, 官方在 8.0.18 已经修复了这个存在了几年的问题.
]]>本站所有文章采用 Creative Commons 署名-非商业性使用-相同方式共享 3.0 协议, 同时文章内容推荐 RSS 订阅.
]]>MySQL内核版本: 8.0.21
latch
数据库中的 latch 和我们通常代码编程中保证并发多线程操作操作临界资源的锁意义一样,通过 latch 的中文翻译“闩”就可以理解,这是为了维护一段临界区域.
lock
而 lock 则是数据库 MySQL 中在事务使用的”锁”, 锁定的对象是表或者行.
数据库内核月报 InnoDB 事务锁系统简介对 InnoDB 的事务锁系统: record lock 和 table lock 做了具体的介绍, 其中对于 record 和 table 会将所有GRANTED
或者WAITING
插入对应的 hash table.
在官方 MySQL 实现中, 事务锁系统由lock_sys_t *lock_sys
统一管理, 当事务尝试申请一个 lock 时,会首先尝试获取lock_sys->mutex
, 在 lock 创建成功后,会插入对应类型的 hash table, 下面是官方MySQL实现中的 hash table:
1 | /** The lock system struct */ |
通过上面的简述可以理解当每一个事务需要尝试申请一个 lock 时,都需要获取这个lock_sys->mutex
全局的 latch, 这对于高并发的事务处理来说是一个瓶颈. MySQL 官方在 8.0.21 版本针对这个问题使用分区 latch 来解决: worklog #10314.
在 8.0.21 之前的版本申请 record lock 时需要获取全局的lock_sys->mutex
, 以 record lock 为例:
1 | dberr_t lock_clust_rec_modify_check_and_lock() { |
尤其当高并发事务处理,lock_sys->mutex
的瓶颈会凸显. 为此官方将lock_sys->mutex
的进行拆分, 引入了 3 个类型的 latch, 一个全局的global latch
, 512 组table latches
和 512 组page latches
:
global latch (lock_sys->latches.global_latch)
: 一个全局读写锁, 当lock_sys
全局操作时, 直接对global_latch
上 X 锁, 其他操作仅需要 S 锁.
table shard latches (lock_sys->latches.table_shards.mutexes)
: 512 个 table latches, 用来分片 table lock.
page shard latches (lock_sys->latches.page_shards.mutexes)
: 512 个 page latches, 用来分片 record lock.
1 | [ global latch ] |
Shard_latch_guard
: 针对 global latch 使用 s-latch 并对单个 shard mutex 上锁.
Shard_latches_guard
: 针对 global latch 使用 s-latch 并对两个 shard mutex 上锁.
Global_exclusive_latch_guard
: 针对 global latch 使用 x-latch.
1 | /* global_latch X 锁. */ |
在使用 shard lock 后, 申请 record lock 只需要获取对应 Page 的lock_rec_hash(page_id) % SHARDS_COUNT
槽位的 mutex 即可:
1 | dberr_t lock_clust_rec_modify_check_and_lock() { |
上述代码是以 record lock 举例,使用 shard lock 后 record 申请的流程为:
针对 global_latch 使用 s-latch
获取对应 page_id 在 lock_sys 中 page_shards 的 latch:
1 | lock_sys->latches.page_shards.get_mutex(page_id) |
对 latch 上锁: mutex_enter(&m_shard_mutex)
.
Shard_latch_guard 等实现均为 RAII 模式, 离开作用域后自动析构.
官方在 8.0.18 版本对死锁检测进行了优化, 将原先的死锁检测机制 MySQL 死锁检测源码分析 交给了 background thread 来处理, 具体的 Patch 链接: MySQL-8.0.18 死锁检测优化. 具体的思路是将当前事务系统的 lock 信息打一份快照, 由这份快照判断是否存在回环, 假如存在死锁即唤醒等待事务.
使用 shard lock 优化后, 因为存在多个 thread 并发更新当前 trx 的锁操作, 所以死锁检测使用Global_exclusive_latch_guard
来互斥当前的 lock 操作.
MySQL 官方针对 lock_sys 的 mutex 瓶颈使用了 sharded lock 的方法进行优化,这依然延续了系统设计的优化思路, 将一个 bottleneck 的全局锁拆分为 sharded, 这也符合当前多核设计下, 充分利用硬件特性以此提高并行处理能力的趋势.
]]>在 MySQL 8.0.14 版本 InnoDB 引擎发布了一个新的特性 Parallel read of index (并行索引读取), 主要用于并行的读取索引数据, 目前仅仅支持 SELECT COUNT() 和 CHECK TABLE 操作, InnoDB 后续对于其他操作还会有更多的优化支持. 通过这个并行索引读取框架, InnoDB 可以支持同步、异步的并发读取索引数据, 异步的读取索引数据可以用来实现逻辑预读操作. 在此之前的预读逻辑, InnoDB 只有线性预读和随机预读这两种物理预读处理方法, 而对于 B+ tree 这种树形结构显然逻辑预读才更合适.
innodb_parallel_read_threads
是 session 级别的变量, 假如需要打开并行扫描框架即:
1 | set local innodb_parallel_read_threads=4; |
Parallel read of index 主要利用当前的多核硬件优势, 针对当前可以并行读取的逻辑例如 SELECT COUNT() 或者 CHECK TABLE, 其主要逻辑是收集数据叶子节点的 Page Number, 使用多个 worker 并行读取数据 Page, 利用不同的回调函数来处理获取后的 rows. 目前 SELET COUNT() 和 CHECK TABLE 都是同步读取, 但 InnoDB 依然提供了接口处理对应的异步读取, 后续会针对需要异步读取的场景提供更多的优化路径.
row_scan_index_for_mysql()
作为 SELECT COUNT() 和 CHECK TABLE 的入口函数:
1 | /* 扫描索引数据 */ |
Parallel_reader::Scan_range: 代表当前并行扫描的范围.
Parallel_reader::Config 并行扫描的 configuration.
Parallel_reader::Scan_ctx 并行扫描的上下文 (context).
Parallel_reader::Ctx 并行读取的执行上下文 (Parallel reader execution context)
Parallel_reader 并行扫描 reader
我们以全表扫描 SELECT COUNT() 为例, 根据源码分析 Parallel Read 的原理:
1 | /* SELECT COUNT() 的入口函数 */ |
Key_reader
使用 partition() 将 B+ tree 分片, 分配各个 worker 线程, InnoDB 的 B+ 树将数据存放在所有的叶子节点, 即叶子节点为 level 0, 分配策略是从 root 节点遍历, 使用 left_leaf()
从左边由上至下直到 level N 层的节点数量大于等于 worker 线程数量:
Key_reader
会在指定 level 的 sub-tree 的 “root page” 中分别选择第一个 record, 从而找到其在 leaf level 层的 page no(create_range()->create_persistent_cursor()), 新建 scan context 交由 worker 线程.
启动 worker 线程, worker 线程也就是真正的读取线程,对一个切好的 sub-tree 做 scan, worker 线程分别根据被分配的 leaf page cursor 进行顺序读取.
并行读取线程会根据创建的读取对应叶子节点的 record, 并且会根据 trx->read_view
来判断可见性.
我们通过 SELECT COUNT() 分析了 InnoDB 实现的 Parallel Read 框架,虽然目前仅支持 CHECK TABLE 和 SELECT COUNT(), 但整个框架支持了足够多的接口,后续应该会支持更多的场景. 例如目前 CHECK TABLE 和 SELECT COUNT() 都是同步的并行读取, 使用 Parallel Read 框架可以考虑针对 SELECT * 的全表扫描可以优化为异步的逻辑预读.
]]>InnoDB 使用 B+ 树作为它的索引数据结构, B+ 树作为一种经典的数据结构具备高效的读写查询, 本文主要分析 InnoDB 中 B+ 树对于 Record 的增删改如何实现, 理解 Record 在 InnoDB 中的 B+ 树如何增删改,可以更直观的帮助我们理解 InnoDB 的索引组织方式.
在MySQL中, 一条 Insert 语句就是一个 Record 的插入操作, 我们以插入一条聚簇索引(非压缩)为例, 略过连接建立过程和 SQL parse 阶段, 经过 InnoDB 的 handler::ha_write_row()
调用:
1 | ------------ |
上述的调用过程说明了插入一条 Record 的过程, 具体的分析如下:
row_ins_clust_index_entry_low()
函数的参数包括我们需要插入的 Record 和当前的dict_index_t
索引. 首先我们需要通过pcur
游标来定位我们需要插入的位置:
1 | /* 调用 btr_pcur_open() 定位待插入的位置. |
针对 cursor 返回的 Record 检查主键重复的问题.
调用btr_cur_optimistic_insert()
乐观插入:
通过 cursor 定位 leaf page, 计算 Record 的物理长度.
假如 Record 的大小超过了 Page 的剩余空间, 则乐观插入失败,需要调用悲观插入.
对于乐观插入成功的情况下, 调用btr_cur_ins_lock_and_undo()
记录 Undo Log.
调用page_cur_insert_rec_low()
完成 Page 的插入并记录类型为 MLOG_REC_INSERT 的 Redo Log.
对于需要分裂的 Page 需要调用btr_cur_pessimistic_insert()
悲观插入:
乐观插入使用的 mode 为 BTR_MODIFY_LEAF, 加锁顺序是先对 dict_index_t 加 S 锁, 再针对所有的 non-leaf page 加 S 锁, 因为需要对 leaf page 进行修改,所以对 leaf page 加 X 锁.
1 | row_ins_clust_index_entry_low(flags, BTR_MODIFY_LEAF, index, n_uniq, |
悲观插入使用的 mode 为 BTR_MODIFY_TREE, 加锁顺序是先对 dict_index_t 加 SX 锁, 在 search 过程中不会针对 page 加任何锁(RW_NO_LATCH), 但会保留整个 branch 涉及的 block, 最后针对路径涉及的所有 block 加 X 锁.
1 | row_ins_clust_index_entry_low(flags, BTR_MODIFY_TREE, index, n_uniq, |
1 | /* Record 删除操作. */ |
通过源码分析我们可以发现 Record 的删除操作对于聚簇索引并不是真的物理删除,仅仅是标记为 REC_INFO_DELETED_FLAG. 而对于其他的二级索引, 依然采用设置标记的方法 (btr_cur_del_mark_set_sec_rec()
).
对于 Record 的修改操作, 使用了和删除操作一样的接口 row_upd_clust_step()
. 对于修改存在多种不同的处理方法:
对于只修改聚簇索引,而无需修改二级索引的 Update 操作, 调用 row_upd_clust_rec()
, 对于仅修改聚簇也存在两种情况: 是否存在 Record 长度的变化.
对于 Update 后长度不变的 Record, 调用 btr_cur_update_in_place()
原地修改.
对于 Update 后引起 Record 长度变化的操作, 依然会根据当前 Page 的剩余空间调用乐观更新(btr_cur_optimistic_update()
)和悲观更新(btr_cur_pessimistic_update()
). 引起 Record 长度变化的 Update 操作都是 append 写入方式, 对于旧的 Record 需要更新其标志位, 插入 Page 的 PAGE_FREE
链表.
对于会影响排序的字段, 调用 row_upd_clust_rec_by_insert()
更新.
对于需要同时修改聚簇索引和二级索引的 Update 操作, 依然调用 row_upd_clust_rec()
完成. 与 一样会在使用 row_upd_store_row()
记录旧的 Record 至 row->node, 以供二级索引更新使用.
对于二级索引的修改操作,全部采用标记删除后重新插入的方式.
我们通过源码分析了 InnoDB 中关于索引部分的增删改步骤, 需要注意的是 B+ tree 中的增删改流程全部处于同一个 trx 的保护中,因此对于聚簇索引和二级索引的修改都保证了原子性, 这里也涉及 InnoDB 的 Undo Log 模块和事务锁系统模块.
]]>InnoDB 采用 adaptive flushing (自适应刷脏)的刷脏策略来处理从 Buffer Pool 写入脏页至磁盘. 如何理解 adaptive flushing 的作用,我们可以假设不采用自适应刷脏策略,我们该如何进行刷脏? 假如没有自适应刷脏算法,我们可以利用阈值的方式来进行刷脏,比如 Buffer Pool 的脏页比例达到了 70% 就触发刷脏,在一般的业务压力下,这个方法没有问题. 但是对于用户业务不确定的场景, 简单的采用阈值的方式容易造成在用户业务压力大的情况下数据库的剧烈抖动. 所以采用自适应的刷脏策略,尽可能在所有的用户场景达到系统平滑运行.
innodb_max_dirty_pages_pct_lwm
的值时, InnoDB 就会触发刷脏.当启用 innodb_max_dirty_pages_pct_lwm
参数时, 表示设置了预刷脏,Buffer Pool 的刷脏线程会避免脏页比超过这个值. 后台刷脏的动作由后台刷脏协调线程触发,该线程的所有工作内容均由buf_flush_page_cleaner_coordinator()
函数完成. 在执行刷脏任务前,会调用 page_cleaner_flush_pages_recommendation()
生成刷脏建议.
函数page_cleaner_flush_pages_recommendation()
生成的建议刷脏的 Page 数量是 adaptive flushing 自适应刷脏策略的核心,它每隔srv_flushing_avg_loops
秒(默认30s)重新根据redo log产生的速度,参考当前刷脏的平均数量和设置的系统IO参数(innodb_io_capacity
, innodb_io_capacity_max
) 三者的平均值生成一个合理的建议刷脏的Page数量. 下面我们分别对这三个调节因子做出对应的解释.
因为刷脏协调线程会每隔srv_flushing_avg_loops
生成一次刷脏建议,关于 Redo Log 产生的平均速度公式即为:
1 | /* |
计算 Redo Log 产生的平均速度这个比较好理解,Redo Log 产生的平均速度反应了当前系统的压力情况,压力越大,Redo Log 产生的速度越快. 在 MySQL 中 Redo Log 是复用的,经过 Log Checkpoint 操作之前的 Redo Log 都可以被复用, 所以 Log Checkpoint 本身就会推进 Buffer Pool 的刷脏, 所以为了保证数据库有足够空闲的 Redo Log 空闲, 自适应刷脏同样需要考虑 Redo Log 产生的速度.
InnoDB 根据当前lsn_avg_rate
来估算一个target_lsn, flush_list
所有oldest_modification_lsn
小于该 lsn 值的 Page 都被考虑进行刷盘. 估算公式如下:
1 | lsn_t target_lsn = oldest_lsn + lsn_avg_rate * buf_flush_lsn_scan_factor; |
因子buf_flush_lsn_scan_factor
被硬编码为3. 由上面的公式 InnoDB 的刷脏协调线程遍历 Buffer Pool 估算出flush_list
需要被刷脏的 Page 数量, 但最后的数量会再除以buf_flush_lsn_scan_factor
.
考虑将当前刷脏的平均速度作为影响因子可能的原因应该是避免生成的建议刷脏 Page 数量与上次刷脏的数量差距过大或过小. 这也符合自适应刷脏的初衷,尽力避免IO抖动.
通过我们设置的innodb_io_capacity
和innodb_io_capacity_max
可以得出系统IO的能力, 通过计算flush_list
占所有 Page 数量的百分比我们可以得出脏页比. 根据脏页比与innodb_max_dirty_pages_pct
大小比较我们决定是否触发激烈刷脏, 假如超过了innodb_max_dirty_pages_pct
设定的大小,我们即认为需要全力进行刷脏了, 这里会充分调动系统的IO能力. 否则则需要与innodb_max_dirty_pages_pct_lwm
比较,从而考虑利用系统多少的IO带宽.
通过上面的计算,我们将这三个因子的建议刷脏 Page 数量计算平均值,得出综合建议刷脏Page数量,由变量n_pages
保存. 接下来,这个建议刷新的总量n_pages
与innodb_io_capacity_max
这个参数进行比较,即建议刷新的总量最大不能超过所设置的磁盘最大随机IO能力。
最后我们需要为每个 Buffer Pool 设置n_pages_requested
, 即要求的刷脏 Page 数量. 具体的细节我们将在下节的源码分析展出.
1 | static ulint page_cleaner_flush_pages_recommendation(lsn_t *lsn_limit, |
InnoDB 的自适应刷脏比较容易理解,重要的是提供了一种对于系统开发过程中对于容易造成性能瓶颈的关键路径优化思路,例如基于 LSM 设计的 RocksDB 中的 compaction 过程经常造成IO瓶颈从而饱受诟病,参考 InnoDB 的自适应刷脏算法针对不同的IO压力选择合适的 compaction 时机是否能使系统更平滑?
]]>MySQL内核版本: 8.0.19
simulated-AIO 是一套由 InnoDB 早先实现的异步 I/O 模型. 在 MySQL 的存储引擎 InnoDB 中分别实现了同步IO以及异步IO, Redo Log 的写入方式采用同步IO, 而数据页的写入由于 Redo Log 的保护则采用异步 IO 的写入方式. 在 Linux AIO 引入之前, InnoDB 实现了一套异步 IO 框架, 即 simulated-AIO. simulated-AIO 的原理类似于 libaio, 原理实现都较为简单.
在Linux平台, 假如安装了 libaio, MySQL 是默认使用 libaio, 只有在设置了innodb_use_native_aio = 0
的情况下才会使用 simulated-AIO.
InnoDB的异步IO主要是用来处理预读和数据Page的写请求,对于正常Page的数据读取则是通过同步 IO 进行.
simulated-AIO 预分配 n 个大小 slot 数组, 每个用户的读写请求通过申请数组中的 slot, 构造对应的 IO 类型、写入 offset 等等. 而 simulated-AIO 的工作线程则根据slot的内容来完成对应的 IO 请求.
1 | /** The asynchronous I/O context */ |
simulated-AIO 原理非常简单,可以理解为一个生产者-消费者模型, 示意图如下:
buf_page_get_gen()
(预读):1 | /* 获取数据页 */ |
buf_flush_page()
(写):1 | /* 刷 Page 至文件 */ |
无论是读操作还是写操作,都要交由os_aio()
处理, os_aio
是一个通用的接口, 在Linux平台封装了 libaio 和 simulated AIO. 具体的处理逻辑如下:
1 | ---------- |
根据IO类型选择对应的 I/O slot 数组(select_slot_array())
.
向 I/O slot 数组申请 slot (reserve_slot()
).
唤醒对应的异步IO线程处理IO请求(AIO::wake_simulated_handler_thread()
).
在MySQL启动时,会分别创建1个ibuf处理线程, 1个log处理线程, n个(srv_n_read_io_threads
)读处理线程, n个(srv_n_write_io_threads
)写处理线程.
1 |
|
io_handler_thread()
会持续监控 IO 请求,直到 MySQL shutdown:
1 | /* storage/innobase/srv/srv0start.cc */ |
fil_aio_wait()
会调用os_aio_handler()
根据不同的IO模型选择不同的函数处理IO请求, simulated AIO 的处理函数是os_aio_simulated_handler()
:
根据 global segment id 选择对应I/O工作线程的event, 计算在该array的segment id.
检查是否有已经完成但状态尚未更新的IO请求:
AIO::release()
更新slot状态.需要判断是否MySQL准备shutdown, 假如需要shutdown则立即返回.
否则从AIO::m_slots
选择等待的IO请求:
选择策略是先选择一个等待时间超过2s的IO请求, 防止等待时间过长.
否则选择写入偏移量最小的一个slot.
假如目前没有待处理的IO请求,则进入wait状态.
处理选中的IO请求前,会调用merge()
进行IO合并, 选择文件偏移量offset连续的IO请求进行合并.
调用 simulated-AIO 封装的同步IO接口(pwrite()
/pread()
)完成IO操作.
核心处理函数os_aio_simulated_handler()
:
1 | /* storage/innobase/os/os0file.cc */ |
simulated AIO 不能保证多线程同时写一个文件, 但 simulated AIO 底层调用的文件接口是 pwrite(), 通过指定参数 offset, 以及每次写的时候加上 Page 锁, 就能保证不写在同一个 offset.
综上所述,通过源码分析我们详细的了解 MySQL 实现的模拟异步 I/O 的框架, 原理非常简单,由用户线程获取 slot 并记录相关的 I/O 信息,而 simulated-AIO 的后台工作线程则通过一定的策略来逐一处理 I/O 请求, 并且通过合并 I/O 的策略来对 I/O 读写做了一些优化.
]]>MySQL 内核版本: 8.0.17
在MySQL中,当两个或两个以上的事务相互持有或者请求锁,并形成一个循环的依赖关系,就会产生死锁. 多个事务同时锁定同一个资源时,也会产生死锁. 在一个事务系统中,死锁是确切存在并且是不能完全避免的. InnoDB 会在每一个事务申请锁时触发死锁检测,并选择一个事务回滚.
在 MySQL 中,事务在申请 record lock 后假如无法立即获取锁会进行死锁检测. 在事务的回滚中,会释放该事务持有的所有 lock.
用户可以配置--innodb-deadlock-detect[={OFF|ON}]
选择是否打开死锁检测.
我们从源码层面分析 MySQL 的死锁检测机制,直接通过源码分析可以更直观的介绍死锁检测机制. MySQL 的死锁检测算法是深度优先搜索,如果在搜索过程中发现了环,就说明发生了死锁. 为了避免死锁检测开销过大,如果搜索深度超过了 200(LOCK_MAX_DEPTH_IN_DEADLOCK_CHECK)也同样认为发生了死锁。
基本的代码流程如下, add_to_waitq()
是申请 Record Lock 的入口函数:
1 |
|
死锁检测的主流程代码在DeadlockChecker::check_and_resolve()
:
1 | /* storage/innobase/lock/lock0lock.cc */ |
关于MySQL死锁检测如何判断是否存在死锁核心代码在函数DeadlockChecker::search()
:
1 | /* storage/innobase/lock/lock0lock.cc */ |
select_victim()
返回一个选中需要被回滚的事务,MySQL 并不会迭代所有的 trx 来选择一个代价较小的事务,仅仅在m_start
和m_wait_lock->trx
这两个事务中选一个优先级较低的事务回滚.
MySQL内核版本: 8.0.17
数据库中的 latch 和我们通常代码编程中保证并发多线程操作操作临界资源的锁意义一样,通过 latch 的中文翻译“闩”就可以理解,这是为了维护一段临界区域.
而 lock 则是数据库 MySQL 中在事务使用的”锁”, 锁定的对象是表或者行. 关于 MySQL 的死锁可以查看另外一篇文章MySQL死锁检测.
行锁
意向锁
GAP 锁
表级别锁的兼容互斥矩阵:
X | IX | S | IS | |
---|---|---|---|---|
X | Conflict | Conflict | Conflict | Conflict |
IX | Conflict | Compatible | Conflict | Compatible |
S | Conflict | Conflict | Compatible | Compatible |
IS | Conflict | Compatible | Compatible | Compatible |
需要注意上图矩阵的X
, IX
, S
, IS
锁均为表锁,并不代表行锁.
锁的含义:
X
: 排他锁IX
: 意向排他锁S
: 共享锁IS
: 意向共享锁
在一个事务trx_t
中用结果trx_lock_t
来存放事务申请的锁信息, 包括行锁和表锁, 即trx->lock.trx_locks
和trx->lock.table_locks
.
MySQL为了支持多粒度的锁, 引入了意向锁,意向锁是一种可以与行锁共存的锁, 例如SELECT ... FOR SHARE
设置了IS
意向共享锁, 而SELECT ... FOR UPDATE
设置了IX
意向排他锁. 意向锁的上锁原则如下:
当一个事务对一个表的某一行记录申请 record 共享锁(行锁), 需要先申请IS
意向共享锁(表锁).
当一个事务对一个表的某一行记录申请 record 排他锁(行锁), 需要先申请IX
意向排他锁(表锁).
X,IS是表级锁,不会和行级的X,S锁发生冲突, 只会和表级的X,S发生冲突. 行级别的X和S只与其它行锁存在普通的共享、排他规则. 而意向锁的意义是当需要向一张表添加表级X锁时,假如没有意向锁,需要遍历lock_sys->rec_hash
判断是否与该X锁存在冲突的锁.
我们以源码分析的方式来直观的理解意向锁的加锁过程,我们以 update 一条 record 获取 IX 锁为例:
在 IX 锁申请之前,会对当前表(dict_table_t
)记录的锁信息的兼容情况进行判断(lock_table_other_has_incompatible()
), 符合兼容矩阵的从而在row_upd_step()
函数中调用lock_table()
申请 IX 锁, 表级锁的申请过程如下:
1 | /* storage/innobase/lock/lock0lock.cc */ |
row_upd_step()
完成申请IX意向排他锁后继续调用row_upd_clust_step()
, 而row_upd_clust_step()
调用lock_clust_rec_modify_check_and_lock()
对修改的 record 申请 X 锁:
1 | ---------------- |
例如此时某一个用户正在使用lock table
语句锁表,依然会进入lock_table_other_has_incompatible()
判断表级锁的兼容情况,假如产生冲突,该用户线程则会进入 wait 状态.
在 Linux 内核中,进程一般称为任务(task), 进程的虚拟地址空间在内存管理模块中被分为用户虚拟地址空间和内核虚拟地址空间,所有的进程共享内核虚拟地址空间, 每一个进程有独立的用户虚拟地址空间. 在内核中,进程有两种特殊形式,没有使用用户虚拟地址空间的进程称为内核线程,共享用户虚拟地址空间的进程称为用户线程.
我们通常开发过程中提及的进程与线程在 Linux 内核中并没有明确的区别,它们都拥有数据结构task_struct
作为描述符,我们通常所讲的进程与线程的主要区别即是否共享用户虚拟空间.
本文着重介绍了 CFS 公平调度算法,它的公平性主要体现在按照优先级将一个完整的调度周期分配给不同的进程, 尽管每个进程因为优先级分得的时间片不同,但保证在一个调度周期内所有的进程都会被运行一次.
(task_struct
数据成员较多,仅列出重要的数据成员)
1 | struct task_struct { |
Linux 内核支持以下调度策略:
停机进程是优先级最高的进程,停机就是我们通常理解的使处理器停下来,做更紧急的任务.
限期进程使用最早期限优先算法,使用红黑书把进程按照绝对截止期限从小到大排序,每次调度时选择绝对截止期限最小的进程.
实时进程支持两种调度策略: 先进先出调度和轮流调度
普通进程支持两种调度策略, 标准轮流分时和空闲调度
处理器上的空闲进程使用空闲调度策略:
每个处理器上有一个空闲进程,即0号进程. 空闲进程的优先级最低,只有当没有其他进程可以调度的时候,才会调度空闲进程.
我们这里介绍相对重要的普通用户进程的调度策略: 完全公平调度策略 CFS(Completely Fair Scheduler):
普通进程使用完全公平调度(CFS)算法. 为了保证在一个周期内所有的进程都能被调度, 完全公平调度算法引入了虚拟运行时间vruntime
的概念:
1 | 虚拟运行时间 = 实际运行时间 * nice0 对应的权重 / 进程的权重( nice 值对应的权重) |
实际运行时间就是字面意思,进程在 CPU 上运行的实际时间. 每一个进程(task_struct
)的调度信息结构体sched_entity
都记录了进程调度开始时间点(exec_start), 实际运行时间(sum_exec_runtime), 虚拟运行时间(vruntime), 上一次时间运行时间(prev_sum_exec_runtime).
调度进程的时候,选中 next 进程, 并开始记录 next 进程的开始运行时间点,运行结束后计算时间差即为进程的实际运行时间.
在kernel/sched/core.c
中定义了 nice 值与权重的对应关系,nice0 的值为1024.
普通进程的 nice 值的取值范围是-20~19, 以下是 nice 值与权重的对应关系如下:
1 | const int sched_prio_to_weight[40] = { |
nice 值越小,进程的权重也就越大.
完全公平调度算法利用 rb-tree 将进程按虚拟运行时间从小到大的排序,每次调度选择虚拟运行时间最小的进程. nice0 对应的权重为常量,即可以理解在实际运行时间相同的情况下,进程的权重( nice 值对应的权重值)越大,被调度的机会就越大.
内核设置了调度最小粒度,默认为 0.75 毫秒,可以通过文件/proc/sys/kernel/sched_min_granularity_ns
调整. 调度最小粒度表示进程在 CPU 至少运行的时间长度.
在某个时间长度可以保证运行队列的每个进程都至少运行一次, 这个时间长度称为调度周期,如果运行队列的进程数量大于 8, 那么调度周期等于调度最小粒度 * 进程数量,否则调度周期为6ms
进程的时间片公式如下:
1 | 进程的时间片(实际运行时间) = (调度周期 * 进程权重 / 运行队列中所有进程的权重总和) |
介绍了CFS的基本概念, 我们来举例来分析为什么 CFS 算法是一个公平调度算法:
假如有两个进程A和B, 进程的 nice 值0和1, 即 A 进程的权重为1024, B 进程的权重为820. 以6ms的调度周期来计算, 根据进程的时间片公式,两个进程分别的运行时间片为A进程6 * 1024 / (1024+820) = 3.33ms
, B进程6 * 820 / (1024+820) = 2.66ms
. 通过进程时间片公式计算我们可以看到不同的优先级的进程运行时间片不同,但为了保证在 CPU 选择进程调度时,尽可能保证每个进程被选择的可能性是相同的,这里就要反推我们上面提到的虚拟运行时间. A 进程的虚拟运行时间为3.33 * 1024(nice 0) / 1024 = 3.33
, B 进程的虚拟运行时间为2.6 * 1024(nice 0) / 820 = 3.33
. 通过虚拟运行时间的公式我们得出A 进程和B 进程尽管优先级不同,但是在 rb-tree 的位置是接近的, 即被调度的优先级是相同的.
我们可以先通过公式推导发现:
1 | 进程的时间片(实际运行时间) = (调度周期 * 进程权重 / 运行队列中所有进程的权重总和) |
CFS 调度算法利用虚拟运行时间保证在一个调度周期每个进程被调度的优先级尽可能的一样.
对于新创建的进程我们如何设置虚拟运行时间, 假如设为0, 则调度器会因为vruntime
较小频繁的调度新建的进程直到它的虚拟运行时间追上就绪队列里其他的进程. 这个现象则违背了 CFS 调度算法的公平性. 所以在有一个数据字段min_vruntime
, 当新进程创建时,我们将其vruntime
初始化为就绪队列里的min_vruntime
, 则确保新进程与大部分的进程之间的虚拟运行时间的 GAP 不会过大, 从而避免被频繁调度.
每一个处理器都有一个运行队列,定义如下:
1 | /* kernel/sched/sched.h */ |
调度进程的核心函数是__schedule
, 函数__schedule()
的处理流程如下:
pick_next_task()
以选择下一个进程context_switch()
以切换进程pick_next_task()
1 | static inline struct task_struct * |
用户进程属于公平调度类,即调用pick_next_task_fair()
选择下一个运行的进程, 公平调度类会从当前cfs_rq
即公平调度运行队列中选择虚拟运行时间最小的调度进程,所有的调度进程都由 rb-tree (红黑树)维护.
context_switch()
1 | /* linux/kernel/sched/core.c */ |
1 | /* switch_mm_irqs_off() -> switch_mm() -> __switch_mm() */ |
1 | /* switch_to() -> __switch_to() */ |
通过上面的系统的分析,我们可以发现在Linux内核中并没有区分进程和线程,对于线程和进程,我们可以这么理解:
我们通常所说的上下文切换分为CPU上下文切换和进程上下文切换, 例如C/C++中的系统调用即会执行CPU上下文切换,而系统性能分析工具vmstat
所显示的也是 CPU 上下文切换和中断的次数. 关于进程上下文切换我们可以利用工具pidstat
:
每隔5秒输出1组数据
这个结果中有两列内容是我们的重点关注对象。一个是 cswch ,表示每秒自愿上下文切换(voluntary context switches)的次数,另一个则是 nvcswch ,表示每秒非自愿上下文切换(non voluntary context switches)的次数. 所谓自愿上下文切换,是指进程无法获取所需资源,导致的上下文切换。比如说, I/O、内存等系统资源不足时,就会发生自愿上下文切换。而非自愿上下文切换,则是指进程由于时间片已到等原因,被系统强制调度,进而发生的上下文切换。比如说,大量进程都在争抢 CPU 时,就容易发生非自愿上下文.
调度进程的时机如下:
进程在用户模式下运行的时候,无法直接调用schedule()
函数, 只能通过系统调用进入内核模式,假如系统调用需要等待某个资源,例如互斥锁(mutex)或者信号量,会将进程的状态设为睡眠状态,然后调用schedule()
来调度进程.
进程也可以通过系统调用sched_yield()
让出处理器,这种情况下进程不会进入睡眠.
Linux内核依靠周期性的时钟中断抢夺处理器的控制权,时钟中断处理程序检查当前进程的执行时间有没有超过限额,如果超过了限额,设置需要重新调度的标志.
在CFS算法中,如果当前调度实体的运行时间超过了前面介绍的进程的时间片,那么会设置重新调度的标志位.
Linux系统可以通过renice
设置进程优先级,具体使用方法可以通过man renice
.
C/C++编程可以使用以下方法:
1 |
|
<< Linux 内核深度解析>>
]]>在 MySQL 中,创建一张表时会默认为主键创建聚簇索引,B+ 树将表中所有的数据组织起来,即数据就是索引主键所以在 InnoDB 里,主键索引也被称为聚簇索引,索引的叶子节点存的是整行数据。而除了聚簇索引以外的所有索引都称为二级索引,二级索引的叶子节点内容是主键的值。
1 | CREATE INDEX [index name] ON [table name]([column name]); |
或者
1 | ALTER TABLE [table name] ADD INDEX [index name]([column name]); |
在 MySQL 中, CREATE INDEX
操作被映射为ALTER TABLE ADD_INDEX
。
例如创建如下一张表:
1 | CREATE TABLE users( |
新建一个以age
字段的二级索引:
1 | ALTER TABLE users ADD INDEX index_age(age); |
MySQL 会分别创建主键id
的聚簇索引和age
的二级索引:
在 MySQL 中主键索引的叶子节点存的是整行数据,而二级索引叶子节点内容是主键的值.
在 MySQL 8.0 中,二级索引的创建具体流程如下图:
二级索引所属的 Onine DDL 可以分为三个阶段: DDL prepare 阶段, DDL 执行阶段和 DDL commit 阶段.
升级至 X 锁, 禁止读写.
ha_prepare_inplace_alter_table()
根据 ALTER TABLE
语句传入的参数进行检查,构建被创建的索引信息,创建索引的B+树.
在 MySQL 8.0 实现中,基本上所有的ALTER TABLE
操作都实现在mysql_alter_table()
函数,而 Online DDL 支持使用Inplace
方式创建二级索引:
row_merge_build_indexes()
用来构建二级索引的索引内容,在 MySQL 中,二级索引的组织关系是
申请内存用来排序,大小为3 * srv_sort_buf_size
,申请临时文件merge_file_t
用来合并排序.
读取扫描表中的整个聚簇索引 B+ 树构建二级索引,假如merge buffer
的空间不满足 Index 的排序,则需要利用临时文件进行合并排序.
根据prepare
阶段构建的索引信息,遍历聚簇索引,构造对应的索引字段. 假如建表时没有指定主键,InnoDB 会默认创建一个名为DB_ROW_ID
的自增字段,所以二级索引的映射关系就是
将合并排序后的二级索引内容通过 Bulk Load 的方式写入Page,使用flush_observer
落盘对应的数据脏页.
关闭删除临时文件,释放排序内存merge_buf
.
MySQL 8.0 要求 DDL 具有原子性,所以在上述的合并排序后插入 Page 的过程中,可以使用flush_observer
直接落盘数据页或者记录 Redo. 这样来保证整个DDL操作是原子的.
为 Table 加上 X 锁, 禁止读写.
更新 InnoDB 的数据字典 DD.
提交 DDL 事务.
清理操作 Clean Up.
在一些需要 rebuild table 的 Online DDL 操作中,例如Dropping a column
, 为了不阻塞 DML 操作,需要引入row_log
来暂存在 DDL 过程中用户的数据修改操作,而在二级索引的创建过程中并不需要 rebuild table, 所以不需要row_log_table
, 用户对于其他字段的数据的修改可以直接基于聚簇索引进行修改, 而对于创建二级索引的字段,需要通过row_log
来处理二级索引创建过程中的 DML 操作.
假如二级索引创建的过程中发生 Crash, 重启后打开临时文件的 Tablespace 会清理上次意外 Crash 遗留的临时文件.
1 | /** Definition of an index being created */ |
name
即索引名.rebuild
表示是否需要重建表.ind_type
表示索引类型.key_number
表示表中索引数量.n_fields
表示索引字段的数量.fields
表示索引字段的定义.在 MySQL 的查询过程中,SQL 优化器会选择合适的索引进行检索,在使用二级索引的过程中,因为二级索引没有存储全部的数据,假如二级索引满足查询需求,则直接返回,即为覆盖索引,反之则需要回表去主键索引(聚簇索引)查询。
例如执行SELECT * FROM users WHERE age=35;
则需要进行回表:
使用EXPLAIN
查看执行计划可以看到使用的索引是我们之前创建的index_age
:
1 | MySQL [sbtest]> EXPLAIN SELECT * FROM users WHERE age=35; |
二级索引是指定字段与主键的映射,主键长度越小,普通索引的叶子节点就越小,二级索引占用的空间也就越小,所以要避免使用过长的字段作为主键。
]]>偶然在网上看到一个问题: OOM 是按照虚拟内存还是实际内存来打分? 这里“实际内存”表达的意思应该是物理内存,而“打分”想表达的意思应该是 OOM killer 机制里面的 badness score
. 当内存吃紧时,假如开启了 OOM killer,OOM killer 会计算进程的 badness score
, badness score
越高,就越优先被 OOM killer 杀死.
当内存吃紧,页分配器尝试回收物理Page失败后,会调用 OOM killer,选择 badness score
最高的进程杀死,释放内存. badness score
的分数范围是0-1000, 0表示不杀死, 1000表示总是杀死, 可以直接通过 cat /proc/<pid>/oom_score
查看进程的 badness score
.
这个问题的本质是 OOM 机制是基于虚拟内存还是物理内存,我们可以先通过一个实验验证这个问题:
通过free -h
可以查看机器的内存情况,物理内存为16GB
1 |
|
这段代码新建了一个名为 oom_killer_file
的文件,先使用 posix_fallocate()
预分配16GB的大小,然后利用 mmap()
分配16GB的虚拟空间, 这段代码会 sleep 600s, 假如 OOM killer 是基于虚拟内存的,这段代码会被 kill. mmap()
的原理可以查看文章mmap源码分析, 调用 mmap()
会为进程分配虚拟内存,当真正写入触发缺页中断时才分配物理内存页.
1 | g++ -std=c++11 -O2 oom_killer.cc -o oom_killer |
我们通过 htop
观察发现进程确实已经分配了16GB的虚拟内存,但物理内存只有1768 bytes, 而经过600s的运行,程序并没有被 kill, 所以可以断定 OOM killer 不是基于虚拟内存而应该是物理内存计算 badness score
.
OOM killer的核心函数是 out_of_memory()
, 执行流程如下:
check_panic_on_oom()
检查是否允许执行内核恐慌,假如允许,需要重启系统./proc/sys/vm/oom_kill_allocating_task
即允许 kill 掉当前正在申请分配物理内存的进程,那么杀死当前进程.select_bad_process
,选择 badness score
最高的进程.oom_kill_process
, 杀死选择的进程.我们分析 badness score
的计算函数来理解 OOM killer如何选择需要被 kill 掉的进程:
1 | unsigned long oom_badness(struct task_struct *p, struct mem_cgroup *memcg, |
通过分析 badness score
的计算函数,我们可以发现 OOM killer 是基于RSS即常驻的物理内存来选择进程进行kill, 从而释放内存. Linux内核内存管理部分最主要的一个逻辑就是延迟分配.
内核版本: 5.0
cgroup
全称Control Groups,顾名思义就是把进程放到一个组里面统一加以控制,cgroup
可以限制进程的各种资源,包括用来控制一组进程的内存使用量,cgroup
把各种资源控制器成为子系统,内存控制即为内存子系统.
cgroup
目前现存两个版本,我们仅讨论cgroup v1
的使用方法.
Centos安装cgroup:
1 | sudo yum -y install libcgroup-tools |
在目录`/sys/fs/cgroup”下挂在tmpfs文件系统
1 | mount -t tmpfs none /sys/fs/cgroup |
在目录/sys/fs/cgroup
下创建目录memory
1 | mkdir /sys/fs/cgroup/memory |
在目录sys/fs/cgroup/memory
下挂载cgroup文件系统, 把内存资源控制器关联到控制组
1 | mount -t cgroup -o memory none /sys/fs/cgroup/memory |
创建新的控制组
1 | mkdir /sys/fs/cgroup/memory/test_memory |
设置控制组的内存使用限制2G
:
1 | sudo echo 2147483648 > /sys/fs/cgroup/memory/test_memory/memory.limit_in_bytes |
将线程组加入控制组:
1 | sudo echo <pid> > /sys/fs/cgroup/memory/test_memory/cgroup.procs |
或者启动进程附带控制组:
1 | cgexec -g memory:test_memory ./a.out |
mem_cgroup
cgroup
的内存资源控制器限制每一个控制组的Page Cache
和RSS
物理内存.
1 | /* |
结构体page_counter
是页计数器,单位为Page:
1 | struct page_counter { |
当为内存控制组中的进程分配物理内存时,会记录内存使用量, 内存记账简单的理解为记录控制组的内存使用量, 以下是记录的时间点:
我们仅分析4类中较为常见的访问文件分配物理页的内存记账处理过程:
我们以ext4文件系统为例, 当需要读取文件时,某个Page不在内存中,需要把该Page读取至内存中,即调用address_space
的操作函数ext4_readpage
:
mem_cgroup_try_charge()
用来表示尝试记账, 把内存控制组的内存计数加上指定的数量.
如果成功,调用mem_cgroup_commit_charge()
以提交计数,否则调用mem_cgroup_cancel_charge()
放弃计数.
cgroup的内存控制器是默认开启OOM killer,当进程消耗的内存超过了cgroup的限制,就会调用OOM killer,向指定的进程发送杀死信号SIGKILL
. 假如触发了OOM,关于crgroup内存控制kill的信息,可以通过dmesg
进行查看.
新建了一个名为test_cgroup
的控制组, 使用进程a.out
申请超过cgroup限制大小的内存:
使用cgroup遇到cgroup change of group failed
问题:
注意
/sys/fs/cgroup/test_memory
即控制组的目录权限, 是否与执行进程的文件权限保持一致.
即使进程以及完全退出,cgroup的内存控制组目录仍然无法清理?
因为cgroup会限制进程使用Page Cache,而Page Cache的清理不会随着进程的退出而完成,所以当我们使用cgroup限制的进程有文件读写操作从而使用了Page Cache, 会导致cgroup内存控制组目录无法清理,所以正确的做法是清空Page Cache:
echo 3 > /proc/sys/vm/drop_caches
或者echo 3 > /cgroup/memory/test_memory/memory.drop_caches
《Linux内核深度解析》
]]>内核版本: 5.0
Page Cache
是内核与存储介质的重要缓存结构,当我们使用write()
或者read()
读写文件时,假如不使用O_DIRECT
标志位打开文件,我们均需要经过Page Cache
来帮助我们提高文件读写速度。而在 MySQL 的设计实现中,读写数据文件使用了O_DIRECT
标志,其目的是使用自身Buffer Pool
的缓存算法。
根据之前总结的 Linux 内存管理文章,在 Linux 内核内存的基本单元是Page
,而Page Cache
也驻存于物理内存,所以Page Cache
的缓存基本单位也是Page
,而Page Cache
缓存的内容属于文件系统,所以Page Cache
属于文件系统与物理内存管理的枢纽。
介绍Page Cache
必不可少的需要涉及VFS的内容,这里我们仅仅简单的介绍相关数据结构的具体含义,文件系统的实现细节暂且略过。Page Cache
整个模块代码量巨大,我们侧重于Page Cache
的刷脏策略分析。
include/linux/fs.h
inode
在文件系统代表一个文件的元信息结构。
1 | struct inode { |
i_mapping
代表inode
所拥有的address_space
include/linux/fs.h
这里我们假定address_space
缓存的Page
来自于磁盘上的文件,而Page Cache
并不是类似于 MySQL 中Buffer Pool
一个缓存结构,它结合了于内核的内存管理和文件系统的address_space
结构。address_space
管理对应的文件映射在物理内存中缓存Page
:
1 | struct address_space { |
host
代表address_space
所属的inode
。i_pages
代表该address_space
缓存的Page
。gfp_mask
代表内存分配flags。i_mmap_writable
代表共享内存映射的Page
数量。i_mmap
代表该address_space
缓存的Page
所存放的rb-tree。
i_mmap_rwsem
用来保护i_mmap
和i_mmap_writable
的自旋锁。
nrpages
代表该address_space
缓存的Page
数量。writeback_index
代表回写时所使用的索引。a_ops
代表address_space
的操作方法函数。flags
代表错误位。wb_err
代表address_space
最近操作方式的错误码。private_lock
用来保护private_list
的自旋锁。address_space_operations
代表address_space
支持的操作方法:
1 | struct address_space_operations { |
writepage
:将Page
写回磁盘。readpage
: 从磁盘读取Page
。writepages
: 写多个Page
至磁盘。set_page_dirty
:设置某个Page
为脏页。readpages
: 读取多个Page
, 一般用来预读。write_begin
: 准备一个写操作。write_end
: 完成一个写操作。invalidatepage
:使该Page
无效。releasepage
:释放Page
。direct_IO
:对address_space
中的所有Page
进行DIO。我们在Linux内核源码分析-内存请页机制中分析了缺页中断时,当访问的 Page Table 尚未分配,即vma
对应磁盘上的某一个文件时,会调用vma->vm_ops->fault(vmf)
对应的文件系统的缺页处理函数。
1 | page = page_cache_alloc(); |
以ext4
为例,ext4_filemap_fault()
为缺页处理函数,具体调用了内存管理模块的filemap_fault()
来完成:
1 | vm_fault_t filemap_fault(struct vm_fault *vmf) |
Page Cache 的插入主要流程如下:
address_space
的i_pages
.address_space
的readpage()
来读取指定 offset 的 Page.假如 Page Cache 中的 Page 经过了修改,它的 flags 会被置为PG_dirty
. 在 Linux 内核中,假如没有打开O_DIRECT
标志,写操作实际上会被延迟刷盘,以下几种策略可以将脏页刷盘:
fsync()
或者sync
强制落盘在这里我们仅仅分析周期回写和强制回写
bdi
是backing device info
的缩写,它描述备用存储设备相关信息,就是我们通常所说的存储介质 SSD 硬盘等等。Linux 内核为每一个存储设备构造了一个backing_dev_info
,假如磁盘有几个分区,每个分区对应一个backing_dev_info
结构体.
1 | /* include/linux/backing-dev-defs.h */ |
bdi_list
是全局维护的所有backing_dev_info
链表.wb
是脏页回写控制块.1 | /* include/linux/backing-dev-defs.h */ |
bdi
是该bdi_writeback
所属的backing_dev_info
.
b_dirty
代表文件系统中被修改的inode
节点.
b_io
代表等待 I/O 的inode
节点.
dwork
是一个封装的延迟工作任务,由它的主函数将脏页回写存储设备:
1 | /* mm/backing-dev.c */ |
bdi_writeback
对象封装了dwork
以及需要处理的inode
队列。当 Page Cache 调用__mark_inode_dirty()
时,将需要刷脏的inode
挂载到bdi_writeback
对象的b_dirty
队列上,然后唤醒对应的bdi
刷脏线程。
wb_workfn
是回写控制块的回调函数
1 | /* fs/fs-writeback.c */ |
wb_do_writeback
分别实现了周期回写和后台回写两部分: wb_check_old_data_flush()
,wb_check_background_flush()
,具体实现我们分不同的场景分析,因为每一个存储设备都有一个backing_dev_info
,所以每个存储设备之间的脏页回写互不影响.
周期回写的时间单位是0.01s,默认为5s,可以通过/proc/sys/vm/dirty_writeback_centisecs
调节:
1 | /* mm/page-writeback.c */ |
Page
驻留为dirty
状态的时间单位也为0.01s,默认为30s,可以通过/proc/sys/vm/dirty_expire_centisecs
来调节:
1 | /* mm/page-writeback.c */ |
1 | /* fs/fs-writeback.c */ |
强制回写分为后台线程回写和用户进程主动回写。
当脏页数量超过了设定的阈值,后台回写线程会将脏页写回存储设备,后台回写阈值是脏页占可用内存大小的比例或者脏页的字节数,默认比例是10. 用户可以通过修改/proc/sys/vm/dirty_background_ratio
修改脏页比或者修改/proc/sys/vm/dirty_background_bytes
修改脏页的字节数。
而在用户调用write()
接口写文件时,假如脏页占可用内存大小的比例或者脏页的字节数超过了设定的阈值,会进行主动回写,用户可以通过设置/proc/sys/vm/dirty_ratio
或者/proc/sys/vm/dirty_bytes
修改这两个阈值。
1 | /* fs/fs-writeback.c */ |
假如用户调用write()
或者其他写文件接口时,在写文件的过程中,产生了脏页后会调用balance_dirty_pages
调节平衡脏页的状态. 假如脏页的数量超过了(后台回写设定的阈值+ 进程主动回写设定的阈值) / 2 ,即(background_thresh + dirty_thresh) / 2
会强制进行脏页回写. 用户线程进行的强制回写仍然是触发后台线程进行回写
触发 Page Cache 刷脏的几个条件如下:
/proc/sys/vm/dirty_writeback_centisecs
调节周期.Page
的写回操作是文件系统的封装,即address_space
的writepage
操作.
因为Linux内核为每个存储设备都设置了刷脏进程,所以假如在日常开发过程遇到了刷脏压力过大的情况下,在条件允许的情况下,将写入文件分散在不同的存储设备,可以提高的写入速度,减小刷脏的压力.
]]>