Redis 持久化、复制与故障恢复
固定源码标签 7.2.5。2026-10-23 本机 8.6.4、standalone/master、0 副本、AOF 关闭,实际只运行小对象、TTL、错误与同连接 WAIT 不足分支。AOF 重写/恢复、复制成功、Sentinel、Cluster 和故障注入均未运行;实验说明将可运行输入与纸面范围分开。
一个成功回复穿过哪些边界
客户端得到 SET 成功,首先说明当前主进程已经执行;本机日志是否 fsync、副本收到多少、哪个副本能晋升、下一次读去哪里仍是不同问题。RDB/AOF 提供恢复材料,复制保存额外数据副本,Sentinel 负责检测与切换,Cluster 负责分槽并带故障转移;不能把四者合成“有高可用所以不丢”。
写链可以画成“主内存执行 → AOF write → AOF fsync”,复制 ACK 则是另一条分支,未必等本机落盘。故障若发生在主确认后、副本收到前,提升副本可丢已确认写;若客户端响应丢失,写可能存在而调用者未知。缓存可允许丢失再限流重建,租约、去重记录、队列若丢失会改变业务正确性。先声明保护哪类已确认操作、进程/整机/磁盘/机房故障范围,以及 RPO/RTO,才能讨论方案。
图是机制示意,不是本机断电或复制实测。并行分支可能先后不同;WAIT 不检查 AOF fsync,WAITAOF 也不把未来所有选主变成共识提交。
过期、淘汰与 RSS 不能混成一个预算
过期由键的逻辑截止时间触发。访问时 expireIfNeeded 检查,主动 activeExpireCycle 周期采样清理不常访问的到期键,不承诺每个键到点立即物理释放。主副本的到期处理还需尊重主节点传播和读取条件,不能把每个副本时钟都当成独立删除权威。入口为 db.c 和 expire.c。普通 SET 的 TTL 语义和本机 PTTL 对照在执行单元。
淘汰由内存压力触发,evict.c 的 performEvictions 按策略选对象。allkeys 覆盖普通键,volatile 只从设过期的键中选,无合适候选仍可能写失败;noeviction 主要拒绝需要增加内存的写,而非把所有读取也禁掉。LRU/LFU 是近似采样,不能声称全局精确最优。TTL 还有一分钟不表示这一分钟内一定不会因淘汰消失,F 水位或去重键也不例外。
maxmemory 不是 RSS 硬上限,参与淘汰的统计与操作系统驻留口径不同。要留分配器碎片、客户端输出、复制/AOF 缓冲、fork COW、进程本身与系统余量;部分缓冲不按同样方式参与淘汰,以免淘汰传播又增缓冲形成反馈。数据/对象20GiB+碎片4GiB+COW6GiB+缓冲/系统3GiB≈33GiB,已超过32GiB主机,不能仅将maxmemory设28GiB就防OOM。COW按被写触及的页衡量,同页多次写与许多页各一字节不同,不能按请求字节相加。
本机128MiB上限、allkeys-lru,实验初始used_memory约0.97MB,写入前检查至少16MiB余量且只造12个有界专用键。没有通过填满共享服务来验证淘汰,也没有修改全局配置;以上淘汰与RSS是源码/机制推导。
RDB 与多部分 AOF 的恢复材料
RDB 保存一个时间点的数据集。后台保存fork子进程,父继续服务但fork可暂停,随后父写共享页造成COW;只有该快照不能找回其后的全部写。AOF记录变更,7.2.5为多部分格式:base可为RDB preamble或AOF,后跟incremental,manifest指向当前有效集合。不能只拷一个appendonly.aof或一个增量文件就声称得到完整备份。官方持久化说明为滚动文档,具体格式按本标签。
重写从当前内存生成等价base,并非编辑旧日志。rewriteAppendOnlyFileBackground / 完成回调处理新base与期间增量,成功后持久更新manifest再处理不再引用的历史;失败则不能把临时文件误认为完整恢复集。检查完整base+有效增量+manifest及恢复加载结果,禁止凭文件名“旧”就手工删文件。
下面是 writeAofManifestFile 的持久发布阶段:
原码连续摘录,截取局部、未改写:redis/redis · 7.2.5 · src/aof.c · L564–L587。
if (redis_fsync(fd) == -1) {
serverLog(LL_WARNING, "Fail to fsync the temp AOF file %s: %s.",
tmp_am_name, strerror(errno));
ret = C_ERR;
goto cleanup;
}
if (rename(tmp_am_filepath, am_filepath) != 0) {
serverLog(LL_WARNING,
"Error trying to rename the temporary AOF manifest file %s into %s: %s",
tmp_am_name, am_name, strerror(errno));
ret = C_ERR;
goto cleanup;
}
/* Also sync the AOF directory as new AOF files may be added in the directory */
if (fsyncFileDir(am_filepath) == -1) {
serverLog(LL_WARNING, "Fail to fsync AOF directory %s: %s.",
am_filepath, strerror(errno));
ret = C_ERR;
goto cleanup;先fsync临时manifest,失败直接cleanup;再rename为正式manifest;再fsync目录使目录项变化得到相应持久化处理。它解释为何清单本身也是恢复协议,不能把“新base已经生成”当成完整切换。这个片段不包含base完成、全部错误清理和加载校验,备份仍要使用一致的有效文件集合,并在隔离环境真实恢复验收。
AOF write、fsync 与错误分支
feedAppendOnlyFile 累积缓冲,flushAppendOnlyFile 将其write到文件。write成功首先是内核接受,不自动等于设备在突然断电后可恢复。always/everysec/no选择同步策略;磁盘健康时everysec常按约一秒近期窗口讨论,却不是所有故障都精确最多丢一秒:
原码连续摘录,截取局部、未改写:redis/redis · 7.2.5 · src/aof.c · L1096–L1119。
if (server.aof_fsync == AOF_FSYNC_EVERYSEC)
sync_in_progress = aofFsyncInProgress();
if (server.aof_fsync == AOF_FSYNC_EVERYSEC && !force) {
/* With this append fsync policy we do background fsyncing.
* If the fsync is still in progress we can try to delay
* the write for a couple of seconds. */
if (sync_in_progress) {
if (server.aof_flush_postponed_start == 0) {
/* No previous write postponing, remember that we are
* postponing the flush and return. */
server.aof_flush_postponed_start = server.unixtime;
return;
} else if (server.unixtime - server.aof_flush_postponed_start < 2) {
/* We were already waiting for fsync to finish, but for less
* than two seconds this is still ok. Postpone again. */
return;
}
/* Otherwise fall through, and go write since we can't wait
* over two seconds. */
server.aof_delayed_fsync++;
serverLog(LL_NOTICE,"Asynchronous AOF fsync is taking too long (disk is busy?). Writing the AOF buffer without waiting for fsync to complete, this may slow down Redis.");
}
}后台fsync未完时首次记录推迟起点并返回,等待不足两秒继续返回,之后计 aof_delayed_fsync 再尝试write。这能同时扩大日志滞后与事件循环延迟,不能拿“每秒安排任务”当耐久硬上界。磁盘和存储控制器的故障模型还决定fsync保证的实际范围。
同一函数后续区分write错误与短写:短写可能尝试truncate回原大小;不能修回时保留已写字节并继续报告错误。always策略下不可恢复write错误会退出,其他策略记录 aof_last_write_status=C_ERR 等状态,后续可能触发写入拒绝;恢复成功又会更新状态。观察 aof_last_write_status、errno、aof_delayed_fsync 与重写状态,不能把内存已经修改自动解释成“磁盘失败就撤销”。源码分支见 aof.c L1145–L1235。
原码连续摘录,截取局部、未改写:redis/redis · 7.2.5 · src/aof.c · L1189–L1214。
}
/* Handle the AOF write error. */
if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
/* We can't recover when the fsync policy is ALWAYS since the reply
* for the client is already in the output buffers (both writes and
* reads), and the changes to the db can't be rolled back. Since we
* have a contract with the user that on acknowledged or observed
* writes are is synced on disk, we must exit. */
serverLog(LL_WARNING,"Can't recover from AOF write error when the AOF fsync policy is 'always'. Exiting...");
exit(1);
} else {
/* Recover from failed write leaving data into the buffer. However
* set an error to stop accepting writes as long as the error
* condition is not cleared. */
server.aof_last_write_status = C_ERR;
/* Trim the sds buffer if there was a partial write, and there
* was no way to undo it with ftruncate(2). */
if (nwritten > 0) {
server.aof_current_size += nwritten;
server.aof_last_incr_size += nwritten;
sdsrange(server.aof_buf,nwritten,-1);
}
return; /* We'll try again on the next call... */
}这里注释明确说明已改数据库不能回滚;always的耐久契约无法满足时退出,其他策略保留未成功写出的缓冲并置错误,下一轮再试。部分字节已写且无法truncate撤销时,只修剪已经写出的前缀,避免盲目重复整缓冲。
恢复还要区分尾部截断与中间损坏、加载策略及修复后可接受损失,保留原故障材料并在隔离副本验证,而非直接修生产文件。普通重启恢复成功只证明该次正常停机路径;容器kill也不等于突然掉电或控制器丢缓存。本机AOF关闭,未执行启用、BGREWRITEAOF、重启或损坏注入。
PSYNC:历史身份与字节范围两道检查
复制offset是特定历史中的字节位点,不是业务行数。backlog是有界近期字节流,不是永久消息队列;第二replid用于衔接上一段历史,但只在截止offset内有效。masterTryPartialResynchronization先查身份:
原码连续摘录,截取局部、未改写:redis/redis · 7.2.5 · src/replication.c · L752–L774。
if (strcasecmp(master_replid, server.replid) &&
(strcasecmp(master_replid, server.replid2) ||
psync_offset > server.second_replid_offset))
{
/* Replid "?" is used by slaves that want to force a full resync. */
if (master_replid[0] != '?') {
if (strcasecmp(master_replid, server.replid) &&
strcasecmp(master_replid, server.replid2))
{
serverLog(LL_NOTICE,"Partial resynchronization not accepted: "
"Replication ID mismatch (Replica asked for '%s', my "
"replication IDs are '%s' and '%s')",
master_replid, server.replid, server.replid2);
} else {
serverLog(LL_NOTICE,"Partial resynchronization not accepted: "
"Requested offset for second ID was %lld, but I can reply "
"up to %lld", psync_offset, server.second_replid_offset);
}
} else {
serverLog(LL_NOTICE,"Full resync requested by replica %s",
replicationGetSlaveName(c));
}
goto need_full_resync;当前ID不同,且第二ID也不同或超过 second_replid_offset,就进 need_full_resync。只要相同第二ID就永远接续会把分叉之后的历史错误拼接。身份通过后另查范围:
原码连续摘录,截取局部、未改写:redis/redis · 7.2.5 · src/replication.c · L778–L789。
if (!server.repl_backlog ||
psync_offset < server.repl_backlog->offset ||
psync_offset > (server.repl_backlog->offset + server.repl_backlog->histlen))
{
serverLog(LL_NOTICE,
"Unable to partial resync with replica %s for lack of backlog (Replica request was: %lld).", replicationGetSlaveName(c), psync_offset);
if (psync_offset > server.master_repl_offset) {
serverLog(LL_WARNING,
"Warning: replica %s tried to PSYNC with an offset that is greater than the master replication offset.", replicationGetSlaveName(c));
}
goto need_full_resync;
}有效请求范围是 [backlog.offset, backlog.offset+histlen];注意请求的是下一段要发送的位点。若保留字节100…200,offset=100/histlen=101,则请求201仍可CONTINUE并暂时不发缺失字节,202不行。请求99即使同ID也得全量;旧ID截止150,请求151即使仍在backlog也得全量。没有backlog也一样。histlen是实际保留量,不必等于配置容量。
全量同步需要基线快照/传输/加载再接增量,磁盘或无盘同步改变路径但都耗CPU、内存、网络与副本恢复时间。副本输出缓冲过大可能断开并重新同步,形成恢复风暴。复制流2MiB/s、断联60秒的缺口约120MiB,256MiB只是加余量的候选,还需计突发、协议与恢复期间新增流;若生产速度一直高于追赶速度,扩大backlog也不能令它永远追上。断联很短仍全量时先查ID、保留寿命/范围和切换,不能只加带宽。复制文档提供操作背景。
WAIT 与 WAITAOF 等待不同的进度
WAIT检查同一个客户端此前写到的 c->woff,不是在任意连接上等待全局“最新写”。连接池在SET后换连接再WAIT会改变等待对象;MULTI或脚本等不允许阻塞的上下文也不能当作普通连接阻塞等待使用。
原码连续摘录,截取局部、未改写:redis/redis · 7.2.5 · src/replication.c · L3546–L3559。
ackreplicas = replicationCountAcksByOffset(c->woff);
if (ackreplicas >= numreplicas || c->flags & CLIENT_DENY_BLOCKING) {
addReplyLongLong(c,ackreplicas);
return;
}
/* Otherwise block the client and put it into our list of clients
* waiting for ack from slaves. */
blockForReplication(c,timeout,offset,numreplicas);
/* Make sure that the server will send an ACK request to all the slaves
* before returning to the event loop. */
replicationRequestAckFromSlaves();
}已有足够副本 repl_ack_off 覆盖woff则立即返回计数,否则阻塞该客户端等待ACK/超时并请求副本反馈。返回数可能不足,不撤销SET,也不证明副本磁盘已fsync。即使WAIT2得到2,也没有约束所有未来选主必从这些确认节点中选出保有这次写的节点。
WAITAOF在7.2.5中等待本机/副本AOF同步进度,并返回两个计数;本机要求为1时,AOF关闭会拒绝该要求。邻近实现:
原码连续摘录,截取局部、未改写:redis/redis · 7.2.5 · src/replication.c · L3578–L3593。
}
if (numlocal && !server.aof_enabled) {
addReplyError(c, "WAITAOF cannot be used when numlocal is set but appendonly is disabled.");
return;
}
/* First try without blocking at all. */
ackreplicas = replicationCountAOFAcksByOffset(c->woff);
acklocal = server.fsynced_reploff >= c->woff;
if ((ackreplicas >= numreplicas && acklocal >= numlocal) || c->flags & CLIENT_DENY_BLOCKING) {
addReplyArrayLen(c,2);
addReplyLongLong(c,acklocal);
addReplyLongLong(c,ackreplicas);
return;
}replicationCountAOFAcksByOffset与 fsynced_reploff 区别于WAIT的普通ACK;不能互换。超时后仍须检查返回数并处理未知结果,不能据更强的落盘等待推成任意分区/故障切换零丢失。本机只实测同socket SET→WAIT1/100ms→GET:0、written。没有副本ACK成功或WAITAOF耐久实测。
Sentinel:quorum 与多数授权不是一件事
本地观测不可达为SDOWN,足够同伴判断满足配置quorum后成为ODOWN;真正故障转移还要由本轮Sentinel leader取得多数授权,并满足quorum。sentinelCheckObjectivelyDown → sentinelStartFailoverIfNeeded → sentinelGetLeader分别承接检测和状态机;下面是leader统计尾部:
原码连续摘录,截取局部、未改写:redis/redis · 7.2.5 · src/sentinel.c · L4846–L4867。
if (winner)
myvote = sentinelVoteLeader(master,epoch,winner,&leader_epoch);
else
myvote = sentinelVoteLeader(master,epoch,sentinel.myid,&leader_epoch);
if (myvote && leader_epoch == epoch) {
uint64_t votes = sentinelLeaderIncr(counters,myvote);
if (votes > max_votes) {
max_votes = votes;
winner = myvote;
}
}
voters_quorum = voters/2+1;
if (winner && (max_votes < voters_quorum || max_votes < master->quorum))
winner = NULL;
winner = winner ? sdsnew(winner) : NULL;
sdsfree(myvote);
dictRelease(counters);
return winner;5个投票者/quorum2,2票可能达到ODOWN却不足3票多数;3票才满足这里授权。若quorum4,3票虽是多数仍不足配置,需要4票。不要把Sentinel票数与数据副本ACK数混在一起。
授权后 sentinelSelectSlave 先按健康/断联条件过滤,再比较priority、复制offset和run ID等,不能说永远只选offset最大。之后提升、重配其他副本、更新配置是异步过程;旧主在分区中可能仍被客户端访问。客户端需发现新主、处理旧主写和结果未知。min-replicas-to-write/max-lag可减少孤立主继续写,依赖检测且不等价共识提交。Sentinel不转发每条业务命令,也不分片。更多运行条件见Sentinel文档。
Cluster:槽迁移、重定向与不确定重试
16384个槽以CRC16路由;hash tag先取key中的第一个{,再取其后第一个},中间非空才用其中内容算槽,否则使用整个key,不继续寻找后面的花括号。foo{}{bar}因此按整个key计算,不能按bar;{order:123}可让相关键同槽,但全用{orders}会集中一个槽。多键事务/脚本一般要求相关键同槽,跨业务聚合不能靠换tag得到跨槽原子提交。
getNodeByQuery提取键、检查槽和迁移状态。MOVED指向当前槽所有者,客户端应更新映射;ASK只是迁移期间访问目标的一次临时重定向,先ASKING再重发,不把全槽永久改映射。迁移源部分键存在、部分不存在时,不能简单把多键拆开后说语义相同:
原码连续摘录,截取局部、未改写:redis/redis · 7.2.5 · src/cluster.c · L7404–L7430。
/* If we don't have all the keys and we are migrating the slot, send
* an ASK redirection or TRYAGAIN. */
if (migrating_slot && missing_keys) {
/* If we have keys but we don't have all keys, we return TRYAGAIN */
if (existing_keys) {
if (error_code) *error_code = CLUSTER_REDIR_UNSTABLE;
return NULL;
} else {
if (error_code) *error_code = CLUSTER_REDIR_ASK;
return server.cluster->migrating_slots_to[slot];
}
}
/* If we are receiving the slot, and the client correctly flagged the
* request as "ASKING", we can serve the request. However if the request
* involves multiple keys and we don't have them all, the only option is
* to send a TRYAGAIN error. */
if (importing_slot &&
(c->flags & CLIENT_ASKING || cmd_flags & CMD_ASKING))
{
if (multiple_keys && missing_keys) {
if (error_code) *error_code = CLUSTER_REDIR_UNSTABLE;
return NULL;
} else {
return myself;
}
}源所有请求键都缺失时可ASK目标,部分仍存在则TRYAGAIN;目标importing且带ASKING时也检查多键缺失。客户端需有界重试、拓扑刷新与明确错误分类。MOVED最终分支见cluster.c L7445–L7448。
Cluster副本提升也依赖授权和异步复制,不会因分槽消除丢失窗口。网络在命令执行后断开时,客户端无法区分“未执行”与“已执行但回复丢失”,INCR盲重试可多计;要查稳定请求身份对应结果或走业务恢复。重定向回复可按协议重发,并不能推广为所有断线都安全重试。协议依据为Cluster规范。
演练、租约关联与条件变化
run_local.py在本机只运行有授权的小对象/TTL/错误/WAIT分支;models.py是确定性纸面状态模型,覆盖第二ID截止、范围端点和5票/quorum2或4,不能充当真实网络分区。输入、独立手算预期和未运行的7.2.5隔离Compose步骤都在实验说明。
Redis租约键也受过期、淘汰和异步切换影响。SET NX PX加随机owner、比较owner后删除只防误删,不能阻止过期后恢复的旧线程;Redis INCR若切换丢失还可能回退。暂停41→接管42→旧41写回、授权发号与资源端原子fencing的完整协议仅维护在租约权威正文。此处只检查Redis能保存/丢失哪些状态,不能把缓存型恢复承诺复用于锁。
主问题“怎样防丢”:先声明缓存或权威状态,再拆本机恢复、副本进度、安全提升和客户端未知结果,给RPO与演练。4分:职责、故障模型、目标、证据各1。第一轮everysec为何不是硬1秒:慢fsync会推迟write、故障模型不同,2分。第二轮WAIT2为何非终局安全:未等磁盘也未限制未来选主,区分WAITAOF,2分。第三轮5票/quorum2只剩2票:不足多数,且候选副本健康仍要检查,2分;合计10分,至少8分且不混票数与数据确认才通过。
条件一,数据翻倍:重算副本总量、碎片、COW、缓冲和重建并发,不能只调maxmemory。条件二,目标从可丢缓存改成不可回退授权/账务:选择能在故障模型下保证任期/提交的存储和资源校验,或数据库条件裁决,不靠WAIT贴强一致标签。条件三,RTT从0.5ms变40ms:同步等待远端会抬写尾延迟,应按RPO/可用性取舍,并测切换后的热点与回源,而非只测稳态GET。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。