☰
从零搭建电商平台实战:技术选型、核心模块与避坑指南
2026/10/29 12:02:41 网站建设 项目流程

电商平台搭建这个话题,说它烂大街也不为过,但真正从零走一遍完整流程、把每个环节的坑都踩一遍的人其实没那么多。我前后参与过三个不同量级的电商项目,从日订单几十单的小型独立站,到日活过万的中型平台都摸过一遍,今天借这个标题把整套搭建思路和实操细节完整梳理出来。所谓“码市实战”,重点在实战两个字——不是讲概念、不是画架构图,而是把从需求梳理、技术选型、核心模块开发、联调测试到上线运维的完整链路拆开揉碎讲清楚。这篇文章适合有一定开发基础、想独立完成一个电商平台搭建的开发者,也适合技术负责人用来对照检查自己项目里可能遗漏的环节。全文会涉及大量具体的参数选择、代码结构、配置方案和踩坑记录,你可以直接拿去参考复现。

1. 项目整体设计与技术选型思路

1.1 电商平台的核心需求拆解

动手写代码之前,先把需求理清楚,这一步偷懒后面会加倍还回来。电商平台看起来复杂,但拆开来看核心链路其实就几条:用户注册登录、商品浏览与搜索、购物车管理、订单创建与支付、库存扣减、物流状态跟踪、售后处理。每一条链路背后都牵扯到不同的技术关注点。

我习惯用“用户视角走一遍”的方法来梳理需求。假设一个用户从打开首页到完成购买,他会经历:进入首页看到推荐商品、点击商品进入详情页、选择规格加入购物车、进入购物车勾选商品、点击结算填写收货地址、选择支付方式完成付款、查看订单状态等待发货、收到货后确认收货或申请售后。这条路径上每一个节点都需要对应的后端接口和前端页面支撑,同时还要考虑异常分支——库存不足怎么办、支付超时怎么处理、地址填写错误能否修改、发货后用户想退款走什么流程。

把这些链路全部列出来之后,再按优先级分级。核心链路(浏览、加购、下单、支付)必须第一版就打通,次要链路(评价、收藏、优惠券)可以放到第二期迭代。这个分级很重要,因为从零搭建最怕的就是一开始铺太大,结果每个模块都半成品,项目迟迟上不了线。

注意:需求梳理阶段一定要把“非功能性需求”也写进去,比如预计日订单量、并发用户数、图片存储量级、是否需要多端适配(小程序/H5/PC)。这些数字直接决定后面的技术选型和服务器配置,漏掉的话后期扩容会非常被动。

1.2 技术栈选型的取舍逻辑

技术选型没有绝对的对错,关键看团队规模、项目周期和后期维护成本。我见过用微服务架构做日订单不到一百的小项目,也见过单体应用撑起日订单几万的场景。选型的核心原则是:在满足当前和可预见未来需求的前提下,选团队最熟悉、运维成本最低的方案。

后端语言方面,如果团队有Java背景,Spring Boot生态成熟、招人容易、社区资源丰富,是稳妥选择;如果追求开发效率、团队规模小,Node.js配合NestJS或Egg.js能快速出活;Python的Django/FastAPI适合快速原型验证。我个人在中小型电商项目中更倾向Node.js技术栈,原因是前后端语言统一、JSON处理天然顺手、异步IO模型对电商这种IO密集型场景很友好。

数据库方面,MySQL基本是标配,商品、订单、用户这些核心数据用关系型数据库保证事务一致性。Redis用来做缓存和会话管理,购物车数据、商品热点数据、秒杀库存都适合放Redis。商品图片和视频用对象存储,不要直接存服务器本地磁盘,后期迁移和CDN加速都麻烦。

前端方面,PC端用Vue3或React配合组件库(Element Plus/Ant Design)能快速搭建管理后台和用户端页面;移动端H5用Vant或NutUI;如果需要小程序端,用Taro或uni-app做跨端适配。这里有个经验:第一版不要追求多端统一,先把H5跑通,小程序端后续用跨端框架迁移成本并不高。

