数据库基础体系 · 第 132/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

分布式数据库中的时间与 ID:时钟、序列、Snowflake、UUID 和顺序

在单机程序中,“当前时间”“自增主键”和“插入先后”经常被直觉地联系在一起:

  • 时间更早的记录,ID 更小;
  • ID 更大的记录,后提交;
  • 自增列没有间隙;
  • 数据库返回的时间就是事务提交时间;
  • UUID 虽然唯一,但天然无序。

在分布式系统中,这些关系通常都不成立。原因是“时间”“唯一性”和“顺序”是三个不同的问题:

  1. 时间:某台机器观察到的物理时刻是什么?
  2. 唯一性:两个参与者生成的值是否可能相同?
  3. 顺序:能否判断两个事件发生、提交、可见或被复制的先后?

理解分布式数据库中的 ID,首先要把这三个概念拆开。


一、先区分五种“顺序”

设事件 aabb 分别表示两个事务、写操作或消息。

1. 生成顺序

如果某个节点先生成 ID aa,再生成 ID bb,则称:

agba \prec_g b

这通常只说明同一个生成器中的本地顺序。不同节点之间没有天然关系。

2. 时间顺序

如果物理时钟读数满足:

time(a)<time(b)time(a) < time(b)

只能说明观察到的时间值如此。它不自动证明 aa 先于 bb。机器时钟可能漂移、跳变,节点之间也可能存在时钟偏差。

3. 因果顺序

如果事件 aa 影响了事件 bb,例如:

  • aa 的结果被 bb 读取;
  • aa 发送消息,bb 接收消息;
  • aa 属于同一事务,且先于 bb 执行;

则可以写作:

aba \rightarrow b

因果顺序通常比“时间戳大小”更可靠,但它只定义部分顺序。两个完全并发的事件之间可能不存在因果关系。

4. 提交顺序

数据库事务有自己的提交过程。事务先执行并不代表先提交:

事务 T1:开始 -> 执行写入 -> 长时间等待 -> 提交
事务 T2:开始 -> 执行写入 -> 很快提交

可能出现:

start(T1)<start(T2)start(T1) < start(T2)

但:

commit(T2)<commit(T1)commit(T2) < commit(T1)

ID 生成时间也可能早于提交时间。

5. 可见顺序

一个事务提交后,其他事务何时能读到它,取决于数据库的隔离级别、复制延迟、读路由和一致性协议。

因此:

ID 顺序生成顺序提交顺序可见顺序\text{ID 顺序} \ne \text{生成顺序} \ne \text{提交顺序} \ne \text{可见顺序}

这是本文后续所有讨论的基础。


二、物理时钟:时间戳不是全局真相

1. 墙上时钟与单调时钟

操作系统通常提供两类时间概念。

**墙上时钟(wall clock)**表示现实世界中的日期和时间,例如:

2025-03-08 10:15:20.123 UTC

它适合展示、审计和按时间范围查询,但可能发生回拨:

10:00:05
10:00:06
09:59:59   <-- NTP 校时或人工调整
10:00:00

**单调时钟(monotonic clock)**只用于测量持续时间。它不一定对应日历时间,但在进程运行期间不会因为校时而倒退,因此适合计算超时:

start = monotonic_now()
执行操作
elapsed = monotonic_now() - start

不能把单调时钟值直接写成业务创建时间,也不能把墙上时钟用于精确的超时判断。

2. 时钟偏差和漂移

在分布式系统中,节点 AA 和节点 BB 的本地时钟可能分别为:

A: 12:00:00.100
B: 12:00:00.080

即使 AA 生成的事件实际更早,也可能观察到更大的时间戳。用时间戳排序,只能得到“按观察值排列的顺序”,不能自动得到真实发生顺序。

时钟偏差还会导致 Snowflake 一类算法遇到以下问题:

上一次时间:1000
本次时间:  999

