← 文章 / 数据与数据库
Hacker News 6小时前 · 2026-09-05 06:06:43 · 2 阅读

OK,但它能水平扩展吗?

YouTube评论截图:有人问

这可能是关于Spacetime被问到最多的问题。它看似简单,似乎也该有个简单的答案。

扩展性是个复杂的话题,而魔鬼总是藏在细节里——这是老生常谈了。另一方面,这个话题也没复杂到不能在博客文章里从第一性原理出发把它讲清楚。

让我们先从探讨扩展性的一般概念入手,然后再回答这个问题:"Spacetime如何扩展?"

如果你只想看结论:扩展有三个维度——计算、存储和网络。水平扩展存储在相对 straightforward,计划于2026年10月31日发布。然而,并非所有的网络和处理都能水平扩展。那些宣称具备通用水平扩展能力的OLTP数据库,往往在每笔事务上承担巨大的开销,在面对并发冲突事务时性能极差。Spacetime能够在高竞争下提供高性能,并提供工具让你轻松扩展可并行的OLTP工作负载。

注意

注:本文大量提及CockroachDB。CockroachDB大致可作为所有通用型水平扩展关系数据库(包括Spanner和Aurora DSQL)的代表。虽然我会谈到这些技术的一些问题,但它们都是极其令人印象深刻的工程杰作。

扩展性

直观地说,每个人对"扩展"(scale)都有个大概的理解。它意味着能做更多事,意味着跟得上需求,意味着能处理十亿级请求、或"无限"请求、或无限的数据量、或无限数量的客户,或者意味着你的应用每年能以10倍或100倍的速度增长,而无需重写软件。

具体来说,我觉得大多数人谈论一个系统“可扩展”时,指的其实是它是否具备水平扩展能力。垂直扩展是靠一台更强大的机器干更多的活,而水平扩展则是靠增加机器数量来提升处理能力。如果机器数量翻倍,能完成的任务也大致翻倍,那这个计算就能水平扩展。毕竟单台机器的体积和性能终究有上限,但理论上你能买的机器数量却没有上限。这个想法非常诱人,所以大家都追求这种特性。

Note

注意:“用更多机器干更多活”这个定义意味着,所有可水平扩展的系统必然是分布式系统。但反过来不成立——分布式系统的目的并不只是水平扩展。比如,分布式状态机复制的目的是为了可靠性,在多台机器上冗余地执行相同的计算,而不是为了扩展性。后文 Spacetime 部分会详细讨论这一点。

“它能水平扩展吗?”这个问题其实问得不够明确。更好的问法是:“它在哪些方面能水平扩展?”因为可扩展性实际上有三个相当独立的维度:

  1. 计算:能处理多少事务
  2. 存储:能存多少数据
  3. 网络:能支撑多少连接、多大带宽

Note

顺便一提,这正是我们在 1.0 发布主题演讲中提到的三大“基础要素”。

为了说明这一点,我们来看几个数据库系统。它们都提供大体兼容 PostgreSQL 的接口,但底层架构却截然不同。

例如:

  • Postgres 本质上是一个单节点数据库。它不会自动为你横向扩展计算、存储或网络能力。当然,你可以通过部署多个 Postgres 实例来横向扩展,但就 Postgres 代码本身而言,它对这些实例的存在几乎一无所知。唯一的例外是只读副本,它允许你手动将读请求分流到副本上。这有助于提升网络和计算能力的扩展性,但会引入读写一致性和性能方面的限制。Postgres 本身没有主节点集群的概念,无法在主节点之间运行事务或查询,也不会自动路由到合适的主节点。当然,你可以自行编写软件来实现这一功能,这也是人们横向扩展 Postgres 的常见做法,但具体如何实现,就留作读者的练习题了。

Animated Postgres architecture: writers queue behind a single primary that holds compute and storage together, WAL streams out to manually configured read replicas, and readers fan out across them; compute and networking scale partially for reads only, and storage does not scale

  • Neon 是 Postgres 的一个修改版,实现了存储的横向扩展。Neon 的表由对象存储支撑,并配有本地页面缓存结构以确保数据访问的快速和高效。虽然缓存未命中时页面访问延迟可能较高,但这种架构为你的 Neon 数据库提供了近乎无限的存储空间。然而,Neon 并不会自动横向扩展计算和网络能力。与 Postgres 一样,所有写事务都必须经过单一主节点。不过,作为 Postgres 的变种,Neon 也支持只读副本,以便对读负载进行计算和网络能力的横向扩展,但这同样伴随着类似的一致性限制。

    即便没有自动的计算扩展,存储的横向扩展也是一大优势。即便是用户数量不多的小型 Web 应用,从理论上讲也可能需要大量的存储空间。

