综合 Valkey 9.2 forkless BGSAVE 实测:内存尖峰从 350MB 降到 10MB,代价是慢 70%

2026-10-04 20:03:48

Valkey 9.2 forkless BGSAVE 实测:内存尖峰从 350MB 降到 10MB,代价是慢 70%

项目地址:

我在繁忙的 Redis 或 Valkey 实例上见过的每一次 BGSAVE 都是同一个剧本:进程 fork,接下来几秒钟宿主机可用内存像被拔了插头一样往下掉。9 月 16 日在 Docker Hub 打上 tag 的 Valkey 9.2.0-rc1 提供了完全跳过 fork 的方式。我想知道它实际买到了什么,而不是发布说明说它买到了什么,所以在同一份数据集上把两条路径都跑了一遍,测了实际发生的事。

短版本:内存尖峰几乎消失,保存明显变慢。

测试环境

我拉取 valkey/valkey:9.2.0-rc1,用同一个镜像起了两个容器,只差一个设置。第一个用默认值 bgsave-default-method fork。第二个用 --forkless-infrastructure-enabled yes --bgsave-default-method forkless 启动,这是打开它的唯一方式。

往每个实例里写入 3,000,000 个 key,每个 300 字节,一共 1.1GB 多一点数据(used_memory 报的是 1.04GiB)。然后对每个容器开 8 个线程,尽可能快地对着随机的已存在 key 猛打 SET,同时从第 9 个连接每 2ms 发一次 PING 并计时往返。每个 12 秒窗口的第 2 秒触发 BGSAVE。每种模式重复 7 次。

这个设置重要的原因只有一个:只有当 fork 持有页面期间真的有页在被写,copy-on-write 才有成本。对空闲数据集做 BGSAVE 几乎说明不了任何问题。我的数据集不是空闲的——8 个写线程以大约每秒 27,000 次操作的速度打进运行 fork 模式的那个容器。

内存尖峰,用两种方式测量

我跟踪两个数字:RDB 报告的 copy-on-write 大小(INFO persistence 里的 rdb_last_cow_size),以及容器自身 cgroup 内存用量,保存期间每 150ms 采样一次。

fork(默认)forkless
rdb_last_cow_size,7 次运行均值354.7MB0MB(始终)
save 期间 cgroup 内存增量,4 次运行均值354.4MB9.9MB
save 墙钟时间(rdb_last_bgsave_time_sec)每次都是 3s5-6s

两个内存测量结果互相吻合,这正是同时测两个的意义:内核自己的 copy-on-write 统计和一份独立的 cgroup 内存采样,通过两个互不相关的仪器收敛到同一个数字。在 1.1GB 工作集、持续写入的条件下,fork 每次都要多花掉大约数据集三分之一大小的内存,7 次运行的区间是 319.6MB 到 367.5MB,收得很紧。Forkless 从未超过 10.0MB。

这是最醒目的结论,而且我每次跑都成立。但它不是全部。

更平滑的内存曲线付出了什么

forkless 的保存耗时 5 到 6 秒,而 fork 稳定在 3 秒。这不是舍入误差——同样的数据慢了大约 70%,每次测都是如此,两边各 7 次。

它还吃掉了写吞吐。在围绕每次保存的 12 秒窗口内,8 个写线程在 fork 模式下平均每秒 27,202 次操作,在 forkless 下是 23,034 次——少了约 15%。forkless 里做序列化的后台线程不是免费的:它和主线程争同一把锁、同一份 CPU 预算,而且争得更久,面向客户端的吞吐把这个成本体现了出来。

所以这笔交易两边都是真实的:fork 的内存尖峰猛烈而短暂;forkless 让内存几乎持平,但花的时间更长,并且在整个执行过程中持续占用你的写吞吐。没有哪一边是免费的。如果你的宿主机内存吃紧,forkless 就是你要的那个。如果宿主机内存有富余、写路径更看重吞吐而不是短暂的内存上扬,那 fork 默认造成的伤害比它的内存曲线看起来要小。

发布说明提到的那个暂停

唯一写在文档里的注意事项是:如果主线程写入一个后台序列化线程还没处理到的 key,那个客户端的请求会短暂停顿,直到这个 key 被移到队首。我在延迟采样里去找这个停顿。

fork(默认)forkless
12 秒窗口内最差单次往返(7 次运行区间)11.5ms – 25.7ms5.1ms – 14.2ms
最差往返均值20.1ms7.2ms