层级选型方案选择理由备选方案
后端框架NestJS (Node.js)模块化清晰、TypeScript友好、开发效率高Spring Boot / Django
关系数据库MySQL 8.0事务支持完善、生态成熟、运维人才多PostgreSQL
缓存Redis 7.x高性能读写、数据结构丰富Memcached
对象存储云厂商OSS免运维、自带CDN、成本可控MinIO自建
前端框架Vue3 + Vite上手快、生态好、构建速度快React + Next.js
部署方式Docker + 云服务器环境一致、迁移方便裸机部署

1.3 系统架构的分层设计

从零搭建最忌讳一上来就搞微服务。我的建议是:第一版用模块化单体架构,把代码按业务域分模块组织,模块之间通过明确的接口调用,为后续拆分留好边界。等订单量真正上来了、某个模块成为瓶颈了,再针对性拆出去。

具体分层是这样的:最上层是接入层,负责负载均衡和静态资源分发;中间是应用层,按业务域拆成用户模块、商品模块、订单模块、支付模块、营销模块;底层是数据层,包括MySQL主从、Redis集群、对象存储。应用层内部再分Controller(接口层)、Service(业务逻辑层)、Repository(数据访问层),每层职责单一,方便测试和维护。

模块之间的调用关系要提前画清楚。比如订单模块创建订单时需要调用商品模块查库存、调用用户模块查收货地址、调用营销模块算优惠价格。这些跨模块调用在单体架构里就是函数调用,但接口定义要设计好,参数和返回值格式统一,将来拆成RPC调用时改动量最小。

实操心得:模块化单体的关键不是代码放在不同文件夹里就完事了,而是要在代码层面强制约束跨模块访问——商品模块的Service不能被订单模块直接import,必须通过定义好的接口层调用。可以用ESLint规则或项目规范来约束,否则写着写着就变成一锅粥了。

2. 核心模块的细节拆解与实操要点

2.1 商品模块:SPU与SKU的数据模型设计

商品模块是整个电商平台的地基,数据模型设计不好,后面购物车、订单、库存全都会跟着乱。核心概念是SPU(Standard Product Unit,标准产品单元)和SKU(Stock Keeping Unit,库存量单位)。用大白话解释:SPU是“iPhone 15”这个概念,SKU是“iPhone 15 128G 黑色”这个具体可售单元。

数据库表设计上,spu表存商品通用信息(标题、描述、主图、分类ID、品牌ID),sku表存具体规格信息(关联SPU ID、规格组合JSON、价格、库存、SKU图片)。规格项单独建表(比如“颜色”“存储容量”),规格值也单独建表,SKU和规格值之间用关联表维护多对多关系。这样设计的好处是规格可以灵活扩展,新增一个规格维度不需要改表结构。

-- SPU表核心字段 CREATE TABLE `spu` ( `id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL COMMENT '商品标题', `category_id` bigint NOT NULL COMMENT '分类ID', `brand_id` bigint DEFAULT NULL COMMENT '品牌ID', `main_image` varchar(500) NOT NULL COMMENT '主图URL', `detail_html` text COMMENT '详情富文本', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0下架 1上架', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- SKU表核心字段 CREATE TABLE `sku` ( `id` bigint NOT NULL AUTO_INCREMENT, `spu_id` bigint NOT NULL COMMENT '关联SPU', `spec_json` json NOT NULL COMMENT '规格组合,如{"颜色":"黑色","容量":"128G"}', `price` decimal(10,2) NOT NULL COMMENT '售价', `stock` int NOT NULL DEFAULT 0 COMMENT '库存', `sku_image` varchar(500) DEFAULT NULL COMMENT 'SKU图片', `status` tinyint NOT NULL DEFAULT 1, PRIMARY KEY (`id`), KEY `idx_spu` (`spu_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

价格字段用decimal而不是float,这是铁律。浮点数在金额计算时会出现精度丢失,0.1+0.2不等于0.3这种事在电商场景里是致命的。库存字段用int,扣减时用乐观锁或Redis预扣,后面订单模块会详细讲。

商品列表页的查询是高频操作,除了常规的分页查询,还要考虑分类筛选、价格区间筛选、关键词搜索、排序(销量/价格/上新时间)。这些查询条件组合起来SQL会变得很复杂,我的做法是把商品列表的查询结果缓存到Redis,缓存key由查询条件hash生成,设置较短的过期时间(比如60秒),既能扛住流量又不会导致数据太旧。

2.2 购物车模块:登录态与游客态的统一处理

购物车看起来简单,但登录用户和未登录游客的处理逻辑差异很大,设计时要把两种情况统一考虑。游客购物车数据存在前端本地(localStorage或Cookie),登录后需要把本地购物车合并到服务端购物车。合并逻辑要处理同一SKU的数量叠加、超出库存上限的截断、已下架商品的过滤。

服务端购物车表设计上,用user_id + sku_id作为唯一索引,数量字段记录购买数量。每次加购时先查是否已存在,存在则更新数量,不存在则插入。这里要注意并发问题:同一用户在多端同时操作购物车时,可能出现重复插入,用INSERT ... ON DUPLICATE KEY UPDATE可以原子化处理。

INSERT INTO cart (user_id, sku_id, quantity, selected) VALUES (?, ?, ?, 1) ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity);

