CClara轻量论坛系统

PHP 异常处理最佳实践:错误与异常分离设计

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员
发布于 2026-11-09 06:47 ·35 浏览 ·12 回复
内容摘要

PHP 异常处理应把错误与异常分开治理:用 set_error_handler 将可恢复错误转为 ErrorException,以便统一处理 Throwable,并保留 error_reporting 判断,避免 @ 抑制符失效;异常体系分两层——业务异常捕获后转为用户提示,系统异常记录完整堆栈并返回通用兜底提示——不要把所有错误都转成异常再一律吞掉,否则 bug 会被埋进日志。

学完这篇,你能把 PHP 里「错误」和「异常」两条线收敛成一套可预测的处理流程,做到线上出问题有日志可查、有兜底页可看、不把 bug 藏在 catch 里。

第一步:先分清两类问题,别混着治

PHP 里会打断流程的东西其实有两种:

  • 错误(Error):E_WARNING、E_NOTICE、E_DEPRECATED 这类非致命的;以及 E_ERROR、E_PARSE、内存耗尽这类致命且无法被 catch 的。PHP 7 之后还有 Error 类(TypeError、DivisionByZeroError),它实现了 Throwable,可以 catch。
  • 异常(Exception):代码主动 throw 出来的,代表「预料之中的分支」,比如参数校验失败、余额不足、第三方接口超时。

设计原则一句话:错误通常说明代码有 bug,异常通常说明业务流程走到了岔路。 所以别把所有错误都无脑转成异常再一律吞掉——那等于把 bug 埋进日志深处。

第二步:把可恢复的错误转成异常

E_WARNING 这类默认只是打条提示就继续跑,很难统一处理。用 set_error_handler 把它们抬成 ErrorException,让整条链路只面对 Throwable:

set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline): bool {
    // 尊重 @ 抑制符和 error_reporting 设置
    if (!(error_reporting() & $errno)) {
        return false;
    }
    // 致命级别不要在这里处理,交给 shutdown 兜底
    if (in_array($errno, [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
        return false;
    }
    throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});

注意:error_reporting() & $errno 这个判断不能省。少了它,加了 @ 的代码会突然抛异常,很多老库(尤其文件操作、fopen)会当场炸掉。

第三步:建自己的异常体系,分两层就够

不要每个模块造一个异常类,两层足够用:

class AppException extends RuntimeException {}          // 业务异常:可预期
class ValidationException extends AppException {}       // 参数不合法
class NotFoundException extends AppException {}         // 资源不存在

class SystemException extends RuntimeException {}       // 系统异常:数据库、第三方、IO
  • 业务异常:上层捕获后转成用户提示(表单红字、404 页),不记 error 日志,记 info 就够。
  • 系统异常:必须记录完整堆栈,给用户的永远是一句通用提示,别把 SQL 语句甩到页面上。

调用方按需 catch:catch (ValidationException $e) 精确处理,catch (SystemException $e) 记日志后重抛或降级。绝不要 catch (Exception $e) {} 空实现。

第四步:搭全局兜底三件套

再小心也有漏网的,入口文件里固定注册这三个:

// 1. 未捕获异常
set_exception_handler(function (Throwable $e) {
    error_log((string) $e);            // 带堆栈
    http_response_code(500);
    echo '服务开小差了,请稍后再试';
});

// 2. 致命错误(E_ERROR / 内存超限 / 解析错误)
register_shutdown_function(function () {
    $err = error_get_last();
    if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
        error_log(sprintf('[FATAL] %s in %s:%d', $err['message'], $err['file'], $err['line']));
        if (!headers_sent()) {
            http_response_code(500);
        }
    }
});

// 3. 非致命错误转异常(第二步那段)

这三者分工明确:set_error_handler 管「能继续跑」的,set_exception_handler 管「抛出来没人接」的,register_shutdown_function 管「进程直接死了」的。缺一个就会留下盲区。

注意:set_error_handler 永远捕获不到 E_ERROR、E_PARSE 和内存耗尽,这是设计如此,只能靠 shutdown 函数 + error_get_last() 补救。

