1. 背景
| 产品 | TMES |
|---|---|
| 涉及模块 | ldap-synchandler |
| 问题描述 | ldap-synchandler 执行 SQL 语句时,经常出现 “Lock wait timeout exceeded; try restarting transaction” 异常 |
| Note | ldap-synchandler 连接 DB 的 session 的事务隔离级别是:读提交。 意味着,每一条 SQL 执行完都会提交事务,并释放锁。 |
2. 问题追溯
2.1 Trace Log
{"log": "[2021/06/29 16:07:07,ldap-sync-handler,ERROR,] Thread-3 @GroupHandler.py:463 1205 (HY000): Lock wait timeout exceeded; try restarting transaction\nTraceback (most recent call last):\n File \"/dirdataupload/scripts/SyncHandler/GroupHandler.py\", line 220, in doHandle\n self.processDelUsers(delUsers)\n File \"/dirdataupload/scripts/SyncHandler/GroupHandler.py\", line 598, in processDelUsers\n self.cursor.execute(delete_sql, x)\n File \"/usr/local/lib/python3.9/site-packages/mysql/connector/cursor.py\", line 551, in execute\n self._handle_result(self._connection.cmd_query(stmt))\n File \"/usr/local/lib/python3.9/site-packages/mysql/connector/connection.py\", line 490, in cmd_query\n result = self._handle_result(self._send_cmd(ServerCmd.QUERY, query))\n File \"/usr/local/lib/python3.9/site-packages/mysql/connector/connection.py\", line 395, in _handle_result\n raise errors.get_exception(packet)\nmysql.connector.errors.DatabaseError: 1205 (HY000): Lock wait timeout exceeded; try restarting transaction\n[2021/06/29 16:07:07,ldap-sync-handler,WARNING,] Thread-3 @handler.py:319 Processed the user groups failed\n"}
{"log": "[2021/07/02 16:53:05,ldap-sync-handler,ERROR,] Thread-3 @RecipientHandler.py:75 1205 (HY000): Lock wait timeout exceeded; try restarting transaction\nTraceback (most recent call last):\n File \"/dirdataupload/scripts/SyncHandler/RecipientHandler.py\", line 73, in doHandle\n self.cursor.execute(self.sql_addChangedDomain, (domainId, domainName))\n File \"/usr/local/lib/python3.9/site-packages/mysql/connector/cursor.py\", line 551, in execute\n self._handle_result(self._connection.cmd_query(stmt))\n File \"/usr/local/lib/python3.9/site-packages/mysql/connector/connection.py\", line 490, in cmd_query\n result = self._handle_result(self._send_cmd(ServerCmd.QUERY, query))\n File \"/usr/local/lib/python3.9/site-packages/mysql/connector/connection.py\", line 395, in _handle_result\n raise errors.get_exception(packet)\nmysql.connector.errors.DatabaseError: 1205 (HY000): Lock wait timeout exceeded; try restarting transaction\n"}
2.2 Findings
根据 log 发现,ldap-synchandler 的 GroupHandler.py,在执行下述 sql 时,Lock wait timeout:
insert ignore into tb_changed_domain(domain_id,domain_name,operate_type,create_time) values (%s, %s, 3, NOW());delete from tb_ldap_group_recipient where ldap_id IN (select a.ldap_id from tb_account_ldap a, tb_account_ldap b where a.account_id = b.account_id and b.ldap_id=%s) and domain_id=%s and user_part in (%s) and ldap_rs_mask = '%s';
涉及的 table 有:tb_changed_domain 和 tb_ldap_group_recipient。
3. 查看 Lock wait timeout
mysql> SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';+--------------------------+-------+| Variable_name | Value |+--------------------------+-------+| innodb_lock_wait_timeout | 120 |+--------------------------+-------+mysql> SHOW global VARIABLES LIKE 'innodb_lock_wait_timeout';+--------------------------+-------+| Variable_name | Value |+--------------------------+-------+| innodb_lock_wait_timeout | 120 |+--------------------------+-------+
发现都是 2 min。
4. 分析 tb_ldap_group_recipient 的 SQL
查看 tb_ldap_group_recipient 的相关的这条 SQL 的执行计划:
explain delete from tb_ldap_group_recipient where ldap_id IN (select a.ldap_id from tb_account_ldap a, tb_account_ldap b where a.account_id = b.account_id and b.ldap_id=11) and domain_id=217971 and user_part in ('june_zhu', 'testgroup', 'shayne', 'test2', 'test123', ) and ldap_rs_mask = '9A0A160DC5AC7C9CADEFA48A20D467CF2A5EE6C1';
结果如下:
*************************** 1. row ***************************id: 1select_type: DELETEtable: tb_ldap_group_recipientpartitions: p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,p10,p11,p12,p13,p14,p15,p16,p17,p18,p19,p20,p21,p22,p23,p24,p25,p26,p27,p28,p29,p30,p31,p32,p33,p34,p35,p36,p37,p38,p39,p40,p41,p42,p43,p44,p45,p46,p47,p48,p49,p50,p51,p52,p53,p54,p55,p56,p57,p58,p59,p60,p61,p62,p63,p64,p65,p66,p67,p68,p69,p70,p71,p72,p73,p74,p75,p76,p77,p78,p79,p80,p81,p82,p83,p84,p85,p86,p87,p88,p89,p90,p91,p92,p93,p94,p95,p96,p97,p98,p99,p100,p101,p102,p103,p104,p105,p106,p107,p108,p109,p110,p111,p112,p113,p114,p115,p116,p117,p118,p119,p120,p121,p122,p123,p124,p125,p126,p127,p128,p129,p130,p131,p132,p133,p134,p135,p136,p137,p138,p139,p140,p141,p142,p143,p144,p145,p146,p147,p148,p149,p150,p151,p152,p153,p154,p155,p156,p157,p158,p159,p160,p161,p162,p163,p164,p165,p166,p167,p168,p169,p170,p171,p172,p173,p174,p175,p176,p177,p178,p179,p180,p181,p182,p183,p184,p185,p186,p187,p188,p189,p190,p191,p192,p193,p194,p195,p196,p197,p198,p199,p200,p201,p202,p203,p204,p205,p206,p207,p208,p209,p210,p211,p212,p213,p214,p215,p216,p217,p218,p219,p220,p221,p222,p223,p224,p225,p226,p227,p228,p229,p230,p231,p232,p233,p234,p235,p236,p237,p238,p239,p240,p241,p242,p243,p244,p245,p246,p247,p248,p249,p250,p251,p252,p253,p254,p255type: ALLpossible_keys: NULLkey: NULLkey_len: NULLref: NULLrows: 109filtered: 100.00Extra: Using where*************************** 2. row ***************************id: 2select_type: DEPENDENT SUBQUERYtable: bpartitions: NULLtype: constpossible_keys: PRIMARYkey: PRIMARYkey_len: 4ref: constrows: 1filtered: 100.00Extra: NULL*************************** 3. row ***************************id: 2select_type: DEPENDENT SUBQUERYtable: apartitions: NULLtype: eq_refpossible_keys: PRIMARYkey: PRIMARYkey_len: 4ref: funcrows: 1filtered: 10.00Extra: Using where3 rows in set (0.00 sec)
可以看到,这条 SQL:
- 对 tb_ldap_group_recipient 的执行计划的 type 是 ALL,意味着,需要进行 全表扫描。
- delete 操作,说明需要申请 表锁。
异常的原因是,执行这条语句时,申请 tb_ldap_group_recipient 的表锁超时。
而第 3 点已经查了设置的事务等待 lock 的时间是 2 min。
5. 分析 lock 原因
那么,什么情况会导致申请表锁超时?
这个表被别的事务加了 写锁,或者 读锁,或者意向写锁,或者意向读锁,且 2 min 都没有释放。
对表加 写锁:SQL 执行计划的 type 为 ALL,SQL 本身为 update,insert,delete,或者 select … for update。 对表加 读锁:SQL 执行计划的 type 为 ALL,SQL 本身为 select … lock in share mode。 对表加 意向写锁:对表中某一行或者几行加 写锁。 对表加 意向读锁:对表中某一行或者几行加 读锁。 NOTE:普通 select 不加任何锁。
我们代码中的 SQL,基本没有写成 select … lock in share mode,或者是 select … for update。
除了 rest api: select id from tb_validrecipient where domain_id=:domain_id for update
所以,肯定是有对 tb_ldap_group_recipient 的 delete,insert,update 操作,并且在一个长事务里。
使用表名“tb_ldap_group_recipient”,作为关键字搜索代码仓库,发现只有四处对 tb_ldap_group_recipient 加写锁的地方:
| 仓库 | SQL | 分析 |
|---|---|---|
| ldap-synchandler | delete from tb_ldap_group_recipient where ldap_id IN (select a.ldap_id from tb_account_ldap a, tb_account_ldap b where a.account_id = b.account_id and b.ldap_id=%s) and group_internal_id=%s and ldap_rs_mask = %s limit %s; | ldap-synchandler 设置了 session 的事务隔离级别为 读提交,并且设置了 auto-commit —> 每条 SQL 语句都相当于会自动提交事务。 因此,这里的语句不会造成长事务导致锁无法释放的场景。 |
| delete from tb_ldap_group_recipient where ldap_id IN (select a.ldap_id from tb_account_ldap a, tb_account_ldap b where a.account_id = b.account_id and b.ldap_id=%s) and domain_id=%s and user_part in (%s) and ldap_rs_mask = ‘%s’; | ||
| delete from tb_ldap_group_recipient where ldap_id IN (select a.ldap_id from tb_account_ldap a, tb_account_ldap b where a.account_id = b.account_id and b.ldap_id=%s) and ldap_rs_mask in (‘%s’) and last_modified_time < ‘%s’ limit %s; | ||
| insert into tb_ldap_group_recipient (ldap_id, user_part, domain_id, group_internal_id, first_name, middle_name, last_name, title, last_modified_time, ldap_rs_mask) values(%s, %s, %s, %s, %s, %s, %s, %s, NOW(), %s) ON DUPLICATE KEY UPDATE last_modified_time = NOW(), first_name=values(first_name), middle_name=values(middle_name), last_name=values(last_name), title=values(title); | ||
| hes-migration-tool | delete from tb_ldap_group_recipient where domain_id = %s; | 这里的事务都很快 commit,应该不至于 2 min 执行不完。 |
| insert into tb_ldap_group_recipient (ldap_id, user_part, domain_id, name, is_primary, group_internal_id, first_name, middle_name, last_name, title) values (%s, ‘%s’, %s, ‘%s’, ‘%s’, ‘%s’, ‘%s’, ‘%s’, ‘%s’, ‘%s’); | ||
| cross-site-migration-tool | insert into tb_ldap_group_recipient( … ) values ( … ) ; | cross-site-migration-tool 几乎不运行,不可能会是 lock 的原因。 |
| uiserver | update tb_ldap_group_recipient set model_status = 1 where id in ( … ); update tb_ldap_group_recipient set model_status = 0 where id in ( … ); |
这两个 SQL 是在 BECDao 执行的,追溯一下是在ModelStatusUpdater 调用的,每 5 分钟调用一次。 下图是 ModelStatusUpdater 的调用关系: |
上图可见,对所有 account 的操作,包括 REST API 请求,tb_ldap_group_recipient 的更新,tb_changed_domain 的 insert,都在一个长事务里。