购物车还有一个容易被忽略的细节:商品价格和库存是实时变化的,购物车列表展示时需要实时查询最新的价格和库存状态,不能直接读购物车表里存的快照。我的做法是购物车表只存sku_id和数量,展示时批量查SKU表拿最新数据,同时在返回结果里标记商品状态(正常/已下架/库存不足),前端根据状态做不同的交互提示。

踩过的坑:早期版本购物车直接存了加购时的价格,结果商品调价后用户看到的还是旧价格,下单时价格对不上引发客诉。后来改成实时查询,虽然多了一次数据库查询,但避免了价格不一致的问题。如果对性能有极致要求,可以在Redis里缓存SKU价格,设置合理的过期时间。

2.3 订单模块:状态机设计与库存扣减策略

订单模块是整个平台最核心也最容易出问题的部分。先说订单状态机,一个订单从创建到完结会经历多个状态:待支付、已支付、待发货、已发货、已收货、已完成、已取消、退款中、已退款。状态之间的流转必须有严格的规则约束,不能随意跳转。

我用一个状态流转表来管理这些规则,每个状态定义允许的下一步操作和触发条件。比如“待支付”只能流转到“已支付”(支付回调触发)或“已取消”(超时未支付或用户主动取消);“已支付”只能流转到“待发货”(系统自动)或“退款中”(用户申请退款)。代码里用状态机模式实现,每次状态变更前校验当前状态是否允许该操作,防止并发场景下的状态错乱。

库存扣减是订单模块的重头戏。常见的方案有三种:下单减库存、支付减库存、预扣库存。下单减库存的优点是简单直接,缺点是恶意用户大量下单不支付会导致库存被占死;支付减库存的优点是库存不会被无效占用,缺点是高并发支付时可能出现超卖;预扣库存是折中方案,下单时在Redis预扣,支付成功后真正扣减数据库库存,超时未支付则释放预扣。

我实际项目中用的是预扣方案,具体流程是:用户提交订单时,先在Redis里用DECR命令扣减对应SKU的可用库存,扣减成功才创建订单,订单状态为待支付并设置超时时间(通常15-30分钟);支付回调成功后,扣减MySQL里的真实库存并清除Redis预扣记录;超时未支付则由定时任务扫描,回滚Redis预扣库存并取消订单。

// Redis预扣库存的Lua脚本,保证原子性 const deductStockScript = ` local stock = redis.call('GET', KEYS[1]) if not stock then return -1 end if tonumber(stock) < tonumber(ARGV[1]) then return -2 end redis.call('DECRBY', KEYS[1], ARGV[1]) return 1 `;

用Lua脚本的原因是DECR和判断库存是否充足这两个操作必须是原子的,否则在并发场景下会出现库存扣成负数的情况。这个脚本返回1表示扣减成功,-1表示库存key不存在(需要从数据库加载),-2表示库存不足。

订单号生成也有讲究。不要用自增ID直接当订单号暴露给用户,一来容易泄露业务量,二来分库分表后自增ID会冲突。我用的方案是“时间戳+用户ID后四位+随机数”拼接,比如20240520153045+8823+6721,长度可控且基本不会重复。如果需要严格保证唯一性,可以用雪花算法生成分布式ID。

2.4 支付模块:回调处理与幂等性保障

支付模块对接第三方支付渠道,核心难点不在发起支付,而在回调处理。第三方支付成功后异步通知我们的服务器,这个通知可能重复发送、可能延迟到达、可能因为网络问题丢失。所以回调接口必须做到幂等——同一笔支付通知处理多次,结果和一次一样。

