页缓存:为什么 free 少不一定是坏事

难度 ★★★★☆ · 前置:进程的地址空间:那 100 多 TB 地址是给谁的 | 一句话结论:页缓存是内核的,不是进程的,所以 free 少不等于内存不够看 available;write 只把数据放进缓存、脏页由内核在 30 秒内刷盘,fsync 才是强制落盘。

我清理了一次内存,然后出了三天事故

接手那台服务器是我入职第二年的事,交接的人只留下一句机器有点慢你看看,我登上去敲的第一条命令就是 free -h。

1
2
               total        used        free      shared  buff/cache   available
Mem: 15Gi 3.2Gi 412Mi 18Mi 12Gi 12Gi

free 只剩 412MB,按 Windows 的经验这已经是红灯了,我打开监控,发现这台机器跑了三个月,free 一直在下降,从 8GB 掉到 400MB,曲线平滑得像在慢性死亡。那种缓慢下滑的走势最唬人,因为你没法指着一个尖峰说这里有问题,只能眼看着它一天比一天低,还得说服自己也许这就是正常的,毕竟机器一直没报过警,业务也一直没出过事,我甚至开始怀疑是不是监控的采集间隔太长,把中间的抖动全抹平了。

于是我决定清理一下内存,心里还挺有底,因为这招在 Windows 的任务管理器里按过。

(下面这条就是我当年干的事,别在业务机上试,它会把这个系统攒了半年的热缓存全扔掉,所有服务立刻集体变慢。)

1
2
3
sync
echo 3 | sudo tee /proc/sys/vm/drop_caches
free -h
1
2
               total        used        free      shared  buff/cache   available
Mem: 15Gi 3.2Gi 12Gi 18Mi 312Mi 12Gi

free 一下子从 412MB 变成 12GB,我挺有成就感的。

(现在回头看那三天的工单,最想删掉的就是当时截的这张图,数字漂亮得很,什么也没说明。)

结果接下来三天,业务方开始抱怨你们的服务怎么突然变慢了,查询要两三秒,以前是几百毫秒。我一开始以为是发布带进来的问题,把前一天上线的提交全回滚了一遍,一点用都没有,又把慢查询日志翻了个底朝天,也没找出哪条 SQL 突然变慢,最后是自己写了个小脚本,把那个数据文件连着读了两遍。

1
time cat /var/lib/app/data.db > /dev/null

第一次 8 秒,第二次 0.2 秒。

我把缓存清了,缓存正在重新预热,那 12GB 的空闲不是空闲,是我亲手扔掉的加速器。这个因果关系我花了半天才想通,因为直觉里两种状态都叫内存变多了,可一种是内核把暂时用不上的页交回来,另一种是你把正在用的东西赶走,前者是回收,后者是破坏,长得像,后果差得远。

free 里那个数字从来不代表可用内存,真正的可用内存叫 available,它从头到尾都是 12GB,我那次操作什么都没优化,只制造了一次真实的性能下降。

缓存是内核的,不是进程的

页缓存(page cache)是内核给文件内容在内存里留的一份副本,以 4KB 页为粒度,它不属于任何一个进程,全系统只有一份,你读的和我读的是同一块内存,这一点是后面所有误解的根,你把 free 里那个数字当成自己的账本,就已经错在起点上了,因为那笔钱压根不在你名下。它的目标是让 read 尽量不要碰磁盘,一次 read 的路径是这样。

命中与未命中差 5 个数量级,这就是为什么第一次读慢、第二次快,也是为什么服务器重启之后头几分钟总是特别卡,不是因为重启本身慢,是因为缓存全空了,每一个操作都在等磁盘。

这也解释了 free 里那个 shared 为什么通常很小,它统计的是 tmpfs 和共享内存映射的量,不包括页缓存,虽然页缓存也是共享的,两者同名不同义,光看那一栏猜不出缓存有多少。RSS 里的 SHR 又不一样,它包含这个进程映射的页缓存中实际驻留的部分,所以把各进程 RSS 加起来会超过实际使用内存,同一份共享库被算了 N 次。这种账目上的重复我当初以为是自己统计脚本写错了,一个进程一个进程加了好几遍,最后才发现本来就应该这么算,SHR 在设计上就没打算让你横向求和。

