内容提要
本文讲解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中防止竞态条件时,图像生成操作应该放在事务外?
因为图像生成可能涉及远程请求,耗时较长。如果放在事务内,会长时间持有数据库锁,导致其他竞争请求等待过久。将生成操作放在事务外,可以尽快提交积分扣减,释放锁,提高并发性能。但需要注意,如果生成失败,可能需要额外的重试或退款策略。