之前我们碰到过一个特别有意思的 customer issue。客户那边一用 pg_dump 生成 backup file,任务跑着跑着就会直接报错:

pg_dump: error: could not open output file: No space left on device

刚看到这个报错的时候,我心里其实是很放松的。因为这类问题看起来太像标准题了,无非就是磁盘不够大,扩容一下就行。

诡异的开端:永远填不满的“黑洞”

最直接的尝试:先加磁盘

客户的数据库本身就有几个 TB。为了图省事,我们第一步根本没多想,直接把目标备份磁盘扩到 10 TB。一般这种场景下,先看一下磁盘空间:

df -h

我当时真的是抱着“这下总该好了吧”的心态去重新跑 pg_dump 的。结果居然又失败了。

这时候我们还没完全警觉,只是觉得,也许 10 TB 还是不够?于是又继续加,一路加到了 20 多 TB,然后再跑一次。

结果还是同一个 error message,还是失败。

这下就不对劲了。因为这已经不是“磁盘不够大”能解释的问题了。df -h 明明显示还有很多 Free Space,但系统就是执意告诉你:No space left on device

到这里我基本就确定了,这个报错一定没有字面上看起来那么简单。

顺藤摸瓜:潜藏的 2 亿个 Large Object

既然不是硬件资源枯竭,那就只能开始翻数据库本身了。我们先检查数据内容,一开始其实也没发现什么特别奇怪的地方:没有极度膨胀的 TOAST data,也没有什么异常的 highly compressed data。

后来才注意到一个很不寻常的点:这个客户的数据库里有海量的 Large Object。

什么是 PostgreSQL 的 Large Object?

在 PostgreSQL 中,Large Object,简称 LO,是一种专门用来存储大型数据,比如图片、音频、文档的机制。它提供了一套类似文件读写的 API,例如 openreadwriteseek

当你使用 pg_dump 并指定 Directory Format,也就是 -Fd 时,pg_dump 会为表结构和数据生成文件,同时还会为数据库中的每一个 Large Object 生成一个独立的 dump file。文件名通常会使用该 LO 的 OID,例如:

blob_12345.dat

正常人的用法一般是存一些很大的 object,但是数量不会太夸张。这个客户刚好反过来:他们存了超过 2 亿个非常小的 Large Object。

问题也就出在这里。pg_dump 显然没有针对这种“数量极大、体积极小”的 LO 使用方式做特殊优化,它还是沿用原本的逻辑,老老实实地给每一个 LO 生成一个独立文件。

本地复现:突破 1400 万的结界

怀疑是这 2 亿个文件引发的问题之后,我们就在本地尽量模拟客户环境开始测试。

结果很快就复现出来了,而且复现得非常稳定。我们盯着备份进程跑,慢慢发现一个特别关键的线索:每次报错的时候,输出目录下的文件数量都差不多停在 1400 万个左右。

我当时的第一直觉其实就是:是不是 inode 被用完了?

因为在 Linux 里,每个文件都要占用一个 inode。如果文件又多又小,就很容易出现“磁盘容量还没用完,但 inode 先耗尽”的情况。于是我们第一时间查了:

df -i

结果一看,不对。inode 还剩很多。

接下来我们就开始一路排查别的可能性:是不是 container 的限制?是不是进程限制,比如 ulimit -a?是不是某种 system-level resource 被悄悄打满了?

但查来查去,全部都正常。

唯一让我越来越在意的,就是那个 1400 万文件的阈值。每次都像撞到一堵看不见的墙一样,到了这里就死。

真相大白:EXT4 的 HTree 与哈希冲突

带着这个“1400 万文件结界”,我查了很多资料,也去问了更懂 Linux 文件系统的人,最后终于把 root cause 挖出来了:问题出在 EXT4 文件系统的目录索引机制上。

深入底层:EXT4 的 HTree 限制与 Hash Collision

在 Linux 系统中,目录本质上也是一个文件,里面记录了它所包含的所有子文件名称以及对应的 inode 指针。为了在一个包含数百万文件的目录中快速查找到特定文件,EXT4 默认使用基于哈希的树状结构,也就是 HTree,来给目录建立索引。

