一个主题预览链接,把站点点成了 502

共 2,676 字 约 9 分钟 61 次阅读

带 ?theme= 的预览链接必 502,病灶却不在主题:emlog 核心的 getCurrentTemplate() 每次被调用都无条件 setcookie(),首页一次渲染能堆出几百条重复响应头,把网关的头缓冲区撑穿。本文记录定位过程、4 行补丁、本机仿真与公网复测数据,以及一个必须记住的前提——emlog 一升级,补丁就没了。

一、现象:只有预览链接会炸

站点跑在 emlog 上,主题做了几套(lcbk-stationery、lcbk-memo 等),预览走的是 emlog 自带的 ?theme= 参数:带它访问一次,主题名就写进 Cookie,之后刷新继续生效。不开后台也能挨套看效果,很顺手。

代价是这样访问会 502:

代码2 行·
https://em.lczld.cn/?theme=lcbk-stationery              → 502 Bad Gateway
https://em.lczld.cn/essay/1.html?theme=lcbk-stationery  → 502 Bad Gateway

三个扎眼的特征:

  • 首页必炸,文章页偶尔能过;
  • 不带 ?theme= 的访问全都好,这站点之前跑了很久没出过事;
  • 502 是 OpenResty 给的,PHP 层没有异常,日志里干干净净。

前两条就把范围锁死了:只有走进「设置 Cookie」那条分支的请求会出事,而且页面里 _g() 被调得越多,越容易炸。

绕开网关直接看上游响应头,病灶一眼可见:

代码2 行·
$ curl -s -D - -o /dev/null "https://em.lczld.cn/?theme=lcbk-stationery" | grep -c 'Set-Cookie: emlog_theme'
200

一次请求,200 条一模一样的 Set-Cookie: emlog_theme=lcbk-stationery。

几百行重复头,体积几十 KB。浏览器未必在乎——值都一样,后一条覆盖前一条就是。可中间的 OpenResty 在乎:它要把上游响应头读进缓冲区,而 proxy_buffer_size 是按 KB 配的,几十 KB 的头直接把缓冲区顶穿,网关判定上游响应非法,转头给客户端一个 502。

这也一次解释完了三条现象:首页 _g() 调用最多,头堆得最狠,稳定越过阈值;文章页调得少,头数卡在阈值线附近,所以「偶尔能过」;不带 ?theme= 根本进不了那个分支,一条不发,自然永远没事。

三、源码:getCurrentTemplate() 为什么会被调用 200 次

include/service/template.php,主题名校验合法之后无条件写 Cookie(改前的 59–63 行):

PHP5 行·
// 仅由字母、数字、下划线或连字符组成的字符串
if ($theme && preg_match('/^[\w\-]+$/', $theme)) {
    setcookie($themeCookieName, $theme, time() + 86400, '/');
    return $theme;
}

坑在于这个函数的定位:它不是「每请求执行一次」的初始化逻辑,而是挂在取值函数 _g() 的热路径上。emlog 模板里到处在用 _g() 取变量,取到主题相关那几项就会走进 getCurrentTemplate();首页一次渲染,这条路径大约走 200 次。

而 setcookie() 的语义是往响应的 header 列表里追加一条——它不检查,也没法检查「这条头是不是已经发过」。200 次调用,就是 200 行头。

改法很朴素:同一个值只发一次。

PHP8 行·
// 仅由字母、数字、下划线或连字符组成的字符串
if ($theme && preg_match('/^[\w\-]+$/', $theme)) {
    if (($_COOKIE[$themeCookieName] ?? '') !== $theme) {
        setcookie($themeCookieName, $theme, time() + 86400, '/');
        $_COOKIE[$themeCookieName] = $theme;
    }
    return $theme;
}

$_COOKIE[$themeCookieName] = $theme; 这一行是命门。删掉它,补丁等于没打。

因为 setcookie() 只写响应头,不回写本次请求的 $_COOKIE 超全局。只加 if 而不回写,那么第一次进分支时 $_COOKIE 是空的,发一条 Cookie;第二次、第三次……第 200 次进来,$_COOKIE 依然是空的,条件依然成立,照样发几百条——那个 if 看着完全没起作用。补上手写回写,同一次渲染里后续的 _g() 才看得见「刚设过」。这是 PHP 里「写响应头」和「写本请求输入变量」彻底分离带来的经典坑。

另外一句:($_COOKIE[...] ?? '') 用了空合并运算符,需要 PHP 7+。生产环境是 8.2,无碍;还停在 5.6 的老站请换成 isset() 判断的写法。

五、仿真验证:先算账,再上线