Animated Neon architecture: writers queue behind a single Postgres compute, WAL flows down through pageservers into bottomless object storage which visibly keeps growing, pages are pulled up on demand, and a read replica serves readers; storage scales, while compute and networking scale partially for reads only

  • CockroachDB(以及 Spanner)理论上通过将每张表的数据分散到集群各节点来水平扩展存储,每张表会被切分为若干个「范围(range)」,每个范围存储在一台机器上,并通常复制到另外两台机器。只要写入操作不都集中在同一个范围,它也能水平扩展网络层。CockroachDB 采用对称架构,集群中的任意节点都能处理任意 SQL 请求,包括读和写。此外,对于能够良好并行化的计算任务(如分析查询或对不相关键的写入),它也支持水平扩展。客户端连接的节点会计算查询计划并返回结果,但读写操作都会以分布式事务的形式在整个集群中基于数据范围执行。

Animated CockroachDB architecture: three symmetric nodes each store replicated ranges, writes land on any node, and each write replicates to a quorum of other nodes and collects acknowledgments before committing; compute, storage, and networking all scale, provided transactions do not contend

能找到圣杯吗?

如果 CockroachDB 能在三个维度上全部扩展,那它肯定在各方面都优于 Postgres 和 Neon,对吧?难道它用了什么 CAP 定理[1]或原子钟的独家秘诀?

很遗憾,这里没有任何魔法。CockroachDB 固然是现代工程的杰作,也能对某些计算做水平扩展,但并非所有计算都能水平扩展。水平扩展本质上是一个并行计算问题:我们能否把计算拆分成多个部分,让不同的机器同时处理?答案往往是否定的。而 CockroachDB 的问题在于,即使数据竞争迫使你只能逐条串行执行更新,你依然要为水平扩展付出巨大的协调开销。CockroachDB 在水平扩展上得到的,在纵向扩展上会加倍失去。

残酷的真相是:水平扩展往往不是用更多机器做更多事,反而是用更多机器做更少的事——一台机器 1 毫秒能完成的计算,10 台机器可能需要 100 毫秒。

CockroachDB 的水平扩展方案主要有两个问题:

  1. 单个事务涉及的数据很少恰好都在同一台机器上。
  2. 任何事务都无法独占一个数据范围,因此每个事务都要承担分布式并发控制的开销,冲突的事务必须等待、中止或重试。

第一个问题的根源在于数据所有权被均匀地分散到整个集群。数据分散在各处,几乎每个事务都需要发起网络请求。虽然分片听起来很麻烦,但如果你的大部分事务都只访问单个分片内的数据,分片反而能带来更好的性能。另外值得一提是,对于很多工作负载,Neon 的设计并不存在这个问题,因为它会把热点页面缓存在同一台机器上。

数据分布方式对比动画:左边所有权均匀分散,单个事务需要访问三个不同节点上的行,每次提交前都要付出网络往返开销;右边数据分片后集中存放,每个分片拥有自己的数据行,事务可以以更快的速度在本地提交;Neon 通过本地缓存热点页面规避了大部分此类开销

注意

Spanner 通过“表间置放(table-interleaving)”部分解决了这一问题,允许你指示 Spanner 将相关的表放置在同一位置。这在简单场景下可以显著提升性能。CockroachDB 曾支持表间置放多年,但在 v21.2 版本中移除了该功能,因为其收益不足以证明引入的复杂性是合理的。

第二个问题是并行化任意计算时的通用难题:争用下的协调。即便是原生的 Postgres 也会面临同样的问题,只是在更小的时间尺度上。它无需协调分布式系统中的事务,而是必须在多个 CPU 核心之间进行协调。Postgres 可以在许多 CPU 核心上运行事务,但一旦这些事务触及相同的数据,它们就需要花费时间进行协调。CPU 本身也必须协调 L1、L2 和 L3 缓存之间的原始内存访问。另一个写入者修改同一条缓存行可能会使你的本地副本失效,并迫使核心同步。Postgres 还需要协调事务本身:谁拥有锁、哪些行版本可见、事务以何种顺序提交,以及冲突的工作是需要等待、中止还是重试。