fork 稳定地roughly 三倍于 forkless。但 forkless 也不是完全平滑:有一次跑到了 14.2ms,几乎是它自己平均最差情况的两倍,这和撞上那个有文档记录的停顿、碰上一个不走运的 key 是吻合的。这个特性收窄了尾部,并没有抹平尾部。

它拒绝什么,还有哪些路径仍然 fork

forkless-infrastructure-enabled 不能对运行中的服务器用 CONFIG SET 打开:

$ valkey-cli config set forkless-infrastructure-enabled yes
(error) ERR CONFIG SET failed (possibly related to argument 'forkless-infrastructure-enabled') - can't set immutable config

它只能写在命令行上,或者启动时的配置文件里,这意味着在现有集群上采用它需要重启,而不是热推配置。不带这个 flag 直接选 method 也会失败,报错至少告诉了你原因:

$ valkey-cli config set bgsave-default-method forkless
(error) ERR CONFIG SET failed (possibly related to argument 'bgsave-default-method') - 'forkless' can only be selected when the server was started with 'forkless-infrastructure-enabled yes'

已经有一个 BGSAVE 在跑时再发第二个,两种模式下都会被直接拒绝,报 ERR Background save already in progress。

更有用的限制在这里:打开 forkless-infrastructure-enabled 只改变 RDB 快照的生成方式。我在启用了 forkless 的实例上打开 appendonly,这会触发 Valkey 自动的初始 AOF rewrite,随后查看 aof_last_cow_size,返回的数值大约是 10MB,而不是 0。AOF rewrite 仍然 fork,跟 forkless RDB 设置无关。如果你真正的痛点是 AOF rewrite 停顿而不是 BGSAVE 停顿,这个特性完全不碰那条路径。

观察它运行

发布说明承诺用于跟踪进度的 INFO 字段确实存在,并且在保存过程中确实有值:

$ valkey-cli info persistence | grep -E 'save_keys|remaining'
current_save_keys_processed:555092
current_save_keys_total:3000000
forkless_estimated_seconds_remaining:3

在同一个 300 万 key 数据集上触发保存后 1.5 秒轮询,这是一个可信的进行中的数字,还附带一个合理的预估,不是占位符。

我中途搞错的地方

我第一版延迟探针用 time.perf_counter() 的读数(独立的单调时钟,零点任意)减去 time.time()(墙钟)来记录每个样本的偏移。两者不可比,所以第一批 CSV 里的每个时间戳都变成了毫无意义的负数,量级在十亿级。已经打到 stdout 的聚合百分位数没受影响,但逐样本文件没法把延迟尖峰对齐到 BGSAVE 开始的确切时刻。修法是在运行开始时记录一个墙钟 epoch,再往上加单调增量,然后重跑受影响的试验。教训还是那句:在相信一个计时结果之前,先确认你的埋点用的时钟是你以为的那个。

自己跑一遍

docker run -d --name vk-forkless -p 16379:6379 valkey/valkey:9.2.0-rc1 \
--forkless-infrastructure-enabled yes --bgsave-default-method forkless --save ""
docker run -d --name vk-fork -p 16380:6379 valkey/valkey:9.2.0-rc1 --save ""

# then load 3,000,000 keys of 300 bytes into both, run 8 writer threads doing SET,
# and a 9th connection sending PING every 2ms; fire BGSAVE two seconds into a 12s run

docker exec vk-fork valkey-cli bgsave
docker exec vk-forkless valkey-cli bgsave
docker exec vk-fork valkey-cli info persistence | grep -E 'rdb_last_cow_size|rdb_last_bgsave_time_sec'
docker exec vk-forkless valkey-cli info persistence | grep -E 'rdb_last_cow_size|rdb_last_bgsave_time_sec'

只跑到这一步、对着空闲数据集,我得到的 rdb_last_cow_size 大约是 12MB,远不到上表里的 350MB。这个差距就是全部的重点:只有保存运行期间页面真在被写,copy-on-write 才有成本,所以要看真实效果,需要完整测试里的 8 线程写负载,而不是这个精简版本。

如果你在宿主机上确实被 BGSAVE 期间的内存余量咬过——不是假设,而是真的 OOM,或者真的由保存触发的驱逐风暴——那在 9.2 以稳定版发布之前,值得拿你自己的数据形态测一次。如果你的问题反过来,是保存太慢、运行期间抢走太多写吞吐,那在切换默认值之前先量一下,因为这个版本并没有让 BGSAVE 变得更便宜。它只是把成本显现在了别的地方。

复制全文 生成海报 Valkey Redis 运维 性能 快照

推荐文章

程序员茄子在线接单