图 1:本文封面,点明主题「主从复制」。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。Redis 主从复制是缓存面试的基本盘——多数人会背「全量加增量」,一问「断线十分钟重连会怎样」就开始含糊。区分度就在断线重连这段。
想象面试现场,面试官的问题很朴素:一个主库两个从库,数据是怎么保持一致的呢?断网重连之后又怎么办?
第一步,先给全景:三种机制撑起整个复制。
按 Redis 官方文档的归纳,复制靠三种机制配合。连接正常时,主库把改变数据集的命令以命令流形式持续发给从库,这叫命令传播。连接断开后从库重连,会尝试部分重同步——只补断线期间漏掉的那段。部分重同步做不到时,才退化为全量重同步——主库生成全量快照发给从库,再补上快照期间的增量。
还要记住一个大前提:Redis 默认是异步复制。主库不等从库确认就返回客户端,延迟低、性能高,代价是主库宕机时没来得及传播的写入会丢。这句话建议放在答案开头,它是后面所有讨论的底色。
第二步,全量同步的五步。
从库第一次接入(或部分重同步失败)时走全量。主库 fork 子进程后台生成 RDB 快照——注意主库自己不阻塞,照常处理请求;生成期间收到的新写命令,被暂存进缓冲区;快照完成后把 RDB 文件发给从库;从库落盘、载入内存;最后主库把缓冲的命令补发给从库追平。此后进入常态的命令传播阶段。
两个细节值得主动提:一是没有缓冲区兜底,快照期间几分钟的写入就丢了,「快照 + 缓冲」是组合拳;二是多个从库同时请求同步时,主库只做一次后台落盘服务所有人。
第三步,断线重连的决策:offset 还在不在积压缓冲区里。
这是本题真正的考点。主库维护两样东西:一个复制 ID(标识一段数据集历史),一个复制偏移量 offset(命令流每产生一段就累加)。从库重连时用 PSYNC 报上自己记得的旧复制 ID 和 offset,主库检查:这个 offset 之后的那段命令,还在我的复制积压缓冲区里吗?
在,就只补发 offset 之后缺失的一段——部分重同步,代价小,秒级完成。不在(断线太久,旧内容已被新写入覆盖),或者复制 ID 对不上(历史已翻篇),就只能全量重来,代价大。所以积压缓冲区的大小,直接决定「断线多久之内还能增量追平」——写流量大的系统,要按「预计最长断线时间 × 写入速率」来配。
第四步,用可运行代码把决策过程坐实。
下面这份代码模拟主库的命令流与固定容量的积压缓冲区,演示两种断线时长的不同结局,用 Node.js 22.15.0 直接运行(node redis-replication.js)即可:
'use strict';
// 极简复制模型:主库维护命令流与固定大小的复制积压缓冲区
const BACKLOG_SIZE = 5;
const master = {
data: {},
offset: 0, // 已产生的命令流位点(此处用命令序号代替)
backlog: [], // 环形:只保留最近 BACKLOG_SIZE 条
write(cmd) {
this.offset++;
cmd.offset = this.offset;
this.data[cmd.key] = cmd.value; // 主库先应用自己的写
this.backlog.push(cmd);
if (this.backlog.length > BACKLOG_SIZE) this.backlog.shift(); // 最旧的一段被覆盖
replica.receive(cmd); // 连接正常时:命令传播
},
};
const replica = {
data: {},
offset: 0,
connected: true,
receive(cmd) {
if (!this.connected) return; // 断线期间收不到传播
this.data[cmd.key] = cmd.value;
this.offset = cmd.offset;
},
};
// 断线重连:带 offset 请求 PSYNC
function reconnect() {
replica.connected = true;
const oldestInBacklog = master.backlog.length ? master.backlog[0].offset : master.offset + 1;
if (replica.offset >= oldestInBacklog - 1) {
const missed = master.backlog.filter((c) => c.offset > replica.offset);
for (const c of missed) { replica.data[c.key] = c.value; replica.offset = c.offset; }
console.log(`PSYNC:从库 offset=${replica.offset - missed.length} 仍在积压缓冲区内,部分重同步补发 ${missed.length} 条`);
} else {
replica.data = { ...master.data }; // RDB 快照 + 期间缓冲命令,此处简化为整体快照
replica.offset = master.offset;
console.log(`PSYNC:从库 offset 已被覆盖(缓冲区最旧=${oldestInBacklog}),只能全量重同步(RDB 快照 + 缓冲命令)`);
}
}
let seq = 0;
const w = () => master.write({ key: 'k' + (seq % 3), value: 'v' + ++seq });
// 场景一:断线期间写 3 条,小于缓冲区容量
for (let i = 0; i < 3; i++) w();
replica.connected = false; // 断线
for (let i = 0; i < 3; i++) w();
reconnect();
console.log('追平后两边一致:', JSON.stringify(replica.data) === JSON.stringify(master.data));
// 场景二:断线期间写 7 条,超出 5 条容量的缓冲区
replica.connected = false;
for (let i = 0; i < 7; i++) w();
reconnect();
console.log('追平后两边一致:', JSON.stringify(replica.data) === JSON.stringify(master.data));
实测输出如下:
PSYNC:从库 offset=3 仍在积压缓冲区内,部分重同步补发 3 条
追平后两边一致: true
PSYNC:从库 offset 已被覆盖(缓冲区最旧=9),只能全量重同步(RDB 快照 + 缓冲命令)
追平后两边一致: true
同样断线,三条之差落在缓冲区内,补发三条追平;七条之差冲掉了旧内容,只能全量重来。缓冲区的「容量账」一目了然。
图 2:全量同步五步与断线重连的 offset 决策。
可能的追问。
「主库挂了换从库顶上,其他从库要全量重同步吗?」不一定。新主记住旧主的复制 ID 与切换时的 offset,其他从库拿旧 ID 来同步,仍能部分重同步。「异步复制怕丢数据怎么办?」客户端可用 WAIT 命令要求至少 N 个从库确认才返回,缩小丢失窗口,代价是延迟。「主从、哨兵、集群怎么选?」那是高可用选型的另一道题,本题只管复制机制本身。
易错点清单。
一,把全量同步说成「主库停机拷贝」——主库 fork 子进程后台生成快照,期间照常服务。二,讲不出积压缓冲区的存在,把断线重连笼统说成「自动同步」。三,以为部分重同步总能成功——offset 被覆盖就只能全量。四,忘记复制的异步本质,声称「从库数据和主库实时一致」。
回到面试现场,建议的答题节奏:先给三种机制的全景与异步复制的前提,再讲全量五步与 PSYNC 的 offset 决策,最后落到积压缓冲区容量这道工程账上。AI 应用的缓存层普遍主从部署,理解复制延迟与丢失窗口,才能回答「缓存里的会话状态到底可不可靠」。
你在生产上见过主从延迟或全量重同步引发的事故吗?当时是怎么定位的?欢迎分享。