磁盘不慢,为什么静态资源还是「第一次慢、后面快」
很多站长会发现一个现象:网站首页刷新几次之后就变快了,过一段时间没人访问又慢下来。原因不是磁盘坏了,而是 Linux 的页缓存(page cache)在起作用——文件第一次读要从磁盘加载,之后副本留在内存里,后续访问直接命中内存。问题是:当内存紧张、或者访问不规律时,页缓存会被其它进程挤掉,你的「热文件」就掉出了内存,下一次访问又得回磁盘。
对于静态资源多、图片和 CSS/JS 体积大的站点,这种「冷启动」延迟是真实存在的。这篇文章讲两件事:一是用 vmtouch 手动把关键文件「钉」进内存并观察命中情况;二是从内核层面调优文件预读(readahead)和 posix_fadvise,让系统主动把你要的文件读进缓存。
vmtouch:看看你的文件到底在不在内存里
vmtouch 是一个小工具,作用是查询和操作文件的页缓存状态。它不属于标准发行版,需要自己编译(依赖非常少,几十秒就能装完):
cd /tmp
git clone https://github.com/hoytech/vmtouch.git
cd vmtouch
make
make install # 装到 /usr/local/bin/vmtouch先看一个目录里有多少内容已经在内存里:
vmtouch -v /var/www/html/
# 输出类似:
# [OOOOOOOOOOOOOOO ] 268/1024 MB (26%)
# Files: 312
# Directories: 84
# Resident Pages: 68544/262144 268M/1G 26.2%
# Elapsed: 0.021 seconds那个进度条很直观:O 表示该文件已在内存,. 表示还在磁盘上。这个数字会告诉你:你的站点静态资源实际命中率是多少。
把热文件强制读进内存
vmtouch -t /var/www/html/index.html
vmtouch -t /var/www/html/assets/ # 递归 touch 整个目录-t(touch)会读取文件内容,把它们加载进页缓存。执行完之后再 vmtouch -v 看,进度条应该满了。这样做的意义是:在站点上线、或者每天凌晨低峰期,用脚本主动把静态资源预热进内存,避免高峰期第一个用户吃「冷启动」延迟。
配合 systemd timer 定时预热:
# /etc/systemd/system/cache-warm.service
[Unit]
Description=Warm page cache for static assets
[Service]
Type=oneshot
ExecStart=/usr/local/bin/vmtouch -t /var/www/html/assets/
ExecStart=/usr/local/bin/vmtouch -t /var/www/html/index.html# /etc/systemd/system/cache-warm.timer
[Unit]
Description=Daily cache warm
[Timer]
OnCalendar=*-*-* 04:30:00
Persistent=true
[Install]
WantedBy=timers.targetsystemctl enable --now cache-warm.timer 之后,每天凌晨 4:30 自动预热,用户无感维护。
锁定内存:防止热文件被挤出去
vmtouch 还能把页缓存「锁定」在内存里,防止内核在内存紧张时把它回收:
vmtouch -l /var/www/html/assets/critical.js
vmtouch -l -d /var/www/html/assets/ # -d 是 daemon,后台持续保持锁定-l(lock)会用 mlock() 系统调用锁住对应页;-d 让它守护化,反复确保这些页一直在内存中。这个功能很强,但要注意:锁定的内存是硬占用,不会被回收,也不会计入 swap。如果你锁了 2G 文件,机器只有 4G 内存,其它进程就可能被 OOM。所以只对真正关键的小体积文件用锁。
内核预读(readahead):让系统提前把文件读进来
Linux 有自动预读机制:读文件时,内核会猜测你接下来要读后面的内容,提前把磁盘数据读进缓存。默认预读窗口是 128KB,对顺序读大文件意义不大。可以按设备调大:
# 查看当前值(单位 512 字节扇区,256 = 128KB)
blockdev --getra /dev/sda
# 设为 8MB(16384 个 512B 扇区)
blockdev --setra 16384 /dev/sda对网站场景,静态文件较多时适度调大预读能提高吞吐,但也不是越大越好——预读窗口太大,遇到随机读会浪费带宽和缓存。SSD 上建议保守,HDD 或云盘可以更激进一些。可以用 /sys/block/sda/queue/read_ahead_kb 写入验证,并写进 udev 规则持久化。
posix_fadvise:程序级预读提示
如果你的服务是自己写的程序(PHP/C/Python),可以在打开文件后主动提示内核「我等会儿要读这个文件」。C 里的接口是 posix_fadvise:
int fd = open("/var/www/data/big.json", O_RDONLY);
posix_fadvise(fd, 0, 0, POSIX_FADV_WILLNEED); // 提示内核提前读入更狠的是 readahead() 系统调用,直接把指定区间的数据读进页缓存,同步等待完成后返回:
readahead(fd, 0, 10 * 1024 * 1024); // 读前 10MB 进缓存这两个接口的差别:posix_fadvise(WILLNEED) 是异步建议,内核尽可能读;readahead() 是阻塞的,返回时数据一定在缓存里。做「预热」时用后者更确定。
别让缓存被「干净页」和 swap 拖累
页缓存被回收,往往是因为系统内存被别的东西占满。几个值得关注的参数:
# 交换倾向,越小越不倾向 swap(数据库服务器常设 1)
sysctl vm.swappiness
# 脏页比例,写太多文件时页缓存被脏数据挤压
sysctl vm.dirty_ratio
sysctl vm.dirty_background_ratio如果机器内存够,把 vm.swappiness 降到 10 以下,能减少热页被换出的概率。如果写操作多(比如频繁生成静态文件、日志),适当降低 vm.dirty_background_ratio,让内核更早地回写脏页,避免页缓存被脏页长时间占用。
还有一个容易忽略的点:/proc/sys/vm/vfs_cache_pressure 控制内核回收 dentry/inode 缓存的倾向。网站文件多、目录深的时候,这个缓存被回收会导致大量 stat 系统调用。适当调低(比如 50)能让元数据缓存留得更久,配合 Nginx 的 open_file_cache 效果很好。
Nginx 侧的配合:open_file_cache
页缓存解决的是「文件内容」在不在内存,open_file_cache 解决的是「文件句柄和 stat 结果」缓存。两者配合,静态资源才能真正快起来:
open_file_cache max=10000 inactive=60s;
open_file_cache_valid 80s;
open_file_cache_min_uses 2;
open_file_cache_errors on;这段配置让 Nginx 把最近打开过的文件描述符和 stat 结果缓存起来,命中时连 open() 和 stat() 系统调用都省了。它在进程内存里,和内核页缓存是两层不同的缓存,叠加使用收益明显。
vmtouch 的另外两个实用玩法
除了查看和预热,vmtouch 还有两个偏运维的用法值得知道。第一个是 -e(evict,逐出):直接把指定文件从页缓存里踢出去,模拟「冷启动」场景,用来测试你的程序在缓存未命中时的表现是否符合预期:
vmtouch -e /var/www/html/assets/app.js
# 然后再 vmtouch -v 看,应该变成空的
vmtouch -v /var/www/html/assets/app.js第二个是配合 -p(page granularity)只预读文件的一部分。对于几 G 的大文件(比如视频、日志归档),没必要整份读进内存,只把开头命中率高的部分预读即可。日常用得最多的是 -t 和 -v,但知道 -e 的存在,在排查「为什么测试环境和线上表现不一致」时很有用。
页缓存的「淘汰」是怎么发生的
理解页缓存被谁挤掉,才能对症下药。当系统内存紧张时,内核有几种回收策略:一是回收「干净页」(clean page,已被写回磁盘或本来就是只读的),这类回收成本最低,直接丢弃即可;二是回收「脏页」(dirty page)前必须先写回磁盘,成本较高;三是把匿名页(进程堆栈等)换出到 swap。页缓存大多是干净页,所以内存一紧张,最先被牺牲的往往就是你的热文件。
这就解释了两个现象:第一,内存不足时静态资源命中率会骤降;第二,同一台机器上跑数据库(数据库缓存占内存多)的站点,页缓存更容易被挤掉。理解了这一点,就知道该往哪个方向优化——要么加内存,要么减少其它进程对内存的争夺,要么用 mlock 把绝不能丢的文件锁住。
怎么判断是不是「页缓存没命中」的问题
怀疑响应慢是因为缺缓存时,别瞎猜,用数据说话。第一步看整体命中率:
# 查看页缓存总量
free -h
# buff/cache 这一列就是页缓存 + 缓冲区
# 详细统计(需要 sysstat)
sar -r 1 5第二步针对具体文件。如果你能定位到慢的是某个文件(比如某张图片、某个 JS),直接 vmtouch -v 看它在不在缓存里——不在,那大概率就是它拖慢的。第三步看磁盘 IO:如果响应慢的时候 iostat -x 1 的 %util 很高、await 很大,说明确实在读盘,页缓存没兜住;如果 %util 很低但响应还是慢,那问题不在存储层,得往 CPU、网络或后端程序那边找。
和 Redis / Memcached 的分工边界
有人会问:既然有 Redis、Memcached 做缓存,页缓存还有必要吗?两者是不同层次的东西,不能互相替代。页缓存是内核层,缓存的是「文件内容」,对应用程序完全透明——你的 Nginx 读一个静态文件,根本不需要知道它在不在内存,内核自动处理。Redis/Memcached 是应用层,缓存的是「计算结果」或「序列化的对象」,需要程序显式去查、去写。
合理的分工是:静态文件、图片、模板这类「本来就是文件」的东西,靠页缓存;数据库查询结果、接口响应、会话数据这类「计算结果」,靠 Redis/Memcached。两者各司其职,硬要拿 Redis 去缓存静态文件反而是绕远路。这也是为什么本文聚焦 vmtouch 和内核调优——它解决的是应用层缓存够不到的那部分。
实测:预热前后的差异
在一台 2C4G 云服务器上,静态目录 400MB,用 ab 打首页:
- 未预热(页缓存冷):平均响应 42ms,P99 210ms
- vmtouch 预热后:平均响应 6ms,P99 18ms
- 预热 + open_file_cache:平均响应 4.5ms,P99 12ms
差距主要来自磁盘 IO 消失。注意:如果用了 CDN,源站这台机器的收益对终端用户影响会变小,但对回源请求、动态页面的模板文件读取依然有效。
注意事项
- 内存是有限的:页缓存再有用也不能超过物理内存。如果站点静态资源几百 G,别想着全塞进去,只预热「最热」的那部分(首页引用的资源、常用图片)。
- mlock 有上限:非 root 进程能锁的内存受
ulimit -l限制,默认可能只有 64KB,需要调大。 - 别锁数据库文件:数据库有自己的缓冲池(InnoDB buffer pool),再用 vmtouch 锁一份数据文件是重复占内存,还可能干扰数据库的判断。
- 重启即失效:页缓存和页锁在重启后全部清零,靠 systemd timer 在低峰期重新预热是最稳的做法。
- 用 free -h 看 cached 列:Linux 的 buff/cache 是「可用内存」的一部分,看到 cached 很大不用担心,那是正常的缓存行为,不是内存泄漏。
结语
站点的「第一次慢、后面快」,本质是页缓存冷热的问题。vmtouch 让我们能观测、预热、甚至锁定页缓存;内核的 readahead 和 posix_fadvise 让程序能主动提示内核提前加载;Nginx 的 open_file_cache 再把元数据缓存加一层。这几招组合起来,不需要换硬件、不需要上 CDN,就能把静态资源的响应时间砍掉一大半。关键是要「按热度取舍」——把有限的内存花在真正被频繁访问的文件上,而不是盲目地全盘预热。