replication - log - replay

05. 主从复制:复制的都是日志

0. 本章先解决什么问题

MySQL 主从复制、Redis 主从复制、Kafka 副本机制,我最初是当三个独立专题学的,每个都有一堆专有名词:binlog、relay log、PSYNC、repl_backlog、ISR、HW。本章先给出统一它们的那句话,再把三家逐个拆到零件级:

两台机器,初始状态相同,按相同顺序执行相同的操作序列,
终态一定相同。
所以复制一个数据库,只需要复制它的操作日志并重放。

这叫状态机复制。上一章说日志是复制的载体,本章就是那个预告的展开。

主从复制的通用结构

这张图怎么读

上半是通用骨架:主节点追加日志,日志通过网络流向从节点,从节点重放。下半是三个系统往骨架上的对号入座:MySQL 送 binlog,Redis 送命令流,Kafka 的 follower 直接拉日志本体。

1. 为什么要复制:三个目的先分清

  1. 高可用: 主挂了,从顶上(下一章故障转移的前提)
  2. 读扩展: 读请求分流到从库,主库只扛写
  3. 数据安全: 多一份物理拷贝,单机磁盘损坏不等于数据消失

注意复制不解决容量问题:每个从节点都存全量数据。数据大到一台装不下,要的是分片(第 7 章),复制和分片正交。

状态机复制成立有两个前提,后面每家的设计都在保它们:操作序列有全序(谁先谁后全网一致),以及每个操作是确定性的(同样输入必得同样输出)。上一章 binlog 的 row 格式取代 statement,就是在保第二条。

2. MySQL:binlog 从主到从的四线程流水线

2.1 完整链路

  • 主库:事务提交时写 binlog
  • 主库 dump 线程:每个从库连上来,主库派一个 dump 线程
    盯着 binlog 有新事件就推给它
  • 从库 IO 线程:收下来先原样写进本地的 relay log(中继日志)
  • 从库 SQL 线程:读 relay log,逐条重放

为什么中间要过一道 relay log:接收和重放解耦。网络快慢和重放快慢互不拖累,从库崩了重启接着重放本地已收的,不用重新拉。这个”先落地再处理”和消息队列先入队再消费同构。

排查时的对应关系:SHOW REPLICA STATUS 里 IO 线程状态对应”拉得动吗”(网络、主库压力),SQL 线程状态对应”放得完吗”(重放速度),Seconds_Behind_Master 是 SQL 线程手里这条事件距它在主库发生时刻的差。

2.2 定位方式:从 file + position 到 GTID

  • 老式:从库记”我读到 mysql-bin.000042 的 1337 字节处”
    主从切换时,新主的文件名偏移和老主对不上,人肉换算,极易出错
  • GTID:每个事务标一个全局唯一编号(源库 uuid:递增序号)
    从库只声明”我执行过的 GTID 集合”,
    连上任何一个新主,双方对比集合自动续传(auto position)
    主从切换从”人肉找位点”变成”自动对表”

2.3 同步程度:三档加两个半同步细分

复制可靠性可以逐档理解:

  1. 异步复制(默认):主库本地提交后立即答复客户端,不等待 binlog 发往从库。主库若在发送前宕机,从库没有这笔事务,故障切换后数据就会丢失。
  2. 半同步复制:至少一个从库确认“收到 binlog”后,主库才答复客户端。这里确认的是写入 relay log,不是已经重放完成。
    • AFTER_COMMIT(旧模式):主库先完成本地提交,再等待从库确认。等待期间其他会话已经能看到这笔事务;如果此时主库崩溃而从库尚未收到,就会出现“别人看见过的数据后来丢了”。
    • AFTER_SYNC(MySQL 5.7 默认,无损半同步):先等待从库确认,再完成主库本地提交。从库尚未确认的事务在主库上也还不可见,因此主库此时崩溃不构成已提交数据丢失。
  3. 组复制 MGR:把写集合广播给成员并经过多数派认证,可靠性模型由主从确认继续推进到共识。

2.4 主从延迟:单写多读架构的固有病

延迟的来源要按流水线的三段分开找:

拉不动: 主库 binlog 产得太快、网络带宽不足(大事务一次产几 GB)
放不完: SQL 线程重放慢,经典原因三个:

  1. 老版本单线程重放对阵主库多线程并发写,天然追不上
  2. 大事务: 主库跑 10 分钟的 UPDATE,从库也要跑 10 分钟,
    期间延迟直线上升
  3. 从库机器或参数比主库弱(常见于”从库反正只读”的省钱心态)