老 Linux 有两个缓存

2.4 以前的 Linux 有俩,page cache 管文件内容,buffer cache 管块设备,两者要同步,非常乱。2.4 之后合并了,统一叫页缓存,free 里的 buff/cache 那一栏现在只是一个显示上的合并,buffers 是块设备的元数据,很小,通常几十 MB,cache 才是页缓存的大头,所以看到 buff/cache 很大,几乎全部是 cache。

读是顺带填的,写是只填不落地

read 命中的时候,内核做的是这三步。

  1. 在 address_space 的基数树(radix tree / xarray)里按页索引找;
  2. 找到就把这一页 copy_to_user;
  3. 找不到就分配一页、加进树、发起 IO、睡等。

注意第 3 步,缓存是读操作自己填的,没有任何后台进程在预读,除了下面要讲的 readahead。

这意味着三件事。

  • 从没读过的文件,第一次读一定慢;
  • 读一个 10GB 的文件,会把别人的缓存挤出去(这是 LRU 的工作);
  • 用 cat 遍历一遍大目录,会污染页缓存,挤掉热点数据。

write 的行为和 read 差别很大。

1
write(fd, buf, 4096);

这一步通常只写进页缓存就返回了,顺手把这页标记为脏页(dirty page),磁盘上还是老样子,所以 write 的返回值只保证数据进了内存,不保证它离开了这台机器。

真正落盘要等下面这几种情况之一。

触发方式 说明
回写线程 内核的 flush 线程,周期性扫脏页
脏页比例超阈值 dirty_ratio(20%)、dirty_background_ratio(10%)
脏页存活超时 dirty_expire_centisecs(3000 = 30 秒)
手动 fsync 明确的持久化要求
内存不够 回收时优先刷脏页而不是丢数据

这就是 write 成功不等于数据安全的完整解释,服务器断电那一刻,最多可能丢掉最近 30 秒写入的数据,30 秒听着不长,但一个每秒三千单的系统,那就是九万单,而绝大多数人写下 write 的时候脑子里想的是它已经落到磁盘上了。(我自己就在评审里放过一次这种代码,每写一行就 fsync,当时还觉得挺严谨,压测一跑吞吐掉了两个数量级,被指出来的时候我第一反应是不可能,因为那行 fsync 看着那么小那么无害。)

fsync 的代价

这六个调用的对照表在前面讲 read/write 那篇里已经列过,这里只说代价。fsync 是数据库性能问题的头号元凶,一次 fsync 在机械盘上要 5 到 10 毫秒,SSD 上 0.1 到 1 毫秒,而一个 memcpy 只要几十纳秒,这三个数不是一个量级的差别,是四五个数量级,所以你在代码里随手写一个 fsync,等于给这个函数挂了一个几毫秒的定时器,调用频率一上来,CPU 占用看着不高,QPS 就是上不去,你还得挨个函数去翻才找得着它。极端例子是 PostgreSQL,默认 synchronous_commit = on,每个事务至少一次 fsync,这就是为什么它的 QPS 上不去,有人为了性能把它关掉,文档允许这么做,代价是崩溃时丢掉最近一小段已经提交的事务,不是数据错乱,是以为写进去了其实没有。

预读:省了顺序读,坑了随机读

内核有个主动优化的地方,顺序读的时候它会多读一点。

1
cat bigfile

第一次读第 1 页时,内核看访问是连续的,就顺手把后面几十页一起读进来,后续的 read 全部命中,这个判断只看你前几次访问的地址是不是挨着,所以它猜得很粗糙,但便宜。代价是随机读的时候预读纯粹是浪费,读了一堆用不上的数据,还挤掉别的缓存,这就是为什么数据库这种随机 IO 常建议 blockdev --setra 0 关掉预读,或者用 O_DIRECT 绕开页缓存,而顺序扫描的场景,pg_dump、tar 这类,预读是巨大加速,同一台机器上跑着这两类负载的时候,你去调一个全局预读窗口怎么调都是错,只能让应用自己用 fadvise 分场景说话。

