发现「配置没生效」的五个筛法

我把 QQ 邮箱的 SMTP 配好,但评论通知投不出去,翻日志才发现收件人还是模板里的占位地址。这篇记录我顺着这个疑问把全仓筛了一遍的过程,以及从中整理出来的五个筛法。筛出来的十二条问题里,每条都是功能在跑、效果缺失。

发现「配置没生效」的五个筛法

配置配了但功能没生效

起因

我把 QQ 邮箱的 SMTP 配好,评论通知的开关也打开,自己发了一条评论试。通知发出去了,我这边一直没收到,翻发信日志才发现是被退信。收件人还是模板里的占位地址 admin@example.com

后台的 SMTP 面板显示「已配置」,发信方也没报错,但缺了「收件人」这个字段。

单修这一处很快。只是修的时候我想的是另一个问题,同一类的毛病,仓库里还有多少处。于是我先把这类问题定义清楚:配置项存在、代码在读、功能也跑着,但效果缺失,而且没有任何地方报错。

这类问题为什么难发现

它们的共同点有三个。一是有降级路径,而降级是静默的,接口一切正常,只是效果没了。二是跨字段依赖,配了 A 才有用的 B,没配也就是 B 不生效,进程照起。三是编译、类型检查、单测全绿,因为它们检查的是代码能不能跑,而这类问题问的是配置读完之后的那个值对不对。第三条我在第十轮评估那批改动里撞到过,一次撞到四个,所以这不能靠多看几遍代码解决。

五个筛法

下面五步是我用的顺序。前两步看配置和降级点,第三步看两个实现之间有没有矛盾,第四步看约定有没有被遵守,最后一步回到数据上做实证。五步都不需要通读业务逻辑,作用是把可疑的地方筛出来,再逐条核实。

第一步 求「声明过的键」与「代码读过的键」的差集

做法很直接:把 .env.example.env.env.docker.example 里出现过的键抽出来,跟代码里 process.env.X${X:} 这类读取点求两个差集。只在配置里、代码从没读过的是死配置,只在代码里、示例从没声明过的是未文档化配置。这一步筛出来四条:

  • API_BASE_URL 全项目零引用(我自己的 .env 里有这行),删掉
  • APP_BASE_URLutils/upload.js 读、示例里没声明,补进 .env.example.env.docker.example 和 compose
  • CACHE_PREHEATserver.js 的启动预热开关,补声明
  • LOG_LEVELconfig/logger.js 被读,补声明

API_BASE_URL 大概是早期写的,后来真正的实现用了 APP_BASE_URL,旧键就留在 .env 里。它对任何东西都没有影响,但看配置时会让我以为自己配了某个地址。

差集不能直接当问题清单。.env 示例里的键不都会进 compose:APP_TIME_ZONE 是 Spring Boot 用的,DB_TIME_ZONE 是 Express 用的,同一个意思在两个后端里是不同的键。筛出来的差异要核实过再定级,不然会把设计当成缺陷去改。

第二步 找只打一行日志就降级的分支

全仓搜 console.warnlog.warn,以及 catch 之后直接 return 的地方。这一步筛出来两条,都是同一个毛病,降级本身是对的,静默是错的。

一是图片的 WebP 变体。Express 侧的写法是:

let sharp = null;
try {
  sharp = require("sharp");
} catch {
  // sharp 是可选依赖,未安装则跳过格式转换
}

sharp 没装上的时候上传接口照常成功,只是不生成 .webp_thumb.webpsharp 装不上不该让整个站点挂掉,不过问题是 /health 里当时只有 database / redis / meilisearch / mail 四块,没有任何地方能看出变体生成器在工作,于是加了一块:

"imageVariants": { "status": "ok", "reason": "" }

我最初想把键名写成 sharp,但 Spring 侧用的是 webp-imageio,按技术名命名会在一端显得莫名其妙。/health 的键要两端逐字段对齐,所以按能力命名成 imageVariants

