第 17 章

复制延迟、一致性与数据丢失场景

第17章 复制延迟、一致性与数据丢失场景

Redis 主从复制默认是异步的。这意味着主库确认写入、但从库尚未收到数据的窗口期是客观存在的。本章通过四个典型场景模拟数据丢失,分析 min-replicas-to-writeWAIT 命令、以及复制积压溢出引发的恶性循环,帮助你在生产中精确把握可用性与数据安全的权衡点。


17.1 异步复制的本质

主库收到 SET k v 后的处理顺序:

1. 写入内存(AOF 异步持久化)
2. 返回 +OK 给客户端         ← 客户端认为写入成功
3. 将命令写入 repl_backlog
4. 异步推送给从库(通过 output buffer)
5. 从库执行命令,更新自身 offset

步骤2和步骤5之间存在时间差,这就是复制延迟(replication lag)。正常情况下延迟 <10ms,但在网络抖动、主库高负载或从库处理能力不足时,延迟可达数秒乃至更长。


17.2 数据丢失场景模拟

场景1:主库崩溃 + 从库延迟1秒

时间线:
T=0:    客户端向主库写入 10000 条命令(~50MB)
T=0.5:  主库已写入 10000 条,返回 OK
T=0.5:  从库已同步 7000 条(延迟500ms)
T=1.0:  主库服务器宕机(断电/OOM Kill)
T=1.1:  Sentinel 检测到主库下线
T=30s:  Sentinel 完成故障转移,从库提升为主库
结果:  3000 条命令(T=0.5s时从库落后的那部分)永久丢失

丢失数据量计算

write_rate = 100_000  # 条/秒
lag_seconds = 1.0     # 从库延迟
lost_commands = write_rate * lag_seconds  # 10万条

缓解措施

# 开启 AOF 并设置 fsync=always(性能损失约30%,但不丢数据)
appendonly yes
appendfsync always

# 或者使用 WAIT 命令(见17.4节)

场景2:网络分区 + 脑裂(Split-Brain)

这是最危险的数据丢失场景:

初始状态:
[主库 M] ←→ [从库 S1] ←→ [从库 S2]
[Sentinel1][Sentinel2][Sentinel3]

T=0: 网络分区,M 与 S1/S2/Sentinel 断开
     客户端 Client-A 仍能访问 M(M 在单独分区内)

T=0~30s: Client-A 持续向 M 写入(M 不知道自己已经是"孤岛")

T=30s: Sentinel 判定 M 客观下线,选举 S1 为新主
       Client-A 被通知新主地址,开始向 S1 写入

T=60s: 网络分区恢复,旧 M 重新上线
       旧 M 收到 SLAVEOF S1 的指令,变为从库
       旧 M 与 S1 进行全量同步,丢弃自己在分区期间接收的所有写入

丢失量 = 分区持续时间 × 写入速率

防护配置

# min-replicas-to-write:至少有 N 个从库确认,主库才接受写入
min-replicas-to-write 1

# min-replicas-max-lag:从库延迟不超过 M 秒才算有效
min-replicas-max-lag 10

开启上述配置后,网络分区发生时:

代价:可用性降低(主库无从库时拒绝写入)。

场景3:BGSAVE 期间 COW 导致 output buffer 溢出

T=0:    主库开始 BGSAVE(fork 子进程)
T=0~5s: 大量写入,触发 COW(Copy-on-Write)
        主库内存从 10GB 膨胀到 16GB
        每秒新写入 100MB,从库 output buffer 以 100MB/s 速率增长
T=4s:   从库 output buffer 达到 256MB(hard limit)
T=4s:   主库强制断开从库连接
T=4s:   从库发现连接断开,尝试重连
T=5s:   从库 PSYNC,由于断连期间 backlog 已被覆盖,触发全量同步
T=5s:   主库再次 BGSAVE(还在进行!叠加)
→ 恶性循环

完整解决方案

# 1. 调大 output buffer
client-output-buffer-limit replica 1gb 256mb 300

# 2. 使用无盘复制,减少 BGSAVE 期间的写压力
repl-diskless-sync yes

# 3. 增大 backlog(断连后可部分重同步,避免再次 BGSAVE)
repl-backlog-size 2gb

# 4. 限制 BGSAVE 触发频率
save ""  # 禁用自动 RDB(如果使用 AOF)

# 5. 监控 output buffer
watch -n1 "redis-cli client list | grep replica | awk '{print \$15}'"

场景4:min-replicas-to-write 反向影响可用性

配置:min-replicas-to-write 1,min-replicas-max-lag 10
场景:唯一从库发生延迟(慢查询/网络抖动),lag > 10s

结果:
- 主库拒绝所有写入命令:
  (error) NOREPLICAS Not enough good replicas to write.