如果算法直接使用时间戳拼接 ID,可能产生重复或逆序。因此生成器必须明确处理时钟回拨。

常见处理方式包括:

  • 暂停生成,等待本地时钟追上;
  • 拒绝请求并报警;
  • 使用逻辑时间补偿回拨;
  • 使用受控时间服务或数据库提供的时间;
  • 将节点时间偏差控制在协议允许的范围内。

这些策略没有统一的正确答案。暂停会增加延迟,拒绝会影响可用性,逻辑补偿会使 ID 中的“时间部分”不再严格等于真实时间。


三、数据库中的时间语义

数据库函数返回的时间值,必须结合引擎、事务和语句边界理解。

1. PostgreSQL:事务时间、语句时间和实际时钟

以下示例针对 PostgreSQL:

BEGIN;

SELECT
    current_timestamp AS tx_time_1,
    statement_timestamp() AS stmt_time_1,
    clock_timestamp() AS wall_time_1;

SELECT pg_sleep(2);

SELECT
    current_timestamp AS tx_time_2,
    statement_timestamp() AS stmt_time_2,
    clock_timestamp() AS wall_time_2;

COMMIT;

在同一个事务中:

  • current_timestamp,以及 SQL 标准形式的 CURRENT_TIMESTAMP,表示事务开始时间,因此两次查询通常相同;
  • statement_timestamp() 表示当前语句开始时间;
  • clock_timestamp() 返回函数执行时读取的实际时钟,前后调用可能不同。

预期现象类似:

tx_time_1  = tx_time_2
stmt_time_1 与 stmt_time_2 不同
wall_time_1 与 wall_time_2 约相差 2 秒

这意味着 PostgreSQL 中:

created_at DEFAULT current_timestamp

记录的是事务时间语义下的时间,而不是严格的提交时刻。

如果事务执行 10 分钟后才提交,created_at 仍然可能是事务开始时刻。若业务需要“提交后可见的时间”,不能仅靠这个列推断;应根据业务模型记录提交事件,或使用数据库内部的提交序列、变更日志等机制。

2. MySQL:语句内稳定的当前时间

以 MySQL 8.4 为例,NOW()CURRENT_TIMESTAMP 在一条语句执行期间保持一致。这适合让一条语句写入统一的创建时间:

CREATE TABLE event_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    message VARCHAR(100) NOT NULL
);

INSERT INTO event_log (message)
VALUES ('a'), ('b');

SELECT id, created_at, message
FROM event_log
ORDER BY id;

同一条 INSERT 语句生成的多行,其默认时间通常相同。这个时间表示语句执行时数据库观察到的当前时间,不等价于严格的事务提交时间。

MySQL 还提供 SYSDATE(),其语义与 NOW() 不同:它可以在函数执行时读取实际时间,因此同一条语句内不同调用可能不同;具体行为还受服务器配置影响。需要一致时间时,不应随意把 SYSDATE()NOW() 混用。

3. 时区、精度与比较

时间列还存在三个独立问题:

  • 时区:存储的是 UTC、带时区时间,还是“无时区的本地日期时间”?
  • 精度:秒、毫秒、微秒是否足够?
  • 比较语义:按绝对时间比较,还是按用户所在时区展示?

时间戳精度更高,不代表顺序信息更强。例如两个并发事务都在同一微秒内生成时间,时间戳仍可能相同;时间戳不同,也不代表事务之间存在因果关系。


四、逻辑时钟:不依赖绝对时间表达顺序

1. Lamport 逻辑时钟

Lamport 时钟为每个节点维护一个整数 LL

规则如下:

  1. 节点本地发生事件时:

L:=L+1L := L + 1

  1. 节点发送消息时,将当前 LL 一起发送;
  2. 节点收到携带时间 tt 的消息时:

L:=max(L,t)+1L := \max(L, t) + 1

如果存在因果关系:

aba \rightarrow b

则一定有:

L(a)<L(b)L(a) < L(b)

