Redis 主从复制详解

Redis 主从复制详解:全量同步与增量同步

如果把 Redis 比作一个图书馆,那么主从复制就像是在不同地方建立了分馆。主馆(Master)负责收录新书,分馆(Slave)自动同步主馆的藏书。这样即使主馆失火,分馆还能继续提供服务。本文将带你深入理解 Redis 主从复制的奥秘。

📖 目录


为什么需要主从复制?

单机 Redis 的困境

想象一下,你开了一家餐厅,只有一个厨师。如果这个厨师生病了,餐厅就得关门;客人太多,厨师忙不过来,就得排长队。这就是单机 Redis 的问题:

markdown 复制代码
┌─────────────────────────┐
│   Redis 单机             │
│   • 单点故障 💥          │
│   • 容量受限 📦          │
│   • QPS 受限 🐌          │
└─────────────────────────┘
         ↓ 宕机
    💥 服务不可用
    💥 数据全部丢失
    💥 用户流失

主从复制:开分店的智慧

主从复制就像给餐厅开分店:

复制代码
🏪 总店(Master):
• 研发新菜品(处理写请求)
• 也可以接待客人(处理读请求)
• 把新菜谱发给分店(数据同步)

🏪 分店(Slave):
• 接待客人(处理读请求)
• 学习总店的新菜品(数据同步)
• 总店关门时可以顶上(故障转移)

带来的好处

  1. 数据冗余:总店失火,分店还有备份菜谱
  2. 读写分离:总店做菜,分店接客,效率翻倍
  3. 故障恢复:总店关门,分店立即顶上
  4. 负载均衡:客人分流到多个分店
  5. 高可用基础:哨兵和集群的基石

性能对比

复制代码
单店模式(单机):
• 做菜能力:5 万道/秒
• 接客能力:5 万人/秒
• 总服务能力:5 万/秒

1 总店 + 3 分店:
• 做菜能力:5 万道/秒(总店)
• 接客能力:15 万人/秒(3 个分店)
• 总服务能力:20 万/秒(提升 4 倍!)

5.1 主从复制概述

主从复制架构

yaml 复制代码
┌────────────────────────────────────────┐
│      Redis 主从架构:总分店模式         │
└────────────────────────────────────────┘

    写请求(新菜品)
         ↓
    ┌──────────┐
    │  Master  │  总店(研发新菜 + 接客)
    │  6379    │
    └────┬─────┘
         │ 📋 菜谱同步
      ┌──┴──┬──────┐
      ↓     ↓      ↓
┌──────────┐ ┌──────────┐ ┌──────────┐
│  Slave1  │ │  Slave2  │ │  Slave3  │
│ 东城分店  │ │ 西城分店  │ │ 南城分店  │
│  6380    │ │  6381    │ │  6382    │
└──────────┘ └──────────┘ └──────────┘
     ↑            ↑            ↑
     └────────────┴────────────┘
        读请求(客人点餐)

特点:
• 总店:可以研发新菜(写),也可以接客(读)
• 分店:只能接客(读),不能研发新菜
• 菜谱流向:总店 → 分店(单向)
• 异步同步:总店不等分店确认收到

核心概念

1️⃣ 主节点(Master)- 总店店长
bash 复制代码
# 总店的职责
• 处理所有"新菜研发"(写请求)
• 也可以"接待客人"(读请求)
• 把新菜谱同步给分店(数据同步)
• 记录菜谱版本号(复制偏移量)

# 查看总店信息
127.0.0.1:6379> INFO replication
# Replication
role:master
connected_slaves:3
slave0:ip=127.0.0.1,port=6380,state=online,offset=1234,lag=0  ✅ 东城分店同步正常
slave1:ip=127.0.0.1,port=6381,state=online,offset=1234,lag=1  ⚠️  西城分店延迟1秒
slave2:ip=127.0.0.1,port=6382,state=online,offset=1234,lag=0  ✅ 南城分店同步正常
2️⃣ 从节点(Slave/Replica)- 分店店长
bash 复制代码
# 分店的职责
• 只能"接待客人"(读请求)
• 不能"研发新菜"(写请求会报错)
• 学习总店的新菜谱(数据同步)
• 也可以开自己的分店(级联复制)

# 查看分店信息
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      # 0 秒前刚通过话
master_sync_in_progress:0         # 未在同步中
slave_repl_offset:1234            # 已学到第 1234 道菜
slave_read_only:1                 # 🔒 只读模式
3️⃣ 复制 ID(Replication ID)- 菜谱版本号
bash 复制代码
# 每个 Redis 实例都有唯一的"菜谱版本号"
master_replid:8e3c9d4f2a1b5c6d7e8f9a0b1c2d3e4f5a6b7c8d

# 就像书籍的 ISBN 号
• 标识这是哪个版本的数据集
• 总店换老板(主从切换)会生成新版本号
• 用于判断分店的菜谱是否过时
4️⃣ 复制偏移量(Replication Offset)- 学习进度
bash 复制代码
# 总店的菜谱进度
master_repl_offset:1234567  # 已研发到第 1234567 道菜

# 分店的学习进度
slave_repl_offset:1234567   # 已学到第 1234567 道菜

# 进度一致 = 菜谱同步
# 进度相差 = 还有新菜没学会(延迟)

应用场景

1️⃣ 读写分离 - 总店做菜,分店接客
java 复制代码
// 写操作(研发新菜)→ 总店
@Autowired
private RedisTemplate<String, String> masterRedis;

public void createNewDish(String dishId, String recipe) {
    // 新菜只能在总店研发
    masterRedis.opsForValue().set("dish:" + dishId, recipe);
}

// 读操作(客人点餐)→ 分店
@Autowired
private RedisTemplate<String, String> slaveRedis;

public String getDish(String dishId) {
    // 客人可以在任意分店点餐
    return slaveRedis.opsForValue().get("dish:" + dishId);
}
2️⃣ 故障恢复 - 分店顶替总店
bash 复制代码
# 场景:总店突然停电
Master (6379) 💥 Down

# 应急方案:东城分店临时升级为总店
Slave1 (6380) → 升级为 Master

# 其他分店改为跟随新总店
Slave2, Slave3 → 跟随新总店 (6380)

