目 录CONTENT

文章目录

一个被缓存一年的 500:Halo 后台 useRouteQuery 排查记录

星廿
2026-07-27 / 0 评论 / 0 点赞 / 0 阅读 / 0 字
温馨提示:
部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

摘要

Halo 后台大量页面突然白屏,浏览器控制台报 TypeError: Q is not a function

根因是:某次 Halo 短暂异常期间,一个前端共享库文件返回了 HTTP 500,而这个 500 响应自带一年的缓存头,被 nginx 老老实实存进磁盘。之后 Halo 完全恢复正常,nginx 却仍在重放那个 500,导致 @vueuse/router 的导出始终挂不上全局对象。

整个过程中最耗时的不是定位缓存,而是从错误的假设出发做了一次数据库手改,把单一故障变成了两个叠加故障

环境:Halo 2.25.4(docker compose 部署)、nginx(宝塔面板)、MySQL、Cloudflare 仅 DNS 解析未开代理。

时间线

按实际发生顺序,而不是按排查顺序:

  1. 使用 AI Foundation 插件的生图功能,功能正常
  2. 过了一会儿,后台页面开始白屏,控制台报错
  3. 因为插件管理页面本身也打不开,怀疑是插件问题
  4. 尝试手动修改数据库里的插件记录 → Halo 彻底启动不了
  5. 删掉手改的坏记录 → Halo 恢复启动,但白屏依然存在
  6. 继续排查 → 定位到 nginx 缓存了一个 500 响应
  7. 删除该缓存条目 → 白屏消失

第 4 步是个岔路。它引入的启动失败问题掩盖了原始的白屏问题,而且因为「改完就起不来了」这个强因果,很容易让人以为白屏也是它造成的。实际上第 5 步已经证明两者独立。

白屏的直接原因

从压缩变量名反推

报错是这样的:

vue.70247c20.js:5 TypeError: Q is not a function
    at setup (PostList-rieik0Qb.js:1:9747)

受影响页面很广:PostList、SinglePageList、CommentList、AttachmentList、PluginList、UserList、SystemSettings、Backups、Menus、AppStore,以及 links-list 等。

Q 是压缩后的变量名。在 Network 面板搜索 useRouteQuery 命中两个文件,抓下来看关键行:

// use-plugin-CQ09IEmZ.js
p = window.VueUse.useRouteQuery,
m = window.Vue.computed,
h = window.Vue.defineAsyncComponent

// PluginList-B8jc7Ilf.js
Q = window.VueUse.useRouteQuery,
zt = window.Vue.computed,
Bt = window.Vue.provide

Q 就是 window.VueUse.useRouteQueryQ is not a function 等价于这个全局属性是 undefined

浏览器侧实测

运行时状态就是真相本身,直接问浏览器比在服务器上猜有效得多:

console.log('has useRouteQuery:', typeof window.VueUse?.useRouteQuery);
console.log('VueUse keys count:', Object.keys(window.VueUse || {}).length);
console.log('route-related keys:', Object.keys(window.VueUse || {}).filter(k => /route/i.test(k)));
console.log('useStorage:', typeof window.VueUse?.useStorage);
console.log('OnClickOutside:', typeof window.VueUse?.OnClickOutside);

输出:

has useRouteQuery: undefined
VueUse keys count: 355
route-related keys: []
useStorage: function
OnClickOutside: object

信息量很大:window.VueUse 有 355 个键,不是空壳;useStorage 正常说明 @vueuse/core 生效;OnClickOutside 正常说明 @vueuse/components 生效;唯独所有带 route 的键一个不剩。

为什么只缺 router

查 Halo 官方源码 ui/src/vite/library-external.ts,externals 映射是:

全局变量
vue Vue
vue-router VueRouter
pinia Pinia
axios axios
@vueuse/core VueUse
@vueuse/components VueUse
@vueuse/router VueUse
@halo-dev/components HaloComponents
@halo-dev/ui-shared HaloUiShared

三个 vueuse 包共用同一个全局 VueUse。它们是三个独立 JS 文件,靠 this.VueUse = this.VueUse || {} 增量挂载到同一对象上:

// vueuse.router.9f3511fd.js 的尾部
V.useRouteHash = x,
V.useRouteParams = M,
V.useRouteQuery = D
})(this.VueUse = this.VueUse || {}, VueUse, Vue, VueRouter);

任何一份加载失败,只缺它那部分导出,其余照常工作。355 个键齐全而 useRouteQuery 缺失,完全符合「router 那份没加载成功」。

定位失败的文件

列出页面实际请求的所有 JS:

performance.getEntriesByType('resource')
  .filter(r => r.name.endsWith('.js'))
  .forEach(r => console.log(r.name));

四份 vueuse 文件都在列表里:

/ui-assets/vueuse/vueuse.shared.b6fe7a13.js
/ui-assets/vueuse/vueuse.components.0dbb3027.js
/ui-assets/vueuse/vueuse.core.fae06cf6.js
/ui-assets/vueuse/vueuse.router.9f3511fd.js   ← 请求发出了

performance 里有记录只代表请求发生过,不代表响应有效。 逐个 curl 才看出问题:

router: HTTP 500 size=280
core:   HTTP 200 size=39395
shared: HTTP 200 size=7901
comp:   HTTP 200 size=11785

这个 500 是缓存重放

响应内容与头:

{
  "detail": "服务器内部发生错误,请稍候再试。",
  "instance": "https://halo.wyong.fun/ui-assets/vueuse/vueuse.router.9f3511fd.js",
  "status": 500,
  "title": "服务器内部错误",
  "type": "about:blank",
  "requestId": "41396617-14974",
  "timestamp": "2026-07-26T12:50:59.370505819Z"
}
HTTP/1.1 500 Internal Server Error
Server: nginx
Date: Mon, 27 Jul 2026 06:11:06 GMT
Content-Type: application/problem+json
Last-Modified: Wed, 24 Jun 2026 10:05:25 GMT
Cache-Control: max-age=31536000

三个细节同时指向缓存重放:

  • application/problem+json 是 Spring 的 ProblemDetail 格式,nginx 不会自己生成,说明请求确实到过 Halo
  • 响应体 timestamp7 月 26 日,响应头 Date7 月 27 日,昨天的内容今天原样发回
  • Cache-Control: max-age=31536000,一年

两个对照实验确认文件本身没问题:

# 绕过 nginx 直连容器
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
  http://127.0.0.1:8658/ui-assets/vueuse/vueuse.router.9f3511fd.js
# → 200 2041

给 URL 加 ?v=1,浏览器里能正常拿到完整 JS。缓存键是完整 URL 含 query,换 query 就 miss、回源、正常。

找到缓存层

站点 server 段是纯 proxy_pass,没有任何缓存指令,nginx.conf 里也搜不到 proxy_cache_path。一度因此误判「没有 nginx 缓存」。

实际上宝塔把缓存配置放在单独文件,并在 http 块被 include:

grep -rn "proxy_cache\|proxy_store\|expires" \
  /www/server/nginx/conf/ /www/server/panel/vhost/nginx/

/www/server/nginx/conf/proxy.conf

proxy_temp_path /www/server/nginx/proxy_temp_dir;
proxy_cache_path /www/server/nginx/proxy_cache_dir levels=1:2 keys_zone=cache_one:20m inactive=1d max_size=5g;
# ...
proxy_next_upstream error timeout invalid_header http_500 http_503 http_404;
proxy_cache cache_one;

nginx.conf 第 27 行 include proxy.conf; 在 http 块,所以所有 server 都继承了缓存,即使站点配置里一个字没写。

而且没有任何 proxy_cache_valid。这种情况下 nginx 完全按上游响应头决定缓存时长——上游给了 max-age=31536000,它就存一年,不管状态码是 200 还是 500。

挖出缓存条目

grep -rl "vueuse.router" /www/server/nginx/proxy_cache_dir
/www/server/nginx/proxy_cache_dir/e/a1/fc9e8d009ef9e1f6ddf2d00471e96a1e
mtime: 2026-07-26 20:50:59 +0800   size=887

KEY: http://127.0.0.1:8658/ui-assets/vueuse/vueuse.router.9f3511fd.js
HTTP/1.0 500 Internal Server Error
Last-Modified: Wed, 24 Jun 2026 10:05:25 GMT
Cache-Control: max-age=31536000
Content-Type: application/problem+json

mtime 20:50:59 +0800 与响应体 UTC 12:50:59Z 是同一时刻。还剩 364 天才过期。

根因链

某次 Halo 短暂异常(触发原因未最终确认,见下节)
  ↓
2026-07-26 20:50:59,浏览器请求 vueuse.router.9f3511fd.js
打进异常状态的 Halo,返回 500 ProblemDetail
  ↓
该 500 因匹配 /ui-assets/ 长缓存规则,带上 Cache-Control: max-age=31536000
  ↓
nginx 在 http 层启用 proxy_cache 且无 proxy_cache_valid,照上游头缓存该 500 一年
  ↓
Halo 恢复正常后,nginx 仍从磁盘重放这个 500
  ↓
@vueuse/router 的导出未挂载到 window.VueUse
  ↓
useRouteQuery 为 undefined
  ↓
所有依赖它的后台页面在 setup 阶段抛错 → 白屏

关键特征是故障的因和果在时间上是分离的。Halo 只异常了很短一段时间,但那一刻产生的错误响应被固化下来,之后一年都在生效。