但反方向不成立。若 L(a)<L(b)L(a) < L(b),不能证明 aba \rightarrow b,因为两个并发事件也可能恰好得到不同的逻辑值。

例如:

节点 A:本地事件 a,L_A = 1
节点 B:本地事件 b,L_B = 1

此时 aabb 并发。若再采用节点编号作为并列时的排序依据,可以构造全序,但这个全序是人为打破平局,并不表示真实因果关系。

2. 向量时钟

向量时钟为每个节点维护一个向量。例如两个节点 A、B:

A 的事件: [1, 0]
B 的事件: [0, 1]

定义:

V(a)V(b)V(a) \le V(b)

表示向量每个维度都不大于对方,并且至少一个维度严格小于对方。此时可以判断 aa 可能因果先于 bb

若:

[1, 0] 与 [0, 1]

互不小于对方,则表示并发。

向量时钟能表达更丰富的因果关系,但向量大小随参与者数量增长,因此不适合简单地作为所有数据库记录的 ID。

3. 混合逻辑时钟

混合逻辑时钟(HLC)通常把物理时间和逻辑计数结合起来:

(timestamp, logical_counter)

物理时间用于大致按现实时间排序,逻辑计数用于处理同一时刻或时钟回拨下的事件。

HLC 可以帮助复制协议、MVCC 或分布式事务维护因果边界,但它不是一个“所有数据库都保证的全局提交时间”。具体保证取决于数据库协议和部署方式。


五、数据库序列:唯一递增,不等于无间隙和提交顺序

1. PostgreSQL SEQUENCE 的核心语义

在 PostgreSQL 中,序列是数据库对象,不是普通表中的一列。示例:

CREATE SEQUENCE order_id_seq
    START WITH 1000
    INCREMENT BY 1;

SELECT nextval('order_id_seq');  -- 1000
SELECT nextval('order_id_seq');  -- 1001

序列的关键特点是:

  • nextval() 在同一序列上提供并发安全的取值;
  • 返回值通常按调用顺序递增;
  • 序列推进不随事务回滚;
  • CACHE 可能让值提前分配到后端进程,因此故障时可能出现更大的间隙;
  • 序列值本身不表示某行已经提交或一定存在。

反例:

BEGIN;

SELECT nextval('order_id_seq');  -- 得到 1002

ROLLBACK;

SELECT nextval('order_id_seq');  -- 得到 1003

1002 不会因为事务回滚而回收。这是有意设计的:如果序列值也参与严格事务回滚,并发事务会为等待回滚而付出复杂的锁和性能代价。

2. CACHE 与故障间隙

假设序列配置:

CREATE SEQUENCE s CACHE 10;

数据库可能一次为某个后端预留一段值。进程异常退出时,尚未使用的预留值可能丢失。因此看到:

1000, 1001, 1010

不代表数据库产生了错误,也不代表中间记录被删除。

如果业务要求“发票号码连续且不能跳号”,普通序列不是合适工具。连续编号需要专门的、通常带事务锁竞争的编号流程,并且还要定义取消、作废和并发分配的法律或业务规则。

3. 序列与表插入的反例

CREATE TABLE t (
    id bigint PRIMARY KEY DEFAULT nextval('order_id_seq'),
    payload text NOT NULL
);

BEGIN;
INSERT INTO t(payload) VALUES ('first'); -- 分配 1004
-- 事务暂不提交

BEGIN; -- 另一个会话
INSERT INTO t(payload) VALUES ('second'); -- 分配 1005
COMMIT;

-- 第一个会话回滚
ROLLBACK;

最终表中可能只有 1005,而 1004 不存在。更重要的是,1004 先分配并不表示它先提交。

4. MySQL AUTO_INCREMENT

MySQL 的 AUTO_INCREMENT 也主要用于生成唯一键,而不是生成无间隙的业务编号。并发插入、事务回滚、服务器重启、批量插入以及存储引擎的分配策略都可能产生间隙。

