ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

电商商品规格与销售属性设计:SPU、SKU与库存扣减实战

电商商品规格与销售属性设计:SPU、SKU与库存扣减实战

1. 商品规格与销售属性,到底在解决什么问题

做电商系统的人,绕不开一个坎:同一个商品,为什么能变出几十上百种卖法。一件T恤有S/M/L/XL四个尺码,每个尺码又有黑/白/灰三种颜色,光这两组就十二个组合,每个组合还可能对应不同的库存、不同的价格、不同的图片。如果再加上“加绒版”“薄款”这种版本差异,SKU数量直接爆炸。很多刚入行的开发者第一反应是给商品表加字段,color、size、version一路加下去,加到第十个字段的时候发现查询语句已经没法看了,更别提运营那边还要随时新增一个“材质”维度。

这就是商品规格与销售属性这套设计要解决的核心矛盾:用一套可扩展的数据结构,把“商品是什么”和“商品怎么卖”这两件事彻底分开。规格是描述商品本身的客观属性,比如颜色、尺码、内存容量;销售属性是决定这笔交易具体形态的参数,比如你选的是“黑色+XL”这个组合,它对应一个独立的SKU,有自己的价格和库存。把这两层拆清楚,前端才能动态渲染选择器,后端才能精准扣减库存,运营才能灵活配置促销。

这套设计适合谁看?如果你正在从零搭一个电商系统,或者接手了一个商品表字段已经加到失控的老系统,又或者你是个前端想搞明白为什么后端返回的SKU列表长那样,那这篇内容就是写给你的。我会从表结构设计一路讲到实际查询和常见坑,尽量把每个决策背后的“为什么”说透,而不是甩几张ER图就完事。

2. 核心概念拆解:SPU、SKU、规格、销售属性到底怎么分

2.1 用生活场景理解SPU和SKU

先把这个最容易混淆的概念理清楚。SPU(Standard Product Unit)是标准产品单元,它描述的是“这是一款什么商品”,比如“某品牌2024春季圆领纯棉T恤”。SKU(Stock Keeping Unit)是库存量单位,它描述的是“这个商品的具体哪一个可售版本”,比如“某品牌2024春季圆领纯棉T恤 黑色 XL码”。

打个比方,SPU就像你去餐厅看菜单,上面写着“宫保鸡丁”这道菜;SKU就是你点单时说“宫保鸡丁,微辣,不要花生”。菜单上只有一道宫保鸡丁,但后厨能做出很多种组合,每种组合的备料和出餐流程可能都不一样。电商系统里,SPU负责展示和搜索,SKU负责交易和库存。

很多新手会问:为什么不直接只用SKU?因为如果只有SKU,你搜索“T恤”的时候会出来几百条几乎一样的记录,用户根本没法浏览。SPU把相同款式的不同版本聚合在一起,用户先看到一款商品,再在里面选具体规格,体验才顺畅。

2.2 规格与销售属性的边界在哪里

这两个词经常被混用,但在数据库设计里必须分开。规格(Specification)是商品的固有属性,它不随交易变化,比如一件衣服的材质是纯棉,这是客观事实。销售属性(Sales Attribute)是用户下单时必须选择的参数,它直接决定SKU,比如颜色和尺码。

但边界不是绝对的。同样是“颜色”,在服装类目里它是销售属性,因为用户必须选一个颜色才能下单;但在某些标准件类目里,颜色可能只是描述性规格,不影响价格和库存。所以设计时不能写死,要把“哪些属性参与SKU生成”做成可配置的。

我见过一个反面案例:某系统把“重量”做成了销售属性,结果用户买个东西还要选“500g版”还是“1000g版”,其实重量只是物流计算用的规格参数。这种设计失误的根源就是没想清楚“用户下单时是否需要主动选择”这个判断标准。

2.3 属性、属性值、SKU组合的三层关系

完整的结构是三层:属性定义层(有哪些属性,比如颜色、尺码)、属性值层(每个属性有哪些可选值,比如颜色有黑/白/灰)、SKU组合层(具体哪几个属性值组合成一个可售单元)。

这三层拆开的好处是运营可以随时加属性值。比如某天要新增一个“藏青色”,只需要在属性值表里插一条记录,然后生成对应的SKU组合,不需要改表结构。如果当初把颜色写死在商品表的字段里,加一个颜色就得改表、改代码、改前端,成本高得离谱。

3. 数据库表结构设计:从字段到索引的完整方案

3.1 核心表清单与职责划分

一套能跑起来的商品规格系统,至少需要这几张表:

表名职责关键字段
商品SPU表存储商品主体信息spu_id, title, category_id, brand_id
属性定义表定义有哪些属性attr_id, attr_name, is_sales_attr
属性值表定义每个属性的可选值value_id, attr_id, value_name
SPU属性关联表记录SPU用了哪些属性spu_id, attr_id
SKU表存储可售单元sku_id, spu_id, price, stock
SKU属性值关联表记录SKU对应哪些属性值sku_id, attr_id, value_id

