cswork / 系统设计

第 37 章 / 共 57 章 · 约 4 分钟

设计模式:资源争抢

本页目录
  1. 1. 问题的本质
  2. 2. 方案,从简单到复杂
  3. 3. 怎么选
  4. 4. 常见追问
  5. 5. 本站用到这个模式的案例
  6. 章节测验

当很多请求同时争抢同一份有限的资源(最后几张票、一个司机、一件爆款库存、同一个账户余额),“先读再写”的朴素做法就会出错:两个请求都读到“还剩 1 个”,都以为自己抢到了。这一章按从简单到复杂的顺序整理处理争抢的手段,以及它们分别在什么规模下失效。

1. 问题的本质

争抢问题都是同一个形状:检查条件(还有座吗?余额够吗?)和修改状态(占座、扣款)之间有时间差,别的请求在这个时间差里改了状态。解决办法要么把两步合成一个原子操作,要么在这段时间里拦住别人,要么干脆让同一份资源的请求排队一个一个来。

2. 方案,从简单到复杂

2.1 数据库原子操作和约束(首选)

  • 条件更新:UPDATE seats SET status='held' WHERE id=? AND status='available',或 UPDATE inventory SET stock = stock - 1 WHERE sku=? AND stock > 0。影响行数为 0 就是没抢到。
  • 唯一约束:UNIQUE(event_id, seat_id) 让数据库拒绝第二次预订;幂等键也是同样的思路。
  • 优点:简单、正确、不需要额外组件。在单个数据库里能解决的,就不要引入分布式锁。

2.2 悲观锁

  • SELECT ... FOR UPDATE 先锁住行,再判断和修改,事务结束释放。
  • 多行时按固定顺序加锁(例如按 seat_id 排序),避免死锁。
  • 代价:高并发下请求在锁上排队,事务要尽量短,不能在持锁期间调用外部服务。

2.3 乐观并发控制

  • 每行带 version,更新时 WHERE id=? AND version=?,失败就重读重试。DynamoDB 用条件写实现同样的效果。
  • 适合冲突少的场景;冲突多时大量重试反而浪费,这时退回悲观锁或排队。

2.4 预占加过期

  • 抢票、下单时先“预占”,给一个过期时间(例如 10 分钟),付款成功再确认,超时自动释放。
  • 预占可以是数据库里带 expires_at 的状态,也可以是 Redis 里带 TTL 的 key。关键是释放要可靠:靠过期时间而不是靠客户端主动取消。

2.5 分布式锁

  • 资源跨越多个存储、无法用单个数据库的原子操作覆盖时,才用分布式锁:Redis SET NX PX 适合“偶尔重复也能接受”的场景;需要严格互斥时用 ZooKeeper / etcd,并且配合 fencing token。
  • 锁一定要有过期时间,持锁者崩溃后才能自动释放。

2.6 按资源排队(单写者)

  • 把同一份资源的所有请求路由到同一个处理者,串行执行:例如用 Kafka 按 event_id 分区,每个分区一个消费者顺序处理;或者按 key 把请求哈希到某个 actor。
  • 串行之后就没有并发冲突了,吞吐靠增加分区数来扩展。代价是请求变成异步,需要告诉用户“处理中”。

2.7 削峰和准入

  • 极端热点(秒杀、开票)时,问题不只是正确性,还有数据库被压垮。前面加虚拟等候室:用户先排队,按数据库能承受的速度发放准入令牌,只有持令牌的请求能进入下单流程。
  • 计数类热点(点赞数、浏览量)可以拆成多个分片计数器,或先在内存、流处理里聚合,再批量写入。

3. 怎么选

场景推荐
单个数据库内的库存、座位、余额条件更新或唯一约束
冲突少、读多写少的编辑乐观并发控制(版本号)
需要先占后付的流程预占加过期
跨服务、跨存储的互斥分布式锁加 fencing token
单个资源上的极高并发按资源排队,前面加等候室
热点计数分片计数器或异步聚合

4. 常见追问

  • Redis 锁和数据库条件更新都能防超卖,选哪个? 库存本来就在数据库里,就用条件更新:一次往返、没有锁过期的边界情况。Redis 锁适合保护不在同一个数据库里的操作。
  • 用户占了座不付款怎么办? 预占带过期时间,后台任务或查询时按 expires_at 判断,过期自动回到可售状态。
  • 悲观锁会不会死锁? 多行加锁时按统一顺序;设置锁等待超时;事务里不做网络调用。
  • 10 万人同时抢 1000 张票,数据库扛得住吗? 绝大多数请求注定抢不到,没必要都打到数据库:等候室限流、Redis 里先扣一个“剩余数量”的预检、最后在数据库里用条件更新确认。

5. 本站用到这个模式的案例

章节测验

5 道选择题
  1. 库存存在 PostgreSQL 里,要防止超卖。最简单可靠的做法是?
  2. 用户在抢票页选了座位,进入付款,但可能放弃付款。怎样避免座位被永久占住?
  3. 一个文档编辑系统,绝大多数时候只有一个人在改同一篇文档,偶尔有两人同时保存。哪种并发控制最合适?
  4. 秒杀活动:10 万人同时抢 1000 件商品。除了保证不超卖,还要防止数据库被压垮。哪个组合最合理?
  5. 什么时候才真正需要分布式锁,而不是数据库的原子操作?

章节讨论

正在加载讨论…