Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

附录H 全局 ID 体系设计

1. 为什么电商系统需要全局 ID 体系

在示例代码中,供给链路为了演示流程,使用了类似下面的写法:

s.repo.NextID(ctx, "draft")

仓储内部再用时间戳、前缀和内存序列拼出一个 ID。这种写法适合教学 Demo,因为它能让读者把注意力放在 Draft、Staging、QC、Publish 的业务流程上;但在生产系统中,这类发号逻辑很快会失控。

问题不在于这行代码短,而在于它跳过了一条完整的 ID 设计决策链。

第一步先判断业务语义draft_id 到底是供给流程里的临时单据 ID,还是正式商品 ID?如果它只是草稿单据,就不应该和 item_idsku_id 复用同一套语义;如果其他服务也要引用它,就必须进入统一 namespace 管理,而不能由某个 repository 临时定义 "draft" 前缀。

第二步判断唯一性边界:如果系统只有单进程,内存序列可能暂时可用;一旦扩展到多个实例,实例之间就必须通过 worker_id、号段分配、数据库唯一约束或中心化 ID 服务避免撞号。多实例解决的是“同一个集群内多个进程是否会生成相同 ID”。

第三步判断部署边界:从单机房迁到多机房后,问题会升级为 region / datacenter 之间是否会撞号。此时要考虑 region bits、独立号段、灾备切换、网络分区和数据汇合,而不仅仅是实例内的自增序列。

第四步判断时间依赖:如果 ID 里拼了时间戳,机器时钟回拨、NTP 抖动、容器迁移都会影响唯一性和排序语义。使用 Snowflake 一类方案时,必须明确时钟回拨时是等待、熔断、切换 worker,还是降级到备用策略。

第五步判断暴露范围:这个 ID 是只在内部日志和数据库中使用,还是会出现在 URL、开放 API、订单详情或客服系统中?如果对外暴露,连续递增或可预测格式可能泄露业务量,也可能带来枚举风险。

第六步判断失败语义:发号失败时业务能不能感知?是直接返回错误、重试、回滚,还是使用本地缓存号段继续服务?如果 ID 已经发出但业务事务失败,这个 ID 是否允许浪费?大多数生产系统的答案是:允许跳号,不允许复用。

第七步判断治理能力:namespace 谁审批?容量谁规划?号段快耗尽谁告警?重复冲突谁发现?发号 QPS、失败率、时钟回拨、worker 租约、审计日志在哪里看?如果这些问题没有归属,ID 生成就还只是工具函数,不是基础设施能力。

电商系统里的 ID 远不止一个 draft_id。商品有 item_idspu_idsku_id,交易有 checkout_idorder_idpayment_id,供给有 draft_idstaging_idqc_review_id,库存有 inventory_keyreservation_id,事件有 event_idoutbox_event_id,链路上还有 trace_idoperation_id。这些 ID 的业务语义、性能要求、暴露范围和容灾策略都不同。

把这条链路走完后,全局 ID 体系要回答的就不再是“如何拼一个字符串”,而是建立一套可治理的规则:

什么业务对象使用什么 ID 类型;
什么 ID 由哪个 namespace 管理;
什么 ID 可以对外暴露;
什么 ID 需要趋势递增;
什么 ID 需要可读业务单号;
什么 ID 只是幂等键或链路追踪键;
发号失败、重复、耗尽、时钟回拨时如何处理。

一句话概括:ID 体系是电商系统的基础设施治理问题,不只是一个工具函数问题。

2. 电商 ID 分类

设计 ID 之前,先不要问“用不用 Snowflake”,而要问“这个 ID 表达什么业务语义”。下表给出电商系统中最常见的 ID 分类。

类型典型字段设计重点不适合的做法
实体 IDitem_idspu_idsku_id长期稳定、索引友好、跨系统引用每个服务各自自增
业务单号order_nopayment_norefund_no对外展示、客服查询、对账、不可枚举直接暴露连续自增
流程单据 IDdraft_idstaging_idqc_review_id流程追踪、审计、低耦合与正式商品 ID 混用
事件 IDevent_idoutbox_event_id幂等消费、重放、排障用时间戳字符串拼接
幂等键idempotency_keyrequest_id表达同一次业务请求当作普通随机 ID
链路 IDtrace_idoperation_id跨服务追踪和审计每层重新生成

这张表背后的关键判断是:ID 的生成方式要服从它的业务用途。

例如,sku_id 通常是商品主数据的稳定实体 ID,适合使用 BIGINT,方便数据库索引、缓存 Key、消息体和下游系统引用;order_no 是对外业务单号,除了唯一之外,还要考虑客服查询、对账、不可枚举和格式兼容;idempotency_key 则不是普通 ID,它表达“同一次业务请求”,必须配合唯一索引和状态机来防止重复下单、重复扣款或重复退票。

