Vue+SpringCloudAlibaba电商实战:Redis+Lua秒杀抢购高并发方案

发布时间:2026/10/20 16:01:50
Vue+SpringCloudAlibaba电商实战:Redis+Lua秒杀抢购高并发方案 秒杀这个场景做过电商的同学应该都不陌生。每年大促那几天运营那边一喊“上秒杀”后端就得连夜盯着监控看QPS曲线生怕哪一秒数据库连接池被打满。我前后参与过三个不同规模的电商项目从最早的纯MySQL硬抗到后来加Redis做缓存再到SpringCloudAlibaba体系下用Sentinel做限流踩过的坑基本能写一本小册子。这篇就围绕“基于VueSpringCloudAlibaba微服务电商项目实战”里第022篇要讲的Redis实现秒杀抢购把整套思路从头到尾捋一遍。先说清楚这个内容适合谁看如果你已经搭好了SpringCloudAlibaba的基础骨架Nacos能注册服务、Gateway能路由转发、OpenFeign能调通接口但对“秒杀到底怎么扛住高并发”还停留在“加个Redis应该就行了吧”的阶段那这篇就是写给你的。我会把库存预热、Lua原子扣减、异步下单、限流削峰这几个核心环节全部拆开讲包括为什么这么设计、参数怎么定、代码怎么写、出问题怎么排查。前端Vue那部分也会带一句毕竟秒杀页面的按钮防抖和倒计时也是体验的一部分。1. 秒杀场景的整体架构设计思路1.1 为什么不能直接让请求打到数据库很多人第一次做秒杀直觉就是“用户点按钮后端查库存库存够就扣减然后生成订单”。这个流程在日活几百的小系统里跑得挺欢但秒杀的本质是瞬时高并发写冲突。假设一个商品放100件库存活动开始那一秒涌进来5万个请求如果每个请求都去SELECT ... FOR UPDATE锁行数据库连接池瞬间就被占满后面的请求全部排队超时。更致命的是行锁竞争会让InnoDB的锁等待队列暴涨CPU上下文切换开销直接把数据库拖垮。我实测过一组数据单表100万行、库存字段带索引的情况下纯数据库扣减在4核8G的MySQL上TPS大概在800到1200之间就出现明显抖动。而秒杀峰值动辄几万QPS差了整整两个数量级。所以核心思路必须是把库存判断和扣减从数据库前移到Redis让绝大多数无效请求在内存层就被拦截掉只有真正抢到库存的那一小批请求才落到数据库去生成订单。1.2 Redis在秒杀链路中扮演的三个角色Redis在这个链路里不是只做一件事它承担了三个明确职责每个职责对应不同的数据结构和操作库存计数器用String类型存商品剩余库存DECR或Lua脚本扣减保证原子性。这是第一道闸门。请求去重与限流用Set或Hash记录已参与用户防止同一用户重复抢购配合令牌桶或滑动窗口做接口级限流。异步队列缓冲用List或Stream做简易消息队列把下单请求先塞进去后端消费者慢慢处理实现削峰填谷。这三个角色是递进关系先扣库存拦住超卖再去重拦住刷单最后用队列把剩余流量平滑掉。少了任何一环系统在极端场景下都会出问题。1.3 SpringCloudAlibaba体系下的组件分工在SpringCloudAlibaba这套技术栈里秒杀模块不是孤立的。我一般这样划分职责组件职责秒杀场景中的具体作用Nacos注册中心配置中心秒杀开关、限流阈值动态下发Gateway网关秒杀接口统一入口做第一层限流和黑名单Sentinel流量防护接口级QPS限流、熔断降级Redis缓存原子操作库存扣减、去重、队列RocketMQ消息队列异步下单比Redis List更可靠Seata分布式事务库存与订单的最终一致性兜底这里有个选型细节值得说为什么用RocketMQ而不是直接用Redis List做队列。Redis List的LPUSHBRPOP确实能实现队列但它没有ACK机制消费者拿到消息后如果处理失败消息就丢了。秒杀订单涉及钱和库存丢一条就是客诉。RocketMQ有重试、有死信队列、有事务消息虽然重一点但可靠性对得起这个场景。当然如果项目规模小用Redis Stream也能凑合它有消费者组和ACK比List强不少。2. 库存预热与Lua原子扣减的核心细节2.1 库存预热什么时候做、怎么做库存预热的意思是在秒杀活动开始之前把数据库里的商品库存同步到Redis里。这个动作不能等到活动开始那一刻才做否则预热本身的IO就会和秒杀流量撞在一起。我的做法是在活动开始前5到10分钟通过Nacos配置一个定时任务开关或者运营后台手动触发一个预热接口。预热逻辑很简单查数据库拿到秒杀商品的库存SET seckill:stock:{goodsId} {stock}。但有几个细节必须注意预热要幂等如果重复触发不能把已经扣减过的库存又覆盖回去。解决办法是预热时先判断key是否存在或者用SETNX只有不存在才设置。预热要记录版本我会额外存一个seckill:stock:version:{goodsId}每次活动重新预热时版本号加一扣减脚本里校验版本防止旧活动的请求打到新库存上。预热后要校验设置完库存立刻GET一次确认值正确同时检查Redis内存和过期时间设置。// 库存预热核心逻辑基于常见实践补充 public void preheatStock(Long goodsId, Integer stock) { String stockKey seckill:stock: goodsId; String versionKey seckill:stock:version: goodsId; // 只有key不存在时才设置保证幂等 Boolean success redisTemplate.opsForValue() .setIfAbsent(stockKey, stock, 2, TimeUnit.HOURS); if (Boolean.TRUE.equals(success)) { redisTemplate.opsForValue().increment(versionKey); log.info(库存预热成功 goodsId{} stock{}, goodsId, stock); } else { log.warn(库存已存在跳过预热 goodsId{}, goodsId); } }2.2 为什么必须用Lua脚本扣库存有人会问Redis的DECR命令本身就是原子的为什么还要写Lua因为秒杀扣库存不是单纯减一它包含三个判断库存是否大于0、用户是否已经买过、扣减后是否要记录用户。这三个操作如果分开执行中间就可能被其他请求插入导致超卖或重复购买。举个例子库存只剩1件用户A和用户B同时请求。如果先GET库存判断大于0两个请求都判断通过然后各自DECR库存变成-1超卖了。Lua脚本在Redis里是单线程原子执行的脚本执行期间不会有其他命令插入所以把“判断扣减记录”打包成一个脚本就能彻底杜绝这个问题。2.3 Lua脚本的完整实现与参数说明下面这个脚本是我在实际项目里用了很久的版本包含库存判断、用户去重、扣减、记录四个动作-- seckill_stock.lua -- KEYS[1]: 库存key seckill:stock:{goodsId} -- KEYS[2]: 用户集合key seckill:users:{goodsId} -- ARGV[1]: 用户ID -- ARGV[2]: 扣减数量通常为1 -- 返回: 1成功 0库存不足 -1重复购买 local stock redis.call(GET, KEYS[1]) if not stock then return -2 -- 库存未预热 end if tonumber(stock) tonumber(ARGV[2]) then return 0 -- 库存不足 end -- 判断用户是否已参与 local exist redis.call(SISMEMBER, KEYS[2], ARGV[1]) if exist 1 then return -1 -- 重复购买 end -- 扣减库存 redis.call(DECRBY, KEYS[1], ARGV[2]) -- 记录用户 redis.call(SADD, KEYS[2], ARGV[1]) -- 设置用户集合过期时间防止内存泄漏 redis.call(EXPIRE, KEYS[2], 7200) return 1几个关键点解释一下。KEYS和ARGV的区分很重要Redis集群模式下所有KEYS必须落在同一个slot所以我把库存key和用户集合key都加了{goodsId}的hash tag保证它们路由到同一个节点。EXPIRE那行是防止用户集合无限增长活动结束后两小时自动清理。返回值设计成多种状态方便上层区分是库存不足还是重复购买给前端不同的提示文案。2.4 脚本加载与执行的工程化封装在Java里执行Lua脚本不要每次请求都传脚本字符串那样网络传输开销大。正确做法是应用启动时用SCRIPT LOAD把脚本加载到Redis拿到SHA1值之后用EVALSHA执行。Spring Data Redis的DefaultRedisScript已经帮我们做了这层封装Configuration public class RedisScriptConfig { Bean public DefaultRedisScriptLong seckillScript() { DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(lua/seckill_stock.lua)); script.setResultType(Long.class); return script; } } // 执行时 Long result redisTemplate.execute( seckillScript, Arrays.asList(stockKey, userSetKey), userId.toString(), 1 );注意DefaultRedisScript默认使用EVALSHA如果Redis重启导致脚本缓存丢失会抛NOSCRIPT异常。稳妥的做法是捕获这个异常后降级用EVAL重新执行一次或者干脆在启动时主动SCRIPT LOAD一遍。3. 异步下单与流量削峰的完整实操3.1 从同步下单到异步下单的转变Lua脚本扣减成功之后如果直接在当前请求线程里写数据库生成订单那这个请求的响应时间就取决于数据库的写入速度。秒杀场景下即使只有10%的请求走到这一步数据库压力依然不小。更麻烦的是如果数据库写入慢Tomcat线程池会被这些请求占满导致后续请求连Lua脚本都执行不了。所以标准做法是扣减成功后立刻返回“排队中”把下单请求丢进消息队列由独立的消费者服务异步处理。用户前端看到的是“抢购成功订单生成中”过一两秒刷新订单列表就能看到。这个体验损失换来的是系统吞吐量的数量级提升。3.2 RocketMQ消息的构造与发送时机消息发送的时机很关键。我见过有人在Lua脚本里直接发消息这是不可能的Lua里没法调MQ。正确顺序是执行Lua脚本扣减库存判断返回值如果为1成功构造消息发送到RocketMQ发送成功则返回“排队中”发送失败则回滚Redis库存INCR回去并返回失败第3步的回滚很重要否则会出现“库存扣了但消息没发出去订单永远不生成”的情况。虽然这种概率低但一旦发生就是资损。public SeckillResult seckill(Long goodsId, Long userId) { String stockKey seckill:stock: goodsId; String userSetKey seckill:users: goodsId; Long result redisTemplate.execute(seckillScript, Arrays.asList(stockKey, userSetKey), userId.toString(), 1); if (result null || result ! 1) { return SeckillResult.fail(result); } try { // 构造消息包含订单必要信息 SeckillMessage msg new SeckillMessage(goodsId, userId, UUID.randomUUID().toString()); SendResult sendResult rocketMQTemplate.syncSend(seckill-order-topic, msg); if (sendResult.getSendStatus() ! SendStatus.SEND_OK) { throw new RuntimeException(消息发送失败); } return SeckillResult.queuing(); } catch (Exception e) { // 回滚库存 redisTemplate.opsForValue().increment(stockKey); redisTemplate.opsForSet().remove(userSetKey, userId.toString()); log.error(秒杀消息发送失败已回滚库存 goodsId{} userId{}, goodsId, userId, e); return SeckillResult.fail(系统繁忙请重试); } }3.3 消费者端的幂等处理与订单落库消费者拿到消息后要做三件事幂等校验、扣数据库库存、生成订单。幂等校验用消息里的唯一ID上面代码里的UUID做在数据库建一张seckill_message_log表唯一索引加在messageId上插入成功才继续处理插入冲突说明已经处理过直接ACK。数据库扣库存要用乐观锁SQL大概长这样UPDATE seckill_goods SET stock stock - 1, version version 1 WHERE id #{goodsId} AND stock 0 AND version #{version}影响行数为0就说明库存已经被扣完或者版本冲突这时候要记录日志并触发Redis库存修正把之前多扣的加回去。订单表插入用普通的INSERT订单号用雪花算法生成保证全局唯一。3.4 前端Vue页面的配合要点前端这块虽然不复杂但有两个细节直接影响用户体验和系统压力。第一是按钮防抖用户点击后立刻置灰防止连点产生重复请求。第二是倒计时同步倒计时不能只靠前端本地时间要以服务端时间为准否则用户改系统时间就能提前抢。// Vue秒杀按钮防抖基于常见实践补充 async handleSeckill() { if (this.loading) return this.loading true try { const res await seckillApi(this.goodsId) if (res.code 200) { this.$message.success(抢购成功订单生成中) this.pollOrderStatus() // 轮询订单状态 } else { this.$message.warning(res.msg) } } finally { // 无论成功失败3秒后才允许再次点击 setTimeout(() { this.loading false }, 3000) } }轮询订单状态建议用递增间隔比如1秒、2秒、3秒、5秒避免固定频率轮询给后端造成额外压力。4. 限流、降级与常见问题排查实录4.1 Sentinel限流规则的配置策略Gateway层和秒杀服务层都要配Sentinel规则形成双层防护。Gateway层做粗粒度限流比如整个秒杀接口QPS限制在5000服务层做细粒度限流按商品维度限制单个商品QPS不超过2000。这样即使网关被绕过服务层还能兜住。限流阈值怎么定我的经验是压测出单机极限QPS然后乘以0.6作为阈值。比如压测发现单机能扛3000 QPS那Sentinel阈值就设1800留40%的缓冲应对突发流量和GC停顿。阈值不是拍脑袋定的一定要有压测数据支撑。# Sentinel规则示例基于常见实践补充 - resource: seckillOrder grade: 1 # QPS模式 count: 1800 # 阈值 strategy: 0 # 直接拒绝 controlBehavior: 0 # 快速失败4.2 常见问题速查表下面这张表是我和团队在实际运维中整理出来的覆盖了秒杀场景下80%以上的故障问题现象可能原因排查思路解决方案库存变成负数Lua脚本未原子执行或回滚逻辑有bug查Redis库存值、查消息发送日志检查脚本是否用EVALSHA、回滚是否成对出现用户重复下单去重集合key过期或未设置查seckill:users集合是否存在确保EXPIRE设置、检查用户ID是否为空消息堆积消费者处理慢或消费线程数不足查RocketMQ消费位点、查消费者日志增加消费线程、优化订单落库SQL接口大面积超时数据库连接池耗尽查Druid监控、查慢SQL扩大连接池、异步下单、加索引Redis内存暴涨用户集合未设过期或key未清理INFO memory、SCAN大key所有临时key必须设TTL活动开始瞬间无响应库存预热未完成或Nacos配置未推送查预热日志、查配置版本提前10分钟预热、配置双人复核4.3 几个只有踩过才知道的坑第一个坑Lua脚本里的数字比较。Redis的GET返回的是字符串tonumber转换后才能比较。我早期写脚本时忘了转换10 9在字符串比较下是true导致库存判断完全错乱。这个bug在测试环境没复现因为测试库存都是个位数上线后库存上百才暴露出来。第二个坑RocketMQ消息发送超时导致重复回滚。syncSend默认超时3秒如果网络抖动导致发送超时但消息实际已投递回滚库存后消费者又处理了消息就会出现“库存回滚了但订单生成了”的不一致。解决办法是消息里带唯一ID消费者幂等处理同时回滚操作也要记录日志人工对账兜底。第三个坑Sentinel规则在Nacos里改了不生效。Sentinel的Nacos数据源有缓存改完规则要等几秒才推送而且如果rule-type配错规则根本不会加载。我建议在测试环境专门验证一次规则推送链路确认Dashboard能看到规则变化。第四个坑前端倒计时用本地时间。有用户把手机时间调到活动开始后提前看到按钮可点击虽然服务端会拦截但用户体验很差还会产生无效请求。正确做法是页面加载时调一次服务端时间接口算出本地时间偏移量倒计时基于偏移量计算。4.4 压测与容量评估的实操建议上线前一定要压测而且要用接近真实的流量模型。我一般用JMeter或wrk分三组场景正常流量预期峰值的50%、峰值流量预期峰值的100%、极限流量预期峰值的150%。重点观察四个指标Redis的instantaneous_ops_per_sec、MySQL的Threads_running、Tomcat的activeThreads、RocketMQ的消费堆积量。容量评估有个简单公式所需Pod数 峰值QPS / 单Pod压测QPS × 1.5。那个1.5是安全系数应对流量预估偏差和单点故障。比如峰值预估2万QPS单Pod压测能扛3000那至少需要10个Pod。这个数字要在活动前一周就扩好别等到活动当天再临时扩容K8s拉Pod也要时间的。5. 数据一致性的兜底方案5.1 Redis与数据库的最终一致性Redis扣减和数据库扣减之间必然存在时间差这个时间差内数据是不一致的。我们的目标是最终一致而不是强一致。兜底手段有三个层次第一层是消息队列的重试机制消费者处理失败会自动重试默认16次基本能覆盖网络抖动和临时故障。第二层是定时对账任务每5分钟跑一次比对Redis库存和数据库库存的差值如果差值超过阈值就告警。第三层是人工干预接口运营后台提供“库存修正”按钮紧急情况下手动把Redis库存重置为数据库库存。5.2 分布式事务要不要上Seata这个问题我被问过很多次。我的观点是秒杀场景下Seata的AT模式并不适合。AT模式依赖全局锁在高并发下全局锁竞争会成为新的瓶颈而且它的事务回滚是基于undo log的秒杀这种高频写场景下undo log膨胀很快。更务实的做法是用“本地消息表定时补偿”或者干脆接受最终一致用对账来兜底。Seata更适合那些并发不高但一致性要求极高的场景比如金融转账。5.3 活动结束后的数据清理活动结束后Redis里的库存key、用户集合key、版本key都要清理。我一般写一个清理接口运营在后台点一下批量删除seckill:*相关的key。注意不要用KEYS命令去匹配删除生产环境KEYS会阻塞Redis要用SCAN游标分批删。清理完记得把数据库里的秒杀商品状态改为“已结束”防止用户通过旧链接继续访问。// 安全清理秒杀相关key基于常见实践补充 public void cleanSeckillKeys(Long goodsId) { String pattern seckill:*: goodsId; ScanOptions options ScanOptions.scanOptions().match(pattern).count(100).build(); try (Cursorbyte[] cursor redisTemplate.executeWithStickyConnection( connection - connection.scan(options))) { while (cursor.hasNext()) { redisTemplate.delete(new String(cursor.next())); } } }这套方案我在两个项目里落地过一个日活十万级一个日活百万级大促期间没出过超卖和资损。核心就一句话把能前移的判断全部前移到Redis把能异步的操作全部异步化剩下的用对账兜底。Redis不是银弹Lua脚本也不是万能的真正的稳定性来自于每一层都有降级方案每一个异常分支都有回滚逻辑。最后分享一个小技巧秒杀接口的日志一定要打全链路traceId从Gateway到Service到Consumer出问题时能一条链路查到底比翻十几个服务的日志高效得多。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询