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

当事务需要加锁的时,如果这个锁不可能发生冲突,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. root@localhost : (none) 14:24:01> Ceate table t(a int not null, b blob, primary key(a));
  2. Query OK, 1 row affected (0.01 sec)
  3. 2. 事务1插入数据
  4. root@localhost : mytest 14:24:16> begin;
  5. Query OK, 0 rows affected (0.00 sec)
  6. // 创建隐式锁,不需要创建锁结构,也不需要添加到lock hash table中
  7. root@localhost : mytest 14:24:21> insert into t values (2, repeat('b',7000));
  8. Query OK, 1 row affected (0.02 sec)
  9. root@localhost : mytest 14:35:20> select * from performance_schema.data_locks; // 此时只有表锁,没有行锁
  10. +--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+-----------+-------------+-----------+
  11. | 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 |
  12. +--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+-----------+-------------+-----------+
  13. | INNODB | 47865673030896:1063:47865663453848 | 1811 | 75 | 1 | mytest | t | NULL | NULL | NULL | 47865663453848 | TABLE | IX | GRANTED | NULL |
  14. +--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+-----------+-------------+-----------+
  15. 1 row in set (0.01 sec
  16. 3. 事务2插入相同的数据
  17. root@localhost : mytest 14:29:45> begin;
  18. Query OK, 0 rows affected (0.01 sec)
  19. root@localhost : mytest 14:29:48> insert into t values (2, repeat('b',7000)); // 主键冲突,将事务1的隐式锁转换为显示锁,事务2则创建S锁并等待
  20. root@localhost : mytest 14:36:04> select * from performance_schema.data_locks;
  21. +--------+-------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
  22. | 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 |
  23. +--------+-------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
  24. | INNODB | 47865673032032:1063:47865663454744 | 1816 | 77 | 1 | mytest | t | NULL | NULL | NULL | 47865663454744 | TABLE | IX | GRANTED | NULL |
  25. | INNODB | 47865673032032:2:4:2:47865661626392 | 1816 | 77 | 1 | mytest | t | NULL | NULL | PRIMARY | 47865661626392 | RECORD | S,REC_NOT_GAP | WAITING | 2 |
  26. | INNODB | 47865673030896:1063:47865663453848 | 1811 | 75 | 1 | mytest | t | NULL | NULL | NULL | 47865663453848 | TABLE | IX | GRANTED | NULL |
  27. | INNODB | 47865673030896:2:4:2:47865661623320 | 1811 | 77 | 1 | mytest | t | NULL | NULL | PRIMARY | 47865661623320 | RECORD | X,REC_NOT_GAP | GRANTED | 2 |
  28. +--------+-------------------------------------+-----------------------+-----------+----------+---------------+-------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
  29. 4 rows in set (0.01 sec)
  30. root@localhost : mytest 14:36:54> select * from performance_schema.data_lock_waits;
  31. +--------+-------------------------------------+----------------------------------+----------------------+---------------------+----------------------------------+-------------------------------------+--------------------------------+--------------------+-------------------+--------------------------------+
  32. | 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 |
  33. | INNODB | 47865673032032:2:4:2:47865661626392 | 1816 | 77 | 1 | 47865661626392 | 47865673030896:2:4:2:47865661623320 | 1811 | 77 | 1 | 47865661623320 |
  34. +--------+-------------------------------------+----------------------------------+----------------------+---------------------+----------------------------------+-------------------------------------+--------------------------------+--------------------+-------------------+--------------------------------+
  35. 1 row in set (0.00 sec)

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

隐式锁转换

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

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

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

MySQL早期版本在判断事务活跃并且转换隐式锁的全过程都要持有lock_sys mutex全局锁,目的是防止在此期间事务提交或回滚,但在读写事务并发很高的情况下,这种开销是非常大的。MySQL在5.7版本引入了隐式锁转换的优化:,通过在事务对象上增加引用计数,可以在不全程持有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类型的锁。

二级索引的隐式锁转换

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

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

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