先从一个经常被问烦了的问题说起:为什么市面上很多号称"分布式"的数据库,写入请求最终还是要乖乖落到某一个固定节点上?无论你怎么扩展副本、加只读从库,写操作依然只有一个"总开关"。但用户根本不在乎你的架构,他们只知道一个事实——离得最近的那个机房写入最快;华南的请求绕到华北的主库,多出来的几十毫秒就足够让客户体验崩一次。这就是"全国各地都能写入"这个需求的真实起点,也是当年 Google 做 Megastore 时要解决的核心矛盾:能不能让数据库在多地部署的同时,每个地方都能处理写入,而数据还能保持一致。
这篇先不铺开讲算法细节,而是把 Megastore 的设计骨架拆开看:它到底怎么用"实体组"这种数据结构换来了多写能力,为什么可以用 Paxos 而不是两阶段提交,以及一个普通业务系统能从中借鉴什么。适合对分布式数据库有基础认知、正在折腾异地多活或者多机房同步方案的后端工程师,也适合想搞懂"Bigtable 之上为什么还要再包一层"的存储爱好者。
1. 为什么"全国各地都能写入"是个硬骨头
1.1 先看单机房方案的隐形天花板
传统关系型数据库的容灾,做得再花哨,本质还是"一个主库 + N 个从库"。主库负责写入,从库负责冗余和只读流量。两台机器放在同一个机房,问题还不大;一旦要跨城市部署,同步延迟就从零变成几十毫秒甚至更高,主库的写入吞吐会被复制链路死死拽住。
更麻烦的是切换。主库所在城市一旦出问题,比如光缆被挖断、机房断电,你只能把某个从库提拔成新主库。这中间有多少坑我都不用多说:未替换完全的 binlog、半同步复制丢的那条事务、应用连接池里还没刷新的旧主库地址……每次都像走钢丝。即便一切顺利,新主库上线后,它接收的写入只包含之前的同步进度,刚刚在主库上提交、还没来得及复制过来的那批事务,就永远找不回来了。对强一致要求不高的业务可能能忍,但金融、支付、库存这类系统根本不敢冒这个险。
所以单机房方案的天花板不是并发量,而是"写入点唯一"这个硬约束。你可以在全国放几十个只读副本,但写入请求永远绕回那个唯一的 master。对老板来说这叫"稳妥",对用户来说这叫"跨城下单慢半拍"。
1.2 异地多活的诱惑与代价
既然单一写入点是瓶颈,那自然想到:在华东放一个主库,在华南也放一个主库,两边都能写,然后互相同步不就行了?这个想法很直觉,落地才发现掉进了分布式系统的经典困局——网络分区。
想象两个机房之间的专线断了。华东用户下单成功,华南用户也下单成功。等网络恢复,两边开始同步数据,这时候问题来了:两边都觉得自己那笔订单才应该保留,谁该让谁?如果没有一个事先约定好的仲裁机制,结果就是两边的数据互相覆盖,用户看到订单被回滚,运营后台看到库存对不上。这类事故在业内发生过不止一次,每次都是大白天上了热搜的级别。
CAP 定理说的就是这个事:网络分区发生时,你在一致性和可用性之间只能选一个。传统选一致性,所以必须牺牲掉其中一个机房的写入;异地多活想选可用性,就必须接受数据在分区间暂时不一致。Megastore 的厉害之处,在于它在中间找出了一条路:把一致性拆成一个小粒度单位,让每个写入只需要在多数派副本上达成一致,而不是在全部机房达成一致。这样一来,整个数据库不再是"一个整体",而是被切成了很多个可以独立运转、又能最终并轨的小块。这个"小块"就是实体组。
2. 实体组:把数据库拆成一个个可以单飞的小空间
2.1 Megastore 到底是什么定位
Megastore 不是凭空出现的数据库,它跑在 Bigtable 之上,底层还是那张巨大的、按 Key 排序的稀疏表。Bigtable 本身没有跨行事务,也没有 SQL 语义,Megastore 的活儿就是在上面补出事务、索引、复制这些"数据库该有的东西"。
很多资料喜欢把它归类为"NewSQL"的早期探索,但更准确地说,它是在 NoSQL 存储和关系型数据库之间搭了一座桥。它有关系型数据库的表结构、索引和 ACID 事务,但事务的范围不是整个库,而是被限制在一个实体组内。这个限制不是妥协,而是整个设计能成立的前提。理解了这一点,就理解了一大半 Megastore。
2.2 实体组到底长什么样
实体组(Entity Group)这个概念,用大白话讲就是把一批"经常一起改、一起读"的数据放进同一个快递箱。比如一个用户账号、他名下的收货地址、他的最近订单,这些数据天然就应该放在一组。为什么?因为你打开 App 的第一件事就是加载"我的资料 + 我的订单 + 我的地址",一次操作涉及的全部数据都在同一个组里,事务和性能就都有了保证。
技术实现上,实体组由一个根实体(Root)和若干子实体(Child)组成,所有实体通过主键上的某种前缀关系组织在一起。比如根实体是用户 12345,那么他的每一个订单、每一条地址记录,在底层存储的 Key 上都带着这个用户 ID 作为前缀。物理上它们紧挨着,逻辑上它们共享同一条复制链、同一个事务边界。
这里有个特别容易理解偏的地方:实体组不是"一张表",它更像是"一个聚合"。同一个实体组里的数据可以来自不同的逻辑表——用户表、订单表、地址表——但在存储和复制层面,它们是一体的。反过来,如果两个表在业务上毫无关联,那就别硬塞进同一个组,否则所有写入都挤在一条复制链上,相当于自己把自己的多写能力废掉。
2.3 实体组带来的关键性质
一旦数据被切分成实体组,Megastore 就获得了两个非常重要、乍一看甚至有点矛盾的保证:
- 同一个实体组内:支持完整的、强一致的 ACID 事务,写入通过 Paxos 在多数派副本上达成一致,读也能拿到最新已提交的数据。
- 不同实体组之间:不提供强一致事务,数据最终会一致,但中间存在一个异步同步的窗口。
这个"局部强一致、全局最终一致"的模型,就是 GeoNames 分布式多写能力的地基。每个实体组相当于一个独立的小数据库,也都拥有自己的多数派,因此任何有副本的机房都可以接受针对某个实体组的写入,只要它能说服多数派副本。你不需要把整个数据库锁住,只需要让这一个快递箱的副本们达成一致。
这也是它和普通分库分表的本质区别:分库分表只是把数据拆到不同 MySQL 实例上,每个分片依然是单点主从;而 Megastore 的每个实体组本身就是一个迷你分布式系统,自带副本、共识和故障恢复能力。每个分片都能独立写入,这才是"全国各地都能写入"的真相。
3. 实体组内的写入:靠 Paxos 而不是两阶段提交
3.1 为什么两阶段提交在这里不合适
看到"多个副本要达成一致",很多人的第一反应是两阶段提交(2PC)。但 2PC 有一个致命的角色叫协调者,它一旦崩溃,整个事务就卡死,所有参与者都得等它恢复。把协调者放在华东,华南的写入请求实际上还是在依赖华东的进程;如果协调者自身没有做高可用,或者网络分区把协调者和某个参与者隔开,事务就只能超时回滚。换句话说,2PC 换汤不换药,多写点依然不成立。
Paxos 不一样。它没有中心协调者,靠的是"多数派"概念:只要超过一半的副本活着且互相连通,系统就能继续对外服务。华东挂了,华南和华北的副本仍然可能组成一个多数派,照样批准新的写入。这就把"单一写入点"换成了"写入点集合",每个机房都可能是那个发起提案的节点。
两阶段提交和 Paxos 解决问题的层次也不同。2PC 解决的是"多个参与者怎么原子地提交一个事务",Paxos 解决的是"多个副本怎么对同一个值达成一致"。在 Megastore 里,事务范围内的数据变更不是直接写到每个副本上,而是先写成一条日志记录,让所有副本对这个日志达成一致,然后再各自应用日志、更新存储。
3.2 写入过程其实就是状态机复制
Megastore 的写入路径,本质上是经典的状态机复制模型。每个实体组的副本都维护着一条按序排列的操作日志;所有读请求只读本地已应用的日志状态,写请求则把一个新的日志条目提交到多数派副本上,提交成功后,每个副本按同样的顺序应用这条日志,最终所有副本达到相同状态。
类比一下,这就像多个城市的乐队成员用同一份乐谱演奏。乐谱本身的修改必须有顺序、有版本,谁都不能凭自己的想法乱改;但只要大家都照着最新版的乐谱演奏,哪怕排练场次不同、进度不同,最终合奏时也是同一首曲子。数据库里的"乐谱"就是日志,"排练"就是异步应用日志。
这里有个容易忽略的细节:事务的原子性体现在日志条目上。一条事务无论改了多少行数据,都会被打包成一条日志记录。副本要么完整应用它,要么等下次再应用,绝不会只应用一半。这也是为什么 Megastore 能直接建立在 Bigtable 这类不具备事务能力的存储之上——真正的事务能力不是由底层存储提供的,而是由日志共识层提供的。
3.3 副本角色与 leader 的真正含义
Paxos 协议里经常听到 leader、proposer、acceptor,很多人一看就觉得这和主从架构没区别:leader 还是单点,还是要挂。但在 Megastore 的语境里要区分两层:Paxos 的 leader 只是"提案的发起者",不是"写入的所有者"。它负责推动某个写入提案,但能否提交,取决于多数派副本的批准。
也就是说,即使 leader 机器宕机,其他副本也能推举出新的 leader,继续处理写入。更关键的是,对于不同实体组,leader 副本完全可以落在不同的机房。华东机房可能是订单分组 A 的 leader,华南机房可能是订单分组 B 的 leader。这样一来,数据被切分成了无数个可以独立决策的小单元,每个单元都就近找一个 leader,天然实现"各地写入"。
用大白话讲,这就是把一个大仓库拆成了无数个小快递站,每个站都有自己的站长,同一个城市里的包裹不必都去总仓过一遍。用户在上海下单,他的订单组 leader 在上海;用户在北京下单,他的订单组 leader 可能在北京。两边各写各的,互不打架。
| 方面 | 传统主从复制 | Megastore 实体组 + Paxos |
|---|---|---|
| 写入点 | 全局唯一 master | 每个实体组可以在任意有副本的机房写入 |
| 故障恢复 | 手动/半自动主从切换 | 多数派自动选新 leader,无需全局协调 |
| 事务范围 | 通常整库或单分片 | 实体组内强一致,跨实体组弱一致 |
| 一致性与可用性取舍 | 分区时只保一致性 | 分区时保局部可用性,全局最终一致 |
4. 一条跨城写入的真实路径:从用户点击到落盘确认
4.1 写入流程逐步拆解
纸上谈兵说完了,现在把一条用户请求掰开揉碎。假设用户在杭州下单,订单实体组的 leader 恰好也在杭州,这条路会走哪几步:
第一步,客户端向杭州副本发起事务。实体组的本地副本会先检查事务涉及的数据是否都在本组内,如果发现跨组操作,会直接走一条代价更高的跨组协议;如果只在组内,就进入第二步。
第二步,本地副本把事务转换成一条待提交的日志记录,并通过 Paxos 向所有副本提议。注意,这里不需要所有副本都在线,只需要多数派副本确认这条日志。多数派可能包含杭州、上海和北京三个副本中的两个,哪怕广州副本暂时失联,也不影响提交。
第三步,一旦多数派确认,事务就算提交了。但提交不等于每个副本都已经应用了这条日志,更不等于用户的读请求马上能看到最新值。提交只代表日志被持久化、序号被确定,应用日志的工作可以在后台异步进行。
第四步,本地副本立即应用日志并更新存储,然后向客户端返回成功。
这个流程里最反直觉的一点是:用户写入确认的速度,取决于多数派副本的往返时间,而不是所有副本的同步时间。如果多数派选得离用户近,延迟就低;如果多数派分布在跨几千公里的三个城市,那单次写入延迟可能要到几十毫秒甚至更高。所以 Megastore 的实际部署,通常会把副本放在尽可能接近用户流量、又不至于落在同一个故障域里的地方。
4.2 读路径:快照隔离与就近读
写入可以被多数派批准后马上返回,读请求的处理则分两种模式。
一种是 current 读,保证读到最新已提交的数据。实现方式也不复杂:先问当前实体组的日志位置到哪了,等本地副本把日志应用到这个位置,再读存储。这个等待时间通常很短,但本质上是一个"读也要参与同步"的过程,适合对实时性要求高的读。
另一种是 snapshot 读,直接读本地副本当前已经应用到的状态,完全不等待,延迟极低,但可能不是最新的。如果业务能容忍"稍微旧一点点的数据",比如动态详情、评论列表,snapshot 读就能把延迟压到最低。
这两种模式切换起来非常灵活,关键点在于 leader 副本上维护了一个时间戳,任何一次 successful commit 都会推进这个时间戳。读者只需要知道当前日志应用到了哪个时间戳,就能决定走哪种读策略。这个设计放到业务系统里,其实就是"强一致读"和"弱一致读"的取舍,但因为它是按实体组粒度做的,粒度细,浪费就少。
4.3 本地率为什么是绩效指标
Megastore 论文里反复强调一个概念:本地可用性,或者叫 local rate。它统计的是所有读写请求中,有多少比例能在发起请求的机房内被完整处理,不需要跨机房协调。
这个数字直接决定系统的真实性能。如果本地率是 99%,意味着绝大多数请求只在本机房内完成共识,体验就是"本地数据库";如果本地率只有 60%,那剩下的 40% 请求都要跨城往返,平均延迟直接翻倍,系统的分布式优势就荡然无存。
要做到高本地率,唯一的路子就是数据设计:把会被同一个用户高频访问的数据放在一个实体组里,并保证同一个用户的所有流量基本都打到同一个机房。这也是为什么 Megastore 类的架构都强调"就近接入"和"按用户维度切分",而不是"按订单号随机散列"。随机散列会让一个用户的数据碎片化,散落在各个机房,重建成本极高,这是很多团队复刻 Megastore 时最容易踩的坑。
5. 跨实体组的操作:能不用就不用
5.1 跨组写为什么贵
前面把实体组夸成这样,也让很多人产生一个错觉:以后所有事务都可以直接做,不用再担心分布式了。实际情况是,跨实体组的事务虽然支持存在,但代价极高。
跨组事务的原理,是在多个实体组之间用两阶段提交做协调,每个参与者组内部还要先通过 Paxos 达成一致。这两层协调叠加在一起,事务延迟和失败概率都会明显上升。只要一个组拒绝提交,整个跨组事务就要回滚;一个组正在进行日志同步,另一个组卡住,协调者就得等。在多机房环境下,跨组事务几乎必然成为性能黑洞。
所以在 Megastore 的实践里,跨组操作有一条铁律:能避免就避免。设计表结构时,你得先问自己一句——哪几条数据是同一个业务动作里必须一起修改的?把他们塞进同一个实体组。哪几条只是"常常一起读,但不常一起写"?那就没必要放一起,读操作用异步聚合就好。
5.2 一条实体组设计的黄金原则
我见过的实体组设计案例,做得好的都有一个共同点:以业务里的"聚合根"为圆心,圈住所有和它生命周期紧密相关的子实体。
举个电商例子。用户、收货地址、优惠券、最近订单列表,明显应该跟着同一个用户 ID 走,放进同一个实体组。为什么?因为用户所有高频操作——下单、查订单、改地址——都只涉及自己这组数据,跨组操作自然就消失了。这里的性能提升不是一点点,而是量级上的。
反过来,有一个典型反面案例:把商品库存设计成一个全局共享的实体组。所有门店、所有渠道的下单都要更新同一个库存组,这个实体组瞬间变成全网的写入热点。它每次更新都要在多数派之间同步,并发一高,整个系统就卡死在库存组上。这不是 Megastore 不行,而是实体组粒度设计错了。库存这类数据天生就是跨用户、跨地域共享的,硬要套一个实体组,等于把所有流量往一个快递站里塞。
| 场景 | 实体组设计建议 | 原因 |
|---|---|---|
| 用户中心 | 按 user_id 聚合账号、资料、订单 | 高频请求全部组内完成 |
| 站内信/通知 | 按收件人聚合 | 读写都围绕单用户展开 |
| 全局库存/热点商品 | 不要单组做全局热点 | 该数据本身是共享可变状态 |
| 报表/汇总数据 | 使用最终一致异步重算 | 跨组强一致代价过高 |
5.3 弱一致窗口怎么补
即便实体组设计得再漂亮,跨组最终一致产生的延迟窗口还是存在的。一个订单在华东提交,统计报表在华北可能几分钟后才同步到。业务方如果无法接受这种延迟,通常的兜底做法是:为了展示而设计的读路径,允许走最终一致;为了钱和货而设计的写路径,则严格保证组内强一致。把"必须强一致"和"可以弱一致"的数据分开,比单纯纠结技术架构要重要得多。
实际上,很多从 Megastore 演化出来的架构,最后都把"跨组状态"本身当成一种可重试的异步任务来处理。比如订单状态变更必须强一致,但订单状态变更引发的一个积分变动、一条消息推送,就可以放进消息队列异步完成。设计原则仍然是同一个:能圈在一个组里的圈进来,圈不进来的,就用最终一致和补偿逻辑承接。这也是我经常挂在嘴边的一句话:"分布式不是把简单的事变复杂,而是帮你把复杂的事画一个边界。"
6. 从 Megastore 能学到什么:给普通后端开发者的 3 条实用启示
6.1 "多地写入"的本质是一次取舍,不是一次技术升级
很多团队做异地多活,第一反应是找一套能"两地三中心双活"的数据库产品,装上去就万事大吉。但 Megastore 的整个设计告诉我们:多地可写不是一种能力,而是把"一致性"切成小颗粒后的副产品。如果没有事先梳理清楚哪些数据能接受最终一致、哪些不能,再先进的存储也撑不住业务复杂度。
我参与过的项目中,最稳的做法是先做业务分级。想一想:哪个操作失败会造成资金损失?哪个操作晚十秒看到没有任何影响?前者必须放进强一致边界,后者可以放到异步链路。这个梳理工作不需要任何数据库知识,但它决定了你要不要上 Megastore 这类方案,以及上了之后会活得舒不舒服。
6.2 "实体组"思想完全可以提前用起来
就算你短期内不可能切换到 Megastore,实体组的思想也值得先在设计业务表结构时实践起来。它和领域驱动设计里的聚合(Aggregate)是一回事:把必须一起修改的数据圈成一个边界,边界内用事务,边界外用事件驱动。
比如传统的订单表和订单明细表分开两张表,靠外键关联;实体组的思路会把它们放在同一个存储单元里,主键加上订单号前缀。你会发现,这样设计之后,后续做分库分表、做数据迁移,都因为"一个聚合的所有数据都挨在一起"而省掉大量麻烦。很多团队搞分库分表失败,不是因为中间件不好,而是因为他们分片的维度不是业务聚合维度,而是随机取模。随机取模把聚合拆得稀碎,跨分片查询自然躲不掉。
6.3 复制协议不是银弹,运维水平才是
Megastore 的 Paxos 方案解决了共识问题,但付出了巨大的运维代价。每一组副本都要维护日志、处理视图变更、跟进已提交日志的追赶。副本故障、日志落后、磁盘损坏,这些场景在传统主从中已经够头疼,在实体组架构里会乘以副本数。
所以从工程角度看,我的建议很朴素:先评估自己的团队有没有能力运维这样的系统。一个能跑的 Postgres 主从 + 消息队列异步补偿,很多时候比一个调不通的 Paxos 集群要可靠得多。技术选型不是越高级越好,而是越匹配团队成熟度越好。Megastore 这类架构适合那些用户分布极广、业务天然可以按用户切分、且团队有能力吃透共识引擎的大规模系统;对绝大多数中小团队来说,借鉴它的实体组设计思路,比直接复刻它的 Paxos 布局更实际。
我自己看 PostgreSQL 生态里的 BDR、CockroachDB 这类产品时,也会下意识先看它们是怎么处理"热点实体组"和"跨组事务"的——凡是没把这两件事讲清楚的,基本都在回避 Megastore 当年踩过的坑。反过来,只要你把数据的聚合边界画对,多数高并发业务用更传统的方案就能扛住,根本不需要走到 Paxos 那一步。
这篇算 Megastore 系列的开篇,先把"为什么可以多地写"的骨架搭清楚了。下一篇我打算直接展开 Paxos 协议在实体组层面的具体行为,讲讲提案编号、quorum 计算和副本追赶这些真正磨人的工程细节。最后留一句个人的经验:阅读这类系统时,别急着背协议名字,先画一张"你业务里的实体组边界图",你会在画的过程中真正想明白这套架构的取舍。