当一个电商系统只在一个国家或一个机房跑时,商品数据往往只需要一张简单的 items 或 spu 表加上几张关联表就能支撑。运营录入标题、价格和库存,买家在前台浏览下单,结构非常直接。

业务扩到多个国家和大区以后,同一套商品数据要同时应付几件事:

  • 同一件冲锋衣,美东站卖 120 美元配英文详情,巴西站卖 600 雷亚尔配葡语详情;沙特站的受监管商品还要先在 SABER 平台拿到符合性证书,海关才放行(见美国商务部 trade.gov 的说明,2018-09-18)。
  • 服装关心尺码和面料,数码产品关心芯片和电池,食品要标保质期和过敏原。每类参数都在老表上加一列,表会越来越宽,字段含义互相打架。
  • 大区离中心远。Azure 公布的区域间往返延迟(P50,2026-07-30 结束的 30 天数据)里,美东到东南亚是 224 毫秒(见 Azure network round-trip latency statistics),页面请求没法每次都跨洋读中心库。
  • 发到各大区的数据要是审核过的完整版本,分发途中还会遇到重复投递、乱序和绕过管道的手工改库。
  • 大促时,商品详情页(PDP)的读请求集中打在各个大区。

解决思路是把职责分到中心和大区两边。中心是一套 PIM(Product Information Management,商品信息管理)加 MDM(Master Data Management,主数据管理)的系统,作为企业商品数据的单一事实源(Single Source of Truth),负责定义商品、审核和发布,Zalando 自研的 Smart Product Platform 就是这两者的组合。各个大区保存只读副本,就近服务读请求。

整体架构#