幂等的实现方式:用支付流水号(第三方返回的transaction_id)作为唯一键,回调处理前先查是否已处理过,已处理直接返回成功。更严谨的做法是在数据库里给支付流水号加唯一索引,插入时如果冲突就说明重复通知,直接返回成功即可。

CREATE TABLE `payment_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '平台订单号', `transaction_id` varchar(128) NOT NULL COMMENT '第三方流水号', `amount` decimal(10,2) NOT NULL, `status` tinyint NOT NULL COMMENT '0处理中 1成功 2失败', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_transaction` (`transaction_id`), KEY `idx_order` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

回调处理的完整流程是:接收通知→验签(验证请求确实来自支付渠道)→查重(transaction_id是否已存在)→更新支付记录状态→更新订单状态→扣减真实库存→返回成功响应。每一步都要考虑异常情况,比如更新订单状态失败怎么办?我的做法是把整个处理逻辑放在一个数据库事务里,任何一步失败都回滚,然后返回失败响应让支付渠道重试。

重要提示:回调接口的响应格式要严格按照支付渠道文档来,返回成功标识后渠道就不再重试了。如果处理逻辑还没完成就返回成功,会导致订单状态和支付状态不一致。所以一定要确保业务逻辑全部执行完毕再返回成功。

另外,除了异步回调,还要提供一个主动查询接口作为兜底。定时任务扫描“待支付”状态超过一定时间的订单,主动向支付渠道查询支付结果,防止回调丢失导致用户付了钱但订单没更新。

3. 完整实操流程与关键环节实现

3.1 开发环境搭建与项目初始化

先说环境准备。Node.js版本建议用18 LTS或20 LTS,用nvm管理多版本。包管理器用pnpm,比npm快且磁盘占用小。数据库MySQL 8.0用Docker跑最省事,一条命令拉起,数据卷挂载到本地目录方便备份。

# 拉起MySQL和Redis docker run -d --name mysql-ecom \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=yourpassword \ -e MYSQL_DATABASE=ecommerce \ -v ./data/mysql:/var/lib/mysql \ mysql:8.0 docker run -d --name redis-ecom \ -p 6379:6379 \ -v ./data/redis:/data \ redis:7-alpine redis-server --appendonly yes

项目初始化用NestJS CLI,按模块生成代码骨架。目录结构按业务域划分:src/modules/user、src/modules/product、src/modules/order、src/modules/payment、src/modules/cart,每个模块内部再分controller、service、entity、dto。公共的工具函数、拦截器、过滤器放在src/common下。

npx @nestjs/cli new ecommerce-api cd ecommerce-api pnpm add @nestjs/typeorm typeorm mysql2 @nestjs/config pnpm add ioredis @nestjs-modules/ioredis pnpm add class-validator class-transformer

数据库连接配置放在.env文件里,用@nestjs/config加载。连接池大小根据服务器配置来定,一般设置10-20个连接足够中小型项目使用。TypeORM的synchronize选项在开发环境可以设为true自动同步表结构,生产环境必须设为false,用migration管理表结构变更。

3.2 用户认证与权限体系落地

用户认证用JWT方案。注册时密码用bcrypt加密存储(saltRounds设为10),登录成功后签发accessToken(有效期2小时)和refreshToken(有效期7天)。accessToken放在响应体里由前端存储,refreshToken建议放在httpOnly Cookie里防止XSS窃取。

权限体系分两层:接口级权限和资源级权限。接口级权限用Guard实现,在Controller方法上标注所需角色(如@Roles('admin')),Guard里解析JWT拿到用户角色进行比对。资源级权限在Service层判断,比如用户只能查看自己的订单,查询时强制带上user_id条件。

// JWT认证Guard核心逻辑 @Injectable() export class JwtAuthGuard implements CanActivate { constructor(private jwtService: JwtService) {} canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new UnauthorizedException('未登录'); try { const payload = this.jwtService.verify(token); request.user = payload; return true; } catch { throw new UnauthorizedException('登录已过期'); } } }

实操心得:JWT的secret不要硬编码在代码里,放在环境变量中,且生产环境和测试环境用不同的secret。另外accessToken过期时间不要太长,2小时是比较合理的值,配合refreshToken自动续期,用户体验和安全性都能兼顾。