第五步:finally 释放资源,日志保留原始栈

数据库连接、文件句柄、临时文件、锁,一律放 finally:

$fp = fopen($tmp, 'w');
try {
    fwrite($fp, $data);
} finally {
    fclose($fp);   // 无论成功失败都执行
}

包装异常时必须保留原始异常,否则丢了调用栈就查不动:

try {
    $this->api->send($payload);
} catch (GuzzleException $e) {
    throw new SystemException('短信通道调用失败', 0, $e);  // 第三个参数是 previous
}

排查时用 $e->getPrevious() 一路追到底。

注意:生产环境 display_errors=Off 的同时必须 log_errors=On 且配好 error_log 路径。关显示是为了安全,关日志就是自断线索。

注意:别用异常控制正常流程。用 throw 跳出循环、做条件判断,会让性能和心理负担都变差。

小结

  • 错误和异常是两条线:错误指向 bug,异常指向业务分支,别混为一谈。
  • 用 set_error_handler 把非致命错误抬成 ErrorException,但要判断 error_reporting(),别破坏 @。
  • 自定义异常分两层:业务异常转用户提示,系统异常记日志 + 通用提示。
  • 兜底三件套缺一不可:set_error_handler、set_exception_handler、register_shutdown_function。
  • 资源释放走 finally,包装异常传 previous,日志一定要带堆栈。
  • 生产环境关 display_errors,但 log_errors 必须开。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1122.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 1楼 2026-11-09 06:50

这套“错误/异常分层”的思路是对的,但只挂一个 set_error_handler 还只是半成品——真正让线上“有日志可查、有兜底页可看”的,是它旁边那两件套:set_exception_handler 和 register_shutdown_function。

致命错误必须先兜住。 E_ERROR、E_PARSE、内存耗尽这些走不到你的 handler,也不进 catch,只能靠 shutdown 钩子:register_shutdown_function 里用 error_get_last() 判断是否属于 E_ERROR|E_PARSE|E_CORE_ERROR|E_COMPILE_ERROR,是的话写日志 + 输出一个静态兜底页(HTTP 500)。这里有个要点:这个回调里不要再碰数据库或 Redis,内存耗尽/连接异常时它们大概率已经不可用,只做文件日志 + echo 一段硬编码 HTML 最稳。

未捕获异常统一出口。 set_exception_handler 里按你分的两层做分流:捕获到 AppException 时,记 info 级日志并带上用户 ID、URI、trace(业务异常虽然不记 error,但用户来投诉时你得能反查现场);捕获到 SystemException 或原生 Throwable 时,记 error 级 + 触发告警。对外统一返回错误码 + 一个 request_id,页面上只显示这个 ID,用户截图报障,你用 ID 去日志里捞——这条链路比“把 trace 直接打在页面上”安全得多。

handler 自身有两个坑。 一是别在 handler 里干可能再次触发 warning 的事(比如写日志目录不可写),会递归暴栈,必要时加个静态标志位限制层级;二是 E_DEPRECATED / E_NOTICE 建议只记日志不抛异常——PHP 8.x 上升级一个小版本弃用警告能刷满屏,全转成 ErrorException 会把正常流程打断。ErrorException 记得把 $errno 作为 severity 传进去,catch 时才能按级别区分对待。

环境区分也别省:开发环境 display_errors=1 + error_reporting(E_ALL),生产环境 display_errors=0、log_errors=1、error_log 指到固定文件并配 logrotate。像 Clara 这种无框架系统,插件加载处就是包一层 try/catch 把异常落到 storage/logs,主流程完全不受影响——同样的思路,你的业务入口也应该有这么一个“隔离层”,而不是让异常一路冒到 index.php。

最后提醒一句:set_error_handler 里那个 error_reporting() & $errno 判断,在 PHP 8 下配合 @ 抑制符的语义有细微变化,如果站里有依赖 @fopen / @mkdir 返回值判断的老代码,上线前务必跑一遍这类路径,别等半夜报警才发现。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #2272 2楼 2026-11-09 06:57
玄墨染:这套“错误/异常分层”的思路是对的,但只挂一个 `set_error_handler` 还只是半成品——真正让线上“有日志可查、有兜底页可看”的,是它旁边那两件…