例如:

CREATE TABLE orders (
    id BIGINT NOT NULL AUTO_INCREMENT,
    payload VARCHAR(100) NOT NULL,
    PRIMARY KEY (id)
) ENGINE = InnoDB;

START TRANSACTION;
INSERT INTO orders(payload) VALUES ('will rollback');
ROLLBACK;

INSERT INTO orders(payload) VALUES ('committed');
SELECT * FROM orders;

第二次插入得到的 id 可能大于第一次已经分配过的值。具体的分配细节受 MySQL 版本、存储引擎和并发模式影响,但“回滚后不保证回收并重用”是应用不应依赖连续性的核心事实。

在复制、分片或多主部署中,每个节点独立分配自增值还可能发生冲突,因此通常需要不同的起始值、步长或外部 ID 生成器。这些方法可以避免碰撞,却不会自动提供全局严格顺序。


六、Snowflake:带时间结构的分布式 ID

1. Snowflake 不是唯一固定标准

“Snowflake ID”通常指一类 64 位左右的整数布局,最常见的思想是:

符号位 | 时间差值 | 节点标识 | 同毫秒序列号

经典实现常见为:

  • 1 位符号位;
  • 41 位毫秒时间差值;
  • 10 位节点标识;
  • 12 位同毫秒序列号。

但位数、起始时间、节点字段和回拨策略都可以改变。因此不能看到一个 64 位整数,就断言它一定是某个特定 Snowflake 实现。

2. 一个具体编码算例

假定:

epoch = 2020-01-01T00:00:00Z
当前时间差 delta = 5 毫秒
worker_id = 3
sequence = 7

采用:

timestamp_bits = 41
worker_bits = 10
sequence_bits = 12

则编码为:

ID=(delta(10+12))    (worker_id12)    sequenceID = (delta \ll (10+12)) \;|\; (worker\_id \ll 12) \;|\; sequence

代入:

ID=(522)(312)7ID = (5 \ll 22) | (3 \ll 12) | 7

其中:

  • delta 决定大致时间顺序;
  • worker_id 区分不同生成节点;
  • sequence 区分同一节点、同一毫秒内的多个请求。

若同一节点在同一毫秒连续生成:

delta = 5, worker = 3, sequence = 0
delta = 5, worker = 3, sequence = 1
delta = 5, worker = 3, sequence = 2

生成的整数递增。

3. 生成器状态与并发流程

一个生成器至少维护:

last_timestamp
sequence
worker_id

生成过程可以写成:

now = current_millisecond()

if now < last_timestamp:
    处理时钟回拨

if now == last_timestamp:
    sequence = sequence + 1
    if sequence 超出上限:
        等待下一毫秒
else:
    sequence = 0

last_timestamp = now
返回编码结果

并发实现必须保证这段状态更新是原子的。可以使用互斥锁、CAS 或单线程事件循环;否则两个请求可能读到相同的 last_timestampsequence,产生重复 ID。

4. 容量推导

若同一节点每毫秒的序列字段有 bb 位,则一个毫秒最多生成:

2b2^b

个 ID。

经典 12 位序列字段最多为:

212=40962^{12}=4096

个 ID/毫秒,即理论上约 409.6 万个 ID/秒,但这是单节点、无等待、无锁竞争和实现正确时的位级上限,不是可直接承诺的业务吞吐量。

若某毫秒内请求超过上限,常见做法是等待下一毫秒。此时延迟会上升;若不等待而重置序列,就会产生重复 ID。

5. 时钟回拨是 Snowflake 的关键故障路径

假设生成器状态:

last_timestamp = 1000

下一次读取到:

now = 998

如果直接编码,新的 ID 可能小于上一个 ID,甚至在某些实现中与历史 ID 冲突。

可选策略:

暂停等待

等待本地时间 >= 1000

优点是容易保持时间字段单调;缺点是时钟回拨期间请求阻塞。

拒绝生成