补丁只该动 Cookie 的发送次数,不该动任何功能。上线前在本机 PHP 跑一遍三种典型场景,数清每种场景实际发出多少条 Set-Cookie:

场景旧逻辑打补丁后
首次访问,一次首页读 200 次200 条1 条
第二次访问(Cookie 已是该主题)200 条0 条
从 stationery 切到 memo200 条1 条

三行对应三种语义,也正好是期望的语义:该发的时候恰好发一条,不该发的时候一条不发。 主题切换记忆、24 小时有效期(time() + 86400)都还在。

六、上线与公网复测

1Panel 环境下的操作路径:

  1. 网站 → 站点 → 点「网站目录」旁的文件夹图标进文件管理,打开 include/service/template.php;
  2. 先在同目录复制一份 template.php.bak;
  3. 只替换上面那一段,保存;
  4. 回容器列表重启 emlog 容器。

第 4 步别省。OPcache 会把编译好的旧字节码留在内存里,只改文件不重启,curl 出来的还是老行为,很容易误判成「补丁无效」,然后回头去怀疑那 4 行代码本身。

复测脚本:

Bash5 行·
for u in "/?theme=lcbk-stationery" "/essay/1.html?theme=lcbk-stationery"; do
  printf "%-42s " "$u"
  curl -s -m 25 -D - -o /dev/null "https://em.lczld.cn$u" \
    | awk 'BEGIN{c=0} /^HTTP/{s=$2} /^[Ss]et-[Cc]ookie: emlog_theme/{c++} END{printf "status=%s cookies=%s\n",s,c}'
done

期望两行都是 status=200 cookies=1,第二次访问起 cookies=0。实测结果:

请求之前现在
/?theme=lcbk-stationery502200,is-home has-home-hero 正常渲染
同页 Set-Cookie 条数数百条(撑爆缓冲)1 条 / 101 字节
带 Cookie 再刷首页—0 条(切换记忆正常)
?theme=lcbk-memo 再进首页—1 条,渲染成 memo(body class="bg-auto")

顺带扫了一遍老链接做回归:/、/essay/22.html、/page/2、/essay、/guestbook.html、/rss.php 全部 200,正文里没有任何 Warning: 或 Fatal error: 泄漏——说明那 4 行改得干净。未安装的别名 /?theme=lcbk-cardgrid 仍旧正常回「模板已被删除或损坏」的提示页,切换逻辑没被改坏。

附带收益:每个访客少下载几十 KB 响应头。

至于当初为这个 502 在 OpenResty 里加的两行 proxy_buffer_size,留着当保险就行,不用再动——它治的是症状,补丁治的是病因,两层各留各的。

七、这个补丁的保质期:一次 emlog 升级

include/service/template.php 是核心文件,emlog 后台一升级就会把它覆盖回去,502 原地复活。

升级后跑这一行自查,结果是 0 或 1 说明补丁还在;几十、上百,说明被冲掉了,重打:

Bash2 行·
curl -s -D - -o /dev/null "https://em.lczld.cn/?theme=lcbk-stationery" \
  | grep -c 'Set-Cookie: emlog_theme'

另一台宝塔上的站点用的是同一份核心代码,毛病一模一样,只是前面没有那层会被撑穿的网关缓冲,于是几百条重复头一直默默地发给每一个访客——同一个补丁顺手打上,没有理由省这几十 KB。

八、往上游推

值得说清楚的是:这不是主题的问题,是 emlog 核心的问题。

主题作者能做的只是给自家站点打补丁,而且每次升级都要重打。一劳永逸靠上游:对调用方来说,getCurrentTemplate() 是一次「读取」,而读取不该带写 Cookie 的副作用;即使要写,也该像上面那样先比对、再回写 $_COOKIE。

这条准备报给官方:「getCurrentTemplate() 无条件 setcookie,导致每次 _g() 都重发一条 Cookie,首页渲染可累积数百条响应头并触发网关 502」。复现成本是一个链接,影响面是所有用预览功能的站点——这种 bug 进下个版本的机会不小。那时就不用再手动重打了。

带走几条

  • 502 未必是后端挂了,也可能是响应头太大。网关报错、PHP 无异常、日志干净,是这类问题的标准长相。
  • setcookie() 不回写 $_COOKIE。少了那行回写,「先比对再发」的判断在同一次请求里永远不会成立。
  • 改动前先本机数一遍副作用的量。三行实测数据比一段推理可信。
  • 改核心文件要留 .bak,改完要重启清 OPcache,否则会把「没生效」误判成「补丁错了」。
  • 给上游打的补丁都有保质期,升级即失效:配一行自查命令,同时往上游推。
游客评论

分享海报