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 哨兵模式详解:职业经理人的自动管理艺术》

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


相关推荐
喵个咪16 小时前
实时游戏网络协议深度对比:KCP vs WebRTC vs WebSocket
后端·websocket·webrtc
普通网友16 小时前
springboot之集成Elasticsearch
spring boot·后端·elasticsearch
QuZero16 小时前
Guava Cache Deep Dive
java·后端·算法·guava
leeyi17 小时前
SSE 实时推流 —— Token 怎么一个个蹦出来
后端·agent
leeyi17 小时前
ReAct 循环的 50 行 Go 实现,逐行拆解
后端·agent
leeyi17 小时前
HITL:让人类随时叫停 AI,并且能优雅地继续
后端·agent
用户342323237631717 小时前
采集网关的离线缓存与断点续传——当网络不可靠时,数据一条都不能丢
后端
用户9168422027417 小时前
Spring Boot application.yml 最全避坑与多环境配置
java·后端
fliter17 小时前
深入理解 Rust Futures:从零开始,一头扎到底
后端
前端的阶梯17 小时前
Cursor 开发 Python 项目完全指南
前端·人工智能·后端