凌晨两点,告警说 Redis 主节点刚刚重启。业务接口很快又恢复了,看起来像是一次普通抖动,可订单团队随后发现:有几十个“已抢到库存”的请求在新主节点上找不到对应记录。缓存命中率、服务可用率都恢复了,数据却没有完整回来。
这类事故最容易让人混乱,因为我们习惯把“安全”“高可用”“一致性”揉成一个词。Redis 有副本,不代表已经备份;命令返回成功,不代表已经落盘;故障切换很快,也不代表切换后绝不会少最后几笔写入。要把系统设计清楚,先得把这三件事拆开看。

从 Redis 写入出发,分别用耐久性、可用性和一致性衡量数据安全。
耐久性回答“故障后数据还能不能找回”,可用性回答“故障时服务还能不能继续接请求”,一致性回答“多个操作、多个客户端或多个副本看到的状态是否符合业务规则”。三者会互相影响,却不能互相替代。
这一章不追求一份“万能生产配置”。我们会从一次写入出发,跟着它经过内存、AOF、RDB、副本和故障切换,再回到慢命令、大键、热键与内存淘汰。你最后应该能回答两个很实际的问题:这笔写入究竟安全到了哪一层,以及接口变慢时第一步该看什么。
“数据尽量别丢”和“服务尽快恢复”没法直接拿来做设计。更有用的做法,是给每类数据分别写出恢复点目标和恢复时间目标。
别急着把所有 key 都定成 RPO=0。推荐结果、验证码、会话、库存预占和支付账本的代价完全不同。更合理的做法是先分级。
这个表里有一条很重要的边界:如果一份数据丢失后无法重建,且业务要求严格审计、复杂事务或跨系统对账,就不要因为 Redis 很快而默认让它成为唯一事实来源。技术选型不是给 Redis“加满配置”,而是决定哪套系统承担哪种承诺。
客户端执行 SET order:948:status reserved 收到 OK,至少说明主节点已经执行了这条命令。至于它是否已经进入操作系统页缓存、是否已经 fsync 到磁盘、是否被副本处理、是否被副本落盘,要看持久化、复制和客户端后续等待策略。
所以,事故复盘时不要只问“Redis 当时返回成功了吗”。应该按层追问:
appendfsync 策略?你会发现,“不丢数据”从来不是某一个开关,而是一条端到端链路。
Redis 的持久化主要有两条路:RDB 保存某个时刻的数据集快照,AOF 记录会改变数据的命令。一个像定时拍照,一个像持续记账。两者都能用于恢复,但风险窗口和运行成本不同。

RDB 像定时拍照,AOF 像逐笔记账;两者的风险窗口和运行成本不同。
RDB 适合做紧凑的时间点副本。下面这段配置表示:60 秒内至少发生 1000 次修改时,触发一次后台快照;同时再保留一个更长周期的条件作为兜底。
save 60 1000
save 900 1
dbfilename dump.rdb
dir /var/lib/redis
rdbcompression yes
rdbchecksum yes这里的 save 60 1000 不是“每 60 秒一定保存一次”。只有时间和修改次数两个条件同时满足才会触发。写入稀疏时,实际快照间隔可能更长。若业务只使用 RDB,主机在下一次成功快照前故障,最近变化就可能不在 dump.rdb 里。
手工执行 SAVE 会阻塞服务直到保存完成,生产实例通常使用 BGSAVE。后台保存的大致过程是:
fork,创建子进程。这里的“后台”不等于“没有成本”。数据集越大,fork 建立页表的时间越可能形成延迟尖峰。保存期间如果写入很密集,写时复制会不断复制被修改的内存页,进程实际占用内存可能明显上升。