- 数据并未丢失,但业务完全不可用

更精细的权衡策略

# 业务分层:
# - 关键数据(余额、订单):WAIT 等待确认
# - 普通数据(日志、统计):异步写入,允许少量丢失

# 关键写入示例:
redis.set("account:balance:1001", 5000)
ack = redis.wait(1, 1000)  # 等待1个从库确认,超时1秒
if ack < 1:
    # 记录警告日志,可能需要补偿
    logger.warning("Replication not confirmed")

17.3 min-replicas-to-write 与 min-replicas-max-lag

参数语义

# redis.conf
min-replicas-to-write 1     # 至少1个从库确认才接受写入
min-replicas-max-lag 10     # 从库延迟不超过10秒才算"有效从库"

主库每秒检查一次从库的 lag 值(通过 ACK 心跳计算)。

判断逻辑

/* replication.c */
int replicationCountAOFAcksByOffset(long long offset) {
    /* 统计 lag <= min-replicas-max-lag 的从库数量 */
    int count = 0;
    listIter li;
    listNode *ln;
    listRewind(server.slaves, &li);
    while((ln = listNext(&li))) {
        client *slave = ln->value;
        if (slave->repl_ack_time < server.unixtime - server.repl_min_slaves_max_lag)
            continue;  /* lag 超限,不计入 */
        count++;
    }
    return count;
}

实际效果验证

# 模拟从库延迟(从库侧)
redis-cli debug sleep 20

# 观察主库(20秒内写入是否被拒绝)
redis-cli set test_key test_value
# (error) NOREPLICAS Not enough good replicas to write.

# 从库恢复后,写入恢复正常

三种配置模式对比

模式 配置 数据安全 可用性 适用场景
纯异步 min-replicas-to-write 0 缓存、可丢失数据
半同步 min-replicas-to-write 1,lag 10 一般业务数据
WAIT强确认 WAIT 2 500 低(有超时) 金融、订单数据

17.4 WAIT 命令:同步等待特定数量从库确认

命令语法

WAIT numreplicas timeout_ms
→ 返回:已确认该命令(及之前所有写入)的从库数量

注意:WAIT 的语义是等待已知偏移量被指定数量的从库确认,而不是等待某个具体命令被确认。

工作原理

1. WAIT 发出时,主库记录当前 master_repl_offset(即 X)
2. 主库向所有从库发送 REPLCONF GETACK *(要求从库立即上报 offset)
3. 主库等待,直到有 numreplicas 个从库上报 offset >= X
4. 超时或满足条件后返回

使用示例

import redis

r = redis.Redis(host='master_ip', port=6379)

# 关键写入
pipe = r.pipeline()
pipe.set("account:balance:1001", 5000)
pipe.set("order:status:9999", "paid")
pipe.execute()

# 等待至少1个从库确认,最多等待500ms
ack_count = r.wait(1, 500)

if ack_count >= 1:
    print(f"Write confirmed by {ack_count} replica(s)")
else:
    print("WARNING: Replication not confirmed within timeout")
    # 根据业务需要决定是否回滚或告警

WAIT 返回值含义

WAIT 1 0   → 返回 0:没有从库确认(timeout=0表示立即返回当前状态)
WAIT 1 500 → 返回 1:在500ms内1个从库确认
WAIT 2 500 → 返回 1:只有1个从库确认(不足2个,但不报错)

返回值 小于 numreplicas 不是错误,只是告知实际确认数量。业务层需要自行判断是否满足要求。

WAIT 的性能影响

# 基准测试:WAIT vs 不等待
redis-benchmark -n 10000 -c 50 -P 16 SET key value
# 吞吐量:约 200,000 ops/sec

redis-benchmark -n 10000 -c 1 --wait 1 500 SET key value
# 吞吐量:约 5,000 ops/sec(串行等待,性能大幅下降)

# 建议:仅对关键写入使用 WAIT,批量使用 Pipeline+WAIT

17.5 复制延迟监控

INFO replication 详解

redis-cli info replication

输出示例:

# Replication
role:master
connected_slaves:2
slave0:ip=192.168.1.2,port=6380,state=online,offset=1234560,lag=0
slave1:ip=192.168.1.3,port=6381,state=online,offset=1234100,lag=1
master_replid:8371b4fb1155b71f4a04d3e1bc3e18c4a990aeeb
master_repl_offset:1234567

关键字段说明:

精确延迟计算

master_offset=$(redis-cli -h master info replication | grep master_repl_offset | cut -d: -f2 | tr -d '\r')
slave_offset=$(redis-cli -h slave info replication | grep master_repl_offset | cut -d: -f2 | tr -d '\r')
lag_bytes=$((master_offset - slave_offset))
echo "Replication lag: ${lag_bytes} bytes"

