带 ?theme= 的预览链接必 502,病灶却不在主题:emlog 核心的 getCurrentTemplate() 每次被调用都无条件 setcookie(),首页一次渲染能堆出几百条重复响应头,把网关的头缓冲区撑穿。本文记录定位过程、4 行补丁、本机仿真与公网复测数据,以及一个必须记住的前提——emlog 一升级,补丁就没了。
一、现象:只有预览链接会炸
站点跑在 emlog 上,主题做了几套(lcbk-stationery、lcbk-memo 等),预览走的是 emlog 自带的 ?theme= 参数:带它访问一次,主题名就写进 Cookie,之后刷新继续生效。不开后台也能挨套看效果,很顺手。
代价是这样访问会 502:
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() 被调得越多,越容易炸。
二、第一现场:响应头里几百条 Set-Cookie
绕开网关直接看上游响应头,病灶一眼可见:
$ 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 行):
// 仅由字母、数字、下划线或连字符组成的字符串
if ($theme && preg_match('/^[\w\-]+$/', $theme)) {
setcookie($themeCookieName, $theme, time() + 86400, '/');
return $theme;
}坑在于这个函数的定位:它不是「每请求执行一次」的初始化逻辑,而是挂在取值函数 _g() 的热路径上。emlog 模板里到处在用 _g() 取变量,取到主题相关那几项就会走进 getCurrentTemplate();首页一次渲染,这条路径大约走 200 次。
而 setcookie() 的语义是往响应的 header 列表里追加一条——它不检查,也没法检查「这条头是不是已经发过」。200 次调用,就是 200 行头。
四、补丁:4 行,命门是 $_COOKIE 回写
改法很朴素:同一个值只发一次。
// 仅由字母、数字、下划线或连字符组成的字符串
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 切到 memo | 200 条 | 1 条 |
三行对应三种语义,也正好是期望的语义:该发的时候恰好发一条,不该发的时候一条不发。 主题切换记忆、24 小时有效期(time() + 86400)都还在。
六、上线与公网复测
1Panel 环境下的操作路径:
- 网站 → 站点 → 点「网站目录」旁的文件夹图标进文件管理,打开
include/service/template.php; - 先在同目录复制一份
template.php.bak; - 只替换上面那一段,保存;
- 回容器列表重启 emlog 容器。
第 4 步别省。OPcache 会把编译好的旧字节码留在内存里,只改文件不重启,curl 出来的还是老行为,很容易误判成「补丁无效」,然后回头去怀疑那 4 行代码本身。
复测脚本:
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-stationery | 502 | 200,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 说明补丁还在;几十、上百,说明被冲掉了,重打:
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,否则会把「没生效」误判成「补丁错了」。 - 给上游打的补丁都有保质期,升级即失效:配一行自查命令,同时往上游推。