Django实现无竞态条件的字符串值递增问题求助
问题描述
我希望在保存文件夹时生成类似name, name(1), name(2)的名称,为此实现了如下代码:
with transaction.atomic(): same_name_count = Folder.objects.filter( owner=validated_data["owner"], name__iregex=r"%s(\s\(\d+\))?" % validated_data["name"], ).count() if same_name_count: validated_data["name"] = f"{validated_data['name']} ({same_name_count+1})" folder = Folder.objects.create(**validated_data)
但在Celery任务中运行该代码时仍出现竞态条件,导致生成重复名称。我也曾尝试使用select_for_update锁定行以获取正确计数,但问题依旧。我的Folder模型定义如下:
class Folder(models.Model): owner = models.ForeignKey('User') name = models.CharField(max_length=255)
请帮忙解决该竞态条件问题。
解决方案
核心问题分析
当前写法在Celery多任务并行场景下失效,原因在于:
select_for_update未正确使用:仅查询计数但无匹配行时,没有任何行被锁定,并发任务仍能同时执行查询逻辑。- 查询计数与创建操作并非原子性的“获取唯一名称+写入”流程,多个任务可能同时拿到相同的计数结果,进而生成重复名称。
可行方案
方案1:数据库唯一约束+重试机制
这是最可靠的方案,利用数据库的唯一约束拦截重复,通过重试生成正确名称:
- 给
Folder模型添加联合唯一约束:
class Folder(models.Model): owner = models.ForeignKey('User', on_delete=models.CASCADE) name = models.CharField(max_length=255) class Meta: # Django 2.2以下写法 unique_together = ('owner', 'name') # Django 2.2+推荐写法 # constraints = [ # models.UniqueConstraint(fields=['owner', 'name'], name='unique_owner_folder_name') # ]
- 修改创建逻辑,加入重试机制:
from django.db import IntegrityError import random import time def create_unique_folder(validated_data): base_name = validated_data["name"] owner = validated_data["owner"] attempt = 0 max_attempts = 5 # 限制最大重试次数,避免死循环 while attempt < max_attempts: try: with transaction.atomic(): # 先检查基础名称是否存在,减少不必要的重试 if not Folder.objects.filter(owner=owner, name=base_name).exists(): return Folder.objects.create(**validated_data) # 提取现有带序号的文件夹的最大序号 from django.db.models import Func, F, Value from django.db.models.fields import IntegerField class ExtractNumber(Func): function = 'REGEXP_REPLACE' template = "%(function)s(%(expressions)s, '.*\\((\\d+)\\)$', '\\1')" output_field = IntegerField() max_num = Folder.objects.filter( owner=owner, name__iregex=r"%s\s\(\d+\)$" % base_name ).annotate( num=ExtractNumber(F('name')) ).aggregate(max_num=models.Max('num'))['max_num'] or 0 validated_data["name"] = f"{base_name} ({max_num + 1})" return Folder.objects.create(**validated_data) except IntegrityError: # 触发唯一约束,说明有并发任务先创建了该名称,重试 attempt += 1 time.sleep(random.uniform(0.1, 0.5)) # 随机休眠降低冲突概率 raise Exception("无法创建唯一名称的文件夹,重试次数已达上限")
核心逻辑:数据库兜底拦截重复,出现冲突时自动重试,确保最终生成唯一名称。
方案2:用户级行锁串行化操作
通过锁定用户对应的专属锁行,确保同一用户的文件夹创建操作串行执行:
- 新增锁模型:
class UserFolderLock(models.Model): user = models.OneToOneField('User', on_delete=models.CASCADE, primary_key=True) # 仅用于占位加锁,无需额外字段
- 修改创建逻辑:
from django.db import transaction with transaction.atomic(): # 获取或创建用户的锁行,select_for_update确保锁定,同一用户的并发任务会排队 lock, _ = UserFolderLock.objects.select_for_update().get_or_create(user=validated_data["owner"]) base_name = validated_data["name"] same_name_count = Folder.objects.filter( owner=validated_data["owner"], name__iregex=r"%s(\s\(\d+\))?" % base_name, ).count() if same_name_count: validated_data["name"] = f"{base_name} ({same_name_count+1})" folder = Folder.objects.create(**validated_data)
这个方案通过锁定用户对应的唯一行,强制同一用户的文件夹创建操作串行,彻底避免竞态。需注意:数据库需支持行锁(如PostgreSQL、MySQL InnoDB)。
为什么之前的select_for_update无效?
之前的select_for_update仅作用于Folder的查询结果,当没有匹配的文件夹(如首次创建)时,没有任何行被锁定,并发任务仍能同时进入查询流程,拿到相同的计数结果,导致重复名称。而方案2锁定的是用户的锁行,无论是否存在文件夹,都会强制串行。
内容的提问来源于stack exchange,提问作者Arti
相关产品推荐
相关产品推荐

