分布式 SQL 引擎:CockroachDB 架构

数据库内核 2026-07-08 · 阅读约 24 分钟

CockroachDB 是 NewSQL 阵营的代表作:对外提供标准 PostgreSQL 协议与 SQL 语义,对内把整个数据库打散成按 key 范围切分的 Range,用 Raft 保证每个 Range 的多副本一致性,再用 MVCC + 混合逻辑时钟(HLC)+ 分布式事务协议支撑跨分片的 ACID。它想同时回答两个问题:像 NoSQL 一样水平扩展、异地多活,又像传统单机关系库一样强一致、支持事务。本文拆解它的分层架构与事务协议,并用一段零依赖 Python 代码把『Range 路由 + 跨分片两阶段提交 + 写意图清理』跑给你看。

一、整体分层:从 SQL 到磁盘的四层

  • SQL 层:无状态的网关。解析、优化(CBO)、执行 SQL,把表数据映射到底层 KV:/tableID/indexID/主键列 -> 行列值。任意节点都能处理任意查询。
  • 事务层:实现分布式 ACID。核心产物是事务记录(Transaction Record)与写意图(Write Intent)。
  • 分布层(Distribution):维护 Range 的元信息与路由(Range Cache / Range Descriptor),决定每个 key 落在哪个 Range、该 Range 的副本在哪些节点。
  • 复制层与存储层:每个 Range 是一个 Raft Group(默认 3 副本);单个副本落在本机的 Pebble(CockroachDB 自研的 LSM-Tree 存储引擎,受 RocksDB 启发)。

一句话概括数据流:SQL -> KV 操作 -> 按 key 定位 Range -> 走该 Range 的 Raft 提交 -> 写入 Pebble。

二、Range:分片与扩展的基本单元

整个 key 空间被切成连续不重叠、左闭右开的 Range,默认约 512MB 触发分裂(split),过小则合并(merge)。每个 Range 的副本分散在不同节点,Raft Leader 负责该 Range 的读写协调。路由信息被客户端(网关节点)缓存在 Range Cache 里,分片分裂/迁移后通过元信息查找修正。