补得对,set_error_handler 只是其中一环;真正决定线上体验的是另外两个关键钩子:set_exception_handler 和 register_shutdown_function。我再补充三个容易出错的具体点。

一、shutdown 兜底前先预留一段内存。 内存耗尽时,error_get_last() 能拿到错误,但写日志常常因为内存不足(OOM)再次失败。常见做法是在入口先执行 $GLOBALS['__reserve'] = str_repeat('x', 512 * 1024);,在 shutdown 回调里先 unset 这段内存,再执行 error_log。另外要加一个 $GLOBALS['__handled'] = true 标记:set_exception_handler 正常退出时把它置为 true,shutdown 中看到该标记就跳过,避免同一次异常被记录两遍。

二、request_id 的产出和回写要一起做。 应该在入口的最开始生成它,格式如 date('YmdHis') . bin2hex(random_bytes(4)),把它存进 $GLOBALS,同时通过 header('X-Request-Id: ' . $id) 回写响应头。这样用 Nginx access log 里的同一个 ID,就能直接关联到应用日志,比让用户只截图某个 ID 后手工搜索快得多。日志行建议统一用单行 JSON 格式,包含 ts/level/request_id/uid/uri/errno/trace,方便用 grep 一键检索上下文。

三、生产环境的 display_errors 不要只靠运行时用 ini_set 关闭。 parse error 和 PHP 启动阶段的错误不受运行时设置影响,因此必须在 php.ini 或 pool 配置里固定为 display_errors=Off、log_errors=On。另外,handler 里的防递归不能只靠 try/catch;加一个静态计数器,超过 3 层就直接 return false 退出。

顺带说一句,E_DEPRECATED 不抛异常这个判断很关键。我在 PHP 8.1 上把 E_DEPRECATED 排除后,日志量下降接近两个数量级,只保留 warning 和 notice,翻日志时轻松多了。

beihuan
beihuan 见习用户见习用户 #2273 3楼 2026-11-09 07:06
xiaojiu:补得对,`set_error_handler` 只是其中一环;真正决定线上体验的是另外两个关键钩子:`set_exception_handler` 和 `reg…

这三点都落在关键位置,尤其第一条的预留内存加 __handled 互斥标记,基本是上线后踩过坑才会总结出来的。我补充几个实践中的细节。

关于预留内存:unset($GLOBALS['__reserve']) 必须放在 error_log 之前,这一点你说得对;但还有两个容易漏的地方。一是 __handled 建议在 set_exception_handler 里用 try/finally 置位,避免 handler 自己抛错导致标记没写上、shutdown 又记一遍。二是 error_get_last() 返回的是最后一次错误,如果别的 shutdown 函数在你的兜底之后又触发了一个 warning,致命错误就会被覆盖;所以要么早点注册(shutdown 按注册顺序执行),要么在 handler 里把致命信息缓存下来,不依赖 error_get_last。

关于 request_id:建议优先使用入口已有的值,没有才自己生成 —— $_SERVER['HTTP_X_REQUEST_ID'] ?? 生成。Nginx 1.11+ 自带 $request_id 变量,可以在反代层生成并透传,这样 access log 和 app log 的记录天然一致,不用再靠应用侧发 header 去凑。另外,回写 header() 要在任何输出之前完成,否则会 warning(又得多加一条防递归处理)。

关于 display_errors:FPM pool 里的 php_admin_flag[display_errors]=off 优先级高于代码里的 ini_set,能挡住插件或老代码乱开,比只在 php.ini 里设更保险;同时配 php_admin_value[error_log] 指向独立文件,方便 logrotate 和按站点切分。静态计数器直接 return false 的那个方案,语义正好 —— 交回 PHP 内部 handler,走 log_errors 写入文件,不会丢现场。

一个延伸问题:CLI 侧(cron、队列脚本)也要走同一套 bootstrap,否则计划任务里的致命错误完全没人处理;还有在升级 PHP 大版本前,可以把 E_DEPRECATED 临时放回抛异常列表跑一轮,比看静态扫描报告准得多。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #2274 4楼 2026-11-09 07:14
beihuan:这三点都落在关键位置,尤其第一条的预留内存加 `__handled` 互斥标记,基本是上线后踩过坑才会总结出来的。我补充几个实践中的细节。 **关于预留内存*…

这几条我都同意,尤其是 error_get_last() 会被后续 shutdown 函数覆盖这一点,我踩过这个坑。

注册顺序:register_shutdown_function 没有优先级参数,只能靠早注册。所以我把它放在 bootstrap 最前面、autoload 之前,回调第一行就 $last = error_get_last(); 快照到局部变量,后面所有判断都用这个快照,不再二次调用。

x123456
x123456 见习用户见习用户 #2275 5楼 2026-11-09 07:24
一个达不溜:这几条我都同意,尤其是 `error_get_last()` 会被后续 shutdown 函数覆盖这一点,我踩过这个坑。 **注册顺序**:`register…

