ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Node.js+Redis的商城小程序秒杀系统架构设计与实现

基于Node.js+Redis的商城小程序秒杀系统架构设计与实现 双十一那天晚上我盯着监控面板秒杀接口的QPS在开场后三秒内冲到两万多随后MySQL连接数瞬间打满订单表主从延迟一路飙到十几秒。后台日志里全是Lock wait timeout exceeded数据库连接池直接被拖死。那不是最惨的最惨的是第二波秒杀开场后有几个商品的库存显示还有剩余但用户那边已经提交不了订单——部分用户明明抢到了扣减成功的提示最终却没生成订单。后来我们把这套商城小程序的秒杀模块整个推翻重做基于Node.js重新设计了架构Redis扛住瞬时流量消息队列做异步削峰小程序端做预加载和防连点。这篇文章就从零拆解这套商品秒杀系统的完整架构把后端核心模块、库存扣减方案、接口防刷手段、异步下单流程、小程序对接要点以及部署踩坑记录全部整理出来源码工程我也会标注清楚对应关系方便你按图索骥。如果你正在做商城类项目或者手里有一个小程序商城需要加上秒杀能力这篇内容能帮你省掉大量试错成本。1. 秒杀系统到底难在哪先看清问题的本质1.1 秒杀和普通商品下单完全是两种玩法普通商城下单用户量分散请求到达率平稳后端的压力是缓慢上升的。秒杀不一样它的流量特征极其极端在活动开始的那一瞬间同一件商品会被几万人甚至几十万人同时抢。更麻烦的是大部分用户点下的那一刻商品可能已经没有了但这些请求依然会打到后端。也就是说秒杀系统要解决的核心问题不是“处理能力”而是如何在极短的时间内用有限的资源扛住超过平时几十倍的瞬时流量同时保证数据不错。我之前犯过一个特别典型的错误一开始图省事直接拿普通下单接口改了一下加了库存判断然后就拿去压测。结果500并发就把接口打崩了。原因很简单——每一个请求都去查一次MySQL库存、再执行一次update扣减数据库根本扛不住这种高频读写。1.2 秒杀场景中最容易翻车的三个地方第一是超卖。两个请求同时读到库存还剩1件同时通过检查同时执行扣减最终卖出去2件。这在技术上叫竞态条件是秒杀系统绝对不能踩的底线问题。第二是缓存雪崩或穿透。如果用Redis做缓存商品详情、库存数量都会放一份在缓存里。如果秒杀开始前缓存没预热或者缓存key在同一时间大面积失效瞬间的请求就会直接打到数据库把库打挂。第三是接口被打满导致正常用户也进不去。秒杀的请求不会因为商品卖完就停止。如果不在入口做拦截几万请求会一直打到应用层普通浏览、购物车、下单这些功能全部跟着瘫痪。所以这套系统的架构设计思路本质上是把“所有请求都处理”的思路改成“大部分请求在入口就被挡掉只有少部分真正有资格的用户请求才被放行”。2. 整体架构设计一条订单从点击到落库的完整链路2.1 模块划分与职责边界这套系统按职责拆成了五层每一层只做一件事模块技术选型核心职责接入层Nginx静态资源、反向代理、基础限流、负载均衡应用层Node.jsExpress Cluster业务逻辑、参数校验、令牌校验、请求分发缓存层Redis库存预热、原子扣减、用户秒杀标记、令牌存取异步层Redis Stream / RabbitMQ削峰填谷异步生成订单存储层MySQL订单数据、商品数据、最终库存落库这里要特别说明一下这套源码工程是单体架构为主但预留了微服务拆分空间。秒杀模块、商品模块、订单模块在代码层面按模块隔离不是写成一个个独立的微服务进程。原因是多数中小型商城的体量单体加Node.js Cluster已经能撑住百万级PV直接上微服务反而会把运维复杂度拉高。如果你的项目体量确实到了多个团队协作或独立扩容的阶段再把秒杀模块单独拆出去接口边界我在源码里已经按这种思路预留了。2.2 为什么用Node.js来做秒杀入口很多人问过我这个问题秒杀这么高并发的场景为什么不用Go或者Java我的选择逻辑是这样的Node.js的事件驱动和非阻塞I/O模型对于秒杀这种高I/O、低计算量的场景反而非常合适。一次秒杀请求里满打满算就是参数校验、Redis读几个key、执行一段Lua脚本这些操作基本都是网络I/O等待CPU占用很低。Node.js单线程事件循环在这种场景下的吞吐量相当可观再加上Cluster模块可以起多个进程分摊CPU核数性能完全够用。另外一个关键点是团队技术栈。这套源码工程本身就是给商城小程序配套的后端前端是微信小程序原生语法后端同事普遍熟悉JavaScript用Node.js意味着前后端可以共用一部分工具函数和类型定义开发效率最高。技术选型从来不是选“最强的”而是选“最合适的”。2.3 一次完整秒杀请求的数据流向我把一次正常秒杀从用户点击到订单生成的过程完整梳理一遍你看完就明白每一层在干什么用户在微信小程序里进入秒杀页页面加载时调用/api/seckill/list获取商品列表同时调用/api/seckill/time获取服务器当前时间。点击“立即秒杀”按钮小程序携带用户登录凭证和商品ID请求后端/api/seckill/doSeckill。Nginx层先按IP和用户维度做简单限流超过阈值的请求直接返回“排队人数过多”。Node.js应用层校验登录态、秒杀活动的开始/结束时间、当前用户是否已经秒杀过该商品。通过校验后执行Redis Lua脚本校验库存并尝试扣减。扣减成功则生成一个秒杀令牌一个随机的短期有效字符串写入Redis并设置过期时间同时把令牌返回给小程序前端。小程序拿到令牌后展示“排队中”或“下单中”状态接着调用/api/seckill/createOrder接口提交令牌。后端校验令牌合法且未被使用后不直接写MySQL而是把一条下单消息推入消息队列立刻返回“订单处理中”。异步消费者从队列中取消息通过事务写订单数据、扣减数据库库存完成后更新Redis中的订单状态。小程序通过轮询或订阅消息查询订单状态显示“待支付”用户完成支付。这个流程里最核心的设计就是请求在应用层只处理到“扣减Redis库存发放令牌”真正的订单写入MySQL在异步队列里慢慢消化。高峰期的两万QPS落到MySQL上可能只有几百TPS这就是削峰填谷的价值。3. 库存扣减与防超卖Redis Lua脚本是如何保证原子性的3.1 库存预热把数据库库存提前搬到Redis秒杀活动开始前需要一个预热步骤把参与活动的商品库存从MySQL读取出来写入Redis。// preheat.js 库存预热脚本片断 const redis require(redis); const client redis.createClient({ url: redis://127.0.0.1:6379 }); async function preheatStock(productId, stock) { const key seckill:stock:${productId}; await client.set(key, stock); // 设置活动结束后的自动过期避免key永久残留 await client.expire(key, 60 * 60 * 24); } // 从数据库读取商品库存并预热 const products await db.query(SELECT id, seckill_stock FROM products WHERE seckill_status 1); for (const p of products) { await preheatStock(p.id, p.seckill_stock); console.log(商品 ${p.id} 库存预热完成: ${p.seckill_stock}); }注意事项预热动作一定要在活动开始前完成并且最好做一个独立的预热接口或命令行脚本不要让应用启动时自动做。如果多个后端实例同时预热要注意别覆盖掉已经扣减过的库存。3.2 Lua脚本原子扣减扣减、检查、设置用户标记防超卖不能靠“先查再扣”我在源码里用的是Redis Lua脚本方案。Redis执行Lua脚本是原子性的脚本执行期间不会被其他命令插入所以不需要加分布式锁。-- seckill.lua 库存扣减脚本 -- KEYS[1] : 库存key例如 seckill:stock:1001 -- KEYS[2] : 用户秒杀标记key例如 seckill:user:1001:userId -- ARGV[1] : 用户唯一标识 -- ARGV[2] : 活动ID local stock redis.call(GET, KEYS[1]) if not stock then return -1 -- 库存不存在活动未预热 end if tonumber(stock) 1 then return 0 -- 库存不足 end local userKey KEYS[2] local hasUser redis.call(GET, userKey) if hasUser then return -2 -- 该用户已秒杀过防止重复抢购 end redis.call(DECR, KEYS[1]) redis.call(SET, userKey, 1, EX, 3600) return 1 -- 扣减成功Node.js端调用这个脚本const script fs.readFileSync(./seckill.lua, utf8); async function tryDeductStock(productId, userId, activityId) { const stockKey seckill:stock:${productId}; const userKey seckill:user:${activityId}:${userId}; const result await client.eval(script, { keys: [stockKey, userKey], arguments: [userId.toString(), activityId.toString()] }); // result: 1成功 / 0库存不足 / -1活动未开始 / -2重复秒杀 return result; }这里有一个细节用户标记key在第一个请求扣减成功的瞬间就写入了这样后续同一个用户再次请求时会被直接拒绝省掉了应用层的判断逻辑。而用户标记的过期时间建议设置成活动结束后一段时间即可不用太长。3.3 数据库兜底与最终一致性Redis扣减成功并不代表订单一定生成成功。异步消费者从队列里拿到消息后在MySQL事务里执行最终扣减这一步才是真正的数据落库。-- MySQL 最终扣减语句 UPDATE products SET seckill_stock seckill_stock - 1 WHERE id ? AND seckill_stock 0;注意这里的WHERE seckill_stock 0条件保证数据库层也不会扣成负数。即便前端Redis层扣减成功数据库层这里万一受影响行数为0说明库存数据不一致这时需要告警并人工对账。这套双写方案里Redis层的作用是高并发下快速拦截数据库层的作用是最终一致。两者必须配合使用只靠任意一层都有问题只靠Redis数据没有持久化只靠数据库扛不住压。4. 接口防刷与流量控制把无效请求挡在门外4.1 秒杀的流量为什么不建议全部扛下来有句运维圈的老话秒杀系统的最高境界不是把所有请求都优雅地处理掉而是让绝大多数请求根本到不了后端。设想一下一场秒杀放出1000件库存结果来了5万人抢。这5万请求里按正常转化率也只有几百人能抢到。剩下4万多个请求如果全部穿透到Node.js应用层、再到Redis甚至MySQL会造成巨大的资源浪费还可能挤兑掉其他正常业务。所以架构上必须层层拦截。4.2 Node.js实现令牌桶限流中间件应用层的限流我实现了一个简单的令牌桶中间件// rateLimiter.js 令牌桶限流中间件 class TokenBucket { constructor(capacity, fillRate) { this.capacity capacity; // 桶容量 this.fillRate fillRate; // 每秒补充令牌数 this.tokens capacity; // 初始装满令牌 this.lastRefill Date.now(); } tryAcquire(count 1) { const now Date.now(); this.tokens Math.min( this.capacity, this.tokens ((now - this.lastRefill) / 1000) * this.fillRate ); this.lastRefill now; if (this.tokens count) { this.tokens - count; return true; } return false; } } // 示例每秒放行2000个请求桶容量3000 const seckillBucket new TokenBucket(3000, 2000); function seckillRateLimit(req, res, next) { if (seckillBucket.tryAcquire(1)) { next(); } else { res.status(503).json({ code: 503, msg: 排队人数过多请重试 }); } }令牌桶相比计数器限流的好处是它可以允许突发流量。桶里攒了一部分令牌秒杀开始的瞬间可以放行一波较密集的请求然后逐渐平滑下来适合秒杀这种流量不平滑的场景。除了应用层限流Nginx层也可以配合limit_req模块做粗粒度限制双保险。不过要注意设置合理的超时重试机制被限流的用户端要给一个友好的提示而不是直接报错。4.3 幂等性与重复提交的防护限流只是挡住了流量规模问题还有个细节是重复提交。用户手抖点了两下或者小程序断网重发请求都会产生重复请求。我在设计接口时做了三重防护Redis用户标记拦截第三章节的Lua脚本里已经处理了同一用户秒杀同一商品只能成功一次。小程序端按钮禁用点击后置灰按钮拿到结果后才恢复。令牌一次性秒杀令牌发放后用SET NX写入Redis创建订单时读取并删除。删除操作要保证只有一个请求能成功删除拿到删除成功的那一个才允许继续下单。// 领取令牌 const token randomUUID(); const tokenKey seckill:token:${token}; // 一次性令牌SET NX 保证只能被领取一次 const ok await client.set(tokenKey, userId, { NX: true, EX: 600 }); if (!ok) { // 理论上不会走到这里若出现说明令牌冲突直接拒绝 throw new Error(令牌冲突); }这样一个令牌只能创建一次订单即使恶意用户绕过小程序直接刷接口拿不到合法令牌也下不了单。5. 异步下单与消息队列把同步等待变成异步确认5.1 同步下单为什么扛不住开始设计秒杀功能时我本能的思路是用户点秒杀 - 后端扣库存 - 写订单 - 返回结果。这套流程在低并发下没有任何问题但在秒杀场景下一次下单请求涉及两个数据库事务操作平均耗时能到50毫秒到100毫秒。假设架构能处理1000个并发请求每个请求占用一条数据库连接长达100毫秒一秒钟最多只能处理约一万个请求同时数据库连接池会瞬间被占满后续请求全部排队等待最终表现为接口超时、用户疯狂重试、系统彻底崩溃。把写订单这个动作从同步接口中拆出去是为了让应用层能在极短的时间内完成请求处理并释放资源。这就是异步削峰的核心价值。5.2 订单消息的设计与消息队列选择源码工程里我使用了Redis Stream作为消息队列。选它的原因很直接工程里本来就用Redis做缓存和库存扣减再引入RabbitMQ或Kafka意味着额外搭建和运维一套中间件对于单体架构来说收益不高。Redis Stream具备消息持久化、消费者组、消息ACK机制足够支撑秒杀场景的异步下单需求。核心的消息类型是create_order{ messageId: uuid, type: create_order, data: { userId: 100234, productId: 1001, activityId: A001, token: xxxx-xxxx-xxxx, price: 19900, createTime: 1734567890123 } }生产者Node.js应用层在用户拿到令牌并请求创建订单时把这条消息推入Stream然后立刻返回“订单处理中请稍后查询”。消费者组的工人从Stream里读取消息执行业务逻辑。消费者在处理时需要保证消息不丢失处理完订单后再调用XACK确认未确认的消息可以重新消费。同一个订单只被消费一次消费者在处理前先用Redis的SET NX锁一下消息ID处理完释放。这样即使消息重复投递也不会重复生成订单。// consumer.js 订单消费者伪代码 async function processOrder(message) { const lockKey order:lock:${message.id}; const acquired await client.set(lockKey, 1, { NX: true, EX: 30 }); if (!acquired) { return; // 该消息正在被处理或已处理完跳过 } try { await db.transaction(async (tx) { // 1. 校验令牌 // 2. 创建订单记录 // 3. 扣减数据库库存 }); // 处理成功后ACK await stream.xack(streamName, groupName, message.id); } finally { await client.del(lockKey); } }5.3 订单超时未支付的库存回补方案用户抢到秒杀资格后如果一直不支付库存会一直被占用。这里需要一套超时回补机制订单创建后10分钟可配置未支付自动取消订单并回补库存。回补库存时要注意一个经典坑直接用INCR把库存加回去这个操作没有问题但可能会把超卖问题带回来——假设库存只剩最后1件A用户抢到后生成订单未支付此时数据库库存已经是0Redis也没法再扣减。但这时如果A超时取消库存回补到1新用户B又能秒杀了这在业务上是合理的。真正的坑在于超时判断的时点用户已经支付了就不能再执行取消。判断时要用订单的支付完成时间做二次校验避免“用户刚好在超时临界点支付却被系统取消”的情况。建议实现方案是在订单创建时写入一个expire_time字段消费者扫描时用SELECT ... WHERE statusunpaid AND expire_time NOW()查出超时订单逐个加分布式锁后处理。6. 小程序前端的秒杀体验改造6.1 服务端时间同步倒计时的正确打开方式小程序端的倒计时如果直接用本地时间会有两个问题一是用户手机时钟可能不准确二是秒杀开始时用户本地和服务器存在几秒误差会导致用户提前看到“立即抢购”按钮但点不动或者时间没到就点了然后被拦截。解决方案是进入秒杀页时请求后端/api/seckill/time接口获取服务器时间戳同时记录下前端当前时间戳。之后用“本地时间偏移量”来修正倒计时而不是每次重新请求。let serverTimeOffset 0; async function syncServerTime() { const localStart Date.now(); const res await request.get(/api/seckill/time); const serverTime res.data.serverTime; // 计算本地时钟与服务器时钟的差值 serverTimeOffset serverTime - (localStart (Date.now() - localStart) / 2); } function getCurrentSyncTime() { return Date.now() serverTimeOffset; }倒计时组件每隔一秒更新一次计算startTime - getCurrentSyncTime()当剩余时间小于等于0时把按钮切换为可点击态。6.2 前端防连点与排队状态反馈小程序端的按钮处理也是一个容易忽略的细节。我见过不少项目后端防刷做得铜墙铁壁结果前端用户连点十下发出十个请求虽然最终只能秒杀一次但白白浪费了后端资源。前端的处理方式比较简单// 点击秒杀按钮 async function onSeckillTap() { if (this.data.isSubmitting) { return; // 正在提交中忽略后续点击 } this.setData({ isSubmitting: true }); try { const result await request.post(/api/seckill/doSeckill, { productId: this.data.productId }); if (result.code 1) { // 拿到令牌进入排队/创建订单流程 await this.createOrder(result.data.token); } else { wx.showToast({ title: result.msg, icon: none }); } } finally { // 根据业务需要延迟恢复按钮 setTimeout(() { this.setData({ isSubmitting: false }); }, 2000); } }加载反馈建议用“排队中”的全屏遮罩加骨架屏让用户知道系统正在处理而不是干等。秒杀场景的用户情绪高度紧张如果点击后没有任何反饋用户会倾向于反复重试这会让后端承受更大的压力。7. 部署与踩坑记录Node.js环境、版本、常见启动问题7.1 环境准备Node.js版本选择与Ubuntu安装这套源码工程建议使用Node.js 20及以上版本。Node.js 20是LTS版本稳定性足够而且原生支持了一些新特性。如果你不是在Windows上开发小程序后端而是在一台Ubuntu服务器上部署安装步骤建议用nvm来管理版本不要直接用apt装的旧版本。# 在 Ubuntu 上安装 nvm curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash source ~/.bashrc # 安装 Node.js 20 LTS nvm install 20 nvm use 20 node -v这里我踩过一个坑如果你直接curl ... | bash装了个默认nvm版本可能会在.bashrc里没有正确写入路径导致新开终端找不到nvm命令。遇到command not found: nvm时手动执行chmod x ~/.nvm/nvm.sh source ~/.nvm/nvm.sh另外有一个容易让人困惑的报错error installing 24.21.0: node.js v24.21.0 is not yet released or is not available这不是你本机有问题而是你输入了一个还不存在的版本号或者版本号拼错了。Node.js的版本发布有节奏不是所有数字组合都存在。如果你确实需要某个版本去官方发布列表确认版本号后再用nvm install安装。比如nvm install 24.21.0如果提示不存在换成nvm install 22LTS或者nvm install 24当前最新即可。7.2 两个高频启动报错与处理这项目在跑起来时我遇到过两个出现频率很高的启动问题记录一下报错一Redis连接不上Error: Redis connection to 127.0.0.1:6379 failed - connect ECONNREFUSED排查思路先确认Redis是否安装并启动。redis-cli ping # 如果返回 PONG说明Redis正常如果没装Redis用apt install redis-server安装启动后设置开机自启。如果Redis装在另一台服务器记得检查项目根目录.env文件里的REDIS_URL配置。报错二端口被占用Error: listen EADDRINUSE: address already in use :::3000说明默认端口3000已经被别的进程占用。要么改项目端口要么找到占用进程kill掉。lsof -i :3000 # 找到PID后 kill -9 PID生产环境部署建议用pm2管理Node.js进程它可以自动重启崩溃进程还能做简单的负载均衡npm install -g pm2 pm2 start ecosystem.config.js pm2 monit我一般在ecosystem.config.js里开启instances: max让pm2自动利用服务器多核CPU。8. 源码工程拆解从目录结构看懂每个模块干什么8.1 工程目录结构与核心文件拿到源码工程后建议按照下面的顺序阅读能最快建立整体认知seckill-system/ ├── client/ # 微信小程序前端 │ ├── pages/ │ │ ├── index/ # 首页/商品列表 │ │ ├── seckill/ # 秒杀活动页面 │ │ └── order/ # 订单列表与支付 │ └── utils/ │ ├── request.js # 封装小程序网络请求 │ └── time.js # 服务器时间同步 ├── server/ # Node.js 后端 │ ├── src/ │ │ ├── controllers/ │ │ │ ├── seckill.js # 秒杀接口控制器 │ │ │ └── order.js # 订单接口控制器 │ │ ├── services/ │ │ │ ├── stockService.js # 库存服务Lua脚本调用 │ │ │ ├── orderService.js # 订单服务 │ │ │ └── tokenService.js # 令牌服务 │ │ ├── mq/ │ │ │ └── consumer.js # 异步订单消费者 │ │ ├── middlewares/ │ │ │ ├── rateLimit.js # 令牌桶限流 │ │ │ └── auth.js # 登录态校验 │ │ ├── scripts/ │ │ │ ├── preheat.js # 库存预热脚本 │ │ │ └── initDb.js # 初始化数据库表 │ │ └── app.js # Express应用入口 │ ├── lua/ │ │ └── seckill.lua # 库存原子扣减脚本 │ ├── .env.example # 环境变量示例 │ └── package.json └── docs/ └── api.md # 接口文档最值得先读的三个文件是seckill.lua理解防超卖、stockService.js理解库存服务封装、consumer.js理解异步下单。8.2 本地启动的完整流程我以Ubuntu环境为例本地跑通这套源码的步骤整理如下# 1. 安装依赖后端 cd server cp .env.example .env # 按实际环境修改数据库、Redis配置 npm install # 2. 初始化数据库 mysql -uroot -p src/scripts/initDb.sql node src/scripts/initDb.js # 3. 启动Redis确认Redis已安装 redis-server --daemonize yes # 4. 启动后端 npm run dev # 5. 在小程序开发工具中导入client目录 # 修改client/utils/config.js里的API地址指向本地后端随后打开小程序开发者工具进入秒杀页如果能看到商品列表和倒计时说明环境已经通了。再用以下命令测试库存扣减逻辑# 给商品1001预热100件库存 node src/scripts/preheat.js --productId1001 --stock100 # 用curl模拟用户请求 curl -X POST http://localhost:3000/api/seckill/doSeckill \ -H Content-Type: application/json \ -d {productId: 1001, userId: test001}注意小程序端的登录态是通过wx.login获取code再在后端换取openid本地用curl测试需要临时绕过登录态校验源码里auth.js中间件有对应的测试开关。8.3 拿到源码后怎么改造成你自己的业务最后这点很关键。源码工程的核心价值在于架构思路不是让你下载下来直接上线用。真正要落地到你自己的商城项目里通常需要改这几个地方登录鉴权源码里的用户体系是简化版你需要接入你自己的微信登录体系把userId替换成自己的用户唯一标识。商品与活动管理后台源码里没有做管理后台需要在products表上扩充活动配置字段比如秒杀开始时间、结束时间、每人限购数、秒杀价格。支付回调源码中订单支付后回补订单状态的逻辑是模拟的接入微信支付后需要在支付回调通知里调用状态更新接口同时处理退款、关闭订单等场景。监控告警秒杀系统上线后Redis命中率、消息队列堆积量、订单成功率这三项必须盯住。建议在公司现有的监控平台里加上这几个指标任何一项异常都要及时收到告警。我自己在这个项目上反复压测过很多轮最有体会的一点是秒杀系统不会因为某一个环节做得好就稳定而是任何一环出现短板都会在流量峰值时被放大。Redis扣减做得再快前端不防连点入口一样会被刷爆异步队列设计得再稳消息不ACK重启后就会重复消费。把每个环节的边界都想清楚压测数据能真正做到连续多轮无超卖、无消息丢失这时候才敢说这套架构是靠谱的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进