MySQL锁等待超时问题技术分享

背景

错误信息:Lock wait timeout exceeded; try restarting transaction,我们常称为锁等待超时。

根据类型一般分为两类:

1、 行锁等待超时;最常见的场景,因为事务等待行锁超时。

2、 元数据锁等待超时;常见在DDL操作期间(此次不涉及)。

行锁等待的现象

    

事务A

事务B

Begin;

Begin;

mysql> select * from t1;

+----+------+

| id | name |

+----+------+

|  1 | paas |

|  2 | csd  |

+----+------+

 

update t1 set name='paas_od' where id=1;

 

 

delete from t1 where id=1;

 

…………waiting…………

Commit;

mysql> select * from t1;

+----+------+

| id | name |

+----+------+

|  2 | csd  |

+----+------+

 

Commit;

 

如上图所示事务A更新ID=1这行的name=’pass_od’,事务B删除ID=1这行;事务A先执行,所以优先获取了主键ID=1的行锁,事务B的DELETE执行下去的前提是获取ID=1的行锁,由于事务A已经获取了,那么事务B只能等待事务A释放这把锁(事务A commit后,事务B正常执行完成),这就是典型的锁等待的场景。

综上所述:当多个事务同时去操作(增删改)某一行数据的时候,MySQL 为了维护 ACID 特性,就会用锁的形式来防止多个事务同时操作某一行数据,避免数据不一致。只有获取到行锁的事务才可以操作该数据行,直到该事务结束释放行锁,而其他没有获取到行锁的事务就会产生锁等待。如果等待时间超过了配置值(也就是 innodb_lock_wait_timeout),则会抛出行锁等待超时错误。

锁等待产生的原因

1. 程序中非数据库交互操作导致事务挂起

将接口调用或者文件操作等这一类非数据库交互操作嵌入在 SQL 事务代码之中,那么整个事务很有可能因此挂起(接口不通等待超时或是上传下载大附件)。

2、事务中包含性能较差的查询 SQL

事务中存在慢查询,导致同一个事务中的其他 DML 无法及时释放占用的行锁,引起行锁等待。

3. 单个事务中包含大量 SQL

通常是由于在事务代码中加入 for 循环导致,虽然单个 SQL 运行很快,但是 SQL 数量一大,事务就会很慢。

4. 级联更新 SQL 执行时间较久

这类 SQL 容易让人产生错觉,例如:update A set ... where ...in (select B) 这类级联更新,不仅会占用 A 表上的行锁,也会占用 B 表上的行锁,当 SQL 执行较久时,很容易引起 B 表上的行锁等待。

综上可以看出,如果事务长时间未提交,且事务中包含了 DML 操作,那么就有可能产生行锁等待,引起报错。

处理方法

当出现锁等待时,往往都是事后业务反馈才发现,同时MySQL本身不会主动记录锁等待的相关信息,再加上锁等待存在偶然性,不容易复现,所以事后分析并不容易。

1、 找开发了解详细情况,获取对应事务的SQL语句,手动复现,实际上很难,开发需要走读代码,耗时,还不一定准确。

2、 增加监控,抓取锁等待时候的详细锁、SQL等信息。

模拟锁等待:

我们使用上面的事务A和事务B去模拟锁等待,并抓取信息来分析

mysql> select * from sys.innodb_lock_waits\G;

*************************** 1. row ***************************

                wait_started: 2020-06-15 15:43:05

                    wait_age: 00:03:47

               wait_age_secs: 227

                locked_table: `test`.`t1`

         locked_table_schema: test

           locked_table_name: t1

      locked_table_partition: NULL

   locked_table_subpartition: NULL

                locked_index: PRIMARY

                 locked_type: RECORD

              waiting_trx_id: 27368

         waiting_trx_started: 2020-06-15 15:42:52

             waiting_trx_age: 00:04:00

     waiting_trx_rows_locked: 1

   waiting_trx_rows_modified: 0

                 waiting_pid: 18

               waiting_query: delete from t1 where id=1

             waiting_lock_id: 139860846826920:214:4:5:139860778262952

           waiting_lock_mode: X,REC_NOT_GAP

             blocking_trx_id: 27367

                blocking_pid: 17

              blocking_query: NULL

            blocking_lock_id: 139860846826048:214:4:5:139860778257000

          blocking_lock_mode: X,REC_NOT_GAP

        blocking_trx_started: 2020-06-15 15:42:42

            blocking_trx_age: 00:04:10

    blocking_trx_rows_locked: 1

  blocking_trx_rows_modified: 1

     sql_kill_blocking_query: KILL QUERY 17