二是全站搜索的降级。Meilisearch 的可用性在旧实现里只探 /health,而 /health 是公开端点,密钥对不对都返回 200;能区分密钥的是 /version 这类接口,不带密钥 401、密钥错 403。所以密钥写错时 Express 报的仍然是 ok,搜索其实已经降级成 SQL LIKE 了。Spring 侧的探测会走到索引初始化才失败,也只打一行英文 warn。改完之后可用性判定过了三关,状态取值统一成五个:ok / unauthorized / unavailable / error / not_configured,另加一个 reason 写原因;降级出口补了一行 warn,含具体原因,每个进程只打一次。

可选依赖的形态是可选、显式降级、可见状态三件一起。少了第三件,前两件加起来就是一个静默失效。

第三步 同一个功能在两端各搜一遍实现

我的项目有两个后端,Express 和 Spring Boot,共用同一份数据库。这一步是按功能名在两端各搜一遍,搜出四处对不上。

客户端 IP 我本来以为只有一处实现,搜完发现有四个口径。Express 的限流用 req.ip,受 app.set("trust proxy", TRUST_PROXY) 约束;Express 的留言入库手写解析了 X-Forwarded-For,绕开了那个开关;Spring 的评论和留言用 getRemoteAddr();Spring 的限流无条件信任 X-Forwarded-ForX-Real-IP,没有开关。还有一处更隐蔽的:Express 的 comment.author_ip 根本没被写入,组装入库数据时没传这个键。

修法不是把四处写法改成一样的,而是两端各留一个唯一实现(utils/clientIp.jsconfig/ClientIpResolver.java),三个消费点都调它,语义统一成「信任右起 N 跳、只认 XFF」。

缩略图的尺寸语义两端也不同。Express 按宽缩(sharp.resize(1200, null)),Spring 是「最长边不超过 N」。同一张 800×1600 的竖图喂给两端,Express 出 400×800,Spring 出 200×400,而前端 srcset 声明的是 400w,所以竖图的缩略图在 Spring 侧比声明小了一半。

返回 DTO 的接口在 Spring 侧按字母序排,Express 按声明顺序,根因是 Jackson 的 Web mapper 默认开了 SORT_PROPERTIES_ALPHABETICALLY。我把「键序也是契约的一部分」当成了约定,所以两端对齐了。

顺带搜出一条非双端的问题:读文章会改 updated_atview_count = view_count + 1 会触发 MySQL 的 ON UPDATE CURRENT_TIMESTAMP,每读一次「最后更新时间」就被推到当下。自增 SQL 里加上 updated_at = updated_at 就能挡住,MySQL 的规则是显式赋值不触发自动更新。Spring 侧还有第二处要一起改,不然前一处白做:getArticleById 先自增、又给托管实体 setViewCount(+1),实体被改脏,事务提交时 Hibernate 再发一次 UPDATE,而实体的 @PreUpdateupdatedAt 设成当前时间。这处改成不碰托管实体,只在返回的 DTO 上体现 +1

这样搜比从头读代码快。

第四步 查注释里的约定有没有被执行

做法是按「需 / 必须 / 调用方」这类词搜注释,再去看调用点。这次筛出来的是图片回退,utils/image.ts 里的注释写着:上传时如果原图本身是 .webp,后端不会生成 _thumb 变体,因此调用方需配合 @error 回退到原图。

而实际的覆盖率是 3 处接了,5 处没接(Header 的站点 Logo、首页 / 主站 / 关于页这三处的头像、文章页的相关推荐封面)。没接的五处,触发条件是「sharp 没装」或「原图本身就是 webp」或「变体文件被删」,条件一到就图裂。而接了回退的那几处,最坏情况只是清晰度降一级,所以这个问题在页面上不容易被看出来。

修的时候我没有逐处补 @error,而是把「推导 + 回退」封成了一个组合式,调用方只剩两行:

const { src, onError } = useSmartImage(() => source);
// 模板里:<img v-if="src" :src="src" @error="onError" />

封的时候也修了派生规则本身的一个毛病:getThumbWebpUrl 原来对任何扩展名都做替换,碰上 svg 会得到一个必然 404 的地址,而欢迎页头像的兜底值正好就是 /favicon.svg。现在只对后端真会转换的栅格图派生。改完还发现 pages/index.vue 里有一段手写的 .endsWith(".svg") 判断,它就是给这个毛病绕路的,一并删掉了。

这类问题的数量往往比想象的多,因为漏一处不会有任何提示。

第五步 直连数据库看字段有没有被写进去

最后一步是把前面几步的判断落到数据上。代码里「没传这个键」是推断,值不值得修、修完有没有生效,都需要要看库。

这次的实证是那条 author_ip:直连库统计空值率,comment 表 8 行全是空串,第三步的推断这就坐实了。

同一个手法也能用来隔离缺陷,不用跑应用代码。updated_at 那条我是这么确认的:建一行临时数据,把 updated_at 预置成 2026-09-01,然后执行:

UPDATE article SET view_count = view_count + 1 WHERE id = ?;

updated_at 被改成当下。换成 SET view_count = view_count + 1, updated_at = updated_at,不变。再来一组显式赋旧值的对照,确认这条 UPDATE 确实落到了那一行。

这里踩到一个坑,MySQL 的 datetime 只精确到秒,我的第一版探针没 sleep,「刚被改成当下」和「本来就在这一秒」区分不开,得出的错误结论是「没被改」。

修法收敛成两个方向

十二条闭环之后回头看,修法收敛成了两个方向。

一个方向是在出口拦。SITE_URL 那条就是:邮件模板里拼 ${SITE_URL}/article/<id>,而 SITE_URL 默认是空串,真实通知里的链接是 /article/4 这种没有域名的相对路径,收信人点开是空页,发信方不会报错。改法是在真正发信前校验一次,为空就打一行 warn,每个进程只打一次。

另一个方向是让状态接口能看见。/mail/status 加了 siteUrl 字段,后台邮件面板多了一行「站点地址」,没配的时候显示「未配置(邮件里的链接不带域名,收件人点开是空页)」。/health 加了 imageVariantsmeilisearch 的状态和原因。上传目录那条做成了启动自检,两端启动时打印解析后的绝对根目录和文件数,再从库里抽一条图片引用核对文件在不在,不在就打 warn 并给出排查入口。

面板上显示「已配置」不等于这件事生效了,跨字段的依赖得在代码的出口兜住。

这两个方向里我明确没有做的一件事是 SITE_URL 为空时不拿别的值顶替。那样只是把静默失效换成静默猜一个域名。

验证要造出故障态

Meilisearch 那条是典型。旧实现下「密钥正确」和「密钥错误」两组对照得出的都是 ok,只跑一遍健康库证明不了任何事,结论碰巧对而已。后来是故意用错密钥跑出 unauthorized,这套判定才有区分度。图片变体那块也一样,我把 sharp 打桩、Spring 侧手工把 webp-imageio 从 classpath 里剔掉,真造出了编码器缺失的环境,而不是只读代码推断。

还没解决的地方

上面写到的五步是筛,产出取决于我事先想到了哪几类。这次能筛出十二条,是因为我按这五个角度各搜了一遍,换成没见过的问题,可能一步都靠不上。

后来我把其中能固定下来的几条挪进了上线前的脚本:scripts/preflight.mjs 检查已知的「配了但没生效」类型(密钥还是不是默认值、.env 有没有被 git 跟踪、四处时区有没有配对、收件人还是不是占位地址),scripts/smoke.mjs 检查跑起来之后的链路。它们只能覆盖我已经踩过的类型。

告警那部分也还粗糙。降级 warn 按每个进程只打一次做,为的是不刷日志,但容器反复重启时它也会跟着反复出现,什么粒度算合适我还没确定下来。