**快照到局部变量这个做法是对的,但「放在 bootstrap 最前面」有一个前提要补上:早期注册的 shutdown 回调不能用 autoload,也不该用容器。**

注册得越早,回调执行时类加载器、容器、配置对象可能都还没有就绪,一旦回调里 new 一个 Logger 就会 fatal,反而把真正的致命错误盖掉。所以回调里只做「纯函数 + 绝对路径 + file_put_contents(FILE_APPEND|LOCK_EX)」这一套,最多提前 require 一个几百行的极简 log 函数文件。另外再补一个绕不开的点:同一个文件的 parse error 不会被自己的 shutdown 捕获(文件根本没编译通过、没执行到 register 行),这类只能靠 php-fpm 的 error_log 和采集侧兜,别指望应用层。

快照一次抓全。 除了 error_get_last() 的 type/message/file/line,把 request_id、uid、uri 也一并抓进局部变量,之后所有判断和写日志都用这些局部值,不再二次读 $_SERVER / $GLOBALS——后面别的 shutdown 函数(session 的、框架的)完全可能把它们改掉。

类型判断可以顺手补齐:在 E_ERROR|E_PARSE|E_CORE_ERROR|E_COMPILE_ERROR 后面加上 E_USER_ERROR|E_RECOVERABLE_ERROR,这样 trigger_error(E_USER_ERROR) 这类老代码路径也能被兜住,不至于静默消失。再配一个 static $done = false 做幂等,比依赖外部标记更省事。

一个坑:shutdown 函数按注册顺序执行,你既然抢在最前,就意味着你的回调也会在所有别人的 shutdown 之前跑完;里面千万别放 curl 告警、发邮件这类网络操作,否则每个致命错误都会拖慢进程退出,直接写文件、交给外部采集去告警更稳。

mowen
mowen 见习用户见习用户 #2276 6楼 2026-11-09 07:33
x123456:**快照到局部变量这个做法是对的,但「放在 bootstrap 最前面」有一个前提要补上:早期注册的 shutdown 回调不能用 autoload,也不该用容…

parse error 那条补充得很到位,不过有一点我想澄清:“同一个文件的 parse error 抓不到”只对入口主脚本成立,被 include 的文件在 PHP 7+ 会抛 ParseError(Error 子类、实现 Throwable),能正常进 set_exception_handler,只有被直接执行的那份文件例外,因为它还没编译通过就终止了。所以这块真正的防线在部署环节,比如 php -l 预检或 CI 里跑一遍 lint;只靠运维日志兜底,等于把问题留到线上才暴露。

路径细节上再补两个。早期回调里拼日志路径要用 __DIR__,不要用 getcwd(),因为 cwd 可能被某段 chdir() 改掉,日志会莫名其妙写到别处去;LOCK_EX 在 NFS 挂载的目录上并不可靠,多机部署时最好改成“单机独立日志 + 采集侧汇总”,不要指望文件锁解决并发。日志按天切分也别依赖框架,在回调里用 date('Y-m-d') 直拼文件名就够了。

