
做秒杀系统这几年我一直有个执念能不能在不引入重型中间件的前提下把高并发场景下的库存扣减、异步下单和失败补偿全部串起来。直到我把 Redis 的 Lua 脚本和 Stream 消息队列组合在一起完整跑通了这套优惠券秒杀链路才意识到以前那些方案不是不够快而是设计思路本身就绕了远路。这个项目是我在某个点评类教学业务的秒杀券抢购场景里落地的最终版本。核心就三件事Redis 做前置校验和库存扣减Lua 脚本保证原子性Stream 负责把“抢到资格”和“真正下单”解耦。整套系统没有引入其他消息队列中间件纯靠 Redis 自身能力就扛住了瞬时高并发。这篇文章适合两类人看一是准备面试时被问到“秒杀怎么做”的开发同学二是真正要落地高并发库存系统的朋友我会把从架构选型到逐行代码的完整路径都讲透。1. 先把整体设计思路拆开看1.1 秒杀系统的三个硬指标秒杀业务和普通下单最大区别在于瞬时流量极高、库存极少、用户极度敏感。排队系统设计得再漂亮只要用户点下去没反应体验就是零。因此我在设计时只盯三个硬指标。不超卖是底线。库存 100 张券卖出去 101 单就是事故。不重复是体验。同一个用户同一轮秒杀重复下单会产生脏数据和资损。延迟低是命门。从用户点击到看到“抢购成功”响应必须在几百毫秒内完成否则用户早就刷新重来了。这三个指标如果全压在数据库上基本是死路一条。数据库的行锁和磁盘 IO 在 1 万 QPS 的压力下会迅速成为瓶颈哪怕加了连接池也无济于事。所以秒杀系统的核心设计思路就一句话把流量挡在数据库前面。1.2 从数据库扛压到 Redis 前置的演进我最早接触秒杀时第一版方案特别朴素用户请求进来直接查库存、扣库存、插订单。在低并发下完全没问题读写都走 MySQL 事务还能保证一致性。但压测到 500 QPS 时数据库连接就开始打满再往后就是大量的锁等待和死锁报错。后来我改成 Redis 做库存扣减但遇到了经典的超卖问题。单纯用 DECR 扣库存确实快可扣完之后没法保证下单人真的有资格用户请求在并发下会出现“并发扣减成功但实际下单失败”的情况。而且如果扣减后直接异步落库退款和超时未支付的库存回收逻辑会变得非常复杂。再往后我尝试用 Redis 的 WATCH MULTI 事务做乐观锁思路是检查库存和用户是否已买再扣减。这个方案能解决超卖但 WATCH 在冲突频繁时会不断重试高并发下大量请求直接失败用户体验极差。真正让我定下最终方案的是 Redis 的 Lua 脚本和 Stream 两个能力。Lua 脚本把校验、扣减、记录用户三个操作打包成一个原子操作不管多少并发进来Redis 单线程执行脚本的特性保证了不会超卖。Stream 则承担了异步下单的职责抢购成功只意味着拿到了“下单资格”真正的订单创建丢给消费者异步处理削峰填谷。1.3 Lua 与 Stream 的分工很多人在设计秒杀系统时会陷入一个误区把 Redis 当数据库用试图把库存、订单、用户状态全塞进去。我的经验是 Redis 只做两件高频、短小的事判断资格和扣减库存。这两件事用 Lua 脚本做一个极短的原子操作其余的事情比如创建订单、扣减用户余额、发送通知全部由 Stream 消费端来接管。简单说Lua 是“守门员”Stream 是“传送带”。守门员只做一件事核对资格同时扣掉库存动作必须原子化。传送带不关心单个请求多快它只管把抢购成功的资格信息稳定地送到后端去落库。这样设计后Redis 单实例能轻松扛上万 QPS而数据库的压力降到了每秒几十个订单的级别。2. 环境准备与数据设计2.1 版本与依赖清单用 Stream 有一个硬性前提Redis 5.0 以上版本。XADD、XREADGROUP、XACK 这些命令是 5.0 才引入的。生产环境我见过有人直接用 4.0 的机器代码里写了 Stream 相关操作启动就报错排查半天才发现是版本问题。所以第一步先确认版本redis-server --version # 必须 5.0推荐 6.x 或 7.x服务端语言我用的是 Java Spring BootRedis 客户端选了 Lettuce。Lettuce 对 Redis 新特性的支持比 Jedis 好Stream 相关的 API 也更完整。需要引入的依赖大致如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency这里有个细节Spring Boot 2.x 之后默认 Redis 客户端就是 Lettuce不需要额外引入。如果你还在用 1.x 版本需要手动排除 Jedis 并引入 Lettuce步骤会繁琐一些。2.2 Key 结构规划Redis 里的 Key 设计决定了后续扩展是否顺手。我在这套秒杀系统中使用了三类 Key库存 Keyseckill:stock:{voucherId}值是剩余库存数量String 类型。每次用户抢购时由 Lua 脚本原子扣减。用户已购 Keyseckill:order:{voucherId}:{userId}记录了当前用户是否已经抢购过String 类型值为 1。这个 Key 同时是 Lua 脚本判断重复购买的依据。用户消息 Keyseckill:stream:{voucherId}是一个 StreamXADD 往里推消息消费者组从里面读。库存和用户已购两个 Key 都必须设置过期时间。很多人会忽略这一点导致秒杀结束后 Key 一直占着内存。我的做法是给这两个 Key 设置当天的过期时间秒杀活动结束自动回收。2.3 模拟业务表结构虽然 Redis 扛了大部分流量但最终订单还是要落数据库。我用了一张秒杀券表存库存和活动信息一张订单表存用户抢购记录。表结构很简单核心字段如下表名核心字段说明抢购券表id, stock, begin_time, end_timestock 是总库存秒杀开始前预扣订单表id, voucher_id, user_id, status, create_timestatus 区分待支付、已支付、已取消这里有一个必须提前想清楚的问题数据库表里的 stock 字段怎么办。我的做法是在秒杀开始前把数据库中的库存预扣为 Redis 中的初始值秒杀结束后用 Stream 消费者异步回写数据库的真实售出数量。也就是说秒杀过程中的库存状态完全以 Redis 为准数据库只负责最终落账。3. Lua 脚本秒杀核心的原子扣减3.1 脚本逐行拆解Lua 脚本是整个系统的心脏。它的作用是在同一时刻只有一个请求能执行完整个“校验资格 - 扣减库存 - 标记用户”的流程。我把完整脚本贴在下面-- KEYS[1]: 库存 key seckill:stock:{voucherId} -- KEYS[2]: 用户已购 key seckill:order:{voucherId}:{userId} -- ARGV[1]: 用户 id -- ARGV[2]: 下单数量一般固定为 1 if redis.call(exists, KEYS[2]) 1 then -- 用户已经抢购过直接返回 1 表示重复购买 return 1 end if tonumber(redis.call(get, KEYS[1])) 0 then -- 库存不足返回 2 表示已售罄 return 2 end -- 扣减库存 redis.call(decrby, KEYS[1], ARGV[2]) -- 记录用户已购买 redis.call(set, KEYS[2], 1) -- 设置用户 key 的过期时间24小时后自动清除 redis.call(expire, KEYS[2], 86400) -- 返回 0 表示成功 return 0这段脚本的逻辑并不复杂但几个细节值得反复强调。第一为什么用exists判断用户是否已购而不是setnx。setnx也可以实现“用户只能买一次”但如果用户已购 Key 不存在时需要先设置再扣库存而exists判断在脚本里更直接、更易于理解。实际业务里如果存在“同一用户可以买多张券”的需求只需要把exists判断改成incr后比对上限即可脚本结构几乎不用动。第二库存判断用的是tonumber(redis.call(get, KEYS[1])) 0而不是redis.call(decrby)之后再去判断返回值是否为负数。两者在高并发下的最终效果一样因为 Lua 脚本是原子的。但前者在库存为 0 时不会执行 DECRBY能减少无意义的写入操作。对于 Redis 来说任何写入操作都会触发内存分配和可能的持久化开销能省就省。第三脚本返回值的约定必须统一。我用 0 表示成功、1 表示重复抢购、2 表示售罄。调用方拿到返回值后要根据不同码做出不同的前端提示。3.2 Java 侧调用 Lua 脚本脚本写好后关键是怎么高效地把它提交给 Redis。我在 Java 侧封装了一个方法private static final DefaultRedisScriptLong SECKILL_SCRIPT new DefaultRedisScript(); static { SECKILL_SCRIPT.setLocation(new ClassPathResource(seckill.lua)); SECKILL_SCRIPT.setResultType(Long.class); }然后通过 StringRedisTemplate 执行Long result stringRedisTemplate.execute( SECKILL_SCRIPT, Arrays.asList(seckill:stock: voucherId, seckill:order: voucherId : userId), userId.toString(), String.valueOf(quantity) );这里有一个踩坑经验DefaultRedisScript的加载路径必须确保能被类加载器找到我在一个项目里把它放在 resources 根目录下结果因为 classpath 配置问题加载失败启动时没报错一执行才 NullPointerException。建议把.lua文件放在明确的目录比如lua/seckill.lua用setLocation(new ClassPathResource(lua/seckill.lua))引用同时在启动时提前初始化一次脚本排查问题会方便很多。3.3 为什么不能只用 DECR 或 WATCH很多人会问Lua 脚本里无非就是 get 判断再 decrby我直接用 DECR 命令判断返回值不行吗单纯用 DECR 的问题是库存扣减和用户标记是两个操作不经过 Lua 脚本就无法保证原子性。假设库存还剩 1 张两个用户同时发起抢购都在 DECR 之前通过了库存判断然后各自 DECR 成功最终卖出了 2 张。这就是经典的“检查-执行”竞态。用 WATCH MULTI 的乐观锁也能解决超卖但它在并发高时会产生大量重试和请求失败。Lua 脚本则完全不同——Redis 在执行 Lua 脚本时不会穿插执行其他命令整个脚本一气呵成。在 Redis 单线程模型下这意味着并发请求会排队执行脚本数量再多也是串行处理天然没有竞态问题。我还试过把用户校验和库存扣减拆成两条命令先exists再decr。结果是超卖问题依然存在因为两条命令之间会被其他请求插进来。所以核心结论只有一条校验和扣减必须打包在一个 Lua 脚本里隔离级别完全由 Redis 保证。4. Stream 异步消费把下单搬出主链路4.1 为什么是 Stream 而不是 List 或 Pub/Sub在 Redis 5.0 之前做异步队列通常只有两个选择List 配合 BLPOP或者 Pub/Sub。List 的 LPUSH BRPOP 确实能实现消息队列但消息没有确认机制消费者处理失败后消息就丢了。Pub/Sub 更不靠谱它根本不持久化消息消费者不在线期间发布的消息全部丢弃这在秒杀场景里是不可接受的。Stream 的优势在于三点第一消息持久化。Stream 里的消息会保存在 Redis 内存中消费者处理完必须显式 ACK否则消息会一直保留在未确认队列。第二消费者组。多个消费者可以组成一个组Stream 会尽量均匀地把消息分发给组内成员这天然解决了并发消费问题。而 List 模式下多个消费者 BLPOP 虽然也能分发但没有组的概念扩展和维护比较麻烦。第三断点续读。每个消费者都有自己的游标宕机恢复后可以从未确认的消息继续处理不会漏消费。4.2 生产者抢购成功后把消息推入 StreamLua 脚本返回 0 表示抢购成功紧接着要做的事就是把用户和券信息推入 Stream。生产者代码很简单if (result 0) { MapString, String msg new HashMap(); msg.put(userId, String.valueOf(userId)); msg.put(voucherId, String.valueOf(voucherId)); msg.put(orderId, generateOrderId()); stringRedisTemplate.opsForStream() .add(ObjectRecord.create(seckill:stream: voucherId, msg)); }有人会纠结消息推入 Stream 之前如果 Redis 宕机了怎么办。确实这里会存在消息丢失的可能。但我的取舍是秒杀的核心目标是先保证库存不超卖订单落库允许短暂延迟和极小概率的补偿补救。如果对可靠性要求极其严格可以把 Stream 的 append-only 文件和 Redis 持久化配置AOF打开保证消息至少落盘。其实这里还有一个更稳妥的小技巧把 Stream 的 XADD 也嵌入到 Lua 脚本中让“扣库存”和“写入消息”同步完成。但这样做会增加 Lua 脚本的复杂度而且 Stream 和库存的 Key 生命周期不一致后续清理会很麻烦。所以在实际项目中我选择拆开先扣库存再发消息两者之间的窗口期很短可用性损失可以接受。4.3 消费者组XREADGROUP 与 ACK消费者端我启动了一个后台线程池通过 XREADGROUP 阻塞读取消息ListMapRecordString, Object, Object records stringRedisTemplate .opsForStream() .read(Consumer.from(seckill-consumer-group, worker-1), StreamReadOptions.empty().count(10).block(Duration.ofSeconds(3)), StreamOffset.create(seckill:stream: voucherId, ReadOffset.lastConsumed()));读取到消息后执行真正的下单逻辑插入订单表、扣减用户账户、发送通知。成功后必须执行 ACKstringRedisTemplate.opsForStream().acknowledge( seckill:stream: voucherId, seckill-consumer-group, record.getId() );ACK 是 Stream 消费者组的命门。如果不 ACKRedis 会认为这条消息还没处理完下次 XREADGROUP 时可能再次投递给消费者。如果业务处理逻辑不是幂等的就会产生重复订单。所以在实际部署时我会严格执行一个流程从 Stream 读取消息根据消息中的业务唯一键订单ID或用户ID先查询数据库判断是否已处理已处理则直接 ACK未处理则执行下单下单成功后再 ACK。4.4 消费者宕机后的消息补偿秒杀场景里最怕的就是消息进了 Stream但消费者还没来得及处理就宕机了。这种情况我用xpending命令来兜底PendingMessages summary stringRedisTemplate .opsForStream() .pending(seckill:stream: voucherId, seckill-consumer-group);定时任务会定期扫描pending列表把超过 30 秒仍未 ACK 的消息捞出来重新处理。这样即使单个消费者节点挂掉其他节点也能接管这些消息保证不遗漏。这个补偿机制我强烈建议在压测结束后就加上千万别等活动上线了再补。5. 全链路验证与性能对比5.1 压测方案我用压测工具模拟了 5000 个并发用户同时抢购 100 张券。压测分了三组做对比第一组是纯数据库扣库存第二组是 Redis WATCH 事务第三组是本文的 Redis Lua Stream 方案。压测脚本模拟真实业务每个用户抢购 1 张券随机 1% 的用户重复请求两次用来验证防重复逻辑。压测结果如下方案并发数成功下单数超卖数量平均响应时间数据库 QPS纯数据库5000约 1200无明显超卖但大量请求失败2200 ms打满Redis WATCH5000约 3000无800 ms中等Redis Lua Stream5000100精确无45 ms极低最直观的差距是平均响应时间。纯数据库方案在 5000 并发下已经接近不可用Redis Lua 方案只用了 45 毫秒返回结果剩下的创建订单异步完成用户感知不到任何卡顿。成功下单数精确等于 100说明不超卖、不重复两个目标都完美达成。5.2 为什么响应能这么快答案在于 Redis 本身的内存操作和单线程模型。Lua 脚本只有三次 Redis 命令调用分别是 exists、get、decrby、set加起来微秒级的耗时。Stream 消费端的下单操作虽然慢涉及数据库但已经移出了主链路。用户按下抢购键后他看到的只是 Redis 返回的结果而不是等数据库写入完成。有一点需要注意Lua 脚本本身必须写得足够短。Redis 是单线程的如果有一个业务把复杂的循环写到 Lua 里整个 Redis 实例都会被阻塞其他 key 的操作全部排队。秒杀脚本里我严格限制只有库存和用户状态两类操作绝不在 Lua 里做耗时的遍历和复杂的字符串拼接。5.3 对线上的影响范围这套方案的正确影响范围是Redis 扛住 QPS数据库只接收到极低的写入量。假设一场秒杀有 10 万用户同时点击最终只有 100 人抢到那么打到数据库的只有 100 条订单插入请求加上定时补偿任务偶尔扫一下数据库负载轻到可以忽略。原来需要扩容数据库、增加读写分离、人肉限流的那套重型方案在这里全都不需要了。同时要注意Redis 的内存占用也会随消息堆积而增长。Stream 里的消息不会自动消失即使消费者已经 ACK消息还是留在内存里。所以秒杀结束后我会手动修剪 Stream 或直接删除整个 keyXTRIM seckill:stream:123 MAXLEN 0或者DEL。这个细节如果漏掉长时间运行后 Redis 内存会被积累的秒杀消息占满影响其他业务。6. 常见问题与排查技巧实录6.1 高频问题速查表问题现象根本原因解决方式压测时部分请求返回“库存不足”但库存还有库存 Key 被其他逻辑误删除或过期时间设置太短检查 Key 的 TTL秒杀期间建议 TTL 大于活动时长用户重复抢购判断已购 Key 存在但还能买到已购 Key 在消费端下单前被手动删除统一回收逻辑不要在活动期间清理用户已购 KeyStream 消费端处理后消息还是反复投递没有执行 XACK在下单逻辑成功提交后调用 acknowledge消费者组消息分配不均某个消费者消息堆积消费者数量远少于消息量或者消费者在阻塞读时崩溃增加消费者数量并启用 xpending 补偿Lua 脚本执行报错ERR unknown commandRedis 版本低于 5.0升级 Redis 或检查客户端路由是否连接了错误实例6.2 我第一次上线踩过的三个坑第一个坑是Lua 脚本里用了服务端时间。我在抢购逻辑里加了一个活动开始时间的判断用redis.call(time)去取服务器时间。结果压测时发现有 5% 的请求在活动已经开始后依然被判定为未开始。排查了很久才发现Redis 服务器和压测客户端所在机器的系统时间有几十秒的偏差导致时间判断不稳定。后来我把时间判断全部放在应用层用统一的业务服务器时间透传到 Lua 脚本里问题才彻底解决。这个教训告诉我Redis Lua 脚本越纯粹越好业务规则里的可变条件尽量通过参数传入。第二个坑是消费者组的创建时机。我以为只要有人调用 XREADGROUP消费组就会自动创建。实际上如果 Stream 里还没有任何消息XGROUP CREATE 没有执行消费者组就不会存在消费端会直接报错。我的解决办法是在应用启动时主动创建消费组stringRedisTemplate.opsForStream().createGroup(seckill:stream: voucherId, seckill-consumer-group);第三个坑是数据库唯一约束。我在订单表上加了一个(voucher_id, user_id)的唯一索引作为兜底防线。本来以为 Redis 层的防重复已经足够了但后来发现有一条线上脏数据用户抢了一张券消费端还没创建订单用户又用另一个接口手动补了一次下单。唯一索引直接拦住了这条脏数据报错信息也帮助我们快速定位到了接口逻辑的问题。所以别嫌冗余数据库层的唯一约束是最后一道安全网一定要加。6.3 排查技巧从响应码倒推问题我在 Lua 脚本的返回值设计上做了几个固定编码排查问题非常方便。应用日志里如果发现大量返回 1说明用户在重复抢购大量返回 2说明库存提前耗尽大量返回 0说明抢购成功但可能因为 Stream 消费端堆积导致订单未及时创建。通过统计返回码比例能快速区分是流量问题还是逻辑问题。举个例子有次线上活动开始后日志里返回 2 的比例在最初 10 秒内就高达 90%但库存明明有 1000 张。直觉告诉我这不正常查了一下 Redis 监控发现库存 Key 在活动开始前被另一个定时任务从数据库同步过来时覆盖成了 0。原来是数据同步任务和秒杀初始化任务并发执行后开始的同步任务把先设置的库存给重置了。通过返回值比例发现异常后我定位到了任务调度的竞态问题修复方式是给初始化任务加分布式锁并让数据同步任务在秒杀期间跳过库存字段。写到最后想说的几句话这套 Redis Lua Stream 的组合我在项目里跑了小半年稳定性超出预期。它的最大价值不是某一项技术的新奇而是把“高并发秒杀”这个听起来很复杂的问题拆解成了两个简单问题用 Lua 快速判断资格并扣库存用 Stream 把后续流程异步化。两者各自都只用到了 Redis 的基础能力没有任何高深技巧但组合在一起就是一套完整、可靠、可扩展的秒杀方案。如果你要复用这套架构我的建议是先明确自己的业务边界。库存只有一档、每个用户限购一张那这篇文章的脚本直接可用。如果要做多档库存、阶梯价格、反作弊风控那就要在 Lua 脚本和 Stream 消息结构上做更多扩展。最后强调一个容易被忽视的点上线前一定要做压测不要在生产环境用真实活动当压测工具我在这方面吃过亏教训已经写在上面了。