隐式锁概述

在数据库中,通常使用锁机制来协调多个线程并发访问某一资源。MySQL的锁类型分为表锁和行锁,表示对整张表加锁,主要用在DDL场景中,也可以由用户指定,主要由server层负责管理;而行锁指的是锁定某一行或几行,或者是行与行之间的间隙,行锁由存储引擎管理,例如最常使用的InnoDB。表锁占用系统资源小,实现简单,但锁定粒度大,发生锁冲突概率高,并发度比较低。行锁占用系统资源大,锁定粒度小,发生锁冲突概率低,并发度比较高。

InnoDB将锁分为锁类型和锁模式两类。锁类型包括表锁和行锁,而行锁还细分为记录锁、间隙锁、插入意向锁、Next-Key等更细的子类型。锁模式描述的是加什么锁,例如读锁和写锁, 在源码中的定义如下(基于MySQL 8.0):

  1. /* Basic lock modes */
  2. enum lock_mode {
  3. LOCK_IS = 0, /* intention shared */
  4. LOCK_IX, /* intention exclusive */
  5. LOCK_S, /* shared */
  6. LOCK_X, /* exclusive */
  7. LOCK_AUTO_INC, /* locks the auto-inc counter of a table in an exclusive mode*/
  8. ...
  9. };

当事务需要加锁的时,如果这个锁不可能发生冲突,InnoDB会跳过加锁环节,这种机制称为隐式锁。隐式锁是InnoDB实现的一种延迟加锁机制,其特点是只有在可能发生冲突时才加锁,从而减少了锁的数量,提高了系统整体性能。另外,隐式锁是针对被修改的B+ Tree记录,因此都是记录类型的锁,不可能是间隙锁或Next-Key类型。

Insert语句的加锁流程

隐式锁主要用在插入场景中。在Insert语句执行过程中,必须检查两种情况,一种是如果记录之间加有间隙锁,为了避免幻读,此时是不能插入记录的,另一中情况如果Insert的记录和已有记录存在唯一键冲突,此时也不能插入记录。除此之外,insert语句的锁都是隐式锁,但跟踪代码发现,insert时并没有调用lock_rec_add_to_queue函数进行加锁, 其实所谓隐式锁就是在Insert过程中不加锁。