# 服务不中断!客人甚至没察觉 ✅
3️⃣ 数据备份 - 专职备份员
bash 复制代码
# 安排一个分店专门做备份(不接客)
Master (6379) → Slave-Backup (6390)
                     ↓
         每天凌晨打包菜谱(BGSAVE)
                     ↓
      backup/recipes-20251026.rdb
4️⃣ 负载均衡 - 客人分流
scss 复制代码
客人来了,去哪个分店?
         ↓
    负载均衡器(前台)
    ├─→ 东城分店 (33% 客人)
    ├─→ 西城分店 (33% 客人)
    └─→ 南城分店 (34% 客人)
    
每个分店压力均衡 ✅

5.2 复制流程详解

建立连接:从相识到相知

分店要加盟总店,需要经历一系列"认亲"过程:

sql 复制代码
┌────────────────────────────────────────┐
│     分店加盟流程:7 步握手              │
└────────────────────────────────────────┘

Slave (分店)                Master (总店)
  │                           │
  │─── ① 表明身份 ───────────→│
  │   "我想加盟您!"            │
  │   replicaof 127.0.0.1 6379│
  │                           │
  │─── ② 打个招呼 ───────────→│
  │   PING                    │
  │                           │
  │←── ③ 回应 ───────────────│
  │   PONG "欢迎欢迎!"       │
  │                           │
  │─── ④ 验证身份 ───────────→│
  │   AUTH password           │
  │                           │
  │←── ⑤ 身份确认 ───────────│
  │   OK "身份验证通过"       │
  │                           │
  │─── ⑥ 报告地址 ───────────→│
  │   REPLCONF listening-port │
  │   REPLCONF ip-address     │
  │                           │
  │─── ⑦ 请求菜谱 ───────────→│
  │   PSYNC replid offset     │
  │                           │
  │←── ⑧ 开始传授 ───────────│
  │   +FULLRESYNC 或 +CONTINUE│

配置分店

bash 复制代码
# 方式 1:写入分店合同(永久有效)
# redis.conf
replicaof 127.0.0.1 6379  # 认 127.0.0.1:6379 为总店
masterauth <password>      # 总店的暗号

# 方式 2:口头约定(临时的)
127.0.0.1:6380> REPLICAOF 127.0.0.1 6379
OK

# 分店想独立(取消加盟)
127.0.0.1:6380> REPLICAOF NO ONE
OK  # 我要自己当老板!

全量同步:打包发货

第一次加盟或中断太久,总店需要把所有菜谱打包发给分店:

perl 复制代码
┌────────────────────────────────────────┐
│    全量同步:菜谱大全发货流程           │
└────────────────────────────────────────┘

Slave (分店)                Master (总店)
  │                           │
  │─── "老板,给我全套菜谱" ──→│
  │   PSYNC ? -1              │
  │                           │
  │                           │ ① "好的,我来整理"
  │                           │   fork 子进程
  │                           │   开始打包(BGSAVE)
  │                           │
  │←── "稍等,正在打包" ──────│
  │   +FULLRESYNC replid offs │
  │                           │
  │                           │ ② 打包期间
  │                           │   新菜谱先记小本上
  │                           │   (复制缓冲区)
  │                           │
  │←── 📦 "菜谱大全" ────────│ ③ 快递发货
  │   (RDB 文件)              │   (网络传输)
  │                           │
  │ ④ "收到!先清空旧菜谱"    │
  │   FLUSHALL                │
  │                           │
  │ ⑤ "开始学习新菜谱"        │
  │   加载 RDB                │
  │                           │
  │←── 📝 "这是期间的新菜" ──│ ⑥ 补发小本上的
  │   (复制缓冲区的命令)       │   新菜谱
  │                           │
  │ ⑦ "学会了!"              │
  │   执行增量命令            │
  │                           │
  │ ✅ 同步完成,开始营业!    │

全量同步的代价(就像搬家):

bash 复制代码
# 对于 10GB 的数据
总店(Master)的工作量:
• 整理菜谱:30 秒(CPU 忙)
• 打包装箱:10 秒(磁盘 IO)
• 快递发货:60 秒(网络 IO)

分店(Slave)的工作量:
• 接收快递:60 秒(网络 IO)
• 清空旧物:5 秒(FLUSHALL)
• 整理新物:30 秒(加载 RDB)

总搬家时间:约 2 分钟

性能优化技巧

c 复制代码
// 总店很忙,多个分店同时要菜谱怎么办?
void syncCommand(client *c) {
    // 聪明的做法:
    // 如果已经在打包了,新分店也等这一份
    if (server.rdb_child_pid != -1) {
        // "已经在打包了,你也等着吧"
        listAddNodeTail(server.slaves_waiting_bgsave, c);
    } else {
        // "我现在开始打包"
        startBgsaveForReplication();
    }
}

// 一次打包,多个分店共享 ✅
// 节省资源,聪明!

增量同步:实时更新

菜谱同步完成后,总店每研发一道新菜,立即通知分店:

bash 复制代码
┌────────────────────────────────────────┐
│      增量同步:新菜实时推送             │
└────────────────────────────────────────┘

Master (总店)              Slave (分店)
  │                           │
  │ ① "研发新菜:宫保鸡丁"    │
  │   SET dish:001 "宫保鸡丁" │
  │                           │
  │ ② 记录到菜谱本            │
  │   (执行命令)              │
  │                           │
  │ ③ 记录到备忘录            │
  │   (写 AOF)                │
  │                           │
  │ ④ 通知所有分店 ──────────→│
  │   *3\r\n$3\r\nSET...     │
  │                           │
  │                           │ ⑤ "学会了!"
  │                           │   (执行命令)
  │                           │
  │                           │ ⑥ 更新学习进度
  │                           │   (更新偏移量)

新菜同步完成!分店可以卖这道菜了 ✅

命令传播实现

c 复制代码
// 总店群发通知
void replicationFeedSlaves(list *slaves, int dictid, robj **argv, int argc) {
    // "各位分店听好了,新菜来了!"
    listIter li;
    listNode *ln;
    
    // 逐个通知每个分店
    listRewind(slaves, &li);
    while((ln = listNext(&li))) {
        client *slave = ln->value;
        
        // 把新菜谱编码成"暗语"(RESP 协议)
        addReplyArrayLen(slave, argc);
        for (int j = 0; j < argc; j++) {
            addReplyBulk(slave, argv[j]);
        }
    }
}