并行复制的演化就是在治”放不完”:

  • 5.6:按库并行。不同 database 的事务可以并发重放,单库场景无效
  • 5.7:按组提交批次并行(LOGICAL_CLOCK)。
    上一章组提交时说过”同批 fsync 的事务标同号”,
    能同批提交说明在主库上互不冲突,从库就敢并发重放它们
  • 8.0:WRITESET。按行级写集合判断冲突,
    不同批次的事务只要没碰同一行也能并行,并行度再上台阶

业务侧的配套纪律:拆大事务(一次删百万行改成分批),避免无主键表(row 格式重放定位行要全表扫)。

2.5 读写分离的一致性:你会读到过去

复制是异步的,从库永远活在主库的几毫秒到几秒前。读写分离一上,业务就要面对”刚写的数据查不到”:

典型事故: 下单成功 -> 跳转订单页(路由到从库)-> 订单不存在
处理手段按成本排序:

  1. 强一致的读强制走主库(按业务标记,最常用)
  2. 会话粘性: 同一用户写后一段时间内的读都走主
  3. GTID 等位: 读从库前先等它追上”我刚才那次写”的 GTID
  4. 接受不一致,前端乐观展示(很多场景其实可以)

没有免费午餐,只有”哪些读必须新、哪些读可以旧”的业务分类。完整展开在 主从复制 · 运维篇读写分离

3. Redis:全量快照 + 增量命令流

3.1 首次同步:又见”快照 + 增量”配方

从节点执行 replicaof 之后的握手,教科书级复现上一章的配方:

从: PSYNC ? -1 (我什么都没有,求全量)
主: FULLRESYNC (记住我的身份证和当前进度)
fork 子进程生成 RDB
生成期间的新写命令暂存进该从库的复制缓冲
从: 清空自己 -> 载入 RDB -> 重放暂存的增量 -> 进入在线复制
在线复制: 主每执行一条写命令,异步转发给所有从

两个工程细节:

无盘复制 repl-diskless-sync: 主库磁盘慢时,RDB 不落盘,
fork 出的子进程直接往 socket 写,省一进一出两趟磁盘
fork 的代价: 和实例内存量成正比(页表拷贝 + 后续写时复制),
大实例每次全量同步都是一次抖动源,所以要极力避免无谓的全量

3.2 断线重连:backlog 环形缓冲决定补量还是重来

主库把最近的写命令流同时抄一份进 repl_backlog(环形缓冲,默认只有 1 MB),断线重连时:

断线从库会发送 PSYNC <replid> <offset>,告诉主库自己上次所属的复制系谱和已经处理到的进度。主库随后判断:

  • replid 匹配且 offset 仍在 backlog 环内:返回 +CONTINUE,只补缺失的那一段,完成部分重同步。
  • replid 不匹配,或 offset 已被 backlog 覆盖:重新执行 fork + RDB 的全量同步;对大实例而言,这正是代价最高的路径。

backlog 的容量算术直接决定线上稳定性:

需要的 backlog >= 主库写入速率 x 最长容忍断线时长
例: 写入 5 MB/s,想扛住 60 秒网络抖动 -> 至少 300 MB
默认 1 MB 在生产写入量下只够撑零点几秒,
这就是”网络一抖就触发全量同步风暴”的标准病因

4.0 的 psync2 补了另一个洞:从库记两代身份证(replid 和 replid2)。故障转移后新主继承老主的 replid 作为 replid2,老部下连上新主时依然能对上身份、走部分重同步,不用因为换了主就全体全量。

3.3 在线复制的语义

传播是异步的: 主执行完就答复客户端,命令在路上丢了就丢了
从库默认只读(replica-read-only)
WAIT N t: 罕用的补丁,阻塞到至少 N 个从库确认了当前偏移,
给个别关键写一点半同步的意思,但没有回滚语义,不是真同步
级联复制: 从库还可以再挂从库,分摊主库的复制扇出压力

细节在 yori - 持久化 & 集群 &哨兵

4. Kafka:日志本身就是数据,复制反而最纯粹

4.1 拉模型和 ISR

每个 partition 一个 leader、若干 follower,follower 像普通消费者一样向 leader 发 fetch 请求拉日志追加到本地。两个进度指针是理解一切语义的钥匙:

LEO(log end offset): 每个副本自己日志的末尾
HW(high watermark): ISR 全体都已复制到的位置
leader 收集所有 ISR 成员的 LEO,取最小值定为 HW
消费者只能读到 HW 之前的消息
(HW 之后的数据还没被足够多副本持有,leader 崩了可能回退,
先藏着不给消费者看,避免”读到过又消失”)
ISR: 跟得上 leader 的副本集合。
落后超过 replica.lag.time.max.ms(默认 30 秒)就被踢出,
追上了再回来。ISR 收缩是集群健康的头号观察指标

4.2 acks:把同步程度的滑杆交给每次写入

acks=0 发出去就算成功。丢了无感知,只配点日志埋点用
acks=1 leader 写入本地就确认。leader 崩且数据没复制走则丢
acks=all ISR 全体确认才成功,配 min.insync.replicas=2 使用:
ISR 缩到 1 个(只剩 leader)时直接拒写,
宁可不可用也不写出”只有一份”的数据
unclean.leader.election.enable=false(默认):
ISR 全灭时不许 ISR 外的落后副本竞选 leader,
宁可分区不可用也不接受数据回退

对照前两节:acks=1 近似 MySQL 异步,acks=all 加 min.insync.replicas 已经踩进多数派地界。Kafka 把别家藏在服务端配置里的滑杆,直接暴露给每个生产者按 topic 自选。

4.3 leader epoch:修”按 HW 截断”的老 bug

老版本 follower 重启后按自己记的 HW 截断日志再追,但 HW 的传播比 LEO 慢一拍,特定时序下会把已提交的数据截掉,或者新老 leader 数据分叉。
修法是给每任 leader 编号(leader epoch),副本重启后先问现任 leader”你这任从哪个 offset 开始”,按任期边界截断而不是按 HW。
这正是下一章”纪元编号封印旧时代”思想在数据面的应用,两章在这里提前握手。

5. 一张对照表收拢

MySQLRedisKafka
复制的东西binlog(逻辑)写命令流(逻辑)日志本体
全量引导备份导入fork RDB从头拉日志
增量断点file+pos / GTIDoffset + backlog 环fetch offset
传输方向dump 线程推流主动推给从follower 拉
同步程度异步 / 半同步 / MGR异步(WAIT 补丁)acks 每写可选
可见性控制无(从库读到啥算啥)HW 之前才可见
延迟观察Seconds_Behind_Masteroffset 差ISR 收缩

记法:列是三个产品,行是同一骨架的七个部位。新遇到任何带副本的系统(ES、MongoDB、etcd),拿这七行去问,答案都填得进去。

6. 联系实际:复制视角的排查清单

  • 现象:从库延迟持续增长

  • 方向:先分”拉不动还是放不完”(IO 线程和 SQL 线程哪个落后),
    放不完就查大事务、并行复制配置、从库配置

  • 现象:Redis 从节点频繁全量同步,主库周期性抖动

  • 方向:网络抖动 + backlog 太小,offset 总掉出环外;
    按”写入速率 x 断线时长”重算 repl-backlog-size

  • 现象:Kafka 生产端报 NotEnoughReplicas

  • 方向:ISR 收缩到小于 min.insync.replicas;查慢副本的磁盘和 GC

  • 现象:主从切换后少了一截数据

  • 方向:异步复制的丢失窗口。评估半同步 AFTER_SYNC / acks=all /
    多数派方案,以及业务是否值得为此付延迟

  • 现象:读写分离上线后偶发”刚写的查不到”

  • 方向:原理性现象。按业务分类哪些读必须走主,哪些可容忍旧

7. 学完本章你能解决什么问题

  1. 状态机复制的两个前提是什么,row 格式和 GTID 各在保哪条?
  2. 四线程流水线里 relay log 为什么存在,延迟怎么按段定位?
  3. AFTER_SYNC 和 AFTER_COMMIT 差的那半步,差出了什么后果?
  4. 并行复制三代(按库、LOGICAL_CLOCK、WRITESET)各靠什么判断”能并行”?
  5. Redis 部分重同步的判断条件是什么,backlog 该怎么算容量?
  6. psync2 的两代 replid 解决了什么场景的全量风暴?
  7. HW 为什么要挡住消费者,leader epoch 修了什么 bug?
  8. acks=all 加 min.insync.replicas 为什么已经算半只脚进了多数派?

核心一句话:复制就是把主节点的日志送到从节点重放;产品差异只在日志形态、增量断点怎么记、“等几个副本确认”这根滑杆拧到哪,以及未完全复制的数据给不给人看。

延伸阅读