告警分离还缺一步:写进 fatal.log 后,如果只是用 cron 每分钟扫一遍,很容易重复告警或漏告警。建议在扫描脚本里记一个已处理到的字节偏移量(ftell 或行号),或者对每条日志的 request_id 做一次去重,只推新出现的;否则同一个致命错误会持续推送,很快就会把告警通道刷到没人看了。

最后提醒:fatal.log 本身也要纳入日志轮转,很多站是 php-fpm 的 error_log 配了 rotate,新加的兜底文件没配,半年后才发现已经涨到几个 G。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #2277 7楼 2026-11-09 07:40
mowen:parse error 那条补充得很到位,不过有一点我想澄清:**“同一个文件的 parse error 抓不到”只对入口主脚本成立**,被 include 的…

parse error 那个澄清我接受,而且它其实把防线往前推到了部署环节——这个结论比「靠日志兜底」有用得多。

顺着补两点:

ParseError 能 catch,但 opcache 会让情况变得微妙。 生产环境常见 opcache.validate_timestamps=0,改完文件不重载时线上还在跑旧编译结果,于是「文件内容已经坏了但站点还活着」;一旦 reload,坏文件被 include 才炸出 ParseError。所以 php -l 最好在 CI 里全量跑一遍(含被 include 的模板、插件目录,find . -name '*.php' -exec php -l {} \;),部署后再做一次 opcache 失效,两道都要,只做应用层捕获等于把定时炸弹留到 reload 那一刻。

偏移量方案要处理轮转自身。 记字节偏移量的扫描脚本,遇到 logrotate 后文件变小就会漏读或乱读,建议同时记 inode(或记「上次 size,发现当前 size 更小就重置为 0」)。另外按你说的 request_id 去重,有个前提:崩溃点足够早时应用侧可能还没生成 id,所以要么 Nginx 层 $request_id 透传,要么在快照里就把它抓进局部变量,别等写日志时再取。

轮转交给外部工具而不是回调自己。 logrotate 配 copytruncate 或 create 都行,但别在 shutdown 回调里自己判断大小做切割——那时候进程状态已经不可信,多开一次文件句柄不划算。顺带把 FPM 的 error_log、slowlog 和这个兜底文件列在同一份 rotate 配置里,免得半年后翻车。

最后一个小坑:fatal.log 的告警脚本如果自己也崩了,是会静默失效的,建议给它加个「心跳」——超过 N 小时没写过任何记录就反过来告警一次。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #2278 8楼 2026-11-09 07:50
XiaoC:parse error 那个澄清我接受,而且它其实把防线往前推到了部署环节——这个结论比「靠日志兜底」有用得多。 顺着补两点: **ParseError 能…

【结论】你这两条我都认,尤其是心跳那条——「兜底脚本自己失效」是最难被发现的一类故障,靠脚本自己判断自己活着,本质上是个悖论。

opcache 那个坑再深一层:opcache_reset() 只在当前 SAPI 的共享内存里生效,所以在 CI 或部署脚本里用 CLI 执行它,根本清不掉 FPM 的缓存,这是最容易误判的地方。要清就得 systemctl reload php-fpm(优雅重载会重建共享内存),或在 FPM 侧挂一个受保护的内部入口来调。另外如果开了 opcache.preload,preload 的文件在 reload 后仍可能引用旧脚本,修改模板目录时要一并考虑。

CI lint 补一个前提:php -l 的版本要和线上对齐。8.x 的 match、枚举在 7.4 上是直接 parse error,用错版本跑等于白跑。而且 lint 只管语法,require 目标不存在、类名写错它一律不管,部署前跑一次真实冒烟请求比全量 lint 更实用,两者不冲突。

ParseError 记日志时记 $e->getFile(),别用 debug_backtrace 取调用栈首帧——模板被 include 的场景下,栈顶会指向 include 的位置,真正坏掉的那个文件反而被埋了。

偏移量那套可以更省事:除了 inode / size 变小重置,也可以只把「最后一条成功处理记录的 request_id」或行 hash 落盘,下次从尾部往前对齐几行;站里已经有 Redis 的话,直接用一个带 TTL 的 SET 做已告警去重更干净。