触发那次异常的原因未确认

白屏最早出现在使用 AI Foundation 插件生图功能之后不久。生图是重负载操作,可能引发 Halo 短暂无响应、GC 停顿或重启,恰好在那个窗口内该静态资源请求拿到了 500。

但这只是时间相关性,没有验证。当时的 Halo 日志没有保留检查,requestId: 41396617-14974 也无从追溯。所以「AI Foundation 生图导致 Halo 异常」目前是合理推测而非结论。

如果要复现验证,方向是:观察生图期间容器的内存占用与重启记录(docker statsdocker inspect --format '{{.RestartCount}}' <容器>),以及该时段 Halo 日志里是否有 OOM 或异常堆栈。

解决方案

一、清除被污染的缓存条目

rm -f /www/server/nginx/proxy_cache_dir/e/a1/fc9e8d009ef9e1f6ddf2d00471e96a1e

删除后白屏立即消失。缓存文件是可重建的派生数据,删了只多一次回源。

不要清空整个 proxy_cache_dir——那会连带清掉数百条健康条目,带来一波回源压力。按 URL 精准定位。

二、配置层根治

proxy.conf 追加一行,让错误响应永不入缓存:

proxy_cache_valid 500 502 503 504 404 0s;

操作流程:

# 备份
cp -a /www/server/nginx/conf/proxy.conf \
      /www/server/nginx/conf/proxy.conf.bak.$(date +%Y%m%d%H%M%S)

# 追加
printf 'proxy_cache_valid 500 502 503 504 404 0s;\n' \
  >> /www/server/nginx/conf/proxy.conf

# 校验
/www/server/nginx/sbin/nginx -t

# 平滑重载
/www/server/nginx/sbin/nginx -s reload

两点注意:

  • proxy.conf 在 http 块 include,这条规则对该 nginx 代理的所有站点生效
  • proxy_cache_valid ... 0s 只阻止新的错误响应入缓存,不回溯清理已有条目,必须配合第一步

三、扫描历史残留

for f in $(find /www/server/nginx/proxy_cache_dir -type f); do
  s=$(head -c 2000 "$f" | grep -a -m1 -o 'HTTP/1\.[01] [0-9]\{3\}' | awk '{print $2}')
  if [ -n "$s" ] && [ "$s" != "200" ]; then
    u=$(head -c 2000 "$f" | grep -a -m1 -o 'KEY: [^ ]*' | cut -d' ' -f2)
    echo "$s  $f  $u"
  fi
done

输出 状态码 缓存文件路径 URL。4xx/5xx 陈旧错误响应该删,301/302/304 属正常缓存应保留。

插曲:手改 extensions 表导致 Halo 起不来

这部分和白屏无关,是排查过程中因为错误假设自己制造的第二个故障。单独记录,因为这个坑本身很典型。

起因:白屏时插件管理页也打不开,于是怀疑插件出了问题,想直接改数据库里的插件记录。

做了什么:Halo 的 extensions 表存扩展与插件元数据:

