修复我的 Golang Web 应用中的内存耗尽 Bug
今年早些时候,我创建了一个名为 PicoShare 的开源应用。它是一个用于分享文件的简单 Golang Web 应用。我用它来发送那些太大而无法作为邮件附件的文件,但又不想让接收者去使用 Dropbox 或 Google Drive。

几个月前,我发现我的 PicoShare 服务器每隔几天就会挂掉。查看日志时,我看到了一个内存不足错误:

当时我没时间调试这个崩溃,所以只是把服务器的内存从 512 MB 增加到 1 GB。后来崩溃依旧,我又把它增加到了 2 GB。
靠堆更多 RAM 来解决崩溃问题并不令人满意,所以在过去两周里,我一直在调试这些崩溃,并在 Twitter 上分享进展。
到目前为止,我已经修复了所有导致崩溃的问题,并在此过程中学到了一些关于 Go、SQLite 和调试的有用经验。
如果你想了解整个事件的来龙去脉,可以看看那个 Twitter 线程。如果你想要一份经过整理、浓缩的经验总结,请继续往下读。
前言:我对 SQLite 的用法很特别
我在 PicoShare 中做出的一个奇怪的架构决策是把所有文件数据都存储在 SQLite 中。这是一个不寻常的选择,因为 Web 应用通常把上传的文件直接存放在文件系统上,而不是数据库中。当上传的文件可以任意大时尤其如此。
把文件数据写入 SQLite 的好处是,PicoShare 的所有应用状态都在同一个数据库里。这本身没什么特别的,但我设计 PicoShare 时让它与 Litestream 集成——这是一个把 SQLite 数据库复制到云存储的工具。Litestream 实际上让 PicoShare“免费”获得了备份和恢复能力。我可以彻底销毁一台服务器,然后在任何地方重新部署它(甚至是另一家云托管服务商),PicoShare 会以完全相同的状态苏醒,提供同样的文件服务。
调试过程
复现错误
PicoShare 每隔几天才崩溃一次,所以我的第一步是找到一种更快地强制触发崩溃的方法。
我成功复现了这个错误:把 PicoShare 部署到一个只有 256 MB 内存的 Fly 实例上,然后上传大文件。我使用了短片《Big Buck Bunny》的高分辨率版本,大小从 269 MB 到 618 MB 不等。

我使用短片《Big Buck Bunny》作为测试文件,因为它足够大,可以测试大文件上传。
并行上传两个 618 MB 版本的副本,几乎总能让 PicoShare 在一分钟左右内因内存不足错误而挂掉。
使用性能分析工具定位 RAM 膨胀
调查的第一个突破口来自 Litestream 的作者、最近加入 Fly 团队的 Ben Johnson。Ben 提交了一个详细的 pull request,解释了一行代码如何导致 PicoShare 消耗大量 RAM。
Ben 在性能分析方面经验丰富,他通过编写一个新的单元测试并在测试结束后分析内存来复现了这个问题。Ben 后来向我展示了一种更简单的方法来获取同样的信息,所以我将展示那种方法,但你可以在他的 pull request 中找到 Ben 的原始技巧。
事实证明,Go 标准库自带一个用于调试 Web 应用问题的神奇工具。你只需把这行代码加到导入中:
_ "net/http/pprof"现在,运行你的应用时,就会出现一个 /debug/pprof/ 路由,其中包含大量有用的调试信息。

添加起来如此简单让我很惊讶。这个 Web 界面里有很多有趣的数据,而我用的是 heap。使用方法是:先向 PicoShare 上传一个大文件,然后运行以下命令:
go tool pprof \
-http=:8081 \
-alloc_space \
call_tree \
http://localhost:4001/debug/pprof/heap它会弹出一个 Web 界面并渲染出这张图:

在底部,你可以看到一个标着 bytes makeSlice 63.99 MB 的大红色块,意思是 PicoShare 分配的 RAM 中有 64 MB 来自 Go 的 makeSlice 函数。
makeSlice 在 Go 标准库里,不是我的代码。为了找出 PicoShare 中哪段代码导致了这次内存分配,我沿着图向上追溯,直到找到一个 PicoShare 的函数:

这条调用链中最后一个 PicoShare 的函数是 handlers.fileFromRequest,它调用了 Go 标准库函数 *Request.ParseMultipartForm。该函数负责解析 multipart HTTP 数据,而这正是 PicoShare 接受文件上传的方式。
ParseMultipartForm 接受一个 maxMemory 参数,文档说明如下:
整个请求体会被解析,其文件部分最多有 maxMemory 字节被存储在内存中,其余部分则以临时文件的形式存储在磁盘上。
PicoShare 的调用是这样的:
r.ParseMultipartForm(32 << 20) // 32 MB尽管我们指定了 32 MB 的限制,Go 却分配了 64 MB 的 RAM。
Ben 尝试把 maxMemory 参数减小到 1 << 20(1 MB),结果 ParseMultipartForm 占用的 RAM 降到了只有 2.5 MB:

这是巨大的内存削减,我以为 Ben 肯定解决问题了。
不幸的是,我部署了一个带 Ben 修复的测试版本,它仍然崩溃。
在 Ben 的修复之后,PicoShare 在崩溃前能承受更多负载了,所以确实有效果。不过,当我并行上传三个大文件时,服务器还是死于同样的内存不足错误。
调用 ParseMultipartForm 后释放资源
通过 Google 搜索,我发现了 ParseMultipartForm 的另一个坑。
文档没有提醒读者,但调用者需要负责调用 r.MultipartForm.RemoveAll() 来释放 Go 在 ParseMultipartForm 期间分配的资源。也就是说,我每次调用 ParseMultipartForm 都在泄漏内存。
更新(2022-08-11):Damien Neil 在评论中指出,Go 本应自动清理这些资源。在 Go 的 HTTP/1 实现中,资源会被自动清理,Damien 还提交了一个 bugfix,让 Go 的 HTTP/2 实现行为保持一致。
为了修复这个泄漏,我重写了代码来清理 multipart 资源:
multipartMaxMemory := 1 << 20 // 1 MiB
if err := r.ParseMultipartForm(multipartMaxMemory); err != nil {
return err
}
// Free form resources before returning from function.
defer func() {
if err := r.MultipartForm.RemoveAll(); err != nil {
log.Printf("failed to free multipart form resources: %v", err)
}
}()这个修复看起来很有希望,因为在显式释放资源后,我看到 Fly 上的 RAM 使用量大幅下降:

遗憾的是,即使有了这个修复,崩溃仍在继续。
优化下载
这时,Dan Wilhelm 开始关注这个 Twitter 线程。尽管他这辈子从没用过 Go,他还是挽起袖子,开始在自己的开发机上试验代码。
Dan 注意到下载文件时 RAM 使用量会飙升。这很奇怪,因为下载不应该消耗太多 RAM。解析 multipart 表单很复杂,可能有很多因素导致 RAM 膨胀,但提供文件下载是相当简单的操作。
PicoShare 把所有文件数据以 328 KB 的块存储在 SQLite 中。这本不该占用大量 RAM,因为我们本应只需把一些块读入内存、发送给客户端,然后释放内存即可。
Dan 在负责从数据库读取 PicoShare 文件数据的代码中发现了一个 bug。看看你能不能找出来:
func (fr *fileReader) populateBuffer() error {
if fr.offset == int64(fr.fileLength) {
return io.EOF
}
startChunk := fr.offset / int64(fr.chunkSize)
stmt, err := fr.db.Prepare(`
SELECT
chunk
FROM
entries_data
WHERE
id=? AND
chunk_index>=?
ORDER BY
chunk_index ASC
`)
if err != nil {
log.Printf("reading chunk failed: %v", err)
return err
}
defer stmt.Close()
var chunk []byte
err = stmt.QueryRow(fr.entryID, startChunk).Scan(&chunk)
if err != nil {
return err
}
// Move the start index to the position in the chunk we want to read.
readStart := fr.offset % int64(fr.chunkSize)
fr.buf = bytes.NewBuffer(chunk[readStart:])
fr.offset += int64(len(chunk)) - readStart
return nil
}Bug 出在 SQL 查询的 WHERE 子句中:
WHERE
id=? AND
chunk_index>=?这个查询本应只检索单个文件数据块,但它却读取了目标块及其之后的所有内容。
修复方法很简单,就是把 >= 改成 =:
WHERE
id=? AND
chunk_index=?题外话:读这段代码时,我还意识到我在不需要的时候也使用了 prepared statements,不过我认为这并没有影响 RAM。
Dan 的修改是在下载这一侧,所以我没指望它能修复上传时看到的崩溃。事实也确实如此,但下载服务的性能有了显著提升。尤其是对于视频或音频这类流式内容,当我跳转到文件的不同位置时,PicoShare 的响应快多了。
移除 SQLite 事务
在 Twitter 线程中,好几个人提出 PicoShare 的 SQLite 事务很可能导致 RAM 膨胀。
当 PicoShare 把文件数据写入 SQLite 时,我是在一个事务中进行的。目的是确保数据库始终处于一致状态。
通过使用事务,SQLite 保证了我永远不会遇到某些写入失败后数据库中只存在部分文件的状态。它还确保我不会在没有写入文件内容的情况下意外写入文件元数据,反之亦然。
一些搜索表明,大型 SQLite 事务可能是内存膨胀的一个来源,所以我觉得值得一试。我尝试不使用事务而是立即提交对 SQLite 的更改,但 RAM 依然膨胀。看来事务似乎并没有什么影响。
RAM 膨胀没关系,但崩溃不行
到了这个时候,我从三个不同的角度测量 RAM 使用量,但它们彼此都不一致:
- Go 的调试指标报告它分配了多少内存
- 虚拟机内的
htop - Fly 从虚拟机宿主机采集的 RAM 指标


