Rails Sidekiq后台任务操作User关联Book表时出现MySQL死锁问题求助
死锁产生原因
核心逻辑问题
InnoDB的行级排他锁是逐行获取的,不会一次性锁定所有目标行,当多个事务以不同顺序请求同一组行的锁时,就会形成循环等待,触发死锁。
你的后台任务包含两处更新操作,天然存在加锁顺序不一致的问题:
- 第一处
user.books.find_by(genre_id: XXX).update(status: "done")会先定位到符合genre_id条件的单条book记录,优先给这行记录加排他锁 - 第二处
user.books.update_all(status: "undone")会扫描该用户所有book记录,默认按照主键ID升序的顺序逐行加锁再更新
如果符合genre_id条件的book主键ID较大,就会出现:先给大ID行加锁,再按从小到大的顺序给其他行加锁的情况,和其他任务的加锁顺序形成冲突。
日志验证
从你提供的InnoDB状态日志可以直接确认循环等待的存在:
- 事务1(TRANSACTION 4613489)已经持有了主键ID较小的行锁(heap no 2对应的主键
0x0000000000000001),正在等待获取主键ID较大的行锁(heap no 187对应的主键0x000000000000040b) - 事务2(TRANSACTION 4613488)已经持有了上述主键ID较大的行锁,正在反向等待获取主键ID较小的行锁,循环等待形成,死锁触发。
额外诱因
你books表的user_id字段大概率未添加索引:执行user.books.update_all时,InnoDB无法通过索引直接定位到对应用户的所有book记录,只能走全表扫描逐行校验user_id,过程中会给所有扫描过的行加排他锁,哪怕不属于当前用户,大幅提升了不同用户任务之间的锁冲突概率。
解决方案
- 固定加锁顺序:所有更新操作前,先按主键升序锁定当前用户的所有book记录,避免顺序混乱:
def perform # 先按主键升序锁定当前用户所有book,统一加锁顺序 user.books.order(:id).lock! user.books.find_by(genre_id: XXX).update(status: "done") # 其他业务逻辑 user.books.update_all(status: "undone") end
- 添加索引避免全表锁:给
books表的user_id字段添加索引,避免update_all时扫描锁定无关行:
# 数据库迁移文件内容 add_index :books, :user_id
- 缩小锁持有范围:如果两处更新不需要在同一个事务内完成,可以拆分事务减少锁持有时间;也可以合并两处更新逻辑,减少锁操作次数。
- 避免同用户任务并发:通过Sidekiq唯一任务插件,保证同一时间同一个用户的该类任务只执行一个,从根源消除并发冲突。
内容的提问来源于stack exchange,提问作者user16012143
相关产品推荐
相关产品推荐