心跳机制:互报平安

总店和分店每秒通个电话,确认对方还在:

arduino 复制代码
┌────────────────────────────────────────┐
│      心跳机制:每秒一个电话             │
└────────────────────────────────────────┘

Slave (分店)                Master (总店)
  │                           │
  │──┐ "报告!"               │
  │  │ 每秒一次                │
  │  │ REPLCONF ACK 1234 ────→│ "我已学到第 1234 道菜"
  │←─┘                        │
  │                           │
  │                           │ 检查:
  │                           │ ✅ 分店还活着吗?
  │                           │ ✅ 学到哪道菜了?
  │                           │ ✅ 电话延迟多少?
  │                           │
  │ 如果 60 秒没接到电话      │
  │ "这个分店可能倒闭了"      │
  │ 断开连接 ❌                │

心跳的妙用

  1. 保持联系:确认分店还在营业
  2. 汇报进度:分店告诉总店学到哪了
  3. 测量延迟:计算总分店之间的"电话延迟"
  4. 质量控制:检查是否有足够的合格分店

配置

bash 复制代码
# redis.conf

# 总店:多久收不到电话就认为分店倒闭
repl-timeout 60  # 默认 60 秒

# 分店:每秒打一次电话
# (这是硬性规定,不能改)

# 质量控制:至少要有几个合格分店
min-replicas-to-write 1       # 至少 1 个分店
min-replicas-max-lag 10       # 延迟不超过 10 秒
# 如果不达标,总店停止研发新菜(拒绝写入)

5.3 PSYNC 命令

PSYNC 演进历史

PSYNC 就像菜谱传授的"武林秘籍",经历了三代演进:

arduino 复制代码
第一代(Redis 2.6 前):SYNC - 笨方法
分店:"老板,我想学菜"
总店:"好,我把所有菜谱从头教你"
     (全量同步,即使只缺一道菜也要全部重学)

缺点:
• 分店电话断了重连,还要全部重学
• 浪费时间和资源
• 就像忘记一个单词,要把整本字典重抄一遍

第二代(Redis 2.8):PSYNC - 聪明了
分店:"老板,我学到第 1000 道菜了"
总店:"好,我看看你缺哪些,只教你缺的"
     (部分重同步,只补缺的菜谱)

优点:
• 断线重连只需补缺
• 节省资源
缺点:
• 总店换老板(主从切换)还是要全部重学

第三代(Redis 4.0):PSYNC2 - 最聪明
分店:"老板,我学的是上一任老板的菜谱第 1000 道"
总店:"没事,我记得上一任老板的菜谱,我看看你缺哪些"
     (即使换老板,也能部分重同步)

优点:
• 即使总店换老板也能智能补缺
• 最大化避免全量同步

PSYNC 命令格式

bash 复制代码
# 格式:我学到哪了?
PSYNC <菜谱版本号> <学习进度>

# 第一次加盟(啥都不知道)
PSYNC ? -1
# 菜谱版本号:?(不知道)
# 学习进度:-1(一道菜都没学过)

# 断线重连(知道自己学到哪了)
PSYNC 8e3c9d4f2a1b5c6d 1234567
# 菜谱版本号:8e3c9d4f...(上次的版本)
# 学习进度:1234567(学到第 1234567 道菜)

总店的回应

bash 复制代码
# 回应 1:"太久没联系,全部重学吧"
+FULLRESYNC <新版本号> <起始进度>
# 分店需要清空旧菜谱,接收完整的菜谱大全

# 回应 2:"没事,我看看你缺哪些"
+CONTINUE [版本号]
# 分店继续接收缺的菜谱即可

# 回应 3:"出错了"
-ERR 菜谱版本对不上

PSYNC2 的智慧

Redis 4.0 的 PSYNC2 就像是总店有了"双重记忆":

场景重现

ruby 复制代码
初始状态:张老板当总店老板
Master (张老板, 版本:aaa, 进度:1000)
  ↓ 菜谱同步
Slave (学徒, 版本:aaa, 进度:1000)

突发事件:张老板退休
Master 💥 张老板退休

学徒晋升:学徒变老板
Slave → Master (李老板, 版本:bbb, 进度:1000)
         ↑ 换老板了,菜谱版本号变了

新学徒加盟:
NewSlave: "李老板,我之前跟张老板学到第 900 道菜"
NewSlave → PSYNC aaa 900
           ↑ 报的是张老板的版本号

旧方案的问题:
李老板:"我的版本号是 bbb,不是 aaa"
李老板:"对不上,重新全部学一遍吧"
→ 全量同步 ❌(浪费!明明只差 100 道菜)

PSYNC2 的智慧:
李老板:"我记得张老板(前任)的菜谱版本"
李老板:"版本 aaa 是前任的,你学到 900,我现在进度 1000"
李老板:"来,我只教你第 901-1000 道菜"
→ 部分重同步 ✅(聪明!)

实现原理

c 复制代码
// 总店记住两任老板的信息
struct redisServer {
    char replid[41];    // 现任老板的版本号
    char replid2[41];   // 前任老板的版本号(PSYNC2 新增)
    
    long long master_repl_offset;      // 现在的进度
    long long second_replid_offset;    // 前任交接时的进度
};

// 新学徒请求同步时
if (replid == replid2 && offset >= second_replid_offset) {
    // "你报的是前任老板的版本,没关系,我记得!"
    return CONTINUE;  // 部分重同步
}

复制偏移量:数据的身份证

复制偏移量就像菜谱的页码,精确标识学到哪一页了:

scss 复制代码
┌────────────────────────────────────────┐
│      菜谱学习进度对照表                 │
└────────────────────────────────────────┘

Master (总店)
• master_repl_offset: 1234567
  "我的菜谱已经写到第 1234567 页了"

Slave1 (东城分店)
• slave_repl_offset: 1234567  ✅
  "我也学到第 1234567 页,完全同步!"

Slave2 (西城分店)
• slave_repl_offset: 1234500  ⚠️
  "我才学到第 1234500 页,落后 67 页"

