高并发秒杀系统实战:Redis预减库存与Lua脚本原子扣减方案
发布时间:2026/10/2 14:06:41来源:尧图网络
很多做后端的朋友第一次接触秒杀场景都是从黑马点评这个项目开始的。我当初拿到优惠券秒杀这个模块时第一反应是不就是下单前减个库存吗结果真把并发压上去之后才发现这个看似简单的功能背后藏着一整套关于原子性、锁粒度、削峰填谷的学问。这篇就把我当时从需求拆解到最终实现再到压测踩坑的完整过程整理出来算是一个可以直接照着落到代码里的复盘。1. 秒杀场景真正要解决的三个核心问题先说结论优惠券秒杀表面上是用户点击抢购、系统扣库存、生成订单三个动作但一旦加上高并发这个前提每一步都会变形。第一个问题是超卖。库存100张券瞬间来了1万人抢如果程序写的是先查询库存是否大于0再执行扣减那在查询和扣减之间其他线程完全可能已经把库存扣光了。数据库层面表现为库存字段变成负数这在任何电商系统里都是不可接受的事故。秒杀场景里解决超卖的思路不是查了再减而是让检查库存是否充足和扣减库存变成一个不可分割的整体操作中间不允许任何人插进来。第二个问题是一人一单。所谓秒杀限制的就是每个人只能抢到一张。但同一个用户连续点击抢购按钮前端可以防抖后端却不能只靠前端控制。因为请求到了服务端是多线程并发执行的两个线程同时查询订单表发现这个用户没买过于是双双放行最终这个用户就下了两单。想要在并发环境下只让一个请求通过就需要一把粒度足够小的锁来挡住重复请求。第三个问题是流量冲击。秒杀是典型的瞬时效流量开场那几秒钟的请求量可能是平日的几百倍。如果每个请求都直接打到MySQL上做库存扣减和订单写入数据库的并发连接数会瞬间被打满。比较务实的做法是把校验资格、预占库存这类能在内存里完成的操作放到Redis只让极少一部分真正抢到的请求落到数据库。黑马点评项目里的秒杀券对应的是tb_seckill_voucher这张表它是在普通优惠券tb_voucher的基础上扩展出来的多出了stock库存、begin_time、end_time这些秒杀专属字段。我当时拿到需求时第一件事就是理清楚这三张核心表之间的关系优惠券主表负责描述券的基本信息秒杀券表单独存库存和时间窗口订单表记录谁抢到了哪张券。订单表之所以独立出来是因为订单的写入频率远高于券的读取频率分开存储之后数据库的压力可以被隔离处理。这个阶段最容易犯的错误是直接在tb_voucher上改库存字段。短期看没问题但秒杀券和普通券是两种完全不同的业务形态普通券不需要限时抢购也不存在库存预减。混在一张表里将来写缓存预热和定时下架的逻辑会非常别扭所以从设计之初就把秒杀券拆出来后面所有复杂操作都会顺手很多。2. 数据库扣减与Redis预减的方案分水岭明确了三个核心问题之后选型就成了绕不开的话题。直接用数据库解决超卖有一个非常经典的写法UPDATE tb_seckill_voucher SET stock stock - 1 WHERE voucher_id #{voucherId} AND stock 0;这条SQL是原子操作利用了数据库行锁执行过程中不会有其他事务插入所以只要影响行数为1就说明这次扣减成功了。这个方案能跑通但问题在于MySQL的吞吐量是有上限的。行锁虽然不会锁全表但同一行上的并发更新会被串行化而且每一次扣减都要走一次网络IO、一次事务提交。实际压测下来单条记录的UPDATE在普通配置的数据库上也就支撑每秒几千次碰到十万级别的请求直接就是雪崩。我当时的方案是用Redis做前置预减MySQL只负责最终落库。思路是把秒杀券的库存预热到Redis里用户请求进来之后先走一段Lua脚本脚本里用DECRBY原子扣减Redis中的库存扣减成功之后再通过消息队列异步把订单写入MySQL。这样一来数据库的写入量就从每秒几万次请求降级成了每秒几百个真实订单压力完全不在一个量级上。这里有一个关键问题为什么Redis的扣减是可靠的因为Redis是单线程执行命令的DECRBY和SISMEMBER这些操作在同一个Lua脚本里执行时中间不会被其他客户端的命令插入这就是原子性的来源。很多新手会把原子性理解成这段代码是原子的实际上Redis的原子性指的是一段脚本在执行期间不会被其他命令打断它不是JVM层面的线程安全而是服务端命令队列的执行特性。Redis里我设计了三个主要keyKey类型说明seckill:stock:{voucherId}String秒杀券的预热库存seckill:user:{voucherId}Set已购买该券的用户ID集合seckill:order:{voucherId}Stream待处理的秒杀订单消息库存用String是为了方便DECRBY用户集合用Set是为了方便SISMEMBER判断用户是否已买订单用Stream是为了后续异步消费。缓存预热我是在秒杀活动开始前通过一个定时任务或者管理后台触发的从MySQL把库存读出来写入Redis。这里需要警惕的是预热动作不能覆盖已扣减的库存所以我每次预热前会先判断Redis中是否存在该key存在就跳过。缓存预热这一步如果你漏掉了后面所有的流程都会得到秒杀未开始的返回。这是我在第一次联调时踩过的坑检查了很久才发现是预热任务根本没触发而不是Lua脚本写错了。3. Lua脚本把资格校验、限购判断、库存扣减揉成一个原子操作整个秒杀链路中最核心的一段代码就是那个Lua脚本。我先贴出我当时线上使用的版本然后逐行讲为什么这么写if(redis.call(exists, KEYS[1]) 0) then return -1 end if(tonumber(redis.call(get, KEYS[1])) 0) then return -2 end if(redis.call(sismember, KEYS[2], ARGV[1]) 1) then return -3 end redis.call(decrby, KEYS[1], 1) redis.call(sadd, KEYS[2], ARGV[1]) return 0调用时传入的KEYS和ARGV是这样的DefaultRedisScriptLong seckillScript new DefaultRedisScript(); seckillScript.setLocation(new ClassPathResource(seckill.lua)); seckillScript.setResultType(Long.class); Long result stringRedisTemplate.execute( seckillScript, Arrays.asList(seckill:stock: voucherId, seckill:user: voucherId), userId.toString() );四个返回值的含义在业务层需要一一对应处理返回值含义业务处理-1活动不存在key没预热提示秒杀活动不存在-2库存不足提示手慢了已被抢完-3该用户已下过单提示一人限购一张0校验通过预占库存成功继续走异步下单流程把库存判断和库存扣减放在同一个脚本里是为了避免分布式锁。有些方案会在Java代码里先加锁然后查库存、减库存、释放锁但这样不仅代码复杂还引入了锁竞争和死锁的风险。Lua脚本本质上用Redis的单线程执行模型替代了锁不需要你自己管理锁的获取和释放命令队列天然保证了执行的完整性。值得一提的细节是那段检查用户是否已购买的SISMEMBER判断对应的正是前面说的一人一单问题。它在脚本里和库存扣减是同一个原子操作所以不存在检查时没买过、扣完库存之后才把用户加入集合的中间窗口。如果脱离了Lua脚本用Java代码先查Set再扣库存并发环境下一个用户的两条请求完全可能同时通过SISMEMBER的检查等两条DECRBY都执行完了这个用户才在Set里出现两次效果等于卖了两张券给同一个人。这个脚本还有一个隐藏好处是它天然防止了缓存穿透。如果秒杀活动结束之后key被删除了脚本第一步就会返回-1不会让无效请求继续往下走到MySQL。我见过有些团队在Java代码里做缓存过期判断缓存没了就直接让请求打到数据库去查活动是否存在压测时数据库瞬间被穿透流量打挂问题就出在这。4. 一人一单的并发控制锁的粒度、持锁时间与防误删Lua脚本解决的是预减库存的原子性但用户抢到资格之后异步创建订单的环节同样可能存在一人一单的漏洞。原因在于Redis中的用户集合是在预减阶段写入的异步消费阶段如果去查询数据库订单表做二次校验两个不同的消费线程可能会同时读到该用户没有订单。当时我的处理方式是在异步下单流程之前对每个用户加一把粒度很细的分布式锁锁的key设计为lock:order:{userId}。这保证了同一个用户的多笔秒杀订单请求在服务端只会有一个请求能进入创建订单的流程。private boolean tryLock(Long userId) { String key lock:order: userId; Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(key, 1, 10, TimeUnit.SECONDS); return Boolean.TRUE.equals(success); } private void unlock(Long userId) { String key lock:order: userId; stringRedisTemplate.delete(key); }锁的粒度一定要精确到用户级别而不是券级别。如果锁的key是lock:order:{voucherId}那这个用户抢券A没抢到所有用户的请求都在等同一把锁并发性能会大打折扣。按用户维度加锁之后不同用户之间的请求互不干扰只有同一个用户的重复请求会发生锁竞争。加锁代码里有个容易被忽略的问题setIfAbsent必须和过期时间放在同一个命令里。如果先SETNX再单独EXPIRE两个操作之间进程崩溃锁就变成了永不过期的死锁。这里直接用Redis的SET key value NX EX seconds组合命令是最稳妥的。锁的释放也必须小心。最典型的错误是在finally块里无条件delete如果当前线程的锁因为超时已经自动失效了另一个线程刚拿到锁这个线程的delete就会把别人的锁删掉。我当时在代码里简单记了锁的持有标识释放的时候先判断当前线程是否持有这把锁避免误删。业界更严谨的做法是用Lua脚本把检查持有者标识删除合并成原子操作但项目里用来做用户级别的限购保护简单的持有标识就足够了。实际使用中还有一个体会这把锁持有的时间必须极短锁内只做查订单表、写入订单表两个数据库操作任何外部网络IO和耗时的序列化工作都不能放进来。秒杀场景下锁的过期时间定到10秒其实已经算很久了正常情况下一个事务在几十毫秒内就能完成如果因为代码写得太重导致持锁超过10秒系统早就已经处于不可服务状态整个设计方案就应该重新审视了。5. 从同步下单到异步下单Redis Stream作为削峰通道库存预减成功之后如果直接在请求线程里创建订单、扣减数据库库存那高并发的问题就又绕回来了。所以我把真正的数据库写入动作后移用户请求只负责完成Lua脚本校验然后往Redis Stream里丢一条订单消息请求立刻返回正在排队请稍后查看结果。Redis Stream是Redis 5.0引入的消息队列结构它比Redis自带的List类型更适合做这部分工作核心原因是它天然支持消费者组。所谓消费者组就是一组消费者共同消费同一个消息流每条消息只被组内的一个消费者处理。这样即使我部署了多个订单处理实例每条秒杀订单消息也只会被消费一次不会出现两个实例同时处理同一笔订单导致重复写入的问题。初始化消费者组的代码大概长这样// 创建Stream和消费者组 stringRedisTemplate.opsForStream().createGroup(seckill:order: voucherId, order-group);读取并消费消息的循环我当时是用一个独立的线程池跑的while (true) { try { ListMapRecordString, Object, Object records stringRedisTemplate.opsForStream() .read(Consumer.from(order-group, consumer-1), StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), StreamOffset.create(seckill:order: voucherId, ReadOffset.lastConsumed())); for (MapRecordString, Object, Object record : records) { // 解析订单数据 Long voucherId Long.parseLong(record.getValue().get(voucherId).toString()); Long userId Long.parseLong(record.getValue().get(userId).toString()); // 创建订单并更新数据库库存 createVoucherOrder(voucherId, userId); // 处理成功确认消息 stringRedisTemplate.opsForStream().acknowledge( seckill:order: voucherId, order-group, record.getId()); } } catch (Exception e) { // 处理失败调用pendingEntries log.error(消费秒杀订单失败, e); } }这里有个实用的细节ReadOffset.lastConsumed()每次读取的是当前消费组还没确认的消息配合block参数可以实现阻塞式等待。如果某条消息处理失败了它会一直留在pending list里下次循环还能再读到。项目的简化版处理是消费失败就把消息转入一个专门记录异常的队列人工介入排查。异步化之后整个秒杀链路就变成了两个阶段。同步阶段Lua脚本扣减Redis库存、写入Stream、直接返回成功提示。异步阶段消费者线程读取Stream、创建数据库订单、扣减MySQL中真实库存、更新用户购买记录。从用户角度看点击抢购之后界面会提示抢购成功订单处理中几秒之后刷新就能看到订单详情。对于秒杀场景来说这个体验是可以接受的因为用户最关心的是我到底抢到没有而不是订单立刻出现在列表里。把耗时操作移出主线程之后请求线程的处理时间从几十毫秒降到了几毫秒即使Redis的QPS上限是10万业务接口也能扛住这个量级的外部请求量。下游数据库真正接收的写操作只是实际抢到的用户数100张券最多也就生成100笔订单这个量对MySQL来说完全没压力。6. 压测现场超卖复现、连接池耗尽、消息堆积的完整排查记录代码写完之后我直接用JMeter拉了一波压测这里把整个过程和几个典型的翻车现场记录下来。压测工具是JMeter线程组配置成200个线程循环5次总共1000个请求集合点组件让所有请求同时发出模拟瞬时并发。数据库里预置了一张库存为100的秒杀券。第一次压测就翻车了结束后查订单表生成了一百零几笔订单虽然库存没有变成负数但多出的订单明显对不上号。排查过程是这样的先看日志发现有几条创建订单时库存不足的报错但同时也有订单写入成功了。这就说明异步消费阶段没有动态检查库存。我的消费者线程在收到消息后直接创建订单并没有再次确认Redis或MySQL中的库存余量。这个问题的根源在于Redis预减阶段扣的是预热库存但数据库中的真实库存没有同步变化等到异步阶段写库的时候数据库的库存其实还是100。多个消费者线程几乎同时读到库存有余量全部放行订单就超了。修复方式是在异步消费里也加上数据库库存的动态判断用前面提到的那条带stock 0条件的UPDATE语句扣减执行结果为0就说明库存被抢先扣完了不创建订单。相当于Redis的预减是准入控制数据库的扣减是最终一致性兜底两道关卡都有才不会出问题。第二个翻车现场是Redis连接池耗尽。压测进行到一半接口开始大面积超时日志里报JedisConnectionException: Unexpected end of stream。原因是每一个请求到Lua脚本执行时都要从连接池借一个连接如果连接池设置太小高并发下连接被借光剩下的请求就只能排队等连接等待时间一长客户端以为超时了直接把连接断开导致Redis服务端报错。这个问题的调整方向不是一味调大最大连接数而是让项目里所有涉及Redis的操作尽量复用减少一次请求内多次借还连接的情况。我的脚本里三个Redis操作已经合并成一次脚本调用连接也只借一次但压测机-服务端-Redis之间的网络开销仍然存在。把连接池最大连接数从默认的8调到了60左右同时设置了合理的等待超时时间问题就缓解了。第三个问题是消息堆积。消费者线程的block等待时间是2秒如果消费者处理订单的速度跟不上Stream里消息进入的速度消息就会在pending list里积压。压测时1000个请求需要处理1000条订单消息消费者如果单线程跑每秒也只能处理几百条压测期间消息积压是必然的。解决办法是消费者线程池化多开几个消费者实例分担压力。只要消息不丢短暂积压是可以接受的毕竟秒杀的高峰期就那么几秒。7. 锁过期、Redis主从切换与秒杀接口的健壮性加固压测通过之后并不代表功能就绝对可靠了。项目里有两个隐患是在上线前的自测阶段发现的一个是锁的过期问题一个是Redis的主从切换问题。先说过期问题。用户抢券时加的分布式锁过期时间设置成了10秒如果持锁线程在锁过期后仍在执行事务比如数据库突然慢查询把事务拖到了10秒以上锁自动释放这时另一个线程又拿到了同一把锁两个线程同时进入临界区一人一单的约束就被突破了。这个问题叫做锁超时失效业界通用解法是引入Redisson这种自带看门狗机制的分布式锁它会在锁快过期时自动续期避免持锁时间超过锁的过期时间。用Redisson重构之后锁的过期问题基本不用再人工管理底层自动续期逻辑会兜底。主从切换的问题更隐蔽。Redis为了保证可用性一般都是主从架构如果主节点在锁写入后还没来得及同步到从节点就宕机了从节点被提升为新的主节点之后那把锁就丢了其他线程可能又拿到同一把锁。Redisson的方案是Redlock向多个独立Redis实例同时写入锁只有大多数实例都写入成功才算加锁成功。这个方案比较复杂它解决的是一致性优先于可用性的场景。对于黑马点评这类教学级项目来说单机Redis加Redisson的看门狗已经足够用但面试和技术方案里应该主动提到Redlock的取舍给后续架构演进留好位置。对秒杀接口本身的健壮性我还做了三个加固动作接口层的幂等校验Redis预减之前先检查请求参数的合法性userId不能为空voucherId必须是已发布状态的秒杀券。兜底数据一致性校验异步消费结束后周期性任务扫描订单表和秒杀券表的库存余量数量不一致时触发告警。库存扣减的防重入数据库扣减库存使用带条件的UPDATE影响行数为0时记录日志不写入订单。这三个动作加上前面的Lua脚本、分布式锁、Redis Stream异步消费才算拼出了一个在压测环境下表现得比较稳妥的完整秒杀功能。8. 如果项目要进阶库存预减还能再做哪些优化最后聊聊这个方案后续可以继续深挖的方向给我的感觉是这几个点都值得在面试里体现出来。第一个方向是限流前置。秒杀开始前可以在Nginx层或网关层加过程级限流比如用令牌桶算法限制单位时间进入秒杀接口的请求总数。黑马点评项目里没做这一层直接用Redis扛全量请求如果请求量真的达到百万级Redis的单线程模型也扛不住。前置限流相当于在最外层先拦掉一波让真正到达应用层的请求量可控。令牌桶算法可以自己用Redis实现或者引入Guava的RateLimiter不过后者只在单机有效分布式限流还是要落在Redis上。第二个方向是阻塞队列削峰。Java层面可以维护一个有界阻塞队列请求进入时先投递到队列由固定数量的线程从队列中取出请求进行处理。这样做的效果是业务的请求入口是全量接收但快速返回只有队列里的请求才会真正执行Lua脚本和数据库操作。它跟异步下单的区别在于阻塞队列还额外控制了处理速率而不是让所有消息一次性涌入Stream。第三个方向是库存热点的拆分。一把库存对应的key是seckill:stock:{voucherId}所有扣减请求都打在这一个key上Redis虽然是单线程执行但仍然会有细碎的网络和队列开销。如果库存数量很大可以拆成多个key比如1000库存拆成10个key每个key承担100的扣减量请求可以先找一个负载最低的key操作。这个方向能提高Redis侧的吞吐量上限但代价是引入了每个key之间库存余量不一致的问题复杂度会显著上升。对我来说黑马点评这个项目的意义不在于功能代码有多精妙而在于用较小的业务模型把秒杀场景中的前置校验、原子扣减、限购约束、异步削峰、最终一致性这几个关键设计串成了完整链路。把这套链路吃透了再去面对真实电商项目的秒杀活动很多方法论都是可以平移过去的。
网站建设高端定制企业官网