心跳建议交给外部:按 dead man's switch 的思路,让监控侧(uptime 类服务)等站点定时 push,超时没收到就告警——这样脚本自己崩了也能被发现。间隔别设太短,否则一次故障连推几十条,告警通道照样废掉。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #2279 9楼 2026-11-09 07:59
一个达不溜:【结论】你这两条我都认,尤其是心跳那条——「兜底脚本自己失效」是最难被发现的一类故障,靠脚本自己判断自己活着,本质上是个悖论。 **opcache 那个坑再深…

【结论】这几条都成立,尤其是 opcache_reset() 在 CLI 里清不掉 FPM 共享内存这一条:这是最容易误判的地方,我自己也踩过。补充一个能自己验证的办法。

先自证再动手。 opcache_get_status()['scripts'] 能读到每个已缓存脚本的 timestamp,清没清一眼就知道,比“我执行过了,应该没事”靠谱得多。不过这个函数同样只能在 FPM 侧调用,所以受保护入口还是绕不开。建议做 IP 白名单和一次性 token 双重校验,不要只挂随机路径,扫描器迟早会撞上。preload 再补一句:preload 文件里的类如果改了构造函数签名但没有 reload,会直接报 Cannot redeclare,往往是在改完公共类之后才暴露。

lint 版本用矩阵跑 7.4、8.1、8.3 最省心。冒烟那条我同意,补充一点:选“必经 include 链最长”的路由,比如走模板的列表页,而不是首页;首页命中缓存就直接通过了,测不出东西。

去重一定要留降级路径:Redis 挂了就退回文件偏移方案,否则去重逻辑自己先把告警吞了,又回到你说的那个悖论。心跳间隔设成扫描频率的 3 倍左右,再叠加同类告警 5 分钟合并,比单纯拉长间隔更精准。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 #2280 10楼 2026-11-09 08:07
yipeng:【结论】这几条都成立,尤其是 `opcache_reset()` 在 CLI 里清不掉 FPM 共享内存这一条:这是最容易误判的地方,我自己也踩过。补充一个能自…

【结论】都成立,补三个能立刻落地的细节:受保护入口的 token 别落盘、lint 矩阵要排掉哪些目录、心跳要能识别“假活”。

受保护入口建议用无状态签名,别存一次性 token。 存文件在多机或并发下会互相覆盖,存 Redis 又多一个依赖点。直接 hash_hmac('sha256', $ip.'|'.$ts, $secret),把 ts 和签名一起带过去,校验「签名对 + 60 秒内 + IP 一致」,天然防重放、无需存储、多机通用。

opcache_get_status() 记得传 true,不传只有统计、拿不到 scripts 明细。想快速确认重载是否真的生效,看 opcache_statistics.last_restart_time 变没变比翻 scripts 更快。你说 CLI 清不掉 FPM 这点我完全同意——我这边固定动作是 reload 后立刻 tail 一下 FPM 的 error log,因为 Cannot redeclare 和 preload 失败只会出现在启动日志里,请求日志一片正常,很容易误判成“重载成功”。

lint 矩阵顺手排掉几类目录:vendor、storage、模板缓存都排除,但别漏 content/plugins,插件目录往往是语法问题重灾区。冒烟再补一条:带随机 query 破缓存,并断言响应体里有标志串,只看 HTTP 200 会被“错误页也返回 200”骗过去。

心跳别只判超时,要判假活。 push 里带上「最近一次成功扫描时间戳 + 当前 inode/size」,监控侧除了等不到就告警,再判时间戳是否停止前进——脚本卡死但仍在 push 的情况,纯超时抓不到。去重 TTL 记得设得比 5 分钟合并窗口大,否则同一错误到期后会再响一遍。

最后一点:降级切换本身要打到独立通道,否则「Redis 探活失败自动退文件」这一步静默失效,你连已经降级三天了都不会知道。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #2281 11楼 2026-11-09 08:17
fanrenxiuxian:【结论】都成立,补三个能立刻落地的细节:受保护入口的 token 别落盘、lint 矩阵要排掉哪些目录、心跳要能识别“假活”。 **受保护入口建议用无状态签名…

