KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

01 · 主从复制:全量同步与增量同步是怎么跑起来的 — keel 龙骨

读写分离、哨兵、集群全都站在同一块地基上:主从复制。这一章把复制连接建立的全过程拆开——为什么有时候是"传一整个 RDB"、有时候是"只传差的那一小段"、以及怎么用两个数字量化"从库落后了多少"。

读写分离、哨兵、集群全都站在同一块地基上:主从复制。这一章把复制连接建立的全过程拆开——为什么有时候是"传一整个 RDB"、有时候是"只传差的那一小段"、以及怎么用两个数字量化"从库落后了多少"。


一、现场:从库明明 online,读到的却是旧值

配好了主从,主库 INFO replication 里 state=online、从库 master_link_status:up,一切正常。但业务反馈:编辑保存完文章,立刻刷新前台看到的还是旧标题,过一两秒再刷才对。

第一反应是"复制是不是断了",一看连接状态又没问题。这里有个关键区分:连接正常 ≠ 数据同步到了最新。复制是异步的,"online"只说明链路还在、心跳还在。

要看清这件事,得先知道数据是怎么搬过去的。

二、直觉模型:一条单向的命令流

先把模型建到能用为止:

主库:执行写命令 → 更新自己的数据集 → 把命令写进 backlog(复制积压缓冲区)→ 发给从库
从库:收到命令流 → 在自己身上重放一遍(顺序与主库一致)

三条直接推论:

  1. 从库的数据是"主库某个历史时刻的样子",不是实时快照。
  2. 主库不等从库。写命令返回 OK 的那一刻,从库可能还没收到。
  3. 从库是只读的,往从库写会被直接拒绝:
127.0.0.1:6380> SET article:42 hacked
READONLY You can't write against a read only replica.

这条报错是有用的信号:它说明你写错地方了。反过来说,如果你在从库上写入成功了,说明有人把 replica-read-only 关掉了——那会造成真正的数据错乱(从库被写脏,随后又被主库的复制流覆盖或与之冲突)。

三、精确定义:四个量决定同步行为

概念 含义 在哪看
replid(复制 ID) 标识一条复制流,也就是主库数据集的一个"世代" 主库 master_replid;从库 master_replid
offset(复制偏移量) 复制流里的字节位置,主库每产生一个字节的写就 +1 主库 master_repl_offset;从库 slave_repl_offset
backlog(复制积压缓冲区) 主库上一段固定大小的环形缓冲,保存最近发出的命令 repl_backlog_size(默认 1MB)、repl_backlog_histlen
replid2 / second_repl_offset 上一次切换前的旧 replid 与切换点 offset,用于切换后仍能做增量同步 主库 master_replid2

判定规则只有一条,从库带着 (replid, offset) 来连主库:

四、一次完整运行:把一对主从跑起来看

第 1 步:让两个从库认主

redis-cli -p 6380 REPLICAOF 127.0.0.1 6379
redis-cli -p 6381 REPLICAOF 127.0.0.1 6379

第 2 步:主库视角(实测输出)

127.0.0.1:6379> INFO replication
# Replication
role:master
connected_slaves:2
slave0:ip=127.0.0.1,port=6380,state=online,offset=0,lag=1
slave1:ip=127.0.0.1,port=6381,state=online,offset=0,lag=1
master_replid:f155ef94f02bb9633e0fae90bbe03172e7817f9f
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:14
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:14

逐项解释(排障时主要靠这几个字段):

第 3 步:从库视角(实测输出)

127.0.0.1:6380> INFO replication
# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up
master_last_io_seconds_ago:0
master_sync_in_progress:0
slave_repl_offset:14
slave_priority:100
slave_read_only:1
connected_slaves:0
master_replid:f155ef94f02bb9633e0fae90bbe03172e7817f9f
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:14
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:14

关键字段:

第 4 步:确认复制真的生效