你自己也能给内核递话:madvise 和 fadvise

内核的推测再聪明也是猜的,如果你知道自己的访问模式,有两个接口可以直接告诉它,这是从进程侧影响页缓存的两个口子,一个按文件偏移量说话,一个按内存地址说话。

posix_fadvise(fd, offset, len, advice)(底层那个 fadvise64 2.5.60 就有了,glibc 的包装从 2.2 起)对文件的某一段给建议。

advice 意思 典型用法
POSIX_FADV_SEQUENTIAL 我会顺序读,预读可以更激进 tar、pg_dump
POSIX_FADV_RANDOM 我随机读,别预读了 数据库的表扫描
POSIX_FADV_WILLNEED 马上要用,提前读进来 启动时预热
POSIX_FADV_DONTNEED 用完了,可以丢缓存 一次性大文件扫完主动释放
POSIX_FADV_NOREUSE 只用一次,别留 日志归档

madvise(addr, len, advice)(Linux 2.4 起)对内存映射的一段地址给建议。

advice 意思
MADV_SEQUENTIAL 顺序访问,可以调大预读窗口
MADV_RANDOM 随机访问,关掉预读
MADV_WILLNEED 提前把页读进来(MAP_POPULATE 是建映射时一次做完)
MADV_DONTNEED 页不用了,还回去。匿名映射就是清零,文件映射下次访问还得重新读
MADV_FREE(4.5+) 页不用了,但没那么急,内存紧张时才真回收
MADV_HUGEPAGE / MADV_NOHUGEPAGE 这段要不要用透明大页

两个接口都不改语义,只影响内核的推测,所以它们永远不会让程序变错,最多是没效果,这条性质挺重要,意思是你可以放心地在线上加,加错了也只是白加,不像 O_DIRECT 那种一改就得整套对齐规则跟着改。我第一次给一个离线分析任务加 POSIX_FADV_DONTNEED 的时候,特意选在凌晨,还写了个回滚开关,结果它什么也没发生,因为那个任务读的文件本来就没在缓存里,我防的是一个不存在的风险,这事儿说出来有点丢人,但那晚我确实把文档读了四遍。

实际能省多少,POSIX_FADV_DONTNEED 是唯一能立刻看到效果的,一个进程 cat 完 10GB 文件之后主动调它,free 里的 buff/cache 会掉下去。MADV_DONTNEED 对匿名内存的效果是立刻释放,但页表还在,下次访问是缺页异常,反而更慢,所以别拿它当重置数组的便宜写法用,我当初真这么干过,在循环里每轮清一次想把内存还回去,跑完比不清还慢一半。

回收的顺序

当内存紧张需要腾地方,内核按这个顺序回收。

页缓存是可以随便丢的,因为文件还在磁盘上,这就是为什么 Linux 敢把空闲内存全拿去做缓存,它是借用,随时能还。匿名页是 malloc 出来的那批,不能随便丢,必须有 swap 才能换出去,没有 swap 的机器,匿名页只能靠 OOM 解决,所以一台没开 swap 的机器上,页缓存再多也救不了你。

两个比例和一个绝对值

1
sysctl vm.dirty_ratio vm.dirty_background_ratio vm.dirty_expire_centisecs
1
2
3
vm.dirty_ratio = 20
vm.dirty_background_ratio = 10
vm.dirty_expire_centisecs = 3000

dirty_background_ratio = 10 是脏页到 10% 时后台线程开始写,但应用不受影响,dirty_ratio = 20 是到 20% 时应用自己的 write 会被阻塞,必须等写完,dirty_expire_centisecs = 3000 是脏了超过 30 秒的页一定会被刷。这两个比例是延迟和吞吐的权衡,调大,单次 write 更少阻塞、吞吐更高,但断电丢的数据更多、突发回写更猛,IO 毛刺会很明显,调小,数据更安全、更平滑,但 write 更容易被卡住,我见过有团队为了压测数字好看把它调到 60,跑完忘了改回来。在内存大的机器上,百分比是不合适的,128GB 的机器上 20% 是 25GB,回写一次要写 25GB 才停,那是一个 IO 风暴,所以生产上更推荐按绝对值设。

