
慢查询日志中的 Lock_time 从哪里来?
经常关注慢查询日志的读者,和 Lock_time 应该算是老相识了,大家对这位老相识了解有多少呢?
研究 Lock_time 之前,我对它的了解,仅限于它表示锁等待时间。至于它包含哪些锁等待时间、怎么计算得到的,我并不清楚。
所以,我一直有个困惑:为什么有些 SQL 执行时间很长,Lock_time 却很小(例如:0.001 秒)?
今天我们就一起来看看,Lock_time 包含哪些锁等待时间、以及是怎么计算得到的?
本文基于 MySQL 8.0.32 源码,存储引擎为 InnoDB。
1. 整体介绍
Lock_time 由两部分相加得到:
- 表锁等待时间,如果 SQL 中包含多个表,则是多个表锁等待时间之和。
- 行锁等待时间,如果 SQL 执行过程中需要对多条记录加锁,则是多个行锁等待时间之和。
对 InnoDB 来说,DML、DQL 对记录进行增删改查操作时,如需加锁,都是加行级别的共享锁、排他锁,而不加表级别的共享锁、排他锁。
共享锁又称作 S 锁,排他锁又称作 X 锁。
那么,InnoDB 有表级别的共享锁、排他锁吗?
别说,还真有!
不过,不常有!
只有执行 LOCK TABLES ... [READ | WRITE],并且系统变量 innodb_table_locks = 1、auto_commit = 0,InnoDB 才会加表级别的共享锁、排他锁。
从代码注释和官方文档对
innodb_table_locks
的介绍来看,执行存储过程和触发器时,InnoDB 也可能会加表级别的共享锁、排他锁,我们就不展开介绍了。
如果 InnoDB 加了表级别的共享锁、排他锁,Lock_time 包含表锁等待时间,我们比较好理解。
如果我们执行 DML、DQL,InnoDB 没有加表级别的共享锁、排他锁,Lock_time 里还包含表锁等待时间吗?
这个问题,就得看用什么标准了:
- 严格来说,Lock_time 就不包含表锁等待时间了。
- 不严格来说,Lock_time 还是包含表锁等待时间的(InnoDB 采用了这个标准)。
接下来,我们通过源码,进入表锁、行锁等待时间的实现逻辑,来一睹芳容。
2. 表锁等待时间
我们先来看一下表锁等待时间实现逻辑的堆栈:
Sql_cmd_dml::execute() 调用 lock_tables() 对多个表加锁。
lock_tables() 调用 mysql_lock_tables() 对多个表加锁。
mysql_lock_tables() 调用 lock_external() 之前,先把当前时间记录下来,作为表锁等待的开始时间。
然后调用 lock_external() 对多个表加锁。
最后,调用 thd->inc_lock_usec() 把表锁等待时间累加到 server 层线程对象(thd)的 m_lock_usec 属性中。
lock_external() 会迭代 SQL 中的表,每迭代一个表,都调用 ha_external_lock() 对表进行加锁。
handler::ha_external_lock() 调用表对应存储引擎的 external_lock() 方法。
对 InnoDB 来说,调用的是 ha_innobase::external_lock()
,这个方法的代码比较多,算是个大杂烩,可以分为三类:
- 加表级别的共享锁、排他锁。
- 把当前迭代表所属表空间的脏页,同步刷新到磁盘。
- 一些初始化逻辑(执行快,花费时间极少)。
ha_innobase::external_lock() 的执行时间会计入表锁等待时间,因为其中可能包含同步刷脏页操作、执行一些初始化逻辑花费的时间,所以,表锁等待时间并不纯粹。
对需要加表锁的 SQL 来说,表锁等待时间包含两部分:
- 加表级别的共享锁、排他锁的等待时间。
- 执行一些初始化逻辑花费的时间。
如果是 FLUSH TABLES ... WITH READ LOCK 语句,表锁等待时间还包含:把其中涉及的表所属表空间的脏页同步刷新到磁盘所花费的时间。
对不需要加表锁的 SQL 来说,表锁等待时间就是执行 ha_innobase::external_lock() 中一些初始化逻辑花费的时间。
我们来看看 ha_innobase::external_lock() 主要包含哪些代码逻辑,对这部分细节不感兴趣的读者,可以跳过这个小节。
这个小节的代码都来自于
ha_innobase::external_lock()
,文件路径 storage/innobase/handler/ha_innodb.cc
。
以上代码,创建 InnoDB 的事务对象(trx_t),保存到 server 层的用户线程对象(thd)中。
上面代码的判断条件有点多,我们用一句话来概括一下代码逻辑:
事务隔离级别为 READ_UNCOMMITTED
、READ_COMMITTED
时,如果 SQL 会产生 ROW
格式的 binlog,而用户设置系统变量 binlog_format 的值为 STATEMENT
,要求记录 STATEMENT 格式的 binlog,ha_innobase::external_lock() 会返回 HA_ERR_LOGGING_IMPOSSIBLE
,因为 MySQL 无法处理这样矛盾的场景。
InnoDB 读取记录时,会根据 m_prebuilt->select_lock_type
的值确定是否加行锁
、加共享锁还是排他锁。
lock_type 等于 F_WRLCK,表示 server 层要求加写锁,对应到 InnoDB 的锁类型,就是排他锁,设置加锁类型为 LOCK_X。
lock_type 等于 F_RDLCK,表示 server 层要求加读锁,对应到 InnoDB 的锁类型,就是共享锁,分两种情况设置 InnoDB 的加锁类型:
- 对于 ACL 表,
m_prebuilt->no_read_locking
会被设置为 true,表示读取记录时不加锁
。 - 如果事务隔离级别是可串行化,并且当前事务需要手动执行 COMMIT 语句提交,以及还没有确定读取该表记录时加什么类型的行锁,设置 InnoDB 加锁类型为
共享锁
。
ACL 表用于访问权限控制,包含如下这些表:
- user
- db
- tables_priv
- columns_priv
- procs_priv
- proxies_priv
- role_edges
- default_roles
- global_grants
- password_history
只有执行 FLUSH TABLES ... WITH READ LOCK 语句时,才会命中代码中的 case 分支。
row_quiesce_table_start() 会调用 buf_LRU_flush_or_remove_pages()
,并把当前加表锁的表所属表空间对象传给该方法,表示把该表空间的脏页刷新到磁盘。
3. 行锁等待时间
我们先来看看对一条记录加行锁的等待时间是怎么计算的。
InnoDB 读取一条记录时,如需加行锁,会调用 sel_set_rec_lock() 进行加锁。
如果其它事务持有该记录的行锁,sel_set_rec_lock()
会返回 DB_LOCK_WAIT,row_search_mvcc()
调用 row_mysql_handle_errors() 处理锁等待逻辑。
row_mysql_handle_errors() 调用 lock_wait_suspend_thread()
,行锁等待逻辑由这个方法实现。
从上面代码可以看到,计算一条记录的行锁等待时间,逻辑比较简单:
先保存当前行锁等待的开始时间,获取到行锁或等待行锁超时之后,再用当前时间减去开始时间,就得到了一条记录的行锁等待时间。
4. 累计时间
一滴水的梦想是终有一天能够汇入大海。
表锁、行锁等待时间的归宿是累加起来,最终成为 lock_time,这个过程是通过调用 thd_set_lock_wait_time()
实现的。
thd_set_lock_wait_time() 调用 thd_storage_lock_wait()
累加表锁、行锁等待时间。
真正干活的是 THD::inc_lock_usec() 方法。
server 层每获取到一个表锁,都会调用 thd_set_lock_wait_time()
,累加表锁等待时间。
最终会调用 THD::inc_lock_usec() 把表锁等待时间累加到 server 层的线程对象 thd 的 m_lock_usec 属性中。
InnoDB 每获取到一条记录的行锁,或者行锁等待超时,都会调用 thd_set_lock_wait_time()
,累加行锁等待时间。
最终会调用 THD::inc_lock_usec() 把行锁等待时间累加到 server 层的线程对象 thd 的 m_lock_usec 属性中。
5. lock_time
SQL 执行完成之后,dispatch_command() 调用 log_slow_statement() 记录慢查询到文件中。
log_slow_statement() 也不是真正干活的,经过多级,最终调用 Query_logger::slow_log_write() 记录慢查询到文件中。
Query_logger::slow_log_write() 被调用时,参数 aggregate
的值都是 false,上面代码不会进入 if (aggregate)
分支。
if (thd->start_utime)
分支,lock_utime = thd->get_lock_usec(),从当前线程对象(thd)中获取之前累加的表锁、行锁等待时间。
然后,调用 log_slow() 记录慢查询到文件中。
Log_to_file_event_handler::log_slow() 最终调用 mysql_slow_log.write_slow() 记录慢查询到文件中。
经常看慢查询日志的读者,想必对这 2 行会非常熟悉:
其中的 Lock_time 就是本文的主题,介绍到这里,总算是和文章标题遥相呼应上了。
6. 总结
Lock_time 由表锁、行锁等待时间相加得到。
表锁等待时间并不纯粹,其中包含执行一些初始化操作花费的时间。
对 FLUSH TABLES ... WITH READ LOCK 语句来说,还包含把其中涉及的表所属表空间的脏页同步刷新到磁盘所花费的时间。
行锁等待时间很纯粹,就是多条记录的行锁等待时间之和,或者一条记录的行锁等待时间。
文章转载自公众号:一树一溪
