2026/10/28 17:29:00

SPU与SKU商品建模:库存、价格与订单快照实践

SPU与SKU商品建模:库存、价格与订单快照实践 做过电商后端的人大概都遇到过这样一个场景需求评审会上产品经理指着原型说“这个商品有四个颜色、三个尺码每个组合的价格和库存都不一样”然后白板上画了两个方框一个写 SPU一个写 SKU房间里一半人点头另一半人眼神发直。SPU 与 SKU 这两个词几乎出现在每一份电商需求文档里但真正能在建模阶段把它们摆对位置、在业务演进中不被反复返工折腾的团队其实没那么多。这篇文章想聊的就是这件事SPU标准化产品单元和 SKU库存量单位到底是什么、为什么要拆成两层、表怎么设计、库存和价格挂在谁身上、订单为什么必须做快照、以及那些只有踩过才知道的坑。内容适合刚接手电商项目的后端同学、正在做商品中台的产品同学也适合想搞明白“为什么商品详情页选规格会灰掉某个选项”这类细节的前端同学。不管你手上是一个日订单几百的小商城还是正在重构一套多仓多商家的商品体系下面这些推导过程和实操细节都能直接拿去用。1. 先把概念掰开SPU 和 SKU 到底在描述什么1.1 从超市货架说起这两个词一点都不抽象我一直觉得理解 SPU 和 SKU 最快的方式不是看定义而是去楼下便利店站五分钟。货架上摆着一排某品牌的苏打水标签写着“500ml 无糖青柠味”这是你眼睛看到的一个“商品”但仓库管理员记账的时候记的不是“苏打水”而是“这个品牌这个规格这个口味进了 24 瓶、出了 3 瓶”。前者是消费者认知里的一个整体后者是管理和结算的最小颗粒度。放到线上SPU 描述的是“这是什么”它聚合了一组共享同样核心信息的商品比如同一个品牌、同一个型号、同一套详情图文案。SKU 描述的是“具体是哪一个”它是用户下单时真正点击的那个东西是库存扣减、价格计算、物流发货的最小单位。一件衣服的 SPU 是“某款纯棉圆领 T 恤”它的 SKU 是“黑色 M 码”“白色 L 码”这样的具体组合。这个区分带来的第一个直接好处是商品详情页只需要渲染一份用户切换颜色尺码时变的是价格、库存和图片而不是整个页面重新加载一套内容。第二个好处是后台管理效率运营维护一次标题、卖点、详情图下面挂着几十个 SKU改文案不用改几十遍。第三个好处才是真正要命的——财务和仓储只认 SKU所有对账、盘点、履约动作都必须能落到唯一编码上。1.2 标准定义与边界别把两层揉成一团行业里比较通行的说法是SPU 是商品信息聚合的最小单位是一组可复用、易检索的标准化信息的集合SKU 是库存进出计量的基本单元可以以件、盒、托盘等为单位。这两句话读起来有点绕翻译成人话就是——SPU 管“信息”SKU 管“实物”。我见过不少项目在这上面翻车最常见的两种极端。一种是只建一张商品表把颜色尺码塞进一个 JSON 字段里库存用一个总数表示。这种做法在商品数量少、人人都买同款的时候能跑一旦出现“黑色卖光了但白色还有货”整个逻辑就崩了因为你根本没法表达“部分可售”。另一种相反事无巨细全部拆到 SKU 层连商品标题、详情图都每个 SKU 存一份结果运营改一次文案要改几十条记录数据一致性极难保证搜索时的聚合也做得异常痛苦。正确的边界感应该是这样一条线凡是“同一款商品的用户不关心差异”的信息放 SPU凡是“用户下单时必须明确、且会独立影响价格库存履约”的信息放 SKU。标题、品牌、类目、材质、详情描述属于前者颜色、尺码、容量、套餐版本属于后者。这条线不是拍脑袋定的它对应的是用户在详情页的决策路径——先看这商品是不是我要的SPU 层再看买哪个具体规格SKU 层。1.3 为什么非拆不可三个绕不开的现实约束有人会问我就不能用一个扁平结构加几个扩展字段搞定吗小规模确实可以但只要业务往前走一步就会撞上三堵墙。第一堵墙是库存。库存的本质是“某个可独立计量的实物还有多少”它必须有一个唯一标识来承载。如果用 SPU 级库存那“黑色没货白色有货”就表达不了如果用“SPU 规格字符串”做键那这个键在数据库里既不好建索引也没法做外键约束迁移和统计都会很痛。SKU 作为库存载体天然满足“唯一、可计量、可追溯”三个要求。第二堵墙是价格。同一个 SPU 下不同规格的价格经常不同大容量比小容量贵、套装比单件贵这是常态。价格如果挂在 SPU 上就得在订单里额外记录“当时选的是哪个规格、按什么规则加价”这个规则一旦改动历史订单的解释就全乱了。挂在 SKU 上一个 SKU 一个价简单直接。第三堵墙是履约和售后。发货要扫码退货要核对具体规格换货要指定换成哪个 SKU。这些动作都要求系统能在“商品”之外精确定位到“这一件”。没有 SKU 这一层仓储系统和电商系统之间就没有共同语言对接时只能靠人工对照表出错率极高。提示如果你的业务确实只有单一规格商品比如图书、标准电子产品也建议保留 SKU 这一层让每个 SPU 至少有一个默认 SKU。这样后续增加规格、接入多仓、对接 ERP 时不用做数据迁移代价只是多一张表和一次关联查询。2. 建模落地表结构怎么设计才不返工2.1 核心表与字段设计先把骨架立住聊完概念就该动手了。我一般会拆成四张核心表商品主表SPU、商品规格表SKU、规格项与规格值表、SKU 与规格值的关联表。前三张负责描述第四张负责建立映射关系。-- SPU 主表承载共享信息 CREATE TABLE spu ( id BIGINT PRIMARY KEY AUTO_INCREMENT, spu_code VARCHAR(32) NOT NULL COMMENT 业务编码对外可读, category_id BIGINT NOT NULL COMMENT 末级类目, brand_id BIGINT DEFAULT NULL, title VARCHAR(200) NOT NULL, sub_title VARCHAR(200) DEFAULT NULL, main_image VARCHAR(500) NOT NULL, detail_html MEDIUMTEXT COMMENT 详情富文本单独存储更佳, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1上架 2下架, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_spu_code (spu_code), KEY idx_category_status (category_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- SKU 表承载独立价格、库存、图片 CREATE TABLE 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 分为单位存储更稳妥, market_price DECIMAL(10,2) DEFAULT NULL, stock INT NOT NULL DEFAULT 0, image VARCHAR(500) DEFAULT NULL COMMENT 规格图为空则回退 SPU 主图, spec_summary VARCHAR(200) NOT NULL COMMENT 冗余的规格描述如 黑色 M, status TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_sku_code (sku_code), KEY idx_spu_id (spu_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个细节值得单独说。一个是spec_summary这个冗余字段很多人第一版不加觉得可以从关联表拼出来。但订单列表、购物车、售后工单这些页面都要展示“黑色 M 码”每次都去 join 三张表拼字符串性能和代码复杂度都不划算。冗余一份写入时保证一致就行。另一个是价格字段的类型。用DECIMAL还是用整数存“分”团队里经常吵。我的建议是金额统一用整数分存储展示时再除以 100。浮点数做金额运算的坑不用多解释0.1 0.2 0.30000000000000004这种事在促销叠加计算里能把人逼疯。DECIMAL虽然也能精确计算但跨语言、跨中间件传输时容易出现精度丢失整数最省心。2.2 规格建模三表关联和 JSON 字段怎么选规格这块是设计分歧最大的地方主要有两种流派。第一种是结构化三表方案一张规格项表颜色、尺码一张规格值表红、蓝、M、L一张 SKU 与规格值的关联表。好处是能按规格维度做筛选、能统计“哪个颜色卖得好”、能支持商家自定义规格、能校验组合合法性。坏处是查询要多几次 join写入时事务范围大一些。第二种是JSON 字段方案直接在 SKU 表上放一个specs字段存{颜色:黑,尺码:M}。好处是简单一个商品一个结构不用维护规格字典。坏处是没法高效按规格筛选想查“所有黑色商品”只能LIKE或者上全文索引数据分析和报表也很麻烦。两种方案的取舍可以看下面这张表对比维度三表结构化方案JSON 字段方案按规格筛选索引友好join 即可需模糊匹配或额外索引规格复用同一类目下规格可复用每个商品独立定义写入复杂度需事务保证多表一致单表写入简单数据分析可按规格聚合统计需要解析效率低适合场景中大型平台、多商家、需筛选小工具、内部系统、规格极少我的实践结论是只要商品列表需要“按颜色筛选”“按尺码筛选”这类功能就必须走三表方案。这不是性能问题是产品能力问题。JSON 方案看着省事等运营提“我想看看这个月黑色卖了多少件”的时候你就得回头重构。三表方案还有一个延伸设计要点规格项要区分“销售规格”和“展示规格”。颜色、尺码属于销售规格直接决定 SKU 的生成而材质、产地、适用季节这些属于类目属性它们参与筛选和搜索但不参与 SKU 组合。这两类混在一起的后果很严重——如果“产地”被当成销售规格那同一款商品广州产和杭州产就变成了两个 SKU库存凭空分裂运营会来找你拼命。2.3 组合合法性笛卡尔积不是全都要建模时一个容易被忽略的问题是规格值的全排列并不等于真实存在的 SKU。一件衬衫有 4 个颜色、5 个尺码理论上 20 个组合但实际可能只有 12 个组合在生产剩下 8 个是“不存在的商品”。如果系统简单地按笛卡尔积生成全部 SKU会带来两个问题后台列表里全是无效 SKU运营要一个个手动下架前端选择规格时用户能选到根本不存在的组合提交订单才报错体验极差。我的做法是在生成 SKU 时提供一个“有效性勾选”环节系统按全排列生成候选列表运营勾选实际在售的组合未勾选的组合直接不落库或者标记为禁用。前端拿到 SKU 列表后用“已存在且可售”的组合矩阵来控制规格按钮的置灰逻辑——选中“黑色”之后如果“XXL”没有对应的有效 SKU就把 XXL 按钮灰掉。这套逻辑看着简单但接口设计上要提前想清楚返回给前端的不能只是 SKU 数组最好还要有一份规格值的可用性标记否则前端要自己遍历 SKU 做匹配逻辑重复且容易出错。// 返回给前端的规格结构示例附带可用性标记 { spuId: 10086, specs: [ { name: 颜色, values: [ { value: 黑色, available: true }, { value: 白色, available: true }, { value: 藏青, available: false } ]}, { name: 尺码, values: [ { value: M, available: true }, { value: L, available: true }, { value: XL, available: true } ]} ], skus: [ { skuId: 1, specs: {颜色:黑色,尺码:M}, price: 12900, stock: 12 } ] }需要说明的是available这个标记在“选中某个规格值”之后应该动态变化——用户选了黑色白色 L 码是否可售的判断就要重新算。所以更严谨的做法是接口返回完整的组合矩阵前端在本地做联动计算避免每点一次都请求一次接口。2.4 索引和查询商品列表页的性能底线商品列表和搜索是流量最大的入口索引设计不到位后面怎么优化都白搭。我一般会保证这几个索引spu(category_id, status)联合索引支撑类目页的商品列表sku(spu_id)普通索引支撑详情页拿全部 SKUsku(sku_code)唯一索引仓储对接的查询入口如果有按价格排序的需求spu表上冗余一个min_price字段并加索引比实时join sku求最小值高效得多。min_price这个冗余字段值得展开说一下。详情页和列表页经常要展示“99 起”这个值就是该 SPU 下所有可售 SKU 的最低价。如果每次列表查询都去子查询算最小值几十个商品还行几百个就开始明显变慢。做法是在 SKU 价格或状态变更时同步更新 SPU 的min_price和max_price用一点写入开销换取查询性能。这种“读多写少场景下的适度冗余”在电商系统里非常常见不必有洁癖。3. 业务场景拆解库存、价格、订单怎么挂3.1 库存为什么必须锁在 SKU 维度库存这块我踩过的坑最多。最典型的一个是早期为了省事库存记在 SPU 上下单时按“总量 - 已售”判断。上线没多久就出问题——某个颜色的货实际已经发完了系统还显示有货用户下单后仓库发不出只能打电话道歉。根因很清楚实物是按 SKU 管理的系统的记账单位就必须和实物对齐。后来改成 SKU 级库存问题立刻消失。库存表的设计我通常会拆成两层一层是 SKU 上的总库存用于列表和详情页展示一层是仓库维度的分仓库存用于实际扣减和履约。CREATE TABLE sku_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, available INT NOT NULL DEFAULT 0 COMMENT 可售库存, locked INT NOT NULL DEFAULT 0 COMMENT 下单锁定未支付, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本, UNIQUE KEY uk_sku_warehouse (sku_id, warehouse_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;扣减逻辑上一定要区分“锁定”和“扣减”两个动作。用户下单时先锁定available - n, locked n支付成功后再真正扣减locked - n超时未支付则释放available n, locked - n。这个设计的意义在于防止超卖——如果不锁两个用户同时看到最后一件都下单成功最后只能有一个发得出货。注意高并发场景下UPDATE ... WHERE available n这种条件更新是最简单可靠的防超卖手段靠数据库行锁保证原子性。但要留意事务不要开太长扣减库存和创建订单最好放在同一个短事务里中间不要夹远程调用否则锁等待会拖垮整个下单链路。3.2 价格体系SKU 定价只是起点把价格放在 SKU 上是最基础的做法但真实业务里价格往往有四五个层次SKU 原价、划线价市场价、会员价、活动价、券后价。如果全塞在 SKU 表的字段里字段会膨胀到没法维护。我的经验是把价格拆成两块基础价格放在 SKU 表原价、划线价动态价格用独立的促销表承载。促销表记录活动范围指定 SKU、指定 SPU、指定类目、优惠方式直降、折扣、满减、生效时间。用户看到的价格 基础价 命中的最优促销。这样运营配置活动不影响商品主数据活动结束自动失效也不需要回头改 SKU。展示层的计算逻辑要特别注意优先级。我的做法是明确规定顺序单品直降 单品折扣 店铺满减 平台券逐层计算每层都记录一个中间结果。这样出问题时能快速定位是哪一层算错了用户投诉“说好的 99 块怎么变 109 了”时客服也能看到明细。还有一个细节列表页的“99 起”这个起价必须是所有可售 SKU 在叠加当前最优促销后的最低价而不是 SKU 原价的最小值。这个口径如果不统一用户点进详情页发现价格变高是会投诉的。3.3 订单快照为什么不能只存一个 SKU ID这是我特别想强调的一点。很多第一版系统为了省空间订单明细表只存sku_id和数量展示时去关联商品表查名称和图片。上线三个月后运营改了商品标题、换主图、调价格历史订单列表一夜之间全部变样用户拿着订单截图来问“我买的明明是这个”客服完全无法解释。订单必须做商品快照。下单那一刻把商品的标题、规格描述、单价、图片、甚至详情链接全部冗余写进订单明细表。这份数据一旦写入就不再变后续商品怎么改都不影响历史订单的可读性。CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, spu_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, sku_code VARCHAR(64) NOT NULL, spu_title VARCHAR(200) NOT NULL COMMENT 下单时快照, spec_summary VARCHAR(200) NOT NULL COMMENT 下单时快照, image VARCHAR(500) NOT NULL, unit_price INT NOT NULL COMMENT 下单时单价单位分, quantity INT NOT NULL, KEY idx_order_id (order_id), KEY idx_sku_id (sku_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;保留sku_id是为了售后和统计能反查商品保留快照字段是为了展示和举证。两者不冲突反而互补。这条经验我认为值得所有电商团队刻在墙上订单数据是不可变的事实记录商品数据是可变的配置两者必须解耦。3.4 组合商品与虚拟 SKU 的处理方式业务再往前走一步就会遇到组合商品的需求——比如“三件套礼盒”“买二送一”“套餐组合”。这类商品在页面上是一个商品但它背后对应多个实物 SKU。处理方式一般有两种。一种是把组合定义成独立的 SPU 和 SKU称之为虚拟 SKU不持有实际库存通过一张绑定关系表关联到真实的组成 SKU。下单时按绑定关系拆分成多个真实 SKU 扣库存发货时按真实 SKU 出库。另一种是用促销引擎做“组合优惠”商品本身还是独立的只是在结算时按规则打折。两种方式的区别在于履约形态。如果组合装是提前打包好、作为一个整体发货的那它应该是一个真实 SKU有自己的库存如果组合装是下单后临时拼装的那用虚拟 SKU 加绑定关系更合适。这个判断点在实际设计时经常被忽略导致后期仓库收到订单不知道是发一个包裹还是三个。-- 虚拟 SKU 的组合关系 CREATE TABLE combo_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, combo_sku_id BIGINT NOT NULL COMMENT 虚拟 SKU, item_sku_id BIGINT NOT NULL COMMENT 组成 SKU, quantity INT NOT NULL DEFAULT 1, KEY idx_combo (combo_sku_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;4. 常见坑与排查实录4.1 SKU 编码规则别用自增 ID 当业务编码自增 ID 做主键没问题但直接拿它当 SKU 编码对外使用迟早出问题。一方面自增 ID 会暴露业务量另一方面迁移、分库分表之后 ID 会变而仓库的货架标签、财务的对账单、供应商的合同上写的都是这个编码改一次牵一发动全身。我一般会设计一套独立的业务编码规则比如“类目码(2位) 品牌码(2位) 年月(4位) 序列号(6位)”长度固定、可读性好、不含业务敏感信息。序列号可以由 Redis 自增或者数据库号段生成。规则定下来之后要写进文档因为对接方会依赖这个格式做解析。还有个小坑编码里不要放颜色、尺码这类会变的属性。曾经有团队把“颜色代码”编进 SKU 编码后来运营把“藏青色”改名成“深蓝色”编码和描述就对不上了对账时双方各执一词。编码只承载标识不承载语义这条原则能省掉很多麻烦。4.2 上下架状态的口径统一商品状态看起来是个小字段实际上非常容易出问题。常见的情况是SPU 状态是“上架”但它下面所有 SKU 都因为库存为 0 被禁用了用户点进详情页发现什么都买不了或者某个 SKU 单独下架了但列表页还在展示。我的处理原则是明确定义三层状态并逐层收敛SPU 状态草稿/上架/下架控制整体可见性SKU 状态可售/停售控制具体规格是否可买库存状态有货/无货只影响按钮文案不影响商品可见性。列表页只查 SPU 状态为“上架”且至少有一个可售 SKU 的商品详情页展示全部 SKU但无货的置灰并显示“缺货”。这套口径要写进接口文档前端、后端、测试三方对齐。我见过太多团队因为“列表页该不该显示无货商品”这个问题反复改需求本质上就是口径没提前定清楚。4.3 规格变更引发的历史数据问题运营删掉一个规格值、或者给已有 SPU 新增一个规格维度都会影响已经生成的 SKU。如果处理不当会出现“订单里的 SKU 在商品表里查不到”的孤儿数据。稳妥的做法是规格和 SKU 一律软删除不做物理删除。给 SKU 加一个deleted标记下架时置为已删除查询时过滤掉但历史订单反查仍然能拿到记录。同时给已有 SPU 新增规格维度属于高风险操作应该走“生成新 SKU 旧 SKU 保留”的路径而不是原地修改。4.4 常见问题速查表下面这张表是我这几年在实际项目里整理出来的高频问题清单遇到类似现象可以先按这个方向排查现象可能原因排查方向详情页选规格后价格不更新SKU 列表未包含该组合 / 前端未做联动检查接口返回的 skus 数组是否覆盖全部有效组合显示有货但下单提示库存不足SPU 总库存与 SKU 库存不同步核对库存更新是否只改了汇总字段列表页显示“0 起”存在价格未填或为 0 的 SKU检查是否有草稿态 SKU 参与了最低价计算订单里的商品名和现在不一样未做订单快照直接关联商品表补快照字段历史数据做一次性回填同一商品出现重复 SKU规格值重复或大小写不一致规格值做唯一约束统一清洗规格按钮全灰无法选择有效组合矩阵计算错误检查笛卡尔积生成与有效性标记的对应关系促销价高于原价多层优惠叠加顺序错误复核价格计算优先级加兜底取最小值5. 一些实操层面的延伸思考5.1 从单仓到多仓库存模型的演进路径单仓的时候SKU 表上放一个stock字段就够了。但只要业务扩张到两个仓这个模型立刻不够用——同一个 SKU 在 A 仓有 10 件、B 仓有 5 件用户下单该从哪个仓发如果只存总数 15系统根本没法回答。演进路径我建议分三步走。第一步把库存从 SKU 表剥离到独立的库存表按sku_id warehouse_id建唯一键SKU 表上的stock退化为汇总字段。第二步引入库存分配策略下单时按收货地址就近选仓或者按“有货优先、成本优先”的规则分配。第三步处理跨仓调拨和库存同步这块要和仓储系统做定时对账避免两边数据漂移。这个演进过程不要一步到位很多团队一开始就设计多仓模型结果单仓阶段写了一堆用不上的逻辑反而增加了出错概率。架构跟着业务走比架构超前业务走要稳。5.2 SPU/SKU 与搜索、推荐系统的联动搜索和推荐是商品数据的下游消费者它们对数据形态的要求和交易系统不一样。交易系统需要精确、强一致搜索系统需要的是扁平、宽松、可聚合。常见做法是把 SPU 和 SKU 的数据打平成一份搜索文档以 SPU 为主体把 SKU 的价格区间、规格值列表、库存状态作为嵌套字段塞进去。这样用户搜“黑色 T 恤”能命中 SPU列表展示时再根据筛选条件过滤具体规格。这个打平过程通常通过消息队列异步完成——商品变更时发一条消息搜索服务消费后更新索引。这里有一个容易忽视的点索引更新是有延迟的。运营改完价格搜索页可能还是老价格用户点进去发现不一致会投诉。缓解办法是详情页以数据库为准列表页接受短暂延迟同时在运营后台给出“索引更新中”的提示让运营知道什么时候生效。这个体验细节做不做直接影响运营对系统的信任度。5.3 一点个人体会这些年做下来我越来越觉得 SPU 和 SKU 本身并不难难的是边界感——什么信息该放哪一层、什么状态该由谁负责、什么数据该冗余什么数据该实时查。每次接手一个老系统我第一件事就是画一张数据流向图把商品、库存、价格、订单四个域的关系理清楚往往能发现一堆历史遗留的口径不一致。如果只能给一条建议那就是在项目最开始把“库存以什么为单位”“价格以什么为准”“订单要不要快照”这三个问题写在文档第一页让产品、后端、前端、测试一起签字确认。这三个问题定下来后面百分之八十的返工都能避免。剩下的无非是根据业务规模选择合适的实现复杂度而已。