3. 全场景 ID 清单

下面的矩阵不是要求所有公司都照抄,而是给出一个可评审的默认选择。实际落地时,可以根据规模、团队能力、数据库类型、是否多机房和是否对外开放 API 做取舍。

业务域关键 ID样例值推荐类型推荐生成方式设计说明
商品中心item_idspu_idsku_iditem_id=800000123456spu_id=700000123456sku_id=600000123456BIGINTSegment 号段或 Snowflake高频查询和跨系统引用,优先索引友好
商品组合offer_idrate_plan_idoffer_id=500000123456rate_plan_id=510000123456BIGINT 或字符串Segment,外部映射可用字符串本地 Offer 用平台 ID,供应商编码单独保存
供给流程draft_idstaging_idqc_review_iddraft_01HZY7K8J7W6S9B2Q5R4T3M1N0staging_01HZY7N4K9P8D7C6B5A4M3T2Q1qc_01HZY7R9S8T7V6W5X4Y3Z2A1B0字符串ULID/UUIDv7 + 受控 prefix流程单据不应与正式商品 ID 混用
供给任务task_idbatch_idsync_batch_idbatch_20260429_hotel_full_0001字符串ULID/UUIDv7 或业务时间分区编码长任务、批处理和补偿需要可追踪
库存事实stock_ledger_idreservation_idstock_ledger_id=920000123456rsrv_01HZY85D2K9M7N6P5Q4R3S2T1VBIGINT 或字符串Segment、Snowflake 或 ULID账本可用 BIGINT,预占凭证可用字符串
库存业务键inventory_keyinv:sku:600000123456:globalinv:sku:600000123456:date:2026-05-01:channel:app字符串业务组合键表达 SKU、范围、日期、渠道、供应商等维度
购物车cart_idcart_01HZY86K8V7T6S5R4Q3P2N1M0字符串或 BIGINT登录态绑定 user_id,游客车用 ULID登录购物车可弱化独立 ID,游客车需要会话标识
结算checkout_idchk_01HZY88P6Q5R4S3T2V1W0X9Y8Z字符串ULID/UUIDv7 + 幂等键一次结算会话要能重试、恢复和防重复
订单order_idorder_noorder_id=1928475629384753152order_no=ORD20260429CN7K3F9Q2X内部 BIGINT + 外部字符串Snowflake 派生业务单号内部主键和对外单号解耦
支付payment_idpayment_nochannel_trade_nopayment_no=PAY20260429F8K2M6Q9channel_trade_no=202604292200149876543210内部 BIGINT + 外部字符串Snowflake 或渠道请求号平台支付单和渠道单号都要保存
售后refund_idafter_sale_idrefund_no=RF20260429P7Q6R5S4after_sale_id=AS20260429Q8R7S6T5字符串或 BIGINTSnowflake 派生单号便于客服、对账和售后流转
营销campaign_idcoupon_idpromotion_idcampaign_id=300000123456coupon_id=310000123456BIGINTSegment 或 Snowflake营销对象数量大,需稳定引用
搜索index_task_iddoc_idindex_task_id=idx_01HZY8A7B6C5D4E3F2G1H0J9K8doc_id=sku_600000123456字符串业务 ID 或 ULID搜索文档通常以业务实体 ID 为主键
履约fulfillment_iddelivery_order_nofulfillment_id=FUL20260429M8N7P6Q5字符串Snowflake 派生单号或外部单号履约单经常要与供应商、物流系统对接
财务ledger_idsettlement_idreconciliation_idledger_id=930000123456settlement_id=SET202604290001BIGINT 或字符串Segment、Snowflake、批次号账务更重视可追溯、不可重复和对账批次
事件event_idoutbox_event_idevt_01HZY8B8C7D6E5F4G3H2J1K0M9evt_product_published_800000123456_12字符串ULID/UUIDv7 或确定性事件 ID用于幂等消费、重放和排障
链路追踪trace_idoperation_idtrace_id=4bf92f3577b34da6a3ce929d0e0e4736op_01HZY8D9E8F7G6H5J4K3M2N1P0字符串Trace 标准或 ULID跨服务传递,不在每一层重新生成
幂等idempotency_keyu_10001:cart_9f2a:req_8c7d字符串客户端请求 ID 或业务语义组合键依赖唯一约束和状态机,不等同于随机 ID

