Go写TB级数据到磁盘时页缓存导致OOM,通过posix_fadvise(FADV_DONTNEED)解决,附4D案例。
大家好!这是关于 RUSEON-core 开发的第二篇文章。RUSEON-core 是一个面向 AI 平台和边缘视频基础设施的零拷贝(Zero-Copy)视频流服务器。在第一篇文章中,我介绍了我们之所以决定自研服务器的根本原因,还讨论了大多数同类方案的共同问题——"惊群效应"(thundering herd),以及我们如何在单核 CPU 上榨出 8 Gbps 的吞吐量。顺便说一句,那篇文章里我忘了提:除了简单推流,我们还会把直播流录制成 fMP4 格式。本地存储 N 天后,会根据客户需求决定是否上传到 S3(如果客户不想长期保留录像,存档会被清理;如果想保留,我们会在 N 时间后把存档传到 S3,然后仍然清理本地副本)。
这篇文章要讲的,恰恰是一个与数据存储相关的、不那么显而易见的问题(至少对我来说是这样,也许对某些人来说是家常便饭),而且这个问题在所有操作系统中都存在。让我们深入进去。
我们上线了第一个版本到生产环境(100 路摄像头),客户满意,我们也开工了。大约过了一个小时,告警开始飞来。我 SSH 登录服务器,打开 htop,看到只剩 100 MB 可用内存。糟糕。我得说明一下,这台生产服务器有 32 GB 内存。预期行为是:CPU 淡定、网络卡跑流量、内存占用约 250-300 MB、磁盘负载不高。所以当你看到 htop 里这样的数字时,你开始怀疑人生,怪自己那双写出了"垃圾"代码的臭手。但我们还是决定去 Google、ChatGPT 等地方找答案。还好,答案很快就找到了,我们也不再自责了。
代码绝对不是罪魁祸首;是 Linux 自己把内存吃掉了。如果你曾经往磁盘写过大量数据,我想你大概已经知道怎么回事了。有个"看不见的敌人"叫 Page Cache(页缓存)。这正是问题的根源。
Page Cache 是怎么工作的,以及如何应对?当你本应往磁盘写数据的函数实际执行写入时,它并没有写到磁盘上——它写到了内存里。Linux 内核的逻辑简单而直接,目标是提升系统的"响应速度"。其本质可以这样解释:"哦,他们刚刚写入了 100 GB 数据,他们可能很快就需要读取这些数据。让我把它留在缓存里,这样用户读取时会很快,他们会很开心。"就这样,一个 GB 接一个 GB,直到服务器的物理内存耗尽。
典型的解决方案是写一个 bash 脚本,每小时执行一次 echo 3 > /proc/sys/vm/drop_caches。还有些人干脆无视它,让系统通过 OOM Killer 随机杀掉进程。但我们在做的是一个高可用的系统——这对我们来说行不通。任务是要告诉 OS 内核:我们的 fMP4 视频存档片段在短期内是"只写垃圾"(因为如果客户不想长期保存记录,存档会被清理;如果想保存,我们会在 N 时间后把存档传到 S3,然后仍然清理本地副本)。所以一切都应该遵循"写完即忘"的原则。
在 C/C++ 中,有一个系统调用可以做到这件事:posix_fadvise。你可以明确告诉 OS 你将如何操作这个文件。在 Go 中,这个没有原生支持。但有一个很好的包:golang.org/x/sys/unix,它可以让你轻松替换系统调用。用的 flag 叫 FADV_DONTNEED。我们实际上是告诉内核:"我写完了,把它刷到磁盘,然后从缓存里滚蛋。"
但这里有一个很坑的细节,我自己也踩过,琢磨了很久(其实只需要读一下文档,但就像组装宜家家具一样:"为什么要看说明书,我自己会装")。Linux 内核不会从缓存中移除"脏"(dirty)的页面——也就是说,那些还没有真正写到磁盘盘片上的数据。如果你直接调用 Fadvise,什么都不会发生。
首先,你需要执行一次硬同步 Sync()。
// File: pkg/storage/localfs/file_linux.go
package localfs
import (
"os"
"golang.org/x/sys/unix"
)
type FileWrapper struct {
*os.File
}
func (fw *FileWrapper) DropCache() error {
// First, flush dirty pages to disk!
if err := fw.File.Sync(); err != nil {
return err
}
// And only then order the kernel to forget them
return unix.Fadvise(int(fw.File.Fd()), 0, 0, unix.FADV_DONTNEED)
}
系统调用几乎总是跨平台开发中的大麻烦。在 Windows 上,FADV_DONTNEED 这个 flag 根本不存在(你好啊 Microsoft!你们还好吗?)。如果你不为不同操作系统分别处理代码,编译器会直接让你滚蛋。
因此,我们实现了一个相当优雅的接口(欢迎在评论区反驳这个说法)。在 recorder 的核心逻辑中,现在有一个检查:文件描述符是否知道如何丢弃缓存:
// OPTIMIZATION: Saving RAM from Page Cache
if dropper, ok := file.(registry.CacheDropper); ok {
_ = dropper.DropCache()
}
然后就是 Go build tags 的魔法了(感谢 Go 团队)。在 file_linux.go 文件中(带有 //go:build linux 标签),我们调用 unix.Fadvise。而在旁边的 file_others.go 文件中(带有 //go:build !linux 标签),DropCache() 方法只是做一次常规的 Sync() 然后返回。代码清晰,linter 开心,构建在所有平台上都能工作。
那么,结果如何呢?我们上线修复方案,启动同样的 100 路摄像头。我打开监控面板,内存消耗曲线几乎是一条完美的直线。服务器拿到了它应有的 250 MB 用来跑 Go 进程堆——就这样。欢呼吧,胜利!再也没有 Page Cache 暴涨了。没有进程被驱逐到 swap 了。服务器老老实实地每小时写入几十 GB,而 RAM 安安静静。
顺便说一句,当你在备份数据库、解析巨型日志、或者单纯在写大文件时,这个功能会帮你省掉一大堆头疼的事。我也不理解为什么这个 flag 几乎没人讨论或写过。通常人们只写如何正确分配 slice,但在文件操作层面,你的 OS 可能把你的所有努力化为乌有——这一点完全没有被提及。
总之,在 ruseon-core 的开源部分,这个逻辑现在已经深深嵌入到录制引擎中。我想给出的结论和建议是——不要盲目信任内核对内存的管理,指望 OS 假设上是"完美且经过深思熟虑的"。有时候你得拍拍内核的脑袋。不然,你就会遇到和我一样的问题。
源代码一如既往地托管在 GitHub:https://github.com/RUSEGAL/ruseon-core