第 37 章 / 共 57 章 · 约 4 分钟
设计模式:资源争抢
当很多请求同时争抢同一份有限的资源(最后几张票、一个司机、一件爆款库存、同一个账户余额),“先读再写”的朴素做法就会出错:两个请求都读到“还剩 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. 本站用到这个模式的案例
- 案例 12:票务抢购:悲观锁和条件更新的对比、带过期时间的预占、虚拟等候室。
- 案例 10:支付与账本:幂等键防止重复扣款,余额更新用版本号做乐观锁。
- 案例 5:网约车:派单时用带 TTL 的司机锁,保证一个司机不会同时接两单。
- 案例 2:分布式限流器:计数器的“读-判断-写”放进 Redis Lua 脚本里原子执行。
- 案例 9:分布式键值存储:并发写同一个 key 时用向量时钟检测冲突。