1
2
3
# /etc/sysctl.d/99-vm.conf
vm.dirty_background_bytes = 268435456 # 256MB
vm.dirty_bytes = 1073741824 # 1GB

*_bytes 和 *_ratio 是互斥的,设一个会把另一个清零。

drop_caches 从来不解决任何问题

1
2
3
# 只在测试机上做,生产机不要碰
sync
echo 3 | sudo tee /proc/sys/vm/drop_caches

三个值,1 丢干净页,2 丢可回收的 slab,dentry 和 inode,3 两个都丢。它从来不解决任何内存问题,因为页缓存本来就会自动回收,它的合法用途只有一个,做性能测试时确保每次测的都是冷启动。

在日常运维里执行它的后果是下面这三条。

  1. 所有热点数据要重新从磁盘读一遍,业务延迟立刻上升;
  2. 回写线程被清空后,大量 IO 集中在接下来几秒,磁盘队列瞬间打满;
  3. 有 sync 才安全。不 sync 就 drop,脏页虽然不会被丢(内核会先写),但会造成一次集中的写风暴。

清了缓存之后 free 变多了这件事本身没有任何价值,因为那些内存马上就要被重新占满,而占满它的过程就是一次全量预热,账面上的空闲买不来任何性能。

只看 free 最右那一列

当前缓存量就摆在 free -m 里,只是那一列在最右边,最容易被跳过。

1
free -m
1
2
               total        used        free      shared  buff/cache   available
Mem: 15884 3289 412 18 12183 12195

判断内存够不够,只看 available,这个数字已经算了能立刻回收的缓存,free 那一栏小反而是好事,说明内存在干活。然后测一次冷读和热读的差别,用一个真实的大文件。

1
2
time cat /var/log/syslog.1 > /dev/null
time cat /var/log/syslog.1 > /dev/null
1
2
real    0m0.412s
real 0m0.003s

第二次快 100 倍,那 0.003 秒确实是 cat 的速度,只是它输出到了 /dev/null,数据从内存来,我第一次看到这个数字以为是测错了。

想确认页缓存到底吃了多少,直接看 /proc/meminfo。

1
cat /proc/meminfo | grep -E 'MemTotal|MemFree|MemAvailable|^Cached|Buffers|Dirty|Writeback'
1
2
3
4
5
6
7
MemTotal:       16265472 kB
MemFree: 422130 kB
MemAvailable: 12487484 kB
Buffers: 21488 kB
Cached: 12235108 kB
Dirty: 1240 kB
Writeback: 0 kB

Cached 就是页缓存主体,12GB,Dirty 是还没落盘的页,1.2MB,Writeback 是正在写盘的页,0。Dirty 持续很高,几百 MB 以上,说明回写跟不上写入速度,这是一个真实的性能信号,比 free 有用得多,因为它直接指向瓶颈在下游。

再验证一次 write 只进缓存,用两个 500MB 的空文件对比。

1
2
time dd if=/dev/zero of=/tmp/probe1.bin bs=1M count=500
time dd if=/dev/zero of=/tmp/probe2.bin bs=1M count=500 conv=fsync
1
2
real    0m0.221s        # 500MB 只用 0.22 秒,因为全进缓存
real 0m2.847s # 带 fsync,被迫等磁盘

12.9 倍的差距,全部来自要不要等磁盘。

(/tmp 如果是 tmpfs 就不成立,得换到真实磁盘上测,我第一次测就踩了这个,两次都是 0.2 秒,还以为自己发现了什么,兴冲冲去汇报,人家问了一句你那个目录在不在 tmpfs 上。)

接着观察回写对 Dirty 的影响。

