一次上架,两个 SKU
「拾光集市」是一个卖生活方式商品的小型电商,本书用它承载交易域的例子。文中出现的用户量、订单量、库存量和金额都是案例假设值,用来演示判断过程,不是真实系统的实测。
大促前,运营要上架「春日手冲套装」。这个商品有两个属性:颜色(红/蓝)和容量(250g/500g)。两个运营各自在后台录入,A 先填颜色再填容量,B 先填容量再填颜色。系统把表单里的属性按提交顺序拼成一个字符串当作业务键,于是同一组「红色 + 250g」生成了两个 SKU:sku-1001 和 sku-1002。
更麻烦的是库存。仓库实际只有 100 套红色 250g,录入时两边各填了 100,系统按 SKU ID 建了两个库存桶。大促当天两个链接都被下单,总共卖出 180 套,仓库只能发出 100 套。这不是并发扣减的问题,而是商品模型在源头就把一个可售单元拆成了两个。
商品、SPU 与 SKU 各放什么
商品域最常见的混乱,是把「商品」和「可售单元」当成同一张表。实际业务里至少有两层:SPU(标准产品单元,可以理解为商品)和 SKU(库存量单位,也就是用户真正下单的东西)。再往下才是某个 SKU 的库存。
| 层级 | 例子 | 应该放什么 | 不应该放什么 |
|---|---|---|---|
| SPU / 商品 | 春日手冲套装 | 标题、卖点、类目、品牌、详情页 | 价格、具体库存、变体属性组合 |
| SKU | 红色 / 250g | 属性组合、价格、上下架状态、条码 | 商品长文案、跨 SKU 的营销规则 |
| 库存 | sku-1001 | 可用数量、预留数量、仓库维度 | 价格、商品文案、订单信息 |
这张表的关键不是分层本身,而是每一层的主键。SPU 的主键是商品 ID;SKU 的主键是商品 ID 加属性组合的规范形式;库存的主键必须能唯一落到一个 SKU。只要其中一层用了「看起来唯一、其实会重复」的键,后面的库存、订单和售后都会跟着分裂。
属性组合为什么要稳定 Key
真正稳定的身份不是数据库自增 ID,而是「商品 + 属性组合」的规范形式。自增 ID 只能保证两行不同,不能保证它们描述的不是同一个东西。两个运营录入了同一组属性,数据库会老老实实给两个 ID,却没有任何机制告诉你这是重复。
规范形式需要三条规则。
- 先排序再拼接。属性是一个键值集合,不是有序列表。遍历 Go 的 map 时顺序是随机的,直接拼接会让同一个 SKU 在两次进程里得到不同的 Key。按属性名排序后,
color=red&size=m和size=m&color=red才会收敛成同一个字符串。 - 转义键和值。如果直接用
&和=做分隔符,值里恰好包含这些字符时就会产生歧义:variant=red&size=m到底是一个属性还是两个属性?用 URL 查询串的转义规则,把分隔符变成%26、%3D,Key 才是单射的。 - 把商品 ID 放进 Key。不同商品可以有完全相同的属性组合,例如两个商品都有「红色 / 250g」。Key 必须带上商品维度,否则跨商品的 SKU 会碰撞。
规范化之后,Key 可以是 prod-coffee-set|color=red&size=m 这样的字符串。它不依赖数据库 ID,不依赖 map 遍历顺序,也不依赖录入表单的字段顺序。唯一索引建在这个 Key 上,重复录入在写入时就会失败,而不是等到大促当天才由仓库发现。
价格为什么放在 SKU
价格放在 SKU,而不是商品,原因有三个。
第一,不同变体的成本不同。250g 和 500g 的咖啡豆,包装、运费和毛利都不一样;如果商品只有一个价格,运营只能取最低价展示,结算时再靠人工改价,迟早出错。
第二,价格是 SKU 级别的生命周期字段。同一个商品下,红色可能在大促降价,蓝色保持原价;某个 SKU 下架时,价格历史要保留,不能因为商品还在售就把它抹掉。
第三,订单行需要可追溯的价格快照。用户下单时,订单行记录的是 SKU ID 加当时的单价(用 int64 分表示),而不是「商品当前价格」。商品价格之后怎么改,都不应该影响已经创建的订单。
商品详情页上展示的「到手价」通常是所有在售 SKU 的最低值,这是一个读模型,由 SKU 价格聚合出来,而不是商品表里的一个字段。把展示价和结算价分开,能避免「页面写 99、结算变 129」这类争议。
失败场景:同一组合被卖成两份库存
回到「春日手冲套装」。两个 SKU 各自绑定一个库存桶,每个桶 100 件。订单服务在扣减时只认 SKU ID,它不知道 sku-1001 和 sku-1002 描述的是同一件东西。于是:
- 两个商品链接都能下单,前端看起来是两个不同的规格;
- 库存校验各自通过,因为每个桶都还有 100 件;
- 仓库实际只有 100 件,卖出 180 件后,80 个订单无法履约;
- 客服把两个链接合并时,已经创建的订单行仍然引用两个不同的 SKU ID,退款和换货都要额外映射。
修复这类问题,不能只删掉重复的 SKU。历史订单、购物车、收藏和库存流水都可能引用旧 ID。更稳妥的做法是保留旧 ID 作为别名,指向规范 Key,再逐步迁移;同时在上架写路径上强制执行 Normalize 和 Key,并在数据库里对 (product_id, canonical_key) 建唯一索引。
预防比修复便宜:如果录入时就因为唯一索引失败,运营会立刻发现「这组属性已经存在」,而不是等到库存被卖穿。
代码:Normalize 与 Key
下面的 internal/catalog/sku.go 把校验和规范化放在一个纯函数里。Normalize 负责拒绝非法 SKU,并复制属性 map,避免调用方后续修改影响已经算出的 Key;Key 在规范化之后排序、转义,再拼成稳定字符串。
// file: internal/catalog/sku.go
// tier: L1
// verify: go test ./internal/catalog/
// Package catalog holds the product, SKU, stock and reconciliation rules used
// by the transaction service in this book.
package catalog
import (
"errors"
"fmt"
"net/url"
"sort"
"strings"
)
// SKU is the smallest sellable unit. Price lives here rather than on the
// product because two variants of the same product may sell at different
// prices, and a price change on one variant must not touch the other.
type SKU struct {
ID string
ProductID string
Attrs map[string]string
PriceCents int64
Status string
}
const (
StatusDraft = "draft"
StatusOnSale = "on_sale"
StatusOffSale = "off_sale"
)
var (
ErrInvalidSKU = errors.New("catalog: invalid sku")
ErrInvalidPrice = errors.New("catalog: invalid price")
ErrInvalidStatus = errors.New("catalog: invalid status")
ErrInvalidAttr = errors.New("catalog: invalid attribute")
)
// Normalize validates a SKU and returns a copy that does not alias the
// caller's attribute map. Rejecting empty keys and values keeps the canonical
// key unambiguous: an attribute that exists but has no value is a data bug,
// not a variant.
func Normalize(s SKU) (SKU, error) {
if strings.TrimSpace(s.ID) == "" || strings.TrimSpace(s.ProductID) == "" {
return SKU{}, ErrInvalidSKU
}
if s.PriceCents < 0 {
return SKU{}, ErrInvalidPrice
}
switch s.Status {
case StatusDraft, StatusOnSale, StatusOffSale:
default:
return SKU{}, ErrInvalidStatus
}
attrs := make(map[string]string, len(s.Attrs))
for k, v := range s.Attrs {
if strings.TrimSpace(k) == "" || strings.TrimSpace(v) == "" {
return SKU{}, fmt.Errorf("%w: empty key or value", ErrInvalidAttr)
}
attrs[k] = v
}
s.Attrs = attrs
return s, nil
}
// Key returns the canonical identity of a product variant. Attribute order in
// the map never changes the result, and url escaping keeps a value containing
// "&" or "=" from colliding with the separator. Two rows that describe the
// same variant therefore produce the same key no matter how the editor saved
// them.
func Key(s SKU) (string, error) {
n, err := Normalize(s)
if err != nil {
return "", err
}
keys := make([]string, 0, len(n.Attrs))
for k := range n.Attrs {
keys = append(keys, k)
}
sort.Strings(keys)
pairs := make([]string, 0, len(keys))
for _, k := range keys {
pairs = append(pairs, url.QueryEscape(k)+"="+url.QueryEscape(n.Attrs[k]))
}
return n.ProductID + "|" + strings.Join(pairs, "&"), nil
}
几个边界值得说明。
ID和ProductID都要求非空。前者是数据库行标识,后者是商品维度;缺任何一个都无法定位 SKU。PriceCents用int64表示分,负数直接拒绝。金额计算不引入浮点,避免 0.1 + 0.2 这类误差在结算和对账里放大。Status只接受draft、on_sale、off_sale三个值。自由字符串看起来灵活,实际上会让查询和状态机到处出现拼写错误。- 空
Attrs是允许的,表示这个商品没有变体;但属性键或值为空要拒绝。空值不是「没有这个属性」,而是录入不完整。 Normalize返回的是副本,不是原对象。Go 的 map 是引用类型,如果直接返回入参,调用方改一个值就会悄悄改掉已经计算出的 Key。
Key 的格式不是协议,不需要对外承诺;需要承诺的是「同一组商品和属性,永远得到同一个字符串」。只要这个性质成立,数据库唯一索引、缓存键和幂等键都可以复用它。
测试:顺序、转义与非法输入
测试要锁住三件事:属性顺序不影响 Key、值里的分隔符不会造成碰撞、非法输入在写入前就被拒绝。
// file: internal/catalog/sku_test.go
// tier: L1
// verify: go test ./internal/catalog/
package catalog
import (
"errors"
"testing"
)
func validSKU() SKU {
return SKU{
ID: "sku-1001",
ProductID: "prod-coffee-set",
Attrs: map[string]string{"color": "red", "size": "m"},
PriceCents: 12900,
Status: StatusOnSale,
}
}
func TestNormalizeReturnsIsolatedCopy(t *testing.T) {
in := validSKU()
got, err := Normalize(in)
if err != nil {
t.Fatal(err)
}
if got.ID != in.ID || got.ProductID != in.ProductID || got.PriceCents != in.PriceCents {
t.Fatalf("normalize changed scalar fields: %+v", got)
}
got.Attrs["color"] = "blue"
if in.Attrs["color"] != "red" {
t.Fatal("Normalize aliased the caller's attribute map")
}
}
func TestNormalizeRejectsBadFields(t *testing.T) {
cases := []struct {
name string
in SKU
want error
}{
{"empty-id", func() SKU { s := validSKU(); s.ID = " "; return s }(), ErrInvalidSKU},
{"empty-product", func() SKU { s := validSKU(); s.ProductID = ""; return s }(), ErrInvalidSKU},
{"negative-price", func() SKU { s := validSKU(); s.PriceCents = -1; return s }(), ErrInvalidPrice},
{"unknown-status", func() SKU { s := validSKU(); s.Status = "selling"; return s }(), ErrInvalidStatus},
{"empty-attr-key", func() SKU { s := validSKU(); s.Attrs = map[string]string{"": "red"}; return s }(), ErrInvalidAttr},
{"blank-attr-value", func() SKU { s := validSKU(); s.Attrs = map[string]string{"color": " "}; return s }(), ErrInvalidAttr},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := Normalize(c.in); !errors.Is(err, c.want) {
t.Fatalf("err = %v, want %v", err, c.want)
}
})
}
}
func TestKeyIsStableAcrossAttributeOrder(t *testing.T) {
a := validSKU()
a.Attrs = map[string]string{"color": "red", "size": "m"}
b := validSKU()
b.Attrs = map[string]string{"size": "m", "color": "red"}
ka, err := Key(a)
if err != nil {
t.Fatal(err)
}
kb, err := Key(b)
if err != nil {
t.Fatal(err)
}
if ka != kb {
t.Fatalf("keys differ by map order: %q vs %q", ka, kb)
}
const want = "prod-coffee-set|color=red&size=m"
if ka != want {
t.Fatalf("key = %q, want %q", ka, want)
}
}
func TestKeyEscapesSeparators(t *testing.T) {
merged := validSKU()
merged.Attrs = map[string]string{"variant": "red&size=m"}
split := validSKU()
split.Attrs = map[string]string{"variant": "red", "size": "m"}
km, err := Key(merged)
if err != nil {
t.Fatal(err)
}
ks, err := Key(split)
if err != nil {
t.Fatal(err)
}
if km == ks {
t.Fatalf("ambiguous attribute values collapsed to %q", km)
}
}
func TestKeyAllowsEmptyAttrs(t *testing.T) {
s := validSKU()
s.Attrs = nil
got, err := Key(s)
if err != nil {
t.Fatal(err)
}
if got != "prod-coffee-set|" {
t.Fatalf("key = %q, want product prefix with empty attrs", got)
}
}
前两个用例覆盖稳定性和转义。TestKeyIsStableAcrossAttributeOrder 用两个属性插入顺序相反的 map,断言 Key 完全相等;TestKeyEscapesSeparators 则构造一个「值里包含 & 和 =」的 SKU,确认它不会和「两个独立属性」得到同一个 Key。
TestNormalizeReturnsIsolatedCopy 容易被忽略。它修改 Normalize 返回值的属性,再检查入参没有被改动。没有这条测试,某次「优化」把复制去掉,代码仍然能通过大部分业务用例,却会在并发场景下产生难以复现的 Key 漂移。
表驱动测试里还有空 ID、空商品 ID、负价格、未知状态、空属性键和空白属性值。这些错误都应该在 Normalize 阶段返回,而不是留到库存扣减或订单创建时才发现。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/catalog/ -count=1
一次本机运行的真实输出如下,耗时随机器变化:
ok webapp/internal/catalog 0.228s
ok 表示包内所有测试通过。这个包同时包含 SKU、库存和对账三组测试;本章后续两节会继续往同一个包里加文件,所以输出里的包名始终是 webapp/internal/catalog。
小结
商品模型的核心不是「商品表怎么建」,而是「什么决定一个可售单元的身份」。SPU 承载商品信息,SKU 承载属性组合、价格和状态,库存再按 SKU 落地。属性组合必须经过排序、转义并带上商品维度,才能得到稳定 Key;价格必须放在 SKU 上,才能支持变体差异、价格历史和订单快照。
「春日手冲套装」的事故说明,重复 SKU 的代价不会停在商品域:它会分裂库存、污染订单、增加售后映射。把规范化写进代码、把唯一索引建在 Key 上,是成本最低的防线。
阅读导航:上一节:目录与学习路径 · 下一节:1.2 库存扣减与超卖 。
常见错误与边界
- 直接按表单提交顺序拼接属性,同一个组合在两次录入后得到两个 Key。
- 遍历 map 拼字符串,进程重启后 Key 顺序变化,缓存和幂等键全部失效。
- 不转义分隔符,属性值里出现
&或=时与另一个组合碰撞。 - 只用 SKU ID 做业务唯一键,重复录入无法在上架阶段被发现。
- 把价格放在商品表,多规格商品只能取最低价或人工改价。
- 把金额存成浮点或字符串,结算、退款和对账各自解析出不同结果。
- 发现重复 SKU 后直接删除,历史订单和库存流水失去引用。
- 允许空的属性键或属性值,Key 里出现
=片段,后续无法区分「没填」和「填了空」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。