Prometheus 监控指标

# redis_exporter 自动暴露以下指标:
redis_replication_lag{addr="slave1:6380"} 0        # 秒级延迟
redis_connected_slaves 2                            # 从库数量
redis_repl_backlog_histlen 536870912               # backlog 使用量(bytes)

# 推荐告警规则:
- alert: RedisReplicationLag
  expr: redis_replication_lag > 30
  for: 1m
  labels:
    severity: warning
  annotations:
    summary: "Redis replication lag > 30s on {{ $labels.addr }}"

- alert: RedisReplicationBacklogFull
  expr: redis_repl_backlog_histlen / redis_repl_backlog_size > 0.9
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "Redis replication backlog nearly full (>90%)"

实时监控脚本

#!/bin/bash
# monitor_replication.sh
MASTER="192.168.1.1:6379"
SLAVES=("192.168.1.2:6380" "192.168.1.3:6381")

while true; do
    master_offset=$(redis-cli -h ${MASTER/:*/} -p ${MASTER/*:/} info replication \
        | grep master_repl_offset | cut -d: -f2 | tr -d '\r')

    echo "=== $(date) ==="
    echo "Master offset: $master_offset"

    for slave in "${SLAVES[@]}"; do
        host=${slave/:*/}
        port=${slave/*:/}
        slave_offset=$(redis-cli -h $host -p $port info replication \
            | grep master_repl_offset | cut -d: -f2 | tr -d '\r')
        lag_bytes=$((master_offset - slave_offset))
        echo "Slave $slave: offset=$slave_offset, lag=${lag_bytes} bytes"
    done
    sleep 1
done

17.6 复制积压溢出触发全量重同步的解决路径

问题诊断

# 从库日志(出现以下内容表示触发全量同步)
redis-cli monitor 2>/dev/null | grep -i "fullresync\|sync\|slave"

# 主库日志关键词:
# "Starting BGSAVE for SYNC with target: disk"
# "Starting BGSAVE for SYNC with target: slaves sockets"
# "Synchronization with slave 192.168.1.2:6380 succeeded"

防止积压溢出的配置矩阵

写入速率 可接受断连时间 建议 backlog 大小
<10 MB/s 30s 1GB
10-50 MB/s 30s 3GB
50-100 MB/s 60s 12GB
>100 MB/s 考虑 Cluster 分片 N/A

紧急处置流程

当发现从库频繁全量同步时:

# Step 1: 临时增大 backlog(不重启)
redis-cli -h master config set repl-backlog-size 2147483648  # 2GB

# Step 2: 临时增大 output buffer(不重启)
redis-cli -h master config set client-output-buffer-limit \
    "replica 1gb 256mb 300"

# Step 3: 确认从库已稳定(lag 趋近于0)
watch -n2 "redis-cli -h master info replication | grep slave"

# Step 4: 将配置写入 redis.conf(持久化)
redis-cli -h master config rewrite

17.7 主从一致性模型总结

Redis 主从复制提供的是最终一致性(Eventual Consistency)

正常情况:延迟 <10ms,几乎是强一致
抖动情况:延迟 100ms~1s,短暂不一致
极端情况:断连数秒,数据可能永久丢失

CAP 定理定位:
- 主从模式(哨兵):CP 倾向(故障转移期间拒绝写入)或 AP 倾向(取决于配置)
- min-replicas-to-write=0:AP(优先可用性)
- min-replicas-to-write=1:CP(优先一致性,牺牲部分可用性)

从库读取一致性保障

# 问题:写主库后立刻读从库,可能读到旧值
redis_master.set("user:1:name", "Alice")
# 此时从库可能还是旧值
name = redis_slave.get("user:1:name")  # 可能是 None 或旧值

# 方案1:写后读走主库
name = redis_master.get("user:1:name")  # 强一致

# 方案2:写后等待(WAIT)再读从库
redis_master.set("user:1:name", "Alice")
redis_master.wait(1, 100)
name = redis_slave.get("user:1:name")  # 99%概率已更新

# 方案3:读写分离时接受短暂不一致(适合缓存场景)

本章小结

场景 根因 防护手段
主库宕机丢最近写入 异步复制延迟 AOF fsync=always 或 WAIT
网络分区脑裂 孤岛主库持续接受写入 min-replicas-to-write
output buffer 溢出恶性循环 BGSAVE COW + 积压 调大 buffer + diskless + 大 backlog
min-replicas 配置拒写 从库延迟超阈值 监控 lag,优化从库性能

Redis 异步复制的数据安全与可用性是一个持续权衡的过程。第18章将进入哨兵模式,深入分析 failover 状态机与 Raft 选主协议。

本章评分
4.9  / 5  (15 评分)

💬 留言讨论