跳过正文
  1. 数据库老司机/

祖传 Bug:Valkey 官方 DEB 包里的一颗定时炸弹

·4411 字·9 分钟· ·
冯若航
作者
冯若航
Pigsty 创始人, @Vonng
目录

老冯最近在 Pigsty 里面更新了 Redis 模块,把 Valkey 也打包进来作为一个可选引擎,结果在打包的过程中发现了一个上游的 BUG。

如果你的 Valkey 跑在 Debian / Ubuntu 上,某天磁盘满了、或者数据目录权限不对,导致快照存不下去——那么每失败一次,这个实例的内存计数器就会往下掉一点。 掉到零以下会回绕成 18446744073709518664。此后只要你配了 maxmemory,它就会拒绝所有写入,直到重启。

如果你用的是从源码编译、或者链接了内置 jemalloc 的版本,那更干脆:一次失败的 SAVE 直接段错误。

原因是一行打包补丁里的代码。它的血脉能追到 2017 年,在 Redis 上被修过一次,又在 Valkey 上原样复发,最后被 Valkey 官方自己抄进了发布流水线——download.valkey.io 上 7.2 到 9.1 的 每一个 DEB 包都带着它。

补丁现在已经被 Valkey 上游合并了,Debian 提了 Bug 但是还没有回应。 这篇文章讲讲这颗雷是怎么被翻出来的 —— 有趣的是,这是 Codex 测出来的。


问题出在哪
#

Valkey 源码里有两处错误处理路径,在快照落盘失败时把当前工作目录打进日志。用的是栈上的缓冲区,不需要任何内存管理。

Debian 的打包补丁把它改成了堆分配:

char *cwdp = get_current_dir_name();        /* glibc 的 malloc() */
serverLog(LL_WARNING, "... in server root dir %s ...", cwdp, ...);
zfree(cwdp);                                /* ← 雷在这一行 */

动机很正当:避免在栈上放一个 4 KB 的数组。

问题是最后一行。get_current_dir_name() 用的是 glibc 的 malloc(),而 zfree()Valkey 自己的释放器。这俩不能配对。

很多人第一反应是「不就是个 free 吗」。但 zfree() 不是 free() 的马甲,它干三件事:问分配器这块内存多大、used_memory 里减掉这个数、然后才真正释放。

第二步无条件出错——这块内存从来没经过 Valkey 的分配器,used_memory 里根本没加过它,现在却要减掉。而 used_memory 是无符号整数,减到零以下就回绕。

至于第一步和第三步会不会出事,取决于编译时链了哪个 jemalloc。用系统的 libjemalloc.so(Debian、Ubuntu 和官方 DEB 包都是),指针至少属于对的堆,不崩,只是计数被腐蚀;用内置的私有 jemalloc(上游默认),它在自己的内部结构里查不到这个指针,直接解引用空指针:

valkey-server(je_malloc_usable_size+0x68)
valkey-server(valkey_free+0x28)
valkey-server(rdbSave+0x18c)
valkey-server(saveCommand+0x4c)

这个栈顶眼熟吗?跟 2020 年 redis/redis#7927 一模一样——那次是 Redis 上的同一个 Bug。

好消息是:只有主进程里的同步存盘会累积。BGSAVE 和周期性存盘是 fork 出去的,损坏随子进程一起消失。所以现实里的触发条件是磁盘满、权限错、只读文件系统,加上有东西在反复调同步 SAVE——监控脚本、备份 cron,或者一个不断重试的客户端。

它专挑你已经出事的时候,在旁边再点一把火。


怎么发现的
#

说来惭愧,不是靠什么高深分析,是靠上游自己的测试套件

老冯在给 Pigsty 重新梳理 Valkey 的 DEB 打包时,例行跑了一遍 runtestunit/shutdown 挂了三个用例,服务端还留下一份崩溃报告。

Pigsty 的包用的是内置 jemalloc,所以我们落在崩溃那一侧,症状比官方包剧烈得多,反而更容易抓住。顺着崩溃栈往上翻,打开 debian/patches/0003-*.patch,那行 zfree(cwdp) 就摆在那儿。

tests/unit/shutdown.tcl 里那个测试,是故意把 dump.rdb 建成一个目录来逼 rename(2) 失败的——正好踩中那两行代码之一。

确认机制之后,老冯做的第一件事是不相信自己的结论:把 Valkey 的两个版本各编译三份(原版、打补丁、改用正确的释放器),分别链三种分配器,在两种 CPU 架构上各跑一遍。结论稳定。

然后花了比技术分析更长的时间去查「是不是早有人报过」。这一步踩了个坑值得说:Debian 的两个搜索端点当时是坏的,对已知存在的 Bug 都返回零结果。要不是顺手加了「这个查询应该有结果」的对照组,我就会拿着一个「查无此 Bug」的假结论往下走了。

