ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

电商系统设计之商品模块:SPU/SKU建模、库存防超卖与状态机实战

2026/9/24 22:18:17 拓冰建站 浏览量
电商系统设计之商品模块:SPU/SKU建模、库存防超卖与状态机实战 做电商系统这些年我见过太多团队在商品模块上栽跟头。有些是初创公司急着上线把商品、SKU、库存全塞在一张表里后期一加规格就崩有些是大厂里的老系统类目层级混乱属性字段靠运营手工维护每次大促前都像在拆弹。说句实话商品模块是电商系统里最不能马虎的部分它看起来只是“商品信息管理”实际上牵扯着库存、价格、订单、搜索、营销一整套流程。你在商品模块省下的设计时间后面会在订单、售后、报表里成倍地还回来。这篇文章我打算基于这些年做“电商系统设计之商品模块”的实践经验把这个模块拆开揉碎地讲一遍。主要内容包括SPU/SKU的核心建模思路、属性与类目的设计、商品状态机的流转、库存预占与超卖防护、多端数据同步与缓存策略还有几个我踩过的比较深的坑。不管你是刚接触电商后端的新人还是已经在维护一套老商品系统的开发这篇文章应该都能给你一些可以直接落地的参考方案。1. 商品模块整体设计思路拆解1.1 SPU与SKU必须先搞清楚的底层概念很多刚入行的同事会把“商品”当成一个简单的实体一张表存名字、价格、图片就完事了。但是一旦业务跑起来需求就会变成“这个手机有黑色和白色”“这个T恤有S/M/L三个尺码”“不同颜色不同尺码的价格还不一样”这时候单表方案就彻底扛不住了。所以主流的电商系统都会做一层抽象SPUStandard Product Unit标准化产品单元和 SKUStock Keeping Unit库存量单位。SPU 是“商品”的逻辑概念比如“iPhone 15 Pro Max”它描述的是这个商品本身的属性不涉及具体的售卖规格。SKU 是“具体可卖的最小库存单元”比如“iPhone 15 Pro Max 黑色 256G”它绑定了规格组合并且每个SKU都有自己的库存和价格。这两层为什么要拆开从数据维护角度看SPU 负责公共信息主图、详情页、类目、品牌、公共属性。SKU 负责差异信息规格快照、销售价、库存、条形码、重量。从业务流程看商品列表页需要的是SPU维度购物车和订单明细需要的是SKU维度。如果一开始不拆后面每次查询都会在冗余数据里纠结到底该用哪条记录改一个公共信息就得刷几十万个SKU想想都头疼。一个比较实用的做法是SPU层维护一个spu_id作为全局标识SKU层用sku_id作为交易标识并且SPU和SKU之间用spu_id做关联。前端在列表和详情页展示SPU信息详情页里的规格切换其实就是选择某个具体的SKU切换后展示的才是那个SKU对应的价格、库存和图片。1.2 类目体系设计是商品模块的地基类目设计经常被低估但它是商品模块里最影响扩展性的部分。比如你现在只有手机、电脑三个类目可能觉得简单的分类字段就够了。等上线半年后运营说我们要加服饰类目服饰需要颜色、尺码两个规格衣服还要填材质、版型、适用季节这时候你才发现原来的数据库连个扩展的入口都没有。常见的类目设计方式有两种一种是固定层级类目比如一级类目“手机数码”二级“手机通讯”三级“智能手机”三级固定不可变。另一种是灵活层级类目类目表自己记录parent_id支持任意层级。我建议在系统起步阶段就实现灵活层级哪怕你业务上只用到三级也不要写死在代码里。除了类目本身的层级还要做“类目-属性”绑定。每个类目下挂一组属性和校验规则比如“手机”这个类目可以挂“屏幕尺寸”“电池容量”“运行内存”等关键属性“服饰”类目挂“材质”“版型”等属性。这样运营在发布商品的时候系统可以根据类目动态渲染表单不会出现手机上填“适用季节”这种荒唐事。这个设计和后面的搜索、筛选也有关系。类目属性是可以参与搜索筛选的例如“筛选屏幕尺寸6.7英寸的手机”这要求属性在发布时被标准化成结构化的key-value不能直接放一段富文本描述否则筛选的时候什么都干不了。1.3 属性模型关键属性、销售属性、非关键属性分开管理属性是商品信息里最繁琐的一块我见过很多项目把属性做成一个大JSON塞进一个attributes字段图省事。确实省事但后续的筛选、搜索排序、规格切换全都变成噩梦。这里我建议至少把属性分成三类关键属性决定商品是什么例如“型号”“品牌”通常参与SPU的聚合和搜索的过滤。销售属性组成SKU的规格例如颜色、尺码、容量。销售属性的不同组合直接产生不同的SKU。非关键属性用于描述商品详情例如“包装清单”“售后服务”一般只展示、不筛选。这种分类的价值在于你可以在发布商品时把销售属性单独抽出来做SKU笛卡尔积把关键属性抽出来做搜索聚合非关键属性则放到详情页展示。每一类属性的存储方式也不同销售属性通常要落到SKU表的规格快照里关键属性最好也用结构化存储非关键属性可以用JSON存。我曾经接手过一个项目他们把销售属性也放进了一个大JSON结果订单导出的时候运营要根据“颜色黑色”筛订单只能写脚本去解析JSON每次跑完还要人工核对。后来改造时把所有销售属性抽成了标准的sale_attr_key和sale_attr_value问题才彻底解决。属性模型这块真不是DBA洁癖是业务迟早会逼着你改的。2. 核心数据模型与字段设计2.1 SPU表、SKU表核心字段怎么设计才够用数据模型这块直接给一套比较通用而且经历过线上检验的落地方案。先说SPU表核心字段如下CREATE TABLE spu ( id bigint NOT NULL AUTO_INCREMENT, spu_id bigint NOT NULL COMMENT 商品ID对外唯一, category_id bigint NOT NULL COMMENT 后台类目ID, brand_id bigint DEFAULT NULL COMMENT 品牌ID, title varchar(200) NOT NULL COMMENT 商品标题, sub_title varchar(500) DEFAULT NULL COMMENT 副标题/卖点, main_image varchar(500) NOT NULL COMMENT 主图URL, detail_html mediumtext COMMENT 详情页富文本, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0草稿 1待审核 2上架 3下架, audit_time datetime DEFAULT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_spu_id (spu_id), KEY idx_category (category_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品SPU表;SKU表要额外注意几个点价格和库存不要在SPU表存因为价格是SKU维度的库存更是SKU维度的。SKU表的核心字段如下CREATE TABLE sku ( id bigint NOT NULL AUTO_INCREMENT, sku_id bigint NOT NULL COMMENT SKU ID交易与库存使用, spu_id bigint NOT NULL COMMENT 所属SPU, title varchar(200) NOT NULL COMMENT SKU名称显示用, image varchar(500) DEFAULT NULL COMMENT SKU图, sale_attrs json NOT NULL COMMENT 销售属性快照如[{k:颜色,v:黑色}], price decimal(10,2) NOT NULL COMMENT 售价, market_price decimal(10,2) DEFAULT NULL COMMENT 市场价/划线价, weight decimal(10,3) DEFAULT NULL COMMENT 重量kg用于运费计算, barcode varchar(64) DEFAULT NULL COMMENT 条码, status tinyint NOT NULL DEFAULT 1 COMMENT SKU状态0禁用 1启用, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_sku_id (sku_id), KEY idx_spu (spu_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品SKU表;注意sale_attrs是JSON字段这是刻意保留的。因为销售属性组合方案对每个类目不一样Java或者PHP侧可以定义对应的结构体来解析不要把它当成一个纯文本字符串去处理。如果你用的数据库版本不支持JSON字段也可以拆一张SKU-属性子表但查询时的聚合会麻烦一些多数新项目直接用JSON是性价比更高的选择。2.2 规格属性存JSON还是关系表关键看查询还是展示关于属性是否要用关系表存储这是个老生常谈的问题。我的经验是只看用途。前面讲了属性分成三类关键属性和非关键属性有不同的出路。如果是给SPU详情页做展示可以统一冗余到SPU的attrsJSON字段里一次查询全拿出来最简单。如果是要支持筛选、搜索、聚合建议把需要参与筛选的属性放到独立的“SPU-属性值”表或者放到搜索引擎比如Elasticsearch的文档字段里。如果是销售属性必须放在SKU维度并且在后端代码里要做结构化处理。这里我比较推荐“宽表JSON冗余”的双写方案SPU主表里保留一个attr_json字段用于详情展示同时根据类目属性定义把关键属性抽取到es索引里参与筛选。不要尝试用一个SQL满足所有需求那样往往什么都做不好。2.3 类目属性绑定表的实现要点类目属性绑定表要记录三样东西类目ID、属性ID、是否必填、是否参与筛选、排序值。核心表结构可以这样设计CREATE TABLE category_attr ( id bigint NOT NULL AUTO_INCREMENT, category_id bigint NOT NULL, attr_id bigint NOT NULL, attr_name varchar(100) NOT NULL COMMENT 属性名冗余, is_required tinyint NOT NULL DEFAULT 0, is_filter tinyint NOT NULL DEFAULT 0 COMMENT 是否参与筛选, is_sale tinyint NOT NULL DEFAULT 0 COMMENT 是否销售属性, sort int NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_category_attr (category_id, attr_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT类目属性绑定表;有了这张表前端发布商品页的表单可以动态生成。属性本身还存在另一张attr表中维护属性名、属性值可选项、控件类型下拉/输入框等。这套“属性字典类目绑定动态表单”的组合是整个商品录入体验的基础也是后续做商品标准化和搜索过滤的地基。3. 商品状态机与上下架流程设计3.1 商品状态流转别再用一个字段硬扛很多系统的商品状态就是status字段0上架1下架简单是简单但遇到审核、定时上架、违规下架就会一地鸡毛。所以我把商品状态拆成了两条线一条是“商品生命周期状态”一条是“审核状态”。生命周期状态包括草稿、已提交、已上架、已下架、已删除。审核状态包括待审核、审核通过、审核驳回。两条线要分开因为一个商品可以先录入成草稿再提交审核审核通过后上架运营可以主动下架也可以因为库存不足被动下架。如果合并成一个状态字段每个节点都要组合出新的枚举代码里到处是switch-case加一个状态就要翻一遍所有逻辑。我习惯的做法是spu表用status表示生命周期用audit_status表示审核状态。这样商品列表筛选就可以直接WHERE status 2 AND audit_status 1。3.2 定时上架与分布式锁定时上架是一个很常见的需求运营把商品提前录入好设定一个“计划上架时间”到点自动上架。实现方式有几种最简单的是在创建/修改商品时写一个定时任务每分钟扫一次planned_on_sale_time把到点的商品状态改成“已上架”。听起来很直接但要注意几个坑分页扫描时如果商品量大不能只按create_time排序要用id做游标避免漏数据。定时任务必须做幂等支持重跑。任务执行中宕机重启后要能继续处理。如果有多台机器部署需要引入分布式锁保证同一时刻只有一个实例在跑这个任务。我用过Redis的SETNX实现也用过xxl-job的分片参数都能解决。另外一个很容易忽略的细节是定时上架要带上审核状态条件只能在商品“审核通过”的前提下上架。否则运营修改了商品信息重新进入待审核商品却被定了时上架了这就出问题了。3.3 SKU上下架的联动与活动状态很多时候单个SPU下的SKU不是统一上下架的比如某个SKU缺货了运营可能只把那个SKU下架其他SKU继续卖。所以在SKU表里必须有status字段SPU的status是总开关SKU的status是细粒度控制订单校验时两个状态都要查任何一个不满足都不能下单。还有一个状态是“活动状态”比如某个SKU正在参加秒杀这时候它不能随意下架否则会破坏活动。我建议在活动期间对SKU的下架接口做拦截或者至少在操作时给出强提示。这类状态联动规则最好沉淀成配置而不是散落在业务代码里。4. 库存模型与超卖防护4.1 库存字段到底该怎么拆总库存、锁定库存、可用库存库存设计是商品模块最容易出问题的地方。很多初级方案是SKU表一个stock字段下单减库存时UPDATE sku SET stock stock - 1 WHERE sku_id ? AND stock 0。这个思路在低并发下能用但一旦遇到大促、秒杀问题就来了由于事务回滚和订单取消你需要“加库存”和“减库存”频繁地交替执行纯一个字段根本记录不了当前的可用量和已经占用的量。我推荐把库存拆成至少三个语义字段total_stock总库存表示采购入库的总量。locked_stock锁定库存表示已经被订单占用但还没有完成支付或者尚未出库的量。available_stock可用库存表示当前还能下单购买的量。每次下单时先“预占”库存可用库存减少、锁定库存增加。支付完成后“确认扣减”锁定库存减少、实际出库量增加。订单取消或超时未支付时“释放库存”锁定库存减少、可用库存增加。这套模型虽然多几个字段但每一步操作都有据可查盘点时也能分清到底是用户买了没付钱还是已经出库了。4.2 预占、回滚与确认扣减三步缺一不可预占库存的SQL要写成原子操作防止并发超卖UPDATE sku_stock SET available_stock available_stock - 1, locked_stock locked_stock 1 WHERE sku_id ? AND available_stock 1;执行后检查影响行数如果影响行数为0说明库存不足直接返回下单失败。如果影响行数为1预占成功可以继续创建订单。订单创建后通常会有一个支付超时时间比如30分钟。超时未支付要释放库存这个一般通过订单系统的定时任务扫描实现。前提是被取消的订单关联的库存释放操作必须幂等出现重复释放就会导致库存虚增。我一般会在“库存流水表”上做约束一个订单对一个SKU只能有一条有效的预占流水释放时把对应流水标记为已释放。确认扣减发生在支付回调之后这里要处理好“支付成功但库存预占流水已经释放”的极端情况。最稳妥的方案是支付回调里先查订单状态如果订单已经处于超时关闭状态则走售后退款流程而不是直接发货。支付和订单的时序问题是电商里最复杂的几个角落之一但依靠状态机可以在很大程度上规避。4.3 高并发秒杀场景下的库存扣减方案秒杀场景下如果直接对UPDATE sku_stock上锁数据库很可能扛不住几千并发。业界有一种通用的做法把库存预热到Redis用Redis的原子操作扣减库存再通过MQ异步同步回数据库。// 伪代码Redis扣减库存 Long remain redisTemplate.opsForValue() .decrement(stock:sku: skuId); if (remain 0) { // 扣减成功发送消息异步创建订单 } else { // 扣减失败回补 redisTemplate.opsForValue().increment(stock:sku: skuId); }这里要注意Redis扣减库存同步到数据库的过程也会存在不一致风险所以需要引入对账任务把Redis中的剩余量和数据库中的剩余量定时校对。秒杀系统还有更复杂的办法批次扣减、分段库存、本地缓存、漏斗限流但商品模块的基础模型不变Redis做前置挡板数据库做最终账本。4.4 库存流水与对账我强烈建议从一开始就建立一张库存流水表每笔预占、释放、扣减、入库都记录一条流水。流水表字段包括id、sku_id、order_id可空、change_type预占/释放/扣减/入库/盘盈/盘亏、change_qty、before_qty、after_qty、create_time。有了这张表出问题了可以追根溯源而不是对着一个库存数字瞎猜。库存对账可以这么跑把每个SKU的当前可用、锁定、总库存和流水表中累计变更做差值比对不一致的列出来人工核查。这个逻辑不复杂但对于大型系统来说它价值极大。5. 商品信息的缓存与多端同步5.1 商品详情的缓存策略别把MySQL打到死商品详情是电商系统里读多写少的典型场景一个热门SPU在几分钟内可能被读几万次但它的内容可能一天才改一次。所以缓存是非常有必要的。我习惯把商品信息分成三层缓存SPU基本信息缓存、SKU列表缓存、SKU价格与库存缓存。前两层变化频率很低设置缓存时间可以长一些例如30分钟或1小时。第三层价格库存变化频繁缓存时间短一些例如1-5分钟。Redis里Key的设计要注意不能直接把整个SPU包含SKU的所有信息塞进一个巨大的JSON里否则任何一个小字段变更都要刷掉整个大Key。拆成细粒度Key的好处是运营改了一张主图只需要删spu:detail:{spuId}不会影响价格库存缓存。我的Key命名习惯spu:base:{spuId} - SPU基本信息 spu:skus:{spuId} - SKU列表不含价格库存 sku:price:{skuId} - SKU价格 sku:stock:{skuId} - SKU库存5.2 缓存与数据库的一致性更新数据库后删缓存即可关于缓存一致性业界其实没有银弹。我最推荐的简单方案是“更新数据库后删除缓存读的时候再回填”也就是Cache Aside Pattern。这个方案虽然会有一个极短的时间窗口内缓存是空的但因为回填很快大多数场景都能接受。有一个细节值得注意删除缓存一定要在事务提交之后执行不要放在事务提交之前。否则事务还没提交另一个线程已经把旧数据回填进缓存然后事务回滚了缓存和数据库就不一致了。另外为了避免删除缓存失败的问题可以使用延迟双删或者通过MQ异步再删一次。对于商品模块这种对一致性要求不是极其严格的场景Cache Aside加上对账定时任务已经够用。5.3 搜索索引的同步走MQ比双写数据库更稳妥搜索系统通常不用MySQL直接查而是同步一份数据到Elasticsearch。同步的触发点有商品上架、商品下架、价格变更、库存变更、标题修改。如果这些变更都直接在业务代码里写ES业务代码会越来越臃肿而且任何一个写ES的接口失败都可能导致商品搜索不到。更稳妥的方案是商品信息的变更统一发一条MQ消息搜索服务消费消息后再更新ES索引。消息体里只需要带spu_id和event_type搜索服务根据事件类型决定是全量刷新这个SPU的文档还是只更新部分字段。这里还要注意删除索引的情况商品删除或者彻底下架后如果只是把ES里的文档删除用户搜索历史商品时就没有数据了。很多系统会保留一个“已下架”状态在ES里标记为不可搜而不是物理删除。这样即使商品重新上架索引同步也变成了更新操作不需要全量重建。6. 常见问题与排查技巧实录6.1 商品模块的典型线上事故第一个典型事故是SKU的规格JSON序列化格式不统一。有的接口返回的是数组有的返回的是字符串前端解析直接报错。这个问题背后是各个服务对sale_attrs字段的处理逻辑不一致。解决方法是定义一个统一的DTO类所有接口都通过这个类序列化/反序列化不允许直接操作JSON字符串。第二个事故是分布式定时任务重复跑导致重复上架。某次大促前我们同时起了两个应用实例都在跑定时上架的任务结果商品的上架记录出现了多条。排查后发现是分布式锁的Key没有带任务名称所有任务共用了一个锁后来又加了业务维度的锁Key问题才解决。第三个事故是库存超卖。当时问题出在“预占”和“扣减”两个操作之间用户下单预占成功后付款前又取消了订单但取消逻辑里对订单状态和库存流水的判断不完整导致库存释放了两次。从那以后我在库存释放的逻辑里强制要求先查库存流水表判断该订单是否已经释放过释放操作才执行。6.2 排查问题时的通用思路商品模块的问题很多都出在数据一致性上所以排查思路比较固定先确认问题在哪一层是展示层缓存、业务层服务日志还是存储层数据库流水。看关键IDspu_id、sku_id、order_id三个ID把整个链路串起来。查库存流水表看预占、释放、扣减是否成对出现。查MQ消息消费记录确认搜索/缓存同步是否落后。最后再考虑缓存击穿、穿透等性能问题不要把性能问题放在一致性排查前面。6.3 商品模块设计速查表整理了一张速查表设计评审的时候可以对照着过一遍检查项推荐做法常见错误SPU/SKU模型必须拆分一张表存所有商品字段销售属性SKU维度结构化存储塞大JSON不解析类目层级灵活层级类目属性绑定写死三级目录商品状态生命周期状态审核状态分离用一个字段硬扛库存模型总库存/锁定库存/可用库存分字段只有一个库存字段库存扣减原子更新条件判断先查后改非原子操作缓存Key按SPU/SKU维度拆分整个商品一个大Key缓存一致性更新后删缓存先删缓存再更新搜索同步MQ异步实现业务代码直接写ES定时任务分片/分布式锁幂等裸奔跑定时任务6.4 一点个人经验商品模块做到后期技术方案反而变得不那么复杂了真正复杂的是对业务规则的理解。每个业务方都会对商品有各种各样的要求运营要能快速改价采购要算库存周转财务要对账搜索要排序售后要退款。商品模块作为这些业务线的数据源头建模一旦定下来后面的改动成本都很高。我个人的建议是先把SPU/SKU、类目、属性、库存、状态机这几张核心表和状态流转画清楚再往上层加接口。别一上来就想着搞微服务拆分、搞复杂配置中心商品模块的核心永远是“数据结构清晰”和“状态流转可靠”这两件事。只要这两件事稳住了后续接什么营销活动、客服系统、数据分析都是水到渠成的事。最后再分享一个小技巧商品模块的表结构和字段建议从第一天就加上update_time和create_time并且明确谁负责更新。很多前同事觉得这两个字段是DBA和框架自动维护的不需要关心但实际上排查数据问题时这两个时间字段是判断脏数据产生时间的重要线索。哪怕系统再简单也不要在这些基础字段上偷懒。