摘要:给站点做了一套 404 访问监控:自定义表去重计数、后台明细与趋势、每日自动清理。功能不复杂,但从「写完」到「敢信它的数字」之间,踩了四个坑——一次请求被记了两次、后台开关开的和代码读的不是同一个键、配置里声明了定时任务却从未注册、以及管理员自己怎么测都看不到数据。其中第一个坑的根因完全不在 PHP 里,而在 nginx 配置,并且它会影响任何挂在 404 上的逻辑。本文逐一还原,附单请求复验法与一份零依赖交付代码。
适合阅读:做过埋点 / 统计 / 日志类功能,或正在排查「计数不对」的开发者。
一、先说清楚要什么
404 是「内容腐化」的最早信号:一篇文章改了 slug、一张图挪了目录、一次改版删了页面,搜索引擎和被引用的旧链接不会主动告诉你,但 404 表会。所以这个功能的目标很朴素:知道有哪些 URL 在被访问却不存在,然后决定是做 301 还是补内容。
| 要求 | 落地方式 |
|---|---|
| 按 URL 去重计数 | 表里 url 存原串、url_key 存 md5(url) 并建唯一键 |
| 记录上下文 | 首次 / 最后时间、来源页(referer)、IP |
| 不污染数据 | 过滤 favicon / robots / sitemap / 静态资源与 AJAX |
| 不被扫描器打爆 | 当日唯一 URL 数量上限 |
| 不无限增长 | 每日 cron 清理 N 天前的记录 |
| 能看到 | 后台「工具 → 404监控」:今日明细 + 近 7 天趋势 |
功能本身没有难度。难点全部集中在「怎么确认它的数字是对的」——下面是四个真实的坑。
二、坑①:一次请求,被记了两次
功能上线后做最基础的验证:清掉某 URL 的旧记录,用 curl 发一次 404 请求,再去看计数。
curl -s -o /dev/null http://127.0.0.1/zhiji-m404t-a?x=1 # 只发一次
# 然后查表 → count = 2
一次请求记两次。第一反应当然怀疑自己的代码——是不是钩子挂了两次?是不是 template_redirect 被触发了两遍?查了一遍,没有。
根因:nginx 与 WordPress 的一次「接力」
把服务器的 nginx 配置拿出来看,两行配置叠在一起就出事:
# 全局 http 段
fastcgi_intercept_errors on; # nginx 拦截 PHP 返回的错误状态码
# 站点 server 段
error_page 404 /404.html; # 拦截后,去取这个静态页
关键在第三件事:/404.html 这个文件并不存在。
于是这个请求走了这样一条路:
| 步骤 | 发生了什么 |
|---|---|
| 1 | 请求 `/zhiji-m404t-a` → nginx 交给 PHP → WordPress 判定 404 → 返回 404 |
| 2 | 全局开着的 `fastcgi_intercept_errors` 让 nginx 拦下这个 404 |
| 3 | 按 `error_page 404 /404.html` 去取静态页 → 文件不存在 |
| 4 | 回落到 `try_files` 的最后一个参数(`index.php`)→ WordPress 又跑了一整遍 |
| 5 | 第二遍同样走到 `template_redirect` → 打点钩子再执行一次 → 计数 +2 |
所以不是「钩子挂了两次」,而是WordPress 真的被完整执行了两遍。
确认与修法
# 确认:清掉该 URL 记录后只发一次请求,看 count
curl -s -o /dev/null http://127.0.0.1/some-404-url
# count=2 → 命中本坑;count=1 → 与本坑无关
# 修法(二选一,改完必须复验)
# A. 注释掉站点配置里的 error_page 404 /404.html;(全局 intercept_errors 可保留)
# B. 或放一个真实的 /404.html,让 nginx 无需回落到 PHP
nginx -t && nginx -s reload
修完再跑一次同样的单请求复验 → count=1。
这个坑真正值得记住的地方:它不属于 404 监控。任何挂在 404 上的逻辑——埋点、广告统计、限流、自动跳转、外链追踪——在这台服务器上都会被执行两遍。遇到「某个统计总是翻倍」,先数一数请求真正被处理了几次,再去怀疑业务代码。
三、坑②:后台开关开的,和代码读的,不是同一个键
第二个坑更隐蔽:后台把「启用404监控」打开了,前台却完全不记录,不报错、不留日志、后台明明显示已开启。
根因:一个「智能到出错」的默认推导
项目的模块框架会为每个模块自动推导一个选项键,规则是「模块名 + _enabled」。对大多数模块没问题,但这个模块叫 Monitor404——名字里带数字:
模块 Monitor404 → 默认推导 monitor404_enabled
实际使用的键 monitor_404_enabled
↑ 数字前多一个下划线
两个键都是合法字符串,谁也不会报错。后台表单写的是 monitor_404_enabled,代码读的是推导出来的 monitor404_enabled——一个写、一个读,永远对不上。
修法:键名不许推导,必须显式声明
// 模块元信息里显式声明选项键,不走推导
// (后台字段的 id 一个字符都不能改——已有站点的数据在它名下)
'option' => 'monitor_404_enabled',
教训:凡是「按名字推导」产生的标识符,都要能显式覆盖。命名规则一旦遇到例外(数字、缩写、历史遗留键),推导就会静默跑偏。而且这类 bug php -l、各种 lint 全都查不出来——两个键都是合法字符串。只有「后台开一下、前台看有没有生效」这种端到端动作能发现它。
四、坑③:配置里声明了定时任务,但它从未被注册
这个模块有一个每日清理任务(删除 30 天前的记录)。声明方式是在模块契约里写一行:
'contract' => array(
'schedules' => array(
array('hook' => 'zhiji_404_log_cleanup', 'interval' => 'daily'),
),
),
按理说,框架会读这份契约,把它同步成真正的 WP-Cron 任务(模块关掉时幽灵任务自动消失)。但它从来没有被注册过。
根因:框架自己一个「取值函数」的缺陷
框架读嵌套配置用的是一个路径取值工具,调用长这样:
$schedules = zhiji_arr_get($def, 'contract.schedules'); // 期望拿到那个数组
而那个工具不支持点路径——它把 'contract.schedules' 当成一个完整的字面键名去数组里找,当然找不到,恒返回空。于是「声明了定时任务」这件事在框架眼里等于没写,cron 同步、依赖校验两条链路一起静默失效。
修法是逐层取值:
// ✗ 不支持点路径:恒空
$schedules = zhiji_arr_get($def, 'contract.schedules');
// ✓ 逐层取
$contract = isset($def['contract']) && is_array($def['contract']) ? $def['contract'] : array();
$schedules = isset($contract['schedules']) ? $contract['schedules'] : array();
这个坑的启示:一个「通用的取值小工具」如果语义和自己名字暗示的不一样,它就会成为整个项目里最隐蔽的 bug 源——因为所有调用方都以为自己是对的。发现它之后,我把项目里所有用点路径的调用点都过了一遍(这个模块的 schedules 与 uses 两处首当其冲)。
另外,这个坑是被 404 模块暴露出来的,但受害者不止它——任何在契约里声明了 schedules / uses 的模块,当时都一起失效了。所以修完框架,要把「谁用了这个入口」全部回归一遍。
五、坑④:管理员自己怎么测,都看不到数据
第四个坑不算 bug,但它是最容易被误判成 bug 的一个:功能上线后自己去访问一个不存在的地址,刷新后台 → 空的。
三个叠加的「善意设计」
| 机制 | 本意 | 后果 |
|---|---|---|
| 登录用户默认不统计 | 避免自己调试时污染数据 | 管理员就是登录用户 → 自己测永远没数据 |
| 静态资源白名单 | 过滤 favicon / robots / 图片的常见误报 | 随手测了个 /a.png 也不会记录 |
| AJAX 不记录 | 后台 AJAX 的 404 是插件在试探,不是内容腐化 | 走了 ajax 路径的测试也看不到 |
三个都是对的,叠起来就是「你怎么测都看不到东西」。正确做法是:先说清楚这三个前提,再给测试留一条明确的路——把「统计登录用户」做成独立开关(默认关,测的时候打开),并且用匿名请求(curl 不带 Cookie)来验证基本链路。
教训:默认值为「静默地不做事」时,一定要给验证留一条后门(一个开关、或一句「用匿名 curl 测」的说明)。否则下一个接手的人会花半小时怀疑代码,而问题只是「你自己的账号被豁免了」。
六、这类功能该怎么验证
统计类功能的验证有个特点:「页面能打开」完全不能说明数字是对的。这次用的是一组分层断言,按能发现的问题从粗到细:
| 断言 | 做法 | 能发现 |
|---|---|---|
| 单请求计数 | 清旧记录 → curl 一次 → 查 count | 重复执行 / 钩子挂两次(坑①) |
| 白名单 | 连打 4 个静态资源 URL → 查表行数应为 0 | 过滤规则失效 |
| 登录豁免 | 带 Cookie 请求 → 应不记录;开开关后 → 应记录 | 豁免开关接反(坑④) |
| 上限保护 | 灌到当日上限 → 应停止记录 | 防爆逻辑失效 |
| 清理 | 造一条 31 天前的行 + 一条今天的行 → 跑 cron → 只删旧的 | 时间段边界写错 |
| 后台页 | 真登录抓 tools.php?page=… → 200 且含明细表 | 权限 / 注册问题 |
其中「单请求计数」这条最值得先做。它用一个动作同时排除了「钩子重复」和「请求被处理两遍」两类完全不同的原因——如果不是 1,再往下查是前者还是后者。
附:同系列文章
本文属于「zibll 子主题开发实录」系列,同系列其它篇目见文末导航。
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」 (本文)
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统









暂无评论内容