一条 URL,把首页打成了 502
那天我打开
https://em.lczld.cn/?theme=lcbk-stationery,首页直接
502;把这段参数删掉再访问,一切正常,文章页多刷几次也能偶尔挤进去。直觉是网关扛不住了,直到我把响应头单独数了一遍:
curl -s -D - -o /dev/null "https://em.lczld.cn/?theme=lcbk-stationery" | grep -c 'Set-Cookie: emlog_theme'它回了 200——一次请求,200 条一模一样的
Set-Cookie。响应头被自己撑爆,网关读不完后端返回的东西,只能甩一个
502。抢修过程上次写过,这篇只讲一件事:?theme= 之后,emlog
到底是怎么选中模板的。
模板不是渲染时才选的
很多人以为,页面渲染到 header 时才决定用哪套模板。emlog
不这么干——它在「谁来处理这个请求」刚定下来、业务逻辑还没跑的阶段,就用
init.php 里两行代码把模板名字固定成两个常量:
// init.php
define('TEMPLATE_URL', TPLS_URL . Template::getCurrentTemplate() . '/');
define('TEMPLATE_PATH', TPLS_PATH . Template::getCurrentTemplate() . '/');一个是网址(拼 CSS、JS、图片),一个是磁盘路径(include
模板文件)。此后整条渲染链只读这两个常量,不再回头重判——选一次,用一路。注意:一个请求里,getCurrentTemplate()
被调用了两次,先记住这个数字。
模板本身的约定也很硬:一套模板就是一个目录,目录名即它的 ID(如
default、lcbk-stationery),只允许字母、数字、下划线、连字符(正则
[\w\-])。目录里必须有 header.php,否则 emlog
根本不认它是模板。后台切模板的真正动作也只有一步:把配置项
nonce_templet 改成新名字,再刷新配置缓存。
<h1
id="getcurrenttemplate四个出口一处隐患">getCurrentTemplate():四个出口,一处隐患
决策全在 include/service/template.php 的
getCurrentTemplate() 里,它有四个出口:
// include/service/template.php(结构示意)
if (!defined('SWITCH_TEMPLATE') || SWITCH_TEMPLATE !== true) {
return Option::get('nonce_templet'); // 出口一
}
$theme = Input::getStrVar('theme');
if ($theme && preg_match('/^[\w\-]+$/', $theme)) {
setcookie('emlog_theme', $theme, time() + 86400, '/');
return $theme; // 出口二
}
if (isset($_COOKIE['emlog_theme']) && preg_match('/^[\w\-]+$/', $_COOKIE['emlog_theme'])) {
return $_COOKIE['emlog_theme']; // 出口三
}
return Option::get('nonce_templet'); // 出口四说白了:出口一和出口四都是「回到后台设定的模板」,出口二是「带参就写
Cookie 并采用」,出口三是「读上次的
Cookie」。盯住出口二——它没有任何「本请求是否已经写过
Cookie」的判断,是无条件的。而 setcookie()
不是「写状态」,是「往响应头追加一行」:一次请求调它多少次,响应头里就有多少条
Set-Cookie。
200 条 Set-Cookie,是这么数出来的
首页为什么把同一个出口踩了 200 次?因为主题里到处在用取设置的助手函数
_g():
function _g($name) {
return TplOptions::getInstance()->$name;
}它最终走进取模板配置的函数。这个函数有缓存(配置只从文件读一次),但缓存挡不住重复解析:它必须先用
Template::getCurrentTemplate()
拿到模板名,再去查内存里的
$this->_templateOptions[$template]。你得先知道「当前模板叫什么」,才能拿这个名字去查缓存——于是「问名字」永远发生在「查缓存」之前,一次也省不掉。
首页 _g() 调得越多,getCurrentTemplate()
被拉起越多;每一次又恰好撞上带 ?theme=
的请求,于是每次都写一条 Set-Cookie。算平账:init 固定 2
次,加主题里 _g() 的 N 次,就是
200 = 2 + N——那个首页模板渲染时大约问了 198
次「当前模板是谁」。这也解释了另外两个怪现象:文章页 _g()
调得少、头没超缓冲区,所以「偶尔能过」;不带 ?theme=
走出口三、压根不写 Cookie,所以「全好」。
它会记住 24 小时,但只在带参数时炸
?theme= 会在浏览器里留下 24 小时的记忆(Cookie 有效期
time() + 86400)——这段时间不带参数再打开,站还是那套模板。但
Set-Cookie
风暴只在带参数那一次请求上发生:只有出口二会写,出口三只读不写。
再往前一步,这套机制默认是关着的,要你在 config.php
里写下 const SWITCH_TEMPLATE = true;
才生效。官方把它写成「方便开发者在一个站点演示多套主题」——它给开发场景准备,不给线上常态。这也正是「为什么偏偏是你的站会炸」的第一环:没打开它,出口二永远进不去。顺带一提,emlog_theme
只有名字、有效期和路径,没有 HttpOnly、没有 Secure、也没有
SameSite。
防线都建在别处
也不是没有兜底:别名被限制成
[\w\-]+,..、/
进不来;模板被删或损坏时,页面会提示「当前使用的模板已被删除或损坏,请登录后台更换其他模板。」;渲染期取文件时还会再拦一次
..。但这些防线都在管「别名合不合法」「文件在不在」。
这次翻车的根源不在那儿——别名合法、文件完好,问题出在「同一个合法动作被重复执行了 200 次」,这不是靠校验能拦住的错。而且这个函数从 pro-2.5.26 到现在一脉相承,不是新引入的回归。
五条事后才想明白的道理
一、一个「贴心」的开关,可能就是把内部路径暴露给线上的钥匙。
SWITCH_TEMPLATE
本意是给开发者预览,一旦打开,?theme=
就成了任何访客都能用的入口。
二、没有请求级记忆的解析就是隐患。 init
明明已把结果固化成常量,问题偏偏出在 _g()
绕过了常量、每次都要回函数里再问一遍。
三、setcookie()
是「写响应头」,不是「写状态」。
在会被反复走到的路径上写响应头,等于埋了一个响应头膨胀的引信。
四、主题层和核心层的耦合会放大每一处疏忽。 主题里
_g() 调得越多,核心的解析函数就被拉起越多。
五、网关缓冲区是兜底,不是根治。 调大只是让爆炸阈值来得晚一点;真正的修法是让这件事一开始就不该发生。
带走几条
- 记住这个开关:
const SWITCH_TEMPLATE = true;是?theme=的总闸,线上别随手开。 - 排查 502 别急着调
buffer,先数响应头:
curl -s -D - -o /dev/null "https://em.lczld.cn/?theme=lcbk-stationery" | grep -c 'Set-Cookie'。 setcookie()只应发一次,给它加一个「本请求是否已发过」的开关,是这次最直接的修法。- 模板名解析值得做请求级记忆——既然 init 已把结果固化成常量,取值就该走常量。
- 凡是「只在本地调试才用得上」的功能,都值得在配置里留一个默认关闭的开关。
一条 URL 参数能把首页打成 502,说到底是「一个正常动作被重复执行」加上「重复会被放大成响应头」。拆开看,问题就不再神秘了。