我用来测量 RAM 使用量的不同工具彼此不一致
特别是,Fly 的指标经常显示 RAM 已满载,而 Go 和 htop 显示几乎没有使用量。调试起来令人沮丧,因为我越深入挖掘,RAM 测量值与我观察到的崩溃行为就偏离得越远。
改变游戏规则的洞察来自 Andrew Ayer,他指出 RAM 膨胀很可能是个干扰项:

Fly 的 CEO Kurt Mackey 也进入线程确认了 Andrew 的假设:

所以,Fly 的内存指标包含了页面缓存,但如果运行中的应用需要内存,虚拟机应该会回收这部分 RAM。
这是一个重大发现。由于触发内存不足崩溃很困难,我一直用 RAM 膨胀作为崩溃的近似指标。但只要虚拟机还有足够的内存让我的进程继续运行,RAM 膨胀就没问题。
现在我不得不重新审视一切。当我否决其他修复方案时,是因为它们造成了无害的 RAM 膨胀?还是我真的观察到了实际的崩溃?
重新审视 SQLite 事务
鉴于 Andrew Ayer 关于 RAM 膨胀的说法,我重新研究了 PicoShare 的 SQLite 事务。当我尝试无事务的实现时,我看到的是崩溃还是仅仅是 RAM 膨胀?我记不清了。
我又试了一次运行无事务的实现。果然,RAM 膨胀了,但 PicoShare 继续运行。我并行上传了三个 618 MB 的文件,每次上传都成功了,而且 PicoShare 一直在处理 HTTP 请求。

成功了!我终于找到了性能问题的根源。
至少我当时是这么想的……
我让服务器运行了一整夜,第二天早上检查时,它又死于同样的内存不足崩溃。

取消 SQLite VACUUM
我立刻怀疑这次夜间崩溃与 SQLite 的 VACUUM 命令有关,该命令压缩数据库文件以回收未使用的磁盘空间。
崩溃时没有人使用 PicoShare 服务器,但时间点确实与 PicoShare 计划好的数据库维护吻合。每七个小时,PicoShare 会从数据库中删除过期条目并执行一次 VACUUM 来回收未使用的磁盘空间。
我在服务器上测试运行 VACUUM 命令,发现它确实减小了我的主 .db 文件的大小,但却增大了 SQLite write-ahead log 的大小。

这时,Ben 问我为什么根本需要 VACUUM:

是啊,我到底为什么要这么做呢?
当初发布 PicoShare 时,用户抱怨删除文件后磁盘空间没有被归还。这对我没有影响,因为我在一台磁盘容量固定的 Fly 虚拟机上运行 PicoShare,用了多少磁盘都无所谓。但加上定期 VACUUM 很容易,所以我就加了。
考虑之后,我决定更改 PicoShare 的行为,让 VACUUM 默认关闭,但用户可以通过命令行标志启用它。
dbPath := flag.String("db", "data/store.db", "path to database")
vacuumDb := flag.Bool("vacuum", false, "vacuum database periodically to reclaim disk space")
flag.Parse()成功:PicoShare 运行在 256 MB 内存上
在默认禁用 VACUUM 并加上我的其他性能修复之后,PicoShare 终于能在低内存下稳定运行了。
我在一台只有 256 MB 内存的 Fly 虚拟机上运行了 PicoShare 24 小时,没有任何崩溃。


