前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >原创|MySQL一个非预期锁等待分析

原创|MySQL一个非预期锁等待分析

作者头像
腾讯数据库技术
发布2022-10-28 11:40:08
6370
发布2022-10-28 11:40:08
举报

提示:公众号展示代码会自动折行,建议横屏阅读

背景

客户发现一个非预期内的锁等待现象,线上频繁出现锁告警,出现问题的case可以简化成以下SQL:

代码语言:javascript
复制
# 表结构和表数据CREATE TABLE `tab1` (  `id` bigint unsigned NOT NULL AUTO_INCREMENT,  `value` int NOT NULL,  `status` tinyint unsigned NOT NULL DEFAULT '1',  PRIMARY KEY (`id`),  KEY `idx_value` (`value`));insert into tab1 (value) values (5);insert into tab1 (value) values (10);

#seesion 1:begin;update `tab1` set `status` = 3 where (`value` = 10);

#session 2:update `tab1` set `status` = 3 where (`value` >= 4) and (`value` < 6); #阻塞

用户贼郁闷,隔离级别用的是 RC,两个session又都只更新一条数据,并且更新的还是两条不一样的数据,按理来说没有冲突,不应该造成阻塞。

原因分析

原始的场景中,用户的二级索引是建立在datetime类型上的,一开始怀疑是datetime类型在InnoDB层的比较出现了问题。

但是后面在int类型的二级索引上复现了该问题,因此可以排除是datetime类型导致的。

使用select * from performance_schema.data_lock_waits; 查看session 2等待的锁信息:

可以看到,session 2等待的锁是在idx_value这个索引的<10, 2>这条记录上,而这条记录正是被session 1持有的。

根据以上线索,透露出来的信息是,session 2尝试去加了session 1锁住的记录<10, 2>的锁,也就是说(`value` < 6)这个条件并没有在innodb层过滤掉<10, 2>这条记录。

通过分析源码,这个流程可以简化成:

Server层调用read_range_next()会循环调用innodb层的row_search_mvcc函数,在row_search_mvcc函数中执行以下步骤:

1. btr_pcur_open_with_no_init // mode = page_cur_ge,如果是第一次调用,找到第一条>= start_range的记录,如果不是第一次调用,找到pcur->next_rec

2. sel_set_rec_lock // 对pcur指向的rec记录加锁,如果加锁失败,返回等待。

3. row_search_idx_cond_check // 检查rec记录是否满足index condition。

