You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

多线程下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块内的锁仅在离开块时释放,因此有两个疑问:

  1. 为何更新代码部分不会因共享锁升级为排他锁而产生死锁?
    aggregated_report.value += transaction.value
    aggregated_report.save()
    
  2. 是否有其他实现方式可完全避免死锁?或者仅捕获异常重试的方案是否足够适用于该场景?

问题解答

为什么更新操作不会触发死锁?

当执行更新场景时,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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.07 08:38:09