# 记录一次排查、修复获取行锁超时失败导致佣金发放失败问题

背景:有一家租户反馈,有部分用户的佣金过了应入账时间,一直没入账。

据负责对接租户的同事的说法,自从她负责对接这个租户就有这个问题了,以前负责的开发一直没根治。每次租户反馈就调一次现有的微服务接口,全部入账一次。

翻了翻这笔佣金入账的日志,看到资产服务有个错误日志:

xxx-treasure catch DataAccessException , hashCode = 1532638683, exception = org.springframework.dao.CannotAcquireLockException:
### Error updating database.  Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction
### The error may exist in URL [jar:file:/xxx-treasure.jar!/BOOT-INF/classes!/mapper/TreasureInfoMapper.xml]
### The error may involve defaultParameterMap
### The error occurred while setting parameters
### SQL: UPDATE treasure_info         SET amt_total   = amt_total + ?,             amt_vailable = amt_vailable + ?,             updated_at  = NOW()         WHERE user_id = ?
### Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction
; Lock wait timeout exceeded; try restarting transaction; nested exception is com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction
org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator.doTranslate(SQLErrorCodeSQLExceptionTranslator.java:262)
org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:72)
org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(MyBatisExceptionTranslator.java:74)
org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:440)

导致下游分佣服务调用超时异常:

feign.RetryableException: 10.122.234.85:8080 failed to respond executing POST http://xxx-treasure/treasure/available/add
        at feign.FeignException.errorExecuting(FeignException.java:65) ~[feign-core-9.7.0.jar!/:na]
        at feign.SynchronousMethodHandler.executeAndDecode(SynchronousMethodHandler.java:105) ~[feign-core-9.7.0.jar!/:na]
        at feign.SynchronousMethodHandler.invoke(SynchronousMethodHandler.java:77) ~[feign-core-9.7.0.jar!/:na]
        at feign.ReflectiveFeign$FeignInvocationHandler.invoke(ReflectiveFeign.java:102) ~[feign-core-9.7.0.jar!/:na]

分佣服务的修复方案:

重试调用资产接口入账佣金。资产入账的接口做了幂等,所以可以随意重试。

1.利用feign的重试。

2.以及自己用定时任务,预期分佣1小时后重试分佣。并利用消息队列削峰,避免重试风暴。

在微服务架构下,可能有服务无法正常工作、调用失败等原因导致业务数据不一致。而分佣这种场景下,要求业务的成功率非常高,所以重试机制是必不可少的。

MySQL的锁机制不赘述了。列举一般出现锁获取失败的情况:

1.死锁。

2.长事务。

3.热点行。

4.事务中有远程调用,且远程调用时间长。

列举一下,我知道的查看锁占用SQL、事务等情况的排查方法:

1.阿里云sql洞察,界面就能看到慢sql、长事务。推荐,因为我所在的公司就是用的阿里云的polar db,很多功能非常实用。

2.infomation_schema库查询

  • innodb_lock_waits,查出正在等待资源的事务、占用资源的事务以及锁id
  • innodb_lock,查出目前数据库的加锁情况
  • innodb_trx,查出目前数据库的事务情况

3死锁日志获取

  • show engine innodb status;可以看到等待锁的语句,以及时间。非结构性的显示,不直观。

经过排查,是下单、分佣业务分别都调用了转账接口,形成了死锁。

以下是精简过的转账接口SQL:

begin;

UPDATE treasure_info         
SET amt_total   = amt_total - #{转账金额},             
amt_vailable = amt_vailable - #{转账金额},             
updated_at  = NOW()         
WHERE user_id = #{转出方user id};

UPDATE treasure_info         
SET amt_total   = amt_total + #{转账金额},             
amt_vailable = amt_vailable + #{转账金额},             
updated_at  = NOW()         
WHERE user_id = #{转入方user id};

commit;

如果A转账给B,事务隔离级别设置是READ COMMITED以上。从加锁的角度看,先锁A,再锁B,事务提交或回滚才释放锁。当B同时也转账给A时,就会形成死锁。

目前分佣的场景可以简单的理解为C下单,D、E、F可以获得佣金,此时是公司账户转账给D、E、F。

如果此时C、E、F任意一位下单,就可能会形成死锁,导致付款失败或佣金入账失败。

解决方案:

  • 配置防御性处理
    • innodb设置死锁检测、回滚其中一个事务innodb_deadlock_detect = true
    • innodb锁等待超时时间,让其自动回滚。innodb_lock_wait_timeout,默认50秒,平时可以设置短一点,比如5秒。排查时可以设长一点
  • 顺序加锁
    • 保证以相同的顺序加锁,比如按user id顺序加锁。

    • 伪代码如下:

    • begin;
      
      if (转出方user id < 转入方user id) {
      UPDATE treasure_info         
      SET amt_total   = amt_total - #{转账金额},             
      amt_vailable = amt_vailable - #{转账金额},             
      updated_at  = NOW()         
      WHERE user_id = #{转出方user id};
      
      UPDATE treasure_info         
      SET amt_total   = amt_total + #{转账金额},             
      amt_vailable = amt_vailable + #{转账金额},             
      updated_at  = NOW()         
      WHERE user_id = #{转入方user id};
      } else {
      UPDATE treasure_info         
      SET amt_total   = amt_total - #{转账金额},             
      amt_vailable = amt_vailable - #{转账金额},             
      updated_at  = NOW()         
      WHERE user_id = #{转出方user id};
      
      UPDATE treasure_info         
      SET amt_total   = amt_total + #{转账金额},             
      amt_vailable = amt_vailable + #{转账金额},             
      updated_at  = NOW()         
      WHERE user_id = #{转入方user id};
      }
      
      commit;