后台保存期间,持续写入会触发 COW 并增加内存占用,fork 与磁盘 I/O 也可能形成延迟尖峰。
不要用“当前 used_memory 只有机器内存的七成”这种固定比例代替容量测试。需要预留的空间取决于写入速率、被改动的内存页、碎片、复制缓冲区和操作系统环境。最可靠的数字来自接近生产数据规模的 BGSAVE 与 AOF 重写压测。
RDB 还有一个经常被忽略的运行边界。默认配置通常会在后台保存持续失败时拒绝可能修改数据的命令,避免你以为快照一直正常。监控里至少要看 rdb_last_bgsave_status、rdb_last_save_time、rdb_changes_since_last_save 和磁盘剩余空间。把错误隐藏掉只会让风险窗口悄悄变大。
AOF 会把写命令追加到日志,重启时通过重放恢复数据。基本配置可以从这里开始:
appendonly yes
appendfsync everysec
appenddirname appendonlydir
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 1gb真正决定耐久性窗口的是 appendfsync:
“已经写入 AOF”与“已经 fsync”不是一回事。前者可能还停留在操作系统缓存里,断电后未必存在。everysec 也不是精确到整秒的计时器;磁盘抖动、调度和正在进行的 fsync 都会影响尾延迟。配置前应该先问业务能否承受这个窗口,而不是把默认值当成安全证明。
从 Redis 7 开始,AOF 不再只是一个不断变大的单文件。一个 AOF 目录通常由以下内容组成:
执行 BGREWRITEAOF 时,子进程根据当前数据生成更短的基础文件;父进程同时打开新的增量文件继续接收写入。重写完成后,Redis 原子地切换清单,再清理不再使用的旧文件。重写的目标是缩短日志,不是做一次业务备份。
如果启用 aof-use-rdb-preamble yes,新的基础 AOF 可以使用 RDB 格式作为前导,后面继续接增量 AOF。它常被叫作“混合持久化”:基础部分加载快,增量部分保留较新的写入。注意,这不等于“RDB 和 AOF 两个独立副本永远各自完整”。真正的恢复集合由 AOF 清单管理。
appendonly yes
aof-use-rdb-preamble yes
appendfsync everysec如果同时启用了独立 RDB 快照和 AOF,重启时 Redis 会优先用更完整的 AOF 恢复。运维脚本不能看到 dump.rdb 就想当然地只复制它,也不能只拷贝 AOF 目录里的某一个文件。
可以把选择过程压缩成四个问题:
纯缓存可以不持久化,但前提是回源容量和缓存重建策略经过验证。可以容忍几分钟损失的派生数据,RDB 可能已经够用。希望缩小数据丢失窗口时,常见做法是 AOF everysec 配合定期 RDB。极少量关键写入若必须确认到磁盘,可以评估 WAITAOF,但它依然不能替代业务事实库和跨系统补偿。
持久化文件在 Redis 所在机器上,主要解决进程重启和部分本机故障。误执行 FLUSHALL、程序批量写错、云盘损坏、主机丢失时,本机文件可能一起失效或保存了同样的错误。备份必须跨越故障域,并且保留历史版本。
可以按“生成、转移、校验、保留、演练”五步设计:
先生成一致的恢复材料。RDB 完成后才会替换正式文件,可以在实例运行时复制已完成的 RDB。多段 AOF 则要把清单引用的整组文件当成一个整体。
把备份传到实例之外。至少跨主机,关键业务还要跨可用区或区域,避免 Redis、数据盘和备份同时失效。
保存文件大小、校验和、Redis 版本、生成时间和配置快照。只有文件名没有元数据,事故时很难判断哪一份可用。
按业务 RPO 设置保留层级,例如近两天按小时、近两个月按天。生命周期策略要防止“备份任务成功,旧文件却被错误清空”。
备份 AOF 目录时要避开重写切换窗口。一个稳妥的操作思路是暂时关闭自动重写,确认 INFO persistence 中 aof_rewrite_in_progress:0,完整复制 appenddirname 目录,再恢复原来的重写阈值。这个动作需要写进自动化脚本并确保异常时也会恢复配置,不能靠值班人员记忆。
如果 AOF 尾部因宕机留下半条命令,较新的 Redis 通常可以在允许截断尾部时加载到最后一条完整命令。真正遇到损坏时,先复制原文件,再检查:
redis-check-rdb /backup/restore-test/dump.rdb
redis-check-aof /backup/restore-test/appendonly.aof.manifest不要一上来就运行带 --fix 的命令。修复工具可能从损坏点开始丢弃后续内容;原文件没有副本时,你就失去了手工分析和采用其他恢复方案的机会。多段 AOF 的检查与修复方式还要以当前 Redis 版本的工具帮助为准,不要沿用旧时代只检查 appendonly.aof 单文件的脚本。
“Redis 能启动”只是恢复的第一关。演练至少要记录:
副本会忠实复制删除和错误写入,所以副本不是历史备份。自动故障切换解决的是服务接管,时间点备份解决的是回到错误发生之前。两套能力都要演练。
Redis 的基础复制是主节点到副本的异步复制。主节点执行写入后把变更放入复制流,副本接收并执行。连接短暂断开时,副本会根据复制标识、偏移量和复制积压缓冲区尝试部分重同步;缺口太大或历史对不上时,就需要重新传输完整数据集,再补上同步期间的增量。