Slave3 (南城分店)
• slave_repl_offset: 1233000  ❌
  "我学到第 1233000 页,落后 1567 页!"

5.4 无磁盘复制

直接快递 vs 先存仓库

想象一下,总店要给分店发菜谱:

lua 复制代码
传统复制(Disk-based)- 先存仓库:
总店老板:
  ① "我先把菜谱整理好"(遍历数据)
  ② "存到仓库"(写 dump.rdb 到磁盘)
  ③"从仓库取出来"(读 dump.rdb)
  ④ "快递发货"(网络传输)

耗时统计:
• 整理:30 秒
• 存仓库:10 秒 ← 多余的步骤
• 从仓库取:5 秒 ← 多余的步骤
• 快递:60 秒
总计:105 秒

无磁盘复制(Diskless)- 直接快递:
总店老板:
  ① "我一边整理,一边就发给你"(边遍历边发送)
  (跳过存仓库的步骤)

耗时统计:
• 边整理边发:90 秒
总计:90 秒(省了 15 秒!)

流程对比图

markdown 复制代码
┌────────────────────────────────────────┐
│    传统 vs 无磁盘:快递方式对比         │
└────────────────────────────────────────┘

传统快递(绕路):
菜谱 → 整理 → 📦 仓库 → 取出 → 🚚 快递 → 分店
              ↑ 多余的中转

直接快递(直达):
菜谱 → 整理 ────────────→ 🚚 快递 → 分店
      ↑ 一气呵成,无中转

配置与使用

bash 复制代码
# redis.conf

# 开启"直达快递"模式
repl-diskless-sync yes  # 默认 no(用仓库)

# 等快递员(等待更多分店一起发货)
repl-diskless-sync-delay 5  # 等 5 秒

# 分店也用"直达收货"(Redis 6.0+)
repl-diskless-load swapdb
# on-empty-db: 只有空店时用
# disabled: 不用
# swapdb: 边收边换(推荐)

"拼单发货"的智慧

makefile 复制代码
时间线:
00:00  分店1:"老板,我要菜谱"
       总店:"好,我等 5 秒,看还有没有其他分店"
       
00:02  分店2:"老板,我也要"
       总店:"好,一起等"
       
00:04  分店3:"老板,我也要"
       总店:"好,马上时间到了"
       
00:05  时间到!
       总店:一次整理,同时发给 3 个分店
       ↓
       节省资源(只整理一次,像拼单快递)✅

适用场景

arduino 复制代码
✅ 适合"直达快递":
• SSD 硬盘(怕磨损)
• 磁盘 IO 慢(仓库太慢)
• 网络很快(快递很快)
• 多个分店同时要(拼单划算)

❌ 不适合"直达快递":
• 机械硬盘(仓库还行)
• 网络很慢(快递太慢)
• 需要存档(要留仓库备份)
• 分店请求分散(拼不了单)

5.5 部分重同步

复制积压缓冲区:备忘录

复制积压缓冲区就像总店老板的"备忘录",记录最近研发的新菜:

yaml 复制代码
┌────────────────────────────────────────┐
│    总店老板的备忘录(环形笔记本)       │
└────────────────────────────────────────┘

Master 的备忘录(默认 1MB)
┌──────────────────────────────────┐
│  📝 最近研发的新菜(循环覆盖)    │
├──────────────────────────────────┤
│ 第 1000 道:宫保鸡丁             │
│ 第 1001 道:鱼香肉丝             │
│ 第 1002 道:麻婆豆腐             │ ← 当前写到这
│ 第 1003 道:...                  │
│ ...                              │
│ (只记录最近的,超过 1MB 就覆盖)  │
└──────────────────────────────────┘
   ↑                          ↑
   最老的记录              最新的记录

作用:
• 分店断线重连,只需补最近缺的菜
• 就像课堂笔记,方便复习

工作原理

c 复制代码
// 环形笔记本
void feedReplicationBacklog(void *ptr, size_t len) {
    // 像记录仪一样,循环覆盖
    while(len) {
        // 写到笔记本末尾了?
        if (server.repl_backlog_idx == server.repl_backlog_size) {
            server.repl_backlog_idx = 0;  // 回到开头继续写
        }
        
        // 记录新菜谱
        memcpy(server.repl_backlog + server.repl_backlog_idx, 
               ptr, thislen);
        
        server.repl_backlog_idx += thislen;
        server.repl_backlog_histlen += thislen;
    }
}

// 就像行车记录仪
// 内存满了从头覆盖
// 只保留最近的记录

触发条件

分店断线重连,什么时候能"只补缺的菜谱"?

arduino 复制代码
分店:"老板,我之前学到第 900 道菜"

总店检查备忘录:
"让我看看我的备忘录..."

┌──────────────────────────────┐
│ 备忘录范围:第 800-1200 道    │
│ 分店进度:第 900 道           │
│ 判断:900 在 [800, 1200] ✅  │
└──────────────────────────────┘

总店:"在备忘录范围内,我只教你第 901-1200 道"
→ 部分重同步 ✅

如果分店说:
"我学到第 500 道菜"

总店:"我的备忘录只记录到第 800 道,500 太久远了"
总店:"你的菜谱太旧了,全部重学吧"
→ 全量同步 ❌

判断公式

c 复制代码
// 能否部分重同步?
bool can_partial_resync = 
    (slave_offset >= backlog_first_offset) &&  // 不太旧
    (slave_offset <= master_offset);           // 不超前

// 用白话说就是:
// "你的进度在我的备忘录记录范围内吗?"

优化策略

1️⃣ 买个大点的备忘录
bash 复制代码
# redis.conf

# 默认备忘录只有 1MB(记不了多少)
repl-backlog-size 1mb

# 增大备忘录
repl-backlog-size 64mb  # 扩大到 64MB

# 怎么算合适的大小?
# 备忘录大小 = 每秒新菜数量 × 预计断线时间

# 例如:
# 每秒研发:1MB 新菜
# 可能断线:60 秒
# 建议大小:1MB × 60 = 60MB
2️⃣ 多给点时间
bash 复制代码
# 电话多久不通就认为分店倒闭?
repl-timeout 60  # 默认 60 秒

