并发事务死锁问题排查

业务系统上线后,服务日志报错:

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"}

deadlock原因

造成死锁竞争状态后,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
}

版权声明:本文为darope原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/darope/p/15048314.html