抛出错误并报警。适用于不能接受错误 ID 的场景,但会降低可用性。

逻辑补偿

将有效时间设置为:

effective_timestamp = max(now, last_timestamp)

并通过额外序列区分回拨期间的请求。这样可以保持 ID 单调,但 ID 的时间字段不再代表真实墙上时间,且必须防止补偿窗口耗尽。

依赖节点分配和时钟治理

为每个节点分配稳定 worker_id,监控时钟偏差,并在部署环境中控制时间同步。这减少故障概率,但不能替代生成器内部的回拨处理。

6. Snowflake ID 保证什么,不保证什么

在节点标识不重复、状态更新正确、时钟策略满足约束的前提下,Snowflake 通常可以提供:

  • 分布式唯一性;
  • 近似按生成时间递增;
  • 适合使用整数主键;
  • 可从 ID 中提取大致生成时间。

它通常不能保证:

  • 严格的全局真实时间顺序;
  • 严格的事务提交顺序;
  • 严格的跨节点生成先后;
  • 时钟回拨、节点 ID 配置错误或状态丢失后的绝对安全;
  • 业务数据已经提交或存在。

例如节点 A 在 12:00:00.001 生成 ID 后暂停,节点 B 在 12:00:00.002 生成 ID 并提交。A 的事务随后在 12:00:01 提交。此时 Snowflake ID 的顺序与提交顺序相反。


七、UUID:优先解决命名空间和唯一性

UUID 是 128 位标识符。它首先解决的是“不同生成者产生相同值的概率或可能性”,并不天然解决数据库索引顺序。

1. UUID v4:随机型

UUID v4 主要使用随机位。它的优点是:

  • 不依赖中心序列;
  • 不暴露时间和节点信息;
  • 适合客户端离线生成。

缺点是随机写入 B-tree 索引时局部性较差。新值可能落到索引的任意位置,引起更多页面分裂和缓存不命中。这个问题是存储访问模式问题,不是 UUID 正确性问题。

2. UUID v1:时间与节点信息

UUID v1 包含时间字段、时钟序列和节点相关信息。它具有一定的时间结构,但会带来隐私和部署方面的考虑,例如可能暴露生成时间或节点信息。其字符串表现和二进制字段排列也不等同于数据库中的时间排序。

3. UUID v7:面向时间排序的 UUID

UUIDv7 使用 Unix 毫秒时间戳作为高位字段,并保留随机字段。其目标是比完全随机的 UUID 更适合按时间插入和索引。

但必须注意:

  • 时间字段通常只有毫秒精度;
  • 同一毫秒内需要依赖随机字段或实现提供的单调序列;
  • 不同节点的生成顺序仍可能与提交顺序不同;
  • 按字符串排序、按原始 16 字节排序、按数据库特定字节布局排序,结果可能不同;
  • “大致有序”不等于“全局严格连续”。

UUIDv7 更准确的理解是:把时间局部性加入 UUID 的唯一性方案,而不是把 UUID 变成全局事务序列。

4. PostgreSQL 中的 UUID 主键

PostgreSQL 的 uuid 类型以 128 位值存储和比较:

CREATE TABLE api_request (
    request_id uuid PRIMARY KEY,
    payload text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT current_timestamp
);

INSERT INTO api_request(request_id, payload)
VALUES ('550e8400-e29b-41d4-a716-446655440000', 'hello');

SELECT request_id, created_at, payload
FROM api_request;

示例中的 UUID 是手工提供的。数据库的 uuid 类型负责存储、比较和索引,但“是否由客户端生成”“采用 v4 还是 v7”“如何保证跨服务一致”属于应用和部署设计。

不要把 UUID 主键的排序当作创建顺序:

SELECT *
FROM api_request
ORDER BY request_id;

这只是按 UUID 值排序,不是按创建时间、提交时间或事件因果排序。

5. MySQL 中的 UUID 存储