flowchart LR subgraph Center["中心:PIM / MDM"] BO["运营后台
草稿、审核、打版"] --> DB[("中心库
版本快照 + outbox 表")] end DB -->|"Debezium 读 binlog"| K["Kafka
按 spu_id 分区"] K --> AG["大区同步代理
按版本号幂等写入"] AG --> RDB[("大区只读库")] AG -->|"写库后发失效事件"| APP["大区应用
Redis + 进程内缓存"] RDB --> APP APP --> U["本地买家"] DB -.->|"定期对账"| RDB

大区用事件在本地维护一份商品数据,Martin Fowler 把这种做法叫 Event-Carried State Transfer,并列了它的好处:接收方不用远程调用,延迟低;源系统不可用时,接收方照样能工作;查询压力也不落在源系统上(见 What do you mean by “Event-Driven”?)。Facebook 的 memcache 部署也是一个区域放主库、其他区域放只读副本,Web 服务器只访问本地的缓存和副本(见 Scaling Memcache at Facebook §5)。代价是大区的数据会比中心晚到,「数据发布与跨大区同步机制」一节讲怎么让这份副本最终和中心一致。

概念模型:全球母商品与站点刊登#

站点之间的差异用两层模型隔开:跨国通用的物理属性放在全球母体上,各站点的语言、价格、合规资料和上下架状态放在站点刊登上。GS1 的 Global Data Model 也把属性分成全球层和区域层(见 GS1 Global Data Model Attribute Implementation Guideline,Release 1.17,2026-08)。

erDiagram GLOBAL_SPU ||--|{ GLOBAL_SKU : "包含规格型号" GLOBAL_SPU ||--|{ SITE_LISTING : "派生各站点刊登" SITE_LISTING ||--|{ SITE_SKU : "包含本地销售单元" CATEGORY ||--|{ GLOBAL_SPU : "约束类目属性" CATEGORY ||--|{ ATTRIBUTE_RULE : "定义参数规范" SITE_LISTING ||--o{ COMPLIANCE_DOC : "附加本地合规资质"

图里的 SPU(Standard Product Unit,标准化产品单元)指一款商品本身,不区分规格;SKU(Stock Keeping Unit,库存量单位)是能单独下单、单独计库存的具体规格,比如同一款冲锋衣的每个颜色和尺码组合(见阿里云开发者社区的电商黑话之 spu sku)。

下面三个小节依次讲全球母体放什么、站点刊登放什么,以及两层之间按什么规则合并。

全球母体(Global Master Item / SPU)#

全球母体记录一件商品在物理世界里的客观属性,这些属性不受销售国家的影响。无论这件衣服在哪个国家销售,它的面料成分、原始制造商代码、包装箱规格和标准尺码表都是固定不变的。国际条码(GTIN,也就是 UPC 或 EAN)按规格分配,GS1 要求每个尺码、每个颜色各有一个(见 GS1 的说明),所以它挂在全球 SKU 上。

母体层由供应链与品牌管理团队统一维护,全集团对「这到底是一件什么商品」只有一个标准定义,各个国家的分支团队不会重复建品,后台库存与采购汇总也不会乱(MDM 里这叫 golden record,见 IBM 对 master data management 的说明)。

站点刊登(Site Listing)#

站点刊登是全球母体在特定国家市场的投影,承接所有需要本地化的内容(Akeneo 用 localizable 和 scopable 属性 表达同一个属性按语言、按渠道取不同的值)。

同一个母体派生到美东站时,标题和详情渲染为美式英语,标价使用美元;派生到日本站时,详情翻译为日文,并附上 PSE 相关的合规资料(日本《电气用品安全法》范围内的电器,销售前必须标 PSE 标识,见 JQA 的说明)。站点层还控制商品在当地的上下架状态:某款商品没通过某个站点的海关环保审查,运营只在这个站点把它标记为禁用,其他站点照常销售(Wayfair 的 Findability Barrier Platform 就是按站点控制商品是否可见)。

继承规则#

站点刊登从全球母体派生,哪些字段能在站点上改按属性定,不逐个商品判断:

字段 维护在哪一层 全球值改了以后
面料、尺码表、制造商代码等物理属性 只在全球母体,站点只读 所有站点跟着变
主图、卖点这类默认沿用的字段 默认沿用全球值,站点可以标记为覆盖 沿用的站点跟着变,已覆盖的保留自己的值
本地化文案、价格、合规资料、上下架 只在站点刊登 不涉及,没有全球值

前两条都有产品做法可以对照:Akeneo 的共同属性存在 product model 上,在 variant 上只读(见 Enrich your products with variants);Adobe Commerce 的字段下面有「Use Default Value」勾选框,取消勾选才用本站点的值(见 Introduction to catalog management)。Pimcore 的规则是字段留空就继承(见 Data Inheritance),但这样分不清「沿用全球值」和「故意留空」,所以这里用显式的覆盖标记。

打版时,系统按这些规则把全球值和站点值合并成每个站点各自的快照。代价是全球母体改了一个默认沿用的字段,所有沿用它的站点都要重新打版、重新分发。

类目体系与动态属性引擎#

品类之间的参数差异交给类目属性规则处理。平台维护一套标准的后台分类树,每个分类节点上挂载属性规则配置(Attribute Rules)。当运营选择「笔记本电脑」类目时,系统会自动拉取该类目预定义的属性列表(Shopify 选定 product category 后会带出该类目的属性;Akeneo 用 family 规定一组商品有哪些属性、在哪些渠道必填):

{
  "category_id": 4012,
  "category_name": "Laptops",
  "attributes": [
    {
      "key": "cpu_model",
      "label": "处理器型号",
      "type": "string",
      "required": true
    },
    {
      "key": "ram_capacity_gb",
      "label": "运行内存容量",
      "type": "number",
      "unit": "GB",
      "required": true
    },
    {
      "key": "battery_type",
      "label": "电池类型",
      "type": "enum",
      "options": ["Li-ion", "Li-polymer"],
      "required": false
    }
  ]
}

属性值存成 JSON,前端按当前类目的规则生成表单和输入校验。新增品类或规格时只改类目规则,不改表结构(Akeneo 也把属性值存在 MySQL 的一个 JSON 列里,见 Bounteous 2019 年的 Akeneo: The Turducken of Product Information Management)。代价是按属性筛选要另建索引或交给搜索引擎:MySQL 的 JSON 列不能直接建索引(见 MySQL 文档),Akeneo 的商品搜索走 Elasticsearch。

数据发布与跨大区同步机制#

中心库的商品变更要送到各个大区。下面五个小节按数据经过的顺序讲,每一步对应分发里的一个问题:打版保证发出去的是审核过的完整版本,outbox 保证快照和事件一起提交,分区保证同一个商品的事件有序,版本号让重复和晚到的消息覆盖不了新数据,对账找出绕过管道的改动。

草稿暂存与打版固化(Draft & Stamp)#

运营在日常编辑商品时,所有的变动都暂存在草稿态(Draft),不会直接进入分发队列。因为一件商品的图文详情、类目参数和多币种标价往往需要多人协作,甚至需要合规团队进行质检与侵权扫描(Akeneo 的 Drafts & proposals 是同一类流程:编辑者的修改先成为草稿,提交后由负责人批准或驳回)。

当所有必要字段校验通过后,业务负责人点击「打版发布」(Stamp)。系统会在数据库中将当前草稿固化为一个带有单调递增版本号(Revision ID)的不可变快照。发布动作是一次原子提交,分发出去的数据是一个语义完整的集合,只写了一半的半成品不会同步到线上大区(commercetools 用 staged 和 current 两份商品数据做同样的事,发布时把 staged 复制到 current,见 Product Projections)。

Transactional Outbox 与 CDC#

写入快照和发出事件要一起成功,这里用 Transactional Outbox 加 CDC。打版时,系统在同一个数据库事务里写入版本快照,同时往 outbox 表写一条领域事件(如 ProductUpdated),两者一起提交(见 Pattern: Transactional outbox)。Debezium 读取 binlog,把 outbox 里的事件投递到 Kafka(见 Debezium 的 Outbox Event Router)。

按聚合根分区#

消息以商品的全局唯一码(如 product.spu_id)作为 Partition Key。Kafka 保证同一个分区内按写入顺序消费,所以同一个商品的事件是有序的;不同商品落在不同分区,大区可以按分区并行消费(见 Kafka 文档)。商品依赖的其他数据可能在别的分区、晚一步到,这时把消息转进重试 topic,等依赖就绪再处理;同一个商品后面的消息也要跟着转过去,才不会越过它先被应用(见 Confluent 的 Error Handling Patterns for Apache Kafka Applications)。

大区按版本号幂等写入#

每个大区的同步代理从 Kafka 拉取快照,写入本地只读库。Kafka 默认是 at-least-once:消费者处理完消息、还没保存 offset 就挂了,接手的进程会再处理一遍(见 Confluent 的 Message Delivery Guarantees)。所以写入按打版生成的版本号做幂等更新(如 ON DUPLICATE KEY UPDATE),版本比已有的旧就丢弃,重复和晚到的消息覆盖不了新数据(Elasticsearch 的 external versioning 用的是同一条规则)。

定期对账#

管道之外的改动,比如大区 DBA 直接登录本地库改了一个字段,CDC 感知不到,要靠定期对账发现。做法参考 Dynamo:按商品 ID 范围建 Merkle tree,两边比对哈希,只同步有差异的商品(见 Dynamo: Amazon’s Highly Available Key-value Store §4.7)。两边要对同一份数据算哈希,也就是发往这个大区的快照。每晚跑一次,漂移最长一天后才会被发现;发现后重新投递最新版本,所以大区的版本比对要接受同版本的写入。

海量读取下的缓存与失效治理#

大促时 PDP 的读请求集中在各个大区,大区本地库前面再加多级缓存:读请求先查缓存,没有再查库并回填,也就是 Facebook 所说的 look-aside cache(见 Scaling Memcache at Facebook §2);应用进程里再放一层本地缓存。

数据变了以后删缓存,不更新缓存:Facebook 的理由是删除是幂等的(同上 §2)。大区同步代理把新版本写入本地库之后,再发一条极简的失效消息;先写库再发失效,是为了避免失效比数据先到(同上 §5):

{
  "event": "PRODUCT_CACHE_EVICT",
  "product_id": "SPU-982314",
  "version": 2026092901
}

大区应用收到事件后,删掉 Redis 和进程内存里对应商品的缓存键。失效消息可能丢:Redis Pub/Sub 是 at-most-once,断线期间的消息不会补发(见 Redis Pub/Sub),所以本地缓存要设一个最大 TTL 兜底,这也是 Redis client-side caching 文档的建议。

缓存失效后的第一波回源用 Single-Flight 合并:同一个进程里只让一个请求查库并回填,其余请求等它的结果。合并只在进程内生效,N 个实例同时 miss 仍会查 N 次库;Facebook 的做法是用 lease 在缓存服务端限流(同上 §3.2.1)。

小结#

这套架构把多站点的问题分到中心和大区两边解决。中心的 PIM / MDM 用两层模型和按属性定的继承规则隔开站点差异,用类目属性规则处理品类差异,并通过草稿和打版只发布审核过的完整版本。大区保存只读副本就近服务读请求:outbox 与 CDC、按商品分区和版本号幂等写入让副本最终和中心一致,定期对账找出管道之外的改动,多级缓存和写后失效扛住大促读压力。

参考资料#