1
2
3
dd if=/dev/zero of=/tmp/probe.bin bs=1M count=2048 &
for i in 1 2 3 4 5; do grep -E '^(Dirty|Writeback)' /proc/meminfo; sleep 1; done
wait; rm -f /tmp/probe1.bin /tmp/probe2.bin /tmp/probe.bin # 一口气清干净,别留 3GB 垃圾

你会看到 Dirty 先涨起来,然后 Writeback 也涨,最后两者都掉回 0。

最后看一眼预读参数。

1
blockdev --getra /dev/sda
1
256

单位是 512 字节扇区,256 等于 128KB,这是默认预读窗口,顺序读时内核一次多读这么多。改小对随机 IO 有利,先确认设备名,/dev/sda 在 NVMe 机器上根本不存在,别照着抄。

1
2
3
4
lsblk                      # 先看清你的盘叫 sda、vda 还是 nvme0n1
sudo blockdev --getra /dev/nvme0n1 # 先读一下当前值
sudo blockdev --setra 0 /dev/nvme0n1 # 改
sudo blockdev --setra 256 /dev/nvme0n1 # 改回去(默认值)

这会影响整个块设备,谨慎,改完记得复原。

最后,慎用这个命令,只在测试环境。

1
2
# 别在生产上跑
sync; echo 3 | sudo tee /proc/sys/vm/drop_caches

缓存不是内存,这两件事要分开

有人问 free 那一栏少是不是内存不够,看 available,free 少说明缓存在工作,这是好事,页缓存把没人要的空闲内存用起来了,压力一来它立刻还。

也有人说页缓存占的内存是浪费,可空闲内存不用也是不用,拿去做缓存是纯粹的收益,顺手还能把磁盘的延迟藏掉,唯一的代价是那一栏数字不好看。

write 返回成功不等于数据安全,它只进了页缓存,断电最多丢 30 秒(dirty_expire_centisecs),要持久化必须 fsync,而 fsync 一次在机械盘上就是 5 到 10 毫秒,它是最容易被忽视的性能瓶颈,尤其在每写一行 fsync 一次的代码里。

echo 3 > drop_caches 优化不了内存,只会造成性能下降,它唯一的正当用途是压测冷启动。这句话我在一篇讲 page cache 的文章里看到过,当时还不服,觉得清一下缓存怎么会变慢,后来在测试机上跑了一遍才知道它清掉的是全系统所有文件的缓存,清完那一刻所有服务都在同步回源,磁盘队列直接拉满,本来只是想看看内存能不能降下来,结果自己的服务先卡了半分钟。buff/cache 也不是 buffers,绝大部分是 cache,buffers 只有几十 MB,是块设备元数据,Cached 只有 12GB 也不代表还有 3GB 没用上,那 3GB 在 used 里,进程的匿名页、slab 等,页缓存只吃没人要的空闲内存。

cat 一个大文件不会把它永久缓存,LRU 会挤出去,cat 一个 10GB 文件之后,原本的热点缓存基本被冲干净了,这叫缓存污染,fadvise(POSIX_FADV_DONTNEED) 就是干这个用的。有人以为 direct IO 会比缓存快,恰恰相反,O_DIRECT 绕过了缓存,每次读写都真的碰磁盘,只在自建缓存的场景(数据库)才有意义。

这块内存是谁的

页缓存是内核给文件内容留的副本,以 4KB 页为单位,不属于任何进程,read 命中缓存是几十纳秒,未命中是几毫秒,差 5 个数量级,write 默认只写进页缓存,脏页由后台线程周期性回写,断电最多丢 30 秒,fsync 才保证落盘,它是数据库性能的头号瓶颈。

Linux 把空闲内存全拿去做缓存,有压力时按干净文件页、脏文件页、匿名页、OOM 的顺序回收,判断内存看 available,判断 IO 压力看 Dirty 和 Writeback,drop_caches 不是优化手段,是压测工具。

回收顺序走到底,最后一步叫 OOM Killer,它会挑一个进程杀掉,挑哪个不是随便挑的,而如果你在容器里,还有一层 cgroup 挡在前面,它的行为跟主机上的 OOM 完全不是一回事。