两个核心写入相同缓存行的动画示意图,按时间比例尺为每纳秒 0.2 秒:核心 1 以 Modified 状态拥有该行并每纳秒写入一次;核心 2 的存储未命中 L1 和 L2,向共享 L3 发送所有权读取请求,L3 目录嗅探到核心 1;核心 1 的副本被置为无效(I 状态),修改后的数据经其 L2、L3 转发并进入核心 2 的缓存,约 100 纳秒后以 Modified 状态到达,期间两个核心均暂停,随后核心 2 以全速写入

例如,想象一个涉及用户行和相关元数据行的轻量 OLTP 事务。首先,在单机 Postgres 中这两行位于同一台机器上,而在具有 N 个节点且使用随机主键的 CockroachDB 集群中,它们落在同一个租约持有者(leaseholder)/ 领导节点上的概率约为 1/N。原本在单个核心上只需约 3 微秒的关键临界区,现在却变成了需要多次网络请求的 1 毫秒分布式关键临界区。即使在 1/N 的共存置情况下,事务也必须等到写入复制到法定人数后才能释放锁,因此锁的持有时间大致相同。其次,也是更为重要的是,其他希望读取或修改这些行的不同事务现在必须等待我们的事务提交或中止,耗时 1 毫秒。这两个问题呈乘积效应叠加:对于热点键,约 3 µs 的关键临界区每秒钟可容纳约 300,000 个竞争事务;而 1 ms 时仅能容纳约 1,000 TPS。反直觉的是,在此场景下单线程方案反而“可扩展性”高出了 300 倍!

注意

即便只有 1% 的事务竞争同一行数据,在 1 ms 分布式提交的情况下,任何水平扩展的集群都会比单个核心更慢。这正是Amdahl 定律在水平可扩展性上的体现。竞争事务必须串行执行,总吞吐量永远无法超过串行速率除以串行事务所占比例:(1 / 1 ms) / 1% = 100,000 TPS。无论添加多少核心!实际上,集群在吞吐量上超越单核心的场景只有一个:没有竞争,且你每月支付超过 3,600 美元(基于模型的成本假设)。这类场景对某些公司来说确实存在,但毕竟属于小众情况。

在竞争情况下,多个写者会使事务显著慢于逐个运行的情况,因为核心将更多时间花在协商谁有权修改共享状态,而非执行有效工作。

但即便是那些可以并行执行的计算,人们也往往严重低估了为克服协调开销而额外需要的硬件。粗略举例,L1 缓存的延迟大约只有 L3 的二十分之一。如果并行化一个负载,把原本廉价的本地缓存访问变成了频繁的同步和跨核通信,那你很可能需要 10 个以上的核心才能赶上单个精心做过缓存优化的核心。这还没算上更大的工作集和 MVCC 之类的元数据把有用数据挤出缓存后,访问主内存的高昂代价。而我们仍然没有考虑分布式 MVCC 这类方案所需的网络请求和序列化,以及它们带来的大量缓存未命中。一个核心就能搞定的事,你真愿意为 10 个核心买单吗?也许在某些问题上我们最终做到了可扩展,但代价是什么?

对于天生串行的计算,你可以把它搬到更快的机器上、优化代码、或为了容错而复制结果,但无论多不情愿,你都无法让十台机器把它跑得更快。读过《人月神话》的程序员凭直觉都明白这一点:往项目里加程序员并不会让软件更快写完,只会让它写得更慢、开的会更多。10 个作者写小说不可能比 1 个人快,何况他们还要靠平信来回沟通。

道理很简单:水平可扩展性并不是一个系统有或没有的属性,它主要取决于工作负载本身。

Spacetime

那就来聊聊 Spacetime 吧。

首先值得区分的是:你使用的 API 和实现它的架构是两回事。Spacetime 编程模型本身并不要求单机或分布式实现。

下面这样的代码有很多种实现方式:

ctx.db.myTable.insert({ name: 'Tyler' }); 
    

或者:

SELECT * FROM my_table 
    

API 并未说明数据存储在哪里、由哪台机器执行事务,以及幕后涉及多少台机器。例如 Convex 就在 MySQL 或 Postgres 等现有存储引擎之上构建了一层事务层。原则上,我们可以用一群 Spacetime 模块服务器配合一个庞大的 CockroachDB 集群作为存储引擎来实现 Spacetime。你可能没想到,这其实与我们最初的实现非常接近:Spacetime 的第一个原型就是基于 Postgres + Kafka 搭建的!

然而,在并行度未完全发挥或机器数量少于几十甚至上百台的场景下,这种架构反而会导致性能更差,同时成本更高。这篇博客文章详细解释了原因。

Spacetime 最初是我们 实时 MMORPG 的后端,因此从一开始我们就面临着极端的交易延迟和吞吐量要求。我们不能为了让非典型场景能够扩展至任意数量的机器,就让常见场景的成本变得难以承受。正是这些要求迫使我们要从头开发自己的定制化存储和执行引擎。

令人意外的是,如今 Spacetime 数据库的执行模型是 单线程设计。直观来看,尤其是对于优化缓存性能经验有限的工程师而言,这听起来像是 “坏消息”。我们最初也抱有同样的假设:我们自定义数据库引擎的早期版本启用了由 MVCC 事务支持的并行执行。但问题是,当我们实际测量时,发现单线程执行的 performance 彻底碾压了并行执行。从某种程度上说,我们花在这上面的代价可能超过了 100 万美元,只是为了得出 “不如直接用一把大锁” 的结论。未来或许确实有可能在一个数据库内部实现并行执行,并在有限场景下获得更好的性能,但这需要极其精细的工程设计和性能测量,以确保其引入的复杂性和开销不会超过它所带来的收益。

钟形曲线迷因:『单核就够了』

这是否意味着 Spacetime 无法水平扩展?当然不是。

Spacetime 的水平扩展

Spacetime 水平扩展解决方案的基础受到Actor 模型的启发。Actor 模型是一种通用的并行计算模型,其动机源于「构建由数十、数百甚至数千个独立单处理器组成的高度并行计算机器,每个处理器拥有自己的本地内存和通信处理器,并通过高性能通信网络进行通信」的愿景。

在当前的 Spacetime 中,每个数据库都是一个单线程 Actor,我们已制定了一个六步策略,让水平可扩展性成为越来越友好的开发者体验。

下一阶段已有明确日期:异步跨数据库通信和分层存储将于 2026 年 10 月 31 日作为我们的可扩展性发布 — Spacetime Continuum 的一部分上线。

步骤 功能 状态
1 提供具有世界级性能的复制数据库 现已推出
2 提供世界级的分片体验(异步 IDC) 2026 年 10 月 31 日
3 水平扩展每个数据库的存储(分层存储) 2026 年 10 月 31 日
4 水平扩展每个数据库的网络(读副本) 计划中
5 实现跨数据库事务(同步 IDC) 计划中
6 实现数据库内分区 计划中

在深入细节之前,有必要区分 Spacetime 的两个版本:

  • SpacetimeDB Standalone,可在 GitHub 上获取的单节点版本。
  • SpacetimeDB Cloud,专有的集群化版本。

具备世界级性能的复制数据库

我们已经详细讲过每个 Spacetime 数据库如何实现世界级的单线程性能,但需要指出的是,SpacetimeDB Cloud 同样支持分布式状态机复制。

也就是说,虽然每个数据库是单线程的,但并不一定只跑在一台机器上。这听起来有点反直觉,但核心思路是:把单线程正在做的事情复制到多台机器上,这样即使某些机器故障,数据库依然可用。换句话说,SpacetimeDB Cloud 中的每个数据库本身就是一个分布式系统。

Viewstamped Replication 动画示意图:客户端把事务发送到节点 1 上的单线程主节点,主节点向节点 2 和节点 3 上的备份流水线式发送 PREPARE 消息并收集 PREPARE_OK 确认;当节点 1 故障时,备份节点通过交换 STARTVIEWCHANGE 和 DOVIEWCHANGE 消息执行视图变更,节点 2 成为视图 2 的主节点并发送 STARTVIEW,客户端随后重新路由到它

