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

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

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

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

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

九、结语

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

相关推荐
独行侠影a13 小时前
APScheduler+Redis 分布式定时任务:解决多实例任务重复执行
数据库·redis·分布式
gwf21615 小时前
SSD读写速度深度解析:顺序读写vs随机读写、IOPS、延迟,你的硬盘性能到底怎么看?
git·嵌入式硬件·缓存·github·智能硬件
番茄炒鸡蛋加糖17 小时前
缓存三大问题
缓存
不在逃避q18 小时前
Golang笔记之Redis
redis·笔记·golang
江晓鱼未暖1 天前
十七、Redis 核心原理与架构详解
大数据·数据库·数据仓库·redis·缓存·架构
红莲凪是1 天前
Redis有哪些部署方案?了解哨兵机制吗?
java·redis·eureka
小罗水1 天前
第13章 Redis 缓存、幂等锁与任务状态
数据库·redis·缓存
热心市民lcj1 天前
Spring Boot 整合 Caffeine 本地缓存实战
spring boot·后端·缓存
zx1154501 天前
【热点 Key 探测与加长缓存 TTL 实战】
缓存
Devin~Y1 天前
从本地生活电商到 AI RAG:互联网大厂 Java 面试场景完整实战
java·spring boot·redis·elasticsearch·spring cloud·kafka·rag