代码语言:javascript
复制
ICP_RESULT row_search_idx_cond_check(    byte *mysql_rec,          /*!< out: record                              in MySQL format (invalid unless                              prebuilt->idx_cond == true and                              we return ICP_MATCH) */    row_prebuilt_t *prebuilt, /*!< in/out: prebuilt struct                              for the table handle */    const rec_t *rec,         /*!< in: InnoDB record */    const ulint *offsets)     /*!< in: rec_get_offsets() */{  ICP_RESULT result;  ulint i;
  ut_ad(rec_offs_validate(rec, prebuilt->index, offsets));
  if (!prebuilt->idx_cond) { // 如果index condition pushdown了,prebuilt->idx_cond为true,这里end_range并没有下推,所以这里直接返回了    return (ICP_MATCH);  }
  MONITOR_INC(MONITOR_ICP_ATTEMPTS);
  /* Convert to MySQL format those fields that are needed for  evaluating the index condition. */
  if (prebuilt->blob_heap != nullptr) {    mem_heap_empty(prebuilt->blob_heap);  }
  for (i = 0; i < prebuilt->idx_cond_n_cols; i++) {    const mysql_row_templ_t *templ = &prebuilt->mysql_template[i];
    /* Skip virtual columns */    if (templ->is_virtual) {      continue;    }
    if (!row_sel_store_mysql_field(            mysql_rec, prebuilt, rec, prebuilt->index, prebuilt->index, offsets,            templ->icp_rec_field_no, templ, ULINT_UNDEFINED, nullptr,            prebuilt->blob_heap)) {      return (ICP_NO_MATCH);    }  }
  /* We assume that the index conditions on  case-insensitive columns are case-insensitive. The  case of such columns may be wrong in a secondary  index, if the case of the column has been updated in  the past, or a record has been deleted and a record  inserted in a different case. */  result = innobase_index_cond(prebuilt->m_mysql_handler); // 通过该函数去判断记录是否满足下推条件  switch (result) {    case ICP_MATCH:      /* Convert the remaining fields to MySQL format.      If this is a secondary index record, we must defer      this until we have fetched the clustered index record. */      if (!prebuilt->need_to_access_clustered ||          prebuilt->index->is_clustered()) {        if (!row_sel_store_mysql_rec(mysql_rec, prebuilt, rec, nullptr, FALSE,                                     prebuilt->index, prebuilt->index, offsets,                                     false, nullptr, prebuilt->blob_heap)) {          ut_ad(prebuilt->index->is_clustered());          return (ICP_NO_MATCH);        }      }      MONITOR_INC(MONITOR_ICP_MATCH);      return (result);    case ICP_NO_MATCH:      MONITOR_INC(MONITOR_ICP_NO_MATCH);      return (result);    case ICP_OUT_OF_RANGE:      MONITOR_INC(MONITOR_ICP_OUT_OF_RANGE);      const auto record_buffer = row_sel_get_record_buffer(prebuilt);      if (record_buffer) {        record_buffer->set_out_of_range(true);      }      return (result);  }
  ut_error;  return (result);}

4. 返回server层该条记录

在server层read_range_next函数中,如果判断刚刚从innodb读上来的记录不在end_range之内,会调用unlock_row()接口放掉刚刚在innodb层加上的锁。

代码语言:javascript
复制
if (compare_key(end_range) > 0) {
      /*
        The last read row does not fall in the range. So request
        storage engine to release row lock if possible.
      */
      unlock_row(); // 这里最终会调用row_unlock_for_mysql函数放掉刚刚在 sel_set_rec_lock 加上的锁。
      result = HA_ERR_END_OF_FILE;
}

因此,session 2需要从innodb层读取两条记录去server层做判断--5和10。10恰巧又被session 1锁住,因此在innodb层读取记录的时候就判断需要锁等待,此时还没有返回到server层做end_range的范围过滤。

如果5和10之间有一条其他的记录,session 2不会被session 1持有的10上面的锁阻塞住。

总结

这个问题的本质原因,是end_range条件没有push down,如果end_range 下推到innodb层,会在row_search_mvcc函数中sel_set_rec_lock之后通过row_search_idx_cond_check函数检查是否满足end_range,这样就不会多加锁了。

追问:如果是主键索引,会是同样的表现吗?

先说结论,这里分两种情况:

  1. 如果是select for update主键索引,表现与二级索引一致,session 2是会发生阻塞的。
  2. 如果是update主键索引,session2 不会发生阻塞。

以下是对应的case:

  • select for update
代码语言:javascript
复制
# 表结构和表数据CREATE TABLE `tab1` (`id` bigint unsigned NOT NULL,`value` int NOT NULL DEFAULT 0,`status` tinyint unsigned NOT NULL DEFAULT '1',PRIMARY KEY (`id`));insert into tab1 (id) values (5);insert into tab1 (id) values (10);

#seesion 1:begin;select * from `tab1` where (`id` = 10) for update;

#session 2:select * from `tab1` where (`id` >= 4) and (`id` < 6) for update; #阻塞
  • update
代码语言:javascript
复制
# 表结构和表数据CREATE TABLE `tab1` (`id` bigint unsigned NOT NULL,`value` int NOT NULL DEFAULT 0,`status` tinyint unsigned NOT NULL DEFAULT '1',PRIMARY KEY (`id`));insert into tab1 (id) values (5);insert into tab1 (id) values (10);

#seesion 1:begin;update `tab1` set `status` = 3 where (`id` = 10);

#session 2:update `tab1` set `status` = 3 where (`id` >= 4) and (`id` < 6); #不会阻塞

代码分析

  1. 如果是select for update主键索引, prebuilt->row_read_type=ROW_READ_WITH_LOCKS,在innodb层的row_search_mvcc函数中,发生锁等待的时候,由于prebuilt→row_read_type != ROW_READ_TRY_SEMI_CONSISTENT,会和secondary index一样走lock_wait_or_error进行等待(下图第一个红框内的代码)。
  2. 如果是update主键索引,prebuilt→row_read_type=ROW_READ_TRY_SEMI_CONSISTENT,如果在innodb层发生锁等待,会先放掉锁(下图第二个红框),并将prebuilt->row_read_type设置为ROW_READ_DID_SEMI_CONSISTENT。后续在server层判断不满足end_range直接结束,但是如果后续在server层判断满足end_range,则会重新发起一次读操作,此时会读取行的最新版本,再次走到下图的代码时会走lock_wait_or_error进行锁等待(下图第一个红框)。

因此,如果是主键索引,select for update还是会多锁记录,但是update语句不会,这是因为它们的row_read_type不同导致的差异。

这个设计就是我们经常听说的MySQL的semi-consistent特性,这个特性的作用是:减少更新同一行记录时的锁冲突,减少锁等待。

具体流程如下,如果一条记录在InnoDB层加锁需要等待,则判断是否可以进行semi-consistent read

判断条件为:

1. prebuilt->row_read_type必须设置为ROW_READ_TRY_SEMI_CONSISTENT

2. 当前scan必须是range scan或者是全表扫描,而非unique scan

3. 当前索引必须是聚簇索引

4. 不满足以上三个条件,就不能进行semi-consistent read,进行加锁等待

如果满足上述条件,根据记录的当前版本,构造最新的commit版本,并且在InnoDB层提前释放锁。

注意:若不需要加锁等待,那么也不需要进行semi-consistent read,直接读取记录的最新版本即可,没有加锁等待的开销。

semi-consistent总结

  • 无并发冲突,读取最新版本的数据并加锁;
  • 有并发冲突,读取最新的commit版本,去MySQL server层判断是否满足更新条件;
    • 如果满足条件,读取最新版本并加锁等待。
  • 对于不满足更新条件的记录,可以提前放锁,减少并发冲突的概率。

思考:semi-consistent为什么不对二级索引做相同的优化呢?

从上述的流程可以得知,对于主键索引,如果需要加锁等待,会根据当前记录构建该记录最新的commit版本(row_sel_build_committed_vers_for_mysql)。

这主要是根据主键索引记录上隐藏存储的DB_TRX_ID和DB_ROLL_PTR列实现的。主键索引在page上原地更新数据,并构建undo log存储数据的旧版本,然后将undo log的指针存储在主键索引的DB_ROLL_PTR中。

因此,通过当前记录很容易就能找到其最新的commit版本。

然而,InnoDB MVCC对二级索引的存储跟主键索引是不同的,一个二级索引列被更新的时候,旧的二级索引记录被标记为删除,同时插入一个新的二级索引记录。也就是说,二级索引记录中不会额外存储DB_TRX_ID和DB_ROLL_PTR列。

如果我们想构造二级索引的一个可见版本,只能通过一行一行扫描二级索引记录,然后回表去判断这条二级索引是否可见,而无法直接根据当前的二级索引记录去构造其可见的commit版本。

而扫描和回表的代价是比较高的,相比于semi-consistent带来的优化可能得不偿失,因此这里对二级索引不做semi-consistent优化。

总结

该问题不能算是MySQL的bug,算是MySQL的feature,由于index condition没有下推,并且semi_consistent的特性综合导致的现象。结果来看,也并不会导致数据错误,只是”尝试“多锁了一些记录,本着宁可错锁,不能漏锁的原则,这样的feature可以接受。

腾讯数据库研发部数据库技术团队对内支持微信支付、微信红包、腾讯广告、腾讯音乐等公司自研业务,对外在腾讯云上支持 TencentDB 相关产品,如 CynosDB、CDB、TDSQL等。本公众号旨在推广和分享数据库领域专业知识,与广大数据库技术爱好者共同成长。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2022-10-27,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯数据库技术 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 提示:公众号展示代码会自动折行,建议横屏阅读
    • 背景
      • 原因分析
      相关产品与服务
      云数据库 SQL Server
      腾讯云数据库 SQL Server (TencentDB for SQL Server)是业界最常用的商用数据库之一,对基于 Windows 架构的应用程序具有完美的支持。TencentDB for SQL Server 拥有微软正版授权,可持续为用户提供最新的功能,避免未授权使用软件的风险。具有即开即用、稳定可靠、安全运行、弹性扩缩等特点。
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档