这里有几个容易混淆的点:

  1. order_idorder_no 可以不是同一个字段。前者可以是内部主键,后者是对外业务单号。
  2. inventory_key 通常不是随机 ID,而是业务维度组合,例如 inv:sku:30001:globalinv:sku:40001:date:2026-05-01:channel:app
  3. checkout_id 不是订单号。结算会话可能失败、过期或被重试,只有创单成功后才产生订单。
  4. idempotency_key 的核心不是“看起来唯一”,而是业务上能判断“这是不是同一次请求”。

4. 常见发号方案对比

4.1 DB 自增

DB 自增是最简单的方案:表主键使用 AUTO_INCREMENT 或数据库原生 identity。它适合单库单表、小规模后台配置、内部字典表和教学示例。

优点是简单、强一致、无需额外服务。缺点也明显:强依赖单库,跨库分表困难;连续递增容易暴露业务量;高并发交易链路可能把数据库打成瓶颈。

在电商系统中,DB 自增可以用于后台低频配置表,但不建议直接作为对外订单号、支付单号或全局 SKU ID。

4.2 DB Sequence 表

Sequence 表通过插入一张专门的序列表获取 LastInsertId,示例中的订单服务就有类似思路:

CREATE TABLE order_id_seq (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    created_at DATETIME(6) NOT NULL
);

每生成一个订单号,就插入一行序列表,再把自增值格式化成 ORD-123。这个方案比直接使用业务表自增稍微解耦,但本质仍是数据库中心化发号。

它适合早期系统、低中并发内部单据和容易理解的教学实现。不适合高并发交易核心,也不适合直接对外暴露连续序列。

4.3 Redis INCR

Redis INCR 可以按 key 递增,例如:

INCR id:order:20260429

再格式化为:

ORD2026042900012345

它的优点是性能高、实现简单、天然适合按天流水号。缺点是强依赖 Redis 高可用和持久化策略;主从切换、数据回滚、双活部署时要谨慎;同时,按天连续递增仍可能暴露业务量。

Redis INCR 适合活动流水、短期批次、低风险业务编号。核心订单和支付单如果使用 Redis INCR,必须设计持久化、主从切换和重复保护。

4.4 Snowflake

Snowflake 是经典的分布式趋势递增 ID 方案。常见实现把一个 64 位整数拆成:

时间戳 + 机器 / 机房标识 + 毫秒内序列

例如常见切分是 41 位毫秒时间戳、10 位机器标识、12 位序列。它的优点是本地生成、低延迟、高吞吐、趋势递增、适合 BIGINT 主键。缺点是依赖时钟,必须治理 worker_id,还要处理时钟回拨。

Snowflake 适合订单内部 ID、支付内部 ID、库存账本 ID、营销 ID,以及需要高并发写入的实体 ID。对外单号可以基于 Snowflake 再格式化,而不是直接暴露原始数字。

4.5 Segment 号段

Segment 号段,也叫 Hi-Lo 模式。核心思想是数据库只负责分配一段 ID,服务实例拿到号段后在本地内存中发号:

product.sku 申请到 1000000 - 1009999
product.sku 申请到 1010000 - 1019999

数据库中通常维护:

namespace、max_id、step、version

服务用乐观锁推进 max_id,一次拿一段。这样既保留数据库的强一致分配,又避免每个 ID 都访问数据库。

优点是不强依赖时钟,容量可控,namespace 独立,适合主数据 ID。缺点是服务重启会浪费一段号;号段耗尽前要预取;如果数据库不可用,新的号段无法分配。

Segment 非常适合 item_idspu_idsku_idcampaign_idcoupon_id 等电商主数据 ID。

4.6 UUIDv7、ULID 与 KSUID

UUID、ULID、KSUID 都属于更偏字符串或 128 位标识的方案。相较传统 UUIDv4,UUIDv7、ULID 和 KSUID 更强调时间有序或近似时间有序,适合日志、事件、流程单据和跨服务追踪。

UUIDv7 已在 RFC 9562 中定义,它把 Unix 毫秒时间放在高位,并用随机位提供唯一性。ULID 也采用时间 + 随机的思路,字符串更短、更适合人类阅读和按字典序排序。

这类 ID 的优点是无需中心服务,跨服务生成方便,天然适合字符串前缀。缺点是比 BIGINT 长,索引和存储成本更高,不适合所有高频实体都使用。

推荐用于 draft_idstaging_idqc_review_idoperation_idevent_idoutbox_event_idcheckout_id 等场景。

