ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Node.js+Vue的鲜花团购秒杀系统设计与高并发实践

基于Node.js+Vue的鲜花团购秒杀系统设计与高并发实践 上个月我一个开花店的朋友突然跑来找我说现在生意淡得不行想在“38节”之前搞一波团购预热但淘宝抽成高微信接单又容易漏。我盘了一遍需求一套能承载鲜花团购、秒杀、订单管理的系统而且前后端最好都是我熟悉的 JavaScript 技术栈。于是就有了这个基于 Node.js Vue 的鲜花销售团购秒杀系统。这个项目不是玩具 demo而是真正能跑起来的小型促销平台。它解决的核心问题是鲜花这种保质期极短的商品怎么通过“团购锁客 秒杀冲量”把库存提前消耗掉同时处理秒杀带来的瞬时高并发。如果你正在做毕业设计或者想从零搭一套全栈电商系统这篇文章从需求拆解到上线部署的完整过程应该能给你一个直接可参考的落地路径。1. 需求拆解鲜花生意为什么要做团购和秒杀1.1 鲜花的高时效性决定了营销玩法鲜花不是普通商品生命周期只有几天。平时没有节日的时候销量很低但节假日又会在一天内涌进大量订单。传统门店靠电话、微信接单很容易出现统计错漏。更重要的是鲜花一旦备货多了卖不掉就是直接亏损少了又错过销售高峰。团购和秒杀刚好解决这个矛盾。团购需要提前预付商家可以根据参团人数准备货源降低库存风险秒杀则可以制造稀缺感在短时间内快速清掉积压库存。所以这个系统里活动和库存是两个核心词而不是普通电商里的商品管理和 SKU 拆分。这个认知很关键决定了后面所有表结构和接口设计的方向。1.2 用户端和管理端的功能划分用户端以促销活动为中心我把它拆成这几个主要页面首页活动 banner、推荐鲜花、团购入口、秒杀入口商品列表 / 商品详情鲜花规格、价格、库存、图文介绍团购页显示团购价、成团人数、进度条点击开团或参团秒杀页倒计时、秒杀价、限购数量、立即秒杀按钮购物车 / 订单确认 / 支付模拟 / 订单列表个人中心我的拼团、我的订单、收货地址管理端相对简单商品管理、活动管理、订单管理、数据统计。我在做路由设计时把用户端和管理端分成两个 Router 实例避免权限混在一起。用户端使用 Vue Router 的懒加载按需加载页面管理端则直接引入 Element Plus 的布局组件。这里有个容易被忽略的细节活动页面用动态路由/activity/seckill/:id因为每个活动都有独立详情页而购物车、个人中心用静态路由即可没必要做成动态的。这个区分在做权限控制时会省很多事。2. 技术选型为什么是 Node.js Vue 而不是 Spring Boot2.1 为什么不用 Spring Boot如果去搜索引擎里看springboot vue的热度明显比node.js vue高很多高校毕设也都选 Spring Boot。但我自己在真正动手时还是选了 Node.js。理由一项目规模决定技术复杂度。鲜花团购秒杀系统虽然有热点请求但整体业务逻辑并不重没有复杂的 ERP、报表、多租户需求。用 Spring Boot 需要额外维护 Java 环境和一堆依赖对一到两个人的开发团队来说有点重。理由二异步 I/O 模型更适合促销场景的高并发读写。Node.js 的事件循环在处理大量短连接、I/O 密集任务比如查 Redis、读写 MySQL、调用支付接口时内存占用比传统阻塞模型低很多。秒杀场景的大部分瓶颈其实在数据库热点行和网络等待上Node.js 的并发能力足够应对前提是不要把业务逻辑写成阻塞式。理由三前后端语言统一。Vue 和 Node.js 都是 JavaScript / TypeScript 生态接口定义、数据结构、命名风格可以共享甚至可以把公共类型抽到 monorepo 里的 shared 包。一个人同时维护前端和后端心智负担小很多。当然Spring Boot 在事务管理、生态成熟度上有优势。我最后也没有完全抛弃事务而是在订单创建、库存扣减的数据库操作局部使用事务这就够用了。2.2 真正用到的 Node.js 后端依赖Express 或 Koa我选了 Koa 2洋葱模型中间件在处理跨域、鉴权、错误拦截时更顺手。ioredisRedis 客户端支持 Cluster秒杀库存操作全靠它。BullMQ基于 Redis 的消息队列用于异步下单。mysql2连接 MySQL使用 Promise 接口。JWT用户登录凭证。pino结构化日志。这里想提醒一下很多人一上来就选 NestJS觉得企业级框架完善。但如果你只是想快速搭一个中小型促销系统Koa/Express 完全够用。NestJS 的依赖注入和模块体系虽然好但学习成本不低而且前期生成的代码有一大半用不上。2.3 Vue 生态里最值得用到的点Vue 3 的 Composition API 配合script setup写业务代码非常舒服。几个高频搜索词里的点我都实际用到了路由Vue Router 4 的动态路由和路由守卫。例如秒杀页面需要在活动开始前显示倒计时、活动结束后禁止购买我就是在路由守卫里校验了活动状态防止用户直接通过 URL 进入已经结束的活动。插槽商品卡片、活动卡片这类组件不同页面需要不同的底部操作区。我用 slot 把“加入购物车”“立即购买”“开团”这些按钮交给父组件决定组件本体只负责展示。这就是 slot 最大的价值避免写一堆v-if。样式处理直接用 scoped CSS加:deep()调整子组件内部样式尤其是覆盖 Element Plus 默认样式时很常用。如果你现在还在看 Vue 入门教程我的建议是别在 Options API 上花太多时间直接看 Composition API。因为这个项目里所有复杂逻辑倒计时、轮询、库存校验都是靠ref/reactive/watch组织起来的。3. 数据库与库存扣减秒杀不超卖的关键3.1 核心表结构我设计表结构的时候没有把“团购”“秒杀”分开建一堆表而是统一抽象成活动表。这样管理后台的活动列表非常好扩展后续如果要做“满减”活动也不用改表。表名关键字段说明usersid, nickname, phone, password_hash用户表flowersid, name, image, price, stock, detail鲜花商品表库存是总库存activitiesid, type, title, status, start_time, end_time活动表type 区分 groupon/seckillactivity_itemsid, activity_id, flower_id, activity_price, group_goal, group_current, seckill_stock活动商品表存活动和商品的关联ordersid, user_id, order_no, activity_id, status, total_amount, created_at订单主表order_itemsid, order_id, flower_id, quantity, price订单明细表grouponsid, activity_id, order_id, leader_user_id, status, expire_time拼团表记录开团和参团状态关键在于activity_items表里既有group_current已成团人数又有seckill_stock秒杀独立库存。为什么秒杀库存不直接用flowers.stock因为秒杀活动经常会单独备一批货和普通售卖库存分开如果共用一个字段活动结束后很难做剩余库存回补。3.2 超卖是怎么发生的秒杀系统最常见的 bug 就是超卖。比如库存只有 1 件1000 个人同时发起请求常规操作是先查库存SELECT stock FROM flowers WHERE id 1;看到结果是 1判断大于 0然后执行UPDATE flowers SET stock stock - 1 WHERE id 1;问题是第一步的 SELECT 在多个请求之间不是隔离的。在隔离级别为可重复读的 MySQL 中两个请求可能同时读到 1然后都去执行 UPDATE最终导致 stock 变成负数。更隐蔽的是如果订单创建和库存扣减不在同一个事务里库存扣了但订单创建失败就会出现“扣了库存但没订单”的情况。3.3 我的处理方案Redis 预减库存 数据库乐观锁我的方案分了多层。第一层活动开始前把秒杀库存预热到 Redisconst redis new Redis({ host: 127.0.0.1, port: 6379 }); await redis.set(seckill:stock:${activityItemId}, seckillStock);第二层请求到达时用 Lua 脚本原子地“检查库存 扣减库存”if redis.call(get, KEYS[1]) and tonumber(redis.call(get, KEYS[1])) 0 then return redis.call(decr, KEYS[1]) else return -1 endLua 脚本在 Redis 里是原子执行的不会出现两个请求同时判断通过的情况。这一步把数据库的并发压力全部转移到了内存操作。第三层拿到 Redis 扣减成功的结果后我把“下单任务”丢进消息队列而不是直接写数据库。消费者再从队列里取出任务在 MySQL 事务中执行“插入订单 扣减数据库库存 更新活动参与人数”。数据库扣减用乐观锁UPDATE activity_items SET seckill_stock seckill_stock - 1 WHERE id ? AND seckill_stock 0如果影响行数为 0说明数据库库存已经被扣完这时候要补偿 Redis把扣掉的库存加回来同时终止这次下单。这样设计的核心原因是数据库热点行的 UPDATE 是串行的如果让秒杀请求直接打数据库数据库的并发能力很快就到瓶颈。而 Redis 的 QPS 很高能先挡住大多数请求只有真正抢到资格的请求才会进入队列继续处理。至于最终一致性Redis 预减和数据库扣减之间可能有短暂的不一致但我们有补偿机制而且秒杀场景用户能接受“提交成功等待结果”这种异步反馈。4. 后端核心逻辑从限流到异步下单4.1 接口防刷与限流秒杀接口一旦上线就会有大量脚本去刷。所以我在/api/seckill/:id这个接口前挂了两层限制。第一层是用户维度限购。Redis 里存一个seckill:user:${userId}:${activityItemId}的 key活动期间有效。用户第一次抢购时写入过期时间设成活动结束时间。如果这个 key 存在直接返回“已参与过活动”。第二层是接口整体限流。这里用了一个非常简单的 Redis 计数器 滑动窗口思路核心思想是每分钟最多处理一定数量的请求超过就拒绝const key seckill:rate:${Date.now() - (Date.now() % 60000)}; const count await redis.incr(key); if (count 1) { await redis.expire(key, 60); } if (count 200) { ctx.status 429; ctx.body { message: 当前请求人数过多请稍后再试 }; return; }这段代码的意思是每分钟最多 200 个请求超过就返回 429。实际项目里我会把阈值做成配置并针对不同接口设置不同限流规则。比如普通商品列表接口限制松一点秒杀接口限制紧一点。4.2 Redis 预减库存 消息队列异步下单下单主流程我在前面提过这里给出可运行的简化版const Queue require(bullmq).Queue; const orderQueue new Queue(seckill-order, { connection: redisConfig }); router.post(/api/seckill/:id, async ctx { const { id: activityItemId } ctx.params; const userId ctx.state.user.id; // 1. 活动校验 const activity await findActivityByItemId(activityItemId); if (!activity || activity.status ! on) { ctx.throw(400, 活动不存在或未开始); } // 2. Redis 预减库存Lua 脚本 const stock await redisSeckillDecr(activityItemId); if (stock 0) { ctx.throw(400, 手慢了秒杀已结束); } // 3. 异步任务入队 await orderQueue.add(createSeckillOrder, { userId, activityItemId, activityId: activity.id, }); ctx.body { message: 排队中请稍后查询结果, queue: true }; });这里有个点容易被忽略Redis 扣减成功之后如果进程在入队之前崩溃库存就白白丢了。所以实际操作中我会在 Lua 脚本里同时记录一份“预扣记录”消费者处理完订单后删除记录如果任务超时未消费通过定时任务把库存回补。这一块是保证最终一致性的兜底逻辑。消费者端的核心逻辑大致是const Worker require(bullmq).Worker; const worker new Worker(seckill-order, async job { const { userId, activityItemId, activityId } job.data; const connection await mysql.getConnection(); await connection.beginTransaction(); try { const result await connection.execute( UPDATE activity_items SET seckill_stock seckill_stock - 1 WHERE id ? AND seckill_stock 0, [activityItemId] ); if (result[0].affectedRows 0) { // 数据库库存不足补偿 Redis await redis.incr(seckill:stock:${activityItemId}); throw new Error(out of stock); } await connection.execute( INSERT INTO orders (user_id, activity_id, order_no, status) VALUES (?, ?, ?, ?), [userId, activityId, generateOrderNo(), pending] ); await connection.commit(); } catch (e) { await connection.rollback(); throw e; } finally { connection.release(); } }, { connection: redisConfig });为什么不用同步下单因为秒杀瞬间请求量很大每个请求都去开数据库事务、查库存、插订单连接池很快会耗尽。用队列把请求缓冲起来下游按固定速度消费系统就变成“削峰填谷”的模式。用户得到的是“排队中”前端再通过轮询或 WebSocket 查询订单结果体验完全能接受。4.3 团购与秒杀的处理差异团购活动的核心不是瞬时并发而是“成团”的状态流转。所以我没有用秒杀那套限流逻辑而是单独设计了拼团服务开团用户选择团购活动商品创建一个groupons记录状态为pending同时生成一个待支付订单。这里要注意成团有效期一般 24 小时过期未成团自动退款。参团另一个用户进入该团支付后group_current 1。当group_current group_goal时把团状态改成success并更新所有参与订单的状态。过期处理写一个定时任务每分钟扫一次expire_time NOW()且状态为pending的团把相关订单标记为失败退款原路返回。对比下来秒杀要解决的问题是“如何挡掉大量请求”团购要解决的问题是“如何管理一个异步的社会化状态”。这两种业务形态放在同一个系统里正好对应了两套后端设计思路。5. Vue 前端实现页面、状态与倒计时5.1 路由设计动态路由与懒加载前端最开始只用了一个路由文件后来页面多了就拆成两个userRoutes和adminRoutes。用户端路由示例import { createRouter, createWebHistory } from vue-router; const routes [ { path: /, name: home, component: () import(/views/Home.vue), meta: { title: 首页 } }, { path: /seckill/:id, name: seckill-detail, component: () import(/views/SeckillDetail.vue), meta: { title: 秒杀详情 } }, { path: /groupon/:id, name: groupon-detail, component: () import(/views/GrouponDetail.vue), meta: { title: 团购详情 } }, { path: /cart, name: cart, component: () import(/views/Cart.vue), meta: { requiresAuth: true } } ];动态路由的:id就是活动商品的 ID。这里有个经验不要在组件里只依赖route.params.id因为同一个组件从活动列表进入和从详情页刷新进入时参数可能不同。我会在组件的watch里监听route.params参数变化时重新拉取数据而不是依赖组件级缓存。5.2 倒计时组件永远以服务器时间为准秒杀页面倒计时是最重要的交互。如果直接用new Date()获取本地时间用户把系统时间改快/改慢倒计时就会错乱。所以我在进入页面时先请求后端接口const { data } await axios.get(/api/common/server-time); const serverTime data.timestamp * 1000;然后每次setInterval更新时都基于serverTime手动增加偏移量而不是重新读取本地时间。这样即使本地时间被修改只要浏览器不刷新倒计时依然准确。实现一个useCountdown函数export function useCountdown(targetTime) { const remain ref(0); let timer null; let baseServerTime Date.now(); const tick () { const now Math.round((Date.now() - baseServerTime) (globalServerTime || Date.now())); remain.value targetTime - now; if (remain.value 0) { clearInterval(timer); } }; const start () { tick(); timer setInterval(tick, 1000); }; onUnmounted(() clearInterval(timer)); return { remain, start }; }这里为了简洁我简写了实际逻辑但核心思想就是“服务器时间 已过的相对时间”来替代本地时间。真正项目里后端接口返回的活动结束时间也是计算好的时间戳前端不需要做任何时区换算。5.3 用 Pinia 管理购物车与订单状态购物车如果只是放在组件内部页面一刷新数据就丢了。我选择用 Pinia把购物车和订单状态都收到 store 里import { defineStore } from pinia; export const useCartStore defineStore(cart, { state: () ({ items: [] }), actions: { addItem(flower, quantity) { const index this.items.findIndex(i i.id flower.id); if (index 0) { this.items[index].quantity quantity; } else { this.items.push({ ...flower, quantity }); } }, async submitOrder() { const { data } await axios.post(/api/orders, { items: this.items }); this.items []; return data; } } });下单成功后清空购物车这是一个很容易忽略的细节。如果不清空用户返回购物车时看到已经支付的商品还在里面会以为下单失败反而重复提交。前面我提到过 Vue 插槽这里再补一个实际场景活动卡片组件ActivityCard.vue的底部操作区根据活动类型不同可能是“去秒杀”“去拼团”“查看详情”。我就在组件里预留一个#footer插槽template div classactivity-card img :srcactivity.image / div classinfo{{ activity.title }}/div slot namefooter :activityactivity/slot /div /template父组件使用ActivityCard v-foritem in activities :activityitem template #footer{ activity } button v-ifactivity.type seckill clickgoSeckill(activity) 去秒杀 /button button v-else-ifactivity.type groupon clickgoGroupon(activity) 去拼团 /button /template /ActivityCard这样一来卡片本身不需要关心按钮逻辑自己只负责展示新增活动类型时也不用改卡片组件。6. 从开发到部署Node.js 环境配置和上线踩坑6.1 Node.js 版本选择和安装很多人在搜索 node.js 安装教程其实是卡在版本选择上。我推荐用nvm管理 Node.js 版本不要直接用 apt 或 yum 安装。因为系统源里的 Node 版本往往很旧而且不同项目可能需要不同版本。在 CentOS 7.9 上我踩过一个坑Node.js 官网最新版本可能要求 glibc 2.28而 CentOS 7.9 的 glibc 是 2.17安装后执行node -v直接报错。所以部署生产环境时我选择的是 Node.js 18 LTS正好兼容。安装命令大致是curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash source ~/.bashrc nvm install 18.20.4 nvm use 18npm 下载依赖慢的问题可以在用户目录创建.npmrc写入registryhttps://registry.npmmirror.com如果你需要部署在老旧服务器上先查一下系统的 glibc 版本ldd --version | head -n1版本低于 2.28 的话装 Node 20 以上大概率会遇到启动失败的问题。这不是代码问题是运行环境不兼容。6.2 前后端分离部署Nginx 反向代理开发环境我用 Vite 的 proxy 转发接口// vite.config.js server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } }生产环境则把前端构建产物放到 Nginx 里所有/api请求转发到 Node 服务。nginx 配置核心段server { listen 80; server_name market.example.com; root /var/www/flower-market/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里try_files $uri $uri/ /index.html;是必须的否则 Vue Router 启用 history 模式后用户直接访问/seckill/123刷新页面会 404。如果不想让所有未知路由都返回 index.html可以只对具体路径做try_files但大多数场景下上面这段就够了。6.3 进程守护与日志直接用node app.js启动的话终端一关服务就没了。我用pm2npm install -g pm2 pm2 start ecosystem.config.jsecosystem.config.js可以配成module.exports { apps: [{ name: flower-market-server, script: app.js, instances: max, exec_mode: cluster, env: { NODE_ENV: production, PORT: 3000 }, max_memory_restart: 400M }] };instances: max会让 PM2 按 CPU 核数开启多个进程相当于负载均衡。不过这里要注意如果多个进程都去消费同一个秒杀订单队列BullMQ 的 Worker 是可以并发处理的没有问题但如果多个进程都去写同一个本地文件就会有冲突。所以日志务必走 PM2 的日志存储或者使用 pino 输出到文件。有一次我排查线上问题发现用户反馈下单后订单状态一直不更新。后来看 PM2 日志才发现 Redis 连接因为长时间空闲被服务器断开了BullMQ 的 Worker 一直在重连订单消息积压。从那以后我就在 Redis 客户端配置里加了keepAlive和自动重连并且给队列消费加了一层异常重试机制。这个问题的排查路径就是先看 PM2 日志再查 Redis 连接状态最后检查队列积压数量。这三个地方是秒杀系统上线后最需要盯的监控点。最后再分享一个实际经验秒杀活动上线前一定要压测。我当时用一个简单的脚本模拟 500 个并发请求打秒杀接口发现如果没有限流数据库连接池会被打满接口响应时间从几十毫秒直接飙到几秒。后来加上限流和队列P95 响应时间稳定在 300ms 以内。压测不是可有可无的步骤而是秒杀系统能否上线的及格线。
返回列表