值得一提的是,通过流水线化实现,我们在基准测试中证明:只要节点间有足够的网络带宽、内存能满足流水线深度,复制数据库的吞吐量与非复制数据库持平(基准测试事务下约为 30 万 TPS)。

流水线复制动画示意图:领导节点的事务日志流经 current、applied、decided、durable 四个水位线;领导节点在头部立即应用新请求,向两个备份节点流式发送 PREPARE 消息,第一个返回的 PREPARE_OK 确认推进 decided 水位线,第一个 DURABLE_OK 推进 durable 水位线,响应按 applied、decided、durable 层级释放给正在监听的客户端,且数据库锁仅被头部正在执行的事务持有

注意,Spacetime 的默认设置 confirmedReads(true) 会让客户端只在事务于集群中达到 durable 状态后才执行读取。

世界级分片体验

对数据库进行分片,可以让仅在单个分片内操作的交易获得最佳性能;分布式事务则让你能够运行跨越多个分片的交易。为什么不能兼得二者之长?如果让大部分交易都在单台机器内完成,并给程序员提供组织数据以促成这一点的工具,那么只有在极少数情况下才需要进行分布式事务。

关键在于让分片边界变得显式且易用。在 Actor 模型中,每个分片可以像其自身独立调度的 Actor 一样运行:分片内的事务保持快速、本地化和单线程,只有确实需要访问多个分片的事务才需承担协调成本。这让架构能够在不假装分布式协调免费的的同时,保留让 Spacetime 如今如此快速的核心性能特征。

每个数据库都是一个拥有自身状态和事务日志的独立 Actor,因此针对不同数据库的事务可以在无需协调的情况下并发执行。

Spacetime 已经提供了管理这种架构的工具。数据库可以作为其他数据库的子级创建,过程可以发布数据库并在其上调用函数,根数据库则可以跟踪其下各个数据库的身份与状态。

多位客户以这种方式运行数百甚至数千个数据库,BitCraft 也以这种方式实现扩展:单个根数据库维护全局数据,区域数据库负责世界的不同部分和不同的玩家群体。同一区域内的事务保持快速和本地化,而各区域本身则并行执行。

我们计划通过一等公民的库际通信(IDC)大幅降低该模型的使用门槛。异步 IDC 允许一个数据库以类型安全的方式调用另一个数据库上的函数。

Animated diagram of a Spacetime cluster with six nodes, each hosting several databases; asynchronous inter-database messages hop between databases across nodes, and every database keeps executing independently

从高层来看,TypeScript API 大致如下:

// 异步 IDC:向另一个数据库发送类型安全的消息。
// 这将在目标数据库上恰好触发一次 reducer 调用。
ctx.db.receivePlayer.insert({
    msgId: 0n,
    target: regionDb,
    player,
});

水平扩展每个数据库的存储

截至今天,Spacetime 将所有表数据 存储在内存中 的 leader 节点上。这意味着在单个数据库中,你能放入表的数据量受限于该机器上的物理内存。

然而,这一限制并非 Spacetime 的根本约束,也不影响其卓越性能的核心。我们将内存、磁盘和对象存储视为 CPU 缓存模型的天然延伸。从理念上讲,我们把内存当作 L4 缓存,磁盘当作 L5 缓存,对象存储当作 L6 缓存。这种缓存模型是单写入者获取极致性能的经典方式,通常被称为“分层存储”(tiered storage)。

分层存储金字塔:CPU 缓存层级 L1 至 L3 由 Spacetime 扩展,内存作为 L4,NVMe 磁盘作为 L5,对象存储作为 L6;每往下提升一层,容量越大、成本越低、速度越慢,其中内存现已可用,磁盘和对象存储层级将于 2026 年 10 月推出

缓存行和预取机制允许你从便宜且较慢的存储中批量预取数据到更昂贵但更快的存储中。为数据库提供分层存储可以摊薄缓存未命中的成本,使整体性能接近更快、更昂贵的存储,同时仍能保留更大、更便宜存储的可扩展性。

分页会不会阻塞单线程执行?

如果每个事务都在一个线程上执行,看起来一次指向对象存储的缓存未命中就可能导致整个数据库阻塞 50 毫秒。那就像经营一家图书馆:每当有顾客预约一本书,就让整条队伍干等两周,书到了才处理下一位顾客。这显然荒谬。让分层存储与单线程执行兼容的规则很简单:拿到锁之后,不要等待缓存未命中。异步下单,然后在等待的同时继续处理下一位顾客!