# 网络不稳定时,可以放宽
repl-timeout 120  # 120 秒(给分店更多时间)
3️⃣ 保留备忘录
bash 复制代码
# 所有分店都不干了,备忘录保留多久?
repl-backlog-ttl 3600  # 默认保留 1 小时

# 永远保留(万一分店想回来呢)
repl-backlog-ttl 0

5.6 主从延迟

延迟产生原因

主从同步不是瞬间完成的,就像快递需要时间:

scss 复制代码
┌────────────────────────────────────────┐
│      数据同步的"快递过程"              │
└────────────────────────────────────────┘

Master (总店)              Slave (分店)
  │                           │
  │ T0: "研发新菜:水煮鱼"    │
  │   (执行写命令)            │
  │                           │
  │ T1: "发快递" ───────────→ │
  │   (网络传输)              │   🚚 在路上...
  │                           │
  │                           │ T2: "收到快递"
  │                           │   (接收数据)
  │                           │
  │                           │ T3: "学会了"
  │                           │   (执行命令)
  │                           │
  │ 延迟 = T3 - T0 = 150ms   │

延迟的"罪魁祸首"

1️⃣ 快递路上堵车(网络延迟)
复制代码
• 总店在北京,分店在上海(物理距离)
• 网络拥堵(高峰期)
• 网线老化(带宽不足)

解决:换专线、升级带宽
2️⃣ 总店太忙(主节点负载高)
vbnet 复制代码
• 客人太多,订单爆满(写入 QPS 高)
• 有人点了满汉全席(大 Key 操作)
• 老板在数钞票(KEYS * 慢查询)

解决:避免慢命令、控制写入速率
3️⃣ 分店太忙(从节点负载高)
复制代码
• 客人爆满(读 QPS 高)
• 厨师累了(CPU 不足)
• 冰箱坏了(磁盘 IO 慢)

解决:增加服务器资源、关闭持久化
4️⃣ 快递包裹太大(大数据传输)
复制代码
• 一次发 1 吨菜谱(BigKey 写入)
• 装车就要 10 分钟(网络传输慢)

解决:避免 BigKey、拆分数据

监控延迟

就像监控快递进度:

bash 复制代码
# 1. 查看总店的发货记录
127.0.0.1:6379> INFO replication
slave0:ip=127.0.0.1,port=6380,state=online,offset=1234,lag=1
                                                          ↑
                                            延迟 1 秒(快递在路上)

# lag 含义(快递状态):
# • 0 秒:  ✅ 已签收(实时同步)
# • 1-5 秒:📦 在路上(正常延迟)
# • >10秒: ⚠️  堵车了(延迟较大)
# • -1:    💥 快递丢了(连接断开)

# 2. 查看具体差了多少页
master_repl_offset:1234567  # 总店写到第 1234567 页
slave_repl_offset:1234000   # 分店学到第 1234000 页
# 差值:567 页(还有 567 页没学会)

Java 监控示例(像快递追踪系统):

java 复制代码
public class ReplicationMonitor {
    
    /**
     * 监控快递进度(主从延迟)
     */
    public void trackDelivery() {
        Jedis master = new Jedis("127.0.0.1", 6379);
        String info = master.info("replication");
        
        // 解析延迟
        Pattern pattern = Pattern.compile("lag=(\\d+)");
        Matcher matcher = pattern.matcher(info);
        
        while (matcher.find()) {
            int lag = Integer.parseInt(matcher.group(1));
            
            if (lag > 10) {
                // 📢 告警:快递严重延迟!
                alert("🚨 分店菜谱延迟 " + lag + " 秒!客人可能点不到新菜!");
            } else if (lag > 5) {
                // ⚠️  警告:延迟有点高
                warn("⚠️  分店菜谱延迟 " + lag + " 秒,需关注");
            }
        }
    }
}

减少延迟方法

1️⃣ 升级快递方式(优化网络)
bash 复制代码
# 从"普通快递"升级到"同城速递"
• 总店和分店开在同一条街(同机房)
• 使用专车配送(专用网络)
• 升级到顺丰(1Gbps → 10Gbps 带宽)

# 配置优化
repl-disable-tcp-nodelay no  
# 禁用 Nagle 算法(小包裹也立即发,不等拼单)
# 延迟更低 ✅
2️⃣ 总店别太忙(优化主节点)
bash 复制代码
# 让总店专心做菜
• 不要数钞票(避免 KEYS *)
• 不要清理仓库(避免 SMEMBERS bigset)
• 控制接单速度(限流)
• 不做超大订单(避免 BigKey)

# 实战建议
KEYS * → SCAN 0 MATCH pattern   # 边数边做事
DEL bigkey → UNLINK bigkey      # 交给保洁阿姨(异步删除)
3️⃣ 分店轻装上阵(优化从节点)
bash 复制代码
# 分店可以不记账(关闭持久化)
save ""        # 不做 RDB
appendonly no  # 不写日志

# 理由:
# • 总店有账本就够了
# • 分店省时间,服务更多客人
# • 分店挂了大不了重新加盟

# 增加硬件
• 更快的厨师(CPU)
• 更大的厨房(内存)
• 更快的网线(网络)
4️⃣ 质量管控(监控告警)
bash 复制代码
# 设置"分店合格标准"
min-replicas-to-write 1      # 至少 1 个分店正常
min-replicas-max-lag 10      # 延迟不超过 10 秒

# 不达标?停止接单!
# "分店都跟不上了,我先缓缓"
# 主节点拒绝写入,防止延迟继续扩大 ✅

5.7 级联复制

分级管理的艺术

级联复制就像连锁餐饮的"区域经理制":

scss 复制代码
┌────────────────────────────────────────┐
│      级联复制:分级管理模式             │
└────────────────────────────────────────┘

            总部(Master)
            "全国总店"
                │
       ┌────────┼────────┐
       ↓        ↓        ↓
    东区经理  西区经理  南区经理
    (Slave1) (Slave2) (Slave3)
       │
    ┌──┼──┐
    ↓  ↓  ↓
  东城店 东郊店 东北店
 (Slave4)(Slave5)(Slave6)

菜谱流向:
总部 → 东区经理 → 东城店、东郊店、东北店
总部 → 西区经理
总部 → 南区经理

