做电商小程序,商品模型是整个系统的地基。规格(颜色/尺码/套餐)、库存、价格、图片这几样东西一旦建模出错,后面的购物车、下单、营销活动全会跟着返工。本文从最基础的SPU/SKU概念讲起,给出一套可直接落地的表结构与核心代码,并复盘我们在真实项目里踩过的四个坑。
一、为什么不能把"商品"做成一张表
新手最常见的错误:商品表直接存 price、stock,规格用一个 JSON 字段塞进去。商品只有单一规格时没问题,一旦出现"红色M码""红色L码"这种组合,库存和价格必须按组合独立管理------红色M码卖完不代表红色L码也卖完,套餐版和单机版价格完全不同。
行业通用的抽象是两层:
- SPU(Standard Product Unit):标准化产品单元,即"一款商品",如"某品牌纯棉T恤",承载商品名称、分类、详情、主图等公共属性。
- SKU(Stock Keeping Unit):库存最小单元,即"一款商品的一个具体规格组合",如"红色/M码",承载该组合独立的价格、库存、编码。
规格维度(规格名+规格值)与SKU是多对多关系:一个SKU对应多个规格值(红色、M码),一个规格值也被多个SKU共享。
二、表结构设计
sql
-- SPU:商品主表
CREATE TABLE product_spu (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200) NOT NULL COMMENT '商品标题',
category_id BIGINT NOT NULL,
main_image VARCHAR(500),
detail_html MEDIUMTEXT COMMENT '商品详情',
status TINYINT DEFAULT 1 COMMENT '1上架 0下架',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) COMMENT='商品SPU表';
-- 规格名:颜色、尺码、套餐
CREATE TABLE product_spec (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
spu_id BIGINT NOT NULL,
name VARCHAR(50) NOT NULL COMMENT '规格名,如:颜色'
) COMMENT='规格名表';
-- 规格值:红色、蓝色;M、L
CREATE TABLE product_spec_value (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
spec_id BIGINT NOT NULL,
value VARCHAR(50) NOT NULL COMMENT '规格值,如:红色'
) COMMENT='规格值表';
-- SKU:具体规格组合
CREATE TABLE product_sku (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
spu_id BIGINT NOT NULL,
sku_code VARCHAR(64) NOT NULL COMMENT '商家编码/条码',
price DECIMAL(10,2) NOT NULL COMMENT '销售价',
origin_price DECIMAL(10,2) COMMENT '划线价',
stock INT NOT NULL DEFAULT 0,
image VARCHAR(500) COMMENT '规格图,如颜色图',
INDEX idx_spu (spu_id)
) COMMENT='商品SKU表';
-- SKU与规格值关联(核心:多对多)
CREATE TABLE product_sku_spec_value (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sku_id BIGINT NOT NULL,
spec_value_id BIGINT NOT NULL,
UNIQUE KEY uk_sku_value (sku_id, spec_value_id),
INDEX idx_value (spec_value_id)
) COMMENT='SKU规格值关联表';
下单时库存扣减必须在SKU维度进行,并配合乐观锁防超卖:
sql
UPDATE product_sku
SET stock = stock - #{num}
WHERE id = #{skuId} AND stock >= #{num};
影响行数为0即库存不足,直接回滚订单。
三、规格选择交互:前端怎么知道哪个组合有货
用户选规格时的经典交互:先选"红色",再选"M码",前端要能立刻定位到对应SKU并展示价格/库存。后端给前端的数据结构建议如下:
json
{
"spuId": 1001,
"specs": [
{ "specId": 1, "name": "颜色", "values": [
{ "valueId": 11, "value": "红色" },
{ "valueId": 12, "value": "蓝色" }
]},
{ "specId": 2, "name": "尺码", "values": [
{ "valueId": 21, "value": "M" },
{ "valueId": 22, "value": "L" }
]}
],
"skus": [
{ "skuId": 9001, "valueIds": [11, 21], "price": "99.00", "stock": 32, "image": "..." },
{ "skuId": 9002, "valueIds": [11, 22], "price": "99.00", "stock": 0 },
{ "skuId": 9003, "valueIds": [12, 21], "price": "109.00", "stock": 15 }
]
}
前端把 valueIds 排序后拼成 key(如 "11,21")建 Map,用户每选一项就查一次 Map,O(n) 规格维度内即可定位SKU,无库存组合置灰。
四、踩坑复盘
坑1:规格值用字符串匹配而不是ID关联
早期图省事,SKU表直接存 spec_desc="红色,M"。结果商家把"红色"改名成"酒红色"后,历史订单、购物车里的规格全部对不上;字符串匹配也无法支撑规格图联动。规格必须用ID关联,展示名只存规格值表一处,改名全局生效。
坑2:笛卡尔积生成无效SKU组合
两个规格各5个值,理论组合25个,但不是每个组合都真实存在(某颜色可能不产XXL码)。如果后端按笛卡尔积自动生成SKU,会出现大量库存为0的"幽灵SKU",前端置灰逻辑和库存报表全乱。SKU必须由商家在后台显式创建,系统只校验"同一SPU下规格值组合唯一"。
坑3:库存扣减和订单创建不在同一事务
先 update stock 再插订单,看似没问题;但订单插入失败时如果事务没包住,库存就丢了。更隐蔽的是营销活动叠加(拼团+优惠券)导致的并发扣减:必须把"锁库存→算价→建订单明细"放在同一个数据库事务里,超卖回滚由 stock >= num 的乐观锁兜底,不要用 SELECT ... FOR UPDATE 长事务扛高并发,行锁持有时间一长连接池就打满。
坑4:价格快照不入库
下单时只存了 sku_id,没存成交单价。半年后商家调价,历史订单详情里的价格全变成新价,退款金额也算错。订单明细表必须冗余 sku_title、spec_desc、deal_price、sku_image 快照字段------商品会改名、调价、删除,订单是法律凭证,数据必须定格在成交那一刻。
五、小结
商品SKU模型的核心就三句话:SPU管公共信息,SKU管价格库存,规格用ID多对多关联;库存扣减用乐观锁并与订单同事务;订单数据永远存快照。这套结构能覆盖服装、生鲜、套餐组合等绝大多数零售场景,后续做多规格营销、库存预警、销量统计也都有干净的数据基础。