好在这个问题早已被充分研究过。H-Store 把这种技术叫做 anti-caching:正常执行事务,一旦碰到不在内存中的数据就中止事务,异步去取数据,等待期间继续跑别的事务;数据到位后再重新执行。中止几乎是零成本的,因为什么都没提交;而每次重新执行只花几微秒的 CPU,相比之下省掉了本会串行等待的毫秒级 I/O。Calvin 用了类似的技巧,称为 reconnaissance queries:先在最近的快照上试运行事务,摸清它会读哪些数据并预取,然后正式执行。TigerBeetle 则在同步应用每批事务之前,显式执行一个预取阶段。

中止与重试的动画示意图:事务流经 leader 的单线程执行器,读取内存中的热页面,往返极快;某事务访问的页面不在内存中,因页面缺失而中止,在 leader 内部的等待槽中挂起,同时页面经磁盘从对象存储异步读入内存;整个取数过程中执行器继续执行其他事务,页面到达后挂起的事务重新执行并提交

Spacetime 特别适合这类技术,因为 reducer 是确定性的。reducer 不能执行 I/O、读时钟或生成随机数,所有数据访问都经过 reducer context,因此引擎能观察到事务的每一次读取。这意味着中止不会产生任何可见副作用,针对同一状态的重新执行会命中完全相同的行,而试运行也能准确发现正式执行所需的数据。

这正是 TigerBeetle 那支实力极强的团队所说的 “对角线扩展”

磁盘表和对象存储表将于 2026 年 10 月 31 日发布。我们将其视为 Spacetime 可扩展性的关键改进。借助磁盘表,我们可以大幅提升存储上限并降低数据存储成本;借助对象存储表,我们可以彻底消除存储限制,允许用户在单个数据库中存储理论无上限的数据。

水平扩展每个数据库的网络连接

如前所述,与计算一样,水平扩展网络连接并非总是可行——某些场景下写入者需要修改相同的状态,因此必须连接到同一台机器并发送数据。这也是 CockroachDB 建议使用随机键来避免集群热点的原因之一。即便如此,我们仍需力争实现尽可能高的读写吞吐量。

与 CockroachDB 类似,SpacetimeDB Cloud 也采用对称架构,即你可以连接到任意节点,Cloud 会确保你的写请求被处理或代理到正确的位置。从外部看,它就像一台超级计算机。对写入者而言,这让我们能够"收敛"连接——客户端连接到任意节点,该节点会将这些连接复用为一条连接到实际托管该数据库的节点的连接。

从原理上讲,我们也可以通过引入一致读副本,将 SQL 订阅和读查询的处理从主节点卸载。一致读副本允许我们从主节点"发散"——订阅评估不必直接连接主节点,而是可以路由到一致读副本。这样一来,我们就可以水平扩展读连接数和带宽。

这里的"一致"意味着副本以相同的总顺序应用主节点的事务日志,因此订阅看到的是完全相同的更新序列,只是存在延迟。对于时序 SQL 查询,主节点会不为每个查询执行操作,而是仅为其在事务日志中分配一个位置,副本在应用到该偏移量之前不会应答查询。因此读操作相对于每次写都具备线性一致性,而主节点的额外开销仅是发放序号。

跨库事务

同步 IDC(分布式协调器)提供了内置的两阶段提交机制,能够支持类似 CockroachDB 那种真正的分布式事务。这使得在需要真正原子性的场景下,事务可以跨越集群中的多个数据库。

其 TypeScript API 看起来就像调用普通的 reducer,只不过目标是在外部数据库上执行。

// Sync IDC:在当前事务内调用另一个数据库。
ctx.at(regionDb).reducers.receivePlayer(player); 
    

这里存在明显的张力。如果每个数据库都在一个大锁后面单线程运行,那么从数据库 A 同步调用数据库 B 会持有 B 的锁,直到 A 的事务提交或中止,而这需要一次网络往返。我们的计划是保持执行单线程,但为这些分布式事务重新引入 MVCC,允许在网络往返进行时并发事务继续执行。

