《互联网产品的服务端架构·交易、增长与平台化》1.1 商品模型与 SKU

从「拾光集市」一次上架生成两个 SKU、同一属性组合被卖成两份库存的事故出发,拆解商品、SPU 与 SKU 各自该放什么,说明属性组合为什么要排序、转义并生成稳定 Key,价格为什么必须落在 SKU 上,并附可运行的 Go 校验函数与表驱动测试。

一次上架,两个 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,却没有任何机制告诉你这是重复。

规范形式需要三条规则。

  1. 先排序再拼接。属性是一个键值集合,不是有序列表。遍历 Go 的 map 时顺序是随机的,直接拼接会让同一个 SKU 在两次进程里得到不同的 Key。按属性名排序后,color=red&size=m 和 size=m&color=red 才会收敛成同一个字符串。
  2. 转义键和值。如果直接用 & 和 = 做分隔符,值里恰好包含这些字符时就会产生歧义:variant=red&size=m 到底是一个属性还是两个属性?用 URL 查询串的转义规则,把分隔符变成 %26、%3D,Key 才是单射的。
  3. 把商品 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 里出现 = 片段,后续无法区分「没填」和「填了空」。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查