好处:
• 总部只管 3 个区域经理(压力小)
• 区域经理管自己的分店(分层管理)

配置示例

bash 复制代码
# 东区经理(既是分店,也是区域总监)
redis-server --port 6380 --replicaof 127.0.0.1 6379

# 东城店(跟着东区经理学)
redis-server --port 6383 --replicaof 127.0.0.1 6380

# 东区经理的双重身份
127.0.0.1:6380> INFO replication
role:slave                    # 我是总部的分店
master_host:127.0.0.1         # 我的总店是总部
master_port:6379
connected_slaves:3            # 我管着 3 个分店
slave0:ip=127.0.0.1,port=6383,...  ← 东城店
slave1:ip=127.0.0.1,port=6384,...  ← 东郊店
slave2:ip=127.0.0.1,port=6385,...  ← 东北店

优缺点分析

优点

1. 总部压力小

复制代码
不分级(总部直管):
总部
  ├─→ 分店1 (全套菜谱)
  ├─→ 分店2 (全套菜谱)
  ├─→ 分店3 (全套菜谱)
  ├─→ 分店4 (全套菜谱)
  ├─→ 分店5 (全套菜谱)
  └─→ 分店6 (全套菜谱)

总部的工作量:
• 打包 6 次(或等待多个分店一起)
• 发货 6 份
• 管理 6 个分店的联系

分级管理:
总部
  ├─→ 东区经理
  │     ├─→ 分店4
  │     ├─→ 分店5
  │     └─→ 分店6
  ├─→ 西区经理
  └─→ 南区经理

总部的工作量:
• 打包 1-3 次
• 发货 3 份
• 只管 3 个经理

轻松多了!✅

2. 节省跨城带宽

scss 复制代码
跨城场景(像快递跨省):
北京总部                     上海区域
Master ────长途专线─────────→ Slave1 (区域经理)
                               ├─→ Slave4 (上海1店)
                               ├─→ Slave5 (上海2店)
                               └─→ Slave6 (上海3店)

跨城带宽:只占 1 份(省钱!)
同城带宽:占 3 份(便宜!)
缺点

1. 延迟加倍(快递中转)

复制代码
直达:
总部 ────→ 分店4
延迟:100ms

中转:
总部 ────→ 区域经理 ────→ 分店4
   100ms      +100ms
总延迟:200ms(慢了一倍)❌

2. 单点故障(区域经理是关键)

markdown 复制代码
场景:区域经理生病了
总部 (正常营业)
  │
  └─→ 东区经理 💥 生病
        ├─→ 东城店 ❌ 收不到菜谱
        ├─→ 东郊店 ❌ 收不到菜谱
        └─→ 东北店 ❌ 收不到菜谱

一人生病,全区瘫痪 ❌

3. 排查问题麻烦(多层传递)

复制代码
问题:东北店的菜谱不对

可能的原因:
• 总部 → 区域经理 的快递丢了?
• 区域经理 → 东北店 的快递丢了?
• 东北店自己搞错了?

需要逐级排查,像破案 🕵️

使用建议

bash 复制代码
✅ 适合分级管理:
• 跨城部署(北京、上海、广州)
• 分店超多(> 10 个)
• 总部压力大

❌ 不适合分级管理:
• 对延迟很敏感(金融交易)
• 对可靠性要求极高(核心业务)
• 分店很少(< 5 个,直管更简单)

📋 使用建议:
• 最多 2 级(总部 → 经理 → 分店)
• 区域经理配置要好(不能成为瓶颈)
• 做好监控(每一级都要盯着)

5.8 读写分离

实现方案

1️⃣ 客户端自己决定
java 复制代码
// 方案 1:手动选择去哪吃饭
public class RestaurantClient {
    private Jedis master;  // 总店(做菜)
    private List<Jedis> slaves;  // 分店(接客)
    
    // 要点新菜?去总店
    public void orderNewDish(String dishId, String recipe) {
        master.set("dish:" + dishId, recipe);
        System.out.println("新菜在总店研发成功!");
    }
    
    // 吃饭?随便找个分店
    public String getDish(String dishId) {
        // 随机选一个分店
        Jedis slave = slaves.get(random.nextInt(slaves.size()));
        return slave.get("dish:" + dishId);
    }
}
java 复制代码
// 方案 2:用注解标记(更优雅)
@Service
public class UserService {
    
    @Master  // 写菜谱 → 去总店
    public void updateMenu(Dish dish) {
        dishMapper.update(dish);
        System.out.println("✍️  菜谱已在总店更新");
    }
    
    @Slave  // 看菜单 → 去分店
    public Dish getMenu(String dishId) {
        return dishMapper.selectById(dishId);
    }
}
2️⃣ 前台指引(代理)
javascript 复制代码
客户来了:
"您好,请问要做菜还是吃饭?"

客户:"我要点新菜"
前台:"请去总店(Master)"

客户:"我要吃饭"
前台:"请去分店(Slave1/2/3)"

┌────────────────────────────────────────┐
│         Proxy(前台接待员)             │
└────────────────────────────────────────┘

客户
  ↓
Proxy(判断请求类型)
  ├─ 写请求(点新菜)→ Master
  └─ 读请求(吃饭)→ Slave1, Slave2, Slave3

优点:
• 客户不用想(对客户透明)
• 统一管理

缺点:
• 多了个前台(增加延迟)
• 前台也可能下班(单点故障)

数据一致性的陷阱

读写分离有个"时间差陷阱",就像这样:

vbnet 复制代码
┌────────────────────────────────────────┐
│      时间差陷阱:客人的困惑             │
└────────────────────────────────────────┘

场景:用户修改密码

时间线:
T0: 客人:"我要改密码为 new_pass"
    → 总店执行修改 ✅

T1: 总店:"好了,已改"
    → 立即回复客户

T2: 总店:"通知各分店"
    → 开始同步给分店(需要 100ms)

T3: 客人:"我用新密码登录"
    → 请求被路由到分店

T4: 分店:"密码是 old_pass"
    → 返回旧密码 ❌

客人:"???我刚改的密码怎么不对?"
→ 数据不一致问题!

解决方案

方案 1️⃣ 刚写完的去总店看(写后读主)
java 复制代码
public class SmartService {
    // 记录最后一次写的时间
    private ThreadLocal<Long> lastWriteTime = new ThreadLocal<>();
    
