如何在Django与PostgreSQL中安全且原子性地递减计数器?
我一直在研究PostgreSQL事务隔离和Django的transaction.atomic()的关联,但对该主题不算精通,不确定自己理解是否正确。
我们的Django应用基于PostgreSQL,有一个配额模型,简化后如下:
class Quota(models.Model): obj = models.OneToOneField(AnotherModel) count = models.PositiveIntegerField()
这个模型实例用来控制对obj实例执行特定操作的次数,count初始为某个值,只会递减到0。
现在有任意数量的进程/线程会并发执行这些操作,我们需要原子性地递减单条数据库行的count值:要避免死锁,同时不能出现两个进程把初始值100的count同时减到99的情况。
我尝试的两种方案
方案一:设置SERIALIZABLE隔离级别
with transaction.atomic(): cursor = connection.cursor() cursor.execute('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE') Quota.objects.filter(obj=instance).update(count=F('count')-1)
但我不确定会不会遇到这种问题:
如果在COMMIT时数据库无法确定该事务相对于其他事务的读写操作可串行执行,则会抛出django.db.DatabaseError,即使它们更新的是不同行也可能发生。
所有针对同一obj的操作都会修改同一行的同一列,会不会触发这个错误?我也不太清楚PostgreSQL是怎么判断事务是否可串行执行的。
方案二:使用select_for_update行级锁
with transaction.atomic(): Quota.objects.select_for_update().filter(obj=instance).update(count=F('count')-1)
这个方案会加行级锁,我觉得不需要修改隔离级别,但不确定这样是否足以正确处理并发。
我的疑问
哪种方案更合适?是否还需要修改来保证原子性和避免死锁?我们也可以用python-redis-lock在Django视图层阻止并发,但感觉在数据库层处理更合理。
核心前提:PostgreSQL UPDATE的原子性
首先明确:PostgreSQL的UPDATE语句本身就是原子性的。直接执行针对单行的更新时,数据库会自动为目标行加排他锁,确保同一行的更新操作串行执行,绝不会出现两个事务同时将count=100减到99的情况。基于这个前提,你的两个方案都做了多余操作,以下是具体分析:
方案一:SERIALIZABLE隔离级别
完全没必要,反而会引入问题:
- SERIALIZABLE是PostgreSQL最严格的隔离级别,它会在事务提交时校验所有并发事务的读写序列是否能等价于某个串行执行顺序,一旦无法判定就会抛出
DatabaseError。 - 针对同一行的高频并发更新,极容易触发这个错误,你需要额外处理重试逻辑,完全是自增复杂度。
方案二:select_for_update + UPDATE
属于冗余操作:
select_for_update会先锁定目标行,再执行UPDATE,但UPDATE语句本身就会自动获取行级排他锁,两步操作反而延长了锁的持有时间,虽然单行操作死锁概率极低,但完全没必要多此一举。
最优方案:直接原子更新
你只需要最简洁的写法就能满足所有需求:
Quota.objects.filter(obj=instance).update(count=F('count')-1)
或者更直接的原生SQL(效果完全一致):
from django.db import connection with connection.cursor() as cursor: cursor.execute( "UPDATE myapp_quota SET count = count - 1 WHERE obj_id = %s", [instance.id] )
为什么这个就足够?
- 原子性保障:PostgreSQL的UPDATE语句原子执行,同一行的更新会被数据库串行化,彻底避免竞态条件。
- 自动锁机制:UPDATE会自动获取目标行的排他锁(ROW EXCLUSIVE),其他事务要更新同一行必须等待当前事务提交/回滚,自然避免并发冲突。
- 性能最优:没有额外的锁操作或隔离级别设置,执行效率最高。
额外注意事项
- 若要防止
count被减到负数,可以在过滤条件中添加count__gt=0,这样只有当count>0时才会执行更新,返回值updated会告诉你是否成功完成递减:updated = Quota.objects.filter(obj=instance, count__gt=0).update(count=F('count')-1) if not updated: # 配额已耗尽,执行对应逻辑 pass - 如果需要在递减后获取最新的
count值,要在同一个事务内查询,避免读取到未提交的旧值:with transaction.atomic(): updated = Quota.objects.filter(obj=instance, count__gt=0).update(count=F('count')-1) if updated: quota = Quota.objects.get(obj=instance) print(quota.count)
内容的提问来源于stack exchange,提问作者JK Laiho

