
你是不是也遇到过这种场景业务上线前一切正常一旦用户从全国各地涌进来分库分表做了读写分离也做了可数据只能落在一个主库上。另一个城市的用户要改一条状态请求先得绕半个中国到主库改完再同步回来。运维群里的同学问能不能让每个地方都能写用户离哪儿近就在哪儿写写本地的副本再让系统自己把数据合并起来。Megastore 就是 Google 当年为回答“全国各地都能写入”这个问题而设计的数据库系统。它在跨地域部署的多份副本之上用实体组Entity Group加 Paxos 协议实现了组内强一致、组间最终一致的多活写入方案。很多现代分布式数据库里的“近端写、远端同步”思路都受它影响。这篇内容适合后端工程师、分布式存储研发、DBA以及正在做多活改造或调研分布式数据库方案的朋友。我会把它的核心模型、写入路径、读路径、索引设计和容灾机制拆开讲尽量用大白话讲到能直接拿去和同事讨论的程度。1. 先搞懂“全国各地都能写入”到底难在哪1.1 我们平时说的“多活”到底有多活先明确一个概念。很多人说“双活”“多活”其实做的是“多读单写”数据在每个地方都存了一份但写入必须全部集中到一个主节点。这种做法解决的是读的延迟写还是要跨地域跑一趟。真正的“多活”是说任何副本都能接受写入而且系统要保证这些写入最终一致、不冲突、不丢数据。听起来很简单但一旦从单机房变成三地五副本每个写请求都要面对“我在北京改了这笔订单状态上海也有人在改同一笔订单到底听谁的”这个问题。Megastore 的多活是这么定义的每个实体组内部所有写操作通过 Paxos 跨副本同步提交读到的是同一个顺序下的数据不同实体组之间允许短暂的不一致但最终每条写都会变成每个副本都认可的事实。这个模型很聪明它没尝试解决“全库强一致”这种昂贵问题而是把强一致的范围缩小到业务访问最频繁的那个小圈子。1.2 CAP 与 PACELC为什么不能既要又要一聊分布式CAP 绕不开。网络分区时C一致性和 A可用性只能选一个。但很多系统在没分区的时候也要做选择这就是 PACELC 的含义在网络分区P时选择可用性A还是一致性C正常运行E时选择延迟L还是一致性C。传统单主库走的是分区时选 C正常时也选 C代价是写入必须集中远地访问延迟高。纯异步复制走的是分区时选 A正常时选 L代价是有可能出现数据回滚和读到旧数据。Megastore 走的是第三条路分区时优先保证一致性不满足法定人数的副本就拒绝写入正常运行时尽量降低延迟让每个地区的客户端写本地副本。它没有奢侈到“全库强一致 全地域低延迟”它只对实体组这个小单元做强一致。这样既保住了业务最重要的那一致性又把延迟控制在一个可接受的范围。1.3 异步复制为什么不是答案有人会问能不能简单点把 MySQL 配成主主复制两边都能写这个方案在低并发、低风险场景能跑但一旦并发上来就有三个硬伤主主复制是异步的一端的写入还没传过去另一端也写了同一条记录两边都会觉得自己是对的后面只能靠冲突检测修复修复过程又常常产生业务脏数据。自增主键、唯一索引在两边同时插入时不做改造必然撞车。网络抖动导致复制积压读多副本的场景会出现“刚提交的数据查不到”或“刚才还能查到过一会儿查不到了”。Megastore 不回避这些问题但它把冲突范围尽量缩小。它用 Paxos 在多个副本之间同步一条事务日志所有副本按同一顺序应用日志所以单实体组里不会出现“两边都觉得自己写对了”的情况。网络断掉时它宁可拒绝写入也不会让副本自己闷头接受一个无人确认的写。2. 实体组把“强一致”和“伸缩性”捏在一起2.1 实体组是什么实体组是 Megastore 最核心的抽象。你可以把它理解成一组“命运绑定”的数据行这些行在逻辑上必须保持一致写操作只能要么全成功、要么全不成功读操作保证能看到同一快照下的数据。最经典的例子是用户和用户的订单。一个用户下单、改单、查单这些操作都围绕着同一个用户 ID 发生。以用户 ID 为实体组键一个用户的全部订单、收货地址、优惠券都放进同一个实体组那这些数据之间的操作就不需要跨组协调直接在这个组里做事务就行。类比来说实体组就像户口本。户口本里的成员变更需要严格登记谁改了哪一页、改的顺序是什么必须非常清楚而不同户口本之间信息偶尔不同步没关系派出所最后会归集同意。2.2 为什么不是整库强一致如果所有数据都在一个“大组”里事务是方便了但所有写请求都必须经过同一个 Paxos 流程等于是把全库的并发写压到一条单行道。Google 内部的很多业务单次事务的影响范围通常很小绝大多数操作只涉及到某一个用户的数据跨用户强一致事务是少数而不是多数。所以 Megastore 的设计哲学是强一致只给“够得着”的数据。一个实体组就是一个独立一致性单元各组之间并行跑自己的 Paxos。同一时间北京的用户 A 在写自己的组上海的组 B 也在写自己的组互不阻塞。这样既保证了业务核心操作的一致性又避免了全库串行化。2.3 实体组与表、分片的区别这里容易混淆。实体组是事务边界分片是存储分布边界。传统分库分表里同一用户的数据通常会被路由到同一个分片如果路由算法不好一次跨分片查询就会带来分布式事务问题。Megastore 把这两个概念拆开实体组决定一致性范围一个实体组通常会被完整存放在同一个底层分片上。分片可以根据负载迁移实体组跟着分片一起搬。也就是说你不用为了事务范围去硬设计数据分布你只需要先把数据按实体组聚好类底层的分片调度由系统负责。这样做还有一个额外好处实体组可以跨多个表比如用户表、订单表、事件表可以共享同一个实体组键但它们在存储上还是一起搬迁不会出现关联数据散落多处的局面。2.4 数据建模的门道用 Megastore 的思路做数据建模一开始就得想清楚实体组边界。我给一个简单的规律凡是出现在同一个业务操作里、且必须强一致的数据放进同一个实体组凡是允许“稍后一致”的数据拆到另一个组跨组用队列异步补。比如电商订单状态和订单明细必须在一个组订单状态和用户积分一般不需要在一个事务里拆成两个组下单成功后用异步消息去加积分。用关系型思维理解就是给表加一个前缀字段比如root_key同一个root_key的所有行属于一个实体组组内主键可以再细分。CREATE TABLE customer_data ( root_key VARCHAR(64), -- 实体组键通常是用户ID item_key VARCHAR(64), -- 组内键比如订单号 payload TEXT, PRIMARY KEY (root_key, item_key) );在你自己的系统里如果暂时没有 Megastore可以用这个字段模型模拟实体组把事务边界约束在设计层面应用代码约定凡是要跨root_key写数据一律走消息队列异步处理不直接开分布式事务。3. 写入路径Paxos 在 BigTable 上如何落地3.1 为什么要一个持久化的日志Megastore 本身不自己存数据它建立在 BigTable 之上。一个副本就是一套 BigTable 集群多地部署时每个地方都有完整的一份。问题来了如果各副本都直接改自己的 BigTable那谁先谁后、谁覆盖谁根本没有统一说法。Megastore 的思路是把“决策记录”和“数据存储”分开。每个实体组有一条专属的 Paxos 日志这条日志不放在内存里而是作为 BigTable 的一行数据通过 BigTable 自己的跨地域复制能力同步到所有副本。任何一次写入先要在日志里追加一条“我要写这个值”的记录等多数副本认可了这条记录再真正把数据写进各自的 BigTable。用生活场景来类比一家公司有三个分部每个分部都有一份通讯录。直接改通讯录肯定乱套所以公司规定改通讯录前先在公共台账上登记“我在某时把某人的电话改成某号”等到三分部里至少两家确认看到了这条登记大家才开始动笔改自己的通讯录。3.2 两轮 RPC 的完整过程常规 Paxos 实现会比较重但 Megastore 在工程上做了优化。一个实体组的写请求大致走这几个阶段客户端把写请求发给当前实体组的 leader。leader 发起 prepare携带一个提案编号和待提交的值发往所有副本。副本收到 prepare 后如果发现提案编号比本地见过的新就承诺不再接受旧编号提案并把这个提案编号记下来。当 leader 获得多数副本的承诺后进入 accept 阶段再次通知所有副本“这个提案已经被选中”。副本把日志写入自己的 BigTable然后应用日志内容到数据区。leader 收到多数确认后向客户端返回写成功。整个流程最少是两个网络往返。如果你把副本分布在北京、上海、深圳三个机房每个机房之间 RTT 按 30 到 50 毫秒算一次写入吃两轮 RTT加上 BigTable 写入落盘和日志同步整体延迟几百毫秒是常态。这在互联网业务里不算快但换来的是“任何地方都能提交且不会冲突”的强一致保证。Megastore 实际做得更细它允许客户端在 prepare 阶段直接携带具体值这样对大多数场景可以省掉一次空 prepare 的额外往返。但无论如何它不可能像单机房数据库那样做到毫秒级。它本质上是在延迟和一致性之间选择了一个适合宽地域部署的平衡点。3.3 日志的裁剪与快照日志不能无限增长。每个实体组的日志里包含了这个组所有的写入历史如果不清理一张长期活跃的表日志会膨胀到无法接受。Megastore 的做法是定期推进 high-watermark高水位把已经被所有副本应用过的日志条目标记为可清理然后生成一份快照把实体组的数据状态固化到底层 BigTable。日志只保留高水位之后的部分。这个设计和大多数预写日志系统没有什么本质区别但因为它跨地域副本所以高水位的推进必须在所有副本同步进行。如果一个副本落后太多日志裁剪就得等待它追上否则落后的副本无法恢复。运维上很容易遇到的坑就是某个副本长期宕机后恢复日志已经被裁剪了只能从快照恢复再追增量如果快照频率太低恢复时间会很长。3.4 队列不是单独的消息中间件Megastore 里一个有意思的设计是它没有额外依赖一套 MQ 来做异步消息。跨实体组的数据交换可以直接用实体组的日志实现。一个实体组的事务提交后会在自己的日志里落一条记录业务逻辑看到这条记录再发起写另一个实体组。本质上就是“写日志即发消息”。这个思路对今天的系统设计很有启发。很多团队为了解耦引入 RocketMQ 或 Kafka但在某些内部强一致场景里消息的可靠性和顺序很难保证。Megastore 的做法是消息本身是一条持久化日志消费者读完才删生产者提交成功就保证消息不会丢顺序就是日志顺序。如果你自己设计一套类似的多活系统可以考虑用“事务日志”代替“消息表”减少数据源不一致的问题。4. 读路径、时间戳与延迟真相4.1 读本地副本真的快吗既然每个地域有完整副本读请求就可以打到本地 BigTable不用跨机房。这是 Megastore 多活的其中一个卖点。但“读本地”不等于“读到最新”。Megastore 给读操作提供了两种隔离级别快照读snapshot read读本地副本的一个一致快照不保证最新但保证在一个稳定时间点之前的所有已提交写对你可见。当前读current read需要临时做一轮 Paxos确认当前日志的最大提交号然后读所有副本都认可的最新值。通常业务查询走快照读就够了。比如说用户查自己的订单即使本地副本落后了几十毫秒用户也不会感知到问题。但如果是分布式锁、库存扣减、对账查询就必须用当前读否则可能出现“明明写成功了换了个节点就读不到”的情况。4.2 为什么写入延迟是几百毫秒很多团队第一次接触 Megastore 类系统最大的不适是写入延迟。单机房 MySQL 写一条记录 1 到 3 毫秒这里怎么要 300 毫秒这里要算一笔账。写入要跨副本同步每个副本要把日志和数据写进 BigTable。如果三地副本相距一千公里一次网络 RTT 至少 30 毫秒Paxos 两个阶段就是两轮 RTT再加上持久化写入算下来确实就是百毫秒起步。这个延迟不是工程实现差而是物理定律决定的。想降低延迟只能做局部化改造把实体组按“用户主要活动区域”打散让该用户所在的组集中在离他最近的两个副本内参与 Paxos。允许某些实体组只在两地之间做同步复制不参与三地全量表决。把强一致写操作合并成批量提交减少单位写入的固定成本。Megastore 论文里没有特别鼓吹低延迟它更强调“在多个数据中心都能写入”这个能力。选型时如果业务特别在意单次写入延迟最好先想清楚是否能接受这个工期。4.3 从提交到可见一条记录的一生一条数据从写入到所有副本可见过程是这样的客户端向 leader 提交写请求。Paxos 日志达成一致写入成功的记录已经落在多数副本。各副本异步把日志应用到本地 BigTable这是纯粹的后台过程。每个副本的应用进度不同所以落后副本上这条数据暂时不可见。后台进程持续应用日志直到所有副本都追上 high-watermark这条数据在全地域可见。这个模型很像用户读和写分离的系统只是把“主从切换”变成了“任意副本写”。对业务来说必须在“读可能旧一点”的前提下设计产品比如不要在用户刚提交完就强制刷新另一地域的查询结果。4.4 横向扩展怎么做实体组模型天然适合水平扩展。一个实体组的数据只落在一个分片上分片可以按负载拆分和迁移。随着用户规模增长把实体组拆分到更多分片即可。跨实体组的查询不好做因为数据不在同一物理位置只能通过全局二级索引或应用层聚合。这给运维带来的启发是用“实体组数量”而不是“行的总数”来衡量系统压力。一个分片能承载的实体组数有限单个实体组内的行数也有限。如果出现热点用户一个实体组内数据量过大整个组的性能会急剧下降。遇到这种情况应该把热点用户的数据拆成多个子组用日志异步关联而不是把更多行塞进同一组。5. 索引把“全局查询”变成“跟随读”5.1 两类索引的分工Megastore 提供两类索引Local 索引和 Global 索引。Local 索引存在于实体组内部数据写入时同步维护保证强一致比如一个用户组内按订单号查订单明细查得快且结果准。Global 索引跨越多个实体组由后台异步维护是最终一致的。这个分工的背后逻辑很清楚Local 索引服务的是“组内高频查询”Global 索引服务的是“跨组低频搜索”。如果你要把“查询全部城市的订单汇总”这种聚合操作直接压在 Global 索引上Megastore 会很难受因为索引是最终一致的跑批时看到的数据可能差几秒。5.2 索引投影到读取端Global 索引虽然异步但它存的内容可以设计得很有讲究。索引项不仅仅指向实体组 ID还可以直接把常用查询字段投影进去。应用层查索引时如果能从索引里拿到大部分需要的字段就不需要再回表读实体组数据。比如用户列表页只需要头像和昵称Global 索引导出的投影行就足够不需要碰实体组原始数据。这个思想和现代搜索引擎、分析型数据库的列存索引很像但用在 OLTP 场景里能显著降低跨分片读的代价。数据变更后后台任务会更新索引短时间不一致可以接受如果业务要求强一致就必须回到实体组内用 Local 索引查。5.3 什么业务适合这种索引模型如果业务查询大多带“用户 ID”这个前缀比如查某用户的订单、查某用户的聊天记录非常适合实体组加 Local 索引。如果业务查询是“全站维度”的比如按标签搜全网用户那就需要 Global 索引但要做好最终一致的心理准备。实际选型时我们通常建议这样判断一个查询如果能在业务代码中先把范围缩小到一个实体组就走实体组读如果死活无法确定实体组键只能走 Global 索引兜底。设计表结构时尽量把“可能的搜索键”往实体组键上靠能靠多少靠多少跨组搜索越少系统越稳。6. 容灾、部署与运维栅栏6.1 一个副本等于一个 BigTable 集群Megastore 的部署模型很直接每个参与写入的地域部署一套 BigTable 集群作为这个地域的副本。副本数量通常为奇数。写入需要多数副本确认也就是超过一半的副本成功事务才算提交成功。三个副本时允许一个副本故障或失去网络连接系统依然可以写入。五个副本时允许两个副本故障。这里的多数确认机制保证了“不可能同时出现两个互不相同的已提交值”因为任意两次成功提交必然有一个副本同时参与了两次决策这个副本会挡住旧值覆盖新值。6.2 Witness只投票不存数据的角色两地部署时只有两个数据副本会出现问题宕其中一个多数就不存在了。Megastore 的解法是引入 Witness 节点它参与 Paxos 投票但不保存业务数据。三副本里可以有一个是 Witness这样即使某个数据副本整体挂掉Paxos 仍能达成多数。这个设计在很多分布式系统里都有比如 Raft 里的 Learner 和 Zookeeper 的观测节点思路类似。它解决的是“偶数站点无法形成多数”的数学问题代价是 Witness 也要参与网络通信但它的成本比数据副本低很多不需要足够存储。使用 Witness 时要注意它不能处理数据读取一旦数据副本少于法定数读请求会受限所以 Witness 只能缓解写入对“多数”的需求不能替代数据容灾。6.3 机房故障时会发生什么当一个机房整体失联Megastore 会把该副本踢出 Paxos 成员集合剩余副本继续工作。由于多数还在写入不中断但可靠性降低三副本变两副本随时可能再坏一个导致写入暂停。恢复时掉线副本重新加入先全量拉取快照再按日志追赶进度。追赶期间它不参与多数投票等数据追平后才恢复完整状态。运维上最容易忽略的问题是掉线副本恢复后日志可能已经被裁剪。此时必须从最近快照恢复快照到当前之间存在部分日志如果快照太旧追赶时间会很长。建议给这些跨地域副本设置合理的快照周期并监控“追赶进度”指标而不是只看 CPU 和磁盘。6.4 优雅降级的边界实体组模型有一个隐藏边界单实体组的 Paxos 是独立的所以不同实体组可以出现短暂的写入顺序不一致。比如实体组 A 的写入在组 B 之前成功但 B 的应用层处理得快先看到了 B 的结果A 的后续逻辑才陆续生效。跨组事务无法实现原子性只能靠补偿任务回滚。运维排障时如果发现“订单状态显示已完成但积分还没到账”这不是数据丢了而是跨组异步任务还没追上。需要检查后台队列消费进度而不是急着手工改库。这条经验在 Fine-grained 的最终一致架构里非常实用。7. 常见误区和排查经验速查7.1 “读本地副本就是零延迟”快照读本地副本确实省了跨机房 RTT但数据可能旧。很多团队上线初期把“用户读本地”理解成“用户读一定最新”结果订单支付成功后用户在另一个地域查不到记录立刻告警。排查最后发现是本地副本滞后几十毫秒业务需要的是 current read。这里没有一刀切的配置只能根据业务要求选择。7.2 “全局索引一定准确”Global 索引是异步更新查询结果可能漏掉刚写入的数据。跑批、统计、搜索场景遇到这种问题非常容易误判为数据丢失。使用 Global 索引做搜索时最好在界面上明确“搜索结果可能延迟几秒”或者定期对账避免用户投诉。7.3 “写入多活就不用考虑写冲突”实体组内部靠 Paxos 规避了冲突但跨实体组的业务逻辑仍然可能因为时序不同产生不一致。比如库存在实体组 A订单在实体组 B两边同时操作只能靠业务补偿。设计时应当尽力避免“两个强一致实体组之间频繁互相依赖”的情况否则最终一致会变成业务结果不确定。7.4 实体组的日志膨胀高流量实体组的日志增长很快如果不及时裁剪BigTable 行会变得过大影响读写性能。排查时可以观察实体组单行大小如果持续上涨需要调高快照频率。另一个陷阱是“热点实体组”单个组写入太大Paxos 变成串行瓶颈此时要拆组而不是盲目加副本。7.5 “跨实体组事务”不存在Megastore 不支持跨实体组的分布式事务。凡是遇到跨组更新只能通过队列异步执行或者在应用层做补偿。踩坑的人容易把一个操作拆成好几个组写然后在线程里顺序循环执行发现中途失败后没有回滚机制最终数据错乱。正确做法是先写日志消息再由独立消费者处理后续写保证最终一致。8. 今天的系统还能从 Megastore 借鉴什么Megastore 已经是十多年前的系统了现在很多数据库产品如 Spanner、CockroachDB 都解决了它的一些限制比如跨实体组强一致事务。但它的设计思路到今天依然值得抄作业事务边界设计优先。先画清楚哪些数据必须同生共死再把它们放进同一个实体组剩下的关系全部异步化。这套方法论可以迁移到任何分库分表、多活改造项目里。用持久化日志代替消息表。事务提交和消息投递不分离减少双写不一致。把强一致的代价压缩到局部。不是所有数据都需要全库强一致能在一个实体组内解决的问题不要升级成全局锁。每个实体组独立 Paxos彼此并行。如果你的业务天然按用户维度隔离这就是你需要的多活方案雏形。回到个人经验我自己在做小型多活方案时也试着套用实体组思路结果一开始把实体组划得太大一个组装下了用户所有历史数据导致热点组越来越慢。后来按时间切片拆组把订单类数据按天、按状态拆开热数据性能立马上来。Megastore 给了我一个很重要的启发不要把系统的一致性要求做成“所有数据一个标准”而是让数据和它最相关的那一小撮邻居保持一致其他交给异步去消化。这个平衡到现在依旧成立。