专业书籍精读 · DDIA · 第 5 章
Designing Data-Intensive Applications · Ch 5 · Martin Kleppmann · 2017
你刷微信、下淘宝单,背后那份数据其实不止一台机器在存——它被抄了好几份,分放在不同机房、不同城市。DDIA 第 5 章讲的就是这件事:为什么要抄、怎么抄、抄的过程中会踩哪些坑。抄这个动作有个专业名字叫「复制」。
三个朴素的理由:一是怕坏——只存一份,那台机器一宕机,整个网站就打不开;抄了三份,坏一台还有两台顶着。二是嫌远——数据放美国,中国用户每次访问都要绕半个地球,慢;在中国也放一份就近;三是嫌挤——一亿人同时来读,一台机器扛不住,抄成十份、大家分着读。
抄一份静态文件谁都会。真正的麻烦是:数据时时在变。你刚改了昵称,这份改动得挨个通知到每一份抄本。抄写要花时间,于是就有了那个最经典的坑——你改完了,别人看到的抄本可能还是旧的(就像信刚寄出、还没送到)。这段时间差,就叫「复制滞后」。
数据能被改,就得规定谁说了算,否则乱套。这一章给了三种管法,全书后面全靠它们打底:
① 一个人说了算(单主):指定一份抄本当「主账本」,所有修改先写它,再由它照着抄给其他副本。清楚、不打架,但主账本那台一倒,得赶紧临时推举一个顶上,这一下最容易出乱子。
② 几个人都能改,事后对账(多主):每个机房都有个能改的账本,各改各的、互相同步。好处是哪个机房都能就近写,坏处是两个人同时改同一格就冲突了——像两个人同时编辑同一份在线文档,得想办法合并。
③ 没人主管,靠人多问准(无主):改的时候同时告诉好几台,读的时候也同时问好几台。只要「写过的」和「问到的」这两拨机器有重叠,你总能碰上一台知道最新消息的。像一条重要通知同时发给 3 个同事,你事后随便问其中 2 个,总能问到知情的那个。
没有免费的午餐:越想「每台抄本时刻一模一样」,就越慢、越不抗故障;越想快、越抗故障,就越得容忍「有人读到旧数据」。单主简单、用得最广(大多数数据库默认就是它);多主适合跨机房和离线协作;无主主打「挂几台也照转」。选哪种,本质是在「快」「稳」「每次都读到最新」这三者里挑你最要的那个。
复制 = 把同一份数据抄到多台机器,为的是抗故障、就近、分摊读。难点不在抄,在改动怎么同步、以及同步没跟上时读到旧数据怎么办。管法有三种:一个人说了算、几个人对账、靠人多问准——三者都在「快 / 稳 / 读到最新」之间做取舍。
想进到具体机制、quorum 记号和示意图? → 切到精读版
复制(replication)就是把同一份数据保存在多台通过网络相连的机器上。它换来三样东西——容错(挂几台还能服务)、低延迟(数据离用户更近)、读扩展(多副本分摊读流量)。真正的难点从来不是「存副本」,而是如何处理对数据的持续修改。这一章把处理修改的方式收敛成三种架构——单主(single-leader)、多主(multi-leader)、无主(leaderless),并逼你直面异步复制带来的复制滞后(replication lag)与一致性异常。
n=副本总数、w=写成功所需台数、r=读所需台数。本章是全书 Part II「分布式数据」的开篇。Part I(第 1–4 章)讲的是单机视角下「数据怎么建模、怎么存取、怎么编码演化」;从这一章起,数据要跨多台机器了。复制是把数据「整份多放几处」,紧接着的第 6 章分区(partitioning)则是把数据「切片分放」——两者常叠加使用。复制又是理解第 7 章事务、第 9 章一致性与共识的前置:本章埋下的「一致性到底能保证到什么程度」的问题,正是后面几章要正面回答的。
假设你有一个读多写少的服务:每秒 5 万次读、5 千次写,用户遍布中美欧。只有一台数据库时,三个问题同时压过来:这台一宕机,全站瘫(可用性);欧洲用户每次查询跨洋往返一两百毫秒(延迟);5 万 QPS 的读把单机 CPU / IO 打满(吞吐)。把数据复制到多台、多地,三个问题一起缓解:坏一台有备胎、就近读省往返、多副本分摊读。
但复制不是「拷贝一份文件」那么简单——数据一直在变。核心难题是:一次写入要怎么传播到所有副本、传播途中(甚至之后)各副本短暂或长期不一致时该怎么办、某台副本或主库挂掉时系统怎么继续正确工作。不解决这些,「多副本」带来的不是可靠,而是更难排查的数据不一致。这一章就是把这套难题拆开、给出三类解法与各自的代价。
是什么:指定一个副本为主库(leader),所有写入只能发给它;主库把每次改动写进本地,同时按顺序发一条复制日志给各从库(follower);从库照着重放就跟上了。读可以走主库、也可以走任意从库。PostgreSQL、MySQL、MongoDB、Oracle Data Guard,乃至 Kafka、RabbitMQ 的镜像队列,默认都是这个模型。
同步还是异步?(本模型的第一权衡)主库把写发给从库后要不要等它确认再回复客户端:同步保证从库有最新副本,但只要那台从库慢或挂,写入就卡住;异步不等、吞吐高,可一旦主库在「已回复客户端、但改动还没传到任何从库」的瞬间宕机,这些写入就永久丢了。实践中几乎不会让所有从库都同步(一台卡住全站卡住),常用半同步(semi-synchronous):保证恰好一台从库同步、其余异步——既有「至少两份最新」的保障,又不至于被单台拖垮。
从库怎么加、怎么恢复:新增从库靠「主库某一时刻的快照 + 从该快照对应的日志位置往后追」;从库宕机重连后,按自己记下的日志位置追赶(catch-up)即可。
主库挂了:故障转移(failover)——最危险的环节。流程是:判定主库确已失效(一般靠超时)→ 选出新主库(选举或由控制节点指派,通常选数据最新的从库)→ 让系统改认新主库。它天生一堆坑:异步复制下,新主库可能没收到老主库最后几条写入,老主库恢复后这些写入常被直接丢弃;若这些自增主键 / 外部系统(如缓存)已被别处使用,会引发数据错乱;还可能脑裂(两个节点都自认为主)。超时设短了会因偶发抖动误判、频繁无谓切换,设长了故障恢复又慢。「什么时候该自动切、什么时候该人工介入」至今没有标准答案。
表 1 · 复制日志的四种实现(决定「主库把改动怎么描述给从库」)
| 方式 | 怎么做 | 坑 / 代价 |
|---|---|---|
| 语句复制 | 把 INSERT/UPDATE 等 SQL 原样发给从库重放 | NOW()/RAND() 等不确定函数、自增列、触发器副作用在各库结果不一,易分叉 |
| WAL 日志传送 | 直接传预写日志(存储引擎的字节级改动) | 与存储引擎版本强耦合——主从版本不同就不能传,滚动升级困难 |
| 逻辑(行)日志 | 按行级别描述改了哪条、新旧值 | 与存储内部解耦,可跨版本、可喂给外部系统(CDC 变更数据捕获的基础) |
| 基于触发器 | 用数据库触发器把变更写到另一张表、由应用搬运 | 最灵活,但开销大、更易出 bug |
是什么:不止一个节点能接受写——每个主库同时又是其他主库的从库。单主在单个数据中心内够用;一旦要跨数据中心(每个机房放一个主库,本地写本地、机房间异步同步)、或要支持离线写(手机日历 App 断网也能改,联网再合并——每台设备就像一个「微型数据中心」)、或协同编辑(多人同时改一份文档),多主就登场了。
直觉与机制:好处是每个主库本地写入延迟低、某个机房整个挂了别的机房照写。但它带来单主天然没有的头号麻烦——写冲突(write conflict):两个主库并发改了同一条记录,同步到一起时对不上。
冲突怎么收敛?几条路:避免冲突——保证同一条记录的写永远路由到同一个主库(最省事,但主库要迁移时失效);最后写入者胜(LWW,last-write-wins)——给每次写打个时间戳 / ID,留大的——实现简单但会静默丢数据;合并——如把两版拼起来(协同编辑走这条);留着冲突、交给应用或用户裁决。复制拓扑也有讲究:全连接(all-to-all)最稳但消息可能乱序到达、破坏因果,得靠版本向量(version vectors)还原「谁先谁后」;环形 / 星形则单点断链就阻塞。
是什么:干脆取消主库,客户端(或一个协调节点)把每次写同时发给多个副本、每次读也同时问多个副本。亚马逊 Dynamo 带火了这一路,Cassandra、Riak、Voldemort 都是。
quorum(法定人数)机制——本节的数学,但只有一行:设副本共 n 台,一次写要得到 w 台确认才算成功,一次读要问 r 台。只要w + r > n,读的这 r 台里就至少有一台与写的那 w 台重叠——那台一定有最新值,读方比较各副本的版本号取新的即可。白话说:「写过的人」和「问到的人」保证有交集,所以你总能碰上一个知情者。典型配置 n=3, w=2, r=2:任意一台副本挂了,写和读都还能凑齐、系统照转。
怎么修复落后的副本:读修复(read repair)——客户端读到某副本给的旧值,顺手把新值写回它;反熵(anti-entropy)——后台进程持续比对、补齐各副本缺的数据。宽松 quorum 与提示移交(sloppy quorum + hinted handoff):网络分区时,写可以先落到能连上但不属于「本该负责」的那 n 台的节点上,事后再转交回去——这样可用性更高,但此时 w+r>n 的「读到最新」保证就不成立了。事实上,即便满足 quorum,并发写、部分写失败、宽松 quorum 等边角情形都可能让你读到旧值——无主复制默认只给最终一致性,不是线性一致。它靠版本向量识别「并发写」,把它们标成兄弟版本(siblings)留待合并。
只要用异步复制来做读扩展(单主 / 无主都常见),就绕不开复制滞后:从副本读到的是过期数据。滞后通常几毫秒到几秒,但从库卡住时能到分钟级。DDIA 给了三条针对性的弱一致性保证,越往下越强:
「最终一致性」这个词故意含糊——它只承诺「终将一致」,却不说途中会读到什么、要等多久。上面三条就是把它切成工程上可操作、可选购的具体保证。
三种复制不是「谁更先进」,而是各自站在不同的取舍点上。核心矛盾始终是那句 CAP 的通俗版:网络一出问题,你要「每次都读到最新(强一致)」,还是要「挂了也能读写(高可用)」——鱼与熊掌。
表 2 · 单主 / 多主 / 无主:什么场景选什么
| 单主 single-leader | 多主 multi-leader | 无主 leaderless | |
|---|---|---|---|
| 谁能写 | 只有主库一处 | 多个主库并发写 | 任意副本(客户端并发发多台) |
| 写冲突 | 天然没有(写串行经主库) | 核心难题,须收敛规则 | 有并发写,靠版本向量 / LWW 处理 |
| 抗主库故障 | 要故障转移,切换有窗口、易出错 | 某主库挂了别处照写 | 无单点,挂几台照转 |
| 读一致性 | 读主库强一致;读从库最终一致 | 最终一致 | 默认最终一致,quorum 可逼近但非线性一致 |
| 典型量级配置 | 1 主 + N 从,半同步保 1 台 | 每机房 1 主 | n=3, w=2, r=2 |
| 最适合 | 绝大多数 OLTP:读多写少、单机房 | 跨地域多写、离线协作、协同编辑 | 要极高写可用、能容忍弱一致(购物车、指标、日志) |
| 代表系统 | PostgreSQL、MySQL、MongoDB、Kafka | BDR / Tungsten、CouchDB、日历同步 | Cassandra、Riak、DynamoDB |
两条实操心法:① 默认从单主起步——它最简单、心智负担最低,能满足绝大多数「读多写少 + 单区域」的业务;只有当跨地域低延迟写、离线、或极端写可用成为硬需求时,才为多主 / 无主的复杂度买单。② 同步 / 异步不是全局开关——半同步(保一台从库同步)常是「丢数据风险」与「写延迟」之间的甜点。
复制是几乎所有生产数据库的默认底座:你用 PostgreSQL 的流复制做只读副本、用 MySQL 主从扛读、用 Kafka 的多副本保证消息不丢、用 Cassandra 的可调一致性做跨机房写——本质都在本章这三种模型里选点。面试里「主从复制怎么做读写分离」「异步复制会不会丢数据 / 怎么保证读到自己刚写的」「Cassandra 的 w+r>n 是什么意思」几乎必考,答案全在这一章。
w+r>n),配合读修复、提示移交、向量时钟来换取「永远可写」的购物车——为高可用不惜牺牲强一致,本章无主一节正源于此。Dynamo: Amazon's Highly Available Key-value Store, SOSP 2007 ↗acks=all + min.insync.replicas,只有当足够多副本确认才算提交——这是本章「半同步」思想在生产消息系统里的直接落地。Kafka Documentation · Replication ↗w+r>n 就等于强一致:它只保证读写集有交集,并不等于线性一致——并发写、部分写失败、宽松 quorum 都能让你读到旧值。要强一致得靠共识(第 9 章)。① 复制 = 同一份数据放多台机器,换来容错 / 低延迟 / 读扩展;难点不在存副本,在持续同步修改。
② 三种架构:单主(一处可写、最常用)、多主(多处可写、跨地域 / 离线)、无主(无单点、quorum 把关)。
③ 单主的第一权衡是同步 vs 异步:异步吞吐高但主库崩时会丢未复制的写;半同步(保一台同步)是常用甜点。
④ 故障转移是最危险环节:可能丢写、脑裂、误切;「自动还是人工」无定论。
⑤ 复制日志有语句 / WAL / 逻辑行 / 触发器四种实现,逻辑行日志解耦、是 CDC 的基础。
⑥ 多主的核心难题是写冲突:靠避免 / LWW / 合并 / 交用户裁决收敛,LWW 会丢数据。
⑦ 无主靠 w+r>n 的读写集交集拿到最新值(典型 n=3,w=2,r=2),但只给最终一致、非线性一致;配读修复 / 反熵 / 宽松 quorum。
⑧ 异步复制必带复制滞后,用读己所写 / 单调读 / 一致前缀读三条弱保证对症下药——它们是「最终一致」的可操作切片。
⑨ 选型心法:默认单主,只有跨地域写 / 离线 / 极端写可用成硬需求时才上多主 / 无主。