线上那套缓存实例从 8G 内存一路涨到接近 40G 的过程中,我先后被三个问题逼到墙角:主库做一次全量同步把磁盘 IO 打满,业务侧 P99 直接翻了三倍;从库扩容时六台从库同时重连,复制风暴把主库拖到几乎不响应;还有一次主库所在的机器宕机,客户端硬是等了四十多秒才恢复写入。这三个问题最后都指向同一套东西——Redis 主从架构的工作原理、psync 与 bgsave 的协作方式、部分数据复制的判定逻辑、复制风暴的成因与解法,再加上 Sentinel 哨兵高可用架构的故障转移流程。把这套东西吃透之后,我发现还有两个被严重低估的工具:管道(pipeline)和 Lua 脚本,它们能在不改架构的前提下把往返开销砍掉一个数量级。这篇内容适合已经会用 Redis、但对其复制链路只停留在“配个 replicaof 就完事”阶段的朋友,也适合正在做缓存高可用方案、需要向团队解释“为什么扩容从库会拖垮主库”的同学。我会按设计思路、核心机制、实操配置、故障排查四条线往下拆,参数尽量给到可抄的数值,坑尽量讲清楚来龙去脉。
1. 主从架构的整体设计与选型考量
1.1 单机为什么撑不住:三个维度的真实瓶颈
很多人上主从的第一反应是“读多写少,加从库分担读”,这个动机没错,但它只覆盖了三分之一的现实需求。我在实际项目里总结下来,单机 Redis 撑不住通常是三个维度同时告急,而且它们的优先级排序跟业务阶段强相关。
第一个维度是读吞吐。单实例的读 QPS 受限于单线程事件循环和网络往返,业务量涨上来之后,一台机器再垂直扩也很难顶住峰值,横向加从库是最直接的解法。第二个维度是故障恢复时间。单机没有冗余,进程挂掉或者宿主机重启,从发现问题到人工介入、拉起实例、预热缓存,中间这段时间业务的读请求会全部压到数据库上,很容易形成雪崩。主从加哨兵能把这段不可用时间从“分钟级”压到“秒级”,这是可用性层面的刚需。第三个维度是运维窗口。没有从库,你想做一次大版本升级、改一个核心参数、甚至只是打个补丁重启,都得挑凌晨低峰期;有了从库,可以先把从库升级验证,再执行一次手动切换,主库的停机时间几乎为零。
这三个维度对应三种不同的架构诉求,搞混了就会做出错误决策。比如只为了读扩展,一主三从足够,压根不需要哨兵;但如果目标是降低故障恢复时间,那哨兵或者集群是绕不开的。我见过有团队为了“高可用”硬上了六台从库,结果主库每次全量同步都像做一次心脏手术,可用性反而下降了。
1.2 一主多从与树状复制的取舍逻辑
拓扑结构的选择,核心是看“主库能不能承受复制带来的额外开销”。一主多从是最简单的模型:所有从库直接挂主库,主库负责把写命令广播给每一个从库。它的优点是链路短、延迟低、状态好观测,主库上INFO replication一眼就能看到所有从库的偏移量和连接状态。缺点是主库的出口带宽和 CPU 都要乘以从库数量,从库越多,主库越累。
树状复制(也叫级联复制)是针对这个问题的直接解法:让一部分从库挂主库,另一部分从库挂在这些从库下面,形成两层甚至三层结构。主库只需要向第一层从库推送数据,第二层的流量由第一层承担。听起来很美好,但代价是复制延迟逐层放大。第一层从库比主库晚 10ms,第二层就可能晚 20 到 30ms,如果业务对读到最新数据很敏感,级联层数就绝不能深。
我的经验是:从库数量在三台以内,一律一主多从;超过四台且主库网络出口已经成为瓶颈,再考虑两层树状;三层以上基本不要碰,除非你的业务能容忍百毫秒级的读延迟。还有一个容易被忽略的点——级联复制下如果中间层从库挂掉,它下面的所有从库都会断流,故障半径反而变大了,所以中间层必须配置哨兵监控。
1.3 复制拓扑的容量与带宽测算
拍脑袋决定从库数量是很多线上事故的起点。真正做方案时我会先做一轮测算,把几个关键量算出来,再决定规模。
首先是写入速率。通过INFO stats里的instantaneous_input_kbps可以拿到当前主库的入流量,但更准的做法是看INFO replication中主库的master_repl_offset随时间的变化量,用两次采样的差值除以时间间隔,得到的就是每秒复制的字节数。假设算下来是 2MB/s,那么每增加一台从库,主库出口就要多承担 2MB/s 的持续带宽,六台就是 12MB/s,约等于 96Mbps,这还没算 RDB 全量传输时的峰值。
其次是全量同步的峰值代价。全量同步期间,主库要 fork 出子进程做 bgsave,同时把这段期间的写命令攒在内存里。如果 RDB 生成加传输花了 30 秒,而这 30 秒内写了 60MB 数据,那么这 60MB 会完整地驻留在复制缓冲区里,直到从库加载完 RDB 才开始消费。这个数值乘以同时重连的从库数量,就是主库可能面临的额外内存压力。
最后是磁盘与 fork 的承受能力。bgsave 会 fork 一个子进程,fork 的耗时与内存页表大小正相关,40G 的实例 fork 一次可能有几十毫秒甚至上百毫秒的卡顿。这个卡顿直接体现在主库的命令延迟上,是可以被业务感知到的。所以我的判断标准是:单实例内存超过 32G 就要认真评估 fork 的影响,超过 64G 就倾向于拆分实例而不是继续加大。
| 评估项 | 观测方式 | 经验阈值 | 超阈值后的动作 |
|---|---|---|---|
| 主库写入速率 | master_repl_offset 两次采样差值 | 单从库超过 5MB/s | 考虑树状复制或拆分实例 |
| 从库数量 | INFO replication 中 connected_slaves | 超过 4 台 | 引入中间层从库 |
| 单实例内存 | INFO memory 中 used_memory | 超过 32G | 评估分片或垂直收缩 |
| 全量同步耗时 | 从库日志中同步起止时间 | 超过 60 秒 | 启用无盘复制并调整延迟 |
| 复制延迟 | 主从 offset 差值 | 持续超过 1MB | 检查网络与从库负载 |
这张表我在多个项目里用过,它不能代替压测,但能快速把明显不合理的方案挡在评审阶段。
2. 复制链路的核心机制:psync、bgsave 与部分重同步
2.1 一次全量同步的完整时序拆解
很多人对“主从同步”的理解停留在“主库把数据发给从库”,但真正出问题时,你需要知道每一段在干什么、哪一段最慢、哪一段最吃内存。我把一次完整的全量同步拆成六个阶段,逐个说。
阶段一,从库发起握手。从库配置了replicaof之后,会向主库建立连接,先做一次 PING 探活,然后发送AUTH(如果配了密码)、REPLCONF listening-port、REPLCONF capa eof capa psync2等协商命令,最后发送PSYNC <runid> <offset>。首次连接的从库发的是PSYNC ? -1,意思是“我没有主库的 runid,也没有偏移量,请给我全量”。
阶段二,主库回复 FULLRESYNC。主库收到PSYNC ? -1后,回复+FULLRESYNC <replid> <offset>,把当前的复制 ID 和主库的写偏移量告诉从库。从这一刻起,从库就记住了自己要以这个 offset 为起点接收增量数据。
阶段三,主库 fork 并执行 bgsave。主库 fork 出子进程把当前内存快照写成 RDB 文件。注意,fork 之后主库本身并不阻塞(除了 fork 那一瞬间的页表拷贝),它继续处理业务写命令,同时把这些写命令追加到一个叫replication buffer的缓冲区里,每个从库一份。
阶段四,RDB 传输。子进程写完后,主库把 RDB 文件通过 socket 发给从库。这个阶段最吃网络带宽,如果 RDB 有 10G,千兆网卡跑满也要 80 秒以上。磁盘复制模式下主库还要额外承担一次写盘和读盘的 IO。
阶段五,从库清库并加载 RDB。从库收到 RDB 后,会先清空自己现有的全部数据,再加载 RDB。这一步是很多人踩坑的地方——如果你把一个已经有数据的实例配成了别人的从库,它的原有数据会全部消失,而且这个过程没有任何二次确认。
阶段六,回放缓冲区。RDB 加载完成后,从库向主库报告加载完毕,主库开始把 replication buffer 里攒的写命令逐条发给从库。从库执行完这些命令,主从数据才真正一致。这之后进入命令传播阶段,主库每执行一条写命令就异步发给所有从库。
这六个阶段里,耗时最长的是阶段三和四,内存压力最大的是阶段三到五之间(buffer 持续增长),最容易出事的是阶段五(数据被清空)。理解了这张时序图,后面所有关于复制风暴和部分重同步的讨论才有落脚点。
2.2 repl_backlog 与部分重同步的判定逻辑
全量同步的代价太大了,所以 Redis 设计了部分重同步(partial resynchronization):从库短暂断线重连后,只要断线期间主库产生的写命令还在缓冲区里,就只补发这一小段,不用重来一遍全量。这个缓冲区就是repl_backlog_buffer。
它的结构是一个固定大小的环形缓冲区,主库持续往里面写,写满之后覆盖最老的数据。关键参数是repl-backlog-size,默认 1MB,这个默认值在生产环境里基本等于没有——稍微有点写入量,1MB 不到一秒就被冲掉了。
断线重连时的判定逻辑是这样的:从库发送PSYNC <replid> <offset>,主库先检查 replid 是否匹配,再检查从库的 offset 是否落在 backlog 的有效范围内(具体是从repl_backlog_off到master_repl_offset之间)。如果落在范围内,回复+CONTINUE,执行部分重同步;如果 offset 已经被覆盖,回复+FULLRESYNC,退化成全量同步。
所以repl-backlog-size的大小直接决定了“断线多久之内还能救回来”。计算公式很简单:
最小 backlog 大小 = 主库平均写入速率 × 期望容忍的断线时长 × 安全系数举个例子,主库写入速率 3MB/s,希望网络抖动或从库重启在 60 秒内恢复都能走部分重同步,安全系数取 2,那就是 3 × 60 × 2 = 360MB。这个值明显比默认的 1MB 大得多,但它是实打实换来的:一次全量同步的成本(fork 卡顿 + 磁盘 IO + 网络带宽 + 从库清库)远超这几百兆内存。
这里有个细节值得单独提一句:repl-backlog-size是所有从库共享一份,不是每个从库一份。所以不要按从库数量去乘。而 replication buffer 才是每个从库独立的,它的上限由client-output-buffer-limit replica控制,默认是256mb 64mb 60,意思是硬上限 256MB、软上限 64MB 持续 60 秒,超过就断开该从库。这个参数如果配得太小,从库加载大 RDB 期间缓冲的命令一旦超限就会被踢掉,然后重连、再全量、再被踢,形成恶性循环。
2.3 bgsave 的隐藏成本:fork、COW 与内存放大
bgsave 表面上只是“生成一个 RDB 文件”,实际上它对主库有三重冲击,而且这三重冲击的大小都和实例内存规模正相关。
第一重是 fork 的停顿。Redis 是单线程处理命令的,fork 系统调用会暂停主线程去复制父进程的页表。这个时间不取决于数据量本身,而取决于页表项的数量——内存越大、页表越长,耗时越久。在几十 G 的实例上,fork 一次耗时几十毫秒很常见,这个期间所有客户端命令都在排队。
第二重是写时复制(COW)带来的内存放大。fork 之后父子进程共享物理内存页,只有当父进程要修改某一页时,才复制一份新的。所以主库在 bgsave 期间写入越猛,被复制的页就越多,额外占用的内存就越多,最坏情况接近翻倍。如果你的实例已经用掉了物理内存的 70%,再叠加一次 COW 就极容易触发 OOM,被系统的内存杀手干掉。
第三重是磁盘 IO 的争抢。如果 RDB 落在和数据文件、日志同一个盘上,bgsave 期间的顺序写会严重干扰正常 IO,进而拖慢 AOF 重写等其它后台任务。
对应的处理手段我一般这么配:vm.overcommit_memory设成 1,避免 fork 时系统因为“看起来内存不够”而拒绝;save策略在大实例上要不就关掉、要不就把触发阈值调高,让持久化主要靠 AOF;开启repl-diskless-sync yes走无盘复制,子进程直接把 RDB 通过 socket 发给从库,省掉一次落盘再读盘的往返,同时配repl-diskless-sync-delay 5,允许主库等几秒钟把多个同时到达的从库合并成一次 fork 一起服务——这个参数是复制风暴的关键武器之一。
2.4 复制风暴的成因与四种解法
复制风暴是主从架构里最典型的“规模副作用”。它的典型场景是:主库重启、或者某个机房网络抖动,挂了六台从库,网络恢复后六台从库几乎同时重连。假如每台从库的 offset 都已经落到 backlog 之外(比如主库刚重启,backlog 是空的),那么六台全部走全量同步。主库瞬间面临六次 RDB 生成请求、六份 buffer 积压、十二倍的网络流量,直接把主库打瘫,从库又断线,又重连,形成死循环。
Redis 本身的保护机制是同一时刻主库只允许一个 bgsave 子进程在跑,其它从库的同步请求要排队等待。这避免了六个 RDB 同时生成的极端情况,但排队本身又拉长了每台从库的同步时间,同步时间越长,buffer 里积压的写命令越多,内存压力越大,一样会出问题。
我的解法是四个层面组合使用,从易到难排列:
第一层,把 backlog 调大。这是性价比最高的。如果大部分“断线”其实只是网络闪断或者从库进程短暂重启,几十秒内就恢复了,那只要 backlog 能覆盖这段时间,压根走不到全量同步那一步。实践中我会把repl-backlog-size设到 256MB 到 1GB 之间,具体按上面公式算。
第二层,开无盘复制并加延迟合并。repl-diskless-sync yes加上repl-diskless-sync-delay 5,主库在收到第一个从库的同步请求后会等 5 秒,把这段时间内到达的其它从库合并到同一次 fork 中,一次生成、多路分发。六台从库同时重连的成本从六次 fork 降到一次。如果从库数量更多,延迟可以调到 10 秒,代价是第一个从库多等几秒。
第三层,改成树状拓扑。六台从库不要都挂主库,改成两台挂主库,剩下四台挂在这两台下面。主库的出口流量和 fork 压力直接减半甚至更多。这个方案要配合哨兵使用,因为中间层从库一旦故障,下游会连锁断流。
第四层,错峰同步。这是最土但最有效的办法。从库扩容、机房重启这类可预期的批量操作,不要同时做,用脚本控制每隔几分钟加一台。我在一次扩容里把八台从库分成了四批,每批间隔三分钟,主库的 CPU 曲线从头到尾都是平滑的,没有任何毛刺。
注意:
repl-diskless-sync-delay生效的前提是无盘复制已开启,而且这个延迟期间主库会阻塞新到的同步请求,所以不要把延迟设得过大,否则单个从库的同步开始时间会被无限推迟。
3. 主从搭建实操与参数调优
3.1 最小可用的主从配置
先给一套能直接跑起来的最小配置,都是配置文件层面的,不依赖命令行replicaof。用配置文件的好处是重启后拓扑自动恢复,不会被忘掉。
主库侧需要关注的几项:
# 主库 redis.conf 关键片段 bind 0.0.0.0 protected-mode yes requirepass your_password # 复制相关 repl-backlog-size 512mb repl-backlog-ttl 3600 repl-diskless-sync yes repl-diskless-sync-delay 5 repl-diskless-load on-empty-db # 从库输出缓冲上限,防止全量同步期间被挤爆 client-output-buffer-limit replica 1gb 512mb 120 # 减少不必要的全量同步触发 replica-serve-stale-data yes replica-read-only yes从库侧只需要三行核心配置:
# 从库 redis.conf 关键片段 replicaof 10.0.0.11 6379 masterauth your_password replica-read-only yes # 建议顺手打开,5.0+ 支持 replica-announced yes这里有几个容易踩坑的细节。masterauth一定要配,否则从库连接主库时认证失败会一直重连;replica-read-only yes是默认值,但有些团队为了“灵活”把它关了,结果有人在从库上写了数据,主从数据出现分叉,最后只能整库重建。repl-backlog-ttl决定主库在没有从库连接时多久释放 backlog,如果网络分区时间可能超过这个值,要适当调大。
3.2 关键参数逐个拆解与计算过程
参数不能抄,得知道每个值是怎么来的。我挑四个最关键的讲。
repl-backlog-size。前面给过公式了,这里补一个更实用的估算方法:观察INFO replication里从库的slave_repl_offset和主库master_repl_offset的差值,这个差值就是当前的积压量。连续采样一段时间的业务高峰,取最大积压量再乘以三到五倍,就是比较稳妥的 backlog 大小。我通常不愿意低于 128MB。
client-output-buffer-limit replica。这个值的算法是“全量同步耗时 × 写入速率”。假设 RDB 传 60 秒,期间写入 50MB,那软上限至少要能覆盖这个量,留一倍余量就是 100MB 起步。默认的 64MB 软上限在中等规模的实例上往往不够,会直接把从库踢下线。我一般设成1gb 512mb 120,宁可浪费一点内存也不要反复断连。
repl-timeout。默认 60 秒,含义是主从之间多久没有有效交互就判定超时。如果从库在做一次很慢的 RDB 加载,主库会通过心跳维持连接,一般不会误判。但如果网络延迟本身很大(跨机房),60 秒可能不够,要往上调。反过来,如果在同机房,调小一点能更快发现死连接。跨机房场景我会设到 180 秒。
repl-ping-replica-period。主库多久给从库发一次心跳,默认 10 秒。这个值不需要频繁改动,但如果你的网络监控依赖心跳来做健康判断,可以适当调小到 5 秒。
| 参数 | 默认值 | 生产建议值 | 依据 |
|---|---|---|---|
| repl-backlog-size | 1mb | 128mb~1gb | 写入速率 × 断线容忍时长 × 安全系数 |
| client-output-buffer-limit replica | 256mb 64mb 60 | 1gb 512mb 120 | 全量同步耗时 × 写入速率 |
| repl-timeout | 60 | 同机房 60、跨机房 180 | 网络 RTT 与 RDB 加载时长 |
| repl-diskless-sync-delay | 5 | 5~10 | 从库数量与重连并发度 |
| replica-priority | 100 | 按机房/规格差异化设置 | 用于哨兵选主优先级 |
3.3 如何验证复制状态与观测延迟
配好之后不能只看“状态是 up”就完事,得有一套能持续观测的指标。我在运维面板上固定放四个数:主库的connected_slaves、每个从库的slave_repl_offset与主库master_repl_offset的差值、从库的master_link_status、以及主库的master_repl_offset增长速率。
延迟的推算方法很直接:拿主库的 offset 减去从库的 offset,得到的就是从库落后的字节数。再除以主库的写入速率,就换算成了时间。比如落后 3MB,写入速率 3MB/s,那么从库大约落后 1 秒。这个换算比看INFO replication里没有的“延迟秒数”靠谱得多。
验证部分重同步是否真的生效,最直接的办法是看从库日志:如果断线重连后日志里出现Partial resynchronization request from ... accepted,说明走了增量;如果出现Full resynchronization或者MASTER <-> REPLICA sync started后跟完整的 RDB 传输日志,说明退化了。我每次调完 backlog 参数都会人为kill -9一次从库进程再拉起来,看看日志走向,确认参数真的起作用。
4. Sentinel 哨兵高可用架构
4.1 哨兵到底是什么:三个定时任务构成的监控网
Servlet 这个词我刚接触时一直有误解,以为它是个“代理”或者“中间件”,实际上哨兵就是一堆特殊的 Redis 实例,以--sentinel模式启动,不存业务数据、不做持久化,只干一件事:持续监视主从拓扑,在需要的时候发起主从切换。
它靠三个周期性任务维持这套监控。每 10 秒向所有被监视的主库和从库发送INFO命令,从返回结果里解析出拓扑结构——这个主库下面挂了几个从库、从库的地址端口是多少、谁是谁的下游。每 2 秒通过主库和从库上的一个特殊频道__sentinel__:hello交换消息,把“我监视了哪些实例、我的判断是什么、我的配置版本号是多少”广播出去,其它哨兵收到后就能拼出完整的哨兵集群视图,也能发现新加入的哨兵。每 1 秒向所有实例(包括主库、从库、其它哨兵)发PING,用这个做存活探测。
这三个任务的周期不是随便定的。10 秒的 INFO 保证了拓扑变化能在十几秒内被发现,2 秒的 hello 保证了哨兵之间的信息同步足够快,1 秒的 PING 保证了下线判断的灵敏度。理解了这三个周期,你就能明白为什么down-after-milliseconds至少要大于 1000——它必须能覆盖若干个心跳周期才不至于误判。
4.2 主观下线、客观下线与领头哨兵的选举
故障转移是一套需要多个哨兵协同才能完成的流程,单个哨兵自言自语是没有意义的。整个链条分四步走。
第一步,主观下线(sdown)。某个哨兵连续down-after-milliseconds时间内没收到主库的有效 PING 回复,就把它标记为“主观下线”。注意这只是单个哨兵的私人判断,可能是它自己网络有问题,也可能是主库真挂了,还不足以下结论。
第二步,客观下线(odown)。该哨兵通过 hello 频道询问其它哨兵:“你们觉得这个主库挂了吗?”如果认为挂了的哨兵数量达到 quorum(多数派),就把主库标记为“客观下线”。quorum 是配置里的sentinel monitor <name> <ip> <port> <quorum>,比如三哨兵部署、quorum 设 2,那么至少两个哨兵都认为主库挂了才算数。这一步的设计意图很清楚:用多数派投票过滤掉单个哨兵的网络抖动。
第三步,选举领头哨兵。确认客观下线后,需要有一个哨兵出来主持切换。这个过程基于类似 Raft 的投票机制:每个认为主库客观下线的哨兵都可以发起投票,向其它哨兵请求“投我一票”,获得**多数派(majority,通常是哨兵总数的一半加一)**票数的哨兵当选。这里要特别注意,quorum 和 majority 是两个不同的数。三哨兵部署、quorum 设 2 时,majority 也是 2;但五哨兵部署、quorum 设 2 时,majority 是 3,意味着光有两个哨兵觉得主库挂了还不够,还得至少三个哨兵同意由某个哨兵来主持切换。
第四步,执行主从切换。领头哨兵从所有从库中挑一个当新主,选择依据依次是:先排除主观下线、断线时间过长、优先级为 0 的从库;然后比较replica-priority,越小优先级越高;优先级相同时比较复制偏移量,offset 越大说明数据越新;还相同时比较 runid,选字典序最小的。选出新主后,领头哨兵向它发送SLAVEOF NO ONE让它升主,再向其它从库发送SLAVEOF <新主>让它们改挂,最后把旧主也降级为新主的从库。旧主恢复后会被自动接入,不需要人工干预。
注意:旧主降级时会先被标记,恢复后如果它的数据比新主更新(因为网络分区导致的),这部分数据会被直接丢弃。异步复制的数据一致性边界就在这里。
4.3 哨兵部署实操与客户端接入
哨兵的部署有两个硬性要求:数量必须是奇数且至少三个,必须分散在不同的物理故障域。两个哨兵的部署是最危险的配置,因为 quorum 只能是 1 或者 2,无论怎么设,一旦其中一个挂掉或者网络分区,投票都无法达成多数派,切换能力直接归零。三哨兵是最小可用配置,五哨兵适合对可用性要求更高的场景。
每台哨兵的配置大致如下:
# sentinel.conf 关键片段 port 26379 daemonize yes logfile "/var/log/redis/sentinel.log" sentinel monitor mymaster 10.0.0.11 6379 2 sentinel auth-pass mymaster your_password sentinel down-after-milliseconds mymaster 30000 sentinel failover-timeout mymaster 180000 sentinel parallel-syncs mymaster 1sentinel parallel-syncs是切换后允许同时向新主发起同步的从库数量,设成 1 意味着切换完成后从库一台接一台地改挂,避免新主刚上位就被多路全量同步打垮。这个参数在从库多的时候尤其重要,设成 1 会让切换整体时间变长,但换来的是切换后新主的稳定。
客户端接入这块,现在主流客户端库都支持“哨兵模式”,你给它一个哨兵地址列表,它会自动向哨兵询问当前主库的地址并建立连接,同时在切换后重新询问。这里有一个必须处理的问题:切换期间客户端会收到连接错误或者写只读错误,如果业务代码没有重试逻辑,就会把这部分请求直接报错给用户。我的做法是给客户端配上三到五次重试、指数退避,并且把“写只读错误”单独识别出来立即重试,因为这类错误在切换窗口内是预期内的。
还有个小坑要提一句:哨兵配置里不要用域名,一律用 IP。哨兵对主机名的解析行为在不同版本间有差异,出过因为 DNS 缓存导致哨兵连到旧地址的事故。
4.4 切换期间的数据丢失边界与缓解手段
哨兵解决的是“可用性”,不是“数据零丢失”。主从复制是异步的,主库执行完一条写命令后就先给客户端返回成功,之后才异步发给从库。如果主库在“返回成功”和“发送给从库”之间挂掉,这条写就永久丢了。这个窗口有多大?取决于网络 RTT 和主库的繁忙程度,通常在毫秒到几十毫秒之间。
Redis 提供了一个缓解参数:min-replicas-to-write和min-replicas-max-lag。前者要求至少有 N 个从库处于“健康”状态,主库才允许写;后者定义了“健康”的标准——从库最后一次回复 ACK 距今不超过多少秒。这两个参数一配,就能把丢失窗口从“任意时刻”压缩到“最多 max-lag 秒”。
但这是个明确的取舍:牺牲可用性换一致性。如果所有从库都掉线了,主库会直接拒绝写入,业务变成不可用。所以这两个参数只适合那种“宁可写失败也不能丢数据”的场景,比如订单、账务类数据。对于缓存类的场景,丢几毫秒的数据无伤大雅,就没必要配,反而应该把可用性拉满。
我在实际项目里的选择是:缓存实例不配这两个参数,接受极小概率的数据丢失;而对于那些用 Redis 做轻量级持久化的场景,会配min-replicas-to-write 1和min-replicas-max-lag 10,同时确保哨兵能在 30 秒内完成切换,把不可用时间控制在可接受范围。
5. Pipeline 与 Lua:把往返次数压下去
5.1 管道的本质与批次大小的实测取值
管道(pipeline)经常被误解成一个服务端特性,其实它纯粹是客户端侧的行为:把多条命令攒在本地缓冲里,一次性 write 出去,再一次性读回所有回复。服务端根本不区分这些命令是来自一次 write 还是多次 write,它只是按顺序处理。所以管道的收益完全来自减少网络往返次数(RTT)。
收益有多大?拿同机房 0.2ms 的 RTT 举例,串行执行 1000 条命令就是 200ms 的纯等待,而用管道批量发送,理论上只需要一次往返的 0.2ms 加上服务端处理 1000 条命令的时间。这个差距在两个数量级以上,所以在批量写入、批量查询的场景里,用不用管道是“秒级”和“毫秒级”的区别。
但批次大小不是越大越好。批次过大有三个副作用:客户端要一次性构造并缓存所有命令,内存占用上升;服务端要一次性把回复写进输出缓冲区,如果回复体积大(比如批量 GET 大 value),输出缓冲会迅速膨胀;单次阻塞时间变长,因为服务端在处理这一批的时候,其它客户端的命令要排队。
我实测下来的经验值:命令体积小、回复体积小(比如批量 SET、批量 EXISTS),一批 500 到 1000 条很合适;命令回复体积大(比如批量 GET 几 KB 的 value),一批 100 到 200 条就够了。判断标准是让单批的数据总量控制在几百 KB 到 1MB 之间,不要超过 2MB。这个量下,客户端和服务端的缓冲都不会失控,同时 RTT 的节省已经接近饱和。
# 批量写入的管道用法示例 import redis r = redis.Redis(host='10.0.0.11', port=6379, password='your_password') def batch_set(pipe_items, batch_size=500): for i in range(0, len(pipe_items), batch_size): chunk = pipe_items[i:i + batch_size] with r.pipeline(transaction=False) as pipe: for key, value in chunk: pipe.set(key, value, ex=3600) pipe.execute() # transaction=False 表示不包 MULTI/EXEC,纯粹批量发送,开销更低这里有个细节值得强调:transaction=False和默认的transaction=True差别很大。后者会给这一批命令包上MULTI和EXEC,服务端会把这批命令当成一个事务整体执行,期间不插入其它客户端的命令。前者只是批量发送,中间可以插入其它命令。如果业务不需要这批命令的原子性,一定用transaction=False,开销更低,也不会因为事务锁住主线程太久。
5.2 Lua 脚本的原子性边界与主从复制陷阱
Lua 脚本解决的问题和管道完全不同:管道只减少往返,不提供原子性;Lua 脚本提供原子性,因为 Redis 在执行脚本期间是单线程阻塞的,中间不会插入任何其它命令。所以“读-判断-写”这类需要原子性的逻辑,必须用 Lua 或者事务,管道解决不了。
它的复制行为有个历史演变,搞不清楚就会在从库上读到不一致的数据。Redis 5.0 之前,主库复制的是脚本本身,从库收到脚本后自己再执行一遍。这带来一个硬性约束:脚本必须是确定性的,不能有随机、不能读系统时间、不能依赖脚本内的状态。一旦脚本里有不确定逻辑,主从各自执行的结果就可能不同,数据就分叉了。Redis 5.0 之后引入了“效果复制”(effects replication)并默认开启,主库执行完脚本后,把脚本产生的实际写命令包装成 MULTI/EXEC 发给从库,从库直接执行这些命令,不再自己跑脚本,从根上解决了不确定性问题。
即便如此,我还是建议脚本内避免不确定性逻辑。原因很简单:线上环境版本不统一的情况太常见了,你没法保证所有实例都在 5.0 以上,而且一旦出现数据分叉,排查成本极高。
另一个必须遵守的原则是脚本要短。脚本执行期间 Redis 是完全阻塞的,一个跑 100ms 的脚本意味着这 100ms 内所有客户端的命令都在排队。我见过的血案是一个“批量校验并写入”的脚本,因为匹配的 key 数量超预期,循环了上千次,直接把实例卡死十几秒。防御手段有两个:一是在脚本内限制循环次数,超过就返回错误;二是运维侧配好lua-time-limit(默认 5 秒),超时后 Redis 会开始拒绝其它命令并允许SCRIPT KILL杀掉脚本(前提是脚本还没执行过写操作,否则只能SHUTDOWN NOSAVE)。
5.3 管道与 Lua 的组合使用策略
这两个工具不是互相替代的关系,实际项目里经常组合使用。典型场景是:业务需要一次性处理 1000 个 key,每个 key 的处理逻辑是“读出来、判断、条件更新”。如果只用管道,能做到低延迟,但每条命令之间可能被其它客户端的命令插入,并发下会出问题;如果只用 Lua,能做到原子,但要一次一次调用,1000 次往返又慢得离谱。
正确的做法是外层用管道,内层用脚本:把 1000 个 key 分成 100 批,每批 10 个 key,每批用一个 Lua 脚本处理,100 个脚本通过一次管道发出去。这样既保证了每一批内部的原子性,又把往返次数从 1000 降到 1。脚本内的 key 数量控制在 10 到 20 个,执行时间通常在微秒级,不会阻塞。
这里有个关于集群模式的重要提醒:在 Redis Cluster 下,一个 Lua 脚本涉及的所有 key 必须落在同一个 slot。如果不是,脚本会直接报错。解决办法是在 key 设计阶段就考虑用 hash tag,比如把user:1001:name和user:1001:age都写成{user:1001}:name这种形式,让花括号内的部分参与 slot 计算,保证同用户的数据落在同一个槽。同样,集群模式下的管道也不是“跨节点批量”,客户端需要自己按 slot 分组,每个节点发一批,主流客户端库大部分已经封装好了。
6. 常见问题与排查技巧实录
6.1 常见问题速查表
下面这张表是我这些年遇到过的、重复率最高的一批问题,按现象、根因、处理方式整理,遇到时可以对着查。
| 现象 | 大概率根因 | 排查手段 | 处理方式 |
|---|---|---|---|
| 从库反复全量同步 | backlog 太小,断线期间 offset 被覆盖 | 查从库日志是否出现 Full resynchronization | 调大 repl-backlog-size |
| 主库周期性卡顿 | bgsave fork 时间过长 | 看 INFO stats 里 latest_fork_usec | 拆分实例或降低单实例内存 |
| 从库同步中途被踢 | 输出缓冲超限 | 查主库日志中 buffer 相关告警 | 调大 client-output-buffer-limit replica |
| 主从数据不一致 | 从库被误写,或脚本非确定性 | 对比主从 key 数量与抽样值 | 重建从库,禁止写从库 |
| 哨兵切换后旧主数据丢失 | 异步复制窗口内的写未同步 | 查旧主 offset 与新主对比 | 配 min-replicas-to-write 降低风险 |
| 哨兵无法完成切换 | 哨兵数量不足多数派 | 检查哨兵日志中的投票过程 | 补齐奇数个哨兵,检查网络分区 |
| 管道批量写入超时 | 批次过大导致阻塞 | 看客户端耗时与服务端慢日志 | 减小批次到 100~500 |
| Lua 脚本卡住实例 | 脚本内循环过多 | 看 lua-time-limit 告警 | 限制循环次数,拆分脚本 |
6.2 几个印象深刻的踩坑记录
第一个坑,从库上的一次误写。某次排查数据不一致,发现主库里有个计数器是 100,从库里是 103。追了半天发现是有个运维脚本图省事,直接连到了从库去“修正”几个脏 key,而replica-read-only被之前的一次配置覆盖关掉了。从库的写操作不会回传主库,于是主从分叉,而且这部分分叉会在下一次全量同步时被“正确”地覆盖掉——也就是说你以为修好了,其实下一次同步又变回去了。从那以后我的规则是:从库的replica-read-only必须显式写在配置文件里并纳入配置审计,不能只依赖默认值。
第二个坑,无盘复制的延迟参数设反了。一开始我以为repl-diskless-sync-delay越小越好,设成了 0,想着“越快开始同步越好”。结果八台从库重连时,主库连续 fork 了八次,每次都要复制页表,主库的延迟直接飙到几百毫秒。改成 5 秒之后,八台从库被合并成一次 fork 服务,主库的延迟曲线瞬间平滑。这个参数的语义是“等一等,把一起来的合并掉”,不是“越快越好”,理解反了就会起反效果。
第三个坑,pipeline 里塞了大 value。有个批量预热的任务,用管道一次发 500 条GET,每个 value 有几十 KB,结果客户端这边收到了近 20MB 的回复,一次性反序列化把内存打爆,GC 卡住。后来把批次降到 50,并且改成流式读取回复,问题消失。批次大小必须结合单条数据体积来定,只看条数是不行的。
第四个坑,哨兵的 quorum 和 majority 搞混了。五哨兵部署,quorum 设了 2,当时的想法是“两个哨兵确认就够了,切换更快”。结果在一次机房网络抖动中,两个哨兵认为主库挂了,数量达到了 quorum,进入了客观下线,但选举领头哨兵需要三票,而当时只有两个哨兵在线,投票永远达不成多数派,切换被无限期卡住。最后是人工介入才恢复的。quorum 决定“要不要切换”,majority 决定“谁来切换”,两者必须分开算,而且 majority 只能靠增加哨兵数量来满足。
第五个坑,Lua 脚本里读了时间。早期一个限流脚本里用了时间戳做滑动窗口,跑了一段时间后发现主从的限流计数对不上。原因是那时用的版本还走脚本复制,主从各自执行时取到的时间不同,结果自然不同。改成由客户端传入时间戳作为参数之后问题解决。脚本里所有外部输入都应该通过 KEYS 和 ARGV 传进去,不要在脚本内部读环境。
6.3 一份可以直接用的切换前检查清单
主从切换是少数几个“平时不练、出事必慌”的操作。我整理了一份检查清单,每次手动切换或者验证哨兵切换前都过一遍,能挡掉大部分低级失误。
- 确认当前主库的
master_repl_offset和所有从库的差距都在可接受范围,如果差距过大先等追平再切。 - 确认目标新主的
replica-priority配置正确,别切到一个规格更小的实例上。 - 确认所有从库的
master_link_status都是up,有断连的先修好。 - 记录切换前的主库地址、offset、最大 key 的 TTL 分布,方便切换后对比。
- 确认客户端已经配好重试逻辑,并且在切换窗口内能正确处理只读错误。
- 哨兵侧确认
sentinel get-master-addr-by-name返回的地址与预期一致。 - 切换后立刻验证写入是否成功、读到的数据是否是新主的数据、从库是否已改挂新主。
这份清单看起来啰嗦,但每一条都对应过一次真实事故。尤其是第一条和第三条,很多“切换后数据丢了”的问题其实是切换前主从就没同步完,锅不在哨兵身上。
最后分享一个我用了很久的习惯:在主从环境里,我会在主库和每个从库上各留一个“心跳 key”,由定时任务每秒写一次主库、读一次从库,把主从延迟和可用性直接变成一条可以告警的曲线。这比看任何监控指标都直观,因为它是从业务视角出发的端到端验证——只要这条曲线断了,不管INFO里的数字多好看,链路一定有问题。