结论:没人报过。


这行代码是怎么来的
#

顺着补丁头往回翻,这事的年头比我想的久:

From: Chris Lamb <lamby@debian.org>
Date: Thu, 16 Nov 2017 03:40:26 +0900
Subject: Use get_current_dir_name over PATHMAX, etc.
  • 2017 年,Chris Lamb 给 Debian 的 Redis 打包写了这个补丁。

  • 2020 年,Redis 的这份拷贝上出现了 zfree(cwdp),炸了:redis/redis#7927Debian #972683,改成 libc 的 free() 修好。

  • Debian 从 Redis 的打包派生出 Valkey 的打包,补丁一并继承。

  • 后来有人发现这里漏了内存,加上了释放——又一次写成了 zfree(cwdp)同一个错误,同一条补丁血脉,隔了五年犯了两次。

  • 2025 年 3 月,Valkey 维护者 zuiderkwast 在评估这批 Debian 补丁时,当场就看出了这个不匹配

    …the latter uses malloc() (rather than zmalloc()) and we later free it using zfree(). This means it will mess up the memory usage tracking done in zmalloc and zfree. Therefore, we may not want to take this patch, at least not unmodified.

    判断完全正确,只是当时认为后果止于「内存统计乱掉」,没意识到在内置 jemalloc 下会直接段错误。这个 issue 后来因为 jemalloc 上游归档而降了优先级。

  • 2026 年 4 月,Valkey 建了一套覆盖 40 个平台组合的自动打包流水线。为了省事,它把 Debian 的整套补丁抄了进来——于是官方发布的 DEB 包也带上了这颗雷。

顺带一提,那个仓库里跑测试的目标是个注释掉的空壳。上游那套本来能抓住这个 Bug 的测试,在官方 DEB 构建里一次都没跑过。


补丁是怎么提的
#

事情牵涉两边,得分开走。

给 Debian 是发邮件。不用注册任何账号,给 submit@bugs.debian.org 发一封纯文本邮件,正文第一行开始写伪头:

Package: src:valkey
Version: 8.1.4+dfsg1-2
Severity: normal
Tags: patch

这里老冯翻了个车。第一封发出去收到退信,说正文第一行没有 Package:,所以「你的邮件被完全忽略了」。可我明明写了——检查文件,第一个字节就是 Package:

真正的原因藏在邮件 ID 里:那是 Apple Mail 的格式,而 Apple Mail 默认发富文本。BTS 只解析纯文本,看到 HTML 就找不到那行了。

所以,从 Mac 上给 Debian 报 Bug 记得先切纯文本格式制作纯文本),顺便把智能引号关掉,否则补丁里的引号会被转成弯引号,补丁直接作废。改完重发,拿到号 #1143239

**给 Valkey 上游是先评论、再 PR。**zuiderkwast 一年前那条评论正好是入口,我在那儿接着说了下去,补上了他当时不知道的部分:在内置 jemalloc 下这不是统计乱掉,是段错误;上游测试本来就能抓到;以及官方 DEB 包今天就有这个问题。

他回得很快:「你有时间提个 PR 吗?」

改动本身只有一行:

-        zfree(cwdp);
+        zlibc_free(cwdp);

zlibc_free() 是 Valkey 专门为这种场景定义的函数,就是为了让调用者能够到真正的 libc free()

顺便也解释了当年为什么会写成 zfree:直接写 free(cwdp) 根本编译不过——Valkey 有意把 free() 标成了 deprecated,而构建是带 -Werror 的。作者八成是被编译器挡了一下,顺手换了个能过编译的名字。

一个防御性设计,反过来把人推进了坑里。


结果
#

zuiderkwast 当天就 approve 了,但附了个问题:为什么要留着这个补丁? 反正那个路径只用在一条错误消息里,用回栈上的缓冲区不就完了。

问得很好。我在 PR 里其实把「干脆整个删掉」列成了备选。既然他往这个方向想,我就先在自己家里把这条路走完——Pigsty 的 Valkey 和 Redis 包直接删掉整个补丁,重建,然后回复他:用户视角什么都没变,错误消息照样打印完整路径,测试全过,问题跟着补丁一起消失了

然后事情朝一个我没预料到的方向发展。zuiderkwast 转头 @ 了建这套流水线的几位维护者:

Why did we copy Debian’s patches?

If some things need to be patched, that’s better fixed upstream in Valkey itself. Bugs in this repo’s patches are harder to spot than bugs in Valkey main repo IMHO.

那句加粗的话,是这整件事最好的注脚。

**2026 年 8 月 7 日,PR 被合并。**五个产品线的打包目录全部修复。建流水线的维护者回了句「Thanks for the notification. Will investigate this!」——对整个补丁栈的重新审视开始了。

