Redis实现优惠券秒杀系统:从分布式锁到Lua脚本的高并发实战

发布时间:2026/9/18 14:56:22
Redis实现优惠券秒杀系统:从分布式锁到Lua脚本的高并发实战 秒杀系统可能是高并发场景里最经典的入门实战了。之前两篇把 Redis 的基本数据结构和常用命令过了一遍这篇直接用优惠券秒杀这个场景把这些知识点串起来。先说明一下这篇笔记的代码基于 Spring Boot Redis 单机环境没有引入 Redisson、Sentinel 这些重量级组件目的是先把核心链路和原理讲透方便后面再做分布式扩展时心里有底。1. 先捋清楚优惠券秒杀到底在解决什么问题1.1 一次秒杀请求要经历什么很多人一提到秒杀就想到“高并发”但并发只是表象。真正要解决的问题是如何在海量请求涌入时保证“不超卖”“不重复下单”“系统不被打挂”。一个优惠券秒杀请求从前端发起到最终完成通常要经历这么几个环节用户点击抢券按钮请求到达后端接口后端校验用户是否登录、是否有资格校验优惠券是否存在、是否在活动时间内检查库存是否充足扣减库存生成订单或优惠券记录返回结果这些步骤如果全部直接操作数据库在 QPS 较高的时候会直接把数据库连接池打满。你会发现数据库 CPU 飙升、锁等待严重最后整个服务不可用。这个场景里最核心的矛盾是数据库的写能力有限但秒杀瞬间的写请求量远超数据库的承受范围。所以思路就变成了——把“校验 扣减库存”这个高频且对一致性要求极高的操作放到 Redis 里去完成数据库只负责异步落单。1.2 常规方案哪里会崩假设我们没有引入 Redis直接拿数据库来实现最典型的流程是SELECT stock FROM coupon WHERE id 1001; -- 判断 stock 0 UPDATE coupon SET stock stock - 1 WHERE id 1001 AND stock 0;这段逻辑看起来没什么问题但在高并发下有两个致命问题。第一超卖问题。如果两个请求同时读到库存为 1都判定可以扣减然后都去执行 UPDATE最终库存会变成负数。虽然我们可以用UPDATE ... WHERE stock 0这种带条件的更新来兜底但这就意味着每一次更新都要走数据库的行锁QPS 一上来数据库就先扛不住了。第二响应延迟。数据库的每一次写操作都涉及磁盘 IO、事务日志等单次开销比 Redis 内存操作高几个数量级。在秒杀场景下用户等不到 200ms 以上的响应体验会非常差。所以 Spring Boot Redis 的这套组合天然适合这个场景Redis 单线程模型保证了所有操作按顺序执行INCR/DECR 这类命令本身就是原子的配合 Lua 脚本可以把“检查库存 扣减库存 记录用户”这几个动作合并成一次原子操作既避免了超卖又大幅降低了响应延迟。2. 技术选型Redis 的哪些特性正好打在秒杀痛点上2.1 为什么是 Redis 而不是其他缓存组件选型之前先想清楚一件事这个场景对缓存的要求是什么答案是原子性、低延迟、数据结构的灵活性。对比一下常见的几种组件组件原子操作能力延迟数据结构适用场景Redis强单线程 Lua微秒级丰富String/Hash/List/ZSet秒杀、限流、排行榜Memcached弱主要就是 set/get微秒级简单纯 KV 缓存本地缓存Caffeine不支持跨节点原子操作极快简单单机热点数据Redis 最大的优势是“单线程 事件驱动”模型所有命令在服务端是串行执行的不存在多线程竞争的问题。这意味着你执行DECR或者 Lua 脚本时不需要考虑并发冲突这在秒杀场景里价值极高。另外还需要区分一个容易踩坑的点Redis 的原子性和事务性是两回事。Redis 的原子性是指单个命令或单个 Lua 脚本执行期间不会被其他命令插入但MULTI/EXEC事务只是“打包执行”不保证执行过程中的失败回滚。所以在设计秒杀方案时优先用 Lua 脚本而不是 Redis 事务。2.2 用到的 Redis 命令和数据结构优惠券秒杀里最常用的几个 Redis 命令和数据结构的组合是String DECR库存扣减。用一个 String key 存储剩余库存DECR 命令进行原子扣减Set或 Hash记录已购买用户。SCARD 判断用户是否已存在SADD 添加用户String SETNX分布式锁防止多个节点同时操作同一个资源ZSet可选用于记录秒杀订单创建的时间排序方便后续对账List可选作为简单的消息队列把订单创建请求入队后台异步消费这里面最核心的是DECR Set的组合。DECR 能保证扣减库存不会超卖Set 能保证同一个用户不会重复下单。再配合 Lua 脚本的原子性两个动作一次性完成。2.3 整体架构演进思路从工程实现的角度优惠券秒杀功能不是一步到位的通常会经过三个阶段演进第一阶段数据库直接扣减库存。适合并发非常低的内部系统代码最简单第二阶段Redis 预扣库存 异步写订单。适合单机或小规模集群也是这一篇笔记要讲的方案第三阶段引入消息队列、分片、限流降级组件。适合大型分布式系统我在实现时直接采用第二阶段的设计但没有引入 MQ而是用 Redis List 配合 Java 的定时任务做异步下单。这么做的原因很简单一个组件解决不了所有问题依赖越多调试越复杂。3. 核心实现从分布式锁到 Lua 脚本的演进3.1 第一版方案基于 SETNX 的分布式锁我的第一版实现思路很直观抢券接口先通过SETNX获取一把锁拿到锁之后再去操作数据库和 Redis。// 伪代码说明思路 Boolean locked redisTemplate.opsForValue() .setIfAbsent(lock:coupon:1001, 1, 10, TimeUnit.SECONDS); if (!locked) { return 手慢了请重试; } try { // 校验库存、生成订单 doSeckill(couponId, userId); } finally { redisTemplate.delete(lock:coupon:1001); }这套逻辑在当前单机 Redis 下没问题但它有两个隐患。第一锁的粒度太粗。所有用户抢同一张券都串行排队虽然避免了对数据库的直接冲击但吞吐量上不去并发 1000 可能就有一半请求拿不到锁用户容易骂娘。第二锁超时和业务时间不匹配。业务执行超过锁的超时时间时会出现锁被提前释放两个请求同时进入临界区的问题。虽然可以通过“续期”解决但代码复杂度会成倍增加。3.2 第二版方案Lua 脚本保证原子性后来我换了一个更优雅的思路没必要显式加锁用 Lua 脚本把“判断用户是否已购 判断库存 扣减库存 记录用户”这几个步骤全部塞进去。Redis 执行 Lua 脚本时是原子的天然避免了并发冲突而且不需要显式释放锁。Lua 脚本示例如下-- KEYS[1]: 库存 key -- KEYS[2]: 用户去重 key -- ARGV[1]: 用户 ID -- 1. 判断用户是否已抢过 local hasBought redis.call(SISMEMBER, KEYS[2], ARGV[1]) if hasBought 1 then return -1 end -- 2. 判断库存是否充足不足返回 0 local stock tonumber(redis.call(GET, KEYS[1])) if not stock or stock 0 then return 0 end -- 3. 扣库存 记录用户 redis.call(DECR, KEYS[1]) redis.call(SADD, KEYS[2], ARGV[1]) return 1用 Java 调用的方式private static final DefaultRedisScriptLong SECKILL_SCRIPT new DefaultRedisScript(); static { SECKILL_SCRIPT.setLocation(new ClassPathResource(seckill.lua)); SECKILL_SCRIPT.setResultType(Long.class); } public Long seckill(Long couponId, Long userId) { ListString keys Arrays.asList( coupon:stock: couponId, coupon:users: couponId ); Long result redisTemplate.execute(SECKILL_SCRIPT, keys, userId.toString()); return result; }这里的返回值约定要提前和前端讲清楚-1 代表重复抢购0 代表库存不足1 代表成功。如果用 String还要考虑转义问题直接用 Integer/Long 更稳妥。3.3 库存初始化和超时时间设计所有 Redis 操作的前提是“库存已经在 Redis 里了”。这一步通常由运营在后台创建秒杀活动时触发或者服务启动时执行。PostConstruct public void initStock() { // 前提couponId 1001 的数据库库存已经设置为 100 for (Long couponId : activeCouponIds) { Object stock redisTemplate.opsForValue().get(coupon:stock: couponId); if (stock null) { // 从数据库读取库存写入 Redis Integer dbStock couponMapper.getStock(couponId); redisTemplate.opsForValue() .set(coupon:stock: couponId, dbStock, 24, TimeUnit.HOURS); } } }关于超时时间我的建议是不超过活动总时长 5 分钟。设置太长会导致活动结束后 Redis 里的脏数据一直存在设置太短又会导致活动未结束库存就没了。真实项目里一般不会让用户直接看到“售罄”状态而是通过定时任务把 Redis 的库存定期同步回数据库保证最终一致性。还有一个小细节不要把库存直接设置为数据库的真实值。秒杀通常会有“超卖保护”比如数据库实际库存 100Redis 初始化时可以设置为 95预留 5 个作为线下兑换或其他渠道的应急库存。这个不是必须的但我在实际项目中用过效果不错。3.4 订单创建异步化第一步当 Lua 脚本返回成功时说明库存已经扣减、用户已经记录这时候可以直接给用户返回“抢券成功”但数据库里还没有订单记录。接下来需要把“创建订单”这个操作异步化。我采用的是 Redis List 做简单的异步队列配合 Spring 的定时任务去消费// 抢券成功后将订单创建请求放入队列 redisTemplate.opsForList().leftPush(order:queue:coupon: couponId, userId : couponId);消费端Scheduled(fixedDelay 500) public void processOrderQueue() { String item redisTemplate.opsForList().rightPop(order:queue:coupon: couponId); if (item null) { return; } // 解析 userId 和 couponId // 插库生成优惠券订单 }这个方案的优点是完全不依赖额外的 MQ 组件适合中小项目。缺点也很明显如果 Redis 宕机队列数据会丢失消息没有重试机制。如果你的系统能接受秒杀场景下少量数据丢失比如运营可以后台补发这个方案完全够用。注意rightPop用rightPop是为了配合leftPush实现 FIFO 效果。如果你在消费端用leftPop那顺序就变成 LIFO后进队列的反而先处理虽然逻辑上也不影响正确性但会让最早抢券的用户等待更久这个细节要注意一下。4. 实操细节与踩坑记录4.1 超卖问题的真正根因很多人以为超卖是 MySQL 的 UPDATE 语句没写好其实在高并发场景下真正的根因是**“检查库存”和“扣减库存”两个操作之间存在时间窗口**。在这个窗口内多个请求都能读到库存大于 0然后都去下单库存自然变成负数。Redis 单线程 Lua 脚本之所以能解决超卖本质是把“读取库存”“判断库存”“扣减库存”合并成一个原子操作消除了中间的时间窗口。我用 JMeter 压测过这个方案的极限。线上配置是 4 核 8G 的云服务器Redis 6.0Spring Boot 2.7300 并发线程循环压测 5 万次请求最终库存扣减数量和历史订单数量完全一致没有超卖。整体 QPS 大约在 8000 左右基本够单机版秒杀场景用。4.2 缓存穿透、击穿、雪崩优惠券场景怎么防御秒杀场景经常会提到“缓存三大问题”但很多人不知道它们分别是什么意思实际影响也不同。我逐个说一下优惠券场景里的表现。缓存穿透查询一个不存在的优惠券 ID。由于 Redis 里没有请求会穿透到数据库。攻击者可以用一堆不存在的 ID 循环请求把数据库打挂。解决方案是在 Redis 里存“空值 短暂过期”或者用布隆过滤器快速判断缓存击穿某个热点优惠券的缓存刚好过期瞬间大量请求涌入全部穿透到数据库。解决方案是热点数据不过期或者用分布式锁控制只有一个请求去重建缓存缓存雪崩大量 key 在同一时间过期导致数据库瞬间压力过大。解决方案是过期时间加随机值避免同一时间齐刷刷过期在秒杀场景里比较常见的是“缓存穿透”和“缓存击穿”。因为秒杀商品是少而集中的热点数据库存 key 一旦不存在几乎所有请求都会打到数据库。所以我在初始化库存时会把空库存也写进 Redis只是返回 0这样数据库就不会被穿透。4.3 限流与防刷不只是因为流量大如果只是简单的“高并发”其实 Redis 完全可以扛住。真正麻烦的是“恶意刷单”和“脚本抢券”。一个黄牛脚本可以在 1 秒内发起几百个请求把正常用户的抢券机会全部挤掉。防刷的核心思路有两个维度限流和防重复。限流可以用 Redis 的 INCR EXPIRE 实现简单的滑动窗口限流也可以用 Redis 4.0 的限流模块。我给每个用户每个接口设置了 1 秒 5 次的阈值String limitKey rate:limit: userId : action; Long count redisTemplate.opsForValue().increment(limitKey); if (count 1) { redisTemplate.expire(limitKey, 1, TimeUnit.SECONDS); } if (count 5) { return 操作过于频繁; }防重复在 Lua 脚本中已经通过SISMEMBER判断了用户是否已抢过券这是第一道防线。另外还可以增加前端埋点或验证码但这超出了 Redis 的范畴。有个容易被忽略的点限流的粒度。如果所有用户共用一个 key 限流比如每秒最多 100 次请求那正常用户会被误伤黄牛却可以通过分布式节点绕过。所以通常要按用户维度去限同时按 IP 维度做一层粗粒度兜底。4.4 压测时发现的连接池问题压测过程中踩过一个很典型的坑Spring Boot 默认的 Lettuce 连接池在并发高的时候会出现连接等待超时。后来排查才发现是连接池配置不合理。spring: redis: lettuce: pool: max-active: 50 max-idle: 50 min-idle: 10 max-wait: 3000ms注意一点这里的连接池是“客户端连接池”不是“线程池”。如果你不配置连接池Lettuce 会使用默认的max-active通常是 8这个并发在秒杀场景下非常不够用。但也不能调得太高因为每条连接都会占用 Redis 服务端的资源。50 是一个相对保守的值300 并发压测下基本够用。还有一个容易踩的坑是Lettuce 的共享连接问题。多线程环境下如果同一个连接被并发使用会有线程安全问题。Spring Data Redis 默认对 Lettuce 做了线程安全封装但你如果自己拿connection.getNativeConnection()操作底层连接就要小心了。这个在生产环境里坑过我一次当时是自定义序列化器里直接拿了原生连接导致偶发的 Redis 连接断开排查了很久。5. 常见问题与排查技巧实录5.1 问题速查表这一节是实战排坑的浓缩总结覆盖我在这套方案落地过程中遇到的典型问题。直接做成表格方便查阅现象可能原因排查方法解决方案库存变负数没有使用原子操作查看业务日志是否存在并发修改改用 Lua 脚本或 DECR用户重复下单没有去重数据库加唯一索引Redis SADD 判断Lua 脚本中增加 SISMEMBER 判断接口超时连接池过小查看 Redis 连接池活跃数调大max-active增加超时时间秒杀刚开始就卖完库存初始化失败或过期检查 Redis key 是否存在活动开始前预热库存设置足够过期时间活动结束后库存仍为 0库存 key 未过期检查 key 的 TTL设置合理过期时间或定时清理数据库订单数量与 Redis 扣减不一致异步消费丢失对比 Redis List 长度和数据库订单数引入消息重试或延迟队列某个用户频繁请求刷接口没有限流查看请求日志中相同 userId 的频率基于 INCR 实现滑动窗口限流多个优惠券共用同一个 keykey 设计冲突查看 key 前缀是否区分了业务维度统一 key 命名规范比如coupon:stock:{id}Redis 内存占用膨胀用户 Set 过大 缓存无淘汰策略检查 Redis 内存使用率设置 maxmemory优化淘汰策略Lua 脚本报错参数类型不对或命令语法不兼容查看 Redis 日志在本地 Redis 用 EVAL 手动测试脚本5.2 排查方法和工具推荐遇到问题先不要盲目改代码我的排查顺序是这样的第一步看Redis 慢日志。执行SLOWLOG GET 100看有没有执行时间超过 1 秒的命令。在秒杀场景下大 key 的 SADD 操作或者大集合的 SISMEMBER 都有可能成为慢命令。第二步看Redis 监控面板。如果是云厂商的 Redis一般自带有监控看板关注keyspace hits/misses、connected clients、instantaneous ops/sec这几个指标。如果ops/sec突然飙升说明流量或者脚本循环有问题。第三步看应用日志。重点搜索RedisConnectionFailureException、JedisDataException、SocketTimeoutException定位是网络问题还是 Redis 端的问题。很多时候是连接池耗尽导致的超时不一定是 Redis 本身挂了。第四步动手验证。用redis-cli --bigkeys检查有没有大 key用redis-cli MONITOR观察实时命令。注意 MONITOR 命令在线上有性能损耗不要长时间开着。5.3 几个值得改进的扩展点这套方案解决了 80% 的场景剩下来的空间在于主从复制问题如果 Redis 配置了主从Lua 脚本执行完成后要同步到从节点。默认WAIT命令可以控制同步等待但会增大延迟。秒杀场景下我通常只重视最终一致性不强制等待同步持久化问题秒杀场景对数据丢失的容忍度较低建议开启 AOF 持久化设置为appendfsync everysec。这样最多丢一秒的库存变动但能保证 Redis 重启后库存不会完全丢失分布式扩展如果 QPS 真的到了单机 Redis 撑不住的量级需要考虑分片。但分片之后Lua 脚本的跨 key 操作就变得复杂了一般做法是把同一优惠券的库存和用户集合放在同一个分片上通过Hash Tag实现写在最后这套优惠券秒杀方案是我在一个电商社区的会员积分兑换模块里实际落地过的核心思想就是“把复杂的并发控制交给 Redis 的原子操作把耗时的 IO 操作全部异步化”。如果你第一次接触秒杀系统我更建议你先把单机 Redis Lua 这套方案吃透不要一开始就上分布式中间件否则你会被消息乱序、分片不均一堆问题淹没反而学不到本质。最后分享一个我自己调试时的习惯写 Lua 脚本时先用命令行把流程完整跑一遍确认所有边界情况库存为 0、重复用户、优惠券不存在都返回了正确的业务码再集成到 Java 代码里。这样能省掉很多在日志里比对字符串的时间。祝你的秒杀系统第一次上线就顺利。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询