关键词:CockroachDB、分布式 SQL、Range 分裂、HLC 混合逻辑时钟、Parallel Commits、Serializable
摘要:CockroachDB 是极少数在全球分布式部署下仍敢提供 Serializable 隔离级别的 SQL 数据库。它靠的不是 2PC 加锁,而是 HLC 混合逻辑时钟、Range 级自动分片与 Parallel Commits 三件套的组合拳。本文从 Key 空间路由讲到不确定性区间,从写意图讲到 STAGING 事务记录,给出可直接落地的调优清单。
前言:为什么 Serializable 的分布式 SQL 这么难
单机 PostgreSQL 靠 MVCC + 快照隔离就能给出漂亮的隔离语义,但它默认隔离级别其实是 Read Committed,真正的 Serializable 要靠 SSI(可串行化快照隔离)在运行时检测读写冲突环。一旦把数据打散到全球三个大区,问题立刻变成三重:
- 没有全局时钟。跨节点的物理时钟必然有偏差,无法用"当前时间"定义一致的快照。
- 没有共享内存。事务状态必须落盘,跨分片提交必须走两阶段提交。
- 没有全局锁表。锁竞争会跨越网络,一次阻塞就是几十毫秒起步。
Spanner 用 TrueTime + GPS/原子钟硬件解决了第 1 点,代价是必须依赖专有硬件。CockroachDB 走的是另一条路:不消除时钟误差,而是把误差显式建模进协议。这个设计决策贯穿了整个系统,也是它最值得学习的部分。
本文所有内容基于 CockroachDB v23/v24 的实现,SQL 示例均可直接运行。
一、Range:把 Key 空间切成可迁移的细胞
1.1 一切皆 KV,Key 空间是单一有序域
CockroachDB 没有"表文件"这个概念。每张表、每个二级索引都被编码进一个全局有序的 Key 空间:
/Table/<tableID>/<indexID>/<indexKeyValues>/<columnFamilyID>
例如主键为 INT8 的表,一行数据的 Key 大致是:
/Table/53/1/1001/0 -> 主键为 1001 的行
/Table/53/2/"alice"/4 -> 二级索引 (name) 指向主键 4
关键工程后果:主键设计直接决定数据物理分布。用 UUIDv4 做主键,写入天然均匀;用自增 INT8 或时间戳前缀做主键,所有写入都会砸向 Key 空间最右端的那一个分片——这就是最常见的"热点 Range"事故来源。
1.2 两级路由:meta range
Key 空间被切成若干个 Range(默认 64MiB 触发分裂,可在 512MiB 量级调优)。要知道某个 Key 落在哪个 Range 上,需要一张路由表——但路由表本身太大,不能直接广播给所有节点。
CockroachDB 的解法是递归的 meta range:
meta1:硬编码在所有节点的启动配置里(实际上它是 Key 空间最开头的一个特殊 Range)。它记录"哪个 Range 保存着 meta2 的哪一段"。meta2:记录"哪个 Range 保存着用户数据的哪一段"。
查找 /Table/53/1/1001 的过程:
本地 RangeCache 命中? -> 直接发给 Leaseholder
| 未命中
v
查 meta1(已知位置)-> 定位 meta2 分片 -> 查 meta2 -> 定位数据 Range
|
v
写入 RangeCache,后续请求零跳转
所以生产上"首次访问慢、之后飞快"是预期行为。客户端驱动维护了一份 RangeCache,遇到 RangeKeyMismatchError 会自动驱逐条目并重试——对应用完全透明,这使得在线分裂、在线迁移副本都无需停机。
1.3 手动干预 Range 分布
自动分裂解决不了所有问题,尤其是"逻辑相邻但访问模式不同"的场景。实战中常用的几个命令:
-- 1. 预分裂:批量导入前把 Key 空间切开,避免导入期集中分裂
ALTER TABLE orders SPLIT AT VALUES (1000000), (2000000), (3000000);
-- 2. 查看 range 分布与 leaseholder
SHOW RANGES FROM TABLE orders WITH DETAILS;
-- 3. 查看热点 range(需要 crdb_internal 视图)
SELECT range_id, start_key, end_key, lease_holder,
(stats->>'key_bytes')::INT8 AS kb
FROM crdb_internal.ranges
ORDER BY kb DESC LIMIT 10;
-- 4. 把某个 range 的租约迁移到指定节点(跨机房读优化)
ALTER TABLE orders RELOCATE LEASE SELECT 3, 'us-east1';
SPLIT AT 在热路径上被严重低估。一个典型场景:某张表按 tenant_id 前缀分片,但大客户的订单量是小客户的千倍。按前缀预分裂能把热点打散:
ALTER TABLE orders SPLIT AT VALUES
('t_vip_001'), ('t_vip_002'), ('t_regular');
1.4 分裂与合并的元数据一致性
分裂本身是一个 Raft 事务:在 Range R 上提交一条 SplitTrigger,原子地把 R 拆成 R1/R2 并更新 meta2。Trigger 机制是 CockroachDB 的一个精巧设计——所有 Range 级别的元数据变更(分裂、合并、副本变更、租约转移)都通过 Raft 日志里的特殊 side-effect 条目在 Apply 阶段执行,保证"状态机复制"与"元数据变更"严格同序。
这意味着:你永远看不到一个"分裂了一半"的 Range。代价是合并(merge)必须等待左右两个 Range 的租约对齐到同一节点,所以自动合并通常比分裂慢得多,这也是为什么删除大量数据后 range 数量不会立即下降。
二、HLC:把时钟误差建模成不确定性区间
2.1 混合逻辑时钟的算法
CockroachDB 不追求物理时钟同步,而是维护一个 HLC(Hybrid Logical Clock):高位是物理时间(墙钟纳秒),低位是逻辑计数器。每次事件发生时:
// 伪代码:HLC.Now()
func (c *HLClock) Now() HLC {
pt := c.physicalClock.Now() // 本地墙钟
if pt > c.state.WallTime {
c.state = HLC{WallTime: pt, Logical: 0}
} else {
// 墙钟没前进,靠逻辑位保证单调
c.state.Logical++
}
return c.state
}
// 收到远程时间戳时
func (c *HLClock) Update(remote HLC) {
local := c.Now()
if remote.WallTime > local.WallTime {
c.state = HLC{remote.WallTime, remote.Logical + 1}
} else if remote.WallTime == local.WallTime && remote.Logical > local.Logical {
c.state = HLC{local.WallTime, remote.Logical + 1}
}
// 否则保持本地
}
HLC 保证了因果序:若事件 A Happens-Before 事件 B,则 HLC(A) < HLC(B)。它不保证物理精度——两个节点的墙钟可能差 100ms。
2.2 不确定性区间:Serializable 的核心技巧
由于时钟有偏,事务 T 在节点 N1 上读到快照时间戳 ts 时,无法排除另一节点 N2 上存在一个物理时间更早、但时间戳尚未确定的提交。CockroachDB 的处理是:
读快照时间戳 = ts
不确定性区间 = [ts, ts + max_offset] // 默认 max_offset = 500ms
当读操作在区间内遇到一个未提交或时间戳落在区间内的写意图(write intent)时,它不能简单判断先后,于是:
- 尝试 Read Refresh:如果事务此前的所有读都能在更晚的时间戳上重放且结果一致(通过
spanRefreshSpans记录读集合),直接把事务时间戳推高并继续。 - 若无法 refresh,则重启事务:这是
RETRY_SERIALIZABLE错误的来源。
-- 典型现象:跨区写入事务偶发重启
ERROR: restart transaction: TransactionRetryWithProtoRefreshError:
ReadWithinUncertaintyIntervalError: read at time 1735...
可落地的优化手段:
-- 1. 用 follower reads 做只读副本访问(牺牲 4.8s 新鲜度换 0 RTT 跨区)
SELECT * FROM orders AS OF SYSTEM TIME '-5s' WHERE tenant_id = 't1';
-- 2. 全局表:小维度表复制到所有大区,跨区 Join 变本地 Join
ALTER TABLE regions SET locality GLOBAL;
-- 3. 表级 locality:把数据固定在写入方大区
ALTER TABLE orders SET locality REGIONAL BY ROW;
max_offset 不是越大越好。把它设成 500ms 意味着任何跨区读都要承担不确定性重启的风险;把它设成 50ms 则要求集群时钟必须稳定到 50ms 以内(NTP 通常够,但云上 VM 迁移会瞬间打穿)。生产建议:跨地域集群 250~500ms,单地域集群 50~100ms。
三、并行提交:把 2PC 的两轮 RTT 压成一轮
3.1 传统 2PC 在分布式 SQL 上的代价
经典两阶段提交需要:
Round 1: PREPARE 所有参与者(写入 prepare 记录)
Round 2: COMMIT(写入 commit 记录 + 清理)
在跨大区场景下,每轮 RTT 是 100~200ms,两轮就是 400ms+,还不包括副本层 Raft 复制的往返。这对 OLTP 是灾难性的。
3.2 Write Pipelining + STAGING 事务记录
CockroachDB 的做法分两步。
第一步:写管道化(Write Pipelining)。事务的每一条 DML 不再等待前一条返回,而是直接把写意图(intent)刷出去,用 KV 层返回的 ResumeSpan 跟踪进度:
// 客户端伪代码:pipelined write
txn := db.NewTxn(ctx)
// 不等返回,连续发出
txn.Put(ctx, keyA, valA) // 立即返回,intent 异步写
txn.Put(ctx, keyB, valB)
txn.Put(ctx, keyC, valC)
// 只有遇到读、或最终 Commit 时才对账
if err := txn.Commit(ctx); err != nil { ... }
注意写意图本身带一个指向事务记录(TransactionRecord)的指针,而事务记录此时还不存在——这是关键。
第二步:Parallel Commits。Commit 时,客户端把所有写意图连同"事务记录"一起并行发送给各参与者,事务记录被标记为 STAGING 而非 COMMITTED:
Round 1 (唯一一轮):
├─ 写 intent on keyA (标记: STAGING txn)
├─ 写 intent on keyB (标记: STAGING txn)
└─ 写 TransactionRecord (status=STAGING, 内含所有 intent 的 key 列表)
↓ 三者都在同一轮并行发出
Round 2 (异步、不阻塞客户端):
├─ 所有 intent 确认 -> TransactionRecord 改成 COMMITTED
└─ 异步清理 intent(把值转正)
客户端只要确认所有 intent 都成功写入且事务记录处于 STAGING 且包含完整的 intent 列表,就可以向应用返回"提交成功"。为什么这是安全的?
因为即使此刻 coordinator 崩溃,任何后续读到一个 STAGING intent 的事务都能通过事务记录里记录的 intent 列表,自己完成判定:检查列表里的每个 intent 是否都已写入——若全部存在,则事务事实上已提交,帮忙推进到 COMMITTED;若有一个缺失,则判定为中止。这叫 intent recovery / transaction push,是一个完全去中心化的恢复协议。
3.3 什么时候会退化
并行提交有触发条件,超出就会退回传统路径:
| 触发条件 | 后果 |
|---|---|
| 事务跨越多个 Range 且某一轮 RPC 超时 | 必须做一轮显式 recovery,延迟翻倍 |
写入超过 1 个 Range 且包含 SAVEPOINT 回滚 | 部分 intent 需清理,无法并行判定 |
| 大事务(默认 > 100k KV) | 意图列表过大,退化 |
显式 BEGIN; ... ; COMMIT 中含交互式读 | 读会强制对账,打断流水线 |
工程建议:把批处理拆成每批 1000~5000 行的小事务(见下),并避免在事务内做应用侧 RPC。
-- 批量导入:不要用大事务,用 IMPORT 或分批
IMPORT INTO orders (id, tenant_id, amount)
CSV DATA ('s3://bucket/orders/*.csv')
WITH delimiter = ',';
-- 或者分批(每批 2000 行)
BEGIN;
INSERT INTO orders SELECT * FROM staging LIMIT 2000;
DELETE FROM staging WHERE id IN (SELECT id FROM staging LIMIT 2000);
COMMIT;
四、Leaseholder 与 Follow-the-Workload
每个 Range 的 Raft 组中,只有 Leaseholder 能提供读写服务(其余副本只做 Raft 复制)。租约是基于 Raft 任期(epoch-based)而非时间的:只要 Raft leader 没变,租约不超时,避免了时钟依赖。
CockroachDB 会持续统计每个副本收到的请求来源,把租约自动迁移到请求最密集的大区——这就是 Follow-the-Workload。它对"用户早上在亚洲、晚上在欧洲"这类模式非常有效。
但要注意两个陷阱:
- 租约迁移有抖动。评估周期(默认约数分钟)内热点可能已经转移,导致来回摆动。可通过
kv.lease_transfer_timeout与副本优先级(ALTER TABLE ... CONFIGURE ZONE)抑制。 - 写仍然要过 Raft 多数派。租约迁移只省掉"读"和"租约持有者的写路径第一跳",写入仍需复制到多数副本。跨大区部署时,把
num_voters中小部分副本设为VOTER_OUTGOING/NON_VOTER能显著降低写延迟:
ALTER TABLE orders CONFIGURE ZONE USING
num_replicas = 5,
num_voters = 3,
voter_constraints = '[+region=us-east1, +region=us-central1, +region=us-west1]',
constraints = '{+region=eu-west1: 1, +region=ap-southeast1: 1}',
lease_preferences = '[[+region=us-east1]]';
上面配置的含义:3 个投票副本在美国三个大区(决定写入 Raft 法定人数),另外 2 个非投票副本放欧洲和东南亚(供本地 follower reads),租约优先留在 us-east1。
五、生产落地清单
结合上面的机制,给出一份可执行的检查表:
Schema 层
- 主键优先用
UUIDv4或crdb_internal.cluster_id() || generate_series之类的分散式生成方式;确需有序主键时,配ALTER ... SPLIT AT预分裂。 - 小维度表用
SET locality GLOBAL,杜绝跨区 Join。 - 多租户表用
REGIONAL BY ROW+crdb_region列,让数据落在租户所在大区。
事务层
- 应用层必须实现
RETRY_SERIALIZABLE的指数退避重试(官方crdb驱动/ORM 通常已内置)。 - 单事务控制在 10k~100k KV 以内,超出改用分批或
IMPORT。 - 只读查询走
AS OF SYSTEM TIME '-5s'的 follower reads,尤其是跨大区读。
运维层
- 监控
crdb_internal.ranges的key_bytes分布,发现单 Range 明显偏大立即手动SPLIT。 - 关注
kv.transaction_restarts中ReadWithinUncertaintyInterval与WriteTooOld的占比;前者偏高说明max_offset或 locality 配置需要调整。 - 定期执行
SHOW EXPERIMENTAL_RANGES FROM TABLE ...核对租约分布与业务流量是否匹配。 - schema 变更走 online DDL(CockroachDB 的列增删是元数据操作,但加索引会触发表级回填,建议低峰期执行并用
CREATE INDEX ... WITH (online = true)评估)。
不要做的事
- 不要用
SELECT FOR UPDATE当互斥锁——它会把整行锁到事务结束,且 Serializable 下冲突率极高。 - 不要在事务里调用外部 HTTP 服务:事务超时(默认 5s 可推高)会强制 abort。
- 不要指望自动合并能快速回收删除后的 Range,空间回收靠 GC TTL(默认 25 小时),误删数据别立刻重建集群。
六、小结
CockroachDB 的设计哲学可以浓缩成一句话:与其消除不确定性,不如把它显式化、可验证、可恢复。
- 时钟不准?用 HLC + 不确定性区间把它变成"可能需要 refresh"的明确分支,而不是假装它不存在。
- 提交要两轮 RTT?用
STAGING事务记录 + intent 列表,把恢复责任下放给每一个后续读者,从而省掉一轮。 - 数据分布不均?用 Range 自动分裂/合并 + Follow-the-Workload 让系统自己收敛,同时保留手动干预的口子。
这套思路对分布式系统设计有普适的借鉴意义:当你无法消除一个物理限制时,最有效的办法往往是把它变成协议里的一个一等公民,让所有参与者都能对它做出确定性判断。相比 Spanner 的硬件方案,CockroachDB 提供的是一条纯软件的、可在任意云上复制的路径——代价是更高的重启率和更陡的学习曲线。选哪条,取决于你的业务能否容忍那部分重试。

发表评论 取消回复