工程细节:最后一个 Range 的上界必须覆盖 key 空间的末尾。下面实验中您会看到,如果分片表写成 ['', 'm') 与 ['m', 'z'),那么 zebra 这种以 z 开头的 key 会因不满足 zebra < 'z' 而路由失败——真实系统用 KeyMax 表示无上界,本文用 ASCII 中 z 之后的字符 { 模拟。

三、MVCC 与混合逻辑时钟(HLC)

CockroachDB 的每个写都带时间戳,同一 key 的多个版本共存(MVCC),快照读直接按时间戳读历史版本,避免读写互相阻塞。时间戳用的是 HLC:物理时钟(PT)与逻辑计数器(L)的组合,既贴近真实时间,又保证因果顺序。各节点时钟偏移被限制在 --max-offset(默认 500ms)内;读操作如果遇到『现在 + max_offset』窗口内的不确定写,需要等待(不确定性重启/等待),以此换取无全局时钟的线性一致性。

四、分布式事务协议:写意图 + 事务记录 + 并行提交

跨 Range 的事务是 CockroachDB 最硬核的部分,简化理解为一个『以写意图为参与者』的变种两阶段提交:

  1. 阶段一(并发写意图):事务把每个要写 key 的写意图(未提交的值 + 指向事务记录的指针)并发地写到各自所在的 Range,并创建事务记录(状态 PENDING/STAGING)。写意图本身就是『参与者的 prepared 状态』。
  2. 提交决策:客户端把事务记录原子地标记为 COMMITTED——这一落点就是整个事务的原子提交点。
  3. 阶段二(异步清理意图):把每个写意图原地解析为已提交的 MVCC 版本。清理是异步的,不影响事务已提交的事实。
  4. 冲突处理:其他事务读到写意图时,检查其事务记录:已提交则帮忙解析;已中止则回滚该意图;仍在进行则推动(push)其时间戳或等待。

现代版本还引入并行提交(Parallel Commits):把『写齐所有意图』与『标记 COMMITTED』流水线化,跨分片事务提交延迟从两个 RTT 压到一个 RTT。

五、动手实验:模拟 Range 路由与跨分片事务

下面代码用两个 Range、一个支持写意图的 KV 存储和一个简化事务管理器,完整演示:路由定位、跨分片写意图暂存、提交前读冲突、提交清理、回滚清理。零依赖,Python 3 直接运行:

# -*- coding: utf-8 -*-
# 简化模拟 CockroachDB 架构核心机制:
# 1) Range 分片 + 路由  2) 跨 Range 分布式事务 2PC 与写意图(intent)清理

# 注意: 最后一个 Range 的上界必须覆盖 key 空间末尾,
# '{' 是 ASCII 中 'z' 之后的字符, 用来模拟 KeyMax
RANGES = [
    ('',    'm',  'range-1'),   # 前缀 a~l
    ('m',   '{',  'range-2'),   # 前缀 m~z (含 z 开头, 覆盖到末尾)
]

def locate(key):
    for lo, hi, rid in RANGES:
        if lo <= key < hi:
            return rid
    raise KeyError(key)

class Store:
    def __init__(self):
        self.data = {}     # key -> (committed_value, commit_ts)
        self.intents = {}  # key -> (txn_id, value, ts) 未提交的写意图
    def get(self, key):
        if key in self.intents:
            i = self.intents[key]
            raise RuntimeError(
                f'READ_CONFLICT: key={key!r} 上有未提交写意图 '
                f'txn={i[0]}, 需等待或推动其时间戳')
        return self.data.get(key)
    def put_intent(self, key, value, txn_id, ts):
        self.intents[key] = (txn_id, value, ts)
    def resolve_commit(self, key, ts):
        txn_id, value, its = self.intents.pop(key)
        self.data[key] = (value, ts)
    def resolve_abort(self, key):
        self.intents.pop(key, None)

store = Store()

class Txn:
    _seq = 0
    def __init__(self, ts):
        Txn._seq += 1
        self.id = f'txn-{Txn._seq}'
        self.ts = ts
        self.writes = {}
        self.status = 'PENDING'
    def stage_intents(self, store):
        for k, v in self.writes.items():
            store.put_intent(k, v, self.id, self.ts)
        self.status = 'STAGING'
    def commit(self, store):
        self.status = 'COMMITTED'
        for k in self.writes:
            store.resolve_commit(k, self.ts)
    def abort(self, store):
        self.status = 'ABORTED'
        for k in self.writes:
            store.resolve_abort(k)

print('== 分片路由 ==')
for k in ['apple', 'mongo', 'zebra']:
    print(f'  key={k!r:>10} -> {locate(k)}')

print('\n== 事务1: 提交(跨两个 Range) ==')
t1 = Txn(ts=100)
t1.writes = {'apple': 5, 'zebra': 9}
t1.stage_intents(store)
print('  阶段一完成, 写意图分布到: ' +
      ', '.join(sorted(locate(k) for k in t1.writes)))
try:
    store.get('apple')
except RuntimeError as e:
    print(f'  提交前读 apple 触发: {e}')
t1.commit(store)
print(f'  阶段二完成; apple={store.get("apple")}, '
      f'zebra={store.get("zebra")}')

print('\n== 事务2: 回滚 ==')
t2 = Txn(ts=200)
t2.writes = {'mongo': 7}
t2.stage_intents(store)
t2.abort(store)
print(f'  回滚后 mongo 意图已清理, 读取结果={store.get("mongo")}')

assert store.data == {'apple': (5, 100), 'zebra': (9, 100)}
assert store.intents == {}
print('\n校验通过: 提交数据已生效, 无残留写意图')

在沙箱(Python 3,无第三方库)上的真实运行输出:

== 分片路由 ==
  key=   'apple' -> range-1
  key=   'mongo' -> range-2
  key=   'zebra' -> range-2

== 事务1: 提交(跨两个 Range) ==
  阶段一完成, 写意图分布到: range-1, range-2
  提交前读 apple 触发: READ_CONFLICT: key='apple' 上有未提交写意图 txn=txn-1, 需等待或推动其时间戳
  阶段二完成, 提交并清理意图; apple=(5, 100), zebra=(9, 100)

== 事务2: 回滚 ==
  回滚后 mongo 意图已清理, 读取结果=None

校验通过: 提交数据已生效, 无残留写意图

六、结果解读

  • 路由正确性:zebra 正确落到 range-2。值得一提,本文初版脚本把右边界写成 'z',zebra 直接抛出 KeyError——这正是分片系统设计里最容易漏的边界条件:最后一个 Range 不能有上界缺口。
  • 读撞上写意图:事务提交前读 apple 触发 READ_CONFLICT,对应真实系统里的『intent resolution』:读者必须检查意图背后的事务记录,决定等待、帮忙提交还是回滚它。这正是 MVCC 数据库『读不被写阻塞、但要能感知未提交写』的具体形态。
  • 原子提交点:事务记录标记 COMMITTED 后,两个 Range 上的数据才对外可见;清理意图只是『收尾保洁』,即便节点在清理前宕机,后续读者也能依据事务记录代为完成解析——这是崩溃恢复正确性的关键。
  • 回滚的代价:事务2 回滚只需删除意图,已提交数据不受影响,最终存储中无任何残留,断言校验通过。

七、与同类方案的对照

维度CockroachDBGoogle Spanner传统分库分表
时间戳HLC(无需专用硬件)TrueTime(原子钟/GPS)各库本地时钟
分片Range 自动分裂/合并/再平衡Split 自动管理人工维护,扩缩容痛苦
跨分片事务写意图 + 事务记录 + 并行提交2PC + Paxos应用层或 XA,性能差
一致性读MVCC 快照读,不确定性窗口等待TrueTime 提交等待(commit-wait)难以保证全局一致

八、工程应用

  • 异地多活与合规部署:用 REGIONAL BY ROW / 副本放置约束把数据钉在用户所在区域,兼顾低延迟与数据主权合规。
  • 跟随读(Follower Reads):历史快照读可以走非 Leader 副本,跨地域只读查询延迟大幅下降。
  • 热点缓解:单调递增主键(如时间戳开头)会把写集中到末尾 Range,官方建议用 UUID 或 hash-sharded index 打散。
  • 变更数据捕获(Changefeed):基于 Range 级 Raft 日志的变更流,直接对接 Kafka 做数仓同步。

九、常见误区

  1. 把 CockroachDB 当『自动分片的 Postgres』而忽略事务开销:跨 Range 写事务天然比单机贵(至少一次共识 + 意图清理),热点小事务应通过表设计尽量落在单 Range。
  2. 用单调自增 ID 做主键:在分布式 KV 上这是制造末尾 Range 热点的经典反模式。
  3. 认为 HLC 能消除时钟问题:HLC 只保证因果序;超出 max-offset 的时钟漂移会直接导致节点被集群拒绝。
  4. 忽视写意图清理失败的影响:意图长期不清理不会破坏正确性,但会拖慢所有冲突读写,真实集群需要关注 intent 积压指标。
  5. 盲目追求 SERIALIZABLE 之外的隔离级别:CockroachDB 只提供可串行化(SER 隔离级别),很多『读已提交够用』的经验在这里反而多余。

十、参考文献

  • Taft, R. et al. CockroachDB: The Resilient Geo-Distributed SQL Database. SIGMOD 2020.
  • Corbett, J. et al. Spanner: Google's Globally-Distributed Database. OSDI 2012.
  • Kulkarni, S. et al. Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases. PaPoC 2014(HLC 原始论文).
  • Ongaro, D., Ousterhout, J. In Search of an Understandable Consensus Algorithm (Raft). USENIX ATC 2014.
  • CockroachDB 官方文档: Transaction Layer & Parallel Commits. https://www.cockroachlabs.com/docs/stable/architecture/transaction-layer