普通写、WAIT 与 WAITAOF 提供不同层级的确认,但都不是完整的强一致协议。
第一个后果是副本读取可能落后。把强实时读取随手路由到副本,会出现“刚写完就读不到”的体验。读写分离之前,要先给业务定义可接受的陈旧时间,并确认客户端能否在关键路径固定读主节点。
第二个后果是故障切换可能丢掉尚未到达候选副本的写入。Sentinel 可以监控、通知、选主并向客户端提供新地址,Redis Cluster 可以分片并为分片做副本接管,但它们都不会把异步复制自动变成强一致复制。
一个稳健的 Sentinel 部署通常至少使用三个 Sentinel,并把它们放到尽量独立的故障域。这里既有“多少 Sentinel 认为主节点不可达”的 quorum,也有“多数 Sentinel 授权这次故障转移”的要求。只把三个进程放在同一台机器上,数量看起来够了,故障域却没有分开。
如果某一类写入比普通缓存更重要,客户端可以在同一连接中先写,再执行:
SET reservation:{948}:status created
WAIT 1 200WAIT 1 200 表示最多等待 200 毫秒,希望至少一个副本确认已经处理到当前连接先前写入所对应的复制偏移。返回值是实际确认的副本数,即使超时也会返回,所以应用必须检查结果,不能只看命令有没有报错。
三个边界必须记住:
WAIT 只追踪当前连接此前的写入,连接池必须保证写入与 WAIT 使用同一条连接;把 WAIT 放进 MULTI/EXEC 里也不会得到想象中的阻塞等待。在事务或脚本这类不允许阻塞的上下文里,它会立即返回当前已确认数量。正确做法通常是在事务 EXEC 完成后、同一连接上单独调用并检查结果。
Redis 7.2 加入了 WAITAOF。它可以等待当前连接此前的写入在本地主节点和指定数量的副本上完成 AOF fsync:
SET reservation:{948}:status created
WAITAOF 1 1 500参数依次表示:要求本地确认的数量(只能是 0 或 1)、要求副本确认的数量、超时毫秒数。返回的是两个整数:本地实际 fsync 数和副本实际 fsync 数。业务要逐项检查是否达到阈值。
WAITAOF 要求相应节点启用 AOF;本地未启用 AOF 时不能要求本地数量为 1。它不能在副本上调用,超时设为 0 会无限等待,在线请求通常不该这么做。和 WAIT 一样,它加强了现实中的数据安全,却没有覆盖所有网络分区、故障选主和集群成员组合,因此也不是强一致承诺。
还可以用下面两个配置做一道保险:
min-replicas-to-write 1
min-replicas-max-lag 10当主节点看不到足够数量、延迟在阈值内的副本时,它会拒绝写入。这能减少“主节点孤岛里持续接受大量写入,随后又被另一侧替换”的风险。代价也很直接:网络抖动时,可用性会下降。这个配置是在用一部分可用性换取更小的数据分叉窗口,仍然不是严格的共识协议。
高可用的目标是缩短中断,耐久性的目标是故障后保住写入。复制、Sentinel、Cluster、RDB、AOF、WAIT 和 WAITAOF 各守一段边界。设计评审时如果一句“我们有主从,所以不会丢”就带过,风险通常还没有被真正讨论。
Redis 单条命令通常是原子执行的。INCR 不会被另一个客户端插到一半,带条件的 SET ... NX 也能一次完成判断与写入。问题往往出在业务把多个命令拼成“先读、在应用计算、再写”。两次网络往返之间,其他客户端完全可以修改同一个 key。