MySQL 可以使用 CHAR(36) 存储带连字符的文本 UUID,也可以使用 BINARY(16) 存储紧凑的二进制形式:

CREATE TABLE api_request (
    request_id BINARY(16) PRIMARY KEY,
    created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    payload VARCHAR(100) NOT NULL
);

SET @u = UUID();

INSERT INTO api_request(request_id, payload)
VALUES (UUID_TO_BIN(@u), 'hello');

SELECT BIN_TO_UUID(request_id), created_at, payload
FROM api_request;

BINARY(16)CHAR(36) 更紧凑,也避免字符串比较和字符集带来的额外成本。

MySQL 提供 UUID_TO_BIN(uuid, swap_flag)BIN_TO_UUID()swap_flag 的特殊重排主要针对 UUID v1 的时间相关字段,以改善索引中的时间局部性;不能把这个参数误认为适用于所有 UUID 版本,也不能由此推导出严格的全局顺序。


八、ID 顺序与数据库索引顺序

B-tree 索引适合范围扫描和局部插入。若连续插入的键大致递增,写入通常更集中在索引右侧;若键完全随机,写入会分散到多个页面。

因此:

  • 单调递增整数通常有较好的索引局部性;
  • Snowflake 通常提供近似递增的整数;
  • UUID v4 通常较随机;
  • UUID v7 通常比 v4 更有时间局部性;
  • UUID 文本和二进制的排序布局必须实际验证。

但索引顺序并不是业务事件顺序。即使主键递增,也可能发生:

事务 T1:拿到 id=10,未提交
事务 T2:拿到 id=11,先提交
事务 T1:后提交

按主键查询得到的顺序是 10, 11,按提交顺序却是 11, 10

如果业务需要事件顺序,应显式建模,例如:

CREATE TABLE account_event (
    event_id bigint PRIMARY KEY,
    account_id bigint NOT NULL,
    event_type text NOT NULL,
    created_at timestamptz NOT NULL,
    payload jsonb NOT NULL
);

其中:

  • event_id 负责唯一定位;
  • created_at 负责展示或时间范围过滤;
  • 若每个账户必须严格有序,还需要账户级序列、版本号或由单一协调者分配的 account_version
  • 若需要判断跨节点因果关系,则应保存因果版本、日志偏移量或协议提供的提交位置。

不要让一个字段同时承担所有语义。


九、分布式数据库中的 ID 生成路径

一个常见的写入路径如下:

客户端
  |
  | 生成 UUID / 请求 ID
  v
路由层
  |
  | 根据分片键选择节点
  v
分片节点
  |
  | 分配本地序列或调用 Snowflake
  v
复制协议 / WAL / Raft 日志
  |
  | 达成提交条件
  v
客户端收到成功响应

这里至少存在四个时间点:

t1:客户端或服务生成 ID
t2:数据库执行 INSERT
t3:复制协议确认或本地事务提交
t4:客户端收到响应

通常只有部分关系成立:

t1t2t3t4t1 \le t2 \le t3 \le t4

但在重试、异步队列和跨服务调用中,客户端观察到的时间还会受到网络延迟影响。若客户端在超时后重试:

第一次请求:数据库已提交,但响应丢失
第二次请求:客户端再次发送

如果 ID 每次重试都重新生成,可能产生两条业务记录;如果使用同一个幂等键,数据库可以拒绝重复提交或返回原结果。

因此:

  • 请求 ID适合追踪一次请求;
  • 幂等键适合识别同一业务操作的重试;
  • 主键适合唯一定位一行;
  • 事件序列适合表达同一实体的变化顺序。

它们可以是同一个值,但不应因为方便就默认它们语义相同。


十、常见误解与诊断方法

误解一:ID 越大,记录越新

只有在 ID 生成器、时钟、提交模型和查询节点都满足额外约束时,这个结论才可能近似成立。分片、并发事务、回滚和复制都会破坏它。

诊断时应同时记录:

id
应用生成时间
数据库写入时间
事务提交或日志位置
节点标识
请求 ID

分别排序比较,而不是只看主键。

误解二:时间戳相同就表示同时发生

相同精度的时间戳只能说明它们在该精度下相同。它们可能来自不同事务、不同节点,甚至相隔数百微秒但被截断为同一毫秒。

需要排序时,应增加唯一的并列键:

ORDER BY created_at, event_id

但这仍然是确定性排序,不等于恢复了真实因果顺序。

误解三:序列应该回滚

序列的主要目标是并发分配唯一值,而不是提供无间隙账本编号。回滚不回收序列值,正是为了避免事务与序列分配之间形成严重锁竞争。

误解四:UUID 天然无序,所以不能做主键

UUID 可以做主键,但要评估:

  • 数据量和索引大小;
  • 生成位置;
  • UUID 版本;
  • 二进制还是文本存储;
  • 插入局部性;
  • 是否需要按时间范围扫描。

UUID v4 在分布式生成和隐私方面有优势;Snowflake 在整数索引和大致时间排序方面有优势;UUIDv7 在标准化的 128 位格式与时间局部性之间折中。它们不是简单的优劣关系。

误解五:数据库时间就是提交时间

PostgreSQL 的 current_timestamp 是事务开始时间语义;MySQL 的 NOW() 在语句内稳定,但都不应被直接当作严格提交时间。若提交顺序是业务要求,必须使用数据库提供的提交序列、复制日志位置或显式版本协议。


十一、如何选择

可以按实际语义选择,而不是按“看起来先进”选择。

选择数据库序列或自增键

适合:

  • 单一数据库或单一分片内生成;
  • 主要需求是高效唯一键;
  • 不要求跨分片全局连续;
  • 不把主键解释为提交时间。

选择 Snowflake 类 ID

适合:

  • 多节点独立生成整数 ID;
  • 希望 ID 大致按时间递增;
  • 需要较紧凑的索引键;
  • 能够管理 worker ID、时钟同步和回拨故障。

必须明确节点标识的生命周期。节点 ID 重复是直接导致 ID 冲突的高风险错误。

选择 UUID v4

适合:

  • 客户端或边缘节点离线生成;
  • 不希望暴露时间或节点信息;
  • 更看重命名空间隔离和随机性;
  • 可以接受随机索引写入的代价。

选择 UUID v7 或其他时间有序 UUID

适合:

  • 需要 128 位分布式 ID;
  • 希望改善随机 UUID 的索引局部性;
  • 需要在不同语言和服务之间使用统一格式;
  • 接受它只提供近似时间顺序,而不是提交顺序。

只使用时间戳

通常不适合作为分布式唯一主键。相同精度内可能并发碰撞,不同节点之间还存在时钟偏差。时间戳更适合作为查询、审计和展示字段,而不是唯一性机制。


十二、最终的语义边界

一个可靠的分布式数据模型,通常会把不同职责分开:

主键 / ID       -> 唯一定位
时间戳          -> 记录观察到的时间
序列 / 版本号   -> 表达局部或协议定义的顺序
提交位置        -> 表达复制或事务协议中的先后
幂等键          -> 识别同一次业务请求
因果元数据      -> 表达跨节点依赖关系

如果要求“严格顺序”,还必须回答三个问题:

  1. 顺序覆盖什么范围?
    单个账户、单个分片、一个数据库,还是整个集群?

  2. 顺序依据是什么?
    生成、接收、执行、提交、复制确认,还是对外可见?

  3. 冲突如何处理?
    两个并发事件是否允许任意排序,还是必须由一个协调者裁决?

只有在这些问题明确后,才能判断应该使用序列、Snowflake、UUID、逻辑时钟、事务版本,还是复制协议提供的日志位置。

ID 能提供唯一性,时钟能提供时间观测,序列能提供某个生成器内的分配顺序;它们都不能单独替代分布式数据库的一致性协议。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。