如何在Django中防止竞态条件

如何在Django中防止竞态条件

💡 原文英文,约6100词,阅读约需22分钟。
📝

内容提要

本文讲解Django积分系统中的竞态条件:两个并发请求可能同时通过余额检查,导致同一积分被重复消费。作者用PostgreSQL行锁(select_for_update)和事务保护余额扣减,确保检查与更新原子执行,并给出复现、修复及并发测试方法。

🔎

延伸解读

行锁与事务的配合要点

文章强调,仅用 transaction.atomic 包裹余额扣减和生成记录创建,并不能阻止两个请求同时读到相同余额。因为 PostgreSQL 默认的 Read Committed 隔离级别下,普通读取不会让另一个事务等待。必须配合 select_for_update 对账户行加锁,让竞争请求在读取前排队,才能确保检查与更新原子执行。

保护范围需覆盖所有写路径

作者提醒,即使 reserve_generation 使用了行锁,如果其他地方(如管理后台调整、后台任务)仍以普通方式读取并更新余额,它们可能基于过期数据覆盖已扣减的余额。因此,所有修改余额的代码路径都应采用相同的锁策略,不能只保护一个函数。

测试并发时的关键设计

文章使用 TransactionTestCase 和 Barrier 强制两个线程在读取后同步,以稳定复现竞态。测试还通过 SET LOCAL lock_timeout 验证服务在检查余额前确实等待了行锁。这些测试依赖 PostgreSQL 的行锁行为,在 SQLite 上会被跳过,因此不能仅凭 SQLite 测试判断并发安全性。

事务边界与外部调用的取舍

作者建议将图像生成等外部调用放在事务之外,避免长时间占用行锁导致其他请求等待。但这也意味着,如果生成失败,已扣减的积分不会自动回滚,需要额外的重试或退款策略。事务只应覆盖积分预留和生成记录创建,确保两者同时成功或失败。

Q&A

Django中竞态条件是什么?为什么会导致积分被重复消费?

竞态条件是指结果的正确性依赖于并发操作的时序或顺序的缺陷。在积分系统中,两个并发请求可能同时读取到相同的余额(例如1积分),都通过余额检查,然后各自扣减并保存,导致同一积分被重复消费。这是因为检查与更新不是原子操作,另一个请求可以在读取和写入之间修改数据。

如何在Django中防止积分系统的竞态条件?

使用数据库事务和行锁。具体做法:在事务中通过select_for_update()获取账户行的锁,然后检查余额并扣减,最后创建生成记录。这样并发的请求会在锁处等待,确保检查与更新原子执行。同时,将图像生成等耗时操作放在事务外,避免长时间持有锁。

为什么在Django中需要使用select_for_update来防止竞态条件?

select_for_update会请求数据库行锁,使竞争请求在锁释放前等待。在PostgreSQL的Read Committed隔离级别下,普通读取不会阻塞其他事务,因此两个请求可能同时读到相同余额。使用select_for_update后,第一个请求锁定账户行,第二个请求必须等待,直到第一个事务提交或回滚,从而基于最新余额做出决策。

如何复现Django积分系统中的竞态条件?

可以打开两个Django shell,分别读取同一个账户的余额(都为1),然后依次执行扣减和保存操作。由于两个shell都基于旧值判断,最终余额为0但创建了两条生成记录。或者使用自动化测试,通过Barrier同步两个线程的读取操作,强制它们先读后写。

在Django中如何测试并发请求下的积分扣减是否正确?

使用TransactionTestCase和ThreadPoolExecutor模拟并发请求。通过Barrier让两个请求同时开始,然后检查响应状态码和数据库状态。例如,对于1个积分,两个并发请求应返回一个201和一个409,余额为0,且只有一条生成记录。还可以测试锁等待行为,验证请求在锁释放前会超时。

为什么在Django中防止竞态条件时,图像生成操作应该放在事务外?

因为图像生成可能涉及远程请求,耗时较长。如果放在事务内,会长时间持有数据库锁,导致其他竞争请求等待过久。将生成操作放在事务外,可以尽快提交积分扣减,释放锁,提高并发性能。但需要注意,如果生成失败,可能需要额外的重试或退款策略。

🏷️

标签

➡️

继续阅读