Linux VFS 目录项缓存 dcache 深度剖析:原理、回收与调优实践

发布时间:2026/10/8 2:37:19
Linux VFS 目录项缓存 dcache 深度剖析:原理、回收与调优实践 如果你在 Linux 上跑过find / -name xxx这样的命令大概率会观察到一种现象第一次执行慢得让人想砸键盘第二次再跑却快得跟开了挂一样。很多人把这个归功于 page cache但实际上真正让路径解析飞起来的是 VFS 层一个叫 dcache 的机制——也就是目录项缓存 dentry。作为 VFS 深度剖析系列的第二篇这篇想把 dentry 从头到尾拆一遍它到底缓存了什么、用什么数据结构组织、生命周期怎么管理、内存回收靠什么驱动以及我们在日常运维中能怎么利用和干预它。这篇文章适合这几类人看维护 Linux 服务器、经常跟文件系统性能和内存占用打交道的运维工程师做嵌入式 Linux、需要裁剪内核或者深挖文件系统行为的内核开发还有准备 Linux 内核岗位面试需要把 VFS 的细节讲清楚的人。我会尽量把原理和实操放在一起讲不搞那种看起来都懂、实际用不上的空框架。1. 从一次 find 命令说起为什么需要目录项缓存1.1 没有 dcache 的话打开一个文件有多难先想一个问题当你在 shell 里执行cat /etc/hostname时内核要做什么它需要从根目录/开始一级一级往下找先解析根目录然后在根目录里找到etc这个目录项再进入etc目录在里面继续找hostname这个文件。每一步找在 VFS 里对应一次 lookup 操作背后可能是一次真实文件系统的目录读取和匹配。如果你的服务器上只有几百个文件这点开销无所谓。但想象一下/usr/lib这种几万个文件的目录或者跑着数据库、代码仓库、容器镜像的机器路径解析就会变成一个实打实的瓶颈。最直观的例子是编译项目Makefile 里每一条规则都要反复访问源文件和头文件如果每次访问都要从磁盘把目录内容读出来、逐个比对文件名编译时间会直接翻倍都不止。1.2 dentry 是 VFS 和具体文件系统之间的缓冲带那怎么解决答案是缓存路径组件的解析结果。VFS 在内存里维护了一张超级大的哈希表把路径中的每一级名称作为 key把这个名称对应的 inode 信息以及父子关系作为 value。这张缓存就叫 dcache缓存里的每一个条目就是一个 dentrydirectory entry。这里要强调一个容易混淆的点dentry 不是磁盘上的目录项它是内存中的对象。ext4 的磁盘上确实有目录项但那是 ext4 自己的格式ext4_dir_entry_2VFS 根本不关心那是怎么排列的。dentry 是 VFS 为了统一管理路径解析而抽象出来的内存结构。每个 dentry 记住我叫什么名字、我的爸爸是谁、我的孩子有哪些、我对应哪个 inode。有了这层缓存第二次访问同一个路径时内核直接查哈希表就能命中根本不用跑去问 ext4/XFS 等底层文件系统。1.3 目录项缓存到底省了什么用数据说话。一次完整的路径解析按最朴素的实现需要考虑每一级目录都需要一次真实文件系统的 lookup 操作每个 lookup 都要读取磁盘上的目录数据块如果目录项很多还需要在目录数据内部做线性或者 B 树查找。而有了 dcache 之后大部分 lookup 变成一次哈希查找 内存指针跳转毫秒级变微秒级量级差异非常可观。对于ls -l /usr/bin这种命令路径解析的耗时几乎可以忽略真正的开销反而转移到了 stat、readdir 等操作上。所以dentry 缓存的本质是用内存换时间——把路径解析这个高频操作的代价从磁盘 IO 转移到内存查找上。理解了这个动机后面看它的数据结构和状态管理就不会觉得奇怪了。2. struct dentry 的硬核拆解字段、哈希表与回调函数2.1 把 struct dentry 的关键字段逐个过一遍dentry 的结构定义在include/linux/dcache.h里。我不打算把每个字段都罗列出来那样反而啰嗦只挑影响我们理解和使用的最核心部分字段作用补充说明d_parent指向父 dentry路径回溯和路径拼接都靠它d_name这个目录项的名字底层是struct qstr带长度和 hash 值d_inode指向这个目录项对应的 inode分离之后会置空对应 negative dentryd_op文件系统相关的回调操作集合比如d_revalidate、d_compared_sb所在文件系统的 super_block回收时按文件系统批量处理d_hash链接到全局哈希桶的节点使用hlist_bl_node支持 RCUd_lruLRU 链表节点用于缓存回收不再被引用时挂到这个链表上d_flags状态标志位是否 connected、是否 mounted 等d_count引用计数决定这个 dentry 能否被回收其中d_name里的qstr结构很有意思它保存了名字、长度和预先算好的 hash 值。为什么要存 hash因为 dentry 查找的核心就是哈希比较提前算好 hash 能避免每次都遍历字符串。2.2 哈希表怎么组织从路径到 O(1) 命中的距离dcache 底层的哈希表定义在fs/dcache.c里的dentry_hashtable是一个数组每个元素是hlist_bl_head。dentry 通过d_hash字段挂在对应的桶上。这里有两个细节值得注意第一哈希 key 是 (parent dentry name hash)而不是完整的路径字符串。相同名字但不同父目录的文件是不同 dentry它们在哈希表里的位置也不一样。这样做的好处是查找效率高——路径解析是按层级走的每一级都只需要在当前父目录的那批子 dentry 里找而不是在整个全局路径里匹配。第二hlist_bl里那个bl表示 bucket list 每桶内置了一个位锁bit lock为了配合 RCU 使用。由于 dcache 是读多写少的典型场景大部分路径解析只需要加 RCU 读锁就能完成不需要拿全局锁这也是为什么多核高并发下路径解析依然很顺畅。关于哈希表大小的计算内核会根据系统内存总量来选择数量级内存越大桶越多冲突越少。实际运行中我们可以直接通过/proc/slabinfo看到 dentry 的 slab 占用情况这块后文会细讲。2.3 d_op 回调到底是谁在掌控 dentry 的行为d_op是 dentry 和具体文件系统之间的桥梁。不同文件系统对目录项的行为要求不一样VFS 就用一组回调把差异封装起来。最常见的几个回调d_revalidate路径解析时内核会调用它确认这个 dentry 缓存是否仍然有效。网络文件系统NFS、CIFS尤其依赖这个回调因为服务端文件可能被别的客户端改了本地缓存可能过期。d_compare自定义目录项名字的比较逻辑像一些大小写不敏感的文件系统会用到。d_delete决定 dentry 被删除时是否需要立即断开与 inode 的关联。d_release和d_prune内存回收阶段的清理钩子。很多人在学 VFS 时会忽略d_op但实际上它才是 dentry 可扩展的灵魂。面向具体文件系统做定制的时候第一件事就是确定要提供哪些回调如果不懂回调机制你写出来的文件系统即使能跑行为上也会和预期差很远。3. 四种状态与一生流转dentry 的生命周期管理3.1 positive、negative、unhashed、disconnected状态机的核心dentry 不像普通的缓存条目那样有就命中、没有就重建它有一套完整的状态机。这是理解 dcache 行为的关键。常见状态就这么几类positive dentryd_inode不为空表示这个目录项真实对应一个 inode可以正常使用和共享。negative dentryd_inode为空。这个名字存在但对应的文件当前不存在。这种 dentry 通常是因为一次失败的 lookup 而创建之后被缓存下来。unhashed dentry已经从全局哈希表中摘下来了通常处于被销毁的边缘。disconnected dentry暂时连不到根节点的 dentry一般是文件系统中间层出现的临时状态正常路径解析完成后会重新连接。每个状态之间可以互相迁移。比如一个 positive dentry 对应的文件被删除VFS 会把它的d_inode置空它就变成 negative dentry但名字可能还留在缓存里。而这个 negative dentry 如果后来有同名的文件被创建lookup 时就能重新挂上新的 inode变回 positive。3.2 引用计数 d_count 的起起落落每个 dentry 都有引用计数d_count。这个计数代表有多少代码路径正在使用这个 dentry。比如路径解析过程中每进入一级目录都会对该级 dentry 持有引用open 文件之后file 结构会引用其对应的 dentry挂在 dcache 哈希表里本身也算一种存在但 LRU 回收不把它当活跃引用。核心操作是dget()和dput()。dput很有意思当引用计数从 1 降到 0说明没有活跃使用者了但 dentry 还没立刻销毁。内核会先把它放到 LRU 链表中等待后续回收如果内存压力大回收线程再按 LRU 顺序把这些 dentry 释放掉。如果此时 dentry 是 negative 的回收的优先级还会更高。这里有个常被误解的细节dentry 不是没人用就立刻销毁而是没人用就进 LRU 留着目的就是尽量延长缓存命中时间。只有内存紧张或者显式触发 shrink 的时候才真正释放。3.3 从分配、查找失败到回收的完整路径我梳理了一条典型链路看完就能对上状态机进程访问/etc/hostnameVFS 开始路径解析在根 dcache 中查找etc这个子 dentry如果命中继续往下如果 miss则调用文件系统inode-i_op-lookup()创建一个新的 dentry 并关联 inode继续查找hostname命中则返回结构否则负缓存住这个文件名文件被unlinkVFS 执行 dentry 的d_delete断开和 inode 的关系变成 negative 或直接 unhash最终dput将引用计数降到 0dentry 被挂入每个 super_block 的 LRU内存回收时shrink_dcache_sb按 LRU 顺序踢掉一部分释放内存。这条链路的每一步背后都有对应的内核函数__d_lookup、d_alloc、d_lookup、dput、shrink_dentry_list等等。3.4 负缓存一个常被忽略的双刃剑负缓存negative dentry值得单独拎出来讲。它的核心价值是**如果某个文件不存在第二次访问时不用再去磁盘确认一遍。**比如频繁检查/var/lock/xxx.lock是否存在每次都要真实 lookup 会拖慢流程负缓存直接回答不存在省掉了 IO。但负缓存也有坑。如果你在 A 机器上删掉一个文件然后又通过另一个客户端比如 NFS创建同名文件A 机器上可能还缓存着那个 negative dentry导致它始终看不到新文件。要解决这种文件明明存在却提示不存在的问题就需要依赖文件系统自己实现d_revalidate或者手动去触发 dentry 的失效。4. dcache 回收与内存水位调优前必须搞懂的机制4.1 LRU 链和 shrink 机制的配合dcache 不是无限增长的它有回收机制。当 dentry 引用计数降为 0就会被放到每个 super_block 的s_dentry_lru链表上。链表顺序就是 LRU 顺序越久没被访问的越靠前越容易被回收。回收的入口是shrink_dcache_sb()它会遍历这个 LRU从尾部开始踢人。什么时候触发 shrink最常见的情况有内存压力系统触发直接回收、drop_caches、文件系统 unmount 等。另外dput在内存紧张时也会主动尝试回收一些 LRU dentry。因为 dentry 是 slab 分配器管理的对象所以查看内存占用直接看/proc/slabinfo里dentry那一行的active_objs和num_objs就行。我习惯用命令grep dentry /proc/slabinfo如果发现 active_objs 很大且nr_unused在/proc/sys/fs/dentry-state里也很高说明系统有大量空闲但未回收的 dentry压力大的机器上就需要考虑 vfs_cache_pressure 的调节了。4.2 /proc/sys/fs/dentry-state 怎么读这个文件每个字段都有含义我用实际环境输出说明$ cat /proc/sys/fs/dentry-state 1234567 1000000 45 0 0 0六个数字分别对应位置含义1dentry 总数2未使用unused的 dentry 数3内存压力下希望保留的最少 unused 数量age limit4当前需要回收的 dentry 数一般 05如果非 0表示有伪文件系统在调整一般为 06同上保留字段我一般观察的是第 1 和第 2 个数字。如果总数非常大、未使用数也很高说明 dcache 里堆积了大量冷 dentry可以考虑调高回收优先级。但先别急着改看下一节。4.3 vfs_cache_pressure 和 drop_caches 的实操经验/proc/sys/vm/vfs_cache_pressure控制了内核回收 dentry 和 inode 缓存相对于 page cache 的倾向性。默认值是 100含义是以同等压力回收调大了会让内核更激进地回收 VFS 缓存调小了则倾向于保留。一个常见经验数据库服务器、目录文件特别多的服务器不要轻易把 vfs_cache_pressure 调大。dentry 缓存对这类负载的路径解析至关重要调大后可能回收过于积极反而导致 lookup 重新变慢、CPU 上升。但如果内存真的紧张、又不想因为 dcache 占满了 page cache那可以尝试sysctl -w vm.vfs_cache_pressure200再配合观察命中率和内存占用找到适合自己的值。想主动清空 dcache 的话比较常用的方式echo 2 /proc/sys/vm/drop_caches这里要提醒两个容易踩的坑echo 2清的是 dentry 和 inode 缓存会短暂增加路径解析延迟不要在业务高峰期做echo 3是 page cache dentry/inode 一起清清理后文件重新读取会全部落到磁盘上影响更大。运维上除非做压测实验否则我很少用 3。4.4 小内存设备上的裁剪手法嵌入式系统内存紧张dcache 也不能放任不管。除了调 vfs_cache_pressure还有一种更根本的办法限制文件系统规模、避免超长路径和超大目录。另一个可以动手的是在编译内核时直接减少哈希表桶的数量通过调低内存阈值让 dentry 哈希表占用的基础内存更小。不过实际项目里我见过最有效的还是应用层配合——减少无谓的路径解析。比如 fix 掉那些频繁stat不存在的文件路径的代码比在内核层做任何调优都直接。5. 现场排查dentry 引发的常见坑与监控手段5.1 慢查询和 CPU 突刺如何确认是 dcache 的问题服务器突然出现 CPU 飙高负载干到几十很多人第一反应是看进程、看磁盘却很少想到路径解析。我之前处理过一个案例某 APP 服务器每秒钟要读几十万个/var/tmp/session_xxx文件但其中三分之一早就不存在了。由于负缓存的存在这些 lookup 虽然不会每次都访问磁盘但高频的哈希查找和引用计数操作还是把 CPU 打满了。排查思路可以先这样用perf top看内核热点。如果看到d_lookup、__d_lookup_rcu、path_openat这些符号在上层说明路径解析是瓶颈再用strace -c看系统调用频次确认是不是openat/stat被疯狂调用最后看/proc/sys/fs/dentry-state如果 dentry 总数特别大而且 unused 比例高说明缓存堆积严重。5.2 文件看不见negative dentry 的坑这个场景还真是很多人踩过——在一个 NFS 共享目录里客户端 A 创建了文件config.ini客户端 B 因为之前 lookup 过这个路径且文件不存在缓存住了 negative dentry。结果 A 创建文件后B 一直ls不到它。处理办法一般是重启 B 上的相关服务或者触发 dentry 失效更彻底的办法是让文件系统实现d_revalidate每次 lookup 都向服务端确认一下临时的应急命令可以是echo 2 /proc/sys/vm/drop_caches但再次强调这会干扰线上谨慎使用。这个问题本质是缓存一致性问题并不是 dcache 本身设计有 bug而是负缓存策略在某些分布式场景下的固有取舍。5.3 dentry 的泄漏式增长怎么判断和收敛一般情况下dentry 总数会随着业务访问的文件路径数量增加而上升然后在某个水位后稳定。如果发现它一直增长而不下降常见原因有程序频繁打开不存在的路径产生大量 negative dentry程序频繁创建临时文件并删除每次创建都会建 dentry删除后进 LRU但如果没有内存压力回收不一定及时某些伪文件系统或者特殊文件系统没有正确实现d_delete缓存不淘汰。我曾经在一个容器平台上看到 host 机器的 dcache 蹭蹭涨后来定位到是监控 agent 每秒扫描/proc/pid/目录产生了几十万个活动进程的 dentry。这种场景下调 vfs_cache_pressure 是治标最终解法是改 agent 的采集策略减少readdir频次。5.4 我常用的三条监控命令没有花哨的工具纯命令行就够了# 查看当前 dentry/inode 缓存总数 grep -E dentry|inode /proc/slabinfo # 查看 VFS 缓存压力参数 cat /proc/sys/vm/vfs_cache_pressure # 查看每个文件系统级别的缓存统计如果有内核编译支持 cat /proc/sys/fs/dentry-state如果要上生产监控可以每分钟采集一次dentry-state的第一列和/proc/meminfo的 Slab 字段并画出趋势结合业务的发布节奏和目录规模变化基本能及时发现异常。6. 和 inode、mount 的边界感容易混淆的几个细节6.1 dentry 和 inode 到底谁该被缓存这是面试必问也是实际理解最混乱的地方。一句话总结dentry 缓存的是名字到 inode 的映射inode 缓存的是文件自身的元数据。打开文件后真正维系文件生命周期的是 inode而不是 dentry一个 inode 如果被硬链接了多个名字就会有多个 dentry 指向同一个 inode。所以在回收顺序上通常是先回收 dentry尤其是 negative dentry再回收 inode。因为 inode 被释放的前提是所有相关 dentry 都被清理掉了。这也解释了为什么 dcache 会显得比 icache 更容易膨胀——它更靠近路径解析入口产生的垃圾更快更多。6.2 mount 点上的 dentry 有什么特殊挂载点是一个比较特殊的目录它既是父文件系统里的一个目录项又是子文件系统访问的根。在 VFS 实现里挂载点 dentry 上会设置DCACHE_MOUNTED标志路径解析走到这里时要跨越到子文件系统。举个例子/mnt/data如果挂了一个独立分区那么访问/mnt/data/file时路径解析在/mnt/data这个 dentry 上会检查挂载关系如果发现d_mounted非空就会切到子文件系统的根 dentry 继续查找。这种挂载切换在嵌套挂载、shared mount 的场景下还会更复杂但理解dentry 上可能挂着一个 mount这件事是所有分析的基础。6.3 为什么说 rmdir、rename 是 dentry 操作的重灾区rmdir一个非空目录会失败这个大家都有经验。在内核层面非空判断实际上就是在检查该目录 dentry 的 hash 链表中是否还有子 dentry。所以如果你在一个巨大目录里频繁创建和删除文件dcache 的维护成本会非常高每次文件创建要新建 dentry 并插入哈希桶删除时要摘链rename 时还要改d_parent和d_name。有个常见的业务坑是大量并发创建和删除同一目录下的文件即使单个文件 IO 很小dentry 的哈希操作也会串行化到某一把锁上导致性能从一句话的事变成一卡十几秒。这种问题用内核参数很难彻底解决最好的办法是程序上分层、分目录减少单位目录的 dentry 增长速率。6.4 老生常谈的 D_path路径重建的代价最后一个容易被忽略的细节是d_path()。当内核需要从 dentry 反推完整路径时比如你打开了一个文件/proc/self/fd里要显示它它依赖d_parent链一路向上拼接。这个过程需要访问多级 dentry如果路径很长、层次很深开销也不小。如果你在自己的模块里调试、打印路径时发现耗时异常先想想是不是在深层目录下反复执行d_path。优化思路通常是尽量减少调用次数比如在 open 时缓存路径或者在适当场景下用最短前缀来代表完整路径。实际运行中我个人的体会是dentry 不是那种需要你天天去调优的东西但它出现的每个异常信号背后几乎都对应真实的业务模式问题。与其把 vfs_cache_pressure 调来调去不如多花点时间看清自己机器上的路径访问模式把不必要的 lookup 和 negative 缓存源头堵住效果往往更明显。最后分享一个小技巧你可以在应用编译或者启动时把那些反复访问的热点目录提前open一次让 dentry 先入缓存对冷启动期间的性能会有立竿见影的改善。这个办法我试过很多次简单但非常好用。