3.3 商品搜索与列表接口的性能优化

商品列表接口是流量最大的接口之一,优化不到位很容易成为瓶颈。基础查询用TypeORM的QueryBuilder拼条件,分页用limit/offset。但当商品量超过十万级时,offset分页会越来越慢,因为数据库需要扫描前面所有行。这时候要改用游标分页(基于上一页最后一条记录的ID),查询条件加上id > lastId,性能稳定在毫秒级。

搜索功能如果要求不高,用MySQL的LIKE查询配合全文索引就能撑一阵子。但商品量大了之后,建议引入Elasticsearch做搜索引擎,支持分词、拼音搜索、同义词、相关性排序。ES的数据同步用消息队列解耦,商品增删改时发消息,消费者异步更新ES索引。

// 商品列表查询核心逻辑(游标分页版) async function getProductList(params) { const { categoryId, keyword, lastId, pageSize = 20 } = params; const query = this.productRepo.createQueryBuilder('p') .where('p.status = :status', { status: 1 }) .orderBy('p.id', 'DESC') .take(pageSize + 1); if (categoryId) query.andWhere('p.category_id = :categoryId', { categoryId }); if (keyword) query.andWhere('p.title LIKE :kw', { kw: `%${keyword}%` }); if (lastId) query.andWhere('p.id < :lastId', { lastId }); const items = await query.getMany(); const hasMore = items.length > pageSize; if (hasMore) items.pop(); return { items, hasMore, nextCursor: hasMore ? items[items.length - 1].id : null }; }

列表页返回的数据要精简,只返回列表展示需要的字段(ID、标题、主图、价格、销量),详情字段不要带出来。图片URL返回时拼接CDN域名和缩略图参数,比如?x-oss-process=image/resize,w_300,减少传输体积。

3.4 下单流程的完整实现与事务控制

下单是整个链路里最复杂的环节,涉及库存校验、优惠计算、订单创建、预扣库存、清空购物车等多个操作。这些操作必须保证一致性,核心用数据库事务包裹,Redis操作放在事务外做补偿。

完整流程拆解:第一步校验参数(收货地址、商品列表、发票信息);第二步查询SKU最新价格和库存,计算商品总价;第三步计算优惠(优惠券、满减、会员折扣),得出实付金额;第四步Redis预扣库存,任一SKU扣减失败则回滚已扣减的;第五步数据库事务内创建订单主表和订单明细表,更新优惠券状态;第六步清空购物车中已下单的商品;第七步返回订单号,前端跳转支付页。