方案是否中心化是否趋势递增主要优点主要风险推荐场景
DB 自增简单、强一致单点瓶颈、暴露业务量小规模后台表
DB Sequence与业务表解耦、易理解高并发瓶颈、跨库困难早期单据号、教学示例
Redis INCR高性能、适合按天流水持久化和主从切换风险活动流水、短期批次
Snowflake大体是低延迟、高吞吐、BIGINT 友好时钟回拨、worker 分配订单、支付、库存账本
Segment 号段半中心化不依赖时钟、容量可控号段浪费、依赖号段预取商品、营销、主数据
UUIDv7 / ULID无需协调、跨服务方便字段较长、索引成本高流程单据、事件、链路

5. 推荐混合架构

生产电商系统更常见的不是“全站只用一个算法”,而是混合架构:

业务服务
  -> ID SDK
  -> ID Registry
  -> Generator Router
      -> Segment Generator
      -> Snowflake Generator
      -> ULID / UUIDv7 Generator
      -> Business Number Formatter
  -> Observability / Audit / Admin

推荐默认规则如下:

商品和库存主数据:Segment 或 Snowflake 的 BIGINT
交易单号:Snowflake 派生业务单号
供给流程、事件和链路:ULID/UUIDv7 + 受控 prefix
幂等:业务语义唯一约束,不等同于普通 ID

也就是说:

  1. item_idspu_idsku_id 这类主数据 ID 优先使用 BIGINT,便于索引和跨系统传递。
  2. order_nopayment_norefund_no 这类对外单号可以在底层 Snowflake ID 上增加日期、渠道、校验位或编码。
  3. draft_idstaging_idqc_review_id 这类流程单据 ID 使用字符串,更适合审计、日志和跨系统排障。
  4. idempotency_key 不由 ID 服务随便生成,而要和用户、购物车快照、请求来源或业务动作绑定。

这个混合架构可以同时满足性能、可读性、治理和扩展性。

6. ID 服务架构

6.1 ID Registry

ID Registry 是 ID 体系的控制面,负责登记所有 namespace,例如:

product.item
product.spu
product.sku
supply.draft
supply.staging
trade.order
trade.payment
event.outbox

每个 namespace 至少要记录:

字段含义
namespace全局唯一的业务命名空间
biz_domain所属业务域
id_typeINT64STRINGBUSINESS_NOIDEMPOTENCY_KEY
generator_typeSEGMENTSNOWFLAKEULIDUUIDV7BUSINESS
prefix字符串 ID 或业务单号前缀
expose_scopeINTERNALEXTERNALMIXED
owner_team负责人团队
statusENABLEDDISABLEDDEPRECATED

不要让业务代码直接传 "draft""order" 这种裸字符串。裸字符串无法治理,也无法做容量规划和审计。

6.2 ID SDK

业务服务应该依赖 SDK,而不是直接访问 ID 表或自己拼接字符串。SDK 至少提供:

type Generator interface {
    NextInt64(ctx context.Context, ns Namespace) (int64, error)
    NextString(ctx context.Context, ns Namespace) (string, error)
    NextBatchInt64(ctx context.Context, ns Namespace, size int) ([]int64, error)
}

SDK 可以封装本地缓存、号段预取、熔断降级、指标上报和错误转换。业务服务只关心“我要哪个 namespace 的 ID”。

6.3 Generator Router

Generator Router 根据 namespace 配置路由到不同发号器:

product.sku       -> Segment Generator
trade.order       -> Snowflake Generator + Business Number Formatter
supply.draft      -> ULID Generator
event.outbox      -> UUIDv7 Generator
checkout.session  -> ULID Generator

这样可以把“业务 ID 规则”从业务代码中拿出来,避免仓储层、应用层、HTTP 层各自发明一套规则。

6.4 Segment Generator

Segment Generator 从数据库申请号段,然后在本地内存中发号。为了避免号段耗尽造成请求抖动,应该支持双 Buffer:

当前号段使用到 70% 时,后台预取下一段;
当前号段耗尽时,如果下一段已就绪,立即切换;
预取失败时,继续使用当前号段并告警;
当前号段完全耗尽且无法预取时,返回明确错误。

6.5 Snowflake Generator

Snowflake Generator 的关键不是位运算,而是 worker 治理:

  1. worker_id 不能靠配置文件随手写,应该由租约表、注册中心或部署平台分配。
  2. 实例启动时申请 worker,定期心跳,退出或过期后释放。
  3. 发现时钟回拨时,要短暂等待、切换 worker 或熔断,而不是继续发号。
  4. 多机房部署时,要预留 region 或 datacenter 位。

6.6 ULID / UUIDv7 Generator

这类生成器适合本地生成,但仍然要受 namespace 约束。推荐格式:

draft_01JABCD...
staging_01JABCE...
qc_01JABCF...
evt_01JABCG...
op_01JABCH...