此处可以看出,一次未成功执行未完成的操作的执行时间超过 3 min。
查 其他成功执行的 log 发现,每执行一次,需要 6 min 左右。
而此处事务的隔离级别为:可重复读。
意味着,一旦某一行或者表被锁着,直到事务 commit,锁才会被释放。
再分析这条 update 的 SQL:
mysql> explain update tb_ldap_group_recipient set model_status = 1 where id in (1,20000000)\G*************************** 1. row ***************************id: 1select_type: UPDATEtable: tb_ldap_group_recipientpartitions: p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,p10,p11,p12,p13,p14,p15,p16,p17,p18,p19,p20,p21,p22,p23,p24,p25,p26,p27,p28,p29,p30,p31,p32,p33,p34,p35,p36,p37,p38,p39,p40,p41,p42,p43,p44,p45,p46,p47,p48,p49,p50,p51,p52,p53,p54,p55,p56,p57,p58,p59,p60,p61,p62,p63,p64,p65,p66,p67,p68,p69,p70,p71,p72,p73,p74,p75,p76,p77,p78,p79,p80,p81,p82,p83,p84,p85,p86,p87,p88,p89,p90,p91,p92,p93,p94,p95,p96,p97,p98,p99,p100,p101,p102,p103,p104,p105,p106,p107,p108,p109,p110,p111,p112,p113,p114,p115,p116,p117,p118,p119,p120,p121,p122,p123,p124,p125,p126,p127,p128,p129,p130,p131,p132,p133,p134,p135,p136,p137,p138,p139,p140,p141,p142,p143,p144,p145,p146,p147,p148,p149,p150,p151,p152,p153,p154,p155,p156,p157,p158,p159,p160,p161,p162,p163,p164,p165,p166,p167,p168,p169,p170,p171,p172,p173,p174,p175,p176,p177,p178,p179,p180,p181,p182,p183,p184,p185,p186,p187,p188,p189,p190,p191,p192,p193,p194,p195,p196,p197,p198,p199,p200,p201,p202,p203,p204,p205,p206,p207,p208,p209,p210,p211,p212,p213,p214,p215,p216,p217,p218,p219,p220,p221,p222,p223,p224,p225,p226,p227,p228,p229,p230,p231,p232,p233,p234,p235,p236,p237,p238,p239,p240,p241,p242,p243,p244,p245,p246,p247,p248,p249,p250,p251,p252,p253,p254,p255type: rangepossible_keys: idkey: idkey_len: 4ref: constrows: 2filtered: 100.00Extra: Using where
| 列名 | 值 | 描述 |
|---|---|---|
| type | range | 索引范围扫描,对索引的扫描开始于某一点,返回匹配值域的行。 |
| key | id | MySQL在查询中实际使用的索引 |
由此,我们可以得出,这条 sql 语句用了 主键索引。
而由于这条语句是 update 操作,因此在 某几行索引加的是 写锁——排他锁。
根据 InnoDB 的设计,当对表的某一行或者几行加写锁前,会先对表加意向写锁。
此时,别的事务对此表的写锁申请,均会被阻塞。
而 tb_ldap_group_recipient 的表的意向写锁,要等到事务提交,才会释放。换句话说,这个意向写锁可能会持续 6 min。
这应该就是 ldap-synchandler 对 tb_ldap_group_recipient 写锁申请 timeout 的原因了。
6. 解决
6.1 思路
分 2 步走:
- 首先,将 tmasewrapper 更新状态的周期改为 1 hour;
- 其次,将单个 account 的处理抽出来放在一个 transaction 里。
对所有 account 操作的 function:
BECService.updateWritingStyleModelStatus()
这个 function 会调用对每个 account 操作的 function:
BECService.updateModelStatus(acctID)
6.2 错误做法
通过Spring 事务的传播特性,将
- updateWritingStyleModelStatus() 的 propagation 改成 Propagation.NEVER。
- updateModelStatus(acctID) 的 propagation 保持 Propagation.REQUIRED。
这样做的结果是,updateModelStatus(acctID) 也没有运行在事务里。
所以是不可行的。
原因是,Spring 只有在类之间的方法调用,才会采用动态代理,创建代理对象。而 Spring 的事务,正是通过注解的方式,创建代理对象实现的。
6.2 正确做法
将 updateWritingStyleModelStatus() 方法抽到另一个类中,且此方法不加 @Transactional 注解。