    public void changePassword(String userId, String newPass) {
        // 在总店改密码
        masterRedis.set("password:" + userId, newPass);
        
        // 记个时间
        lastWriteTime.set(System.currentTimeMillis());
    }
    
    public String getPassword(String userId) {
        Long writeTime = lastWriteTime.get();
        
        // 刚改完密码(1 秒内)?去总店确认
        if (writeTime != null && 
            System.currentTimeMillis() - writeTime < 1000) {
            // "刚改的,我去总店看看"
            return masterRedis.get("password:" + userId);
        }
        
        // 时间够久了,分店肯定已经更新了
        return slaveRedis.get("password:" + userId);
    }
}
方案 2️⃣ 重要的事去总店(关键操作读主)
java 复制代码
public class PaymentService {
    
    public void pay(Order order) {
        // 支付这么重要,必须去总店查余额!
        Balance balance = masterRedis.get("balance:" + order.getUserId());
        
        if (balance.amount >= order.amount) {
            // 扣款(总店操作)
            balance.amount -= order.amount;
            masterRedis.set("balance:" + order.getUserId(), balance);
            
            System.out.println("💰 支付成功!在总店确认余额,安全!");
        }
    }
}
方案 3️⃣ 强制指定店(用户选择)
java 复制代码
// 让用户自己选
public String getValue(String key, boolean mustGoMaster) {
    if (mustGoMaster) {
        // "我必须去总店"
        return masterRedis.get(key);
    } else {
        // "随便哪个店都行"
        return slaveRedis.get(key);
    }
}
方案 4️⃣ 检查快递进度(延迟检查)
java 复制代码
public String smartGet(String key) {
    int lag = checkReplicationLag();
    
    if (lag > 5) {
        // "分店快递延迟太久,我还是去总店吧"
        return masterRedis.get(key);
    } else {
        // "分店菜谱是新的,可以去"
        return slaveRedis.get(key);
    }
}

最佳实践

java 复制代码
/**
 * 读写分离的"餐饮服务"最佳实践
 */
@Service
public class SmartRedisService {
    
    @Autowired
    private RedisTemplate<String, String> master;  // 总店
    
    @Autowired
    private List<RedisTemplate<String, String>> slaves;  // 分店们
    
    /**
     * 1. 写操作:新菜只在总店研发
     */
    public void write(String key, String value) {
        master.opsForValue().set(key, value);
        log.info("✍️  新菜谱已在总店记录");
    }
    
    /**
     * 2. 读操作:优先去分店,分店关门了再去总店
     */
    public String read(String key) {
        try {
            // 选个分店
            RedisTemplate<String, String> slave = pickRandomSlave();
            return slave.opsForValue().get(key);
        } catch (Exception e) {
            // 分店关门了?没事,总店还在
            log.warn("⚠️  分店出问题了,去总店", e);
            return master.opsForValue().get(key);
        }
    }
    
    /**
     * 3. 强一致性读:重要事务必去总店
     */
    public String readFromMaster(String key) {
        log.info("🎯 重要查询,直接去总店");
        return master.opsForValue().get(key);
    }
    
    /**
     * 4. 负载均衡:客人排队,轮流去不同分店
     */
    private AtomicInteger counter = new AtomicInteger(0);
    
    private RedisTemplate<String, String> pickRandomSlave() {
        int index = counter.getAndIncrement() % slaves.size();
        log.debug("🎲 选择分店 {}", index + 1);
        return slaves.get(index);
    }
    
    /**
     * 5. 智能选择:检查分店菜谱是否够新
     */
    public String smartRead(String key) {
        if (areSlavesHealthy()) {
            log.debug("✅ 分店菜谱都是新的,去分店");
            return read(key);
        } else {
            log.warn("⚠️  分店菜谱太旧了,去总店");
            return readFromMaster(key);
        }
    }
    
    /**
     * 健康检查:分店菜谱够新吗?
     */
    private boolean areSlavesHealthy() {
        int lag = checkReplicationLag();
        return lag < 1;  // 延迟小于 1 秒认为健康
    }
}

生产环境配置

总店配置(Master)

bash 复制代码
# ============ 总店运营手册 ============
# redis-master.conf

# 总店地址(接受全国加盟)
bind 0.0.0.0

# 总店电话
port 6379

# 加盟暗号(防止冒牌货)
requirepass <strong_password>

# 总店记账(持久化)
save 900 1      # 15 分钟改了 1 次就记账
save 300 10     # 5 分钟改了 10 次就记账
save 60 10000   # 1 分钟改了 1 万次就记账
appendonly yes
appendfsync everysec

# 备忘录大小(记录最近的新菜)
repl-backlog-size 64mb
repl-backlog-ttl 3600

# 分店质量管控
min-replicas-to-write 1      # 至少 1 个分店正常
min-replicas-max-lag 10      # 延迟不超过 10 秒

# 快递方式(可选)
repl-diskless-sync yes       # 直达快递
repl-diskless-sync-delay 5   # 等 5 秒拼单

分店配置(Slave)

bash 复制代码
# ============ 分店运营手册 ============
# redis-slave.conf

# 总店地址(认谁当老大)
replicaof 192.168.1.100 6379
masterauth <strong_password>

# 分店只接客(只读)
replica-read-only yes

# 分店密码(如果分店也要密码)
requirepass <strong_password>

# 分店不记账(节省时间接客)
save ""
appendonly no

# 分店优先级(总店倒闭时,谁有资格接班)
replica-priority 100  # 数字越小,优先级越高

# 网络优化(快递加速)
repl-disable-tcp-nodelay no

# 联系超时(多久不通电话算失联)
repl-timeout 60

监控脚本

bash 复制代码
#!/bin/bash
# 📊 餐饮连锁监控系统
# redis-replication-monitor.sh

MASTER_HOST="127.0.0.1"
MASTER_PORT="6379"
MASTER_AUTH="password"

echo "🔍 正在检查各分店状态..."

# 获取总店信息
info=$(redis-cli -h $MASTER_HOST -p $MASTER_PORT -a $MASTER_AUTH INFO replication)

