多线程下Django get_or_create结合MySQL死锁问题及优化咨询
场景与代码
我运行了一个多线程数据库脚本,核心代码如下:
with transaction.atomic(): ( aggregated_report, created, ) = db_model.objects.select_for_update().get_or_create( currency=currency, date=today_date, aggregator=aggregator, type=transaction.type, **kwargs, defaults={"value": transaction.value}, ) if not created: aggregated_report.value += transaction.value aggregated_report.save()
当get_or_create未找到记录需要创建时,出现了MySQL死锁,目前已在应用层捕获OperationalError并重试。
死锁日志
执行SHOW ENGINE INNODB STATUS得到的死锁日志:
------------------------ LATEST DETECTED DEADLOCK ------------------------ 2023-10-30 16:26:39 281472641544064 *** (1) TRANSACTION: TRANSACTION 2108849, ACTIVE 0 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s) SELECT `reports_aggregatedvalues`.`id`, `reports_aggregatedvalues`, `reports_aggregatedvalues`.`currency`, `reports_aggregatedvalues`.`date`, `reports_aggregatedvalues`.`type`, `reports_aggregatedvalues`.`aggregator`, `reports_aggregatedvalues`.`value` FROM `reports_aggregatedvalues` WHERE (`reports_aggregatedvalues`.`aggregator` = '---' AND `reports_aggregatedvalues`.`currency` = 'USD' AND `reports_aggregatedvalues`.`date` = '2023-10-09 00:00:00' AND `reports_aggregatedvalues` AND `reports_aggregatedvalues`.`type` = 'TRANSFER') LIMIT 21 FOR UPDATE *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 26 page no 5 n bits 384 index Unique Report of table `phoenix`.`reports_aggregatedvalues` trx id 2108849 lock mode S Record lock, heap no 313 PHYSICAL RECORD: n_fields 6; compact format; info bits 0 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 26 page no 5 n bits 384 index Unique Report of table `phoenix`.`reports_aggregatedvalues` trx id 2108849 lock_mode X locks rec but not gap waiting Record lock, heap no 313 PHYSICAL RECORD: n_fields 6; compact format; info bits 0 *** (2) TRANSACTION: TRANSACTION 2108848, ACTIVE 0 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s) SELECT `reports_aggregatedvalues`.`id`, `reports_aggregatedvalues`, `reports_aggregatedvalues`.`currency`, `reports_aggregatedvalues`.`date`, `reports_aggregatedvalues`.`type`, `reports_aggregatedvalues`.`aggregator`, `reports_aggregatedvalues`.`value` FROM `reports_aggregatedvalues` WHERE (`reports_aggregatedvalues`.`aggregator` = '----' AND `reports_aggregatedvalues`.`currency` = 'USD' AND `reports_aggregatedvalues`.`date` = '2023-10-09 00:00:00' AND `reports_aggregatedvalues` AND `reports_aggregatedvalues`.`type` = 'TRANSFER') LIMIT 21 FOR UPDATE *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 26 page no 5 n bits 384 index Unique Report of table `phoenix`.`reports_aggregatedvalues` trx id 2108848 lock mode S Record lock, heap no 313 PHYSICAL RECORD: n_fields 6; compact format; info bits 0 *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 26 page no 5 n bits 384 index Unique Report of table `phoenix`.`reports_aggregatedvalues` trx id 2108848 lock_mode X locks rec but not gap waiting Record lock, heap no 313 PHYSICAL RECORD: n_fields 6; compact format; info bits 0 *** WE ROLL BACK TRANSACTION (2)
疑问
我理解死锁原因是:get_or_create执行时先通过SELECT FOR UPDATE获取共享锁,后续创建记录时尝试升级为排他锁,若两个线程同时持有共享锁,会互相等待释放导致死锁。
但脚本中有数千次更新操作、仅5-10次创建操作,死锁却只发生在创建场景。已知transaction.atomic块内的锁仅在离开块时释放,因此有两个疑问:
- 为何更新代码部分不会因共享锁升级为排他锁而产生死锁?
aggregated_report.value += transaction.value aggregated_report.save() - 是否有其他实现方式可完全避免死锁?或者仅捕获异常重试的方案是否足够适用于该场景?
为什么更新操作不会触发死锁?
当执行更新场景时,get_or_create的SELECT FOR UPDATE会找到已存在的记录,此时InnoDB会直接给该记录加排他锁(X锁),而不是共享锁(S锁)。因为SELECT FOR UPDATE的语义就是为后续的更新操作预先锁定目标行,确保其他事务无法修改该行——当目标行存在时,InnoDB会直接授予X锁,不存在时才会先加S锁保护索引间隙(防止幻读),后续插入时再尝试升级为X锁。
所以更新场景下,每个事务一开始就持有了目标行的X锁,不会出现多个事务同时持有S锁、互相等待升级的情况,自然不会触发死锁。
完全避免死锁的方案
1. 使用select_for_update(skip_locked=True)(非强一致性场景适用)
如果业务允许跳过被锁定的行(稍后重试),可以在select_for_update中添加skip_locked=True参数。这样当事务发现目标行被其他事务锁定时,不会等待,而是直接抛出异常(或返回空,取决于ORM实现),避免循环等待的死锁场景。
2. 先插入后更新(乐观锁思路)
调整逻辑,先尝试插入新记录,若因唯一键冲突失败,再执行更新操作。这种方式可避免SELECT FOR UPDATE带来的锁竞争:
try: with transaction.atomic(): aggregated_report = db_model.objects.create( currency=currency, date=today_date, aggregator=aggregator, type=transaction.type, **kwargs, value=transaction.value ) except db_model.IntegrityError: # 捕获唯一键冲突异常 with transaction.atomic(): aggregated_report = db_model.objects.select_for_update().get( currency=currency, date=today_date, aggregator=aggregator, type=transaction.type, **kwargs ) aggregated_report.value += transaction.value aggregated_report.save()
注意:必须确保(currency, date, aggregator, type, ...)组合有唯一索引,这样插入失败可明确判断为记录已存在。
3. 使用MySQL原生INSERT ... ON DUPLICATE KEY UPDATE
利用数据库原生原子操作,将“查询-创建/更新”合并为一条SQL,完全在数据库层面保证原子性,彻底消除锁竞争:
INSERT INTO reports_aggregatedvalues (currency, date, aggregator, type, value) VALUES (%s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE value = value + %s;
在Django中,可通过bulk_create配合update_conflicts(Django 4.0+支持),或直接用raw()执行原生SQL。这种方式无需应用层事务和锁,从根源上避免死锁。
捕获异常重试是否足够?
对于你的场景(仅少量创建操作触发死锁),捕获OperationalError并重试是完全可行的:
- 死锁发生概率低,重试成本可控;
- InnoDB会自动回滚死锁中的一个事务,应用层重试即可恢复;
- 实现简单,无需大幅修改现有代码。
但需注意:
- 设置有限的重试次数,避免无限循环;
- 确保异常退出时事务已自动回滚(
transaction.atomic块会处理); - 若后续创建操作频率上升,死锁概率会增加,建议结合上述方案优化。
内容的提问来源于stack exchange,提问作者Antonio Cruz

