「无涯茶榻」首页第一屏那张大图,刷新一次换一张。点开分类页、标签页、归档页,换的也是同一批图。这不是主题自带的轮播组件,也不是每天改一次配置的定时任务——而是每次浏览器发起请求时,由反代层现场随机挑一张图片跳过去。
这套机制从 2026 年 11 月 7 日重构上线至今,替换掉了此前依赖第三方随机图 API 的旧方案。这篇拆解它的完整工作原理:随机是怎么产生的、图片从哪来、为什么删一张图能立刻生效、以及在什么情况下它会破图,以及破图之后怎么自愈。
一、先看最终效果:轮转的到底是什么
站点上与「首屏大图」相关的有四个页面,它们共用同一张背景图。实测抓取服务端 HTML,四页的背景图地址完全一致:
/ background-image:url(/api/random-banner.jpg)
/categories/ background-image:url(/api/random-banner.jpg)
/tags/ background-image:url(/api/random-banner.jpg)
/archives/ background-image:url(/api/random-banner.jpg)也就是说,浏览器拿到 HTML 时并不知道自己会看到哪张图——HTML 里只有一个固定路径,真正决定用哪张图的,是浏览器随后向这个路径发起的第二次请求。
这个路径不是一张静态图片,而是一个 302 跳转器。对它连续请求 100 次,实测结果:
命中比例与理论期望吻合,说明随机确实是均匀的,不存在某几张图被偏爱的偏差。
二、整体架构:为什么把随机逻辑放在反代层
这是整个方案里最需要解释的一个设计选择。随机图片这件事,按理说可以在 Halo 应用层做,为什么非要放到 Nginx Proxy Manager 里?
因为 Halo 有一个绕不开的特性约束:图片的静态资源响应头是 Cache-Control: max-age=31536000,也就是缓存一年。如果让浏览器直接指向某一张具体的图片地址,无论怎么改配置,浏览器都会把第一张图钉死一整年——这就是「改了大图地址但页面没变」的经典原因。
而反代层完全不在这个缓存体系内。一个 302 跳转响应只要带上禁用缓存的头,浏览器每次都会老老实实重新发起请求。这就把「静态资源强缓存」这个死结绕开了。
核心思路:让浏览器请求一个「会变化」的地址,由反代层在响应的那一刻决定具体跳到哪张图。缓存的是跳转逻辑,而不是跳转结果。
完整链路如下:
浏览器
| GET /api/random-banner.jpg
v
Nginx Proxy Manager(openresty + Lua)
| (1) FFI readdir 现场列 /banners,取 banner-01.jpg ... banner-50.jpg
| (2) 随机挑一张,直连 Halo 内部端口 HEAD 确认真的能访问
| (3) 302 -> /upload/banner-NN.jpg(保留 query)
v
Halo 附件目录(用户在 Console 附件页自行上传/删除)全链路只涉及本站的 /api/ 与 /upload/,不请求任何第三方域名。
三、随机是怎么产生的
核心代码是 NPM 的 advanced_config 里的一段 Lua,挂在 location = /api/random-banner.jpg 上。完整的判断逻辑分五步。
第一步:现场列目录,而不是读清单文件
最初的实现是读一个清单文件(banner_pool.txt),但那个文件只由同步脚本更新,问题很快就暴露出来了:
删除 —— 端点会对不存在的图片做一次 HEAD 校验,能实时跳过,这一条本来就没事;
新增 —— 清单是冻结的,新图永远进不了轮换,而且没有任何报错。
问题出在「谁能增删图」和「图池从哪来」这两件事没有对齐:最终决定权交给了人——在 Halo Console 的附件页手动上传删除。那图池就必须变成「请求时现场发现」。
做法是把 Halo 的附件目录以只读方式挂进 NPM 容器:
# /root/data/docker_data/npm/docker-compose.yml
volumes:
- ../halo/data/attachments/upload:/banners:ro然后用 Lua 的 FFI 直接调用 C 库的 opendir/readdir 遍历目录:
local ffi = require("ffi")
ffi.cdef[[
typedef struct __dirstream DIR;
typedef struct {
unsigned long d_ino;
long d_off;
unsigned short d_reclen;
unsigned char d_type;
char d_name[256];
} dirent_t;
DIR *opendir(const char *name);
dirent_t *readdir(DIR *dirp);
int closedir(DIR *dirp);
]]
local C = ffi.C
local d = C.opendir(BANNER_DIR)
if d ~= ffi.NULL then
while true do
local e = C.readdir(d)
if e == ffi.NULL then break end
local n = ffi.string(e.d_name)
local nn = n:match("^banner%-(%d%d)%.jpg$")
if nn then
local v = tonumber(nn)
if v >= NN_MIN and v <= NN_MAX then pool[#pool + 1] = n end
end
end
C.closedir(d)
end这一步没有 fork、没有子进程、没有清单文件,只是一次目录遍历。结果是 Console 里的增删操作立刻生效,中间没有任何同步窗口。
注意一个容易踩的坑:宿主机上 mount --bind 做的绑定挂载不会出现在容器里。Docker 默认使用 rprivate 传播,宿主机新建的子挂载不传播进容器。必须写进 compose 文件并重建容器才生效。
第二步:严格到位的命名过滤
过滤用的正则是 ^banner%-(%d%d)%.jpg$,有三个刻意为之的约束:
必须是两位补零的编号(01 到 50)。一位数的
banner-1.jpg不匹配。必须严格落在 01–50 区间内。
banner-51.jpg会被排除。不允许任何后缀。Halo 在上传时如果发现同名文件,会自动追加一个随机后缀(
banner-03-OClX.jpg),这种带后缀的文件名不匹配正则,因此永远进不了轮换池。
第三条尤其值得留意。它是一个静默失败:图确实传上去了,Console 里也看得见,但永远不会在首页出现,没有任何报错。
实测当前附件目录里 113 个文件,NPM 容器挂载点里 114 个条目(多出来的是目录项本身),其中符合命名规则的恰好 50 张。其余 63 个是历史遗留——早期用 01-liquid-sugar.jpg 这类命名,以及一串时间戳命名的图片,它们不参与轮转。
第三步:直连 Halo 内部端口做存在性校验
随机挑中一张之后,并不能直接跳过去——Halo 里可能存在「文件在、记录没了」或者「记录在、文件被连带删了」的状态。
这里有个很隐蔽的坑。最初用的是 ngx.location.capture 来探测文件是否存在,但这个 API 会命中 location / 里的 force-ssl 规则,拿到的是 301(http 跳 https)而不是 200。于是 status == 200 永远不成立,结果就是兜底图 100% 触发——端点看起来完全正常,但永远只跳同一张图。
正确做法是绕开 Nginx,直接用 ngx.socket.tcp 连 Halo 的内部端口发一个 HEAD:
local HALO_HOST = "192.3.59.208"
local HALO_PORT = 8090
local function exists(path)
local sock = ngx.socket.tcp()
sock:settimeout(3000)
local ok = sock:connect(HALO_HOST, HALO_PORT)
if not ok then sock:close() return false end
sock:send("HEAD " .. path .. " HTTP/1.0\r\nHost: www.wycha.top\r\nConnection: close\r\n\r\n")
local line = sock:receive("*l")
sock:close()
if not line then return false end
return line:find(" 200", 1, true) ~= nil
end校验不通过就换下一张,最多试 10 次。
第四步:保留 query 字符串
主题模板在引用这个地址时会自己追加查询参数,比如 ?pageid=<post-id>。端点把原样的 query 拼到 302 目标上,Halo 才能继续按 ?width= 生成缩略图。
实测确认这个链路是通的:
端点 ?pageid=5 -> https://www.wycha.top/upload/banner-26.jpg?pageid=5
端点 ?width=800 -> https://www.wycha.top/upload/banner-27.jpg?width=800顺带一提,Halo 的 ?width= 只认四个预设值,其它任何取值都会退回 800 宽。实测:
可以看到 ?width=1920 拿到的是 800 宽的图(116,585 字节与 800 完全一致),说明这个预设表是硬编码的四个值,没有中间档。
第五步:禁用缓存
这一步是整个方案里最容易漏掉、后果也最隐蔽的一行:
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
add_header Pragma "no-cache" always;如果只写了 302 跳转而不加这两个头,浏览器和 CDN 会把第一个跳转目标缓存住,之后每次访问都是同一张图。服务端日志看起来完全正常,因为它确实每次都返回了 302。这是最典型的静默失败。
四、主题侧怎么接
主题本身不需要任何改动,端点挂好后,把三个配置项指向它就行:
还有一个开关 basic.enable_random_img_api,它本身不参与渲染,只决定 Console 的主题设置面板里是否显示「随机图 API」这个输入框(主题的 settings.yaml 用它控制可见性)。不打开的话,模板照样能读到配置值,但你没法在界面上改。
改配置不需要重启 Halo——ConfigMap 是数据不是模板,PUT 完刷新页面就生效。只有改模板文件才需要重启,因为 Thymeleaf 不会热重载。
五、两级兜底:保证任何情况下都不破图
轮换池有可能被删空(图池全被清空、附件目录被误操作、或者 Halo 出故障)。如果端点跳到一个不存在的地址,浏览器会显示破图图标,而首屏大图是 CSS background-image——它加载失败时不会触发任何 JS 报错,页面照样 200,只是图出不来。这种故障排查起来极其费劲,因为服务端日志里什么都没有。
所以兜底是必须的,而且做了两级:
对应的 Lua 判断逻辑:
local target
if picked then
target = "/upload/" .. picked .. q
elseif exists(FALLBACK) then
target = FALLBACK .. q
else
target = FALLBACK2 .. q
end
ngx.redirect(target, 302)主兜底图放在附件库而不是主题目录,是为了让它可以被用户直接替换——换一张图不需要改任何代码。但附件有被误删的可能,所以再留一层主题自带的图兜底。
这两级兜底是实测验证过的,不是纸面设计。验证方法是临时把轮换池的文件改名移走(同盘 rename 是原子操作,测完原样恢复),然后观察端点行为:
兜底图的文件名必须不匹配 banner-NN.jpg。否则它会被第一步的 readdir 收进轮转池,多出一张随机图,兜底语义直接失效。本站用的是 banner-fallback.jpg——「fallback」不是两位数字,正则不匹配。
六、数据一致性:磁盘、记录、可见
「文件在磁盘上」和「Halo 认得这个文件」是两件事。附件资源由三个环节共同构成:
物理文件:
attachments/upload/下的实际字节;Attachment 资源:Halo 数据库里的记录,Console 附件页显示的就是它;
容器挂载:NPM 容器里
/banners下能看到这个文件名。
任何一处不同步,端点就会出现「有的图抽中却跳不过去」或者「Console 里删了但还在轮换」。当前实测三者完全对齐:
Halo 的 LocalAttachmentUploadHandler 在 Attachment 记录被删时会连带删掉物理文件。这个行为在 Console 里手动删附件时很方便,但也意味着一次误操作会让两处同时消失。
七、实战踩过的坑
这一节记录的是真实出过问题的部分,以及后来怎么修的。
坑一:第三方图床证书过期,全站首屏白板
旧方案依赖外部随机图 API。2026 年 11 月 7 日,那个域名的 TLS 证书(notAfter=Nov 7 04:52:39 2026 GMT)过期,首页、分类页、标签页、归档页的大图全部白板。元素在、样式在,就是图加载不出来。
这次事故直接催生了现在的自建方案——把图片实体全部放在本站的 /upload/ 目录,零第三方依赖。顺带一个教训:页面 200 不代表页面是对的,验证图片必须查 naturalWidth > 0,而不是只看 HTTP 状态码。
坑二:ConfigMap 的 PUT 是整体替换
这是最贵的一个坑,值得完整说一遍。
Halo 主题的设置存在一个 ConfigMap 里,data 字段下按组分组,当前一共 23 个组:
ads archives aside basic beauty blogger carousel categories code_block custom
footer friends home journals leaving links navbar other photos post social tags theme每个组的值是一个 JSON 字符串,而 PUT 接口的语义是整体替换——你 PUT 什么,数据库里就只剩什么。
某次只想改 beauty.big_banner_Photos 这一个键,代码里 GET 回来 23 个组、解析出 3 个组(basic / beauty / home)、改完键就 PUT 上去了。结果那 20 个没 PUT 的组全部被清空。
后果是全站渲染中断,而且症状极具误导性:
主题模板读
theme.config.theme.enable_show_in_up时拿到 null,SpringEL 抛EL1001E: Type conversion problem, cannot convert from null to boolean;页面响应码还是 200——因为响应已经开始流式输出,异常发生在中途;
HTML 恰好在 32768 字节处被截断;
首页、分类页、标签页、归档页全部变成半截页面。
「响应 200 + 内容在 32KB 处整齐截断」是 Thymeleaf 模板崩溃的典型特征,不是网络问题、不是内容太大、也不是主题坏了。
恢复过程本身就说明了为什么要留备份。Halo 的附件目录里有一份系统自带的备份 zip,里面的 extensions.data 存着所有扩展资源的完整导出,包括这个 ConfigMap 的原始内容。把它解出来、核对版本号与破坏前一致,再整体回写 23 个组,站点就恢复了。
核对时发现一个好消息:备份里的 basic / beauty / home 三组与破坏前的值逐键完全一致,唯一差异就是本次要改的那一项。也就是说备份版本就是破坏前的版本,没有丢失任何中间改动。
事后补上了三重防护,写进了管理脚本:
改前断言 group 数量不少于 23;
PUT 前断言待提交的 group 集合与 GET 回来的一致;
PUT 后逐 group 逐键比对,实际变化的键必须只有目标键。
同时给脚本加了一条硬性要求:改 ConfigMap 之前,先把 GET 全量落盘存备份。只备份要改的那一个组是不够的——出问题时你会发现另外 22 个组已经没了,而你没有它们的备份。
坑三:Halo 对 HTML 路由的 HEAD 返回 404
用 curl -I 做站点探活会得到 404,让人误以为站点挂了。实际上 Halo 只对静态资源的 HEAD 正常响应,HTML 路由一律 404。页面探活必须用 GET。
坑四:清单文件是缓存,不是权威源
这是坑一和坑二的共同根源。最初的图池来自一个静态清单文件,它只是文件系统状态的缓存。一旦有人删图,清单就过期了,端点会跳到不存在的路径——破图,而且只在部分请求上出现,非常难查。
现在改成请求时现场列目录,权威源就是磁盘本身,缓存问题从根上消失了。
八、当前状态与维护入口
图池逻辑已经重构完成,全部收敛到一个脚本:
/root/.hermes/data/banner-rotate/banner_pool.py当前实测状态:图池 50 张、编号 01–50 无缺失无重复、总计 21.9 MB(平均 0.44 MB/张)、全部 1920×1280;主题三项配置全部指向端点;主题 CSS 无第三方图源残留;六项验收全部通过。
九、写在最后
这套方案真正想解决的问题只有一个:让首屏大图这件事不再依赖任何外部服务。图在本站附件目录里,随机在本站反代层里发生,缓存策略在本站可控,兜底路径也在本站。第三方服务挂掉、证书过期、限流、跑路——这些都不再是本站的问题。
而支撑它的其实是一个很朴素的思路:如果一个状态需要同步,就把它从「同步」变成「现场发现」;如果一个操作是整体替换,就先备份全量再动手;如果一个失败是静默的,就给它造一个能被观测出来的注入手段。
这三个原则,比这套代码本身更值得留着。
评论区