Django+MySQL使用select_for_update时的死锁问题排查求助
问题背景
在多进程并发场景下,实现端点逻辑:获取用户最新未完成的Play记录,存在则修改,不存在则创建,要求并发请求时一个创建、另一个阻塞等待后修改。使用Django的transaction.atomic()上下文结合select_for_update()实现,但压测后仍出现死锁。死锁日志显示:事务1持有finished索引锁,等待某Play主键锁;事务2持有该Play主键锁,等待finished索引锁,且两个Play属于不同用户。
死锁原因分析
1. 索引设计缺陷导致锁范围过大
当前仅依赖finished单字段索引,执行select_for_update().filter(user=X, game=X, finished=False, discard=False)时,InnoDB无法通过finished索引精准定位到当前用户的行:
- 首先会通过
finished索引筛选所有finished=False的行,然后回表校验user、game、discard条件; - 此过程中,InnoDB会对
finished=False对应的整个索引范围(包括间隙)加锁,而非仅锁定当前用户的目标行。这意味着事务会锁定其他用户的Play记录对应的索引项,甚至未存在的行的间隙。
2. 锁获取顺序不一致引发循环等待
结合代码中拆分的事务逻辑(先查询锁索引,再在另一个事务中锁主键),可能出现以下死锁场景:
- 事务A先执行
filter查询,锁定了finished索引的大范围(包含事务B用户的Play索引项); - 事务B先创建了自己的Play记录,持有该记录的主键锁;
- 事务A后续尝试修改某Play需要获取该主键锁,而事务B执行
filter查询时需要获取finished索引锁,两者形成循环等待,触发死锁。
3. 重复加锁与事务拆分的问题
代码中在第一个查询已通过select_for_update()锁定Play行,后续又在独立的transaction.atomic()中再次执行select_for_update().get(pk=play.pk),这种重复加锁且拆分事务的操作,会打乱锁的获取顺序,增加死锁概率。
解决办法
1. 创建精准覆盖查询条件的联合索引
建立包含所有查询条件的联合索引,让InnoDB直接定位到目标行,缩小锁范围:
CREATE INDEX idx_user_game_finished_discard ON play (user_id, game_id, finished, discard);
(注:根据实际表名、字段名调整,确保索引顺序匹配查询条件的过滤优先级)
2. 将所有操作合并到同一事务中
把查询、判断、修改/创建逻辑放在同一个transaction.atomic()上下文内,避免锁的生命周期拆分,确保锁的获取顺序一致:
with transaction.atomic(): # 精准锁定当前用户的未完成Play plays = list(Play.objects.select_for_update().filter( game=self.game, user=self.user, discard=False, finished=False, )) if plays: play = plays[0] # 执行Play的修改操作 play.some_field = new_value play.save() else: # 不存在则创建新记录 play = Play.objects.create( game=self.game, user=self.user, discard=False, finished=False, # 其他字段赋值 )
3. 移除不必要的重复加锁
删除后续独立事务中对play.pk的二次select_for_update()操作,第一个查询已经完成了行锁定,无需重复加锁。
4. 优化锁的获取顺序(可选)
确保所有事务遵循相同的锁获取顺序,比如先通过用户维度锁定,再处理索引相关操作,避免循环等待的可能。
内容的提问来源于stack exchange,提问作者Antonio Cruz

