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:00fetch failed,HTTP 0,站点无响应与 OOM 时间窗吻合(推断)
10-20 06:00502 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 分钟,然后才上服务器。取证时它没有照抄命令,而是先确认清单本身对不对——结果发现这台机器最近三次启动是:

启动时间范围说明
-210-20 07:13 → 10-24 20:18:47出问题的这次启动 ← 真正的现场
-110-24 20:19:04 → 20:20:09只活了 1 分钟(疑似再次重启)
010-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_children8012
pm.start_servers53
pm.min_spare_servers52
pm.max_spare_servers206
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_adjoom_score
mariadbd-900109
php-fpm master0668

内核现在会明确选 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,54683.1%76 GB(折合每月约 5–8GB)
搜索引擎(有流量价值)609,7288.0%8.3 GB
AI 抓取(零访客)365,2774.8%6.9 GB
纯 SEO 采集(零访客)204,3772.7%4.8 GB
脚本/未知112,3241.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 三层拦截:按「价值 / 成本」分层,而不是按「爬虫 / 人类」

最终策略是我改了它第一版方案后定下的:

层级对象范围理由
1AhrefsBot、SemrushBot、MJ12bot、DotBot、DataForSeoBot、BLEXBot、serpstatbot、Barkrowler、SeznamBot、MauiBot全站 403纯 SEO 采集,零访客、零回报
2GPTBot、ClaudeBot、meta-externalagent、Amazonbot、CCBot、PerplexityBot、Bytespider仅 /events* 403不给流量,但可能带来 AI 搜索引用,保住别的页面
3PetalBot、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.log1.8G176M(gzip 归档)
wordpress.local.error.log235M4.0M
/www/wwwlogs 合计2.1G184M(回收 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 踩到的四个坑(这才是真实工作量)

  1. /www/server/panel/vhost/nginx/*.conf 整个目录被 http 上下文 glob 包含。把含 location 的文件放这里,nginx -t 直接报错。PHP handler 最后挪到了 /www/server/nginx/conf/wp-cache/。幸好每次都先 nginx -t 再 reload,中途两次语法错误没有影响线上(旧配置持续服务)。
  2. Polylang 每个响应都下发 Set-Cookie: pll_language=zh,而 nginx 默认拒绝缓存任何带 Set-Cookie 的响应。表现是:一直 MISS、缓存目录 0 文件。解决:fastcgi_ignore_headers Set-Cookie(语言已经通过 cache key 隔离,不会串)。
  3. PURGE 的 cache key 必须硬编码 GET。缓存键含 $request_method,而 PURGE 请求自身的 method 是 PURGE,不硬编码就永远对不上——表现是「返回 404、清不掉缓存、静默失效」。
  4. cache key 含 pll_language cookie → 同一个 URL 有 3 份缓存(无 cookie / zh / en)。我们验证过:pll_language=en 的 cookie 会触发跳转而不是同 URL 不同内容,所以 cookie 必须留在 key 里;代价是清缓存必须清所有变体。于是封装了 purge-wp-cache.sh,一次清三份。

还有一个更朴素的坑:用 heredoc 改 nginx 配置时忘了给 << 加引号,$ 被 shell 吃掉,patch 静默不生效,排查了半天。凡是含 $ 的配置,一律写成文件再上传。

7.3 效果

页面缓存前命中后倍数
首页 /1.24s16–27ms~50×
文章列表 /news/1.14s15ms~75×
单篇文章 /blog/...1.23s19ms~65×
单事件页 /event/...1.23s19ms~65×
/events/ 日历1.34s19ms~70×
分类事件列表1.14s54ms~21×
5 并发 15 请求平均2.76s19ms~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 七条可复用的原则

按事故阶段排,不按发现顺序排:

  1. 事前:没人复查的默认值都是定时炸弹。 max_children、swap=0、日志不轮转、监控 6 小时一次——四项加起来每月花 5 分钟复查。对没有专职 IT 的小机构来说,这 5 分钟可能是性价比最高的一笔运维投入。
  2. 事前:监控要「内容级 + 异常即推」。 HTTP 200 不等于活着,维护页、WAF 挑战页、DB 报错页都会 200。我们那套 13 项检查就是为这个写的。
  3. 事中:先确认现场在哪。 journalctl --list-boots,别对着一个只活了 60 秒的空壳启动查 OOM。
  4. 事中:诊断不要打自己的站点。 观察者必须控制自己的观测成本,否则日志会被自己污染。
  5. 事中:固定四步改动纪律。 备份(.bak.$(date +%Y%m%d_%H%M%S))→ 语法校验(nginx -t / php-fpm -t)→ 平滑重载 → 立即验证。两次语法错误零影响的底气就在这。
  6. 事中:止血顺序不能反。 OOM 免疫(活下来)→ 缓冲垫(有余量)→ 源头减负(少干活)→ 缓存(不干活)。先上缓存也会被 OOM 打穿。
  7. 事后:验证矩阵 + 真实流量证据 + 内容级校验,并把坑写成脚本和文档。 它两次公开推翻过自己的判断(一次是归因错了的「误报」,一次是慢日志路径),另外修了 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 校验为准。

Avatar photo
网站管理员
文章: 2