流水线两阶段提交的动画图示:在第一轮中,数据库 A 上的客户端事务调用数据库 B,收到 PREPARED 消息,两个数据库在内存中提交并释放锁;在第二轮中,数据库交换 PREPARED TO PERSIST 和 COMMIT PERSIST 消息,将日志条目复制到备份,不持有任何锁,同时并发事务全程持续执行

我们设计了一种流水线两阶段提交协议,首先在内存中提交事务,并且在磁盘 I/O 期间从不持有数据库锁,同时我们已经用 TLA+ 模型检查了其安全性属性。细节值得单独写一篇博文。

结合这些特性,多个数据库将组成一个连贯的分布式应用,而无需让分布式协调成为每个事务必经之路。大部分工作仍将保持本地化和异步。只有真正需要在多个数据库间保证原子性的操作,才会付出分布式事务的成本。

库内分区

多个数据库是一个自然的扩展边界,但它们也要求你管理多个模块、独立更新它们的 schema,并想办法在负载变化时重新平衡。解决这些用户体验问题的下一步是在单个逻辑数据库内部引入分区。

分区机制让一个数据库模块能够包含多个独立运行的 shard。这样既保持了单一部署产物,又能让 schema 变更原子性地应用到整个数据库,同时不同分区上的事务可以并发执行。

Spacetime 的分区和 CockroachDB 的 range 是两回事。range 是一种放置策略:它决定某张表的某段数据存在哪台机器上,但任何事务仍然可以访问任意 range,而且无论数据落在何处,每次写入都以同样的方式复制和协调。Spacetime 的分区则是一个执行单元:它拥有自己的数据、自己的串行事务日志,操作这些数据的 reducer 代码也运行在分区内部。开发者可以根据工作负载的结构来划分分区边界,让常见的事务不跨出所在分区;Spacetime 还能在机器之间整体迁移分区来重新均衡负载,而不削弱这一保证。

数据库内部分区的动画示意图:一个由单一模块构建的逻辑数据库分布在两台机器上,包含四个分区,每个分区有自己的事务日志;客户端事务按键路由到对应分区并并发执行,罕见的跨分区事务在两个分区之间运行两阶段提交,重平衡时把整个分区从机器 1 迁移到机器 2,期间一切照常运行

不跨分区的事务无需为其他分区的存在付出任何代价。这就是零开销原则:在你真正用到水平扩展之前,不应该为它买单。[2]

Spacetime 事务的决策流程:留在单个数据库或分区内部的事务走本地快速路径(现已可用);跨边界操作使用 async IDC(2026 年 10 月上线),除非必须同步保证原子性,此时采用计划中的分布式事务并承担协调开销

那么,它到底能不能扩展?

是的,它确实可以扩展。如今,你可以通过将并行工作负载分片到多个数据库中实现扩展,同时保持每个分片的快速与本地性。正如我们所见,如果你的工作负载存在大量竞争,或者你不想为获得单核性能而支付大型集群的成本,这种架构几乎是最优选择。

随着时间的推移,分层存储、数据库间通信、分布式事务和分区将使得这种架构变得越来越透明。我们的目标不是让协调变得免费,而是确保你只在实际需要时才为此付费。

Tyler Cloutier
Clockwork Labs 联合创始人


[1] 给分布式系统发烧友的一个简短旁白:关于 CAP 定理。CAP 定理,又称 Brewer 定理,简单而言就是:没有任何系统能同时满足一致性(consistent)、可用性(available)和分区容忍性(partition tolerant)。一个系统可以具备其中 0、1 或 2 个特性,但永远无法同时具备全部 3 个。

此处的“一致”意味着每次读操作都能获得最近的写操作结果或一个错误。在实践中,这一特性通过正确实现 分布式状态机复制(即 Paxos 或 Viewstamped Replication 的某种变体)来实现。

“可用”意味着每个发往非故障节点请求都会收到非错误的响应。

“分区容忍”意味着系统存在于节点间消息可能被延迟或完全丢弃的世界中。

如果再听到有人模糊地引用 CAP 定理作为“证明”某个数据库能否水平扩展,我可能就要精神崩溃了。不知为何,人们普遍存在一种误解,认为 CAP 定理(又称 Brewer 定理)会以某种方式限制 OLTP 数据库系统的可扩展性。例如,投资者和工程师大约六次向我询问,我们是如何“绕过 CAP 定理”以获得 Spacetime 基准测试数据的。事实上,CAP 定理对此毫无疑义。

