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

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

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

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

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

九、结语

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

相关推荐
青衫码上行2 小时前
NoSql数据库简介 + Redis概述
数据库·redis·nosql
可涵不会debug3 小时前
Redis魔法学院——第四课:哈希(Hash)深度解析:Field-Value 层级结构、原子性操作与内部编码优化
数据库·redis·算法·缓存·哈希算法
fengxin_rou4 小时前
【黑马点评实战篇|第一篇:基于Redis实现登录】
java·开发语言·数据库·redis·缓存
我待_JAVA_如初恋4 小时前
Redis常用的数据类型之String
数据库·redis·缓存
ALex_zry4 小时前
分布式缓存与微服务架构的集成
分布式·缓存·架构
ALex_zry5 小时前
分布式缓存安全最佳实践
分布式·安全·缓存
学到头秃的suhian5 小时前
Redis跳表
redis
Anastasiozzzz5 小时前
数据库与缓存的一致性之间的终极博弈!
缓存
Demon_Hao6 小时前
JAVA缓存的使用RedisCache、LocalCache、复合缓存
java·开发语言·缓存