prefix 不是随意字符串,而是 Registry 中登记过的前缀。这样日志、排障和数据治理可以快速识别 ID 类型。

6.7 Business Number Formatter

业务单号通常不直接等于底层 ID。订单号可以设计为:

ORD + yyyyMMdd + base36(snowflake_id) + check_digit

例如:

ORD20260429CN7K3F9Q2X

这种格式便于客服和对账按日期定位,同时不直接暴露连续自增值。校验位可以降低人工录入错误。

6.8 Observability / Audit / Admin

ID 服务必须可观测:

指标说明
idgen_qps各 namespace 发号 QPS
idgen_error_rate发号失败率
segment_remaining当前号段剩余比例
segment_alloc_latency申请号段耗时
clock_rollback_count时钟回拨次数
worker_lease_expired_countworker 租约过期次数
duplicate_key_error_count下游唯一键冲突次数

高频 ID 不应把每次发号都同步写审计表,否则 ID 服务会被审计拖垮。更合理的方式是:常规路径打指标,异常路径写审计。

7. 关键业务 ID 设计

7.1 sku_idspu_iditem_id

item_id 是前台商品入口,spu_id 是商品定义层的标准品,sku_id 是具体销售规格。它们都属于长期稳定的主数据 ID,推荐使用 BIGINT

默认选择:

product.item -> Segment
product.spu  -> Segment
product.sku  -> Segment

如果系统写入并发特别高,也可以改成 Snowflake,但要统一 worker 管理。无论使用哪种方案,ID 一旦发出就不应复用。草稿废弃、商品下架、SKU 删除都不应该回收 ID。

供给链路中是否提前生成 sku_id,取决于业务:

  1. 如果外部供应商、图片、库存、审核都需要提前引用 SKU,可以在 Draft 阶段占号,状态为 RESERVED
  2. 如果希望未审核数据完全不污染正式商品空间,可以在 Publish 成功时生成正式 sku_id

两种方案都可行,但必须在附录和代码中讲清楚边界。

7.2 order_idorder_no

订单建议内部主键和对外单号解耦:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    order_no VARCHAR(64) NOT NULL,
    user_id BIGINT NOT NULL,
    status VARCHAR(32) NOT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_order_no (order_no)
);

其中:

id       -> 内部主键,Snowflake 或 Segment
order_no -> 对外业务单号,Snowflake 派生格式

不要直接暴露 ORD-1ORD-2 这类连续单号。它会暴露业务量,也容易被枚举。

7.3 checkout_ididempotency_key

checkout_id 表达一次结算会话,idempotency_key 表达一次业务请求。它们可以相关,但不能混为一谈。

典型设计:

checkout_id = ULID
idempotency_key = user_id + cart_snapshot_hash + client_request_id

创单时,订单系统需要唯一约束:

UNIQUE KEY uk_order_idempotency (user_id, idempotency_key)

这样用户重复点击“提交订单”时,系统返回同一笔订单,而不是生成多笔订单。

7.4 payment_id、渠道单号与对账

支付系统至少要区分三类编号:

字段说明
payment_id平台内部支付主键
payment_no平台对外支付单号
channel_trade_no支付渠道返回的交易号

平台调用渠道时,还需要一个稳定的渠道请求号,例如 out_trade_no。这个请求号通常应该由平台生成,并作为调用渠道的幂等键。不要用渠道返回单号作为平台支付单的唯一依据,因为渠道单号只有调用成功后才出现。

7.5 draft_idstaging_id 与供给审核单

供给流程 ID 推荐使用字符串:

draft_01J...
staging_01J...
qc_01J...

原因是它们不是正式商品资产,不需要像 sku_id 一样参与高频交易查询。字符串 prefix 能快速表达流程类型,便于运营后台、日志检索和问题排查。

关键边界是:Draft、Staging、QC 阶段的 ID 不应替代正式 item_idspu_idsku_id。只有发布事务成功后,商品中心才持有正式商品主数据 ID。

7.6 event_id 与 Outbox 去重

事件 ID 需要支持幂等消费和重放。常见方案有两种:

  1. 随机或时间有序 ID,例如 evt_01J...
  2. 确定性事件 ID,例如 evt_product_published_{item_id}_{version}

对于 Outbox,确定性事件 ID 很有价值,因为同一个聚合版本只应该发布一次事件。消费者侧仍然要有处理表或唯一索引:

CREATE TABLE event_consume_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    consumer_group VARCHAR(64) NOT NULL,
    event_id VARCHAR(128) NOT NULL,
    consumed_at DATETIME NOT NULL,
    UNIQUE KEY uk_consumer_event (consumer_group, event_id)
);

