# 上游没有的 Bug，为什么会出现在官方包里？

> 祖传 BUG 奇闻：一行打包补丁从 Debian Redis 传到 Debian Valkey，又被 Valkey 官方发布流水线整体搬了回来。上游源码本身没有这个 Bug，用户下载的官方 DEB 却有。

---

LLMS 索引： [llms.txt](/zh/llms.txt)

---

老冯最近在 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 的构建上，后果更干脆：第一次失败的同步存盘，就可能直接把进程打崩。

在 [这个修复前](https://github.com/valkey-io/valkey-release-automation/pull/63)，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 上游源码也没有这个问题。

触发条件并不算宽。持久污染主要发生在主进程执行同步 SAVE 时；BGSAVE 和周期性存盘发生在子进程中，错误不会把主进程的内存一并改坏。
但这反而让它更阴险：它专挑你已经出事的时候，在旁边再点一把火。磁盘已经满了，快照已经失败了，监控或者备份脚本又在那里不断重试。
原本只是一次存储故障，最后却变成了内存统计穿越、业务写入 OOM，或者进程直接崩溃。

有趣的是：这不是 Valkey 内核里的 Bug —— 它是打包环节制造出来的。

## 雷不在上游源码里

Valkey 在 RDB 落盘失败时，会把当前工作目录打印到日志里。上游原版使用栈上的固定缓冲区，不需要做任何动态内存管理。

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

```c
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 #7927 和 Debian Bug #972683 记录了几乎一样的调用栈，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 再写一个数据库，不如先让它把你准备发出去的每一个包，认真跑一遍。

现在，至少不再缺有耐心的测试者了。

《[AGI 里程碑：不会放弃的机器](https://vonng.com/ai/agi-milestone/)》

---

> **归档说明（2026-08-30）：** 本文原载 [vonng.com](https://vonng.com/db/valkey-bug/)。文中的软件包数量、截图与上下文以原始发表日期为准；当前行为请以 [PIG 文档](/zh/docs/)和实时[扩展目录](https://pigsty.cc/ext/)为准。