# 有多少个分店?
slave_count=$(echo "$info" | grep "connected_slaves" | awk -F: '{print $2}' | tr -d '\r')

echo "🏪 总共 $slave_count 个分店"

# 检查每个分店的状态
for i in $(seq 0 $(($slave_count - 1))); do
    lag=$(echo "$info" | grep "slave$i" | grep -oP 'lag=\K\d+')
    offset=$(echo "$info" | grep "slave$i" | grep -oP 'offset=\K\d+')
    
    echo "━━━━━━━━━━━━━━━━━━━━━━━━━━"
    echo "分店 $i 状态:"
    echo "  📄 菜谱进度: $offset"
    echo "  ⏱️  延迟: $lag 秒"
    
    # 健康度评估
    if [ "$lag" -eq 0 ]; then
        echo "  ✅ 状态:优秀!实时同步"
    elif [ "$lag" -le 5 ]; then
        echo "  😊 状态:良好"
    elif [ "$lag" -le 10 ]; then
        echo "  ⚠️  状态:注意,延迟有点高"
    else
        echo "  🚨 状态:警告!延迟过高!"
        echo "  🔔 发送告警..."
        # 发送告警通知
        # curl -X POST "https://alert.example.com/api/alert" \
        #      -d "message=分店$i 延迟: $lag 秒,客人可能吃不到新菜!"
    fi
done

echo "━━━━━━━━━━━━━━━━━━━━━━━━━━"

# 检查总店菜谱进度
master_offset=$(echo "$info" | grep "master_repl_offset" | awk -F: '{print $2}' | tr -d '\r')
echo "📚 总店菜谱总进度: $master_offset 页"
echo ""
echo "监控完成 ✅"

常见问题解答

Q1: 主从复制是同步还是异步?

A: 异步的,就像发快递。

arduino 复制代码
总店:"新菜做好了"
总店:"发快递给分店"
总店:"不等分店确认收到,继续做下一道菜"

优点:总店效率高
缺点:可能存在延迟

Q2: 分店可以研发新菜吗?

A: 可以,但不建议。

bash 复制代码
# 分店默认只能接客
replica-read-only yes

# 如果改成 no,分店也能"研发新菜"
replica-read-only no

# 但是!分店的"新菜":
# ❌ 不会同步到总店
# ❌ 不会同步到其他分店
# ❌ 总店同步过来时会被覆盖

# 就像分店私自改菜谱,总部不认可

Q3: 总店倒闭了,分店会自动接班吗?

A: 不会,需要手动或用"经理"(哨兵)。

bash 复制代码
# 手动接班
127.0.0.1:6380> REPLICAOF NO ONE
OK  # "我不认老板了,我自己当老板!"

# 或者用 Redis Sentinel(职业经理人)
# 自动监控,总店倒了立即让分店接班

Q4: 怎么判断总店和分店的菜谱是否一致?

A: 看页码(偏移量)。

bash 复制代码
# 总店菜谱
master_repl_offset:1234567  # 写到第 1234567 页

# 分店菜谱
slave_repl_offset:1234567   # 学到第 1234567 页

# 页码相同 = 菜谱一致 ✅
# 页码不同 = 分店还没学完 ⚠️

Q5: 全量同步会让总店停业吗?

A: 几乎不会,只是开业时顿一下。

bash 复制代码
• fork 子进程:像"开分身",顿一下(200-500ms)
• 整理菜谱:分身干活,总店照常营业
• 发快递:异步发送,总店不等

# 大总店(10GB)fork 可能慢一点
# 但也就是眨眼的功夫(毫秒级)

总结

本文用"餐饮连锁"的比喻,深入浅出地讲解了 Redis 主从复制:

核心概念

  • 🏪 主从复制:总店 + 分店模式
  • 📋 菜谱同步:总店研发,分店学习
  • 🎯 复制偏移量:菜谱的页码

复制流程

  • 🤝 建立连接:7 步握手,从相识到相知
  • 📦 全量同步:打包发货,完整菜谱
  • ➕ 增量同步:实时推送,新菜通知
  • 💓 心跳机制:每秒互报平安

核心技术

  • 📖 PSYNC:智能判断全量还是部分
  • 🎓 PSYNC2:换老板也能智能补缺
  • 📝 复制积压缓冲区:备忘录机制
  • 🚀 无磁盘复制:直达快递,省时间

高级应用

  • 🏗️ 级联复制:区域经理制
  • 📖 读写分离:总店做菜,分店接客
  • ⚠️ 数据一致性:警惕时间差陷阱
  • 📊 性能优化:监控、告警、降级

实战经验

  1. ✅ 备忘录要够大(repl-backlog-size 64mb)
  2. ✅ 时刻监控延迟(lag 监控)
  3. ✅ 分店轻装上阵(关闭持久化)
  4. ✅ 读写分离要注意一致性
  5. ✅ 重要操作去总店(安全第一)

理解主从复制,就像理解连锁经营的奥秘:

  • ✅ 实现高可用(分店顶替)
  • ✅ 提升性能(分流客人)
  • ✅ 数据安全(多处备份)
  • ✅ 灵活扩展(想开几个分店开几个)

💡 下一篇预告:《Redis 哨兵模式详解:职业经理人的自动管理艺术》

当总店倒闭,如何让分店自动接班?哨兵模式就是答案!敬请期待!


相关推荐
ZZHHWW3 小时前
Redis 集群模式详解(上篇)
后端
EMQX3 小时前
技术实践:在基于 RISC-V 的 ESP32 上运行 MQTT over QUIC
后端
程序员蜗牛3 小时前
Java泛型里的T、E、K、V都是些啥玩意儿?
后端
CoderLemon3 小时前
一次因缺失索引引发的线上锁超时事故
后端
ZZHHWW3 小时前
Redis 集群模式详解(下篇)
后端
ZZHHWW3 小时前
Redis 哨兵模式详解
redis·后端
Mintopia4 小时前
🚀 Next.js Edge Runtime 实践学习指南 —— 从零到边缘的奇幻旅行
前端·后端·全栈
紫荆鱼4 小时前
设计模式-备忘录模式(Memento)
c++·后端·设计模式·备忘录模式
程序员爱钓鱼4 小时前
Python编程实战 · 基础入门篇 | 字典(dict)
后端·python·ipython