sql_kill_blocking_connection: KILL 17

通过sys.innodb_lock_waits可以获取到对应SQL的语句、等待时间、开始时间、锁的类型、事务ID、线程ID等,同时最后会给出建议的KILL `PID`;但是绝大多数时候无法查询到阻塞的SQL(blocking_query),而且就算能获取到也只能获取到阻塞事务中的某一条SQL语句,这时候我们有2种方式去获取整个事务的SQL语句:

一是开启general_log,根据blocking_pid查找对应事务的全部SQL,缺点是数据库繁忙时,日志信息巨多,影响性能。 
  

根据阻塞的PID 17可以看到阻塞PID 18的SQL语句是UPDATE语句,我就方便我们后续分析具体原因。

 

二是开启P_S,根据events_statements_current、events_statements_histiory等获取SQL信息,缺点保存的信息有限,有可能被覆盖。

mysql> select t5.*,t4.PROCESSLIST_INFO as waiting_sql from PERFORMANCE_SCHEMA.threads t4 ,(SELECT t1.wait_started,t1.wait_age,t1.waiting_trx_id,t1.waiting_pid,t1.blocking_trx_id,t1.blocking_pid,t1.blocking_trx_age,t2.THREAD_ID AS block_thread_id,t4.SQL_TEXT as current_sql,t3.SQL_TEXT as history_sql from sys.innodb_lock_waits t1,`performance_schema`.threads t2, (         SELECT             thread_id,             group_concat(   CASE WHEN EVENT_NAME = 'statement/sql/begin' THEN "transaction_begin" ELSE sql_text END ORDER BY event_id SEPARATOR ";" ) AS sql_text         FROM             performance_schema.events_statements_history         GROUP BY thread_id     ) t3,`performance_schema`.events_statements_current t4 where t1.blocking_pid = t2.PROCESSLIST_ID and t2.THREAD_ID= t3.THREAD_ID and t2.THREAD_ID =t4.THREAD_ID) t5 where t5.waiting_pid = t4.PROCESSLIST_ID\G;

*************************** 1. row ***************************

    wait_started: 2020-06-15 15:43:05

        wait_age: 00:01:15

  waiting_trx_id: 27368

     waiting_pid: 18

 blocking_trx_id: 27367

    blocking_pid: 17

blocking_trx_age: 00:01:38

 block_thread_id: 71

     current_sql: update t1 set name='paas_od' where id=1

     history_sql: insert into t1 select (1,'paas');insert into t1 values(1,'paas');select * from t1;transaction_begin;update t1 set name='paas_od' where id=1;commit;transaction_begin;insert into t1 values(1,'paas');transaction_begin;update t1 set name='paas_od' where id=1

     waiting_sql: delete from t1 where id=1

通过结合P_S库、I_S库中的表,可以获取到阻塞其他事务的SQL信息。

 

刚刚我们是模拟锁等待,所以肯定能获取被阻塞的SQL信息,但有的时候是事后我们才知道发生了锁等待,这时被阻塞的SQL语句因为超过innodb_lock_wait_timeout被回滚了,所以不知道执行哪一条SQL由于锁等待超时被回滚了;我们依旧可以用P_S库获取到报锁等待超时的SQL和相应的信息,然后分析一下对应时间点的慢SQL,看看是否有涉及该表的,然后做对应的优化,此方法存在可能找错SQL,也可能存在找不到的情况,如:阻塞者事务中涉及该表的SQL并不是慢SQL,而是其他SQL执行慢,或者其他原因导致事务慢。 
    

