电商类小程序的骨架其实很清楚:商品 → 购物车 → 订单 → 支付 → 履约。真正让开发者头疼的不是流程,而是三个具体问题:同一个商品有多种规格时购物车怎么存 、库存什么时候扣 、并发下单怎么不超卖 。
这篇文章按这三个问题展开,给出可落地的数据模型与关键代码。示例基于 Node.js + MySQL 8,思路可以直接迁移到其他技术栈。
一、SKU 模型:先把商品拆对
很多项目在这里埋下第一颗雷:把商品当成一个整体存,规格用字符串拼接,比如 "大杯/加冰/少糖"。后果是价格算不准、库存扣不准、退款算不清。
正确做法是三层模型:
-- 商品(SPU):只描述"是什么"
CREATE TABLE spu (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(120) NOT NULL,
category_id BIGINT NOT NULL,
status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 规格项(如"杯型""温度")
CREATE TABLE spec (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
spu_id BIGINT NOT NULL,
name VARCHAR(40) NOT NULL,
KEY idx_spu (spu_id)
);
-- 规格值(大杯/中杯、加冰/去冰)
CREATE TABLE spec_value (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
spec_id BIGINT NOT NULL,
value VARCHAR(40) NOT NULL,
sort INT NOT NULL DEFAULT 0,
KEY idx_spec (spec_id)
);
-- SKU:可售卖的最小单位,价格和库存都挂在这一层
CREATE TABLE sku (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
spu_id BIGINT NOT NULL,
spec_value_ids VARCHAR(200) NOT NULL COMMENT '规格值id,逗号分隔,如 3,7',
code VARCHAR(60) NOT NULL COMMENT '商家编码',
price_cents INT NOT NULL COMMENT '售价,分',
stock INT NOT NULL DEFAULT 0,
lock_stock INT NOT NULL DEFAULT 0 COMMENT '已锁定未付款',
UNIQUE KEY uk_code (code),
KEY idx_spu (spu_id)
);
为什么价格挂在 SKU 上 :同一款奶茶,大杯比中杯贵 3 元,加料再加 2 元。价格是 SKU 的属性,不是 SPU 的。SPU 层只放"展示价"(一般取最低 SKU 价),真正的成交价必须按 SKU 算。
为什么库存也要分 stock 和 lock_stock :下单未付款的库存要锁住,否则两个人同时下单会把同一件货卖两次。可售库存 = stock - lock_stock。
二、购物车:存 SKU 还是存"组合"
购物车的表结构很简单,坑在唯一键。
CREATE TABLE cart_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
quantity INT NOT NULL DEFAULT 1,
selected TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_sku (user_id, sku_id),
KEY idx_user (user_id)
);
uk_user_sku 是关键:同一个用户加同一个 SKU 两次,应该合并成一条并把数量累加,而不是插两条。这条唯一索引直接在数据库层面保证了这一点,应用层用 INSERT ... ON DUPLICATE KEY UPDATE 就能实现幂等加法:
async function addToCart(userId, skuId, quantity) {
if (quantity <= 0) throw new Error('数量必须大于 0');
await db.query(
`INSERT INTO cart_item (user_id, sku_id, quantity)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity)`,
[userId, skuId, quantity]
);
}
这里有一个容易被忽略的细节 :如果商品后来下架了、SKU 被删了,购物车里那条记录怎么处理?常见做法是保留记录但不允许勾选,结算时提示"该商品已下架",而不是直接删掉用户的数据。用户会记得自己加过什么,突然消失会让他怀疑系统。
加料怎么处理 :有一种做法是把"加料"也做成 SKU,通过组合生成新的 SKU(比如"大杯 + 珍珠"是一个 SKU)。SKU 数量会指数级增长,几十个组合很快变成几百个,维护成本极高。
更实用的做法是分成两层:主商品 SKU + 加料选项(独立的 sku_addon 表),购物车里存 sku_id 和一个加料 JSON 字段。结算时价格 = SKU 价 + 加料价之和。
ALTER TABLE cart_item
ADD COLUMN addons JSON NULL COMMENT '加料:[{"id":1,"qty":1,"price_cents":200}]';
代价是"同一 SKU + 不同加料"会变成两条购物车记录,唯一键要改成 (user_id, sku_id, addons_hash)。这个取舍要按业务定:加料组合少于 10 种,用 SKU 组合更简单;组合多,用 JSON 更灵活。
三、库存扣减:三个时机,各有利弊
这是电商系统里争论最多的问题。三种主流做法:
|----------------------------|-----------|-----------------|-------------|
| 时机 | 优点 | 缺点 | 适用 |
| 下单即扣(减 stock ) | 实现简单,不会超卖 | 恶意下单可占库存,需靠超时释放 | 库存紧张、单价低的商品 |
| 下单锁库存(加 lock_stock ),支付后转扣 | 兼顾防超卖和体验 | 逻辑稍复杂,要处理超时释放 | 大多数零售电商 |
| 支付成功才扣 | 不会占库存 | 高并发下必然超卖 | 几乎不推荐 |
推荐第二种。核心是两条 SQL:
-- 下单:尝试锁定库存,条件里带上可售库存判断
UPDATE sku
SET lock_stock = lock_stock + ?
WHERE id = ?
AND stock - lock_stock >= ?;
-- 影响行数为 0 表示库存不足,直接回滚整个下单事务
-- 支付回调成功:锁定转实扣
UPDATE sku
SET stock = stock - ?,
lock_stock = lock_stock - ?
WHERE id = ?;
第一条 SQL 是防超卖的关键。不要在应用层先 SELECT 查库存再 UPDATE ,两步之间有时间窗,并发下必然出错。把判断条件写进 WHERE,让数据库的行锁来保证原子性,这是最省事也最可靠的做法。
四、订单超时关闭与库存释放
锁了库存不付款,库存就被占着。必须有超时释放机制。
推荐做法是订单表带过期时间,用定时任务扫描:
-- 订单表关键字段
-- status: 1待支付 2已支付 3已取消 4已完成
-- expire_at: 下单后 15 分钟
-- 每 30 秒扫描一次
UPDATE orders
SET status = 3, cancel_reason = 'TIMEOUT'
WHERE status = 1
AND expire_at < NOW()
LIMIT 200;
取消成功后,在同一事务里把 lock_stock 减回去。
这里有个经典错误 :先查出一批过期订单,再逐条取消,中间如果有用户刚好付款成功,就会把已付款的订单改成已取消。正确做法是取消时加状态条件:
UPDATE orders SET status = 3
WHERE id = ? AND status = 1; -- 只有仍待支付才取消
影响行数为 0,说明状态已经变了,跳过这条,不要释放库存。
五、并发下还要注意的三件事
5.1 购物车结算时的价格漂移
用户加购时价格是 39,结算时商家改成了 45。要以结算时刻的价格为准,并且在提交订单页明确提示"价格已变动"。不要用加购时的价格成交,也不要不提示就直接涨价------前者亏钱,后者投诉。
5.2 重复提交订单
用户手抖点两次"提交订单",会生成两个订单、锁两次库存。解决方式是前端按钮置灰 + 后端幂等键:
CREATE TABLE order_idempotent (
idem_key VARCHAR(64) PRIMARY KEY,
order_no VARCHAR(32) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
提交时先插入幂等键(主键冲突则返回已有订单号),再创建订单。幂等键可以用 userId + cartHash + 时间窗口 生成。
5.3 库存为负的排查
如果监控发现某个 SKU 的 stock 出现负数,说明有绕过 WHERE 条件扣减的路径。常见的漏点有两个:后台手工改库存的接口没做校验、退款回补库存时没做上限判断。这两处都要加上"不得小于 0"的约束,数据库层面也可以用 UNSIGNED 兜底。
六、库存与订单的对账口径
系统跑起来之后,运营迟早会问一个问题:这个月卖了多少件、还剩多少件,为什么对不上?
对不上通常有三个来源,理清楚就能自己排查:
一是锁定量没释放。 用户下单锁了库存、订单超时取消了,但释放逻辑失败重启了,lock_stock 就一直挂着。表现是"明明有货却显示售罄"。排查方式是查 lock_stock > 0 且对应订单已取消或已完成的 SKU。
二是手工改库存。 后台调整库存的接口如果直接写 stock,不写流水,事后就查不出是谁改的、为什么改。规范做法是后台改库存也必须写一条流水,注明操作人和原因。
三是退款回补。 退货入库和退款是两件事,退款成功不代表货已经回到仓库。库存应该在"确认收货入库"时才回补,而不是退款时立刻回补,否则会出现"账上有货、仓里没有"。
这三条对应三个校验任务:每日扫描异常锁定、每月核对库存流水与余额差、每周核对退货入库单与退款单。三张表跑一遍,差异基本都能定位。
七、小结
电商小程序的购物车和库存,难点不在功能多少,而在几个关键决策是否一次做对:
- SKU 模型把价格和库存挂到最小可售单位,SPU 只做展示;- 购物车唯一键决定合并行为,加料用 JSON 还是 SKU 组合要按组合数量取舍;- 库存用"下单锁、支付扣",防超卖靠 WHERE 条件里的原子判断,不靠应用层查询;- 超时关闭必须带状态条件更新,避免与支付回调打架;- 重复提交用幂等键,价格漂移要提示。
青海青帝信息科技有限公司在本地电商类项目中,把这五条列为代码评审的必查项------它们出问题的概率不高,但一旦出问题,往往是在大促当天。