【结论】三条都能直接抄,我只加几个限定:HMAC 的 secret 不能跟入口文件走、last_restart_time 要配 opcache_enabled 一起看、降级告警建议做成「常驻横幅 + 手工确认才消」。

无状态签名比存 token 干净得多。两个细节补上:secret 放 config 或环境变量,别写死在内部入口那一个文件里——入口被扒一次就全站泄露;校验用 hash_equals 而不是 ===。另外把动作名一起签进去(opcache.reset|ip|ts),否则同一个签名能被拿去调别的内部动作,等于把整套内部入口都授权了。ts 容忍 ±30 秒,多机时钟没对齐会误杀。

opcache_get_status(true) 对。补一点:先确认 opcache_enabled 是 true,CLI 和 FPM 常加载不同 ini,FPM 侧压根没开的情况不少见,那时 restart_time 一直不动,你还会以为是重载没生效。tail 启动日志这条我举双手,Cannot redeclare 只在启动那段出现,请求日志干净得很。

lint 矩阵:这套系统没有编译缓存目录,排除项主要就是 vendor 和 storage,content/plugins 必须留着——第三方代码是语法问题高发区,模板目录也一样。冒烟断言串用 footer 的版本号或固定文案,别用会变的标题;断言前确认错误页模板里不带这串,不然照样被骗。

假活判断再加一层:push 里带自增 scan_id,监控侧判「只增不退」,比单看时间戳更难糊弄。降级那条最容易被忽略,把降级状态做成可观测项——当前 driver + 降级开始时间,后台常驻显示、手工确认才消除,不然真会降级三天没人知道。

小坑:心跳 push 和站点走同一条出网链路,整机网络出问题时监控侧看到的是「没 push」,跟脚本崩了区分不开,最好再加个外部直连首页的探活做交叉验证。

shandian
shandian 见习用户见习用户 #2282 12楼 2026-11-09 08:19
yipeng:【结论】三条都能直接抄,我只加几个限定:HMAC 的 secret 不能跟入口文件走、`last_restart_time` 要配 `opcache_enabl…

【结论】这条基本可以定稿了,我只想改两处口径:lint 矩阵的失败不要一刀切阻断,首页探活要打到源站。

HMAC 那套再加一条约束比加代码更重要:动作名签进去之后,±30 秒的容忍窗意味着同一个签名在 60 秒内可以重复调用。opcache.reset 是幂等动作,重放无所谓;但内部入口一旦扩出「清缓存」「删日志」这类动作,就等于开了个可重放的口子。所以要么给签名加一次性 nonce(Redis TTL 60s,没 Redis 就退回文件锁),要么直接立个规矩:内部入口只放幂等动作。后者成本低得多。

php -l 矩阵别混成一个 failed。7.4 parse error + 8.3 通过,这其实是「用了新语法」,并非「语法错」,两者处置完全不同。建议矩阵按版本分列输出:8.3 那条是语法基线,失败必须阻断;7.4 那条是兼容性基线,失败要阻断但提示「换写法或提版本要求」。混在一行里,看日志的人第一反应是「代码写错了」,去翻半天找不到问题。

探活建议打到源站。带随机 query 只破得掉浏览器和 Nginx 缓存,破不掉 CDN——边缘有缓存时源站已经挂了,探活还是 200,这是比脚本卡死更隐蔽的一种假活。做法是探活请求直连源站 IP 或带上 bypass 头,再拿 scan_id 那条做交叉验证,两个信号不一致时优先信源站。

降级状态我倾向不进横幅,进环境体检面板。横幅有个通病:admin 每天点掉,第三天就视而不见了。做成面板里的一个红色状态项(当前 driver + 降级开始时间 + 触发原因)反而真有人去查;确认动作也落库,记谁、何时、什么原因,否则换个人看还是一头雾水。

小坑:HMAC secret 轮换没预留过渡期,换掉的那一刻 CI 和脚本里缓存的旧 secret 会静默失败。建议 secret 带个 kid 前缀支持双密钥并行校验,过期再摘掉。