这样即使消息系统 at-least-once 投递,也能实现业务上的精确一次效果。

8. 容灾、风险与治理

风险表现缓解策略
时钟回拨Snowflake 生成重复或乱序 ID使用 NTP 单调配置;检测回拨;短暂等待;超过阈值熔断或切换 worker
号段浪费Segment 服务重启后未用完的 ID 丢失接受不连续;容量规划;合理设置 step;禁止回收已发号段
重复发号多实例使用同一 worker 或并发申请同一号段worker 租约;DB 乐观锁;唯一索引;重复冲突告警
ID 枚举外部用户通过连续 ID 猜测订单量或访问资源内外 ID 解耦;业务单号编码;权限校验;必要时加校验位
跨地域冲突多机房各自发号后 ID 冲突预留 region bits;按 region 分段;中心化 namespace 规划
字段类型失控同一个 ID 在不同系统里一会儿是字符串,一会儿是数字统一契约;IDL / OpenAPI 固化类型;迁移期双字段兼容
把幂等键当 ID重试请求仍然生成多笔订单或多次扣款唯一约束;请求状态表;幂等返回;业务状态机保护

电商系统还要特别注意“唯一性不是只靠 ID 服务保证”。最终写入业务表时仍然要有唯一索引。ID 服务负责降低冲突概率和统一规则,业务数据库负责最后一道硬约束。

9. 数据库与接口设计

9.1 Namespace 注册表