过去 24 小时内 100% 正常运行
其他收获
除了上面学到的内容,我在这次调试之旅中还获得了一些有用的附带经验。
优化构建-测试循环
我希望自己早点做的一件事就是优化构建-测试循环。为了验证任何假设,我的流程是:
- 把改动部署到 Fly(2-3 分钟)
- 上传一个大文件(1-2 分钟)
- 等待 Fly 的 RAM 指标更新(30-60 秒)
也就是说,仅仅测试任何一项改动就要花多达六分钟,而且每一步都需要手动操作。这还不算写代码改动的时间。
我最初尝试过在一个限制内存的 Docker 容器中运行 PicoShare,但它从不崩溃。
RAM_LIMIT="64m"
PORT=3001
PS_SHARED_SECRET="somesecretpass"
docker run \
--memory "${RAM_LIMIT}" \
--env "PORT=${PORT}" \
--env "PS_SHARED_SECRET=${PS_SHARED_SECRET}" \
--publish "${PORT}:${PORT}/tcp" \
--name picoshare \
mtlynch/picoshare:1.1.7$ docker stats
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
1ababd398113 picoshare 3.82% 63.68MiB / 64MiB 99.50% 278MB / 208MB 6.09GB / 7.1MB 21我至今仍不明白为什么 PicoShare 在 Docker 下的表现与真实虚拟机不同,但我最好的猜测是 Docker 对 RAM 使用量的限制不如虚拟机那么严格。
当 Dan Wilhelm 报告他通过在本地运行 PicoShare 并观察 RAM 使用量取得了多大进展时,我才意识到自己每次改动都部署到 Fly 浪费了多少时间。我尝试在家里自己的虚拟机服务器上运行 PicoShare,但它从未像在 Fly 上那样崩溃或使 RAM 膨胀。
最终奏效的方法是在 Fly 上创建自己的开发环境。我写了一个包含 PicoShare 源码和一些开发工具的 Dockerfile 并部署到 Fly。这样我就可以用 fly ssh console 打开服务器上的 shell,快速测试代码改动。
虽然仍然不算很快,因为 Fly 的 RAM 指标更新前大约有 30 秒延迟,但这比每次从头部署要好多了。
用描述性的 git 分支和提交信息记录笔记
我在这次调查中发现的一个有用技巧是:在各自的 git 分支中测试每个假设,然后用提交信息记录结果:

由于各种假设满天飞,很难记住测试每个想法时代码处于什么状态。例如,有一次我看到的崩溃其实是我自己在调试过程中引入的新 bug 导致的。
记录代码当时的状态以及我为测试它做了什么,帮助我理清思路,避免重复劳动。
Go 的测量工具看不到 cgo 中的内存分配
我最早期的调试步骤之一是在 PicoShare 中添加一个页面,显示来自 runtime.ReadMemStats 的一些 RAM 指标(后来我意识到 net/http/pprof 在这方面做得更好)。

James Tucker 指出,这种测量不会包括我通过 cgo 分配的任何资源:

我确实是通过 cgo 使用 SQLite 的。PicoShare 使用的是 mattn/go-sqlite3,这是 Go 最流行的 SQLite 库。
这也说得通:使用 cgo 会让 Go 无法显示准确的性能指标。如果你用 Go 调用外部 C 代码,Go 无法跟踪外部代码中的资源。
为了绕过这个问题,我尝试使用 modernc.org/sqlite,一个纯 Go 实现的 SQLite。但不知为何,即使用纯 Go 代码我也看不到资源泄漏。
Fly 的磁盘性能快得离谱
有一次,Twitter 上的评论者提出,我可能是因为磁盘写入耗尽了 RAM。如果 PicoShare 向 Fly 虚拟机磁盘写入数据的速度快于磁盘把数据写到物理介质的速度,数据就会在 RAM 中排队。
为了验证这个理论,我使用了以前从未用过的 fio 磁盘基准测试工具。我以为是自己用错了工具,因为它报告的写入速度是 3353 MB/s,这对云虚拟机来说似乎快得不可能。作为对比,这大约比我在家里的 NAS 服务器上做同样测试快 30 倍。
Kurt Mackey 确认测量结果很可能是正确的,因为 Fly 的本地磁盘是企业级 NVMe 硬盘:

死胡同
虽然我希望自己的调查是一个稳步推进的过程,但实际上我走了很多弯路,追随了许多毫无结果的假设。以下是一些这样的死胡同。
怪罪 Litestream
调试的第一条法则是假设问题出在你自己的代码里。但在这里我违反了这条法则,部分原因是我害怕追查这些 bug 要花多少功夫。
话虽如此,怀疑 Litestream 也是有正当理由的。尽管 PicoShare 以一种奇怪的方式使用 SQLite,但在 SQLite 中存储 1 GB 数据也不算那么奇怪。Litestream 相对较新,并且以新颖的方式使用 SQLite,所以想象崩溃来自 Litestream 并不算太牵强。
虽然我怀疑 Litestream,但我不想给 Litestream 的维护者 Ben Johnson 制造更多工作。我知道 PicoShare 对 Litestream 来说是一个不寻常的使用场景,而且我没有一个能隔离问题的简单复现方法。
但到了五月,Fly 收购了 Litestream 并聘请 Ben 来维护它。现在似乎是打扰 Ben 的完美时机,因为这同时关系到 Litestream 和 Fly!
我在 Litestream 上提交了一个 issue,解释了我尝试过的东西以及为什么我认为问题与 Litestream 有关。15 分钟后,我在禁用 Litestream 的情况下成功让 PicoShare 崩溃了,于是关闭了这个 issue。
话虽如此,针对 Litestream 提交 issue 还是有用的,因为它迫使我足够严谨地对待问题,写出了一份详细的 bug 报告。这也激起了 Ben 的好奇心,即使在明确 Litestream 不是原因之后,他仍然提供了很多有用的建议。
怪罪 Fly
当我无法在自己的本地虚拟机或 Docker 下复现崩溃时,我开始怀疑问题出在 Fly 那边。这似乎不太可能,因为我做的事情并不算太特殊,如果 Fly 的其他用户都没有注意到他们的部署死于内存匮乏,那就太奇怪了。
不过,我还是想排除 Fly 这个可能性。我把 PicoShare 部署到 Lightsail——Amazon 的托管 Docker 容器服务。他们没有 256 MB 内存选项,所以我部署到了一个 512 MB 的实例。几分钟后,我就成功在那里复现了崩溃,排除了 Fly 作为罪魁祸首的可能。
/tmp 不是 RAM 盘
在 Twitter 线程中,一些评论者提出 Fly 可能会把它的临时目录挂载为 RAM 盘。Go 的 ParseMultipartForm 函数会把上传的文件保存在临时目录中,所以如果 Fly 把临时目录挂载为 RAM 盘,那就能解释为什么普通文件上传会耗尽 RAM。
但当我运行 lblk 时,没有看到任何迹象表明 /tmp 或其他临时目录是 RAM 盘。系统看起来用的就是普通磁盘:
$ lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
vda 254:0 0 128M 0 disk
vdb 254:16 0 8G 0 disk /
$ du -h /tmp/*
149.3M /tmp/multipart-338465586
7.66 /tmp/test1手写 multipart 表单读取器
Go 标准库的 ParseMultipartForm 函数消耗的内存似乎超过了其文档规定的上限,这是个危险信号。我还注意到,如果我调用 ParseMultipartForm 然后丢弃字节,RAM 同样会被占满。
为了看看能否绕开 ParseMultipartForm 中的问题,我尝试为我的特定场景编写自己的手工定制 multipart 读取器。
遗憾的是,我的 multipart 读取器表现得并不比标准库好,不过在更底层玩弄 multipart 数据还是挺有趣的。
限制上传速度
我很好奇更慢地上传数据是否会对 RAM 有影响。我尝试从客户端限速,把 Chrome 设置为模拟 3G 速度,但 PicoShare 的行为没有变化。
我也尝试在服务器端限速以降低写盘速度,但同样毫无作用。
我倒是学会了在 Go 中限制 I/O 速度有多容易。如果你在处理一个 io.Reader 接口,只需用一个限速 reader 包装一下即可:
import "github.com/juju/ratelimit"
...
throttleRate := 1 << 20 // 1 MB
bucket := ratelimit.NewBucketWithRate(float64(throttleRate), throttleRate)
throttledReader := ratelimit.Reader(reader, bucket)
w := file.NewWriter(tx, metadata.ID, d.chunkSize)
if _, err := io.Copy(w, throttledReader); err != nil {
return err
}PicoShare 1.2.0
周末,我发布了 PicoShare 的 1.2.0 版本,其中包含了对我在这次调查中发现的所有性能问题的修复。
致谢
非常感谢所有帮助我调查这个问题的人,特别感谢几位付出远超预期的人:
随机一篇博客