这里有个设计决策值得展开:为什么SKU属性值关联表要存attr_id而不只存value_id。因为value_id本身已经能推出attr_id,但存冗余的attr_id可以让查询少一次join。在SKU数量大的时候,这个冗余能明显提升筛选性能。代价是要保证数据一致性,插入时校验value_id确实属于attr_id。

3.2 属性定义表的关键字段设计

属性定义表看起来简单,但有几个字段决定了系统的灵活性:

CREATE TABLE product_attribute ( attr_id BIGINT PRIMARY KEY AUTO_INCREMENT, attr_name VARCHAR(50) NOT NULL COMMENT '属性名,如颜色', category_id BIGINT NOT NULL COMMENT '所属类目', is_sales_attr TINYINT DEFAULT 0 COMMENT '是否销售属性 1是 0否', is_required TINYINT DEFAULT 0 COMMENT '是否必填', input_type TINYINT DEFAULT 1 COMMENT '输入方式 1单选 2多选 3文本', sort_order INT DEFAULT 0 COMMENT '排序', status TINYINT DEFAULT 1 COMMENT '状态 1启用 0禁用' );

is_sales_attr这个字段是整个设计的开关。当它为1时,这个属性的值会参与SKU组合生成;为0时,它只是描述性规格,不影响SKU。input_type决定了前端渲染成单选按钮、复选框还是输入框。category_id让不同类目有不同的属性集,服装类目有尺码,手机类目有内存容量,互不干扰。

注意:属性名在同一类目下应该唯一,但不同类目可以重名。比如“颜色”在服装和手机类目下都存在,但它们的属性值完全不同。所以唯一索引要建在(category_id, attr_name)上,而不是单独attr_name。

3.3 SKU表与库存价格的关系

SKU表是交易的核心,价格和库存都挂在这一层:

CREATE TABLE product_sku ( sku_id BIGINT PRIMARY KEY AUTO_INCREMENT, spu_id BIGINT NOT NULL, sku_code VARCHAR(64) UNIQUE COMMENT '商家编码', price DECIMAL(10,2) NOT NULL COMMENT '售价', original_price DECIMAL(10,2) COMMENT '划线价', stock INT NOT NULL DEFAULT 0 COMMENT '可售库存', locked_stock INT NOT NULL DEFAULT 0 COMMENT '锁定库存', image_url VARCHAR(255) COMMENT 'SKU主图', status TINYINT DEFAULT 1 COMMENT '1上架 0下架', INDEX idx_spu (spu_id) );

这里要解释两个设计点。第一,为什么库存要分可售和锁定两个字段。用户下单后库存不能立刻扣减,因为可能超时未支付。常见做法是下单时把可售库存转到锁定库存,支付成功后锁定库存真正扣掉,超时未支付则把锁定库存还回可售。这样能避免超卖,也能处理未支付订单。

第二,价格为什么放在SKU而不是SPU。因为不同规格的成本和定价策略不同,XL码可能比S码贵,加绒版比薄款贵。如果价格放在SPU层,就没法做差异化定价。当然,如果业务上所有SKU同价,可以在SPU层放一个默认价格,SKU层价格为空时取默认值,这是常见的优化。

3.4 索引设计与查询性能考量

商品列表页和详情页的查询模式完全不同,索引要分别优化。列表页通常按类目、品牌、价格区间筛选,所以SPU表上要有(category_id, status)的联合索引。详情页要根据spu_id查所有SKU,SKU表的idx_spu索引就是干这个的。

SKU属性值关联表的索引更讲究:

CREATE TABLE sku_attribute_value ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL, attr_id BIGINT NOT NULL, value_id BIGINT NOT NULL, UNIQUE KEY uk_sku_attr (sku_id, attr_id), INDEX idx_value (value_id) );

uk_sku_attr保证一个SKU在一个属性下只能有一个值,比如一个SKU不能同时是黑色又是白色。idx_value用于反向查询,比如“找出所有颜色为黑色的SKU”,这在筛选场景下很有用。

4. SKU组合生成与前端交互的实操细节

4.1 笛卡尔积生成SKU的正确姿势

当运营在后台勾选了颜色(黑/白/灰)和尺码(S/M/L),系统需要自动生成3×3=9个SKU。这个过程叫笛卡尔积展开。实现时要注意几个细节:

第一,不是所有组合都有效。比如灰色可能没有S码,运营需要能手动删除无效组合。所以生成后要提供一个可编辑的SKU列表,而不是直接落库。

第二,生成时要保留已有SKU的数据。如果运营只是新增了一个颜色,不应该把原来的价格和库存清空。常见做法是以属性值组合为key做diff,只新增缺失的组合,保留已存在的。

第三,SKU编码的生成规则。可以用spu_id加属性值id拼接,比如“1001-201-301”,保证唯一且可读。但要注意属性值id可能很长,拼接后超过字段长度限制,所以要么用哈希,要么用自增序号。

4.2 前端规格选择器的联动逻辑

用户在详情页选规格时,前端需要做可选性判断。比如用户选了黑色,那么尺码里只有有库存的才应该可点,没库存的置灰。这个判断依赖后端返回的SKU列表。

后端返回的数据结构通常是这样的:

{ "spu_id": 1001, "attributes": [ {"attr_id": 1, "attr_name": "颜色", "values": [{"value_id": 201, "value_name": "黑色"}, ...]}, {"attr_id": 2, "attr_name": "尺码", "values": [{"value_id": 301, "value_name": "S"}, ...]} ], "skus": [ {"sku_id": 5001, "price": 99, "stock": 10, "attrs": {"1": 201, "2": 301}}, ... ] }

前端拿到后,每次用户选择变化,就遍历skus找出匹配当前已选属性的组合,判断剩余属性的哪些值还有对应SKU。这个逻辑用JavaScript实现大概是这样:

function getAvailableValues(selected, skus, attrId) { return skus.filter(sku => { // 已选属性必须全部匹配 for (let [aid, vid] of Object.entries(selected)) { if (sku.attrs[aid] !== vid) return false; } return sku.stock > 0; }).map(sku => sku.attrs[attrId]); }

提示:这个计算在前端做还是后端做,取决于SKU数量。如果SKU超过200个,建议后端提供一个“可选值查询”接口,前端每次选择后请求一次,避免前端遍历大数组造成卡顿。

4.3 库存扣减与超卖防护

库存扣减是电商系统最容易出事故的地方。我踩过的坑是:用“查询库存-判断足够-更新库存”这种三步操作,在高并发下必然超卖。正确做法是用数据库的原子操作:

UPDATE product_sku SET stock = stock - 1, locked_stock = locked_stock + 1 WHERE sku_id = ? AND stock >= 1;

然后判断affected rows是否为1,为0说明库存不足。这条语句把判断和扣减合并成一个原子操作,依赖数据库的行锁保证并发安全。

如果系统规模更大,数据库行锁成为瓶颈,可以引入Redis做库存预扣。但要注意Redis和数据库的一致性问题,常见方案是Redis扣减成功后发消息异步落库,同时用定时任务对账补偿。这个方案复杂度高,中小系统用数据库原子操作就够了。

5. 常见问题与排查技巧实录

5.1 SKU组合爆炸怎么处理

一个商品如果有5个销售属性,每个属性5个值,笛卡尔积就是3125个SKU。这在前端渲染和后端存储上都是灾难。实际业务中,大部分组合是无效的,真正在售的可能只有几十个。

处理思路有三个。第一,限制销售属性数量,一般不超过3个,超过3个的建议拆成多个SPU。第二,懒生成,运营只维护有效组合,系统不主动展开全部笛卡尔积。第三,分页返回SKU,详情页只返回有库存的SKU,无库存的不返回给前端。

5.2 属性值删除后的数据一致性

运营删除了一个属性值(比如“藏青色”),但已经有SKU关联了这个值。直接删除会导致SKU属性值关联表出现孤儿记录。正确做法是软删除,把属性值状态置为禁用,同时下架关联的SKU。如果确实要物理删除,必须先检查是否有SKU引用,有引用则拒绝删除。

5.3 商品详情页SKU价格显示错误

常见原因是前端取价格时取了SPU的默认价格,而不是当前选中SKU的价格。排查时先确认后端返回的skus数组里每个SKU的price是否正确,再检查前端选中逻辑是否根据用户选择匹配到了正确的SKU。另一个坑是价格精度问题,JavaScript的浮点数运算会导致99.9显示成99.899999,所以价格在前后端传输时建议用整数分,展示时再除以100。

5.4 常见问题速查表

问题现象可能原因排查方向
规格选择器全部置灰SKU列表为空或stock都为0检查SKU是否上架、库存是否大于0
选中规格后价格不更新前端未监听选择变化检查选中事件是否触发SKU匹配
下单提示库存不足但页面显示有货库存缓存未同步检查Redis与数据库库存是否一致
新增属性值后SKU未生成未触发组合生成逻辑检查后台保存时是否调用生成方法
SKU编码重复生成规则冲突检查编码拼接逻辑是否包含唯一标识

6. 从设计到落地的经验总结

这套商品规格与销售属性的设计,核心思想就一句话:把变化的维度抽出来做成数据,而不是做成字段。属性定义表、属性值表、SKU组合表这三层结构,让系统能适应任意类目的商品形态,运营加一个规格维度不需要开发介入。

我在实际项目里最大的体会是,前期多花两天把表结构设计清楚,后期能省两个月改bug的时间。尤其是SKU属性值关联表,一定要存attr_id冗余,一定要加唯一索引,一定要做软删除。这三个“一定”是用血泪换来的。

另外,前端规格选择器的联动逻辑建议封装成独立组件,把SKU匹配算法抽成纯函数,方便单元测试。我见过太多项目把这段逻辑散落在各个页面里,改一处漏一处,最后没人敢动。

最后分享一个小技巧:在SKU表里加一个attrs_hash字段,把属性值id排序后拼接做哈希。这样判断两个SKU是否重复时,直接比哈希值就行,不用逐条比对属性值。插入前查一下哈希是否存在,能有效防止运营重复生成SKU。这个字段在数据迁移和去重时特别好用,成本只是一个额外的索引。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进