async createOrder(userId: number, dto: CreateOrderDto) { // 1. 查询SKU信息并校验 const skus = await this.skuService.findByIds(dto.items.map(i => i.skuId)); const skuMap = new Map(skus.map(s => [s.id, s])); // 2. 计算金额 let totalAmount = 0; for (const item of dto.items) { const sku = skuMap.get(item.skuId); if (!sku || sku.status !== 1) throw new BizException('商品已下架'); totalAmount += sku.price * item.quantity; } // 3. 计算优惠 const discount = await this.couponService.calculate(userId, dto.couponId, totalAmount); const payAmount = totalAmount - discount; // 4. Redis预扣库存 const deducted = []; try { for (const item of dto.items) { const ok = await this.stockService.preDeduct(item.skuId, item.quantity); if (!ok) throw new BizException('库存不足'); deducted.push(item); } } catch (e) { await this.stockService.rollback(deducted); throw e; } // 5. 事务创建订单 const order = await this.dataSource.transaction(async (manager) => { const orderNo = this.generateOrderNo(userId); const order = await manager.save(Order, { orderNo, userId, totalAmount, payAmount, status: OrderStatus.PENDING_PAY }); await manager.save(OrderItem, dto.items.map(i => ({ orderId: order.id, skuId: i.skuId, quantity: i.quantity, price: skuMap.get(i.skuId).price }))); if (dto.couponId) await this.couponService.markUsed(dto.couponId, manager); return order; }); // 6. 清空购物车 await this.cartService.removeItems(userId, dto.items.map(i => i.skuId)); return { orderNo: order.orderNo, payAmount }; }

这里有个细节:Redis预扣库存和数据库事务不在同一个原子操作里,如果数据库事务失败,需要手动回滚Redis预扣。代码里用try-catch包裹,事务抛异常时调用rollback释放预扣库存。虽然极端情况下仍可能不一致(比如回滚时Redis挂了),但配合定时对账任务可以兜底修复。

3.5 支付对接与回调联调实录

支付对接以常见的聚合支付为例,流程是:前端调起支付→后端生成支付参数(含签名)→前端拉起支付收银台→用户完成支付→支付渠道异步回调后端→后端处理回调更新订单状态。

签名是支付对接里最容易出错的地方。不同渠道的签名算法不同(MD5、HMAC-SHA256、RSA),参数排序规则也不同。我的经验是:先把渠道文档里的签名示例跑通,用固定的参数和密钥算出签名,和文档给的签名值比对,一致了再接入业务逻辑。这一步花半小时能省后面几小时的排查时间。

回调接口的联调建议用渠道提供的沙箱环境,沙箱环境可以模拟支付成功、支付失败、退款等各种场景。联调时把回调的原始请求体打印到日志里,方便排查签名验证失败的问题。回调接口的URL必须是公网可访问的,本地开发时可以用内网穿透工具临时映射(注意仅用于开发调试)。

// 支付回调处理核心逻辑 async handleCallback(channel: string, body: any, headers: any) { // 1. 验签 const valid = this.payService.verifySign(channel, body, headers); if (!valid) return { code: 'FAIL', message: '签名错误' }; // 2. 幂等检查 const exists = await this.paymentRepo.findOne({ where: { transactionId: body.transaction_id } }); if (exists && exists.status === 1) return { code: 'SUCCESS' }; // 3. 事务处理 await this.dataSource.transaction(async (manager) => { await manager.save(PaymentRecord, { orderNo: body.out_trade_no, transactionId: body.transaction_id, amount: body.total_amount / 100, status: 1, }); await manager.update(Order, { orderNo: body.out_trade_no }, { status: OrderStatus.PAID, paidAt: new Date() }); await this.stockService.confirmDeduct(body.out_trade_no); }); return { code: 'SUCCESS' }; }

踩过的坑:回调接口返回的响应体格式一定要严格按文档来,有的渠道要求返回纯文本“SUCCESS”,有的要求返回JSON。格式不对渠道会认为通知失败,持续重试,导致日志里大量重复回调记录。另外回调接口不要做耗时操作,处理逻辑控制在3秒内完成,超时渠道也会判定失败。

4. 常见问题排查与避坑经验实录

4.1 高频问题速查表

实际开发和运维过程中遇到的问题五花八门,我把最常见的问题整理成速查表,方便对照排查。

问题现象可能原因排查方向解决方案
下单时提示库存不足但实际有库存Redis预扣库存与数据库库存不一致对比Redis库存key和DB库存值执行库存对账任务,修复差异数据
支付成功但订单状态未更新回调丢失或回调处理异常查支付渠道回调日志、查应用日志主动查询接口兜底,定时补偿
商品列表加载慢数据库查询未走索引或数据量过大EXPLAIN分析SQL执行计划加索引、改游标分页、加缓存
用户重复提交订单前端按钮未防抖或接口未做幂等查订单表是否有重复记录接口加防重token,订单号唯一索引
购物车数量异常并发加购导致数量覆盖查购物车操作日志用原子SQL更新,加分布式锁
图片加载慢未走CDN或图片未压缩查图片URL和响应头接入CDN,图片压缩转WebP

4.2 库存不一致的排查与修复

库存不一致是电商平台最头疼的问题之一,表现形式是前台显示有货但下单失败,或者超卖了但系统没拦住。根因通常是Redis预扣和数据库扣减之间的时序问题。

排查步骤:先查Redis里对应SKU的库存值,再查数据库sku表的stock字段,两者对比。如果Redis值小于DB值,说明有预扣未释放(通常是订单超时未支付但定时任务没跑或跑失败了);如果Redis值大于DB值,说明有扣减未同步(通常是支付成功后confirmDeduct失败)。

修复方案分两步:短期用对账脚本修复差异数据,脚本逻辑是以数据库库存为准,重新设置Redis库存值(注意要排除当前有效的预扣记录);长期优化是完善定时任务和补偿机制,订单超时取消时确保释放预扣,支付成功时确保扣减DB库存,所有异常操作记录到补偿表由定时任务重试。

// 库存对账脚本核心逻辑(伪代码) async function reconcileStock() { const skus = await skuRepo.find({ select: ['id', 'stock'] }); for (const sku of skus) { const redisStock = await redis.get(`stock:${sku.id}`); const pendingCount = await getPendingDeductCount(sku.id); // 有效预扣数量 const expectedRedis = sku.stock - pendingCount; if (Number(redisStock) !== expectedRedis) { await redis.set(`stock:${sku.id}`, expectedRedis); logger.warn(`库存修复 sku=${sku.id} redis=${redisStock} expected=${expectedRedis}`); } } }

实操心得:对账脚本不要频繁跑,建议每小时跑一次,且要在低峰期执行。跑之前先备份Redis库存数据,万一脚本逻辑有bug可以快速回滚。另外对账日志要保留至少一周,方便追溯问题。

4.3 订单超时未支付的定时任务设计

订单超时取消依赖定时任务,实现方式有几种:数据库轮询、Redis过期键监听、延迟队列。数据库轮询最简单,定时任务每分钟扫一次“待支付且创建时间超过30分钟”的订单,批量取消并释放库存。缺点是订单量大时扫描慢,需要加索引(status + created_at联合索引)。

Redis过期键监听(keyspace notification)实时性好,但有可靠性问题——Redis重启或网络抖动时通知可能丢失。延迟队列(如RabbitMQ的TTL+死信队列)可靠性高,但引入额外中间件增加运维成本。

我的选择是数据库轮询为主、主动查询支付状态为辅。轮询任务用分页批量处理,每批100条,处理完再取下一批,避免一次性加载大量数据。取消订单时先调支付渠道查询接口确认确实未支付,防止用户已付款但回调延迟导致误取消。

-- 订单表加联合索引支撑超时扫描 ALTER TABLE `order` ADD INDEX `idx_status_created` (`status`, `created_at`);

定时任务的执行频率设为每分钟一次,单次执行时间控制在30秒以内。如果订单量特别大,可以按订单ID取模分片,多个任务实例并行处理不同分片。

4.4 并发下单的防重与限流

大促场景下并发下单量激增,除了库存问题,还要防重复下单和接口限流。防重复下单用“防重token”机制:用户进入结算页时后端生成一个token返回给前端,提交订单时带上这个token,后端用Redis的SETNX判断token是否已使用,已使用则拒绝。token设置5分钟过期,防止用户长时间停留后token失效。

接口限流用令牌桶算法,按用户维度限流(比如每个用户每秒最多提交1次下单请求),按IP维度限流(防止恶意刷接口)。NestJS里可以用@nestjs/throttler模块快速实现,也可以自己用Redis实现更灵活的限流策略。

// 基于Redis的简单限流实现 async function checkRateLimit(key: string, limit: number, windowSec: number) { const current = await redis.incr(key); if (current === 1) await redis.expire(key, windowSec); return current <= limit; } // 调用:checkRateLimit(`order:${userId}`, 1, 1) 表示每秒最多1次

注意:限流阈值要根据实际业务调整,设太严会误伤正常用户,设太松起不到保护作用。建议先观察一段时间的正常流量峰值,再设定阈值为峰值的1.5倍左右。

4.5 上线前的检查清单

上线前把这份清单过一遍,能避免大部分低级事故。数据库方面:确认生产库表结构已通过migration执行、索引已创建、慢查询日志已开启。缓存方面:确认Redis连接池配置合理、热点key已设置过期时间、缓存穿透和雪崩的防护已到位。接口方面:确认所有接口有参数校验、有统一的异常处理、有请求日志。安全方面:确认敏感接口有权限校验、密码加密存储、SQL注入和XSS防护到位、HTTPS已配置。监控方面:确认应用日志、错误告警、接口耗时监控、服务器资源监控都已接入。

最后再分享一个小技巧:上线前用压测工具对核心接口做一轮基准测试,记录下单接口、商品列表接口的QPS和P99耗时,作为后续优化的基线。压测数据不用追求多高,能覆盖预计峰值的1.5倍即可,重点是发现明显的性能瓶颈。我在实际项目中靠这一步提前发现过商品列表接口缺少索引的问题,避免了上线后的事故。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询