你在云上跑过一个几十 GB 的分析查询,第一次闷了几十分钟,第二次再跑突然几秒钟就出来了。大多数玩 PostgreSQL 的人第一反应是“shared_buffers 缓存热了”,但如果底层用的是大云海山数据库(He3DB)这种存算分离形态,真正在背后干脏活累活的,往往是 FileCache。这篇文章我想把 He3DB FileCache 机制的来龙去脉讲透,包括它到底缓存什么、为什么不直接用 Page Cache、命中率和淘汰策略怎么设计、参数怎么调、上线后会踩哪些坑。适合正在折腾云原生数据库选型、想把 PostgreSQL 业务迁到共享存储架构,或者单纯好奇“为什么同一个查询第二次跑得快这么多”的 DBA 和后端开发者。
1. 先搞清楚背景:He3DB 为什么要做 FileCache
1.1 大云海山数据库到底是什么形态
先说清楚 He3DB 的定位:这是一款整体延续 PostgreSQL 生态的云原生数据库产品,对外协议兼容 PG,所以应用层几乎不用改就能接入。但它和传统“一台机器上跑一个 PG 实例,数据落在本地磁盘”的部署方式有本质区别。He3DB 做了计算与存储分离:计算节点只负责 SQL 解析、执行计划、事务处理,真正存放数据的是一套远端共享存储。也就是说,底层的那份数据文件并不在计算节点的本地磁盘上,而是在远端。
这种架构最直观的红利是弹性。计算节点可以独立扩缩容,存储也不绑死在单机上,数据可以多副本冗余,底层存储故障不影响计算节点。传统 PG 碰到磁盘满了只能扩盘或者做迁移,存算分离方式下这些问题都被一层共享存储挡在下面,对业务是透明的。
但代价也很实在:每一次数据页访问都要走网络。读一个 8KB 的页面,本地盘可能几十微秒就完成了,远端存储再快也要经历网络 RTT,通常是百微秒到毫秒级别。如果每个查询都老老实实去远端读页面,再好的执行引擎也会被 IO 延迟拖死。FileCache 在这里承担的角色,就是给计算节点配一个“本地仓库”,把远端经常访问的热数据页缓存到本机存储上,尽量让热点查询在本地就能拿到数据。
1.2 没有本地缓存时,一条查询的数据路径有多长
为了说明 FileCache 的价值,我拆一下最朴素的数据访问路径。假设 SQL 要读某张表的一批页面,在没有 FileCache 的情况下,一次页面请求大概需要经历这么几步:
- PostgreSQL 的 buffer manager 去 shared_buffers 里找目标页,没命中。
- 调用存储管理接口,把请求发给远端存储客户端。
- 客户端把读请求编码、加密、通过网络发送到远端存储服务。
- 远端存储从底层磁盘或者分布式存储集群中取出页面。
- 数据再经网络返回计算节点,做校验、解密。
- 页面进入 shared_buffers,返回给查询执行层。
这条链路里每一步都有实际开销。网络抖动、存储服务端排队、协议解析,都可能让单次页面读取变成几毫秒。如果是一个全表扫描或者大范围索引扫描,涉及上万甚至上百万个页面,这种延迟会直接叠加成分钟级的差距。
所以 FileCache 的出现本质上是在回答一个问题:既然网络读取这么贵,能不能在计算节点本地保留一份最近用过的页面副本?下次再读同样的数据,直接从本地存储返回,把远端网络 RTT 从关键路径上拿掉。这就是 FileCache 最核心的定位——一层位于计算节点本地、以数据库页面为缓存单位的应用层缓存。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. FileCache 的核心机制与设计选择
2.1 缓存粒度:为什么按“页”管理,而不是按文件或整表
FileCache 在设计上最需要先确定的就是缓存粒度。你可以按整张表文件缓存,也可以按数据块缓存,还可以按 PostgreSQL 的页面(默认 8KB)缓存。He3DB 的实现是走页面级粒度,这个选择在工程上很自然,原因有三点。
第一,PostgreSQL 自身所有 IO 都是以 page 为单位的,shared_buffers 中换入换出的最小单位也是页。FileCache 如果按页缓存,正好可以和上层的共享缓冲池一一对应,控制逻辑最省事。读到哪个页缺哪个页,就缓存哪个页,不需要额外设计一套表文件级别的读取逻辑。
第二,热数据往往是局部的。一张大表可能几十 GB,但业务真正高频访问的只有最近写入的几个数据块或索引片段。如果按文件粒度缓存,命中率上去了,但缓存空间会被大量冷数据白白占用。按页缓存则可以把有限的空间精准投放到真正热的数据上,空间使用效率要高得多。
第三,失效和一致性的控制更精确。当远端存储上的某个页面被更新后,FileCache 只需要对这一个页面的副本做失效处理,而不需要把整个文件的缓存全部清掉,影响面小很多。
按页管理带来的代价是元数据开销和碎片管理。每页一条缓存记录,几 TB 缓存空间可能要维护上亿条记录,索引结构设计不好,光查缓存元数据就会成为一个瓶颈。工程上的常见做法是设计多级索引或者哈希表加 LRU 链表,再配合本地文件的预分配和空闲块管理来降低碎片影响。
2.2 FileCache 和 shared_buffers 之间的关系
很多刚开始接触 He3DB 的人会问:PostgreSQL 不是本来就有 shared_buffers 吗?为什么还要再搞一层 FileCache?这两个东西经常被混为一谈,但实际上是两层完全不同的缓存。
shared_buffers 是数据库进程共享内存里的一块区域,由 PostgreSQL 的 buffer manager 直接管理。它的特点是快,纯内存访问,纳秒级到微秒级;但容量受内存限制,通常建议配置为物理内存的 25% 左右,不可能无限大。而且数据库实例重启后,shared_buffers 里的内容会全部清空,需要重新预热。
FileCache 则是把数据缓存在计算节点的本地磁盘上。本地磁盘比内存慢,但比网络访问快几个数量级,更重要的是容量可以从几十 GB 到几 TB 规划,成本远低于内存。还有一个容易被忽略的点:FileCache 是持久化的,数据库或计算节点进程重启后,本地磁盘上的缓存内容仍然在,可以继续复用,不需要重新经历漫长的冷启动预热。
在实际访问路径上,层次关系是这样的:查询引擎先从 shared_buffers 里找页,找不到就去 FileCache 里找,FileCache 再找不到才真正发起远端读取。远端读到的页面会同时回填到 FileCache 和 shared_buffers。所以可以把 shared_buffers 理解为收银台旁边的“手边高频柜”,FileCache 是门店后面的“本地仓库”,远端共享存储则是“总仓”。手边柜放不下的放本地仓库,本地仓库也没有才去总仓调货。
2.3 读写路径和一致性判断是真正的难点
FileCache 的读路径相对好理解:算页面号,查缓存,命中就读本地,未命中就回源。真正复杂的写路径和缓存一致性。He3DB 既然是多计算节点共享同一份远端数据,那么一个计算节点上的写入,必须能反映到其他节点的缓存判断上。
如果这个问题不处理,会出现经典的缓存脏读:节点 A 把某个页面缓存在本地,节点 B 随后更新了远端存储上的同一个页面,节点 A 再读本地缓存时拿到的就是旧数据。对于数据库这种一致性要求极高的场景,这是完全不可接受的。
从机制设计上看,解决思路一般会有两条路线。一条是集中式失效通知:远端存储或者元数据服务在页面被更新后,主动广播失效消息给所有计算节点,让它们把对应的缓存页标记为无效。另一条是版本校验:每个数据页带上版本号或 LSN(日志序列号),计算节点在读缓存时先确认本地缓存页的版本是否仍然是最新的,一旦发现落后就重新回源读取。实际产品往往会结合两者,因为纯广播在大集群下容易变成信令风暴,纯版本校验又会让每次读缓存都多一次元数据查询,影响命中时的性能收益。
从我踩过的经验来看,理解到这里就够了。日常运维中并不需要手动处理缓存一致性问题,但如果遇到“明明更新了数据,查询却还是老结果”的诡异现象,排查方向就应该往这个机制上靠,而不是先怀疑业务代码。等真出现这种问题时,你再回来看这一段,会少走很多弯路。
3. 命中率、预读与淘汰策略
3.1 命中率是衡量 FileCache 效果的第一指标
FileCache 做得好不好,不需要看一堆复杂指标,先看缓存命中率。命中率定义很直接:读请求中能从本地缓存直接返回的比例。命中率 99% 意味着 100 次页面读取里有 99 次是本地磁盘响应,只有 1 次走了网络;命中率降到 90%,网络回源次数就放大了 10 倍,查询延迟马上能感觉到差异。
这里要注意区分是“页面命中率”还是“查询命中率”。数据库领域通常更关注页面命中率,因为一个查询可能要读成千上万个页面,只有页面级命中率上去了,查询整体才快。查询级命中率很容易做到很好看,但意义不大。比如一条查询 100 万个页面只漏了 1 个,查询命中率 99.9999%,看起来很高,实际那 1 个漏网页面仍然可能因为一次慢速网络读取拖慢整个 SQL。
从我的经验看,一个健康的联机交易业务,FileCache 页面命中率长期低于 90% 就要警惕了。要么是缓存容量规划不足,要么是访问模式过于离散,要么是预读策略没有适配业务。分析型业务的命中率往往会周期性波动,这和大查询刷新缓存有关,不一定代表有问题,需要结合时间窗口观察。
3.2 淘汰策略:为什么简单的 LRU 可能不够用
缓存空间有限,总要面临一个问题:当缓存满了,新页面要进来,哪些老页面被挤出去。最经典的策略是 LRU,即淘汰最久未访问的数据。但实际在 FileCache 这种场景下,纯 LRU 有一个非常典型的缺陷:全表扫描会把缓存全部污染。
想象一下,你的缓存里整整齐齐放着业务热点页面,突然跑了一个几 GB 的临时分析查询,全表扫描疯狂读页面。这些扫描页面虽然只被读一次,却会顺着 LRU 顺序把前面真实的热点数据全部挤出去。等分析查询跑完,热数据全部需要重新回源,整体命中率断崖式下跌,业务高峰期的表现会变得非常不稳定。
所以成熟实现一般会引入带频率概念的策略,比如 LRU-K 或者 LFU 的变种。核心思想是:一个页面只有在短时间内被访问了至少 K 次后,才被认为是“值得缓存”的;只被访问一次的页面,即使进入了缓存,也很快会被淘汰,不给它污染热数据的机会。相当于给缓存加了一道过滤器:想留在本地,你先证明自己是热数据。
另一个从工程上非常有效的补充手段是分区隔离。把缓存空间划分为不同区域,比如高频交易表、系统表、临时分析表分别使用独立预算。这样即使某一天某个业务跑了一次大型报表,也不会把其他业务的热数据全部挤掉。代价是实现复杂度高,因为不同区域的容量比例需要动态调整,分区太死板又会导致空间浪费。
3.3 预读和预热:主动让缓存“热”起来
FileCache 不是只能被动地等查询命中。很多场景下可以用预读能力提前把数据搬进本地。尤其是顺序扫描场景,数据库知道下一批要读的是连续页面,完全可以在当前页面被访问时,把后续若干个页面一并异步读入 FileCache。这样下次访问时直接命中,不需要现场走一次网络。
预读逻辑要做到克制。预读太多,会把缓存里塞满还没用到的数据,反而占了热点页面的空间;预读太少,又起不到提前加载的效果。我这里给一个相对实用的判断准绳:顺序特征明显的扫描可以大胆预读,随机点查场景基本不要依赖预读,而是通过缓存容量和淘汰策略来提升命中率。
如果你知道自己第二天早上会有固定的批量任务或者高峰流量,还可以提前做手动预热。做法不复杂:在低峰期把核心业务表的关键数据段扫描一遍,让 FileCache 先把这些页面加载到本地。这样高峰一到,热点数据已经在本地,业务延迟会平稳很多。这个操作在传统 PostgreSQL 里也有类似实践,但存算分离架构下的收益更明显,因为把预热成本从网络 IO 变成了本地后台 IO,不影响在线业务。
4. 配置、容量规划与调优实操
4.1 部署前先盘清楚四笔账
FileCache 的配置调优,不是装好后看几个参数改一改就完事的。我建议在做容量规划时先回答四个问题:工作集多大、本地盘够不够、淘汰压力多大、回源带宽是多少。
工作集指的是业务在某个时间窗口内真实访问的页面集合大小。这个值可以从监控里观察,也可以通过压测获取。存量业务可以先抓一段时间 buffer 命中情况和热数据量来估算,新业务可以按核心表数据量的一个百分比起步。
本地盘容量决定了缓存上限。通常建议配置为工作集的 1.2 到 2 倍,预留一些空间给突发的分析型查询。如果本地盘太小,缓存永远只能装下工作集的一半,再多策略也救不回命中率。淘汰压力实际上反映的是缓存容量和访问模式的匹配程度,如果监控里频繁出现大批量淘汰,说明容量偏小,扩容是最直接的手段。
回源带宽容易被忽略。当缓存未命中时,所有计算节点都要从远端存储读取数据。如果多个节点同时冷启动,回源带宽会成为瓶颈。规划时一定要确认远端存储和网络能承受所有计算节点同时回源的峰值流量,而不是只看平均流量。很多问题排查到最后发现不是缓存机制的问题,是网络带宽被打满了。
4.2 一组可以落地的初始配置
我不建议直接照抄别人的参数,但可以先给一组经过实践验证的起点值,再根据监控调整。以我接触到的 He3DB 部署环境为例,以下是一个针对联机交易场景的初始配置组合:
- 本地缓存目录单独挂载一块 NVMe SSD,不与其他日志、临时文件混用。
- FileCache 容量设置为工作集估算值的 1.5 倍左右,比如估算工作集 400 GB,就分配 600 GB。
- shared_buffers 保持 PG 常规设置,不需要为 FileCache 刻意调低,两者互不挤占。
- 顺序预读页面数量设置为 8 到 32 个页面,数值太大容易造成缓存污染。
- 淘汰策略如果可选择模式,优先选带频率感知的模式,而不是基础 LRU。
- 数据库和文件系统层面把 IO 调度器设置为适合 SSD 的模式。
关于配置项的具体名称,不同发行版和版本之间的变量命名可能会略有差异,建议拿到环境后先用 SHOW 命令把当前相关配置全部列出来,再逐个对照。盲改一个看起来很像的参数名是运维里最危险的操作之一。
4.3 用监控指标反推调优方向
配置启动之后要通过监控持续观察。比较关键的是这几个指标:页面命中率、缓存淘汰数量、回源 IOPS、回源延迟、本地缓存空间使用率、缓存目录 IO 延迟。
如果命中率长期低于 90%,说明容量或访问模式有问题,优先看有没有大量全表扫描在污染缓存,再看缓存容量是否不足。如果命中率很高但查询依然慢,问题可能不在缓存层,而在 CPU、执行计划或者远端存储本身。如果缓存空间使用率始终在 80% 以下,说明容量有冗余,但也可能是工作集本身就不大,不需要急着扩容。最容易被忽视的是本地磁盘的 IO 延迟。FileCache 的本地磁盘慢,会直接拖慢所有“命中”的请求,导致命中率很高但性能依然很差。这种情况建议检查是否有其他进程在争抢同一块磁盘 IO,比如日志落盘、系统备份扫描等。
调优是一个反复迭代的过程,没有一套参数能适配所有业务。我在实践中的方法论是:每次只改一个变量,观察 24 小时以上的监控数据,再做下一步调整。同时修改多个参数,出了问题根本定位不到是哪一个引起的。
5. 上线以来踩过的坑与问题排查实录
5.1 缓存命中率只有 20%,问题出在 SQL 模式上
我最早遇到的一个生产问题是:FileCache 配置了足够大的容量,业务流量也正常,但监控上命中率只有 20% 左右。排查了很久本地盘和网络都没有异常,最后分析 SQL 日志才发现,很多报表任务用了类似 SELECT * 的方式做全表扫描,过滤条件完全没有走到索引上,每天有大量扫描页面在缓存里进进出出,把真正的热点页面全部挤掉了。
解决方式不是调大缓存容量,而是从业务侧入手优化 SQL,让查询只取必要的列和行,同时为高频过滤字段建立合适的索引。SQL 优化之后,扫描量降下来,命中率很快就恢复到 95% 以上。这个案例给我的教训是:缓存命中率上不去,先别急着调参,回来看业务访问模式可能更有效。
5.2 磁盘告警:本地缓存变成了“写放大”的来源
另一个印象比较深的坑是磁盘空间告警。当时本地缓存目录设置得不够谨慎,加上后台任务频繁产生大量新页面,缓存淘汰和写入的频率非常高。FileCache 写入本身消耗磁盘空间,同时元数据更新和可能的日志记录也在同步写盘,最后本地磁盘直接被写满了。
这个问题的排查过程比较曲折,一开始还以为是系统日志膨胀,后来才发现是缓存空间策略和后台维护任务叠加导致。最后的处理是重新规划了缓存目录容量,为系统预留了 20% 的磁盘余量,同时限制了低优先级后台任务对缓存的填充速率。
经验总结下来有三点:本地缓存专属目录要和系统目录分离;容量配置时一定要留出头部空间,不能把磁盘用满;如果有多种后台任务都在读写数据文件,要考虑它们对缓存的整体冲击,必要时做任务错峰。
5.3 重启后的“冷启动”阵痛期怎么看
传统 PostgreSQL 实例重启之后,shared_buffers 清空,性能会有一个下降过程。存算分离架构下还有一个 FileCache 的问题:虽然 FileCache 数据在本地磁盘上持久保留,但如果计算节点迁移到了新的物理机或者本地盘被重新初始化,缓存就没了,相当于完全冷启动。
冷启动期间所有页面访问都要回源,网络 IO 压力会猛增,查询延迟明显变高。如果业务对延迟极其敏感,建议在重启或迁移前记录一下核心工作集的页面范围,提前做一轮预热。如果没有预热条件,就要接受一段时间的性能缓冲期,并确保远端存储的带宽余量足够扛住回源流量。我一般在变更窗口前会先抓一份热点表清单,变更完成后按清单顺序做一次低优先级预热,能把阵痛期从小时级压缩到分钟级。
5.4 常见问题速查表
最后把我在实际运维中遇到过的典型问题整理成一张表,方便排查时直接对照:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 命中率长期偏低 | 大量全表扫描、缓存容量不足 | 检查慢 SQL、评估工作集与缓存容量比例 |
| 命中率高但响应慢 | 本地磁盘 IO 争抢、磁盘老化 | 用 iostat 观察缓存目录所在磁盘的延迟和队列 |
| 缓存目录空间告急 | 容量规划不足、后台任务大量写入 | 拆分目录、限制维护任务速率、扩容 |
| 重启后性能骤降 | 本地缓存未持久化或实例迁移 | 提前预热,确认缓存目录落在持久盘上 |
| 更新数据后读到旧值 | 缓存一致性判断异常 | 检查版本校验机制、观察 LSN 推进是否正常 |
| 大查询拖垮在线业务 | 扫描数据污染缓存 | 开启频率感知淘汰、做缓存分区隔离 |
这个表不能覆盖所有场景,但可以当作第一轮排查的切入点。数据库底层的诡异问题,十有八九都出在缓存和一致性这两个词上,FileCache 也不例外。把这两条线的机制理解清楚,遇到问题时思路会清晰很多。
最后再分享一点个人体会。FileCache 这类机制,最反直觉的地方在于:它看起来只是一个“缓存”,但实际上它的设计牵扯到存储架构、IO 路径、一致性协议和业务访问模式,任何一个环节理解不到位,都可能让性能调优变成玄学。我建议你在自己的环境里多做对照实验,故意关掉 FileCache 跑一轮压测,再打开跑一轮,把两边的延迟和回源指标放在一起看,很快就能建立起对这套机制的实感。纸上谈兵一万遍,不如亲手验证一遍。
