优惠卷秒杀
一、前言:为什么“发个优惠券”这么难?
你是否以为“优惠券秒杀”只是简单地:
UPDATE coupon SET stock = stock - 1 WHERE id = 1001 AND stock > 0;
但在高并发场景下(如 10 万人同时抢 100 张券),你会遇到:
- ❌ 库存超卖:发了 150 张,实际只有 100 张
- ❌ 重复领取:同一个用户领了多次
- ❌ 数据库被打爆:QPS 飙升至 10万+
根本原因:未做并发控制 + 缺乏原子性保障!
本文将带你从零设计一个高性能、防超卖、可扩展的优惠券秒杀系统,并提供完整代码实现。
二、核心挑战分析
| 问题 | 原因 | 后果 |
|---|---|---|
| 库存超卖 | 多线程同时读到 stock=1,都执行 -1 | 发放数量 > 总量 |
| 重复领取 | 未校验用户是否已领取 | 用户薅羊毛 |
| 性能瓶颈 | 直接操作数据库 | DB 连接池耗尽 |
💡 解决思路:缓存前置 + 原子操作 + 异步落库
三、整体架构设计
用户请求
↓
Nginx(限流)
↓
Spring Boot 应用
↓
✅ Redis(预加载库存 + 原子扣减)
↓
✅ RabbitMQ / Kafka(异步落库)
↓
MySQL(最终持久化)
核心原则:
- Redis 扛住高并发
- Lua 脚本保证原子性
- 异步写 DB 提升吞吐
四、第一步:初始化优惠券库存到 Redis
优惠券上线前,将库存预热到 Redis:
# Redis 中存储两个关键数据
SET coupon:stock:1001 100 # 总库存
SET coupon:user:1001 {} # 已领取用户集合(用 Set)
💡 使用
Redis Set天然去重,避免重复领取
五、第二步:Lua 脚本实现原子扣减(关键!)
编写 Lua 脚本,一次性完成:校验库存 + 校验用户 + 扣减库存 + 记录用户
-- seckill.lua
local stockKey = KEYS[1]
local userSetKey = KEYS[2]
local userId = ARGV[1]
-- 1. 检查库存是否充足
local stock = tonumber(redis.call('GET', stockKey))
if stock <= 0 then
return 0 -- 库存不足
end
-- 2. 检查用户是否已领取
if redis.call('SISMEMBER', userSetKey, userId) == 1 then
return 2 -- 已领取
end
-- 3. 扣减库存 + 添加用户
redis.call('DECR', stockKey)
redis.call('SADD', userSetKey, userId)
return 1 -- 成功
Java 调用 Lua 脚本:
@Component
public class CouponSeckillService {
@Autowired
private StringRedisTemplate redisTemplate;
private DefaultRedisScript seckillScript;
@PostConstruct
public void init() {
seckillScript = new DefaultRedisScript<>();
seckillScript.setScriptSource(new ResourceScriptSource(
new ClassPathResource("lua/seckill.lua")));
seckillScript.setResultType(Long.class);
}
public boolean trySeckill(Long couponId, Long userId) {
String stockKey = "coupon:stock:" + couponId;
String userSetKey = "coupon:user:" + couponId;
Long result = redisTemplate.execute(
seckillScript,
Arrays.asList(stockKey, userSetKey),
userId.toString()
);
if (result == 1) {
// 秒杀成功,发送 MQ 异步落库
mqProducer.sendSeckillRecord(couponId, userId);
return true;
} else if (result == 0) {
throw new BusinessException("库存不足");
} else if (result == 2) {
throw new BusinessException("您已领取过该优惠券");
}
return false;
}
}
✅ 优势:
- 原子性:Lua 脚本在 Redis 中单线程执行
- 高性能:一次网络往返完成所有校验
- 防超卖 + 防重复:双重保障
六、第三步:异步落库(提升吞吐)
秒杀成功后,不立即写 MySQL,而是发送消息到 MQ:
// MQ 消息体
{
"couponId": 1001,
"userId": 10086,
"timestamp": 1712345678
}
消费者异步写入数据库:
@RabbitListener(queues = "seckill.queue")
public void handleSeckillRecord(SeckillRecord record) {
// 1. 再次校验(幂等性)
if (couponRecordMapper.exists(record.getCouponId(), record.getUserId())) {
return;
}
// 2. 插入记录
couponRecordMapper.insert(record);
// 3. 更新业务状态(如用户优惠券列表)
}
💡 好处:
- 数据库 QPS 降低 90%+
- 系统吞吐量大幅提升
七、第四步:兜底与监控
1. 限流保护
- Nginx 层:
limit_req - 应用层:Sentinel 限流(如 1000 QPS)
2. 库存补偿机制
- 定时任务比对 Redis 与 DB 库存
- 发现不一致时自动修复
3. 监控指标
- Redis 库存剩余量
- 秒杀成功率
- MQ 积压消息数
八、常见误区与避坑指南
❌ 误区 1:用 synchronized 本地锁
后果:多实例部署时无效
正解:必须用 Redis 原子操作或分布式锁
❌ 误区 2:先查库存再扣减(非原子)
if (stock > 0) {
stock--; // ❌ 并发下仍会超卖
}
正解:必须用 DECR + Lua 原子脚本
❌ 误区 3:同步写数据库
风险:DB 成为瓶颈
正解:异步落库 + 幂等消费
九、扩展思考:如何支持“一人多张”?
若业务允许用户领取多张(如上限 3 张),只需修改 Lua 脚本:
-- 替换 SISMEMBER 为 SCARD
local userCount = redis.call('SCARD', userSetKey)
if userCount >= 3 then
return 2 -- 超过领取上限
end
-- 添加时用 SADD(自动去重)或 List(允许重复)
十、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!








