商城系统搭完基础框架后,第一个核心模块就是商品服务。商品看着简单,但 SPU/SKU 模型、属性管理、库存管理这些东西做起来水还是挺深的。这篇记录下商品服务的设计过程。
SPU 与 SKU
先把两个概念理清楚:
- SPU(Standard Product Unit):标准产品单元,是一个抽象概念。比如"iPhone 15 Pro"是一个 SPU。
- SKU(Stock Keeping Unit):库存管理单元,是 SPU 的具体变体。比如"iPhone 15 Pro 黑色 256GB"是一个 SKU。
一个 SPU 对应多个 SKU。用户在商品详情页看到的是 SPU 信息,选择颜色和规格后确定了具体的 SKU,加购和下单都是基于 SKU。
数据库表设计
SPU 表
CREATE TABLE spu (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(200) NOT NULL COMMENT '商品名称',
subtitle VARCHAR(500) COMMENT '副标题',
category_id BIGINT NOT NULL COMMENT '分类ID',
brand_id BIGINT COMMENT '品牌ID',
main_image VARCHAR(500) COMMENT '主图URL',
images TEXT COMMENT '轮播图URLs(JSON数组)',
detail_html TEXT COMMENT '详情富文本',
status TINYINT DEFAULT 0 COMMENT '0下架 1上架 2删除',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_category (category_id),
INDEX idx_status (status)
);
SKU 表
CREATE TABLE sku (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
spu_id BIGINT NOT NULL,
name VARCHAR(200) NOT NULL COMMENT 'SKU名称(自动生成)',
price BIGINT NOT NULL COMMENT '价格(分)',
stock INT NOT NULL DEFAULT 0 COMMENT '库存',
specs JSON COMMENT '规格属性 {"颜色":"黑色","存储":"256GB"}',
image VARCHAR(500) COMMENT 'SKU图片',
status TINYINT DEFAULT 1 COMMENT '0禁用 1启用',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_spu (spu_id)
);
属性表
CREATE TABLE product_attribute (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
category_id BIGINT NOT NULL COMMENT '所属分类',
name VARCHAR(50) NOT NULL COMMENT '属性名(如:颜色/尺码)',
type TINYINT NOT NULL COMMENT '1规格属性 2销售属性',
values_list VARCHAR(1000) COMMENT '可选值列表(JSON)',
sort_order INT DEFAULT 0
);
商品属性设计
属性分两种:
规格属性(Sale Attribute):决定 SKU 的属性,比如颜色、尺码、存储容量。用户在详情页选择的就是规格属性,不同的规格组合对应不同的 SKU,价格和库存可能不同。
基本属性(Base Attribute):描述商品特征但不影响 SKU 的属性,比如品牌、产地、材质。所有 SKU 共享同样的基本属性。
我在 product_attribute 表里用 type 字段区分。规格属性关联到 SKU,基本属性关联到 SPU。
SKU 组合生成
创建商品时,运营人员选择规格属性的值,系统自动生成 SKU 组合。比如:
- 颜色:黑色、白色
- 存储:128GB、256GB
生成的 SKU 组合:
| SKU | 颜色 | 存储 | 价格 | 库存 |
|---|---|---|---|---|
| 1 | 黑色 | 128GB | 待填 | 待填 |
| 2 | 黑色 | 256GB | 待填 | 待填 |
| 3 | 白色 | 128GB | 待填 | 待填 |
| 4 | 白色 | 256GB | 待填 | 待填 |
组合生成算法就是一个笛卡尔积。我在后端用递归实现:
输入: [["黑色","白色"], ["128GB","256GB"]]
输出: [["黑色","128GB"], ["黑色","256GB"], ["白色","128GB"], ["白色","256GB"]]
生成后前端展示为表格,运营人员填写每个 SKU 的价格和库存。如果某个组合不需要(比如白色 128GB 不卖了),可以单独禁用。
价格和库存管理
价格
价格存在 SKU 上,单位是"分"(int64),避免浮点数精度问题。前端展示时再转成"元"。
后期如果要做促销价、会员价,加一张价格表关联到 SKU:
CREATE TABLE sku_price (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sku_id BIGINT NOT NULL,
price_type TINYINT COMMENT '1原价 2促销价 3会员价',
price BIGINT NOT NULL,
start_time DATETIME,
end_time DATETIME
);
当前阶段先只做原价,促销后面再加。
库存
库存也在 SKU 上。库存操作(扣减、回退)是高频且需要保证原子性的操作,这块用了 Redis + Lua 脚本来做,后面订单模块再详细讲。
数据库里的 stock 字段作为最终一致的持久化存储,Redis 里的库存用于实时扣减。定期同步。
商品状态管理
SPU 有三个状态:
- 下架(0):新创建的商品默认是下架状态,不在前台展示
- 上架(1):审核通过后上架,用户可以看到和购买
- 删除(2):逻辑删除,不物理删除数据
状态转换规则:
下架 ←→ 上架
下架 → 删除
上架 → 下架 → 删除
不能从上架状态直接删除,必须先下架。这是为了防止误操作——上架中的商品可能有人正在下单。
SKU 也有自己的启用/禁用状态。一个 SPU 上架时,至少要有一个启用的 SKU,否则上架失败。
商品详情接口设计
商品详情页需要聚合多种数据:
{
"spu": {
"id": 1001,
"name": "iPhone 15 Pro",
"subtitle": "强大的 A17 Pro 芯片",
"main_image": "...",
"images": ["...", "..."],
"detail_html": "...",
"base_attrs": [
{"name": "品牌", "value": "Apple"},
{"name": "产地", "value": "中国"}
]
},
"skus": [
{
"id": 2001,
"specs": {"颜色": "黑色", "存储": "256GB"},
"price": 899900,
"stock": 150,
"image": "..."
},
...
],
"spec_options": {
"颜色": ["黑色", "白色", "蓝色"],
"存储": ["128GB", "256GB", "512GB"]
}
}
spec_options 用于前端渲染规格选择器。用户选择具体规格后,前端根据选择的值匹配到对应的 SKU,展示该 SKU 的价格和库存。
几个细节
-
SKU 名称自动生成:SKU 名称 = SPU 名称 + 规格属性值,如"iPhone 15 Pro 黑色 256GB"。运营不需要手动填。
-
商品搜索:当前用 MySQL FULLTEXT 做简单搜索,后面数据量大了切 Elasticsearch。商品表里 name 和 subtitle 字段加了全文索引。
-
图片管理:每个 SPU 有一组轮播图,每个 SKU 也可以有自己的图片(用于展示不同颜色的实物图)。图片上传到 OSS,数据库只存 URL。
-
分类管理:商品分类用的是三级分类(大类→中类→小类),用 parent_id 实现树形结构。前端用级联选择器展示。
商品服务是商城的基础,后面的购物车、订单、搜索都依赖它。设计时尽量把模型做规范,后面扩展起来会轻松很多。