Debian 那边的报告从 8 月 1 日受理至今还没有回复。这很正常,Debian 维护者的响应周期以周计。另外还有一件没做完的事:bookworm 的 Redis 包有一模一样的缺陷,我只在 Valkey 那个报告里顺带提了一句,还没单独报。


尾声
#

复盘一下,这件事里没有任何一方犯了愚蠢的错误。

Chris Lamb 2017 年写那个补丁,动机是正当的打包卫生。后来加 zfree(cwdp) 的人是在修一个真实的内存泄漏,只是挑错了释放器——而挑错的直接原因,是 free() 被上游有意标成了 deprecated,编译器把他推向了那个看起来最像的名字。 zuiderkwast 一年前就看出了不对,判断也完全正确,只是低估了后果。建流水线的人抄 Debian 的补丁,是因为那是最省事也最靠谱的做法——Debian 的打包质量是出了名的。

每一步都合情合理,凑在一起就成了一颗在五条产品线、四个发行版上静默待命的雷。

**打包补丁是 Bug 最好的藏身之处。**它们不在上游的 CI 里,不在上游的 code review 里,不在任何人的 git log 视野里; 它们跨项目复制粘贴(Redis → Valkey),跨组织复制粘贴(Debian → Valkey 官方),每次复制都把原始上下文丢掉一点。

至于怎么发现的——说到底是因为跑了测试。上游写好的那个用例一直躺在那儿,故意把 dump.rdb 建成目录,就等着这种 Bug。官方流水线把测试目标注释掉了,所以它一次都没跑过。

打包这活儿枯燥,但该跑的测试还是得跑。


后记:在 PGConf 上被问到的问题
#

今年 PGConf 上,老冯讲了个 Extension for Everyone 的主题演讲。讲完之后,PGDG APT 仓库的维护者 Christoph Berg 问了我一个问题:这么多包,你都是怎么测试的?

这问题问到点子上了。Pigsty 现在维护着几百个扩展和组件的打包,光打包仓库里的 patch 就有一百多个,相当一部分都在解决各种琐碎问题与上游的缺陷。而一个人不可能把每个包的每条路径都手工验一遍 —— 时间根本不够。

我的回答是:除了软件包自带的构建测试之外,我还会让 Codex 做一轮冒烟测试,按这个软件在真实场景里会被怎么用,尽可能多地去模拟可能踩到的坑。

它确实翻出了不少东西。这篇文章讲的这个,是其中比较显著的一个。

值得思考的是:这个 Bug 为什么能藏这么久。

Valkey 现在早已不是无人问津的小项目,它在不少发行版里已经是 Redis 的默认替代品; Debian 在海外是绝对的主流发行版,打包质量出了名的好;Valkey 的维护者是顶尖的工程师 —— zuiderkwast 一年前看到那个补丁,就把机制完整说对了。 而 Redis 官方的打包,同样是直接照搬 Debian 的 patch。

所以缺的从来不是能力,是 耐心和韧性

愿意为一条冷错误路径上的脚注级问题,花一整天去搭一个 3×3×2 的编译矩阵、跑三十遍崩溃复现、翻十年前的补丁血脉、把五个搜索端点挨个查一遍确认真的没人报过——这件事在人类的时间尺度上,性价比是不划算的。它太枯燥了。

而这恰恰是 agent 最擅长的那类活 —— 我在《AGI 里程碑:不会放弃的机器》里面就写到过,这次这一波 “AGI 模型” 真正跨过阈值的,不只是模型的智力,而是可以按算力购买、复制和并行的韧性。

这次从头到尾:测试是它跑的,复现矩阵是它搭的,补丁是它写的,Debian 的报告邮件是它起草的,GitHub 上跟维护者的往来措辞也是它组织的。老冯做的事情是派活、追问、以及最后拍板。

得补一句,这不是「AI 全自动、人类躺平」。就在这次调查里,我让另一个 agent 用最高强度对初稿做对抗性 review,一口气揪出四处事实错误:Debian 那个老 Bug 当年的修法我记错了、 FLUSHDB 其实根本不走这条路径、「拒绝一切写入」的说法过头了、还有 bookworm 的 Redis 至今仍然中招这条我整个漏了。这些要是原样发给维护者,丢的是我的脸。

所以真正的价值不在于「AI 一次就对」,而在于 —— 让它反复自我否定的成本,低到人类做不到。人会觉得为一个统计计数器搭六份编译不值得,agent 不会,你只要给它目标,它就会孜孜不倦的去做。

技术进步得是真快。两年前这套流程里的每一环都得我自己来,我估计就放弃了。现在我坐在这儿派活、追问、拍板,剩下的它自己就跑完了,还跑得挺丝滑。

相关文章