大云海山数据库(He3DB)里有个很容易被忽略、但实际在存储计算分离架构中起关键作用的模块,叫 FileCache。我第一次看这个名字,以为只是普通的文件系统级缓存,后来把整体读写链路完整过了一遍才发现,它在 shared_buffers 和远端存储之间补了一层真正的二级页缓存。这篇就系统地讲一讲 FileCache 到底解决了什么问题、内部是怎么设计的、有哪些值得注意的坑,以及在实际调优时该怎么判断和取舍。比较适合正在研究数据库内核、或者在做国产数据库存储引擎选型和性能优化的人参考。
1. FileCache 要解决什么问题
1.1 内存缓存的天花板不是内存条,而是存储链路
传统 PostgreSQL 的缓存体系大家都熟悉:shared_buffers 里放热数据页,通过 clock sweep 之类的近似 LRU 算法管理,读路径先查这里,没有命中再去数据文件抓页。这套机制本身没有问题,问题出在“冷热数据规模”和“物理存储位置”上。
单机部署时,shared_buffers 没命中就直接读本地磁盘,即使磁盘慢一点,整个链路仍然是可控的。但在云上存储计算分离架构里,数据库计算节点和真实数据存储之间隔了网络,哪怕走 RDMA 或者高速存储网络,单次读页的延迟和吞吐也比不了本地内存访问。这种场景下,如果把所有压力都推给远端存储,缓存命中率稍微低一点,整个数据库的响应时间和吞吐量就会明显恶化。
而 shared_buffers 本身又不可能无限调大。内存贵是一方面,更关键的是数据库的缓冲池管理是有代价的:buffer 数量越大,查找哈希表、维护淘汰链表、处理淘汰和刷脏的 CPU 开销都在涨,超过一定规模后,加内存带来的收益会被管理成本吃掉很大一部分。所以一般的 PG 实例把 shared_buffers 设置到机器内存的 1/4 到 1/2 已经算很激进了,再大就容易出现长路径扫描把整个缓冲池冲垮、checkpoint 刷盘毛刺变高等问题。
1.2 存储计算分离带来的新瓶颈
He3DB 这种云原生数据库走的是存储计算分离路线,好处是存储可以独立扩展、成本低,但代价就是计算节点不再天然拥有数据文件的本地副本。每一次真实的读 miss 都要通过网络请求远端存储,这个成本比传统本地磁盘还要不稳定,因为可能受网络抖动、存储侧排队、多租户争抢等因素影响。
这时候你会面临一个很现实的选择:要不要在计算节点本地放盘,承担一层额外的缓存?如果放,放什么盘?如果用内存,成本和上面的管理开销挡着。如果完全依赖远端存储,性能不稳定,尤其是遇到大范围扫描、批量分析这种查询时,缓存 miss 会成片出现。
FileCache 的思路其实就是:充分利用计算节点上的本地高速存储,比如 NVMe SSD,把它组织成数据库页感知的二级缓存。它的热度档次介于内存和远端存储之间:容量比内存大一个数量级,访问延迟和吞吐比远端存储稳定得多,成本又远低于内存。它不是简单的操作系统页缓存,而是直接从数据库页的粒度去管理数据块的存放位置、命中状态和淘汰顺序,能感知页面大小、脏页状态、事务一致性边界,这是普通文件级缓存做不到的。
1.3 FileCache 在整体架构里的价值定位
一句话总结 FileCache 的定位:它是内存缓冲池的延伸,也是远端存储的挡箭牌。
当一个数据页在 shared_buffers 中 miss 时,原来的逻辑是直接去远端存储抓页。引入 FileCache 后,读路径变成了先去 FileCache 查一级,如果有就直接把数据页加载进内存缓冲池,避免了远端网络 IO;如果没有,才真正访问远端存储,并把读回来的页顺带写入 FileCache,方便下一次命中。
这个设计对整个系统的性能模型有很直接的意义:
- 提升缓存体系的总命中率,而不只是把宝押在 shared_buffers 那几十 GB 上。
- 吸收突发的扫描型负载,避免全表扫描、大批量分析查询把内存缓冲池冲垮的同时,把远端存储的 IOPS 打满。
- 让存储扩容和数据缓存解耦,存储可以很大很便宜,热数据却可以就近放在计算节点。
理解 FileCache,重点不是记它有哪些参数,而是理解它在这个架构里到底扮演什么角色。把它当成二级缓冲池来理解,后面的机制细节就顺了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. FileCache 的整体设计与数据布局
2.1 文件映射与页级索引
FileCache 既然要从数据库页粒度管理缓存,就不可能直接拿整个文件丢给操作系统去缓存,那样完全无法感知哪些页被访问过、哪些页是脏的、哪些页可以安全淘汰。它必须自己维护一套页级元数据。
实际的实现里,FileCache 会在本地盘上创建一个或者几个大文件作为缓存载体,然后把这个文件按照数据库页大小(通常是 8KB)切成固定大小的 slot。文件内部布局一般分三块:文件头、元数据区、数据区。文件头记录版本、缓存文件总大小、合法页数量等全局信息;元数据区保存每一页的使用状态;数据区就是真正存放数据页内容的位置。
光有文件还不够,还要能快速回答一个问题:我想找表 A 的某个页面,这个页面在不在缓存文件里?如果逐页扫描元数据区,那性能就完蛋了。所以实现上通常会为缓存页建立一个哈希索引,key 是数据页的唯一标识,比如文件节点号加块号,value 指向缓存文件里的 slot 位置。通过哈希索引把查找复杂度压到常数级,这样才能承接数据库 buffer 访问那样高频的查询。
这里有一个容易被忽略的细节:因为缓存页大小固定,所以 slot 和文件偏移之间是线性映射关系,拿着 slot 编号就能直接算出要读写的文件位置。这也是为什么 FileCache 能用很轻量的方式管理大量页面的基础,不需要为每个页单独维护一个文件描述符。
2.2 缓存项的状态机设计
缓存里每个 slot 不是简单的“有数据/没数据”两种状态,它至少需要维护以下几类信息:
- 有效位:这个 slot 当前是否缓存了合法的数据页。
- 脏标记:这个页从远端读进来以后,是否被本地修改过。
- 引用计数或者访问热度:用于后续淘汰算法判断哪些页值得留下。
- 世代号或者版本号:防止文件复用后,旧的访问请求错误命中其他表的数据页。
状态机最关键的转换发生在数据页被修改时。如果一个页从远端读进 FileCache,然后在内存里被改了,那么这个页就变成了脏页。脏页不能随便淘汰,因为一旦丢弃,就丢失了修改内容,必须在合适的时机先回写到远端存储。
在 PageCache 的设计里,脏页的状态管理往往会和数据库的日志系统关联。至少要保证一个原则:只有在对应事务的 WAL 日志落盘之后,脏页的回写才是安全的。否则一旦崩溃,可能出现日志里没有这个修改、但数据页已经写回的情况,整个恢复逻辑就被破坏了。
2.3 为什么 O_DIRECT 和文件系统的选择很关键
FileCache 虽然用的是普通文件,但它不希望文件内容再多经过一层操作系统页缓存,否则会出现双份缓存、内存浪费,甚至数据不一致的问题。比较合理的做法是使用 O_DIRECT 模式绕过 page cache,让数据直接从用户态缓冲区写到块设备。
但 O_DIRECT 有几个额外要求。第一,读写缓冲区的地址、大小、偏移通常要对齐块设备逻辑块大小,一般是 512 字节或 4KB,所以 FileCache 的 IO 路径需要自己管理对齐。第二,绕过了操作系统页缓存以后,IO 调度、合并这些优化也没了,缓存模块反而要更注意读写批次大小,避免大量小 IO 导致本地 SSD 性能崩掉。
文件系统方面,尽量选支持稳定 O_DIRECT 语义、元数据开销小的,比如 xfs、ext4。也有人考虑过裸设备,但裸设备不利于工具诊断和管理,生产环境一般不会这么干,除非性能要求极端苛刻。
3. 替换策略与生命周期管理
3.1 直接照搬 LRU 的问题在哪里
很多人的第一反应是:FileCache 不就是个缓存嘛,用 LRU 淘汰最久没访问的页不就行了?实际做内核实现的时候,直接在 FileCache 上用教科书 LRU 会遇到几个现实问题。
第一个问题是全表扫描污染。分析型查询会大量顺序扫描数据页,如果这些页进入 LRU 链表并霸占头部,很快就能把缓存里原本热度很高的页面全部挤出去,等真正的热查询回来时,发现全 miss,性能瞬间崩塌。这在实际工作中太常见了。
第二个问题是链表维护的加锁开销。FileCache 可能管理几十万个甚至上百万个页,每次都精确地把某个节点移动到链表头部,在高并发下会产生严重的锁竞争。数据库页访问是非常高频的操作,在这个路径上做太重的事情,反而会变成瓶颈。
第三个问题是脏页淘汰的级联代价。如果选中的淘汰页面恰好是脏页,不能直接丢,得先回写远端存储再复用 slot,回写是一个慢操作。一旦命中率波动导致大面积淘汰,就会引发一轮严重的回写风暴。
3.2 近似 LRU 时钟扫描的具体思路
生产级 FileCache 一般不会用教科书 LRU,而会用类似 CLOCK 算法的近似 LRU。我对这种算法的一个理解是:它用一个环形数组和一个扫描指针模拟访问时间维度,避免精确 LRU 的全局精确排序。
CLOCK 算法的核心逻辑不复杂:每个页有一个 access bit,页面被访问时置 1,表示最近用过。淘汰时扫描指针往前走,看到 access bit 为 0 的页就淘汰;看到 access bit 为 1 的页就把它的 bit 清 0,继续往前走。这个过程像时钟的指针一样转圈,所以叫 CLOCK。
它和 LRU 的主要区别在于:它不是维护一个严格的访问时间排序,而是把“多久没访问”这个问题模糊成一个近似的概率判断。访问过的页会获得二次机会,但不可能永远被保下来。这样的好处是淘汰操作是批量的、顺序扫描式的,不需要频繁移动链表节点,锁粒度可以做得更粗,并发性能显著好。
我用一个生活例子来帮助理解:衣柜里塞满了衣服,新衣服只往右边放。找旧衣服时不是一翻到底,而是从左往右快速扫一遍,看到哪件没穿过就丢掉,刚穿过的打个标记就放回去。这种策略不一定能精确找出“最久没穿的那件”,但找起来很省事,综合效果也不错。
3.3 脏页回写与一致性保护
FileCache 的脏页回写,是整个机制里最容易出问题、也最需要耐住性子理解的部分。
回写触发时机大致有三个:一是缓存空间不足,需要淘汰脏页换入新页;二是后台线程周期性扫描,发现脏页比例超过阈值,提前把一部分脏页写入远端存储;三是实例做 checkpoint 或类似机制时,强制把一段范围内的脏页全部刷出去。
这里有个经验优化:淘汰路径上的回写必须尽量少发生。否则用户查询稍微热点波动,淘汰命中的多是脏页,每个 miss 都要先等待回写完成再加载新页,查询延迟会剧烈抖动。
我看到不少实现里会设计两条水位线,类似于内存回收的高低水位。高水位控制后台回写线程的启动条件,低水位控制停止条件。通过这两个阈值,让脏页比例始终保持在一个可控区间,避免因为脏页太多,到淘汰时才被迫同步刷写大量页面。
一致性方面,FileCache 虽然是缓存、允许宕机后丢弃重建,但脏页回写过程不能把数据写坏。比较稳妥的设计是:回写前先确保日志已经安全落盘;回写时并发控制好页面的修改;回写完成后才允许更新元数据,把页标记为干净。如果回写中实例崩溃,缓存数据可能会部分丢失,但数据库本身还有日志和远端存储兜底,崩溃恢复时会将缓存视为无效副本重新构建,不至于损坏主数据。
注意:这里说的 FileCache 一致性,前提是采用 write-back 模式。有些情况下会愿意牺牲一点性能换安全性,使用 write-through 模式,每次修改完内存缓冲池就直接回写缓存,缓存中永远只放干净页。这个选择的本质是在性能和恢复复杂度之间做取舍,需要结合业务容忍度来决定。
4. 关键参数与调优实践
4.1 与 FileCache 相关的配置维度
He3DB 的 FileCache 在可观测、可控维度上,通常需要关注几个方面:
- 缓存容量规模:缓存文件可以占用多少本地空间。
- 脏页回写水位:后台刷脏启动和停止的阈值。
- 回写并发度:允许多少个线程同时写远端。
- 淘汰策略和扫描参数:是否启用扫描保护、时钟扫描一次走多少页。
参数的具体名称和分布不是统一标准,更重要的是理解每个维度对应什么行为。在实际调优之前,先想清楚想解决什么问题,是命中率不够、还是刷盘抖动、还是淘汰风暴,这样才能对症下药,而不是盲目调大缓存。
4.2 容量大小的估算逻辑和安全边界
设置 FileCache 容量,不是拍脑袋定一个值。需要结合本地盘空间、远端存储热数据规模、工作集特征三个因素综合判断。
我通常的经验是先观察业务的工作集大小,也就是在较长周期内被反复访问的不同页的总量。如果热工作集约 200GB,FileCache 至少得给到热工作集的 1.5 到 2 倍,留出一定冗余,否则会出现大片热页刚被缓存就被淘汰的抖动情况。
容量也不是越大越好。FileCache 需要一个随容量增长的元数据缓存结构驻留在内存里,每个 slot 都有对应的状态、哈希索引节点。如果一个节点只占几十个字节,那一百万个缓存页的元数据开销也有几十 MB 到几百 MB,在海量分片、高并发实例上必须要提前把这块内存开销算进实例总内存预算。
容量还受限于本地盘的可靠性和寿命。NVMe SSD 的写寿命是有限的,如果 FileCache 脏页回写频繁,或者大量缓存内容不停被覆盖,会放大写放大效应。所以容量设计同时要保证 SSD 的磨损在可接受范围内,长期超过盘的耐久度设计,硬件故障率会明显增加。
4.3 脏页回写的高低位水位调优
回写水位高低直接影响系统波形。如果你发现实例的 IO 曲线呈现周期性锯齿,很大概率是脏页水位设置不合理,后台刷得太少、太晚,脏页比例高到临界点后触发了一次集中回写。
一个相对稳妥的调法包括以下步骤:
- 观察脏页比例的稳定区间,记录它在正常负载下的平均值和峰值。
- 把高水位设置在比常见峰值高一截的位置,让后台刷脏不要过于频繁地介入。比如脏页比例通常在 30% 以下波动,高水位可以设在 40% 以上,低水位设置在 20% 以下。
- 再观察一次完整压测曲线,如果锯齿仍然明显,优先考虑增加后台回写并发,而不是把水位调得过低。并发提升后回写吞吐变大、单次持续时间变短,能显著降低毛刺。
水位不能设得太保守。低水位设太高会导致后台线程几乎不停在工作,虽然脏页少,但平白占用了 CPU 和网络带宽,反而影响正常查询。
4.4 如何观测 FileCache 是否在工作
要验证调优效果,离不开监控指标。一般至少要看这些指标:FileCache 总容量、当前缓存页数、读命中次数和 miss 次数、最近一段时间的命中率、脏页占比、后台回写页数和耗时、淘汰页面总数、回写失败和异常次数。
实际调优的时候,我会习惯按下面的方式快速判断当前状态:
- 如果命中率持续低于预期,先确认容量是否覆盖热工作集,再确认是否存在扫描型负载污染缓存。
- 如果命中率正常但查询延迟波动大,重点看脏页比例曲线和回写耗时,这个阶段优先调刷盘参数。
- 如果淘汰量很大而且淘汰页中脏页占比高,属于典型容量不足导致连锁反应,需要扩容或者优化替换策略。
我在生产环境调优时会做一个简单记录表,持续跟踪关键指标的变化。
| 观察项 | 健康状态参考 | 异常信号 |
|---|---|---|
| FileCache 读命中率 | 长期稳定在 75% 以上 | 持续下降且无法解释 |
| 脏页占比 | 多数时间低于 50% | 迅速逼近高水位并反复触发回写 |
| 后台回写耗时 | 平滑、无长尾 | 单次回写耗时突增 |
| 淘汰页中脏页比例 | 低于 30% | 超过 50% 说明容量明显紧张 |
5. 实际场景中的典型问题与排查思路
5.1 命中率上不去,先分清是容量问题还是策略问题
很多人一看到命中率低,第一反应就是容量给小了,加空间。但在实际环境里,有时候空间根本没用满,命中率还是上不去。这时候要分场景看。
如果容量充足但命中率低,最典型的原因是工作集本身远远大于缓存容量,或者存在大量重复性很低的高频扫描。比如数据仓库做全量分析,整个表可能有一万亿页,但每个查询只扫一遍,缓存了也没价值,命中率当然是低的。这种情况属于场景错配,不是参数问题,应该考虑调整查询计划来减少扫描量,而不是盲目扩容 FileCache。
另一个容易被忽略的原因是缓存索引分片或哈希设计得不好,大量并发查询之间发生哈希冲突,导致查找效率下降,但表面看起来是命中率低。排查方法很简单:降低负载压测,观察单线程顺序查询的命中率是否正常。如果单线程下命中率很高、并发一高就掉,那问题大概率出在索引设计上。
5.2 回写抖动:为什么 IO 曲线变成锯齿状
我曾经遇到过一个典型案例:压测时吞吐量能稳定跑到峰值,但每过几分钟 IO 曲线就像一个锋利的尖刺,瞬时延迟升高 5 倍以上。排查后发现脏页比例在高水位线上反复横跳,后台回写线程一旦启动就快速刷到低水位,然后又停下来等脏页累积,整个系统在一个近似于开关控制的状态里循环震荡。
这个问题的直接原因有两个可能:高水位和低水位之间的距离太近,或者后台回写并发太小,单次刷不干净就被动等待。
调整方式就是前面提到的,把低水位调低、拉开两条水位的差距,同时增加回写并发。我也碰到过无论怎么调水位都仍然震荡的案例,最后发现是本地盘容量不够,FileCache 文件碎片化严重,回写 IO 性能大幅下降。那时只能换更大的本地盘或者调整缓存文件数量和预分配策略。
建议在实际调整时,保持单变量原则,一次只动一个参数,观察至少一个完整压测周期,再决定下一步怎么调整。
5.3 FileCache 和 shared_buffers 之间的协同问题
有人总觉得,既然有了 FileCache,是不是可以把 shared_buffers 调小一点,把内存省给应用?这个想法很危险。
shared_buffers 是数据库访问的第一层,FileCache 是第二层。访问每一层都有查找开销,哪怕 FileCache 命中率高,把数据从 FileCache 拷贝进 shared_buffers 也需要一次本地磁盘读取,这个成本远高于 shared_buffers 直接命中。把 shared_buffers 调小,等于强制把大量本该在内存层解决的访问给降级到 FileCache,整体性能一定会下降。
正确理解是:shared_buffers 负责解决高频点查和小范围扫描的快速访问,FileCache 负责解决大范围或者周期性的热数据访问。前者追求低延迟,后者追求高容量覆盖。调优时应该先保证 shared_buffers 足够容纳核心热数据的活跃子集,再让 FileCache 覆盖更大的工作集。
如果内存充足,优先加 shared_buffers 而不是 FileCache。只有当内存已经加到合理上限、仍然存在大量本来可以通过二级缓存吸收的访问时,FileCache 的价值才会最大化。
5.4 异常宕机后 FileCache 如何保证不损坏主数据
FileCache 是缓存,理论上宕机了重建就行。但现实里没有这么简单,因为 FileCache 很可能保存着一些还没有回写远端存储的脏页。如果宕机直接丢弃这些脏页,会不会丢数据?
这要回到前面说的一致性边界:只要数据库日志是完整的,并且脏页回写时机严格走在日志落盘之后,即使 FileCache 里最新的脏页全部丢失,崩溃恢复时也能通过日志把数据恢复到最新状态。FileCache 丢掉的最多是性能,不会丢数据。
如果实现里日志和回写时序控制不严格,那才是真正的灾难场景。所以我在检查这套机制时,一定会重点看两个设计点:
- 后台回写脏页前是否强制校验对应事务日志已经安全落盘。
- 缓存元数据在实例异常退出时能不能快速重建,或者能不能通过检查文件头识别出损坏状态,从而让实例拒绝使用一个半损坏的缓存文件自动降级为无缓存模式启动。
换句话说,FileCache 应该被设计成一个“锦上添花”的模块,可用的时候加速性能,异常的时候要能够无痛退出,绝不能让缓存本身的损坏拖垮主数据链路。
6. 场景适配与实用经验
6.1 什么业务场景最能发挥 FileCache 优势
从我的观察来看,FileCache 在几类场景中的收益最为明显。
第一类是有明显热数据分层、但热数据总量超过内存容量的在线业务。比如一些互联网业务,活跃用户关联的数据大概几百 GB,单机内存只能放几十 GB。这种场景如果不做二级缓存,每次促销或者流量高峰之后,热点数据换一批,远端存储都要被高强度访问一遍,网络和存储都会成为瓶颈。
第二类是分析型查询和大范围批量操作比较多的业务。这类查询会频繁扫描大表,但同一批数据在短时间内又可能被多个查询反复读取。FileCache 可以把第一遍扫描的数据留下来,后续查询很多直接命中本地缓存,避免重复消耗远端存储的带宽。
第三类是对稳定性要求高于极致性能的场景。本地 FileCache 即使速度不如内存,但延迟波动通常比网络存储小得多。如果业务对 P99 延迟比较敏感,FileCache 相当于给存储访问增加了一个低波动的缓冲层。
反过来,如果业务特征是完全随机的大工作集访问,每次查询翻到的页面基本都不重复,那 FileCache 没有价值,反而多了一层拷贝和管理开销。
6.2 从内核实现中总结的三条实操心得
做了这一轮机制梳理和实际压测后,我最大的体会是,FileCache 不难理解,难的是在各种极端场景下保持行为可预期。这里有三条心得记下来,供后来者参考。
第一,缓存模块最怕的不是容量不够,而是行为不可控。宁可把容量设小一点,也要保证淘汰和回写路径没有无法解释的毛刺。可控性差的缓存,就像车里一个随时可能突然猛踩刹车的系统,哪怕它多数时候能加速,也没人敢放心用。
第二,一定不要只盯着命中率一个指标。一个命中率稳定在 80% 的系统,如果那 20% 的 miss 请求全是高优先级关键查询,实际体验也可能很差。缓存命中率很重要,但它衡量的是整体命中情况,反映不了单条关键查询的感受。
第三,所有缓存参数都要能经得起故障演练的考验。FileCache 真正见功力的时候不是性能跑分最高的时候,而是模拟宕机恢复以后,系统能否快速回到正常水位,缓存能否平滑重建,业务受不受影响。这方面多花时间设计、验证,比做任何微调都值。
如果你自己也在做类似机制或者正在用 He3DB 的 FileCache,我个人建议从一个小实验开始:把缓存容量从零开始逐步扩大,记录命中率、延迟和 IO 曲线变化,一般很快就能找到适合你业务的那个甜蜜点,也会对这套机制形成更直观的理解。