CREATE TABLE id_namespace (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    namespace VARCHAR(64) NOT NULL COMMENT '业务命名空间,例如 product.sku、trade.order',
    biz_domain VARCHAR(64) NOT NULL COMMENT '业务域,例如 product、trade、supply',
    id_type VARCHAR(32) NOT NULL COMMENT 'INT64/STRING/BUSINESS_NO/IDEMPOTENCY_KEY',
    generator_type VARCHAR(32) NOT NULL COMMENT 'SEGMENT/SNOWFLAKE/ULID/UUIDV7/BUSINESS',
    prefix VARCHAR(32) DEFAULT NULL COMMENT '字符串 ID 或业务单号前缀',
    expose_scope VARCHAR(32) NOT NULL COMMENT 'INTERNAL/EXTERNAL/MIXED',
    step INT NOT NULL DEFAULT 1000 COMMENT 'Segment 号段步长',
    max_capacity BIGINT DEFAULT NULL COMMENT '容量规划上限',
    owner_team VARCHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'ENABLED/DISABLED/DEPRECATED',
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_namespace (namespace),
    KEY idx_domain_status (biz_domain, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ID 命名空间注册表';

9.2 Segment 号段表

CREATE TABLE id_segment (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    namespace VARCHAR(64) NOT NULL,
    max_id BIGINT NOT NULL COMMENT '当前已经分配到的最大 ID',
    step INT NOT NULL COMMENT '每次申请的号段大小',
    version BIGINT NOT NULL COMMENT '乐观锁版本',
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_namespace (namespace)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Segment 号段表';

申请号段时使用乐观锁:

UPDATE id_segment
SET max_id = max_id + step,
    version = version + 1,
    updated_at = NOW()
WHERE namespace = ?
  AND version = ?;

9.3 Snowflake Worker 租约表

CREATE TABLE id_worker (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    worker_id INT NOT NULL,
    region_code VARCHAR(32) NOT NULL,
    datacenter_code VARCHAR(32) NOT NULL,
    instance_id VARCHAR(128) NOT NULL,
    lease_token VARCHAR(64) NOT NULL,
    lease_until DATETIME NOT NULL,
    heartbeat_at DATETIME NOT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'ACTIVE/EXPIRED/DISABLED',
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_worker_region_dc (worker_id, region_code, datacenter_code),
    UNIQUE KEY uk_instance (instance_id),
    KEY idx_status_lease (status, lease_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Snowflake worker 租约表';

这张表不是普通配置表,而是 Snowflake 实例之间争抢 worker_id 的租约表。每个正在发号的实例必须先在自己的 region_codedatacenter_code 下拿到一个未被占用的 worker_id,并且在租约有效期内持续续租。UNIQUE KEY uk_worker_region_dc 保证同一个地域和机房内,同一个 worker_id 同一时间只能被一个实例持有;UNIQUE KEY uk_instance 保证同一个实例不会同时占用多个 worker。

核心字段的使用方式如下:

字段用法
worker_id写入 Snowflake 的 worker bits,取值范围要受位数限制,例如 0 到 31
region_codedatacenter_code约束 worker 的部署边界,避免不同地域或机房混用同一组 worker
instance_id当前持有租约的实例标识,通常由部署平台注入,例如 Pod UID 或进程实例 ID
lease_token每次成功获取或抢占租约时生成的新 token,用于续租和释放时做 fencing 校验
lease_until租约过期时间,过期后其他实例才允许抢占
heartbeat_at最近一次心跳时间,用于排查实例卡顿、网络抖动和租约丢失
statusACTIVE 表示可用租约,EXPIRED 表示已释放或过期,DISABLED 表示该 worker 位被运维禁用

实例启动时的流程是:

  1. 生成或读取 instance_id
  2. 先按 instance_id 查询自己是否已有租约行;如果有有效租约,则续租并复用原来的 worker_id
  3. 如果自己的租约行已经过期但没有被禁用,则优先在原行上重新生成 lease_token 并续租,避免触发 uk_instance 唯一索引冲突。
  4. 如果没有自己的租约行,则在当前 region_codedatacenter_code 下扫描可用 worker_id
  5. 对空闲 worker 插入新行;对已经过期的 worker,使用条件更新抢占。
  6. 只有插入成功或条件更新影响 1 行时,实例才算拿到租约,Snowflake Generator 才能进入 ready 状态。

这里的“扫描可用 worker_id”不是说实例提前知道要抢哪一个,而是由 Snowflake 位数推导出候选集合。当前设计里 worker_id 是 5 bit,所以候选范围是 0..31。实例可以用 hash(instance_id) % 32 作为扫描起点,然后按环形顺序尝试 32 个候选,避免所有实例都从 worker_id = 0 开始竞争。

对每个候选 worker_id,实例按下面顺序处理:

  1. 如果表里没有这一行,尝试插入新租约;插入成功就获得该 worker。
  2. 如果这一行存在且 status = 'DISABLED',跳过。
  3. 如果这一行存在且 lease_until >= NOW(),说明仍被其他实例持有,跳过。
  4. 如果这一行存在且 lease_until < NOW(),再执行下面的条件更新抢占。
  5. 如果 32 个候选都不可用,实例保持 not ready,后台退避重试。

空闲 worker 可以直接插入:

INSERT INTO id_worker (
    worker_id, region_code, datacenter_code,
    instance_id, lease_token, lease_until,
    heartbeat_at, status, created_at, updated_at
) VALUES (
    ?, ?, ?,
    ?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND),
    NOW(), 'ACTIVE', NOW(), NOW()
);

并发启动时,两个实例可能同时插入同一个候选 worker。此时唯一索引会让其中一个插入失败;失败方不要报错退出,而是读取最新行状态,继续尝试下一个候选或尝试抢占已经过期的候选。

抢占过期 worker 时必须带上过期条件,避免两个实例同时抢到同一个 worker:

UPDATE id_worker
SET instance_id = ?,
    lease_token = ?,
    lease_until = DATE_ADD(NOW(), INTERVAL ? SECOND),
    heartbeat_at = NOW(),
    status = 'ACTIVE',
    updated_at = NOW()
WHERE worker_id = ?
  AND region_code = ?
  AND datacenter_code = ?
  AND status <> 'DISABLED'
  AND lease_until < NOW();

续租时必须同时校验 instance_idlease_token。如果更新影响行数不是 1,说明租约已经过期、被抢占或被禁用,当前实例必须立刻停止发号,进入 not ready 状态,并记录 WORKER_LEASE_LOST

UPDATE id_worker
SET lease_until = DATE_ADD(NOW(), INTERVAL ? SECOND),
    heartbeat_at = NOW(),
    updated_at = NOW()
WHERE instance_id = ?
  AND lease_token = ?
  AND status = 'ACTIVE';

正常退出时不要删除租约行,而是把租约置为过期,便于审计和后续排查:

UPDATE id_worker
SET lease_until = NOW(),
    status = 'EXPIRED',
    updated_at = NOW()
WHERE instance_id = ?
  AND lease_token = ?;

实现时要把数据库时间作为租约判断的基准,减少不同机器本地时钟不一致带来的误判。实例本地还应维护一个租约看门狗:如果距离上次成功心跳已经超过安全阈值,即使数据库还没返回失败,也要先把 Snowflake Generator 标记为 not ready,避免长时间 GC、网络卡顿或容器暂停后继续使用已经可能被别人抢占的 worker_id

9.4 发号审计与异常记录

CREATE TABLE id_issue_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    request_id VARCHAR(64) NOT NULL,
    namespace VARCHAR(64) NOT NULL,
    caller VARCHAR(128) NOT NULL,
    issue_type VARCHAR(32) NOT NULL COMMENT 'SUCCESS/FAILED/ROLLBACK/SEGMENT_ALLOCATED',
    issued_value VARCHAR(128) DEFAULT NULL,
    error_message VARCHAR(512) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_request_id (request_id),
    KEY idx_namespace_time (namespace, created_at),
    KEY idx_issue_type_time (issue_type, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键 ID 发号审计与异常记录';

审计表不应该记录所有高频发号请求。建议只记录关键 namespace、号段申请、异常、回拨和人工操作。

9.5 Go SDK 接口

type Namespace string

const (
    NamespaceProductItem  Namespace = "product.item"
    NamespaceProductSPU   Namespace = "product.spu"
    NamespaceProductSKU   Namespace = "product.sku"
    NamespaceSupplyDraft  Namespace = "supply.draft"
    NamespaceSupplyStage  Namespace = "supply.staging"
    NamespaceTradeOrder   Namespace = "trade.order"
    NamespaceTradePayment Namespace = "trade.payment"
)

type Generator interface {
    NextInt64(ctx context.Context, ns Namespace) (int64, error)
    NextString(ctx context.Context, ns Namespace) (string, error)
    NextBatchInt64(ctx context.Context, ns Namespace, size int) ([]int64, error)
}

应用服务依赖这个接口,仓储只负责保存实体:

type SupplyOpsService struct {
    repo  SupplyRepository
    idgen id.Generator
}

10. 示例代码改造建议

当前示例中的写法是:

DraftID: s.repo.NextID(ctx, "draft"),

生产级演进方向是:

draftID, err := s.idgen.NextString(ctx, id.NamespaceSupplyDraft)
if err != nil {
    return nil, err
}

再构造领域对象:

draft := &domain.ProductSupplyDraft{
    DraftID:     draftID,
    OperationID: operationID,
    Status:      domain.DraftStatusDraft,
    CreatedAt:   now,
    UpdatedAt:   now,
}

ProductCenterRepository.NextItemID(ctx) 也可以演进为:

itemID, err := s.idgen.NextInt64(ctx, id.NamespaceProductItem)
if err != nil {
    return nil, err
}

订单服务中的 NextOrderID(ctx) 可以拆成两层:

internalID, err := s.idgen.NextInt64(ctx, id.NamespaceTradeOrder)
if err != nil {
    return nil, err
}

orderNo := s.orderNoFormatter.Format(internalID, time.Now())

这样,仓储不再定义 ID 规则,业务服务也不再传裸 prefix。所有 namespace、生成策略和对外格式都由 ID 体系统一治理。

本附录只给出改造方向,不要求立刻重构示例代码。教学代码可以保留简化实现,但正文要让读者知道生产系统应该如何演进。

11. 面试和架构评审要点

设计全局 ID 体系时,可以用下面的问题自查:

  1. 这个 ID 是内部实体 ID、对外业务单号、流程单据 ID、事件 ID,还是幂等键?
  2. 这个 ID 是否会出现在 URL、订单详情、客服系统或开放 API 中?
  3. 如果对外暴露,是否会泄露业务量或被枚举?
  4. 这个 ID 是否需要趋势递增?是否真的需要严格递增?
  5. 数据库主键是 BIGINT 还是字符串?索引成本是否可接受?
  6. 多实例部署时,worker 或号段如何分配?
  7. 机器时钟回拨时,系统等待、降级还是熔断?
  8. 多机房部署时,是否预留 region 或 datacenter 位?
  9. ID 服务不可用时,业务是失败、降级还是使用本地缓存号段?
  10. 最终业务表是否有唯一索引兜底?
  11. 幂等键是否有业务语义,还是只是随机字符串?
  12. 老系统 ID 如何迁移?是否需要双写、双查或映射表?

如果这些问题没有答案,就说明 ID 体系还停留在工具函数层面,没有进入基础设施治理层面。

12. 小结

统一 ID 体系的重点不是某个算法,而是按业务语义治理 namespace、生成策略、暴露形式和失败处理。

电商系统中,sku_idorder_nodraft_idevent_ididempotency_key 看起来都叫 ID,但它们解决的问题完全不同。生产级设计应该把它们分开建模:

实体 ID:稳定引用,索引友好
业务单号:对外展示,防枚举,便于对账
流程单据:追踪流程,支持审计
事件 ID:幂等消费,支持重放
幂等键:表达同一次业务请求
链路 ID:贯穿调用链和操作链

推荐的默认架构是:Segment 号段 + Snowflake + ULID/UUIDv7 + 幂等键治理。这套组合不是最炫的方案,但足够贴近真实电商系统:它尊重不同业务场景的差异,也给后续多实例、多机房、开放平台和长期演进留下空间。