然而,传统的 EXT4 HTree 深度是有限的,通常只有 2 层。当我们往同一个文件夹里无脑塞入上千万个文件时,由于文件名生成的 hash 值空间有限,就会开始发生严重的 hash collision。当某个 hash bucket 被填满,而 HTree 又已经达到深度限制、无法继续分裂时,文件系统就会拒绝写入新文件,并向操作系统返回 ENOSPC,也就是 No space left on device

这就完美解释了为什么我们每次都在 1400 万个文件时崩溃:因为 pg_dump 每次都是按顺序生成文件,文件名又是由 LO ID 决定的,所以命名模式完全一致。也就是说,每次生成到某一类特定名字的时候,它都会走进同一条冲突路径里,然后非常稳定地撞墙。

说实话,查到这里的时候,那种感觉还挺爽的。前面一直像在黑屋子里乱摸,到了这一刻,终于“梆”地一下,所有现象全都能对上了。

破局之道:如何解决这个问题?

一旦定位了 root cause,解决起来就有明确方向了。我们可以从三个层面入手。

1. 数据库备份层面:分离 dump

既然 Directory Format 会把所有 LO 都塞进一个文件夹里,那我们就可以把表数据和 Large Object 分开处理:

  • 普通数据依然使用 Directory Format,也就是 -Fd,但排除 LO。
  • Large Object 单独导出成一个大文件,例如使用 plain SQL 或 custom format,这样就避免生成数以亿计的小文件。

也就是说,可以用 --no-blobs 排除大对象,然后单独导出大对象。

当时我们也是选择了这种解决方案.

2. OS 文件系统层面:开启 large_dir

其实现在的 EXT4 已经意识到了这个极端场景,并提供了一个叫 large_dir 的 feature。开启之后,它支持 3 层深度的 HTree,并且允许目录大小突破 2 GB,哈希冲突的概率会大大降低,基本可以消除单目录 1400 万文件这个瓶颈。

可以通过下面的命令为磁盘开启这个能力:

# 开启前先 umount 磁盘
umount /dev/sdX

# 开启 large_dir 特性
tune2fs -O large_dir /dev/sdX

# 检查文件系统并重新挂载
e2fsck -f /dev/sdX
mount /dev/sdX /backup_dir

3. 架构设计层面:目录分片

如果在实际开发中,某些业务确实需要将数千万的实体文件存放在磁盘上,那么把它们全部塞进同一个文件夹本身就是一个非常危险的设计。即使开启了 large_dir,像 ls 这样的命令也可能会非常卡。

业界更标准的做法是:根据文件名的 hash 值或 ID 进行目录分片。

举个例子,如果某个 Large Object 最终的文件名叫:

1234567.dat

我们可以这样切分路径:

  • 前两位 12 作为第一层目录
  • 三四位 34 作为第二层目录

最终路径就变成:

/backup_dir/12/34/1234567.dat

这样做之后,千万级文件会被均匀打散到成千上万个子目录中,每个目录下的文件数都能保持在一个很低的水平,从根源上规避文件系统的性能瓶颈。

最后

回头看这次排查,我还是会觉得它特别像那种很典型的 debug 过程:一开始你觉得这题太简单了,甚至有点不屑一顾,结果越查越发现事情根本不是你想的那样。

最开始我们真的只是想“给盘加大一点不就好了”。结果从 10 TB 加到 20 多 TB,问题还原地不动地摆在那里。然后你开始怀疑 inode,怀疑 container,怀疑 process limit,怀疑各种 system-level resource。每个方向都查了一遍,每个方向都不像。

最后真正的问题,居然藏在 EXT4 目录索引和哈希冲突这种平时根本不会去想的地方。那一刻其实还挺有意思的,因为前面所有看起来很碎、很乱、很莫名其妙的现象,突然一下子就都连起来了。

所以我后来越来越觉得,排查这种问题最有意思的部分,不只是“把 bug 修掉了”,而是你会被逼着重新理解一些原来觉得理所当然的东西。像这次这个报错就是个很好的例子:No space left on device,听起来特别直接,但它真正表达的,其实可能完全不是你第一眼理解的那个意思。

如果以后你也遇到这种“明明还有空间,却怎么都写不进去”的场景,希望你别急着被 error message 带跑。很多时候,真正的问题,往往都藏得比报错本身深一点。