一、前言:单机没问题,上集群就超卖?
很多团队在本地或单机测试时,优惠券秒杀逻辑"完美运行"。
但一旦部署到多实例集群环境,立刻出现:
- ❌ 库存超卖(100 张券发了 150 张)
- ❌ 一人多领(用户 A 领了 3 次同一张券)
- ❌ 重复下单(幂等性失效)
根本原因 :单机锁(如 synchronized)在集群下完全失效!
本文将带你全面识别集群并发陷阱,并给出生产级分布式解决方案。
二、集群环境带来的三大并发问题
问题 1️⃣:本地锁失效 → 超卖重现
java
// 单机有效,集群无效!
synchronized (this) {
if (stock > 0) {
stock--;
issueCoupon();
}
}
- 原因:每个 JVM 实例有自己的锁,无法跨进程同步
- 后果 :10 台机器同时判断
stock=1,全部通过 → 超卖
问题 2️⃣:数据库连接池竞争 → 乐观锁重试风暴
即使使用乐观锁,在集群高并发下:
- 多个实例同时读取相同
version - 只有 1 个更新成功,其余失败
- 若不做限流,大量请求反复重试 → DB 被打垮
📉 实测:1000 QPS 下,有效成功率 < 10%
问题 3️⃣:状态不共享 → 一人多单
- 实例 A 记录"用户 1001 已领取"
- 实例 B 不知情,再次发放
- 原因:内存状态未共享(如用 ConcurrentHashMap 缓存领取记录)
三、核心原则:所有状态必须外部化!
✅ 在分布式系统中,任何需要跨节点共享的状态,都必须存储在外部中间件中(如 Redis、DB)
| 本地状态 | 正确做法 |
|---|---|
| 内存库存变量 | → 存入 Redis |
| 用户领取缓存 | → 存入 Redis Set |
| 锁对象 | → 使用分布式锁 |
四、解决方案 1:Redis + Lua ------ 防超卖终极方案
✅ 原理:
利用 Redis 单线程 + Lua 原子执行特性,将"查库存 + 扣减"合并为不可分割的操作。
🔧 Lua 脚本(含一人一单检查):
Lua
-- cluster_safe_receive.lua
local stock_key = KEYS[1] -- coupon:stock:1001
local received_key = KEYS[2] -- coupon:received:1001
local user_id = ARGV[1]
-- 1. 检查是否已领取(一人一单)
if redis.call('SISMEMBER', received_key, user_id) == 1 then
return -1 -- 已领取
end
-- 2. 检查并扣减库存
local stock = tonumber(redis.call('GET', stock_key))
if not stock or stock <= 0 then
return 0 -- 库存不足
end
redis.call('DECR', stock_key)
redis.call('SADD', received_key, user_id)
return 1 -- 成功
✅ 优势:
- 完全防超卖
- 天然支持一人一单
- 集群安全(所有实例操作同一 Redis)
- 高性能(QPS > 10万)
五、解决方案 2:分布式锁(谨慎使用)
若业务逻辑复杂(如需调用外部服务),可使用 Redis 分布式锁:
java
String lockKey = "lock:coupon:" + couponId;
String lockValue = UUID.randomUUID().toString();
try {
// 尝试加锁(NX + EX)
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofSeconds(10));
if (!locked) {
throw new RuntimeException("系统繁忙,请稍后再试");
}
// 执行业务:查 DB、扣库存、发券...
if (hasReceived(userId, couponId)) {
throw new RuntimeException("您已领取过");
}
if (stock <= 0) {
throw new RuntimeException("已抢光");
}
issueCouponAndDecreaseStock(...);
} finally {
// 安全释放锁(Lua 保证 value 匹配)
releaseLock(lockKey, lockValue);
}
⚠️ 注意事项:
- 锁粒度要细 :按
couponId加锁,避免全局锁 - 设置超时时间:防止死锁
- 锁值用唯一 ID:防止误删他人锁
- 性能较低:相比 Lua 方案,吞吐量下降 50%+
📌 建议 :简单扣减用 Lua,复杂流程用分布式锁
六、解决方案 3:数据库兜底 + 最终一致性
即使使用 Redis,也要考虑数据持久化与对账:
架构设计:
用户请求
→ 网关限流
→ Redis Lua 原子扣减(防超卖 + 一人一单)
→ 异步写入 DB(MQ 或线程池)
→ 定时对账(修复不一致)
对账 Job 示例:
java
// 每天凌晨执行
void reconcileCouponStock(Long couponId) {
Long redisStock = getRedisStock(couponId);
Long dbIssuedCount = getCouponIssuedCountFromDB(couponId);
Long initialStock = getInitialStock(couponId);
Long expectedRedisStock = initialStock - dbIssuedCount;
if (!redisStock.equals(expectedRedisStock)) {
log.warn("库存不一致,couponId={}, redis={}, expected={}",
couponId, redisStock, expectedRedisStock);
// 人工介入 or 自动修正
}
}
七、其他集群并发防护措施
1. 前端 + 网关层限流
- 用户按钮点击后禁用 1 秒
- Nginx / Sentinel 限制单 IP QPS ≤ 5
2. 请求去重(Token 机制)
- 进入秒杀页时生成唯一 token
- 提交时携带 token,服务端用 Redis 标记"已使用"
- 防止用户重复提交(F5 刷新)
3. 热点 Key 优化
- 大促前预热库存到 Redis
- 使用 LocalCache + Redis 二级缓存(谨慎!需解决集群一致性)
八、方案对比总结
| 方案 | 防超卖 | 一人一单 | 性能 | 集群安全 | 推荐场景 |
|---|---|---|---|---|---|
| 本地锁(synchronized) | ❌ | ❌ | 高 | ❌ | 单机测试 |
| 数据库乐观锁 | ✅ | 需额外查 | 低 | ✅ | 低并发 |
| Redis + Lua | ✅✅ | ✅✅ | 极高 | ✅✅ | 高并发秒杀首选 |
| Redis 分布式锁 | ✅ | ✅ | 中 | ✅ | 复杂业务流程 |
| MQ 异步削峰 | ⚠️(需配合) | ⚠️ | 高 | ✅ | 超大流量兜底 |
九、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!