2.1 商品模型:SPU 与 SKU 表设计
场景:一个商品配置改半天,活动差点上不了线
第一版秒杀我们把商品信息塞在一张表里,结果上线前一天运营要给秒杀价单独加个库存字段,DBA 一看表里已经有四十多个字段,改一个字段牵连三处代码、两个缓存键。更糟的是秒杀价和日常价混在同一个 price 字段里,靠一个 is_seckill 标记位区分,某个接口忘了判断标记位,秒杀商品按日常价卖了半小时,亏了十几万。痛完之后我们老老实实把 SPU 与 SKU 拆开,秒杀活动挂在 SKU 层。
拆表前后的对比非常直观,团队的直观感受是维护上的突变:
| 对比项 | 拆表前 | 拆表后 |
|---|---|---|
| 改一次秒杀价 | 改表加改三处代码 | 只改活动表一行 |
| 新增一场秒杀 | 与日常销售互相干扰 | 独立场次互不影响 |
| 商品详情缓存 | 整体失效 | 分层失效 |
| DBA 心理状态 | 每次发版如临大敌 | 常规变更 |
表结构维护这件事,一开始省的每一步设计功夫,后面都要用事故来还。
方案:SPU 与 SKU 与秒杀活动三层分离
商品领域的经典拆法是 SPU 描述是什么商品,SKU 描述具体哪个规格。秒杀活动的最小单位是 SKU,因为用户抢的是那台 8 加 256 的手机,不是一个抽象的商品概念。三层数据模型各管一段:
| 层级 | 存什么 | 例子 | 变更频率 |
|---|---|---|---|
| SPU | 商品类目级信息 | 某品牌手机,标题、详情图 | 低 |
| SKU | 规格级信息与日常价 | 8 加 256 版本,日常价 4999 | 中 |
| 秒杀活动 | 秒杀价、秒杀库存、时间段 | 周晚八点场,秒杀价 3999 | 高 |
这样拆的好处直接体现在变更隔离上:运营改秒杀价只动活动表,商品详情页的日常价缓存完全不受影响;改 SKU 只影响规格相关缓存;SPU 层面更是几天才有一次改动。缓存键互不连累,这在洪峰期特别重要。秒杀商品的表结构上线后,我们还把所有历史商品数据做了迁移,迁移过程中发现的字段缺失问题反过来又推动了 SPU 表字段的一次规范化,算是意外的收获。
再补充一点为什么秒杀活动绝不能挂在 SPU 上。SPU 是规格抽象,一个 SPU 下往往有四五个 SKU,库存各自独立。如果秒杀挂在 SPU,用户看到的是抽象的低价,点进去发现只有指定规格才参与活动,这种货不对板的体验在秒杀场景会被放大十倍——用户本来就只有几秒钟决策时间。挂在 SKU 上,运营配置的是具体某个规格的库存与价格,用户看到什么就抢到什么,不存在歧义。下面这张字段清单是我们最终落地的版本,供参考:
| 表 | 必备字段 | 说明 |
|---|---|---|
| spus | id、title、category_id、detail_images | 类目与图文 |
| skus | id、spu_id、spec_name、price、attrs | 规格与日常价 |
| sku_stocks | sku_id、stock、version | 日常库存与乐观锁版本 |
| seckill_sessions | sku_id、total_stock、price、limit_per_user、时间窗 | 秒杀场次 |
为什么不直接在 SKU 表上加秒杀价和秒杀库存字段?因为秒杀活动是周期性的、一场接一场,同一天可能有上午场和晚场,场与场之间库存独立、价格不同。塞在 SKU 表里要么字段爆炸要么靠软删除区分历史场次,两种做法维护起来都是灾难。
配置:三张表与关联模型
Schema::create('spus', function (Blueprint $table) {
$table->id();
$table->string('title', 128);
$table->unsignedInteger('category_id')->index();
$table->json('detail_images');
$table->timestamps();
});
Schema::create('skus', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('spu_id')->index();
$table->string('spec_name', 64); // 如:8GB+256GB 钛色
$table->unsignedInteger('price'); // 日常价,分
$table->json('attrs');
$table->timestamps();
});
秒杀活动表在 1.1 节已经给过定义,这里补充它与 SKU 的关联查询方式,Laravel 的关联模型让三层关系很清爽。实际开发中我们还在 Sku 模型里加了一个只读访问器,直接把当前生效的秒杀价带出来,商品列表接口因此少了一次循环查询,接口耗时下降比较明显。关联查询一定要避免 N 加一问题,否则列表接口会随着数据量增长退化成几十条 SQL,影响用户首屏体验。
class Sku extends Model
{
public function spu(): BelongsTo
{
return $this->belongsTo(Spu::class);
}
public function liveSeckill(): HasOne
{
// 当前生效的秒杀场,按服务器时间过滤
return $this->hasOne(SeckillSession::class)
->where('status', 'live');
}
}
商品详情接口的取数顺序固定为:从缓存取 SKU,未命中再查库;秒杀信息从独立的缓存键取,与商品缓存物理隔离。这个设计在 2.4 节会展开成完整的多级缓存方案。取数顺序之所以要定死,是因为不同接口的容错要求不同。商品详情页可以接受读不到秒杀价(大不了显示日常价),但下单接口绝不能接受读到旧库存。所以我们给读操作也分了级别,下单链路上的关键读操作一律跳过本地缓存直查 Redis:
| 读操作 | 可走的缓存层 | 容忍的延迟 |
|---|---|---|
| 商品标题、图片 | CDN 加本地缓存 | 秒级 |
| 秒杀价、限购规则 | 本地缓存加 Redis | 秒级 |
| 库存余量(展示) | 仅 Redis | 亚秒级 |
| 下单前余量校验 | 仅 Redis 原子读 | 毫秒级 |
这张表后来成为所有秒杀接口 code review 的对照清单,新人来的第一周先背熟它,写接口少走很多弯路。
踩坑:表设计上的三个回头坑
坑一:金额用浮点数存。 早期有的同学为了省事用 decimal 之外更随意的 float,对账时尾差对不上,财务多跑了两天的账。我们整条链路统一用整数分存储,前端展示时再除以 100,彻底告别浮点精度问题。这件事还专门在全组做了一次技术分享,因为金融级的教训最容易被新人重新犯一遍,值的教训值得反复讲。
坑二:秒杀库存与 SKU 库存共用一个字段。 秒杀的 500 件和日常销售的库存必须物理分开,否则超卖判断会互相串。我们的做法是 SKU 表只保留日常库存,秒杀库存独立存在活动表里,两者永不交叉。
坑三:规格字段塞 JSON 不建索引。 前台按规格筛选商品时全表扫描,慢查询告警在大促前两天突然冒出来。解决方案是高频筛选字段单独抽出列并建索引,JSON 只承担低频展示属性。回头看这些都是基础功课,但在赶工时最容易省掉的就是数据建模这一步。
坑四:表结构改了但存量数据没洗。 拆表上线那天晚上,新表里只有部分商品的历史价格同步过来了,剩下的是默认值,导致详情页显示价格为零,运营半夜打电话过来。从那以后表结构变更必须附带数据迁移脚本与回滚脚本,并在灰度环境跑一遍数据比对,比对报告里存量记录数、关键字段非空率、抽样一致性三项全部达标才允许上线。DBA评审清单也是这个时候建立起来的。
小结
商品模型是秒杀系统的地基,SPU、SKU、秒杀活动三层分离后,价格改不动、库存串不了、缓存互相不连累。把这一步做扎实,后面库存扣减和对账才有可靠的落点。建表这件事,慢一点就是快。最后给打算重构商品表的同行一个实用建议:重构期间新旧表并行双写一个月,读流量先切一半观察,确认无误再全量切换,整套迁移零停机完成,用户全程无感。迁移完成后留下一份字段对照表,后续每次加字段都按这份表的标准格式登记,表结构文档从此再也没有过期过,DBA 与后端的沟通成本肉眼可见地降低。如果只能记住一条规范,那就是:库存字段绝不允许出现在 SPU 表里,出现在哪一层就回到哪一层去,这条规矩我们贴在数据建模规范的扉页上,每次评审先过这一条,违规设计当场打回,这比事后修补便宜十倍。
