资讯详情

03-数据会不会丢:RDB、AOF 与内存管理的真实边界

📅 2026/11/6 6:37:00 | 华诺云谱 👁 阅读
03-数据会不会丢:RDB、AOF 与内存管理的真实边界
数据会不会丢RDB、AOF 与内存管理的真实边界一、重启之后购物车少了一大截第 02 篇结尾留了个问题缓存丢了可以重建那存储层的数据丢了怎么办。我们很快撞上了丢的不是缓存是被当成存储的那部分。购物车没进 MySQL直接落在 Redis——中小项目常这么干理由是反正 Redis 有持久化。某次凌晨发布重启实例第二天监控显示购物车条数少了一大截。没有报错没有告警启动成功、客户端连上读不到的 key 返回空业务把它解释成用户没加过购物车。复盘查到两件事这台实例appendonly no只有 RDBsave用默认档位默认值非实测。两次快照之间的写入全丢。另一台开了 AOF 的实例更隐蔽断电时 AOF 尾部写了半条命令aof-load-truncated yes默认值让 Redis 带警告启动、跳过不完整部分。恢复点不是最后一条命令而是最后一个完整命令。第二条比第一条危险。丢最后几秒业务顶多重做回到过去却是读到一个格式完全正确、时间点是过去的值——不报错、不打库被当正确值用上一周才发现。所以本篇只回答一个问题对这台 Redis宕机时到底丢多少由哪几个配置决定。二、丢失窗口配置说了算配置宕机最多丢代价定性适用不做持久化全部无纯缓存可全量重建仅 RDB默认save上次成功快照之后的全部写入恢复快、文件小丢一段也能重建的数据AOFeverysec约 1 秒官方表述磁盘阻塞时可能更多每秒一次 fsync主流起点AOFalways单条命令级每条命令一次 fsync写延迟被磁盘绑定量小且不能丢AOFno交给内核刷脏页不可控最快不该用于数据混合rdb-preambleeverysec同everysec不同 RDB恢复快 丢得少推荐默认一个常被读错的点aof-use-rdb-preamble只改变 AOF 文件的开头长什么样不改变尾部追加和 fsync 节奏。开了混合持久化丢失窗口仍然是appendfsync决定的那一个。「丢多少」由appendonlyappendfsync决定RDB 决定的是「恢复多快」。三、RDBfork 复制的是页表不是数据BGSAVE调fork()内核复制页表、建立写时复制COW映射主线程同步完成阻塞时长与页表规模相关——内存越大、页越多fork 越慢。fork 返回后父子共享同一批物理页任何一方写某页内核才复制那一页。所以主线程只在 fork 期间被按住不是整个快照期间。之后的内存上涨是 COW 的结果不是阻塞。快照期间写得越密复制的页越多最坏接近翻倍。[图fork 复制页表 → COW 写时复制 → 快照期间内存膨胀]两个容易漏的细节透明大页THP会放大 fork 开销。4KB 页合成 2MB页表项变少但单次复制变重fork 延时会明显拉长生产建议关掉。复制了多少页有字段可查INFO persistence的rdb_last_cow_size。它比我觉得没涨多少可靠也是后面算maxmemory的依据。SAVE是主线程原地写期间所有命令排队只该在停机维护时用BGSAVE才 fork 子进程。同一时刻只允许一个 bgsave重复触发会被跳过看rdb_bgsave_in_progress。自动触发的场景除了save seconds changes还有SHUTDOWN、主从全量同步、DEBUG RELOAD。关掉 RDB 的后果要说清save 只是不再自动快照不等于永不 fork——主从全量同步仍要生成 RDB落盘或 diskless。若同时appendonly no重启就是空库。它不是性能开关是数据开关。四、AOFeverysec不是零丢失4.1 三种 fsync 的边界appendfsync语义最坏丢失定性代价always每条命令 write fsync 后才回 OK单条命令写延迟被磁盘绑定吞吐显著下降everysec后台线程每秒 fsync 一次约 1 秒官方表述折中主流选择no只 write 到内核缓冲由内核决定不可控最快也最不可控约 1 秒是上限描述不是保证。机械盘、大 AOF、快照或重写并发时这个窗口会比 1 秒长下面讲原因。有没有发生过看INFO persistence的aof_delayed_fsync大于 0 就说明出现过因 fsync 未完成而延迟写。这是最该加监控、却最少人看的字段之一。再往下看一层everysec的阻塞其实来自内核。fsync本身是丢给后台线程做的但每秒一次这个承诺要靠主线程自己守。当上一次后台fsync迟迟没返回源码里以 2 秒上下为界具体以版本为准而新命令又需要写 AOF 时主线程会主动停下来等它完成——不这样的话继续写在磁盘上就违背了最多丢 1 秒。这一等等的就是磁盘机械盘坏道、云盘突发 IO 限流都会把这份等待直接传给业务线程。这也是aof_delayed_fsync值得单独告警的原因它记录的正是主线程为等 fsync 被拖住的次数。这个数一涨业务侧就会出现延迟毛刺而SLOWLOG里可能一条慢命令都没有——因为慢的不是某条命令是整条主线程被磁盘按住了第 06 篇会用LATENCY的事件视角把这个区别讲透。4.2 重写fork 又来了还多一份缓冲AOF 是命令流水会越来越长重写要做的是生成当前数据集的最小表达。触发auto-aof-rewrite-percentage 100且auto-aof-rewrite-min-size 64mb默认值非实测。期间双写子进程只看到 fork 那一刻的数据集此后新到的命令既要写旧 AOF也要写进aof_rewrite_buf重写完成后追加到新文件末尾。风险重写期间的内存双杀。写入量很大时上面两件事会同时恶化COW 不断复制物理页aof_rewrite_buf不断变长。这个缓冲没有任何配置项能直接给它设上限。aof_rewrite_buf不是客户端输出缓冲client-output-buffer-limit管不到它它的大小约等于写入速率 × 重写剩余时间只能间接控制。两份内存叠加RSS 会迅速逼近甚至超过物理内存上限直接引发 OOM——而used_memory看上去可能毫无异常。怎么观察、怎么压老版本的INFO persistence里有aof_rewrite_buffer_length能看实时大小7.0 改多文件 AOF 后该字段未必还在以自己实例为准。真正有效的动作是三条错峰——把重写放到低峰期手工BGREWRITEAOF别让它在流量高峰自己触发调小重写规模——把auto-aof-rewrite-percentage降下来让它更早、更小地发生而不是等 AOF 涨得很夸张再一次性重写别指望no-appendfsync-on-rewrite——那个参数换的是延迟不是内存。[图AOF 重写期间的双写、aof_rewrite_buf 与 COW 叠加]Redis 7.0 起 AOF 是多文件结构appendonlydir/appenddirname默认值下是 base、incr 与 manifest。好处是重写不用再复制整个大文件代价是目录结构变了降级到 6.x 可能直接读不了。4.3 混合持久化与版本兼容aof-use-rdb-preamble yes5.0 起默认重写时把当前数据集按 RDB 格式放在文件开头后续命令按 AOF 追加恢复时先按 RDB 加载快再回放增量丢得少。兼容性要给个明确态度“能重启不等于能降级”。RDB 带版本号高版本写的低版本读不了启动直接报错7.x 的多文件 AOF 也不能被 6.x 直接加载。要降级得在新版本上导出重建或用redis-cli --rdb搬数据不同小版本行为有差异需按实际环境验证。4.4 从库要不要开 AOF全量同步的 RDB 从哪来主库 fork 生成落盘再传或不落盘直接写 socketdiskless。repl-diskless-sync yes在 7.0 起是默认6.x 及更早默认norepl-diskless-sync-delay 5默认值会等更多副本聚集再一起传——一次 fork 服务多个副本代价是切换后多等一下。从库开 AOF它成为可用备份源主库彻底挂掉还能从副本恢复代价是每台从库都有磁盘写放大且两边 AOF 的丢失窗口是两回事。主库开 AOF 是在守丢多少这条线从库开 AOF 是在守还有没有第二份。五、内存去哪了5.1 四个指标先分清指标视角看什么used_memory分配器的逻辑视角数据集 服务器开销used_memory_rss操作系统视角实际占用的物理页含碎片mem_fragmentation_ratiorss / used 1 有碎片 1 说明用了 swapused_memory_overhead服务器自身复制 backlog 与缓冲区、客户端输出缓冲、AOF bufferused_memory_datasetused − overhead数据本身占了多少排查顺序固定先看dataset涨没涨 → 再看overhead有没有异常 → 最后看rss − used的差值。[图used_memory / rss / overhead / dataset 四层关系]ratio明显大于 1经验上 1.5 以上值得查通常是碎片小于 1 要立刻处理——说明部分内存被换到 swap延迟会出现无法预测的毛刺。5.2 过期了内存没降的三种情况逻辑删除 ≠ 物理释放。DBSIZE走字典大小key 一删就降used_memory是分配器视角jemalloc 回收后未必立刻还给 OSrss可能不动。从库不主动过期。从库等主库的DEL传播过来才删在此之前这些 key 仍占内存只是读取时返回空。所以从库内存比主库高不一定是 bug。大 key 过期本身就是一次删除释放是 O(N) 的。lazyfree-lazy-expire默认为no用CONFIG GET lazyfree-lazy-expire确认自己实例主线程会亲自回收表现为内存降了但那一刻命令抖了一下。清理机制本身是抽样hz 10默认值dynamic-hz默认开启每轮从带 TTL 的 key 里抽 20 个源码常量不是配置项过期比例超过 25% 就再抽一轮。所以过期 key 的清除速度由采样决定不是到期即删。5.3 八种淘汰策略策略候选范围选谁淘汰noeviction—不淘汰写操作直接报 OOMallkeys-lru所有 key最久未使用采样近似allkeys-lfu所有 key使用频率最低allkeys-random所有 key随机volatile-lru仅设了 TTL 的 key最久未使用volatile-lfu仅设了 TTL 的 key频率最低volatile-random仅设了 TTL 的 key随机volatile-ttl仅设了 TTL 的 key剩余 TTL 最短默认是noeviction默认maxmemory 0不限制——两者一起意味着默认配置下 Redis 会一直吃到被系统杀掉。LRU/LFU 都是采样近似。maxmemory-samples 5默认值决定采样池大小从池里挑最久未用/最少使用的那个。它和精确 LRU 的命中率有差距采样数越大越接近代价是每次决策更费 CPU。LFU 是 8 位计数器0–255加衰减lfu-log-factor 10控制增长难度lfu-decay-time 1控制衰减速度均为默认值更适合长期热点 突发扫描因为它记得频率而不只是最后访问时间。volatile-*有个陷阱一个 key 都没 TTL淘汰就无事可做写操作直接 OOM。这是明明配了淘汰策略为什么还是写不进去的根因。5.4maxmemory到底设多少maxmemory ≤ 物理内存 − 系统与其他进程占用 − 复制缓冲区与客户端输出缓冲的峰值 − fork 期间 COW 的增量上界 − 碎片余量项怎么估系统与其他进程free -m的 used 减去 Redis 的used_memory_rss复制/输出缓冲client-output-buffer-limit各项上限之和replica 默认硬限 256mbCOW 增量看rdb_last_cow_size的历史峰值按写入比例外推碎片余量按mem_fragmentation_ratio的常态值折算关键maxmemory约束的是逻辑内存不包含 fork 时的 COW 副本。设成物理内存 100%等于把 OOM Killer 的枪口对准自己——快照或重写一来COW 把物理内存推上去内核直接杀进程。被杀时没有 OOM 日志只有dmesg里一行记录很容易被误判成Redis 自己挂了。更深一层为什么配了maxmemory进程还是被 OOM Killer 杀了因为maxmemory管的是 Redis 的逻辑内存used_memory而内核决定杀不杀你看的是物理内存RSS。这两者之间隔着一个常被忽略的差额——COW 副本、碎片、复制与客户端缓冲区。举个算术例子只是推演不是实测一台 16G 的机器maxmemory 12G看着留了 4G 余量。这时触发一次BGSAVE或 AOF 重写恰好写入又很密集COW 把大量共享页复制成私有页RSS 从 12G 被推到 16G 以上。内核触发 OOM Killer按oom_score挑一个杀掉占内存最多的那个通常就是 Redis。全程 Redis 的逻辑内存可能一直没超过 12G所以它在自己的日志里什么都没做错。结论不是maxmemory设小点更安全这么简单而是maxmemory要按物理内存 − COW 峰值 − 缓冲区 − 碎片 − 其他进程来设写入越密集留给 COW 的余量就要越大。顺手还有两件事值得做给 Redis 单独调oom_score_adj让它别第一个挨刀在容器里把maxmemory定在 cgroup 上限之下而不是照着宿主机内存算。5.5 单个 key 为什么比你想的贵SDS 头按长度选sdshdr8/16/32/64。embstr≤ 44 字节把 redisObject 与 SDS 一次分配超过就变raw两次分配。dictEntry 桶数组哈希表节点与桶本身负载因子 1扩容时翻倍。jemalloc 尺寸类按档向上取整44 字节会落到 48 或 64 字节档看 jemalloc 版本与分配路径需实测。expires 字典设了 TTL 的 key 要在过期字典里再占一份 dictEntry——加 TTL 是要付内存代价的。所以 key 名越长、数量越多overhead 占比越高。用MEMORY USAGE key实测单个再乘数量别用STRLEN推那只是下限。5.6 碎片与 activedefrag碎片来自反复增删、变长 valueAPPEND、RPUSH、不同尺寸类交替驻留。手段三个mem_fragmentation_ratio看趋势MEMORY DOCTOR直接给结论activedefrag默认no开起来后有五个阈值可调ignore-bytes 100mb、threshold-lower 10、threshold-upper 100、cycle-min 1、cycle-max 25默认值非实测。MEMORY PURGE只在 jemalloc 下有效把空闲页还给 OS。最彻底的仍是重启或主从切换但要付丢失窗口和恢复时间的代价。慎用activedefrag yes。碎片整理不是后台线程干的活它在主线程里切片执行——虽然每次只做一小段但吃的是本来该用来处理命令的 CPU 时间搬动数据时还要临时占内存。高并发写入时强行开着吞吐会出现肉眼可见的下降。所以它更像救命药不是日常保健品只在碎片确实严重mem_fragmentation_ratio长期偏高、MEMORY DOCTOR也明确提示且业务处在低峰时临时CONFIG SET activedefrag yes整理到指标回落再关掉。另外要注意它依赖 jemalloc某些构建里根本没编进去开了没反应先确认这一点别以为是参数写错了。六、命令与实验6.1 三套配置 INFO persistence逐字段# A. 纯 RDB appendonly no save 3600 1 300 100 60 10000# B. 纯 AOFeverysec appendonly yes appendfsync everysec auto-aof-rewrite-percentage 100 auto-aof-rewrite-min-size 64mb save # C. 混合推荐起点 appendonly yes appendfsync everysec aof-use-rdb-preamble yes save 3600 1改完用CONFIG REWRITE落盘或启动时指定配置文件。别用CONFIG SET appendonly yes长期放着不管——它只改运行时重启即失效。字段含义什么时候要看rdb_bgsave_in_progress是否正在快照排查延迟毛刺时rdb_last_bgsave_status上次快照成功/失败失败必须告警否则丢失窗口在悄悄变大rdb_last_cow_size上次快照期间复制的字节数算maxmemory的依据aof_last_write_status上次 AOF 写盘状态出现err说明磁盘满或权限问题aof_delayed_fsync因 fsync 未完成而延迟写的次数大于 0 就要查磁盘aof_rewrite_in_progress是否正在重写与内存上涨的时间点对照aof_last_cow_size重写期间 COW 字节数判断重写是否把内存顶上去字段在各版本有增减以实际输出为准需按实际环境验证。6.2DEL与UNLINK的差别怎么观察# 造一个大 key沿用第 01 篇 5.4 的做法seq1100000|awk{print RPUSH biglist item-$1}|redis-cli--piperedis-cli MEMORY USAGE biglist redis-cli CONFIG RESETSTAT redis-cli DEL biglist redis-cli INFO commandstats|grepcmdstat_del# 再建一次改用 UNLINKseq1100000|awk{print RPUSH biglist item-$1}|redis-cli--piperedis-cli CONFIG RESETSTAT redis-cli UNLINK biglist redis-cli INFO commandstats|grepcmdstat_unlinkredis-cli INFO stats|greplazyfree_pending_objectscmdstat_*的usec_per_call是命令在主线程花的时间lazyfree_pending_objects是后台待回收对象数UNLINK后会先出现非零值再回落到 0。前者包含真正的内存释放后者只把 key 从字典摘掉、释放交给后台线程所以DEL的usec_per_call更高。具体差值取决于元素数量与机器留【本机实测待补】多次重复取平均单次观测噪声很大。6.3 容量估算脚本思路是先量单 key再乘数量最后用系数覆盖字典与碎片而不是背一个系数。#!/usr/bin/env python3Redis 容量估算算出来的是量级不是精确值。importargparse,jsondefestimate(daily_writes,key_len,val_len,keep_days,overhead,replicas):per_key_floorkey_lenval_len# 只是下限不含 SDS 头、dictEntry、size class 取整keysdaily_writes*keep_days datasetkeys*per_key_floor extradataset*(overhead-1)# 字典、头部、取整、expires 字典totaldatasetextrareturnkeys,total,total*(1replicas)if__name____main__:pargparse.ArgumentParser()p.add_argument(--writes,typeint,requiredTrue,help日均写入 key 数)p.add_argument(--key-len,typeint,requiredTrue)p.add_argument(--val-len,typeint,requiredTrue)p.add_argument(--days,typeint,default30)p.add_argument(--overhead,typefloat,default1.35,help经验系数必须用下面的校准步骤在你自己的实例上量出来)p.add_argument(--replicas,typeint,default1,help从库数量)ap.parse_args()keys,total,with_replestimate(a.writes,a.key_len,a.val_len,a.days,a.overhead,a.replicas)print(json.dumps({keys:keys,MB_主库:round(total/1024/1024,1),MB_含从库:round(with_repl/1024/1024,1),提醒:overhead 是经验值未校准前不要拿它做采购决策,},ensure_asciiFalse,indent2))校准比脚本本身重要foriin$(seq11000);doprintfSET sample:%s %s\r\n$i$(printfx%.0s$(seq1180))done|redis-cli--piperedis-cli MEMORY USAGE sample:1# overhead ≈ MEMORY USAGE 结果 / (key 名长度 value 长度)拿到真实系数再回头跑脚本。key 名长度、是否带 TTL、value 是 JSON 还是短串系数都不同——别用一个系数算所有业务。6.4 定位内存涨了、数据没涨redis-cli--bigkeys-i0.1# 走 SCAN-i 让每 100 条命令歇 0.1 秒降低对线上的影响redis-cli MEMORY USAGE product:1001 SAMPLES0# 0 表示精确遍历所有元素慢慎用redis-cli MEMORY STATS|grep-Epeak.allocated|total.allocated|dataset.bytes|overhead.totalredis-cli INFO memory|grep-Eused_memory:|used_memory_rss:|used_memory_overhead:|used_memory_dataset:|mem_fragmentation_ratio|maxmemory_policy判定路径used_memory_dataset没涨 → 不是数据变多继续。used_memory_overhead涨 → 查复制缓冲区、客户端输出缓冲INFO clients的client_recent_max_output_buffer。rss − used_memory涨 → 碎片见 5.6。都不涨但进程被杀 → 查 fork 期间的 COW 峰值rdb_last_cow_size与aof_last_cow_size。6.5 两个能复现的实验过期不等于释放redis-cli SET k:expvPX1000redis-cli DBSIZEsleep2redis-cli DBSIZE# 通常已经降了redis-cli INFO memory|grepused_memory: redis-cli MEMORY USAGE k:exp# nil单个小 key 的释放对used_memory是噪声级别看不出变化。要看批量过期写一万个带 TTL 的 key 再对比前后差值留【本机实测待补】重点看used_memory与rss是否同步下降。淘汰策略踩空redis-cli CONFIG SET maxmemory 100mb redis-cli CONFIG SET maxmemory-policy volatile-lru redis-cli SET nokey:1v# 这些 key 都没设 TTLredis-cli SET nokey:2v# → (error) OOM command not allowed when used memory maxmemoryredis-cli CONFIG SET maxmemory0# 实验结束还原报错文案会明确告诉你内存超了但不让淘汰。实验完记得还原别把实验配置留在生产上。七、踩坑清单把everysec当零丢失—— 我见过 SLA 里写着Redis 数据零丢失方案 AOF everysec的。它是上限描述磁盘一慢窗口就比 1 秒长aof_delayed_fsync会替你记着。以为 RDB 快照期间主线程全程阻塞—— 我写错过这句话被同事纠正。阻塞在 fork 那一瞬之后是 COW 导致内存上涨两件事别混。把 Redis 当唯一存储拿持久化当 WAL—— 持久化是尽力恢复到最后一个完整命令不是事务日志没有事务边界、没有回滚、没有业务语义上的校验。真不能丢的数据宿主要在数据库。不设maxmemory或设成物理内存 100%—— 我见过进程被内核杀掉后一群人查了半小时代码最后在dmesg里看到真相。maxmemory不含 COW 副本这是最常被漏掉的一点。也别以为设到 70% 就安全写入密集时 COW 照样能把 RSS 顶穿。以为 LRU 是精确 LRU—— 采样近似maxmemory-samples 5。命中率与理想值有差距key 多、访问分散时更明显。以为 key 过期就立刻释放内存—— 抽样清理 分配器不归还DBSIZE降了rss不动是常态不是 bug。混用volatile-*却没给 key 设 TTL—— 淘汰无对象可选写操作直接 OOM。这条在只给部分 key 设了 TTL的系统里最容易出现。八、小结丢失窗口由appendonlyappendfsync决定RDB 决定恢复速度不决定丢多少。fork 只阻塞一瞬代价在之后的 COW。rdb_last_cow_size是最直接能验证这件事的字段。内存要分层看dataset → overhead → 碎片顺序别乱maxmemory必须给 COW、缓冲区、碎片留位置。下一篇换个维度我们配好了持久化算好了maxmemory以为数据万无一失了。但持久化只能防宕机不能防机器故障。磁盘坏了、整台机器没了、机房断了AOF 再完整也读不出来。第 04 篇讲主从、哨兵与 Cluster——三种拓扑各自的代价以及切换窗口里那些没人告诉你的坑。自检三问你的实例appendfsync是什么如果现在断电最多丢多久的写入——这个答案你验证过还是猜的INFO persistence里rdb_last_bgsave_status和aof_last_write_status有没有配告警上一次失败是什么时候你的maxmemory是按什么算出来的扣掉 fork 的 COW 增量之后还剩多少真正可用
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