在双十一、618、秒杀、优惠券发放等电商高峰场景中,**“高并发 + 库存限制”**常常导致后端系统承压严重,甚至出现“超卖”、“重复下单”、“系统雪崩”等问题。
本文以“抢券”业务为例,深入分析如何从架构层面、并发控制、数据库设计等多维度解决“高并发 + 超卖”这一经典难题,内容通俗易懂,适合有一定后端开发经验的读者阅读。
一、抢券业务特点与挑战
1. 抢券业务场景分析
抢券和秒杀业务的典型特征:
-
并发极高:可能在1秒内涌入几万甚至几十万请求
-
库存有限:只有固定数量的优惠券或商品
-
写操作密集:每次请求都可能修改库存、生成订单等
-
数据一致性要求高:不能发出超过库存数量的券(不能超卖)
2. 抢券系统的核心挑战
挑战点 | 描述 |
---|---|
高并发 | 瞬间大量请求进入,数据库或应用服务器承压 |
数据一致性 | 多线程同时读取库存,可能导致超卖 |
性能瓶颈 | 数据库写入能力有限,QPS不高 |
可扩展性 | 单机处理能力有限,必须支持横向扩展 |
二、系统吞吐量指标:QPS 与 TPS
系统优化的第一步,是理解性能指标。
1. QPS:每秒查询请求数(Queries Per Second)
-
常用于衡量接口的处理能力。
-
包括读请求,如查询用户状态、查库存等。
示例计算:
一台服务器 10 秒内处理了 20,000 个接口请求,那么它的 QPS = 2000。
静态资源(如图片、JS)常常由 CDN 分担,QPS 可以非常高(>10,000)。但动态接口尤其是数据库写入类接口(如“扣库存”)QPS 会显著降低。
2. TPS:每秒事务处理数(Transactions Per Second)
-
更偏向于数据库或交易系统的指标。
-
关注“写操作”的处理能力,如订单生成、扣库存等。
一般 TPS < QPS,因为写操作代价更高,需保证事务、持久化等。
三、抢券系统中的超卖问题
1. 超卖现象回顾
场景:假设库存为 1,同时来了两个用户请求,读取库存都为 1,判断都通过,最终都成功扣减库存。结果超卖了 1 个。
2. 为什么会超卖?
根本原因在于多个请求“并发读取库存”时,读取到的都是相同的旧值,造成多个请求都判断库存充足,进而并发写入。
这是典型的并发冲突问题。
四、常见解决方案及优劣分析
接下来我们逐一分析解决并发超卖的几种常见方案。
1. 使用 Java synchronized
同步锁
public synchronized void抢券() {
if (库存 > 0) {
库存--;
// 下单逻辑
}
}
优点:
-
实现简单,适合单机测试
-
JVM 层加锁,线程安全
缺点:
-
只在单个JVM内生效,多实例部署无法共享锁
-
串行处理,请求吞吐量极低,不适合高并发
2. 数据库悲观锁:SELECT ... FOR UPDATE
START TRANSACTION;
SELECT * FROM coupon WHERE id = 1 FOR UPDATE;
-- 判断库存 > 0
UPDATE coupon SET stock = stock - 1 WHERE id = 1;
COMMIT;
优点:
-
能保证事务强一致性
-
适合金融、电商核心业务(要求高一致性)
缺点:
-
锁表或锁行,并发高时阻塞严重
-
性能差,不适合 QPS > 100 的场景
3. 数据库乐观锁(CAS机制)
表结构新增 version 字段,每次更新时加上版本号条件。
UPDATE coupon
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 当前版本号;
优点:
-
不加锁,提升并发性能
-
支持多线程并发执行
缺点:
-
成功率随并发升高而降低,需要加重试逻辑
-
编码复杂,需额外字段和逻辑控制
4. Redis 分布式锁控制并发访问
使用 SETNX
或 Redisson 实现分布式锁机制:
// 获取锁
Boolean success = redis.setIfAbsent("lock_key", "UUID", 5秒过期);
if (success) {
try {
// 查询库存、扣减逻辑
} finally {
// 释放锁
redis.del("lock_key");
}
}
优点:
-
支持分布式部署
-
可以精准控制访问
缺点:
-
性能瓶颈:每个请求都要走 Redis 一次 SETNX
-
存在死锁、锁丢失等问题(需设置过期+唯一标识)
✅5. Redis 原子操作(最终推荐)
Redis 提供天然的原子命令,比如 DECR
:
DECR coupon_stock
原理: Redis 是单线程的,所有命令天然串行执行,保证操作原子性。
优势非常明显:
优点 | 说明 |
---|---|
性能极高 | 单线程执行命令,无锁实现,处理百万 QPS 不是问题 |
支持并发安全 | 不需要加锁,自带原子性 |
网络请求少 | 不需要读-判断-写三步,只需要一次写操作 |
易于扩展 | 可以搭配 Lua 脚本,进一步实现逻辑判断 |
典型逻辑:
-
将库存预热到 Redis 中,key:
coupon:stock:123
-
抢券请求使用
DECR
扣减库存 -
若
DECR
结果 >= 0,表示抢券成功,异步入库
五、方案总结与选型建议
方案 | 性能 | 并发安全 | 成本 | 可扩展性 | 推荐等级 |
---|---|---|---|---|---|
synchronized | 低 | ✅ | 低 | ❌ | ⭐ |
悲观锁 | 低 | ✅ | 中 | ❌ | ⭐⭐ |
乐观锁 | 中 | ✅ | 中 | 一般 | ⭐⭐⭐ |
分布式锁 | 中 | ✅ | 高 | ✅ | ⭐⭐⭐ |
Redis原子扣减 | ✅高 | ✅ | ✅低 | ✅ | ⭐⭐⭐⭐⭐ |
六、最佳实践建议
-
小型项目 / 教学实验:可使用数据库悲观锁或乐观锁
-
中型项目 / 单机部署:考虑 Redisson 分布式锁 + 本地队列
-
大型项目 / 高并发抢购:使用 Redis 缓存 + 原子扣减 + 异步削峰(MQ)
七、写在最后
高并发场景的核心思路是“加锁避免冲突、缓存抵抗压力、异步削峰填谷”。任何一个优化点的背后,都是对底层原理的深入理解与工程实践的不断打磨。
希望这篇文章能帮你理清抢券系统背后的技术细节,也欢迎你评论区分享你遇到的坑,我们一起交流进步。