只有在特殊情况下,才会将隐式锁转换为显示锁。这个转换动作并不是加隐式锁的线程自发去做的,而是其他存在行数据冲突的线程去做的。例如事务1插入记录且未提交,此时事务2尝试对该记录加锁,那么事务2必须先判断记录上保存的事务id是否活跃,如果活跃则帮助事务1建立一个锁对象,而事务2自身进入等待事务1的状态,可以参考如下例子:

  1. 1. 创建测试表
  2. root@localhost : (none) 14:24:01> Ceate table t(a int not null, b blob, primary key(a));
  3. Query OK, 1 row affected (0.01 sec)
  4. 2. 事务1插入数据
  5. root@localhost : mytest 14:24:16> begin;
  6. Query OK, 0 rows affected (0.00 sec)
  7. // 创建隐式锁,不需要创建锁结构,也不需要添加到lock hash table中
  8. root@localhost : mytest 14:24:21> insert into t values (2, repeat('b',7000));
  9. Query OK, 1 row affected (0.02 sec)
  10. root@localhost : mytest 14:35:20> select * from performance_schema.data_locks; // 此时只有表锁,没有行锁
  11. +--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+-----------+-------------+-----------+
  12. | ENGINE | ENGINE_LOCK_ID | ENGINE_TRANSACTION_ID | THREAD_ID | EVENT_ID | OBJECT_SCHEMA | OBJECT_NAME | PARTITION_NAME | SUBPARTITION_NAME | INDEX_NAME | OBJECT_INSTANCE_BEGIN | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
  13. +--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+-----------+-------------+-----------+
  14. | INNODB | 47865673030896:1063:47865663453848 | 1811 | 75 | 1 | mytest | t | NULL | NULL | NULL | 47865663453848 | TABLE | IX | GRANTED | NULL |
  15. +--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+-----------+-------------+-----------+
  16. 1 row in set (0.01 sec
  17. 3. 事务2插入相同的数据
  18. root@localhost : mytest 14:29:45> begin;
  19. Query OK, 0 rows affected (0.01 sec)
  20. root@localhost : mytest 14:29:48> insert into t values (2, repeat('b',7000)); // 主键冲突,将事务1的隐式锁转换为显示锁,事务2则创建S锁并等待
  21. root@localhost : mytest 14:36:04> select * from performance_schema.data_locks;
  22. +--------+-------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
  23. | ENGINE | ENGINE_LOCK_ID | ENGINE_TRANSACTION_ID | THREAD_ID | EVENT_ID | OBJECT_SCHEMA | OBJECT_NAME | PARTITION_NAME | SUBPARTITION_NAME | INDEX_NAME | OBJECT_INSTANCE_BEGIN | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
  24. +--------+-------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
  25. | INNODB | 47865673032032:1063:47865663454744 | 1816 | 77 | 1 | mytest | t | NULL | NULL | NULL | 47865663454744 | TABLE | IX | GRANTED | NULL |
  26. | INNODB | 47865673032032:2:4:2:47865661626392 | 1816 | 77 | 1 | mytest | t | NULL | NULL | PRIMARY | 47865661626392 | RECORD | S,REC_NOT_GAP | WAITING | 2 |
  27. | INNODB | 47865673030896:1063:47865663453848 | 1811 | 75 | 1 | mytest | t | NULL | NULL | NULL | 47865663453848 | TABLE | IX | GRANTED | NULL |
  28. | INNODB | 47865673030896:2:4:2:47865661623320 | 1811 | 77 | 1 | mytest | t | NULL | NULL | PRIMARY | 47865661623320 | RECORD | X,REC_NOT_GAP | GRANTED | 2 |
  29. +--------+-------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
  30. 4 rows in set (0.01 sec)
  31. root@localhost : mytest 14:36:54> select * from performance_schema.data_lock_waits;
  32. +--------+-------------------------------------+----------------------------------+----------------------+---------------------+----------------------------------+-------------------------------------+--------------------------------+--------------------+-------------------+--------------------------------+
  33. | ENGINE | REQUESTING_ENGINE_LOCK_ID | REQUESTING_ENGINE_TRANSACTION_ID | REQUESTING_THREAD_ID | REQUESTING_EVENT_ID | REQUESTING_OBJECT_INSTANCE_BEGIN | BLOCKING_ENGINE_LOCK_ID | BLOCKING_ENGINE_TRANSACTION_ID | BLOCKING_THREAD_ID | BLOCKING_EVENT_ID | BLOCKING_OBJECT_INSTANCE_BEGIN |
  34. +--------+-------------------------------------+----------------------------------+----------------------+---------------------+----------------------------------+-------------------------------------+--------------------------------+--------------------+-------------------+--------------------------------+
  35. | INNODB | 47865673032032:2:4:2:47865661626392 | 1816 | 77 | 1 | 47865661626392 | 47865673030896:2:4:2:47865661623320 | 1811 | 77 | 1 | 47865661623320 |
  36. +--------+-------------------------------------+----------------------------------+----------------------+---------------------+----------------------------------+-------------------------------------+--------------------------------+--------------------+-------------------+--------------------------------+
  37. 1 row in set (0.00 sec)

如何判断隐式锁是否存在

InnoDB的每条记录中都一个隐含的trx_id字段,这个字段存在于聚集索引的B+Tree中。假设只有主键索引,则在进行插入时,行数据的trx_id被设置为当前事务id;假设存在二级索引,则在对二级索引进行插入时,需要更新所在page的max_trx_id。

因此对于主键,只需要通过查看记录隐藏列trx_id是否是活跃事务就可以判断隐式锁是否存在。 对于对于二级索引会相对比较麻烦,先通过二级索引页上的max_trx_id进行过滤,如果无法判断是否活跃则需要通过应用undo日志回溯老版本数据,才能进行准确的判断。

隐式锁转换

将记录上的隐式锁转换为显示锁是由函数lock_rec_convert_impl_to_expl完成的,代码如下:

  1. static void lock_rec_convert_impl_to_expl(const buf_block_t *block,
  2. const rec_t *rec, dict_index_t *index,
  3. const ulint *offsets) {
  4. trx_t *trx;
  5. ut_ad(!LockMutexOwner::own(LOCK_REC_SHARD, block->page.id));
  6. ut_ad(page_rec_is_user_rec(rec));
  7. ut_ad(rec_offs_validate(rec, index, offsets));
  8. ut_ad(!page_rec_is_comp(rec) == !rec_offs_comp(offsets));
  9. if (index->is_clustered()) {
  10. trx_id_t trx_id;
  11. // 对于主键,获取记录上的DB_TRX_ID系统隐藏列,获取事务ID
  12. trx_id = lock_clust_rec_some_has_impl(rec, index, offsets);
  13. // 根据事务 ID,判断当前事务是否为活跃事务,若为活跃事务,则返回此活跃事务对象
  14. trx = trx_rw_is_active(trx_id, NULL, true);
  15. } else {
  16. ut_ad(!dict_index_is_online_ddl(index));
  17. // 对于二级索引,通过Page的MAX_TRX_ID判断事务是否活跃
  18. trx = lock_sec_rec_some_has_impl(rec, index, offsets);
  19. if (trx && !can_trx_be_ignored(trx)) {
  20. ut_ad(!lock_rec_other_trx_holds_expl(LOCK_S | LOCK_REC_NOT_GAP, trx, rec,
  21. block));
  22. }
  23. }
  24. if (trx != 0) {
  25. ulint heap_no = page_rec_get_heap_no(rec);
  26. ut_ad(trx_is_referenced(trx));
  27. /* If the transaction is still active and has no
  28. explicit x-lock set on the record, set one for it.
  29. trx cannot be committed until the ref count is zero. */
  30. // 如果是活跃事务,则将隐式锁转换为显示锁
  31. lock_rec_convert_impl_to_expl_for_trx(block, rec, index, offsets, trx,
  32. heap_no);
  33. }
  34. }

主键的隐式锁转换

对于主键,通过lock_clust_rec_some_has_impl函数读取记录上的事务ID,然后再判断该事务是否活跃,判断事务是否提交由函数trx_rw_is_active完成,代码如下:

  1. UNIV_INLINE
  2. trx_t *trx_rw_is_active(trx_id_t trx_id, /*!< in: trx id of the transaction */
  3. ibool *corrupt, /*!< in: NULL or pointer to a flag
  4. that will be set if corrupt */
  5. bool do_ref_count) /*!< in: if true then increment the
  6. trx_t::n_ref_count */
  7. {
  8. trx_t *trx;
  9. /* Fast checking. If it's smaller than minimal active trx id, just
  10. return NULL. */
  11. if (trx_sys->min_active_id.load() > trx_id) {
  12. return (NULL);
  13. }
  14. trx_sys_mutex_enter();
  15. trx = trx_rw_is_active_low(trx_id, corrupt);
  16. if (trx != 0) {
  17. trx = trx_reference(trx, do_ref_count);
  18. }
  19. trx_sys_mutex_exit();
  20. return (trx);
  21. }

MySQL早期版本在判断事务活跃并且转换隐式锁的全过程都要持有lock_sys mutex全局锁,目的是防止在此期间事务提交或回滚,但在读写事务并发很高的情况下,这种开销是非常大的。MySQL在5.7版本引入了隐式锁转换的优化:http://dev.mysql.com/worklog/task/?id=6899,通过在事务对象上增加引用计数,可以在不全程持有lock_sys mutex全局锁的情况下,保证进行隐式锁转换的事务不会提交或回滚。lock_rec_convert_impl_to_expl_for_trx负责将隐式锁转化为显示锁,创建显示锁结构并且加入到lock hash table中。锁模式为LOCK_REC | LOCK_X | LOCK_REC_NOT_GAP,由于隐式锁针对的是被修改的B+树记录,因此不是Gap或Next-Key类型,都是Record类型的锁。

二级索引的隐式锁转换

由于二级索引的记录不包含事务ID,如何判断二级索引记录上是否有隐式锁呢?前面提到二级索引页的PAGE_MAX_TRX_ID字段保存了一个最大事务ID,当二级索引页中的任何记录更新后,都会更新PAGE_MAX_TRX_ID的值。因此,我们先可以通过PAGE_MAX_TRX_ID进行判断,如果当前PAGE_MAX_TRX_ID的值小于当前活跃事务的最新ID,说明修改这条记录的事务已经提交,则不存在隐式锁,反之则可能存在隐式锁,需要通过聚集索引进行判断,其判断过程由函数row_vers_impl_x_locked_low完成,关键代码如下:

  1. trx_t *row_vers_impl_x_locked_low(
  2. const rec_t *clust_rec, /*!< in: clustered index record */
  3. dict_index_t *clust_index, /*!< in: the clustered index */
  4. const rec_t *rec, /*!< in: secondary index record */
  5. dict_index_t *index, /*!< in: the secondary index */
  6. const ulint *offsets, /*!< in: rec_get_offsets(rec, index) */
  7. mtr_t *mtr) /*!< in/out: mini-transaction */
  8. {
  9. trx_id_t trx_id;
  10. ibool corrupt;
  11. ulint comp;
  12. ulint rec_del;
  13. const rec_t *version;
  14. rec_t *prev_version = NULL;
  15. ulint *clust_offsets;
  16. mem_heap_t *heap;
  17. dtuple_t *ientry = NULL;
  18. mem_heap_t *v_heap = NULL;
  19. const dtuple_t *cur_vrow = NULL;
  20. DBUG_ENTER("row_vers_impl_x_locked_low");
  21. ut_ad(rec_offs_validate(rec, index, offsets));
  22. heap = mem_heap_create(1024);
  23. clust_offsets =
  24. rec_get_offsets(clust_rec, clust_index, NULL, ULINT_UNDEFINED, &heap);
  25. // 获取保存在聚集索引记录上的事务ID
  26. trx_id = row_get_rec_trx_id(clust_rec, clust_index, clust_offsets);
  27. corrupt = FALSE;
  28. // 判断事务是否活跃
  29. trx_t *trx = trx_rw_is_active(trx_id, &corrupt, true);
  30. // 事务已提交,返回0
  31. if (trx == 0) {
  32. DBUG_RETURN(0);
  33. }
  34. comp = page_rec_is_comp(rec);
  35. // 获取deleted_flag
  36. rec_del = rec_get_deleted_flag(rec, comp);
  37. for (version = clust_rec;; version = prev_version) {
  38. // 通过undo日志获取老版本记录
  39. trx_undo_prev_version_build(clust_rec, mtr, version, clust_index,
  40. clust_offsets, heap, &prev_version, NULL,
  41. dict_index_has_virtual(index) ? &vrow : NULL, 0,
  42. nullptr);
  43. // 没有之前老版本的记录,即是当前事务插入的记录,则二级索引记录rec含有implicit lock
  44. if (prev_version == NULL) {
  45. if (rec_del) {
  46. trx_release_reference(trx);
  47. trx = 0;
  48. }
  49. break;
  50. }
  51. // 获取获取lao'ban'b的各个字段的偏移量
  52. clust_offsets = rec_get_offsets(prev_version, clust_index, NULL,
  53. ULINT_UNDEFINED, &heap);
  54. // 获取老版本记录的deleted_flag
  55. vers_del = rec_get_deleted_flag(prev_version, comp);
  56. // 获取老版本记录的事务ID
  57. prev_trx_id = row_get_rec_trx_id(prev_version, clust_index, clust_offsets);
  58. // 构造老版本tuple
  59. row = row_build(ROW_COPY_POINTERS, clust_index, prev_version, clust_offsets,
  60. NULL, NULL, NULL, &ext, heap);
  61. // 构造老版本二级索引tuple
  62. entry = row_build_index_entry(row, ext, index, heap);
  63. // 两个版本的二级索引记录相等
  64. if (0 == cmp_dtuple_rec(entry, rec, index, offsets)) {
  65. // 两个记录的deleted_flag位不同,则表示某活跃事务删除了记录,因此二级索引记录含有隐式锁
  66. if (rec_del != vers_del) {
  67. break;
  68. }
  69. dtuple_set_types_binary(entry, dtuple_get_n_fields(entry));
  70. if (0 != cmp_dtuple_rec(entry, rec, index, offsets)) {
  71. break;
  72. }
  73. } else if (!rec_del) {
  74. // 两个版本的二级索引不相同,且记录rec的deleted_flag为0, 表示某活跃事务
  75. // 更新了二级索引记录,因此二级索引记录含有隐式锁
  76. break;
  77. }
  78. result_check:
  79. // 如果两个版本的二级索引记录相等,并且两个记录的deleted_flag位是相同的,
  80. // 或者两个版本的二级索引不相同,且记录rec的deleted_flag为1,此时判断trx->id
  81. // 和prev_trx_id,如果不相等则表示之前的事务已经修改了记录,因此记录上不含有隐式锁。
  82. // 否则,需要通过再之前的记录版本进行判断。
  83. if (trx->id != prev_trx_id) {
  84. /* prev_version was the first version modified by
  85. the trx_id transaction: no implicit x-lock */
  86. trx_release_reference(trx);
  87. trx = 0;
  88. break;
  89. }
  90. }
  91. DBUG_PRINT("info", ("Implicit lock is held by trx:" TRX_ID_FMT, trx_id));
  92. if (v_heap != NULL) {
  93. mem_heap_free(v_heap);
  94. }
  95. mem_heap_free(heap);
  96. DBUG_RETURN(trx);
  97. }

二级索引在判断出隐式锁存在后,也是调用lock_rec_convert_impl_to_expl_for_trx函数将隐式锁转化为显示锁,并将其加入到lock hash table中。

判重过程

基于隐式锁,如何保证插入数据时主键或唯一二级索引的unique特性呢 ? 对于主键,插入时判重主要调用流程如下:

  1. |-row_ins_step 插入记录
  2. |-memset(node->trx_id_buf, 0, DATA_TRX_ID_LEN);
  3. |-trx_write_trx_id(node->trx_id_buf, trx->id)
  4. |-lock_table 给表加IX
  5. |-row_ins 插入记录
  6. |-if (node->state == INS_NODE_ALLOC_ROW_ID)
  7. |-row_ins_alloc_row_id_step
  8. |-if (dict_index_is_unique())
  9. |-return
  10. |-dict_sys_get_new_row_id 分配一个rowid
  11. |-mutex_enter(&dict_sys-|-mutex);
  12. |-if (0 == (id % DICT_HDR_ROW_ID_WRITE_MARGIN))
  13. |-dict_hdr_flush_row_id()
  14. |-dict_sys-|-row_id++
  15. |-PolicyMutex::exit()
  16. |-dict_sys_write_row_id
  17. |-node->state = INS_NODE_INSERT_ENTRIES;
  18. |-while (node->index != NULL)
  19. |-row_ins_index_entry_step 向索引中插入记录,把 innobase format field 的值赋给对应的index entry field
  20. |-n_fields = dtuple_get_n_fields(entry); // 包含系统列
  21. |-dtuple_check_typed 检查要插入的行的每个列的类型有效性
  22. |-row_ins_index_entry_set_vals 根据该索引以及原记录,将组成索引的列的值组成一个记录
  23. |-for (i = 0; i < n_fields + num_v; i++)
  24. |-field = dtuple_get_nth_field(entry, i);
  25. |-row_field = dtuple_get_nth_field(row, ind_field->col->ind);
  26. |-dfield_set_data(field, dfield_get_data(row_field), len);
  27. |-field->data = (void *)data;
  28. |-dtuple_check_typed 检查组成的记录的有效性
  29. |-row_ins_index_entry 插入索引项
  30. |-dict_index_t::is_clustered()
  31. |-row_ins_clust_index_entry 插入聚集索引
  32. |-dict_index_is_unique
  33. |-log_free_check
  34. |-row_ins_clust_index_entry_low 先尝试乐观插入,修改叶子节点 BTR_MODIFY_LEAF
  35. |-mtr_t::mtr_t()
  36. |-mtr_t::start()
  37. |-初始化mtr的各个状态变量
  38. |-默认模式为MTR_LOG_ALL,表示记录所有的数据变更
  39. |-mtr状态设置为ACTIVE状态(MTR_STATE_ACTIVE
  40. |-为锁管理对象和日志管理对象初始化内存(mtr_buf_t),初始化对象链表
  41. |-btr_pcur_t::open() btr_pcur_open_low
  42. |-btr_cur_search_to_nth_level cursor移动到索引上待插入的位置
  43. |-取得根页页号
  44. |-page_cursor = btr_cur_get_page_cur(cursor);
  45. space = dict_index_get_space(index);
  46. page_no = dict_index_get_page(index);
  47. |-buf_page_get_gen 取得本层页面,首次为根页面
  48. |-mtr_memo_push
  49. |-page_cur_search_with_match_bytes 在本层页面进行游标定位
  50. |-btr_cur_get_page 取得本层页面,首次为根页面
  51. |-page_get_infimum_offset
  52. |-page_rec_get_next
  53. |-page_rec_is_supremum
  54. |-row_ins_must_modify_rec
  55. |-row_ins_duplicate_error_in_clust // Checks if a unique key violation error would occur at an index entry insert
  56. |-row_ins_set_shared_rec_lock cursor 对应的已有记录加 S 锁(可能会等待)保证记录上的操作,包括:Insert/Update/Delete
  57. |-lock_clust_rec_read_check_and_lock 判断 cursor 对应的记录上是否存在隐式锁(有活跃事务), 若存在,则将隐式锁转化为显示锁
  58. |-lock_rec_convert_impl_to_expl 如果是活跃事务,则将隐式锁转换为显示锁
  59. |-lock_rec_lock 如果上面的隐式锁转化成功,此处加S锁将会等待,直到活跃事务释放锁。
  60. |-row_ins_dupl_err_with_rec // S锁加锁完成之后,再次判断最终决定是否存在unique冲突, 1. 判断insert 记录与 cursor 对应的记录取值是否相同,
  61. 2.二级唯一键值锁引,可以存在多个NULL值, 3.最后判断记录的delete_bit状态,判断记录是否被删除提交
  62. |-cmp_dtuple_rec_with_match
  63. |-return !rec_get_deleted_flag();
  64. |-btr_cur_optimistic_insert // 插入记录
  65. |-mtr_t::commit() // 提交mtr

插入主键时如果出现了重复的行,持有重复行数据的事务并没有提交或者回滚,需要等其事务完成提交或者回滚,如果存在重复行则报错,否则继续插入。在判重过程中,对游标对应的已有记录加S锁,保证记录上的操作(包括Insert/Update/Delete) 已经提交或者回滚, 在真正进行insert操作进行时,会尝试对下一个record加X锁。

当更新修改聚簇索引记录时,将对受影响的二级索引记录加隐式锁,在插入新的二级索引记录之前执行duplicate check, 如果修改二级索引的记录是活跃的,则先将隐式锁转换成显示锁,然后对二级索引记录尝试加S锁,加锁成功后再进行duplicate check。