优惠卷秒杀集群环境下的并发问题

一、前言:单机没问题,上集群就超卖?

很多团队在本地或单机测试时,优惠券秒杀逻辑"完美运行"。

但一旦部署到多实例集群环境,立刻出现:

  • ❌ 库存超卖(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 异步削峰 ⚠️(需配合) ⚠️ 高 ✅ 超大流量兜底

九、结语

感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!

相关推荐
姜鱼问生5 小时前
Nginx 缓存命中率监控:从 X-Cache-Status 到实时统计
运维·nginx·缓存
禾小西8 小时前
07丨Redis 哨兵机制:主库故障后,如何恢复服务?
java·开发语言·redis
骉马代驾10 小时前
代驾系统实战(三):抢单池的 Redis 锁 + RabbitMQ 异步消费怎么做
redis
行百里er12 小时前
Redis Streams——有确认、能回溯的消息队列
redis
小马同学-12 小时前
Redis消息队列与客户端编程
数据库·redis
weixin1997010801612 小时前
《1688图片空间API踩坑:img.upload 与 album.* 的防盗链与CDN缓存问题》(附Python源码)
开发语言·python·缓存
fengkai454513 小时前
十二、Redis -2
运维·数据库·redis·容器
hweiyu0013 小时前
Redis命令:BLPOP
redis·缓存
敲个大西瓜14 小时前
langchain笔记(一)
redis·笔记·langchain
小小龙学IT18 小时前
C++ Redis 客户端深度解析(hiredis 与 redis-plus-plus)——工业数采实时缓存 C++ 侧实战
c++·redis·缓存