CREATE TABLE extensions (
  name    varchar(255) NOT NULL,
  data    longblob,
  version bigint(20),
  PRIMARY KEY (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

data 字段本应是原始 JSON。但它是 longblob,很多客户端查看时会以 base64 呈现。照着看到的内容写回去,就变成了双重编码——存进去的是 JSON 的 base64 字符串。

排查时看到内容以 eyJzcGVj 开头,base64 解码正好是 {"spec":,确认了这一点:

-- data 是 longblob,直接用 JSON 函数会报
-- Cannot create a JSON value from a string with CHARACTER SET 'binary'
SELECT JSON_VALID(CONVERT(FROM_BASE64(data) USING utf8mb4))
FROM extensions WHERE name = 'xxx';
-- → 1,说明 base64 还原后是合法 JSON,长度 2412 → 1809

后果:Halo 启动时 schemeInitializer 会初始化 Plugin 索引,逐条反序列化 extensions 记录。Jackson 拿到 base64 字符串解析失败,异常上抛,Spring 应用上下文启动中止,容器 Exited(1)

恢复:删掉手改的那条记录后正常启动。但白屏还在——这恰好证明了两个问题相互独立。

几点提示:

  • 改 Halo 的 extensions 表前先确认字段编码。blob 字段在客户端显示为 base64 是呈现方式,不是存储内容
  • 排查中发现另一条记录也是 base64,但删掉第一条后服务就恢复了。说明并非所有坏记录都会阻断启动,可能与初始化遍历顺序或插件是否启用有关
  • 更重要的是:在没有确诊之前不要动数据库。当时「插件页打不开」被当成了「插件坏了」的证据,但那只是白屏的一个表现而已

踩过的坑

grep "HTTP/1.1 500" 返回 0 条

在缓存目录搜 500 响应,精确匹配 HTTP/1.1 500,结果 0 条,一度判定「缓存里根本没有 500」。

实际那条坏条目的状态行是 HTTP/1.0 500。nginx 缓存文件里存的是上游返回的原始状态行,协议版本取决于上游。

匹配 HTTP 状态行时用 HTTP/1\.[01] 或只匹配状态码。一个过于精确的 grep 比没有 grep 更危险——它给你一个确定的错误答案。

/ui-assets/ 不是插件路径

看到 use-plugin-CQ09IEmZ.jsPluginList-B8jc7Ilf.js 这些文件名,很容易以为是插件资源。

实际上 /ui-assets/console 主体的构建产物路径。插件前端资源走 /plugins/{name}/assets/ 和聚合的 /apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js。文件名里有 plugin 只是因为那是「插件管理页面」,属于原生功能。

命令折行把 nginx -t 变成了启动命令

执行长命令链时终端折行,-t 被拆成独立 token:

/www/server/nginx/sbin/nginx
  -t && ...

实际执行的是nginx,即启动第二个实例:

nginx: [emerg] bind() to 0.0.0.0:443 failed (98: Address already in use)
...
nginx: [emerg] still could not bind()
-bash: -t: command not found

最后一行 -bash: -t: command not found 才是真相——不是配置有问题,是命令被拆了。

这次没造成损失(现有 nginx 占着端口,新实例起不来),但若某端口恰好空闲,就可能起来一个配置不一致的第二实例。改配置这类操作分步单独执行,别拼长 && 链。另外 && 链里 printf 排在 nginx -t 前面,意味着校验失败时文件也已经改了。

容器里没有 unzip / jar / python

想从 jar 里翻 console 静态资源时才发现,Halo 官方镜像很精简,unzipjarpython 都没有,java 也因 jdk.compiler 不在 boot layer 而无法用单文件源码模式。

在容器里做取证前先确认工具链。宿主机侧用 unzip -p ... | grep 管道更好——不落临时文件,也不用清理。

无痕窗口能排除浏览器缓存,但不能定位服务端缓存

用无痕窗口验证仍然白屏,说明缓存不在客户端,这个信号有用。但 ?v=1 能正常访问并不能证明缓存在客户端——缓存键含 query,任何按 URL 缓存的组件都有这个特征。

判断缓存层位置最有效的办法是逐层对照:直连上游、经代理、带不同 query,三组结果一比就清楚。

走错的三个方向

都看起来很合理,值得记一下:

假设一:某插件用 UMD 打包,把 window.VueUse 整体顶掉了。
扫描全部 47 个插件 jar 搜对 .VueUse 的赋值语句,零命中,排除。

假设二:console 的 shared 库构建时漏打了 @vueuse/router
查官方 library-external.ts 后发现映射完全正确,且 vueuse.router.*.js 确实作为独立文件产出,排除。

假设三:禁用 PluginLinks 和 app-store-integration 试试。
报错里有 links-listlink-feed-listAppStore,与这两个插件对得上。但这治不了 PostList、UserList、SystemSettings 白屏——那些是原生页面。而且当时插件列表页本身白屏,禁用只能靠改数据库或移 jar,都是写操作,风险远高于收益。

加上开头那次数据库手改,一共四次基于「大概是插件问题」的猜测,全部落空。

共同的问题是在拿到直接证据前就开始设计修复方案。真正的转折点是浏览器控制台那次 performance.getEntriesByType('resource')——它把「哪个文件没加载成功」从推理变成了观测。之后 curl 一对照,几分钟就定位了。

可复用的经验

故障期间的错误响应会被缓存,成为故障的「余震」。 主故障修好了,缓存层还在忠实重放当时的错误。现象的时间戳和根因的时间戳可能相差很远。响应体 timestamp 与响应头 Date 不一致,是识别缓存重放最直接的信号。

永远给 proxy_cache 配上 proxy_cache_valid 只写 proxy_cache 不限定各状态码缓存时长,等于把缓存策略完全交给上游。一旦上游在异常状态下返回带长缓存头的错误响应,就会被钉死很久。

http 块的 include 会静默影响所有 server。 看站点配置不够,用 nginx -T 看展开后实际生效的完整配置。

前端「某函数 undefined」,先确认提供它的文件是否真的加载成功。 压缩变量名可通过在源文件搜索原始函数名反推。performance.getEntriesByType('resource') 列的是请求记录,不代表响应有效,务必逐个核实状态码。

不要在确诊前改数据库。 这次白屏排查里,「插件页打不开」被当成「插件坏了」的证据,结果一次手改把单一故障变成了两个叠加故障,还让原始问题被掩盖了一整轮。

0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin
  3. QQ打赏

    qrcode qq

评论区

鄂ICP备20003961号-3