总结

锁等待分析起来并不难,难的是如何抓取阻塞事务执行的SQL语句,一般的情况都是写监控脚本定时监控采集等待锁等待复现。

 

相关案例1:

背景:

业务反馈INSERT INTO TF_O_ORDER出现大量锁等待报错。 
 

处理过程:

    1、抓取对应的MySQL信息,取如下表的内容

information_schema.innodb_trx:MySQL事务信息表

sys.innodb_lock_waits:MySQL锁等待信息表

show engine innodb status\G:MySQL 引擎日志

2、抓取完信息后,KILL innodb_trx中涉及到tf_o_order相关的事务连接;或者切换主备,然后KILL掉主库上的事务,防止出现两边不一致。

 

分析过程:

    1、查innodb_trx中涉及tf_o_order相关的SQL 

总共48个SQL,只有DELETE与INSERT两类SQL。

2、查sys.innodb_lock_waits中阻塞insert into tf_o_oder相关的SQL 

锁等待表中最多的就是15746(执行了43S)、15099(执行了37S)阻塞了对应的INSERT SQL。 

合计阻塞了50个对应的SQL。

3、完整SQL信息与执行计划

delete from tf_i_activeinfo where phone_no='18599655483'         and 0=(select count(1) from tf_o_order,tf_o_trade where tf_o_order.phone_no='18599655483' and tf_o_order.trade_id = tf_o_trade.trade_id and tf_o_trade.status = 'H0')

执行计划如下: 

DELETE条件中的子查询中tf_o_order为全表扫描

4、解析慢SQL 

该慢SQL从2月18日至今,总计执行了238W多次,95%是在69S执行完成,属于高频SQL的慢SQL。

 

结论:

Lock wait timeout锁等待超时,是指一个SQL语句执行下去需要获取对应表中某些记录的锁,而这些锁被另外的SQL或者事务占有,当等待时间超过innodb_lock_wait_timeout设置的值后,就会报锁等待超时的错误。这类错误一般是由SQL效率低下或者业务逻辑冲突导致的。

而本次是因为DELETE语句中涉及TF_O_ORDER表的操作为全表扫描且执行时间过长,执行DELETE时会长时间持有锁,导致所有对该表进行的增删改都将阻塞等待。

建议:

  1、调整数据库与代理的隔离级别,默认是RR,调整为RC,减少GAP锁的持有和减少某些特定类型SQL获取的锁。(PAAS组操作) 
  2、业务对此DELETE慢SQL进行优化,可以优先尝试给tf_o_order.phone_no添加一个索引;如果不行,则需要改写SQL逻辑。(业务侧执行,PAAS组核实)
  3、定期优化慢SQL。

相关案例2

背景: 
       为了快速解决由于MDL元数据锁导致请求等待,造成大量线程积压的问题。 
处理流程: 
      一、有正在执行的SQL导致 
      1、查找到正在做DDL的语句,获取对应表名 
select * from information_schema.processlist  
       2、获取导致DDL操作阻塞的SQL 
select * from information_schema.processlist  
where command<>'Sleep' and db<>'null' and info like '%${table_name}%' order by time; 
       3、kill掉对应的SQL 

      二、未执行完的事务导致 
      1、获取未提交事务最后一条SQL的信息 
SELECT *,t4.PROCESSLIST_ID FROM PERFORMANCE_SCHEMA.events_statements_current t3, 
(SELECT t2.thread_id,t2.PROCESSLIST_ID FROM information_schema.INNODB_TRX t1,`performance_schema`.threads t2 
WHERE t1.trx_mysql_thread_id = t2.PROCESSLIST_ID) t4 
where t3.THREAD_ID = t4.thread_id 

      2、获取未提交事务的完整SQL信息 
