协会地址:上海市长宁区古北路620号图书馆楼309-313室
AI 抓崩了我的网站,AI 又救活了我的网站
14 个月里 AI 爬虫抓了 36 万次、其中 5.5 万次砸在最慢的页面上,把一台 3.6G 的 WordPress 拖进 OOM;3.5 小时后,另一个 AI 把它捞了回来
关键词:OOM、AI 爬虫、php-fpm、fastcgi_cache、Redis 对象缓存、AI Agent 运维 | 约 12 分钟
背景:一家没有 IT 部门的协会网站
先把场子交代清楚——这是一个没有专职 IT 部门的协会网站,后来出了一次典型的 WordPress OOM。
shanghaiopen.org.cn 是上海开源信息技术协会的官网。协会成立于 2020 年 3 月,是上海市民政 3A 级社团组织,由企业、高校、科研院所、社会组织和专业人员自愿组成,指导单位是上海市科学技术协会。网站不大,活儿不少:新闻中心、活动日历(开发者活动、读书会)、开源博客、科普教育、会员服务,还有中英双语。协会的品牌活动从开源产业生态大会、开源技术沙龙一路排到油条咖啡读书会,线上得跟着更新;再加上 3,500 多篇文章、二十来个活动,全部跑在一套 WordPress 上。
最关键的一点是:我们是一家规模不大的协会,没有专职 IT 人员。 网站的日常维护,只能由协会同事在业务工作之外兼顾。手上有什么?一台 3.6G 内存的云服务器、一个宝塔面板、一个每 6 小时跑一次的巡检脚本,就是全部家当。
所以那天晚上的问题,不是「一个技术团队怎么把它修好」,而是「一个没有运维编制的机构,怎么在半夜把一台正在 OOM 的服务器救回来」。
有点黑色幽默的是:一个把「开放、共享、协作」写进章程、天天推动开源的组织,被 AI 爬虫按字面意思理解了一遍。这就是 2026 年的互联网——你的内容既是写给读者看的,也是喂给模型吃的。
0. 一个有点荒诞的闭环
先说结论:把我这台 WordPress 慢慢拖进 OOM 的,是爬虫,而且 AI 抓取占了大头;把它救回来的,是另一个 AI。
这不是玩梗。翻完 14 个月的访问日志我才知道,AI 抓取类爬虫——GPTBot、ClaudeBot、meta-externalagent、Bytespider 这些——在我这个站点上打了 365,277 次请求,其中 55,236 次砸在单次渲染超过 30 秒的事件页上。而我这台 3.6G 内存的机器,从头到尾只有一行没人复查过的 pm.max_children = 80 在兜底。
坑是我自己挖的,铲子是他们递过来的。
20:15,我在飞书里问了一句:「你上次检查站点能不能访问,是什么时候?」
我以为这是句例行确认,结果它甩回一份报表。
站点从 10 月 8 日接入自动检查以来,一共跑了 69 次,其中 2 次是真实故障:
| 时间 | 监控看到的现象 | 真相(后来才拼出来) |
|---|---|---|
| 10-19 12:00 | fetch failed,HTTP 0,站点无响应 | 与 OOM 时间窗吻合(推断) |
| 10-20 06:00 | 502 Bad Gateway(150 bytes) | 与 OOM 时间窗吻合(推断) |
这两次在我这儿都只是「服务器又抽风了,重启一下就好」。直到那天晚上我才知道,它们是同一个病灶的两次发作。
更糟的是检查间隔是 6 小时。也就是说,最坏情况下站点可以挂着 6 小时没人知道。我当场提了两个要求:间隔改成 30 分钟,以及——「重启会清掉现场,证据要从日志里捞」。
我一直觉得,这句话是整个晚上的转折点。
1. 一段插曲:AI 先宣布「我误报了」,然后被日志推翻
那晚 20:15 到 20:23 之间,还有一段当时谁都没看出来的情节。
Agent 连发几轮诊断,结论很吓人:「站点现在访问不了」——两次 curl 都是 20 秒超时。它接着查到 443 端口连不上、而服务器内部 nginx 一切正常,先怀疑是网络层拦截,又怀疑是自己打得太狠触发了限流。
然后它开始自我否定。冷却 90 秒后重测,3 次请求全部 200、稳定 1.15s。于是它向我公开纠正:
「站点是健康的,问题出在我的测试方式上。」
这句话很诚实,但它把一个真实的故障归类成了测试噪音。
因为就在它宣布「站点健康」的几分钟前,20:17:45 内核 OOM killer 已经动手了,20:17:46 MariaDB 被杀(478MB RSS);20:18:47 我手动重启了服务器,20:19–20:20 又疑似重启了一次。20:15–20:18 这个窗口里,站点是真的处在从严重劣化到宕机的区间里,不是「只是测试方式的问题」。
坦白说,会话记录里这两段结论是自相矛盾的:20:23 它说「站点是健康的、是我误报」,20:30 的取证报告又写「20:15–20:18 站点严重劣化」。能确定的是,它当时的自我纠错方向错了——而纠错本身没有错,人也会这么干:当你的测量结果和预期不符,第一反应总是怀疑测量。
教训一:没有日志支撑的「自我否定」,和没有日志支撑的「自我肯定」一样不可靠。 还有一条副产品:它那 300 条访问日志里有 28 条来自自己那台机器——诊断手一重,日志就不可读了。
2. 取证:重启会清掉现场,但日志不会
我给了 Agent 一份「人类先验清单」,因为我踩过太多这个坑:
journalctl -b -1 -k | grep -iE 'oom|killed|hung'
journalctl -b -1 -u nginx -u 'php*-fpm' -u mariadb --no-pager | tail -200
sar -f /var/log/sysstat/sa$(date +%d) 2>/dev/null | tail -30
外加「按 WordPress + MariaDB 经验、命中率从高到低」的 5 个怀疑方向:PHP-FPM 打满、MariaDB 慢查询、被刷、wp-cron 风暴、磁盘/inode 满。
它先按我的要求把检查间隔改成 30 分钟,然后才上服务器。取证时它没有照抄命令,而是先确认清单本身对不对——结果发现这台机器最近三次启动是:
| 启动 | 时间范围 | 说明 |
|---|---|---|
| -2 | 10-20 07:13 → 10-24 20:18:47 | 出问题的这次启动 ← 真正的现场 |
| -1 | 10-24 20:19:04 → 20:20:09 | 只活了 1 分钟(疑似再次重启) |
| 0 | 10-24 20:20:39 → 现在 | 当前 |
我给的是 -b -1,但它对应的恰恰是那个只活了一分钟的空壳启动。真正的现场在 -b -2。 journald 是持久化的,所以重启没让证据蒸发。
20:17:45 到底发生了什么
Oct 24 20:17:45 kernel: php-fpm invoked oom-killer
Oct 24 20:17:46 kernel: global_oom, task_memcg=/system.slice/mysqld.service
Oct 24 20:17:46 kernel: Out of memory: Killed process 1823 (mariadbd)
根因:一台 3.6G 的机器,被配成了 23G 的野心
# /www/server/php/83/etc/php-fpm.conf
pm.max_children = 80 # 3.6G 内存的机器配 80 个 worker
pm.max_spare_servers = 20 # 长期常驻 20 个空闲 worker
request_terminate_timeout = 100
实测一个 php-fpm worker 的 RSS 约 290MB。也就是说 80 × 290MB ≈ 23GB 的潜在内存需求,而这台机器总共 3.6GB 内存、swap = 0,连一点回旋余地都没有。
为什么每次死的都是数据库? 因为 OOM killer 挑内存占用最大的进程下手。MariaDB 约 478MB,比单个 PHP worker 的 290MB 更大,于是它永远第一个被选中。数据库一死,整站立刻瘫痪——不是「PHP 挂了」,是「PHP 把数据库一起拖下水」。
而且这是老毛病,不是偶发:
| 启动区间 | OOM 击杀次数 | 被杀进程 |
|---|---|---|
| boot -3(10-08 → 10-20) | 7 次 | 6× mariadbd、1× php-fpm |
| boot -2(10-20 → 10-24) | 1 次 | mariadbd |
排除法:先证明「不是什么」
一份可信的故障报告,得同时交代排除了什么。它把其中能排除的项逐条用数据否掉:
| 怀疑项 | 实测 | 结论 |
|---|---|---|
| 被刷/被攻击 | 事故时段每分钟仅 4–5 个请求;wp-login.php 0 次、xmlrpc.php 0 次、wp-cron 3 次 | 不是攻击 |
wp_options autoload 膨胀 | 696 行,autoload 数据 0.00MB | 健康 |
| wp-cron 堆积 | cron 序列化数据 0.01MB | 健康 |
| The Events Calendar 数据膨胀 | 仅 20 个事件 | 不是数据量问题 |
| 磁盘 / inode 满 | 25G 可用、inode 使用 2% | 健康 |
结论很干净:不是外部压力,也不是数据膨胀,是配置超配。 这个结论最大的价值,是把一个「玄学抽风」变成了「改一行配置」的问题。
3. 止血:WordPress OOM 之后,把「运气」换成「配置」
它给了按性价比排序的方案,我只批了两项 P0。
第一步:php-fpm 收敛
| 参数 | 修改前 | 修改后 |
|---|---|---|
pm.max_children | 80 | 12 |
pm.start_servers | 5 | 3 |
pm.min_spare_servers | 5 | 2 |
pm.max_spare_servers | 20 | 6 |
pm.max_requests | (无) | 500 |
这里有个容易忽略的隐形浪费:max_spare_servers = 20 意味着长期维持 20 个空闲 worker。它们不干活、只占内存——在 3.6G 的机器上,这是持续失血。
改动的流程克制得值得抄:备份 php-fpm.conf.bak.20261024_204409 → php-fpm -t 校验(test is successful)→ SIGUSR2 平滑重载(不是 restart)→ 立刻验证站点和 worker 数。重载瞬间 worker 从 21 降到 4,随后按 start_servers/min_spare 拉起,稳态 7 个;站点 200。
第二步:让 MariaDB 在 OOM 面前「免疫」
原本最理想的写法是 systemd drop-in:
[Service]
OOMScoreAdjust=-900
但它先摸了服务单元,发现这里是宝塔的 LSB 脚本(MainPID=0,systemd 根本不跟踪进程),drop-in 写了也不生效。于是换了个更脏但可靠的办法:一个幂等的校正脚本 /usr/local/bin/ensure-mariadb-oom-protect.sh,配上 /etc/cron.d/oom-protect,每分钟执行一次,开机后 45 秒再执行一次,日志可以通过 journalctl -t oom-protect 追溯。
生效后最关键的这组数据(oom_score_adj 是我们给进程设的权重,oom_score 是内核算出的最终得分,越低越安全):
| 进程 | oom_score_adj | oom_score |
|---|---|---|
| mariadbd | -900 | 109 |
| php-fpm master | 0 | 668 |
内核现在会明确选 php-fpm,而不是数据库。杀 worker 只影响单个请求,杀数据库是整站宕机——用一个「谁先死」的排序,换了「死得多惨」的结果。
两项 P0 实测约 3 分钟全部落地(它自己预估是 1 分钟)。验收时:worker 21 → 7,内存占用 1461MB → 1295MB,可用内存 2194MB → 2360MB,listen queue 0,站点 6/6 全部 200、1.06–1.22s。
还有一个坑提前记了下来:宝塔面板里改 PHP 设置,可能重新生成 php-fpm.conf 并覆盖 max_children。以后动过面板就得复查这一项。
4. 加固:加缓冲垫,并让「慢」变得可见
4.1 2GB swap
零 swap 是这次事故的放大器——内存一紧就直接进 OOM,没有任何缓冲。加 2GB swapfile、写进 /etc/fstab(备份 fstab.bak.20261024_204816)、vm.swappiness = 10(数据库服务器不宜频繁换页)。
4.2 超时与慢日志
request_terminate_timeout 100s → 30s,request_slowlog_timeout 30s → 10s。后半项的作用是把记录门槛从「只记 >30 秒」降到「10 秒以上都记」——10 到 30 秒这一档的慢请求因此也能被看见。
4.3 一次「我说错了」的纠正
第一轮排查时,Agent 曾断言:慢日志配的是相对路径、文件实际不存在,所以慢请求从来没被记录过——它把这个当成「过去排查困难的元凶」写进了报告。
第二轮去修这个「问题」时,它发现:慢日志一直在正常工作。 相对路径是按 php-fpm 的前缀解析的,文件就在 /www/server/php/83/var/log/slow.log,已经 83.9MB(同刻 php-fpm.log 44.5MB;轮转报告里这两个数记的是 80MB 和 42.5MB——两组数来自同一次会话的不同记录时刻)。只是它第一次查错了位置。
而这份真实的慢日志,直接给出了 OOM 的上游:
filter_hijack_page_content → get_view_html → render
→ v2/list.php:83 → ical-link.php:56 → subscribe-links/list.php
单次耗时 > 30 秒
慢请求几乎全是 The Events Calendar 的事件列表页(/events/... 的 list view 加 ical/subscribe 链接渲染)。同时慢日志的回溯里高频出现 wp-includes/option.php(79,960 次)、class-wp-hook.php(77,619 次)、class-wpdb.php(71,382 次)——这是「缺少持久化对象缓存」的典型指纹。
于是完整的因果链浮出水面:
TEC 事件页极慢(>30s)→ worker 被长期占用 → 并发堆积 → 内存耗尽
→ OOM 杀 MariaDB → 整站宕机
4.4 php-fpm 的两份日志,从没轮转过
php-fpm.log 42.5MB、slow.log 80MB,从上线起就没轮转过。新建 /etc/logrotate.d/php-fpm-bt:超 50MB 轮转、保留 4 份压缩、用 copytruncate 不重启服务。
小配置,大作用——你没法在 80MB 的慢日志里做分析,就像没法在 1.8G 的访问日志里 grep。这句话在下一节会应验。
5. 减负:766 万条日志里的真相
到这一步服务器已经不会随便死了,但 /events/ 依然 >30 秒,worker 依然被长请求占着。Agent 提了两件事:给宝塔的 wwwlogs 加轮转,以及做一次全站爬虫流量分析。
数据规模:/www/wwwlogs/wordpress.local.log,1.8GB、766 万条请求、跨度 2025-08-07 → 2026-10-24,一次都没轮转过。分析方式很朴素——写一个单遍扫描脚本传上去跑,几分钟读完 1.8GB,按 UA 归类。
5.1 爬虫格局
| 类别 | 请求数 | 占比 | 流量 |
|---|---|---|---|
| 非爬虫(真人等) | 6,351,546 | 83.1% | 76 GB(折合每月约 5–8GB) |
| 搜索引擎(有流量价值) | 609,728 | 8.0% | 8.3 GB |
| AI 抓取(零访客) | 365,277 | 4.8% | 6.9 GB |
| 纯 SEO 采集(零访客) | 204,377 | 2.7% | 4.8 GB |
| 脚本/未知 | 112,324 | 1.5% | 0.9 GB |
无价值爬虫合计 569,654 次(占全部爬虫的 44%)、11.7GB。
而最关键的一条发现是:
AI 爬虫正在系统性地抓最慢的页面。
/events/列表/20,845 次 +reading-club/列表/18,674 次 +developer-events/列表/15,717 次 = 55,236 次请求,全部砸在单次渲染 >30 秒的页面上。
这 5.5 万次就是 php-fpm worker 被榨干、诱发 OOM 的直接推手。爬虫不一定在攻击你,但它可能正在用你最贵的页面,自杀式地消耗你的机器。
看到这组数字,我才意识到这件事有多荒诞:把我服务器压死的,是 AI 的「饥饿」;而帮我把它救回来的,是我自己跑在另一个模型上的 Agent。 抓我的是 GPTBot、ClaudeBot、meta-externalagent(分别来自 OpenAI、Anthropic、Meta),修我的是一个跑在 DeepSeek 上的 Agent——它们甚至不是同一家公司的产品,但在这个故事里,它们是同一种力量的两端。
5.2 插播:PetalBot 是什么
我中途问了一句「PetalBot 是什么东西」,回答顺便揭了一个更大的口子。它是华为「花瓣搜索」的爬虫,旧名 AspiegelBot,IP 段属于华为云(AS136907),遵守 robots.txt,所以规则对它有效。鉴别真假的方法很实用:把 IP 反解,114.119.151.185 → petalbot-114-119-151-185.petalsearch.com 就是真身,伪造 UA 的爬虫反解不出这个域名。
它的数据是:全站累计 92,583 次请求,其中 34% 砸在 3 个最慢的 /events/ 视图上,还伴随 2,713 次 499——客户端等不及 30 秒渲染跑完自己断开。这个 499 就是「页面有多慢」的铁证。
所以对它的处置不是「封」,而是「只封最贵的部分」。
5.3 三层拦截:按「价值 / 成本」分层,而不是按「爬虫 / 人类」
最终策略是我改了它第一版方案后定下的:
| 层级 | 对象 | 范围 | 理由 |
|---|---|---|---|
| 1 | AhrefsBot、SemrushBot、MJ12bot、DotBot、DataForSeoBot、BLEXBot、serpstatbot、Barkrowler、SeznamBot、MauiBot | 全站 403 | 纯 SEO 采集,零访客、零回报 |
| 2 | GPTBot、ClaudeBot、meta-externalagent、Amazonbot、CCBot、PerplexityBot、Bytespider | 仅 /events* 403 | 不给流量,但可能带来 AI 搜索引用,保住别的页面 |
| 3 | PetalBot、Sogou web spider | 仅 /events* 403 | 有真实搜索流量价值,只砍慢视图 |
我必须说明:当时的 nginx 规则正文没有保留在会话记录里,只留下了段名 #BOT-BLOCK-START、UA 名单和配置备份名。下面是我事后整理的等价策略示意,用 map 表达(比在 if 里拼变量可靠得多),照抄前请先 nginx -t:
# http 块
map $http_user_agent $bot_tier {
default "";
~*ahrefsbot|semrushbot|mj12bot|dotbot|dataforseobot|blexbot|serpstatbot|barkrowler|seznambot|mauibot "seo";
~*gptbot|claudebot|meta-externalagent|amazonbot|ccbot|perplexitybot|bytespider "ai";
"~*petalbot|sogou web spider" "search";
}
map $request_uri $is_events { default 0; ~*^/events 1; }
map "$bot_tier:$is_events" $bot_deny {
default 0;
"seo:0" 1; "seo:1" 1; # 第 1 层:纯 SEO 采集器,全站拒绝
"ai:1" 1; # 第 2 层:AI 爬虫,只拒绝慢视图
"search:1" 1; # 第 3 层:搜索爬虫,只拒绝慢视图
}
# server 块
if ($bot_deny) { return 403; }
这段我用 nginx 1.28 在本地跑过一遍:nginx -t 通过;AhrefsBot 访问首页和 /events/ 都是 403,GPTBot 首页 200、/events/ 403,Googlebot 和普通用户访问 /events/ 都是 200——和上面那张策略表的每一层都对得上。
另外还加了物理 robots.txt(会覆盖 Yoast 的虚拟版本,这是副作用,得记住):对 * 禁止带过滤参数的重复 URL(?tribe-bar-date=、?ical=、?outlook-ical=),对激进爬虫禁止 /events/,保留 Sitemap 声明。
验证矩阵是我最看重的一步——改动必须证明「该拦的拦了、不该拦的没拦」。纯 SEO 采集器 8/8 全部 403;AI 爬虫和 PetalBot 首页 200、/events/ 403;Google、Bing、Baidu、Apple、Yandex 5/5 全部 200,零误伤;正常用户全部 200。该拦的一个没漏,不该拦的一个没伤。
但真正让我放心的不是 curl,是日志里当场抓到的这一条:
114.119.151.185 "GET /events/类别/developer-events/日/2025-10-24/" 403
UA: ...PetalBot;+https://webmaster.petalsearch.com/...
规则不是「配上了」,是在真实流量上生效了。
5.4 宝塔的 wwwlogs 也没轮转过(回收 1.9GB)
这跟 4.4 是同一类问题,只是发生在另一套日志上。新增 /etc/logrotate.d/bt-wwwlogs(每日、留 14 份、压缩、kill -USR1 让 nginx 平滑重开日志):
| 文件 | 轮转前 | 轮转后 |
|---|---|---|
wordpress.local.log | 1.8G | 176M(gzip 归档) |
wordpress.local.error.log | 235M | 4.0M |
/www/wwwlogs 合计 | 2.1G | 184M(回收 1.9G) |
至此,这台机器的「慢」被拆成了两半:一半是爬虫在磨最贵的页面,一半是日志大到根本没法查。
6. Redis 对象缓存:82% 命中率,只换来 12% 提速
6.1 两层缓存的本质区别
| 层 | 缓存的什么 | 省掉了什么 | 命中后耗时 |
|---|---|---|---|
| Redis 对象缓存 | 数据库查询结果(wp_options、post meta…) | PHP 内部的 DB 往返 | 仍要跑 PHP,但快 3–5 倍 |
| nginx fastcgi_cache | 最终 HTML 页面 | 整个 PHP 执行 | 10–30ms |
一句话:/events/ 那 30 秒的成本,只有 fastcgi_cache 能挡住(连 PHP 都不进);Redis 只是让 30 秒变成 20 秒。
6.2 落地
Redis 7.2.15,只监听 127.0.0.1,maxmemory 256mb + allkeys-lru——主动给自己设内存上限,保证它不会反过来参与 OOM;关闭持久化(纯缓存用途);phpredis 6.1.0 源码编译;WordPress 侧装 redis-cache 插件并生成 object-cache.php drop-in。
踩到的坑:宝塔的 FPM 和 CLI 用两份不同的 php.ini(php.ini vs php-cli.ini)。只改一份的结果是——网页有 Redis 了,但 wp-cli 看不到,wp redis status 直接报错。两处都加才通。
6.3 数据,以及一个诚实的结论
上线后状态看着一切正常:连接正常、命中率 82.2%(hits 16,655 / misses 3,609)、只占 7.91MB、淘汰 0 次、拒绝连接 0 次。
但首页 TTFB 只从 1.247s 掉到 1.093s。12%。
我盯着这个数字看了半分钟,然后意识到它其实是个好消息:它把我原本笃信的「瓶颈在数据库」给否掉了。真正吃时间的是模板渲染本身——15 次请求以 5 并发打首页,平均 TTFB 反而升到 2.76s(worker 相互竞争);/events/ 单次仍需 1s+,高峰照样会飙到 30s。
顺手做了个韧性测试:中途把 Redis 杀掉重启,站点依然 200,WordPress 自己重连上了。这条底线守住了——缓存挂了不拖死站点。
教训二:慢日志里 option.php 出现 7.9 万次,不等于「DB 是瓶颈」。 它是「缺少对象缓存」的证据;把对象缓存做掉之后,剩下的成本是模板渲染本身。局部证据只能证明局部问题。
7. fastcgi_cache:把 PHP 从关键路径上摘掉
7.1 配置三片
① http 块定义缓存区
fastcgi_cache_path /www/cache/nginx levels=1:2 keys_zone=WP:100m
max_size=2g inactive=60m use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri$cookie_pll_language";
fastcgi_cache_lock on;
cookie_pll_language 进 key,是因为站点用 Polylang 中/英双语——这样不同语言偏好各自独立缓存,不会串。
② 站点专用 PHP handler(不动宝塔的 enable-php-83.conf,避免面板升级时被覆盖)
location ~ [^/]\.php(/|$)
{
try_files $uri =404;
fastcgi_pass unix:/tmp/php-cgi-83.sock;
fastcgi_index index.php;
include fastcgi.conf;
include pathinfo.conf;
fastcgi_cache WP;
fastcgi_cache_valid 200 301 302 15m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache_use_stale error timeout updating http_500 http_503;
add_header X-Cache $upstream_cache_status always; # 便于验证
}
③ server 块:替换 include + 跳过规则
# 原来是 include enable-php-83.conf; 改成:
include /www/server/nginx/conf/wp-cache/php-handler.conf;
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; } # TEC 筛选页带参 → 走动态
if ($http_cookie ~* "wordpress_logged_in|comment_author|wp-postpass") { set $skip_cache 1; }
if ($request_uri ~* "(/wp-admin/|/wp-json/|/wp-cron\.php|/xmlrpc\.php|/wp-login\.php|preview=true|/sitemap)") { set $skip_cache 1; }
这几条必须有:
wordpress_logged_in—— 不跳过会缓存登录态页面,导致串号泄露/wp-json/—— 站点的「RSS → AI → 发布」管线全靠 WP REST API,绝不能缓存$query_string——/events/?tribe-bar-date=...这类筛选页变体无穷,缓存会雪崩
7.2 踩到的四个坑(这才是真实工作量)
/www/server/panel/vhost/nginx/*.conf整个目录被 http 上下文 glob 包含。把含location的文件放这里,nginx -t直接报错。PHP handler 最后挪到了/www/server/nginx/conf/wp-cache/。幸好每次都先nginx -t再 reload,中途两次语法错误没有影响线上(旧配置持续服务)。- Polylang 每个响应都下发
Set-Cookie: pll_language=zh,而 nginx 默认拒绝缓存任何带Set-Cookie的响应。表现是:一直MISS、缓存目录 0 文件。解决:fastcgi_ignore_headers Set-Cookie(语言已经通过 cache key 隔离,不会串)。 - PURGE 的 cache key 必须硬编码
GET。缓存键含$request_method,而 PURGE 请求自身的 method 是PURGE,不硬编码就永远对不上——表现是「返回 404、清不掉缓存、静默失效」。 - cache key 含
pll_languagecookie → 同一个 URL 有 3 份缓存(无 cookie / zh / en)。我们验证过:pll_language=en的 cookie 会触发跳转而不是同 URL 不同内容,所以 cookie 必须留在 key 里;代价是清缓存必须清所有变体。于是封装了purge-wp-cache.sh,一次清三份。
还有一个更朴素的坑:用 heredoc 改 nginx 配置时忘了给 << 加引号,$ 被 shell 吃掉,patch 静默不生效,排查了半天。凡是含 $ 的配置,一律写成文件再上传。
7.3 效果
| 页面 | 缓存前 | 命中后 | 倍数 |
|---|---|---|---|
首页 / | 1.24s | 16–27ms | ~50× |
文章列表 /news/ | 1.14s | 15ms | ~75× |
单篇文章 /blog/... | 1.23s | 19ms | ~65× |
单事件页 /event/... | 1.23s | 19ms | ~65× |
/events/ 日历 | 1.34s | 19ms | ~70× |
| 分类事件列表 | 1.14s | 54ms | ~21× |
| 5 并发 15 请求平均 | 2.76s | 19ms | ~145× |
这里的 1.34s 是缓存上线当次 MISS 抽样的耗时;30 秒是慢日志/历史观测值,两者不是同一测量口径。
服务器侧:load average 1.12 → 0.29,php-fpm 6/12 worker(不再打满),OOM 0 次,Redis 命中率升到 86.8%。
内容正确性我没有只看状态码——用自己写的那套 13 项内容级检查跑了一遍,判定正常:145KB、标题正确、品牌词 19 处、链接 111、静态资源 127、零乱码。/events/ 页面单独校验了体积、<title>、乱码数、活动链接数。
该拦的拦了、不该拦的没拦:登录态、/wp-json/、带参数筛选页、/feed/、/sitemap、POST 全部 BYPASS;PURGE 从本机 200、从外网 403。
回滚分三级,任意一级都能秒退:只关 /events/ 缓存改一行注释;全关页面缓存把 include 改回 enable-php-83.conf;清空缓存 rm -rf /www/cache/nginx/*。配置都按日期备份好了。
8. 最终形态:一台机器上的五层防护
用户请求 → nginx
├─ 第1层 爬虫拦截(纯SEO全站403,AI/搜索爬虫仅 /events)
├─ 第2层 fastcgi_cache 页面缓存 ← 命中则直接返回,PHP 零执行 【新增】
│ └─ 未命中 → PHP
│ ├─ 第3层 Redis 对象缓存(DB 查询减少,86.8% 命中) 【新增】
│ └─ MariaDB(oom_score_adj=-900,OOM 免疫)
├─ 第4层 php-fpm 收敛(12 worker / 30s 超时)
└─ 第5层 日志轮转(wwwlogs 2.1G → 184MB)
从 20:15 到 23:46,一共 3.5 小时。但「站点能打开」这件事其实只花了不到 2 分钟——我 20:18:47 重启,20:20:39 那个启动起来之后站点就恢复 1.15s 了。可重启只是清现场,不治病。
真正把「随时会死」变成「不会再死」的,是 20:17:45 到 20:46 这 28 分钟:20:28–20:29 从 -b -2 里发现 OOM、锁定根因,20:46 两项 P0 落地——worker 从 80 收到 12,数据库被钉在 oom_score_adj=-900 上。从那之后 OOM 再没发生过。
后面 3 小时做的事情性质变了:不是救命,是把「随时会死」改成「偶尔会慢」,再把「偶尔会慢」改成「基本不慢」。垫 2GB swap、超时从 100s 砍到 30s、用 766 万条日志把 29 万次爬虫请求挡回去、Redis 砍掉一部分 DB 查询,最后 fastcgi_cache 把 PHP 从关键路径上摘掉——/events/ 从 30 秒变成 19 毫秒,load 从 1.12 掉到 0.29。
五层里任何一层单独失效,后果完全不同:爬虫拦截挂了,最坏是慢;fastcgi_cache 挂了,回源 1.2s,站点还活着;Redis 挂了,站点 200(实测过);MariaDB 被 OOM 杀了,整站 502。所以排在最前面的两层不是「优化」,是「防护」。
至于开头那句「最坏情况下站点可以挂着 6 小时没人知道」——现在是每 30 分钟一次,只在异常时推飞书、恢复时补一条闭环。10-19、10-20 那种「挂着几小时没人发现」的剧本,不会再演第二遍。
9. 岔路口:两个我没做的选择
中途我想过两条更彻底的路,最后都没走。
静态化。 WP2Static 已停维护,Strattic 被 Elementor 在 2025-01-01 关停;还能用的 Simply Static($99/年)和 Astro 无头方案,卡在 7 项阻塞点上(6 个功能依赖 + 文章规模):TEC 的筛选页会退化成「发布时快照」,?ical=1/?outlook-ical=1 这类按需生成的文件直接失效;WPForms 没有 PHP 接收端;站内搜索依赖数据库;Redirection 的规则存在库表里(它那张 404 日志表已经涨到 10MB);Polylang 要分别导出中英两份;Yoast 的动态 sitemap 会消失;3,554 篇文章必须增量导出。代价从 1–2 天(Simply Static)到 2–4 周(Astro)不等,而它解决的是「PHP 或 MySQL 崩了站就没了」,不是我们今天的病因「每一页都要跑一遍 PHP」——后者缓存就能吃掉 80%,1 天成本。
CDN。 站点当时是裸 IP 直连、没有 CDN,但顺序不能反:/events/ 未命中要回源渲染 30 秒,而 CDN 回源超时通常 30–60s,冷启动时反而更容易 504;WordPress nonce 有 12–24 小时时效,缓存过久的页面里的表单 token 会过期,提交时报「安全校验失败」;不配 set_real_ip_from,日志里的真实 IP 全乱,今天做的爬虫分层拦截还得在 CDN 侧镜像一份;另外每日 URL 刷新有配额。
Agent 当时说了句话我很喜欢:只上 CDN 不修源站,等于给一个扛不住的发动机换更好的排气管。 所以顺序是:源站缓存(今天,0 元)→ 观察一周拿真实流量基线 → 再上 CDN → 图片走 COS CDN。月真人流量 5–8GB,境内 CDN 每月几元到几十元——它是锦上添花,不是救命的那一个。
一句话:能用一个下午解决的,别用四周去绕。
10. 代价与后事
收益讲完了,边界得说清楚。
先说我还没解决的那件事:缓存 TTL 是 15 分钟,而 purge 当时还没接进发布管线。也就是说发文、改价、改活动之后,最多 15 分钟内部分读者看到的是旧页。对一个每天靠自动管线发稿的站,这是真问题——所以「发布后自动清缓存」是下一步,不是可选项。
再是冷启动。首次访问仍要 1.34s,如果缓存刚被清空、几百个爬虫同时来,源站会不会被打穿?靠 fastcgi_cache_lock on(并发回源只渲染一次,其余请求等锁)和 use_stale / background_update(源站抖动时先吐旧缓存、后台再更新)。这也是我后来才敢放开 /events/ 缓存的原因。
还有语义边界。/events/ 缓存的是「URL 快照」——按缓存语义,页面里的动态片段(比如报名数、评论数)也会跟着一起被冻结最多 15 分钟;登录用户走 wordpress_logged_in BYPASS,不受影响。正因如此,「我这边没看到更新」和「缓存坏了」会很难区分——得先知道哪类页面本来就不适合缓存。
成本这边:0 元软件投入、3.5 小时人工,对比静态重构的 1 天到 4 周(外加 Simply Static Pro 的 $99/年授权)。这笔账本身就是结论。
如果重来一次,我会把三件事提到最前面。第一天就把监控改成 30 分钟并且异常即推——这次两份真实故障都是事后从日志里捞出来的,当时到底推没推、有没有人看、有没有闭环,其实没验证过。动手前先 journalctl --list-boots 确认现场,别急着重启。还有,止血和缓存不要排队:/events/ 这种「每页跑 30 秒 PHP」的坑,先把缓存锁死再说。
11. 复盘:让 AI Agent 干生产运维,我学到的
11.1 人负责三件事
给先验。 那份检查清单把它的搜索空间从「无限」收窄到了「五条」。它没有盲从(自己发现现场在 -b -2),但如果没有清单,它大概率会花更多时间在「是不是被攻击」上。
定取舍。 它第一版只拦 /events/,我改成了三层:纯 SEO 采集器全站拦、AI 爬虫只拦慢页。理由是像 AhrefsBot 那种全站爬、零回报的,没必要给它留任何一个页面;而 AI 爬虫虽然不给流量,但可能带来 AI 搜索的引用,只砍最贵的渲染就够了。工具给方案,人来定价值。
批节奏。 P0 两项 → P1 三项 → 爬虫拦截 → Redis → fastcgi_cache,每次只批一步,每步都有验收数据才走下一步。它每次都会附上一串「下一步建议」,其中有些(比如「直接上 CDN」)在我这套栈上其实会变差——取舍还是人做。
它做不了的事也很明确:它不知道「这个页面慢是正常的」,不知道哪些页面值得为 SEO 保留;它第一次怀疑自己怀疑得不够早,而且归因归反了——先怀疑自己、又把自己说服了,真正该信的是服务器时间线。
11.2 七条可复用的原则
按事故阶段排,不按发现顺序排:
- 事前:没人复查的默认值都是定时炸弹。
max_children、swap=0、日志不轮转、监控 6 小时一次——四项加起来每月花 5 分钟复查。对没有专职 IT 的小机构来说,这 5 分钟可能是性价比最高的一笔运维投入。 - 事前:监控要「内容级 + 异常即推」。 HTTP 200 不等于活着,维护页、WAF 挑战页、DB 报错页都会 200。我们那套 13 项检查就是为这个写的。
- 事中:先确认现场在哪。
journalctl --list-boots,别对着一个只活了 60 秒的空壳启动查 OOM。 - 事中:诊断不要打自己的站点。 观察者必须控制自己的观测成本,否则日志会被自己污染。
- 事中:固定四步改动纪律。 备份(
.bak.$(date +%Y%m%d_%H%M%S))→ 语法校验(nginx -t/php-fpm -t)→ 平滑重载 → 立即验证。两次语法错误零影响的底气就在这。 - 事中:止血顺序不能反。 OOM 免疫(活下来)→ 缓冲垫(有余量)→ 源头减负(少干活)→ 缓存(不干活)。先上缓存也会被 OOM 打穿。
- 事后:验证矩阵 + 真实流量证据 + 内容级校验,并把坑写成脚本和文档。 它两次公开推翻过自己的判断(一次是归因错了的「误报」,一次是慢日志路径),另外修了 2 个自己写出来的配置 bug(vhost 目录 glob、PURGE 的 key)。这些坑最后都进了
README.md、purge-wp-cache.sh、ensure-mariadb-oom-protect.sh——让一次救援变成长期资产。
附录 A:5 分钟自检清单(同类 OOM 站点)
体检
pm.max_children× 单 worker RSS 是否远大于物理内存?
→ 本次:80 × 290MB ≈ 23GB,机器只有 3.6GB,超配约 6 倍;整改后整机已用内存约 1.3GB- swap 是否为 0?→ 零 swap 等于没有回旋余地,本次事故的放大器
journalctl --list-boots确认现场启动号,再journalctl -b -N -k | grep -iE 'oom|killed'/events、/blog这类慢页是否被 AI/SEO 爬虫高频抓取?查 access_log 的 UA 分布- 日志有没有轮转?
du -sh /www/wwwlogs/*.log(本次:1.8GB,14 个月没轮转)
止血(改前必备份;-t 校验;reload 不是 restart)
php-fpm -t通过 →SIGUSR2平滑重载 → 立刻 curl + 看 worker 数cat /proc/$(pgrep -x mariadbd)/oom_score_adj→ 期望-900- 站点 200 但内容是否正常?跑内容级检查(200 ≠ 正常)
上线后验收
curl -sI https://your.site/ | grep -i x-cache→ 第二次应为HIT- 登录态 /
/wp-json// 带参数页 / POST → 必须BYPASS PURGE从外网应 403- 抽查页面内容:体积、
<title>、品牌词、乱码数
附录 B:内容级健康检查清单
单纯看连通性会漏判。这套检查跑一次就知道「站还活着吗」:
HTTP 200 | 不是维护页 | 无 DB/PHP 致命错误 | 不是 WAF 挑战页 | <title> 含品牌词 | 正文含品牌词 | 体积 ≥30KB | 站内链接 ≥5 | 出现 ICP 备案号 | 静态资源 ≥10 | 耗时 <10s | 无 U+FFFD 乱码
(会话记录里这套检查记为 13 项,逐项能复原的是上面 12 项。)
附录 C:命令速查
# OOM 证据
journalctl --list-boots
journalctl -b -2 -k | grep -iE 'oom|killed'
# OOM 保护是否生效
cat /proc/$(pgrep -x mariadbd)/oom_score_adj # 期望 -900
cat /proc/$(pgrep -x mariadbd)/oom_score
# 缓存是否命中
curl -sI https://your.site/ | grep -i x-cache
# 清缓存(封装脚本,一次清 pll_language 三个变体)
./purge-wp-cache.sh /
./purge-wp-cache.sh /blog/2026/10/24/xxx/
尾声
写这篇文章时,我反复想到一句话:这次不是「AI 很聪明」,而是「流程很正确」。
Agent 会误报、会查错路径、会把 $ 喂给 shell、会在一次 heredoc 里静默失败——它犯的错和人一模一样。但整场救援之所以能成,是因为每一步都有三个固定动作:先取证、改前校验、改后验证。这三件事,人和 AI 谁都偷不了懒。
回头看,这次 WordPress OOM 真正贵的地方不是那 3.5 小时,而是它安静地躺了十几天才发作。
至于那行 pm.max_children = 80——它可能只是某次装机时的默认值,也可能是为了「多扛点并发」拍脑袋调大的。至少从 10-08 那次启动起,它就在稳定地制造 OOM。
运维世界里,最贵的东西从来不是硬件,是那行没人复查过的配置。
最后说回那个荒诞感。这次事故里 AI 同时扮演了加害者和救援者:一边是 36 万次抓取,把我这台机器当成免费的算力,拿走内容、几乎零回报(AI 抓取的访客数是 0);另一边是一个 Agent,在 3.5 小时里帮我从 766 万条日志里捞出证据、写配置、做验证,最后把 /events/ 从 30 秒压到 19 毫秒。
站在站长的位置上,我现在的判断很朴素:AI 爬虫拦不拦,是一笔流量账——看它配不配得上你花的机器钱;AI Agent 用不用,是一笔流程账——看你愿不愿意为它保留「先取证、改前校验、改后验证」这套纪律。 前者决定你的服务器会不会死,后者决定它死后你能不能活过来。
本文基于 2026-10-24 20:15–23:46 的一次真实会话整理。文中主机、密钥、账号等敏感信息均已脱敏;配置片段为整理后的示意版本,落地前请以自己的环境和 nginx -t / php-fpm -t 校验为准。