127.0.0.1:6379> SET article:42 v1
OK
127.0.0.1:6380> GET article:42
v1

五、两条同步路径的真实日志

同步过程写在从库的日志里。让一个全新的从库(它自己的 replid 与主库不同)连上来,日志是这样(实测,时间戳已省略):

* Before turning into a replica, using my master parameters to synthesize a cached master:
  I may be able to synchronize with the new master with just a partial transfer.
* REPLICAOF 127.0.0.1:6379 enabled (user request from 'id=3 addr=127.0.0.1:57001 ... cmd=replicaof')
* Connecting to MASTER 127.0.0.1:6379
* MASTER <-> REPLICA sync started
* Master replied to PING, replication can continue...
* Trying a partial resynchronization (request 38fc7df1a21d4a8f589998778378a3e161497b05:1).
* Full resync from master: f155ef94f02bb9633e0fae90bbe03172e7817f9f:0
* MASTER <-> REPLICA sync: receiving 7112 bytes from master
* MASTER <-> REPLICA sync: Flushing old data
* MASTER <-> REPLICA sync: Loading DB in memory
* MASTER <-> REPLICA sync: Finished with success

注意中间这三行的顺序,它决定了"全量同步期间从库还能不能读":

receiving 7112 bytes from master   ← 收主库发来的 RDB
Flushing old data                  ← 清空自己的旧数据集(此刻起旧数据没了)
Loading DB in memory               ← 加载新数据(这段时间里从库处于不完整状态)

从库请求的是 38fc7df1...:1(它自己的 replid),与主库的 f155ef94... 不同,于是走全量。

同一个从库重启后重连(它把 replid/offset 持久化在 RDB 里),日志变成:

* Before turning into a replica, using my master parameters to synthesize a cached master:
  I may be able to synchronize with the new master with just a partial transfer.
* Connecting to MASTER 127.0.0.1:6379
* MASTER <-> REPLICA sync started
* Trying a partial resynchronization (request f155ef94f02bb9633e0fae90bbe03172e7817f9f:47984).
* Successful partial resynchronization with master.
* MASTER <-> REPLICA sync: Master accepted a Partial Resynchronization.

没有 RDB 传输,没有 Flushing old data。请求里带的 replid 与主库一致、offset 47984 仍在 backlog 覆盖范围内,于是只补差的那一段。

判定流程画成一张图:

flowchart TD
    A["从库发起 PSYNC<br/>带上自己的 (replid, offset)"] --> B{"replid 与主的<br/>master_replid 相同?"}
    B -- 否 --> F["全量同步"]
    B -- 是 --> C{"offset 之后的数据<br/>还在 backlog 里?"}
    C -- 否 --> F
    C -- 是 --> P["增量同步"]
    F --> F1["主库生成 RDB 快照<br/>期间的写进 backlog"]
    F1 --> F2["发 RDB → 从库清旧数据 → 加载"]
    F2 --> F3["补发 backlog 里的增量"]
    F3 --> S["进入持续增量复制"]
    P --> P1["主库从 offset 处<br/>只补发差的那一段"]
    P1 --> S
    S -.->|"从库落后超出 backlog<br/>或主库换了 replid"| F

虚线那条边是重点:运行中也可能退化成全量同步——从库断线太久、或从库太慢,导致"要补的数据"已经滚出了那个环形缓冲。

六、失败注入:把增量同步逼成全量同步

判定条件就是上面那张图:从库要补的字节数 > repl_backlog_size。所以

可容忍断线时长 ≈ repl_backlog_size ÷ 主库写入速率(字节/秒)

实验:主库持续写入,从库断开足够久,再重连,观察日志从 Successful partial resynchronization 变成 Full resync from master。

先给主库灌点数据(Lua 一次写 200 个 200 字节的 key):

redis-cli -p 6379 EVAL "for i=1,200 do redis.call('set','lab:key:'..i, string.rep('x',200)) end return 200" 0
# 输出:200