SELECT 
    locked_schema, 
    locked_table, 
    locked_type, 
    waiting_processlist_id, 
    waiting_age, 
    waiting_query, 
    waiting_state, 
    blocking_processlist_id, 
    blocking_age, 
    substring_index(sql_text,"transaction_begin;" ,-1) AS blocking_query, 
    sql_kill_blocking_connection 
FROM 
    ( 
        SELECT 
            b.OWNER_THREAD_ID AS granted_thread_id, 
            a.OBJECT_SCHEMA AS locked_schema, 
            a.OBJECT_NAME AS locked_table, 
            "Metadata Lock" AS locked_type, 
            c.PROCESSLIST_ID AS waiting_processlist_id, 
            c.PROCESSLIST_TIME AS waiting_age, 
            c.PROCESSLIST_INFO AS waiting_query, 
            c.PROCESSLIST_STATE AS waiting_state, 
            d.PROCESSLIST_ID AS blocking_processlist_id, 
            d.PROCESSLIST_TIME AS blocking_age, 
            d.PROCESSLIST_INFO AS blocking_query, 
            concat('KILL ', d.PROCESSLIST_ID) AS sql_kill_blocking_connection 
        FROM 
            performance_schema.metadata_locks a 
        JOIN performance_schema.metadata_locks b ON a.OBJECT_SCHEMA = b.OBJECT_SCHEMA 
        AND a.OBJECT_NAME = b.OBJECT_NAME 
        AND a.lock_status = 'PENDING' 
        AND b.lock_status = 'GRANTED' 
        AND a.OWNER_THREAD_ID <> b.OWNER_THREAD_ID 
        AND a.lock_type = 'EXCLUSIVE' 
        JOIN performance_schema.threads c ON a.OWNER_THREAD_ID = c.THREAD_ID 
        JOIN performance_schema.threads d ON b.OWNER_THREAD_ID = d.THREAD_ID 
    ) t1, 
    ( 
        SELECT 
            thread_id, 
            group_concat(   CASE WHEN EVENT_NAME = 'statement/sql/begin' THEN "transaction_begin" ELSE sql_text END ORDER BY event_id SEPARATOR ";" ) AS sql_text 
        FROM 
            performance_schema.events_statements_history 
        GROUP BY thread_id 
    ) t2 
WHERE 
    t1.granted_thread_id = t2.thread_id 
    3、根据2中字段sql_kill_blocking_connection,kill掉对应的进程。 

    三、异常导致表上持有的锁未释放 
    需要找到持有锁的线程,然后KILL,暂未遇到。可以通过performance_schema.events_statements_current、sys.schema_table_lock_waits查找。

相关案例3 

       最近几天持续收到客服数据库锁等待的告警信息,今天使用脚本收集到了相关信息,根据收集到的信息分析如下: 

1、告警时,系统会自动收集当前的锁等待信息

图中1、是被阻塞事务的执行时间;2、是被阻塞事务的线程ID

 

2、从锁等待信息中可以发现,最开始阻塞事务的线程ID1481272

 

3、根据时间和线程IDgeneral日志中查找记录(general会记录数据库所有执行的SQL),

当前事务一直未有收到commit/rollback命令。

 

4、从代理日志中可以发现1481272对应的事务一直执行了5分钟(从36:02~~41:02)后超时被代理kill掉了。

 

5、当1481272对应的事务被kill后,阻塞的事务commit成功。

 

6、当前数据库锁等待时间设置的是120S,当事务等待120S后会自动rollback

 

7、查看之前被阻塞的事务在general日中的信息,可以证实2分钟后被数据库自动回滚了

同时代理中也会有对应的锁等待超时记录

 

结论:

1、请业务侧排查一下,为什么没有commit/rollback?从最近收到锁等待告警信息来看都是更新DSTAFFSTATUS表中的记录,请检查与该表相关的业务逻辑是否存在异常;也可以咨询一下曹永斌,之前服开也有类似的问题。
2、调整数据库锁等待时间为默认的50S,尽早返回rollback,降低空跑带来的资源消耗。

posted @ 2022-12-21 14:45  Harda  阅读(5134)  评论(0编辑  收藏  举报