我的观点是,这种误解源于 Google 的 Eric Brewer(CAP 猜想的最初提出者,后来已被证明为定理:这篇论文由 Seth Gilbert 和 Nancy Lynch 完成证明)发表的 这篇文章这篇论文。Spanner 作为大名鼎鼎的水平可扩展 SQL 数据库而广为人知,或许正是因此,人们得出这样一种印象:既然该论文同时提及了 CAP 定理和 Spanner,那么 CAP 定理必然与可扩展性有关。然而,你一定会注意到,原始的 Spanner 论文 完全没有提及 CAP 定理。

无论如何,CAP 定理只在一种意义上与数据库相关,即:最优的情况只能在设计 CP 系统或 AP 系统之间做出选择。CP 系统始终保持一致性,但在网络故障时可能不可用;AP 系统始终保持可用,但在网络故障时可能出现不一致。

既然我们无法让网络永远可靠,从某种意义上说,CAP 定理只是在问一个问题:你希望系统正确,还是可用?CAP 定理对吞吐量、延迟和可扩展性根本没有任何说明。

但事实上,对于 OLTP 数据库而言,甚至不存在真正的选择余地。包括 Spanner 在内的具有强一致性保证的 OLTP 数据库必须是 CP 系统,因为不一致意味着用户会看到错误的结果(例如过时读取、非单调读取、冲突写入,这些在极端情况下甚至可能导致整个电商业务崩溃)。

通过这一视角可以清楚看到,CAP 定理既无趣也与可扩展性无关。真正值得探讨的问题是:如何构建一个水平可扩展、且一致性(即正确性)得到保证的 OLTP 数据库系统?

CAP 中的 Consistency(一致性)与 ACID 中的 Isolation(隔离性)密切相关(容易混淆的是,ACID 里的 Consistency 指的是完全另一回事)。CAP 的一致性指的是线性一致性:所有操作看起来都按照一个遵循真实时间的单一顺序发生。而 ACID 的隔离性,在最强级别下是可串行化:所有事务看起来按某种串行顺序执行。能同时提供这两种保证的数据库称为「严格可串行化」,Spanner 正是如此。

线性一致性要求每个事务都在一个遵循真实时间的全局顺序中占据一个位置。对于访问相同数据的事务,这个顺序天然是串行的,也就是前文提到的竞争问题。而对于互不相关的事务,仍然需要就顺序达成一致,而这通常意味着要通过消息传递进行协调。

现在我们也能理解 Spanner 为什么要用原子钟了。有了原子钟,Spanner 就能为那些没有相互数据依赖(也就是天然可并行)、甚至可能分别在地球两端执行的事务,赋予一个线性一致的顺序。原子钟让 Spanner 无需在全球范围内来回传递消息,就能判断哪个事务先发生——而这些事务本来是完全不需要协调的。

[2] 那么跨分区的查询和事务怎么办?我们最终当然可以支持它们。跨分区的分布式 SQL 查询可以获得 CockroachDB 这类系统提供的通用水平扩展能力,同时对于仍局限在单个分区内的交易,还能保留 Spacetime 的快速本地路径。

不过,我们大概率不会允许 reducer 跨分区读写,因为那就又回到了我们反复讨论的那个性能陷阱。分区仍会把相关数据放在一处,你的模块代码也仍需明确遵守这些边界,但客户端将可以在全局范围内对所有分区执行 SQL 查询和订阅。

这种方案在实际部署中或许方便,或适合某些特定负载,但就目前而言,尚不清楚这一步究竟是否值得承担其复杂度,还是说大多数查询直接指向一个只读的分析数据库集群更合适。无论如何,出于性能考虑,跨分区查询不应当成为应用的主要模式。

跨数据库分区查询的两种探索性方案示意图:方案 A 为散列-归约 SQL,将单个客户端查询通过网关分发至每个活跃分区,再合并结果,导致读取触及写路径;方案 B 将分区变更流式写入只读分析数据库,由客户端直接查询,使读取操作远离热点核心

对于这个问题,我认为最好采取“倾听客户”的方式,看看他们实际需要什么。

原始来源: Hacker News

评论 (0)