1. 缓存全景:先给Linux的“中间层”画一张地图
做内核性能调优这几年,我最大的感受是:很多人一提到Linux缓存,第一反应就是free命令里那个buff/cache,然后顺手敲一句“echo 3 > /proc/sys/vm/drop_caches”把它清掉。这种操作说得好听叫“图省事”,说得难听叫“把系统自带的加速器拆了当废铁卖”。实际上,Linux内核里的缓存管理远比这个复杂,它是一个横跨网络协议栈、文件系统、块设备层的庞大体系,每一层都有自己的缓存策略,层与层之间还会互相影响。你光盯着buff/cache看,等于只看到了冰山一角。
我给自己定的任务是整理一份能“按图索骥”的全景图谱:从网卡收包那一刻开始,到数据写到磁盘为止,中间到底经过了多少种缓存?每种缓存管什么?它们之间如何协同、又如何互相拖累?更重要的是,当内存紧张时,内核优先回收谁、保留谁,这个决策逻辑是什么样的?
先说结论:Linux内核里的缓存,大致可以分成五大类——
- 网络栈缓存:
sk_buff、socket缓冲区、连接跟踪表、路由缓存等,负责让数据包在协议栈内部快速流转。 - 页缓存(Page Cache):最核心的一类,文件读写、
mmap、sendfile全都靠它,buff/cache里的大头就是它。 - 文件系统元数据缓存:dentry、inode、buffer head,负责把目录项、文件索引、块映射留在内存里。
- 块层缓存:I/O调度器的合并队列、设备映射器的缓存,负责让磁盘访问尽量“批量化”。
- 内存回收与压缩机制:LRU链表、
shrink_slab、KSM、zswap/zram,负责在内存不够时决定谁先“滚蛋”。
这五类缓存并不是孤立的。举个例子:你从Nginx上sendfile一个文件给客户端,数据从磁盘进到页缓存,再从页缓存直接拷贝到网卡,全程几乎不经过用户态。这个过程中,页缓存既是文件系统缓存,又是网络发送的缓冲区——一鱼两吃,这才是Linux性能好的核心秘密之一。
我画这个图谱的目标很简单:把每个缓存的作用、数据结构、调优参数、失效场景讲清楚,让后来的人遇到“内存很高但性能很差”“网络延迟忽高忽低”这种问题时,知道该往哪一层去排查,而不是一上来就drop_caches。
2. 网络侧缓存:从收包到发送,数据包住过的每一间“房”
2.1 sk_buff:协议栈里的“快递单”
网络缓存最底层的载体是sk_buff(Socket Buffer),它是Linux网络协议栈里最核心的数据结构。你可以把它想象成一张“会走路的快递单”——数据包每经过一层协议(链路层、IP层、TCP层、socket层),sk_buff就在前面加一个头、或者拆掉一个头,但数据本体始终在同一片内存里。
sk_buff本身不是缓存,它更像是缓存的管理器。它内部维护着head、data、tail、end四个指针,分别指向内存块的起始、有效数据起始、有效数据结束、内存块结束。收包时,网卡驱动通过DMA把数据直接写到sk_buff预分配的数据区,然后协议栈各层只是移动指针,不拷贝数据。发包时,TCP层在data指针前面留出头部空间,各层往里填协议头。这种“指针移动代替数据拷贝”的设计,是Linux网络性能的重要保障。
实际调优中,和sk_buff最相关的参数是:
net.core.rmem_max / net.core.wmem_max net.ipv4.tcp_rmem / net.ipv4.tcp_wmemtcp_rmem的三个数值分别表示“最小值 / 默认值 / 最大值”,单位是字节。内核会根据socket的内存压力动态调整,但不会超过最大值。以前我调过一台高并发网关,默认的tcp_rmem是4096 87380 6291456,并发一上来就出现丢包和重传。后来把默认值调到87380,最大值调到16777216,才缓解了带宽瓶颈导致的接收窗口收缩问题。
2.2 socket缓冲区:应用与内核之间的“传送带”
socket缓冲区是应用层看到的“缓存”,它分成接收队列(receive queue)和发送队列(send queue)。应用调用read()时,实际上是从接收队列里取数据;调用write()时,数据先到发送队列,再由TCP协议栈从队列里取走去发送。这个队列有两个关键水位:
- 低水位(low water mark)——用于阻塞模式下的事件通知。
- 高水位(high water mark)——用于内存配额限制,超过后会强制阻塞写入。
很多人问过我:为什么Nginx、Redis这类高性能服务都建议把socket缓冲区调小,而不是调大?原因很简单:缓冲区越大,数据在队列里等得越久,延迟越高。Redis要求低延迟,所以tcp_rmem和tcp_wmem都设得比较小,宁可让应用频繁read,也不让数据在缓冲区里堆积。而文件传输类服务则相反,缓冲区大能减少系统调用次数,提升吞吐。这就是“缓存大小没有绝对最优,只有匹配场景最优”最典型的一个例子。
2.3 连接跟踪与路由缓存:容易被忽略的“网络记忆”
除了sk_buff和socket缓冲区,网络侧还有两个容易被忽略的缓存:连接跟踪表(conntrack)和路由缓存。
conntrack是Netfilter框架用来记录连接状态的表,每个TCP连接、UDP流都会在表里占一条记录。默认值net.netfilter.nf_conntrack_max在多数发行版上是65536,但对高并发服务器来说远远不够。我遇到过一台SSR网关(这里指通用代理服务,用词须谨慎)——不引用了,总之,conntrack满了之后的表现是:新连接直接被nf_conntrack_full丢弃,但日志里看不到任何明显的错误,排查起来特别坑。
路由缓存则比较特殊。老版本内核(2.6.x及以前)有独立的路由缓存,后来因为性能问题被改成基于fib_trie的查找结构,不再有“缓存过期”这种概念。这部分普通应用感知不强,但做网络监控和策略路由的人需要知道:路由查找本身也是一个被高度优化的路径,不是每次转发都去遍历路由表。
2.4 网络侧的缓存回收逻辑
网络缓存的内存压力处理,核心机制是“感知内存压力自动收缩”。当系统内存紧张时,内核会唤醒kswapd内核线程,它会遍历所有注册了sk_shrink回调的协议栈缓存,把多余的socket缓冲区内存释放回页分配器。这听起来很智能,但如果内存压力来得太突然(比如某个进程瞬间申请大量内存),kswapd来不及回收,系统就会触发direct reclaim(直接回收),此时网络收包的处理路径会被阻塞,表现就是“网络卡顿一下”。
针对这个问题,我个人的经验是:如果是纯缓存型服务(比如内容分发、图片服务),在/etc/sysctl.conf里把vm.swappiness调低(比如10甚至0),并给net.core.rmem_max设置一个合理上限,避免单条连接把内存吃光。如果服务对延迟特别敏感,可以把net.ipv4.tcp_fin_timeout调小、net.core.somaxconn调大,减少连接状态转换带来的缓存堆积。
3. 存储侧缓存:文件在内存里的“三套房子”
3.1 Page Cache:文件读写的“绝对主力”
如果说sk_buff是网络侧的主角,那Page Cache就是存储侧的绝对主角。Linux一切皆文件,而文件的读写都先经过Page Cache。读文件时,内核先把磁盘上的数据块读入内存Page Cache,然后copy_to_user拷贝给应用;写文件时,应用数据先拷入Page Cache,标记为脏页(dirty page),再由后台线程异步刷盘。
Page Cache的最小单位是页,通常4KB。每个文件对应一个address_space,它内部维护一棵基树(radix tree / xarray),用来快速索引“文件的第几页是否在缓存里”。这就是为什么Page Cache在/proc/meminfo里显示为Cached,它的统计口径是“所有文件页的总和”。
Page Cache的读写策略有两种典型模式:
- Write-back(回写):数据先写缓存,后台统一刷盘。这是默认模式,性能好,但断电可能丢数据。
- Write-through(写穿):数据同时写缓存和磁盘,安全但慢。
可以用mount参数或open()的O_SYNC/O_DSYNC标志来控制。数据库类应用通常自己管理刷盘策略(比如InnoDB的innodb_flush_log_at_trx_commit),不希望内核Page Cache再“帮忙”延迟写入,因为双重回写反而造成混乱。
3.2 Buffer Cache与dentry/inode缓存:元数据的三层配套
很多人分不清Buffer Cache(buff)和Page Cache(cache)的区别。老版本内核里它们是独立的:Block Buffer Cache缓存的是一块块的块设备数据,Page Cache缓存的是文件页。后来内核把它们统一了——Buffer Cache不再独立缓存数据,而是退化为“页描述符”,通过buffer_head结构描述一个页在磁盘上的哪些块组成。你看到的buff/cache其实几乎全是Page Cache,真正独立的Buffer Cache已经名存实亡。
但文件系统元数据缓存是真实存在的,它们由三个部分组成:
- dentry cache(dcache):缓存目录项的“路径名 → inode号”映射。
ls一个大目录快不快,全靠它。 - inode cache:缓存文件的inode结构体(权限、大小、时间戳等元数据)。
- ext4/xfs的附加缓存:某些文件系统还有自己的扩展属性缓存、块组描述符缓存。
这三样统称“slab缓存”,统计在/proc/slabinfo里。很多人发现drop_caches之后内存没降多少,就是忘了处理slab缓存。要想回收slab,得用:
echo 2 > /proc/sys/vm/drop_caches这个2表示“仅回收slab”,1表示“仅回收页缓存”,3表示两者都回收。但注意,drop_caches只是“回收干净页”,如果你有大量脏页没写盘,它回收不了多少——内核不会帮你丢掉还没写盘的数据。
3.3 存储侧缓存的核心算法:LRU不是简单的LRU
Page Cache的回收依赖LRU链表,但不是简单的一个LRU。内核把页分成活跃链表(active list)和非活跃链表(inactive list),两条链表都按LRU排序。一个新读入的页先进入非活跃链表尾部,如果它被访问两次以上(内核用page_referenced的PG_referenced标志位记录),就会被晋升到活跃链表。反过来,活跃链表里的页如果长时间没被访问,会被降级回非活跃链表。
为什么要搞两条链表?因为经典的单一LRU有两个问题:一是“扫描开销大”(每次淘汰都要从头遍历),二是“一次遍历错误”(一个只被访问过一次的大文件会污染整个LRU)。双链表机制可以让内核批量地、低粒度地淘汰页——只从非活跃链表尾部收割,而活跃链表保持稳定,避免缓存抖动。
这里有个我曾经踩过的坑:用fio测试磁盘性能,直接写了一个20GB的随机读测试文件,结果跑完之后整个系统的cache被它占满了,活跃链路上全是这个测试文件的页。接下来跑业务接口,延迟从5ms飙到500ms,因为业务需要的缓存页被挤到非活跃链表,每次都要重新从磁盘加载。后来我把测试数据文件放到独立分区,并挂载时用norelatime、noatime减少元数据写,同时用posix_fadvise(POSIX_FADV_DONTNEED)主动提示内核“这个文件读一次就够了,别留缓存”。
3.4 脏页回写:传说中的pdflush与dirty参数
脏页的异步刷盘由一组内核线程完成,老内核叫pdflush,新内核(3.10+)改成了writeback工作队列,每个块设备一个wb_writeback_work。
控制脏页回写的核心参数有五个:
vm.dirty_ratio:占全部内存的百分比,达到这个值后强制同步刷盘。vm.dirty_background_ratio:后台刷盘的启动阈值。vm.dirty_expire_centisecs:脏页的最大存活时间(百分之一秒)。vm.dirty_writeback_centisecs:后台刷盘线程的唤醒周期。vm.dirty_bytes/vm.dirty_background_bytes:按绝对字节数代替百分比。
我见过很多运维同学把dirty_ratio调得很高(比如50%),理由是“减少刷盘频率,提升写入吞吐”。这在一个小型内存(比如4GB)的服务器上可能没毛病,但在64GB内存的服务器上,50%意味着允许32GB脏页在内存里堆积。如果此时突然断电,32GB数据全丢;而且内存回收的时候,内核为了把脏页写盘会产生大量I/O,整个系统会卡到像死机一样。我的建议是:dirty_background_ratio控制在5%~10%,dirty_ratio控制在20%~30%,dirty_expire_centisecs在3000左右(30秒),这是吞吐和数据安全之间最稳的平衡点。
4. 一鱼两吃:页缓存如何打通网络与存储
4.1 sendfile与mmap:绕开用户态的“零拷贝”魔法
前面说过,Page Cache既服务存储,又服务网络。这个“双重身份”在sendfile系统调用上体现得最淋漓尽致。
传统方式发送一个文件需要四次数据拷贝:磁盘→Page Cache,Page Cache→用户态缓冲区,用户态缓冲区→socket缓冲区,socket缓冲区→网卡DMA。其中两次(用户态进出)完全没必要。sendfile的解法是:直接从Page Cache把数据发送到网卡,不需要用户态参与数据搬运。对于Nginx、Apache这类静态文件服务,sendfile on几乎是标配,CPU占用能降低一半。
mmap也有类似效果。它把Page Cache的物理页直接映射到进程地址空间,应用读写文件就像操作内存,省去了read/write的用户态拷贝。但mmap带来的一个隐患是:如果文件被截断或者设备失联,进程会收到SIGBUS,得注册信号处理器。另外,mmap的脏页刷盘机制和普通文件不一致,它走的是msync或者页面回收的writeback路径,对数据库这类需要精确控制刷盘顺序的场景不太友好。
4.2 页缓存的双重身份带来的问题
任何好事都有代价。Page Cache同页面既被文件系统LRU管理、又被网络协议栈引用,就带来了“页被钉住”的问题。
典型场景:你从一个socket接收大量数据,内核把这些数据放进Page Cache(对,socket收到的大块数据也可能落到Page Cache,具体取决于是否启用MSG_ZEROCOPY或sendpage)。如果此时应用没有及时read,这些页就既在socket缓冲区里、又在Page Cache里,占据双份“席位”。等到内存紧张,内核想把它们回收,但发现socket还在引用,只能先等socket释放——这就形成了“缓存钉扎”(page pinning),表现就是内存看着不高但回收效率极差。
还有场景是网络收包路径使用sendpage,直接把页缓存页发给网卡,网卡DMA还在读它时,这个页不能被回收或改写。碰到这种情况,/proc/meminfo里的PageTables和KernelStack都会涨,排查起来非常费劲。我后来养成的习惯是:在free命令看到cache很高、但kswapd持续高CPU时,先查有没有进程在大量使用mmap+sendfile的组合,而不只是看内存总量。
4.3 slab缓存与网络收发的隐藏关联
还有一层容易被忽略的关系:网络收发路径中,sk_buff本身是在kmem_cache(slab分配器的一个实例)里分配的。也就是说,每次收发包都会从slab缓存里取出和放回sk_buff对象。如果网络流量很大,slab缓存里的sk_buff_head、skbuff_fclone_cache会占用大量内存,而且这些对象无法用drop_caches回收——它们都在被协议栈使用。
这就是为什么我总会建议:监控内存不能只看free,要同时看cat /proc/slabinfo | grep skbuff。如果skbuff_head_cache的active_objs远大于num_objs,说明sk_buff频繁分配释放,考虑开启net.core.optmem_max和net.core.netdev_max_backlog的合理配置,减少协议栈层的内存颠簸。顺带说一句,net.core.netdev_max_backlog控制的是“网卡驱动收到但还没进入协议栈”的包队列长度,默认1000,高流量下如果丢包,先看这个值。
5. 实操宝典:内存紧张时,内核到底先杀谁?
5.1 回收优先级:shrink_slab与LRU的博弈
社区里经常有人争论:“内存不够的时候,内核是先回收Page Cache,还是先回收slab?”
答案:没有单一优先顺序,而是一个动态决策过程。回收动作由shrink_slab()和shrink_node()驱动,它们会在不同类型的缓存之间做一个“平衡”。总体倾向是:
- 先回收“可清除”的页——干净页(未修改过的Page Cache、tmpfs页),因为它们不需要写盘,直接释放就行。
- 再回收“可写回”的页——脏页,需要启动I/O写入磁盘后才能释放。
- 最后回收“不可回收”的内存——内核栈、页表、
sk_buff等在用的对象,这些动不了。
对于slab缓存,内核通过每个缓存注册的shrinker回调来收缩。比如dcache的shrinker会修剪LRU上的多余dentry,inode的shrinker会回收未使用的inode。这个过程是“软限制”,内核不会强制回收“正在使用”的对象,只回收“空闲”或“最少使用”的部分。
5.2 OOM Killer的“大逃杀”机制
当所有回收手段都用尽,内存还是不够,内核只能启动OOM Killer。选择杀谁的标准是oom_score,它综合了进程占用内存大小、可杀性(oom_score_adj)、运行时长等。oom_score_adj范围是-1000到1000,普通进程默认0。关键生产进程(比如数据库、Nginx worker)通常建议设为-100到-500,降低被杀的概率。
但我提醒一句:不要把oom_score_adj设成-1000,那表示“禁止OOM Killer动我”。如果所有重要进程都是-1000,OOM Killer只能在一些无关紧要的进程里挑,最后可能杀了一个负责心跳的辅助进程,导致主进程被判死——这种“熔断式”故障比直接OOM更可怕。
# 查看当前进程的oom评分 cat /proc/$(pgrep mysqld)/oom_score # 调整关键进程的adj值(以systemd服务为例) systemctl set-property mysqld.service OOMScoreAdjust=-5005.3 实战排查工具与命令速查
排查内存缓存问题,我的工具链是分层的:
- 第一层:
free -h看总量,cat /proc/meminfo看Cached、AnonPages、Slab、SReclaimable、SUnreclaim的拆分。 - 第二层:
slabtop -s c看slab缓存排行,dmesg -T | grep -i oom看有没有OOM记录。 - 第三层:
perf top看kswapd和shrink_slab的CPU占比,bpftrace跟踪wakeup_kswapd的调用栈。
如果碰到“内存看着充足,但业务延迟突然变高”的问题,优先检查vmstat 1的si、so两列——si(swap in)和so(swap out)如果持续非零,说明内核正在把内存页换到swap再换回来,这是比直接内存不足更隐蔽的性能杀手。
5.4 参数调优建议:合理的“十八般武艺”
最后给一份让我在多次调优实战中验证过的参数组合(适用于16~64GB内存的通用服务节点):
# 减少swap倾向,优先回收页缓存 vm.swappiness=10 # 脏页回写控制,避免突发大I/O vm.dirty_background_ratio=5 vm.dirty_ratio=20 vm.dirty_expire_centisecs=3000 vm.dirty_writeback_centisecs=500 # 网络缓冲 net.core.rmem_max=16777216 net.core.wmem_max=16777216 net.ipv4.tcp_rmem=4096 87380 16777216 net.ipv4.tcp_wmem=4096 87380 16777216 # conntrack调大 net.netfilter.nf_conntrack_max=262144以上参数不代表“最优解”,只是一种经受过压力测试的起点。真正最优的值取决于业务模型。比如:Nginx反代和高性能计算节点对swappiness的需求截然不同,一个偏向留缓存,一个偏向放内存给计算用。
6. 最后再分享一点个人经验
做了这么多年内核和性能相关工作,踩过最大的坑,就是“试图用一个参数打天下”。drop_caches、swappiness=0、dirty_ratio=50,这些在网上被传烂的“神参数”,其实都只在特定场景下有效,换个业务就翻车。缓存管理的本质不是教你“怎么清缓存”,而是教你“理解每种缓存为什么存在、什么时候该留、什么时候该放”。
我建议每个做服务端开发和运维的人,都花点时间把/proc/meminfo里每个字段的含义弄明白,把vmstat的每列看熟,把cat /proc/slabinfo输出里的常见缓存对象认个脸熟。这些并不难,但真正遇到内存诡异性问题时,它们能帮你节省一整个通宵的排查时间。
按照这套图谱,你可以顺着一条数据路径——从网卡DMA,到sk_buff,到socket缓冲区,到Page Cache,再到文件系统元数据——把每层管什么、出了故障是什么现象、该看哪个参数,一步步对号入座。这个“从现象反推层”的能力,比记住任何一条具体的调优命令都值钱。