并发事务死锁问题排查
业务系统上线后,服务日志报错:
Jul 20 15:10:30 xxx: {"level":"error","error":"Error 1213: Deadlock found when trying to get lock; try restarting transaction","time":"2021-07-20T15:10:35.845197649+08:00","message":"error delete entities before insert"}
上游业务系统监听多个topic,但不同topic有交集,交集为共同更新我们系统的某一张表。服务虽然一直在报错,但是数据并没有出现重复及丢失的情况。针对这个问题现象进行排查。
1 排查思路:
1.1 首先调研下mysql InnoDB锁的详细说明:
概念:
共享锁(S Lock):允许事务读一行数据,多个事务可以并发对某一行数据加S Lock
排他锁(X Lock): 允许事务删除或更新一行数据,只有行数据没有任何锁才可以获取X Lock
共享锁和排他锁,就是我们日常见到的读锁和写锁。一个线程加了读锁,其他线程如果是读取数据,也可以加读锁继续读取。而一旦有一个线程需要加写锁,前提是该数据没有加锁,如果当前数据已经加了读锁或者写锁,当前线程必须等到锁释放,才可以加写锁。
共享锁和排他锁,在InnoDB中对应的是行级别锁。但是InnoDB除了支持共享锁(S Lock)和排他锁(X Lock),还支持表级别的两把锁,意向共享锁(IS Lock)和意向排他锁(IX Lock),意向共享锁和意向排他锁虽然是表级别的锁实际应用在行级锁之中,用来锁定一个小范围。IS Lock
事务想要获得一张表中某几行
的共享锁; IX Lock
事务想要获得一张表中某几行
的排他锁
- 行锁 :锁定一行数据,即我们常见的共享锁和排查锁
- 间隙锁:锁定一个范围,但不包含记录本身。例如数据库中id为3,8,11,那么锁定的区间可能为(-∞, 3), (3, 8), (8, 11), (11, +∞)。假如插入的数据id为6,那么此时锁定的区间为(3, 6), (6, 8)被锁定,不包括要插入的6
- 行锁 + 间隙锁:锁定一个范围,包括记录本身。例如数据库中id为3,8,11,那么锁定的区间可能为(-∞, 3], (3, 8], (8, 11], (11, +∞]。那么假如插入id为6的数据,此时锁定的区间为(3, 6], (6, 8]两个部分,可以看到,6也被锁定了。
1.2 间隙锁有什么用?
我们了解了MySQL的InnoDB的常见锁,了解了表级别间隙锁会应用在行级别的范围之中。那么间隙锁有什么好处。
我们应该听说过幻读,即在同一事务下,连续执行两次同样的SQL
语句可能导致不同的结果,第二次的SQL
语句可能返回之前不存在的行。InnoDB
使用行锁 + 间隙锁的方式解决这个问题。当然,InnoDB
存储引擎在查询数据时是不存在锁的,这是因为查询的数据来自于快照版本,即历史数据。
1.3 MySQL常见操作对锁的应用
- Insert操作:数据库插入一行数据时,需要获取行锁
- Update操作:更新一条记录时,如果记录存在,需要行锁,如果不存在,需要行锁+间隙锁。
- Delete操作:删除一条记录时,如果记录存在,需要行锁;如果记录不存在,行锁+间隙锁。
- Select操作:不会加锁,因为查询的数据主要来自于快照版本,即历史数据。除非显示的调用lock share mode或for update。
-- 显示的为查询添加共享锁S Lock
select * from a where id = 1 lock in share mode ;
-- 显示的为查询添加排他锁X Lock
select * from a where id = 1 for update ;
1.4 服务为啥会Deadlock
通过前期对Mysql InnoDB锁相关资料的了解,分析我们系统为啥会出现大量的deadlock日志报错。
Jul 20 15:10:30 xxx: {"level":"error","error":"Error 1213: Deadlock found when trying to get lock; try restarting transaction","time":"2021-07-20T15:10:35.845197649+08:00","message":"error delete entities before insert"}
造成死锁竞争状态后,mysql会将优先的事务提交,另一个事务释放锁,然后抛出报错信息。
2 解决思路
并发情况下减少delete-insert事务操作
可以回避这种在事务中,delete-insert多线程操作的问题,例如我们可以先查数据是否存在,不存在不执行delete操作,避免不存在执行delete操作,触发mysql的行锁+间隙锁机制。如果存在我们delete,只会用到mysql的行锁。这就一定程度上避免了锁竞争无法释放的问题。但是这样操作也会存在一定的风险,是否可以软删除,避免高并发情况下,出现数据已经被删除,而其他事物正在删除不存在的数据问题。
单进程下可考虑在事务上加锁
sessionA和sessionB两个事务,在竞争的情况下,删除了不存在的记录,会触发mysql的行锁+间隙锁。主要出发点在于,与其在mysql竞争间隙锁的过程中报错,然后事务回滚,资源大量浪费,不如在进入事务之前进行并发控制。虽然锁的粒度有点粗,但是相对于事务一直回滚,服务端不停打印错误日志,是更能接受的。
多进程高可用的情况
对于高可用多进程情况,可以通过分布式锁结局。如果不想借助非mysql的外部锁结局,那么也可以考虑对delete-insert事务进行排序,加入有序队列中,挨个消化。这实质上也是变相做了同步操作。
思考方向:尽可能避免触发mysql的间隙锁。
3 最终解决办法
单进程加了一个锁,对多线程的delete-insert事务,同步处理。
// 对线程并发调用的方法
func (ei entitiesImpl) UpsertEntitis(ctx context.Context, id string, entities model.Entities) error {
conn, err := DB.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
// 对delete-insert做同步处理
entityMux.Lock()
defer entityMux.Unlock()
tx, err := conn.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return nil
}
res, err := tx.ExecContext(ctx, "delete from entities where id = ?", id)
if err != nil {
tx.Rollback()
return err
}
_, _ = res.RowsAffected()
for _, v := range entities {
_, err := tx.ExecContext(ctx, "INSERT INTO entities (`id`) VALUES (?)",v.id)
if err != nil {
tx.Rollback()
return err
}
}
tx.Commit()
huskar.Debug(ctx).Int("entities_size", len(entities)).Msg("insert new entities")
return nil
}