实测到的两种结果(同一对实例,区别只在重连时 offset 是否还在 backlog 内):

# 情况 A:replid 对不上(新从库 / 从库被重置)
* Trying a partial resynchronization (request 38fc7df1...:1).
* Full resync from master: f155ef94...:0
* MASTER <-> REPLICA sync: receiving 7112 bytes from master

# 情况 B:replid 一致且 offset 仍在 backlog 内
* Trying a partial resynchronization (request f155ef94...:47984).
* Successful partial resynchronization with master.

这条实验的工程结论:repl-backlog-size 不是"设大点更好"的玄学参数,它直接决定从库能断多久而不触发全量重传。1MB 的默认值,在 1MB/s 的写入速率下意味着从库断线 1 秒就得全量重传。

全量重传的代价是三重的:主库 fork 并写 RDB(CPU + 磁盘)、网络传输整个数据集、从库清空并重新加载(期间读服务受损)。所以写密集的实例一定要调大 backlog——这是最便宜的保险。

七、WAIT:唯一能让你"等一下"的内置命令

复制默认"主库写完就返回,不等从库"。WAIT 让你显式等:

127.0.0.1:6379> WAIT 2 1000
(integer) 2        # 两个从库都在 1000ms 内确认收到了这次写
127.0.0.1:6379> WAIT 1 500
(integer) 1        # 只要一个从库确认

返回值是实际确认的从库数量,不是成功/失败。当没有从库确认时(例如从库已断开):

127.0.0.1:6379> WAIT 1 1000
(integer) 0

WAIT 的三条边界(它最容易被误读成"强一致开关"):

  1. 它等的是"从库收到了",不是"从库应用了",更不是"持久化了"。
  2. 它不保证切换后这次写还在。哨兵选主时会参考 offset,但整套机制不是一致性协议(第 04 章)。
  3. 超时后返回已确认数,调用方必须自己判断返回值够不够,不能只看"没报错"。

所以 WAIT 的正确定位是缩小丢数据窗口(关键写入后等一个副本确认),而不是"这样就安全了"。

八、生产环境怎样替换

教学替身 生产替换 要点
本机三端口 跨机器、跨可用区的主从 同机多实例只验证机制
默认 repl-backlog-size 1MB 按「写入速率 × 期望容忍断线时长」调,写密集实例常见 64~256MB CONFIG SET 会重置 backlog,可能触发一次全量重传,挑低峰做
REPLICAOF 命令 写进配置文件 replicaof ... 只靠命令配的关系,重启就没了
无认证 requirepass + 从库 masterauth(Redis 6+ 用 ACL) 从库不配 masterauth 会连不上
人眼看 INFO 采集 master_repl_offset - slave_repl_offset、master_link_status、connected_slaves 告警要基于字节差,不能只看连接状态

九、练习与验收

练习 1:起一主一从,观察从库日志里的全量同步过程;重启从库进程再连,观察增量同步过程。写出两条日志的差异,并说明触发判定用的是哪两个值。

练习 2:把 repl-backlog-size 调到最小,主库持续写入,从库断开一小段时间后重连,验证是否退化成全量同步,并算出你的实例在当前写入速率下的"可容忍断线时长"。

练习 3:写代码做一次 WAIT 1 500,分别在有从库、从库停掉两种情况下打印返回值,并给返回值不足时的降级分支。

验收点:不看资料,说出 replid / offset / backlog 三者如何共同决定"增量还是全量";解释 WAIT 返回 0 意味着什么、它不保证什么。


现在能解释什么:复制连接建立的两条路径与判定条件;INFO replication 主要字段的含义;怎么量化从库落后;为什么调大 backlog 能减少全量重传;WAIT 能缩小什么风险、不能提供什么保证。

下一步:第 02 章把复制用起来——读写分离,以及那个绕不开的"刚写完读不到"。

进入 keel 阅读