四种工具解决的问题不同:网络往返、连续执行、冲突检查和服务端原子逻辑不能混为一谈。
进入 MULTI 后,命令先进入队列;调用 EXEC 时,Redis 按顺序连续执行队列中的命令,中间不会插入其他客户端命令。
MULTI
HINCRBY wallet:{u42} balance -100
HINCRBY wallet:{u77} balance 100
EXEC这段命令的“连续执行”是真的,但拿它演示真实转账并不合适。Redis 不会检查余额是否为负,也不会在第二条命令发生类型错误时自动撤销第一条。真实资金账本还需要权威事务库、审计与补偿。
Redis 事务里的错误分两类:
EXEC 拒绝执行整批队列。DISCARD 只是清空尚未执行的队列,不是把已经执行的修改回滚。Redis 不维护关系数据库那种撤销日志。这里最危险的误解,就是把“EXEC 期间不被插队”翻译成“要么全部成功,要么全部回滚”。
Redis 事务的原子性主要描述执行边界和不可插队,不等于关系数据库式回滚。运行时一条命令失败,其他已排队命令仍可能成功。业务校验应尽量在写入前完成,并对返回数组中的每一项负责。
假设库存保存在 product:{p9} 的 stock 字段里。应用要先读取库存,再决定扣减数量。可以用 WATCH 把这段流程变成检查并设置:
WATCH product:{p9}
当前库存 = HGET product:{p9} stock
如果 当前库存 < 购买数量:
UNWATCH
返回“库存不足”
MULTI
HINCRBY product:{p9} stock -购买数量
XADD order-events:{p9} * type reserved qty 购买数量
EXEC如果被监视的 key 在 WATCH 之后、EXEC 之前被其他客户端修改,EXEC 会返回空结果,队列不执行。应用需要重新读取最新数据后重试,而不是原样重放旧计算。重试要限制次数并加入少量随机退避,否则热点库存会让大量客户端在同一节奏上反复冲突。
在 Redis Cluster 中,事务、脚本或函数访问的多个 key 需要位于同一哈希槽。上例用相同的 {p9} 哈希标签,让两个 key 可以落到同一槽。哈希标签是部署约束,不是业务一致性的自动保证。
Pipeline 允许客户端连续发送多条命令,再批量读取回复。它解决的是 RTT 累加,不解决并发插队:
pipe = client.pipeline(transaction=False)
pipe.get("profile:{u42}")
pipe.get("settings:{u42}")
pipe.zrevrank("rank:weekly", "u42")
result = pipe.execute()这三条命令可能在服务器上依次执行,但它们不是不可分割的整体,其他客户端命令可以穿插。还有一个客户端陷阱:有些库的 pipeline() 默认会启用事务包装,有些不会。代码评审时要看客户端的具体参数,不能只看变量名叫 pipe。
一次塞进几十万条命令也不聪明。服务器需要暂存回复,客户端也要保留请求与结果。更稳妥的做法是分批,例如每批几百到几千条,再用压测决定大小。批次越大,吞吐可能越高,但单批尾延迟、内存占用和失败重试成本也会上升。
当 WATCH 冲突频繁,或一次操作需要多轮读写,可以把短小的校验与修改放进 Lua 脚本。下面的脚本在一个 Hash 内检查库存并扣减:
local stock = tonumber(redis.call('HGET', KEYS[1], 'stock') or '0')
local quantity = tonumber(ARGV[1])
if quantity <= 0 then
return redis.error_reply('购买数量必须大于 0')
end
if stock < quantity then
return {
EVAL "脚本内容" 1 product:{p9} 2脚本执行期间不会被其他客户端命令插入,因此“检查库存”和“扣库存”构成一个原子执行单元。但脚本同样没有运行时错误自动回滚的魔法。应该先完成参数、类型和业务条件校验,再开始写入,并让脚本保持很短,因为慢脚本会阻塞其他客户端。
Redis Functions 从 Redis 7 开始提供命名函数库。与临时的 EVAL 脚本缓存不同,函数是数据库的一部分,会随数据持久化和复制,应用通过 FCALL 调用。它更适合被多个服务长期复用的服务器端逻辑,但也需要版本发布、权限、超时和回滚方案。函数原子执行,照样应该避免长循环、大范围扫描和不可预测的复杂度。
Redis 接口慢时,最常见的反应是加连接、扩大 Pipeline、调 maxmemory,甚至直接扩机器。问题是延迟可能根本不在同一层:客户端到 Redis 的网络 RTT、命令执行、fork、磁盘 fsync、操作系统调度、连接池排队都会反映到应用耗时里。

先测应用端延迟并建立基线,再按证据选择工具,不要一上来就调参数。
先同时看三种时间:
command、fork、AOF、淘汰等事件造成的尖峰。如果应用端很慢,而 SLOWLOG 很干净,先查网络、连接池和超大响应,不要武断地说“Redis 没问题”。如果应用端和慢日志同时出现尖峰,就进一步定位命令复杂度和 key 规模。
常用命令可以按这个顺序执行:
# 从客户端位置观察往返延迟
redis-cli -h redis.example.internal --latency
# 查看慢命令;执行时间单位是微秒,不包含网络 I/O
SLOWLOG GET 20
SLOWLOG LEN
# 开启毫秒级内部延迟事件采样,并查看诊断
CONFIG SET latency-monitor-threshold 10
LATENCY LATEST
LATENCY DOCTOR
# 观察内存、统计、持久化和复制状态
INFO memory
INFO stats
INFO persistence
INFO replicationCONFIG SET 只修改当前运行配置,是否持久化到配置文件要由变更流程明确决定。诊断结束也要评估是否保留采样阈值,不要留下没人知道的临时设置。
HGETALL 对只有 5 个字段的 Hash 很快,对百万字段的 Hash 就可能阻塞。SMEMBERS、LRANGE 0 -1、大范围 ZRANGE、集合交并集、一次删除巨型聚合值,都有相似问题。算法复杂度里的 N 是实际元素数量,不能脱离数据规模谈“这个命令安全”。
生产环境不要使用 KEYS * 枚举整个键空间。需要遍历时使用 SCAN,同时限制每轮工作量。SCAN 是增量迭代,不保证一次遍历过程像快照一样固定;如果 key 正在变化,业务要能容忍重复或变化。
大键是一个 key 的值或元素数量很大。它可能造成大响应、长时间序列化、删除卡顿、复制带宽尖峰和迁移困难。热键则是访问集中在少数 key,即使值很小,也可能把单个 Redis 节点、网卡或 CPU 核心打满。
可以用限速扫描做初步排查:
# 综合查看元素数量和内存较大的 key;-i 给扫描留出间隔
redis-cli --keystats --top 20 -i 0.1
# 老版本或只关心元素数量时
redis-cli --bigkeys -i 0.1
# 精确估算单个 key 的内存,聚合类型可采样
MEMORY USAGE suspicious:key SAMPLES 5
# 只有启用 LFU 类 maxmemory-policy 时才能用该模式采样热键
redis-cli --hotkeys -i 0.1扫描本身也会产生负载,数据量大时要在低峰运行、限速并监控延迟。--hotkeys 依赖 LFU 访问计数;没有启用 LFU 策略时,可以结合客户端埋点、代理指标、命令采样和业务访问日志定位热点。
处理大键通常有四条路:拆分聚合结构、分页读取、把一次工作改成小批增量、使用 UNLINK 异步释放大值。处理热键则可能需要本地缓存、读副本、请求合并、热点拆分或限流。大键拆分后可能变成一堆热 key,方案仍要压测。
同步逐条执行 N 条命令时,延迟大致包含 N 次 RTT;Pipeline 可以把多条请求先送出去,再集中读取回复,因此网络往返成本会明显下降。但 Redis 还是要执行每一条命令,Pipeline 不会把 O(N) 操作变成 O(1),也不会凭空增加单节点 CPU。
评估批次时至少记录吞吐、P50、P95、P99、客户端内存、Redis 输出缓冲区和失败重试成本。只看每秒请求数,很容易得到一个平均吞吐漂亮、尾延迟已经不可用的批次。
Redis 到达 maxmemory 后怎么处理新写入,由 maxmemory-policy 决定。这个选择必须和数据角色一致。
常见策略可以这样理解:
noeviction:不主动淘汰 key,新增数据需要更多内存时返回错误。适合不能静默丢 key 的数据,但应用必须处理 OOM 错误。allkeys-lru:从所有 key 中近似淘汰最近较少使用的 key,适合访问“越新越可能再用”的缓存。allkeys-lfu:从所有 key 中近似淘汰访问频率较低的 key,适合长期热点较稳定的缓存。volatile-lru / volatile-lfu:只在设置了 TTL 的 key 中选择。如果有缓存 key 忘记设置 TTL,可淘汰范围会与预期不同。volatile-ttl:优先淘汰剩余寿命较短的 key,适合 TTL 本身能表达价值的场景。LRU 和 LFU 都是近似算法,不会扫描全部 key 找到数学意义上的“最冷”对象。是否合适要看命中率、淘汰数量和回源成本,不要只凭策略名字判断。
maxmemory 12gb
maxmemory-policy allkeys-lfu这里的 12GB 不能直接写成机器总内存。Redis 还需要内存承担复制和 AOF 缓冲、客户端输出缓冲、碎片、模块开销、fork 页表与 COW。用于判断淘汰的内存口径也不包含所有复制/AOF 缓冲,因此机器必须留出额外空间。可以结合 INFO memory 中的 used_memory、used_memory_rss、mem_fragmentation_ratio、mem_not_counted_for_evict 观察,而不是只盯一个百分比。
当内存已经顶到上限,新写入到来时 Redis 要一边找候选 key、一边删除对象,再接受新数据。持续高淘汰率意味着缓存容量或策略不匹配,也可能把延迟推高。重点监控:
evicted_keys 的增长速度,而不是只看累计值;keyspace_hits 与 keyspace_misses 计算出的命中率;expired_keys,判断 TTL 到期与内存淘汰的比例;used_memory_rss 与系统可用内存,发现 COW 或碎片压力;如果 Redis 混放了“可淘汰缓存”和“不可丢业务状态”,任何策略都会难受:allkeys-* 可能删业务状态,volatile-* 又可能被漏设 TTL 的 key 卡住。更清晰的做法是拆成不同实例或数据库服务,分别设置内存、持久化和故障目标。
一套配置能启动,不等于它在真实故障里成立。上线前可以按下面的顺序收口。
WAIT 返回、WAITAOF 返回分别代表哪一层确认。做进程崩溃演练:在可控环境持续写入带序号记录,随机终止实例,再计算不同持久化策略下实际恢复到哪个序号。
做主从网络分区演练:分别测试普通写、WAIT 和 WAITAOF,记录超时、客户端行为、选主后缺失记录以及恢复旧主时的处理。
做资源压力演练:在接近生产数据量与写入速率下触发 BGSAVE 和 BGREWRITEAOF,观察 fork 时间、P99 延迟、RSS、磁盘吞吐和复制延迟。
做离线恢复演练:从异地备份创建全新实例,让应用通过正常发现机制连接,执行 key、TTL、业务不变量和回源容量检查,并记录完整 RTO。
演练数据要带可核对的单调序号或业务事件 ID。否则你只能看到“好像恢复了很多 key”,却无法精确计算丢失窗口。关键请求还要设计幂等键,防止客户端因超时重试时把同一操作执行两次。
至少建立这些告警:
SLOWLOG 新增条目和 LATENCY 事件;rdb_last_bgsave_status、aof_last_bgrewrite_status、最近成功时间;master_repl_offset 与副本偏移差、复制链路状态、积压缓冲区覆盖时间;evicted_keys 速率、命中率、大键与热点变化;真正可靠的 Redis 方案不是“从未出过故障”,而是团队能说清每一层承诺,监控能提前暴露窗口,故障演练能复现恢复过程,业务还能通过幂等、对账和补偿处理基础设施没有覆盖的最后一段风险。
某活动系统把库存预占只存进 Redis,配置了一个主节点和一个异步副本,没有 AOF,只每 15 分钟做 RDB。应用收到 DECR 成功就告诉用户“预占成功”。主节点故障后副本被提升,最近几十条预占记录消失。
请从耐久性、可用性和一致性三个角度指出问题,并给出一套改进顺序。
请判断下面四句话分别错在哪里:
当你能把这四句话改准确,就已经抓住这一章的主线:每一种 Redis 机制只解决一段问题。先说清业务承诺,再组合持久化、复制、原子操作、监控与恢复流程,系统才不会靠一句“Redis 很快”撑到下一次事故。
定期恢复到隔离实例,验证 key 数量、核心业务不变量、抽样内容、函数库和过期时间。把恢复耗时记录下来,才能知道真实 RTO。