老冯最近在 Pigsty 里面更新了 Redis 模块,把 Valkey 也打包进来作为一个可选引擎,结果在打包的过程中发现了一个上游的 BUG。 我给 Valkey 和 Debian 上游提交了修复,本文记录了这个过程。
内存炸裂
先看一个天文数字:18446744073709518664。
如果你的 Valkey 某天在 INFO memory 里报出这么一个数字,别怀疑服务器突然长出了 18 EB 内存。 这是一个 64 位无符号整数从零以下减穿之后,绕回来的结果。
在修复前发布的 Valkey 官方 DEB 包中,如果一次同步 SAVE 因为磁盘满、权限错误或者只读文件系统而失败,实例记录的 used_memory 就会悄悄减少一点。 失败次数足够多,计数器便会从零以下回绕到接近 2^64 的天文数字。
此时只要配置了 maxmemory,那些受内存上限检查约束的写命令就会开始返回 OOM。进程的实际 RSS 可能完全正常,日志里也没有什么明显线索,但 Valkey 坚信自己已经用掉了十几艾字节内存,直到重启才会恢复。
同一份补丁如果用在内置私有 jemalloc 的构建上,后果更干脆:第一次失败的同步存盘,就可能直接把进程打崩。
在 这个修复前[1],Valkey 7.2、8.0、8.1、9.0、9.1 五条官方 DEB 产品线,在 Debian 12、Debian 13、Ubuntu 22.04、Ubuntu 24.04 和两种 CPU 架构上都能复现。 RPM 没带这份补丁,所以不受影响;原始的 Valkey 上游源码也没有这个问题。老冯提了个 PR,Valkey 已经修了这个问题。
![]()
触发条件并不算宽。持久污染主要发生在主进程执行同步 SAVE 时;BGSAVE 和周期性存盘发生在子进程中,错误不会把主进程的内存一并改坏。 但这反而让它更阴险:它专挑你已经出事的时候,在旁边再点一把火。磁盘已经满了,快照已经失败了,监控或者备份脚本又在那里不断重试。 原本只是一次存储故障,最后却变成了内存统计穿越、业务写入 OOM,或者进程直接崩溃。
有趣的是:这不是 Valkey 内核里的 Bug —— 它是打包环节制造出来的。
雷不在上游源码里
Valkey 在 RDB 落盘失败时,会把当前工作目录打印到日志里。上游原版使用栈上的固定缓冲区,不需要做任何动态内存管理。
Debian 的打包补丁把它改成了堆分配:
char *cwdp = get_current_dir_name();
serverLog(LL_WARNING, "... in server root dir %s ...", cwdp, ...);
zfree(cwdp); /* 问题在这里 */
动机非常正常:不要在栈上放一个固定大小的 4 KB 数组,也不要依赖PATH_MAX。
问题在最后一行。
get_current_dir_name()返回的内存遵循普通的malloc()/free()语义;zfree()却是 Valkey 自己的内存释放接口。它并不是给free()换了个名字,而是会先向分配器查询这块内存有多大,再从 Valkey 自己的used_memory账本里扣掉这笔数字,最后才执行实际释放。
这块内存从来没有通过 Valkey 的zmalloc()分配,进账时没有记在used_memory里,出账时却照扣不误。
这不是一次普通的释放器错配,而是一笔只出不进的假账。
至于它是静默记错账,还是当场崩溃,取决于 Packaging 阶段选择了哪套 jemalloc。
Valkey 官方 DEB 使用操作系统发行版提供的 jemalloc。它接管了全局的malloc()和free(),所以这块指针至少仍然落在同一个实际分配器中,错误释放通常不会立即崩溃;但zfree()仍然会错误修改 Valkey 的内存统计。如果你选择直接从源码编译,、或者链接了内置 jemalloc 的版本,那更干脆:一次失败的SAVE直接段错误挂掉。
同一份补丁,因为链接方式不同,一个表现为悄悄腐蚀计数器,另一个表现为第一次失败就段错误。
这就是很多人容易忽略的一件事:用户实际运行的软件,并不只由上游源码决定。编译参数、依赖库和分配器选择,同样会改变程序的行为。Packaging 从来不是把源码塞进一个.deb文件那么简单。
它是对软件做的最后一次改写。
一条补丁绕了九年
顺着这份补丁的历史往回翻,它的血脉可以追到 2017 年。那一年,Debian 开发者 Chris Lamb 给 Debian 的 Redis 包写了一份补丁,用动态分配的get_current_dir_name()替代固定大小的工作目录缓冲区。
这是一项很正常的打包改进,最初也没有那行有问题的zfree()。
2020 年,Redis 的这份补丁中出现了相同的释放器错配,并触发了崩溃。Redis Issue 和 Debian Bug 记录了几乎一样的调用栈,Debian 随后删除了那几处错误释放。
![]()
后来,Debian 从 Redis 的打包体系中派生出了 Valkey 打包,这份补丁也跟着迁了过去。在后续演化中,zfree(cwdp)又一次出现在 Valkey 的补丁里。同一个错误,沿着同一条补丁血脉,隔了几年重新活了过来。
![]()
2025 年 3 月,Valkey 维护者 zuiderkwast 在研究系统 jemalloc 支持时,已经当场看出了问题:前面走的是普通malloc(),后面却用zfree(),这会搞乱 Valkey 的内存统计。这个判断完全正确。只是当时他的注意力放在 system jemalloc 上,所以结论停留在 “统计会出错”,没有继续发现:如果启用这个补丁换成内置私有 jemalloc 后,这条路径会直接 Seg Fault。
到了 2026 年 4 月,Valkey 建立了一套官方自动打包流水线,覆盖多条版本线、RPM 与 DEB,以及 40 个操作系统和架构组合。为了快速获得成熟的 DEB 打包能力,官方仓库把 Debian 的打包文件和补丁栈搬运了进来。于是,一个原本只存在于下游发行版中的补丁 Bug,沿着下面这条路线走了一圈:
Redis 上游 → Debian Redis → Debian Valkey → Valkey 官方发布仓库。
Valkey 官方不是从自己的主仓库中引入了一个 Bug,而是从 Debian 那里把自己原本没有的 Bug 搬了回来。官方发布的 DEB 包因此获得了一项上游源码并不具备的 “特色功能”。这是整件事最有黑色幽默感的地方。
开源世界通常是下游从上游拿源码,再打上一些自己的补丁。到了这里,官方项目为了发布 DEB,又反过来从下游把补丁整体抄回来。它复制了 Debian 多年积累的打包经验,也复制了 Debian 补丁栈里遗留的错误。
后来 zuiderkwast 在我的 PR 下面直接问了一句:
Why did we copy Debian’s patches?
这个问题比那一行zfree()更重要,因为真正需要解释的,已经不再是“这一行为什么写错”,而是:
为什么一份脱离上游主干、缺少原始上下文的下游补丁,会未经完整功能回归,重新进入官方发布链路?
测试一直在,只是测错了层
这颗雷最终能被翻出来,不靠什么高深的静态分析。
靠的是跑测试,而且是 codex 去跑测试。
我在给 Pigsty 重新梳理 Valkey 的 DEB 打包时,例行跑了一遍上游自带的runtest。unit/shutdown当场挂了三个用例,服务端留下了一份崩溃报告。Pigsty 的包使用内置 jemalloc,所以我们正好落在“第一次失败便直接崩溃”的分支上。症状比官方 DEB 剧烈得多,反而更容易定位。
顺着调用栈往上翻,最后就翻到了debian/patches下面那行zfree(cwdp)。
而那个失败的测试,写得非常有针对性:它故意创建一个名为dump.rdb的目录,让最后的rename(2)必然失败,从而进入这条冷门错误路径。这个测试早就写好了。只要运行,就能抓住问题。
为了确认不是偶然现象,我又让 Codex 把不同 Valkey 版本、不同补丁状态、不同分配器和两种 CPU 架构组合起来,做了一轮交叉构建与复现。原始上游源码测试全过;应用错误补丁后稳定崩溃;把释放函数改正确后,测试再次全过。
问题在于,Valkey 官方的打包流水线没有运行这套上游测试。这里需要说得更准确一点:官方流水线并非“完全没有测试”。它会检查软件包能不能安装,二进制是否存在,systemd 服务能不能启动,安全加固、兼容符号链接和卸载流程是否正常。这些都是合格的 Packaging 测试,也很有必要。
但 Debian 打包规则中的override_dh_auto_test是一个空目标,真正运行 Valkey 上游回归测试的命令被注释掉了;那些被注释的命令后面,甚至还跟着一个吞掉错误的|| true。它测了包装盒是否完整,测了开箱后机器能不能通电,却没有运行机器自己的自检程序。于是出现了一个非常典型的质量接缝:
上游没有测到,因为上游源码里根本没有这份补丁;打包流水线没有测到,因为它没有运行上游功能测试。
这个 Bug 正好活在两套质量体系之间。上游 CI 是绿的,打包 CI 也是绿的,官方包照常发布。三件事可以同时成立,因为两边测试的根本不是同一份东西。
修复只有一行,问题不止一行
技术上的最小修复非常简单:把zfree(cwdp)换成zlibc_free(cwdp)。
Valkey 专门提供了zlibc_free(),用于释放不属于 Valkey 自己内存账本的分配。直接调用free()反而编译不过,因为 Valkey 有意把free()标记成 deprecated,构建又开启了-Werror。
![]()
这也很可能解释了当初为什么有人会写出zfree():作者试图修复一个真实的内存泄漏,先写free(),被编译器挡了回来,于是换成了一个看起来最接近、又能通过编译的函数。
一个原本为了防止开发者绕过内存统计而设计的安全护栏,反过来把人推向了错误答案。
事情牵涉 Debian 和 Valkey 两边,我分别提交了 Bug 报告和上游 PR。给 Debian 报告时还被 Apple Mail 的富文本格式坑了一次:正文里的伪邮件头没有被 BTS 识别,第一封报告被系统整个忽略,切成纯文本才成功拿到 Bug 编号。
PR 很快获得批准,但 zuiderkwast 随后追问:既然这份补丁只是在一条冷门错误日志里避免使用 4 KB 栈数组,为什么还要保留它?这个问题问得对。我随后在 Pigsty 的 Valkey 和 Redis 包中直接删除了整份 Debian 打包补丁,重新构建并运行测试。用户可见行为没有变化,错误日志照常打印,测试全部通过,问题也随着补丁一起消失了。
这比把错误的zfree()换成正确的zlibc_free()更彻底。最安全的补丁,往往是根本不需要维护的补丁。
2026 年 8 月 7 日,修复 PR 被合并,五条版本线中的补丁副本全部得到修正。随后,维护者开始重新审视官方发布仓库中整套从 Debian 搬来的补丁。
![]()
但写到这里,这篇文章真正想讲的已经不只是 Valkey 了。
打包不是装箱
做 Pigsty 发行版越久,我越确信一件事:
打包不是把别人写好的软件装进纸箱,而是软件工程的最后一道生产工序。
用户最终运行的,从来不是 GitHub 上的那棵源码,而是下面这些东西共同组成的产物:
上游源码、下游补丁、编译器与编译参数、系统依赖、链接方式、默认配置、目录与权限、服务脚本、升级规则,以及最终到底跑了哪些测试。
一个软件包,实际上冻结了维护者对这些问题的全部判断。
好的打包可以弥补上游的不足:回补尚未进入稳定版本的修复,处理不同操作系统和依赖版本的兼容性,选择更稳妥的编译参数,修正默认配置,补齐服务管理、升级、回滚和安全加固。
坏的打包也可以反过来制造上游根本不存在的问题:补丁写错、编译参数改变语义、分配器换了一套、依赖库版本不匹配,或者干脆把最关键的测试跳了过去。
打包封装是最后一道质量防线
这起 Valkey 事故几乎把 Packaging 的作用演示得不能再清楚:
•上游源码本身没有问题;•下游补丁引入了错误;•编译时选择哪种 jemalloc,决定错误是静默腐蚀还是直接崩溃;•官方发布流水线又把下游补丁复制到自己的包里;•包级安装测试通过了,上游功能测试却没有运行。
同一个项目、同一个版本号,仅仅因为打包方式不同,用户得到的就可能是三种完全不同的行为。
这也是发行版存在的意义。
Pigsty 现在维护着几百个扩展和组件,打包仓库中的 patch 有一百多个。它们有些用于修复上游缺陷,有些处理不同 PostgreSQL 版本、操作系统、编译器和系统库之间的兼容问题,还有一些只是为了让软件真正符合生产环境的目录、权限和运维约定。
![]()
我坐在同一个火山口上。
所以这件事对我不是一则可以站在旁边点评的开源趣闻,而是一次非常直接的警告:每一份 patch 都应该被当成生产代码来审查;每一个最终生成的软件包,都应该在真实目标环境中重新运行上游测试;每一种编译参数和依赖组合,都可能产生不同的行为。
我愿意把话说得更难听一点:
没有测试最终交付的软件包,就谈不上真正支持这个软件。最多只是把一个二进制文件放进了仓库。
发行版的质量,也不应该用“收录了多少组件”来衡量。
更重要的问题是:这些组件到底有没有在目标系统上被正确构建,有没有运行完整的回归测试,升级和降级是否安全,默认配置是否合理,出错时能不能恢复,维护者是否清楚每一份补丁为什么存在、何时可以删除。
上游项目提供的是原材料。发行版交付的才是用户真正运行的产品。
所有人都做对了自己的那一步
复盘整条链路,会发现这件事里没有一个特别愚蠢的反派。
2017 年写补丁的人,是想避免固定栈缓冲区;后来加释放的人,是想修复真实的内存泄漏;free()被禁止,是为了防止开发者绕开 Valkey 的内存统计;2025 年的维护者一眼就看出了错配,只是低估了不同构建方式下的后果;官方发布流水线复用 Debian 补丁,也是因为 Debian 长期以来以打包质量著称。
每个人都在解决自己眼前的问题。每一步单独看,都有合理解释。凑在一起,却变成了一颗进入五条官方产品线的雷。
这种事故最麻烦的地方,就在于它不属于某一个明确的责任域。补丁不在上游主仓库的日常 Code Review 中,不在上游 CI 测试的源码树里,也不容易出现在普通用户查看的git log中。它从 Redis 复制到 Valkey,从 Debian 复制到 Valkey 官方,每复制一次,原始背景就少一点。
上游认为这是发行版自己的改动;发行版认为这是一份继承已久的成熟补丁;官方打包认为 Debian 已经替自己做过质量把关;用户最终拿到的是编译好的二进制,压根看不到里面打过什么 patch。
三方都默认有人看过。结果是谁也没有完整地看。所以真正的问题不在某一个人,而在那个没人完整负责的交界面。而 Packaging,恰恰长期处在这样的交界面上。
Agent 最该干的,不是再写一堆数据库
今年 PGConf 上,我做完Extension for Everyone的主题演讲之后,PGDG APT 仓库维护者 Christoph Berg 问了我一个非常实际的问题:这么多包,你都是怎么测试的?
这确实是发行版维护中最难回答的问题。
一个人不可能手工检查几百个组件、多个 PostgreSQL 大版本、多个 Linux 发行版和两种 CPU 架构,更不可能把每个软件的冷门错误路径、升级路径和异常场景全部走一遍。
我的回答是:除了项目自带的构建与回归测试,我会让 Codex 对最终软件包再做一轮冒烟和场景测试。按照用户真实使用这个软件的方式去安装、启动、连接、执行操作、制造错误,再观察它会不会在某个不起眼的地方露出问题。
这次的 Valkey Bug,就是这样翻出来的。测试是 Agent 跑的,交叉构建矩阵是它搭的,历史补丁是它追的,Debian 报告和 GitHub PR 是它起草的。后来我又让另一个 Agent 对调查结论做对抗性审查,它又找出了几处过度概括和事实错误。
这并不意味着 Agent 可以自动给出正确答案。恰恰相反,它第一次经常也不对。真正的价值在于:让它反复验证、推翻自己、重新编译、重新复现的成本,已经低到了人类很难做到的程度。
还有一个颇有黑色幽默感的细节:Valkey 官方打包流水线在合并之前,也使用 Claude 做过代码审查,找出了不少 GitHub Actions、安全性和构建稳健性问题;但那颗藏在补丁里的 allocator Bug 依然漏了过去。
这不说明 Claude 不行,也不说明 Codex更聪明。区别在于任务。一个 Agent 在看流水线代码,另一个 Agent 把最终软件包真的构建出来、安装起来、运行起来,然后故意把它弄坏。前者做的是 Review,后者做的是实验。
而软件工程最终相信的,应该是实验。
现在 AI 圈最热闹的用法,是让 Agent 去 vibe coding,或者宣布要重写 PostgreSQL、重写 Redis、重写操作系统。这样的故事听起来宏大,也很容易吸引眼球。但在基础设施领域,真正稀缺的往往不是更多代码。
缺的是验证。
让 Agent 再写一套数据库,最后只会给世界增加一套新的代码、补丁、依赖和供应链风险;让它把现有数据库在几个发行版、多个版本、两种架构上,把正常路径、错误路径、升级路径和边界条件一遍遍跑完,没那么性感,却更接近真实的工程价值。
人类不愿意为一个内存计数器搭十几份编译环境,不愿意跑几十遍崩溃复现,不愿意翻九年的补丁历史,也不愿意为了确认搜索结果可靠,再专门设计一组对照实验。
Agent 不嫌烦。
你只要给它一个清楚的目标,它可以把这些过去性价比极低、因此长期无人问津的质量工作做到底。
这才是 Agent 在测试和发行版工程中最值得期待的用途:
不是帮我们生产更多未经验证的代码,而是把已经准备交付给用户的代码,验证得更彻底。
与其让 Agent 再写一个数据库,不如先让它把你准备发出去的每一个包,认真跑一遍。
现在,至少不再缺有耐心的测试者了。
本文AI含量:50% 顺便一提,测试,Issue,Bug Report,都是 Codex 和 Claude 安排进行的。
![]()
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.