发现「配置没生效」的五个筛法
我把 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_URL被utils/upload.js读、示例里没声明,补进.env.example、.env.docker.example和 composeCACHE_PREHEAT是server.js的启动预热开关,补声明LOG_LEVEL在config/logger.js被读,补声明
API_BASE_URL 大概是早期写的,后来真正的实现用了 APP_BASE_URL,旧键就留在 .env 里。它对任何东西都没有影响,但看配置时会让我以为自己配了某个地址。
差集不能直接当问题清单。.env 示例里的键不都会进 compose:APP_TIME_ZONE 是 Spring Boot 用的,DB_TIME_ZONE 是 Express 用的,同一个意思在两个后端里是不同的键。筛出来的差异要核实过再定级,不然会把设计当成缺陷去改。
第二步 找只打一行日志就降级的分支
全仓搜 console.warn、log.warn,以及 catch 之后直接 return 的地方。这一步筛出来两条,都是同一个毛病,降级本身是对的,静默是错的。
一是图片的 WebP 变体。Express 侧的写法是:
let sharp = null;
try {
sharp = require("sharp");
} catch {
// sharp 是可选依赖,未安装则跳过格式转换
}
sharp 没装上的时候上传接口照常成功,只是不生成 .webp 和 _thumb.webp。sharp 装不上不该让整个站点挂掉,不过问题是 /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-For 和 X-Real-IP,没有开关。还有一处更隐蔽的:Express 的 comment.author_ip 根本没被写入,组装入库数据时没传这个键。
修法不是把四处写法改成一样的,而是两端各留一个唯一实现(utils/clientIp.js 和 config/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_at。view_count = view_count + 1 会触发 MySQL 的 ON UPDATE CURRENT_TIMESTAMP,每读一次「最后更新时间」就被推到当下。自增 SQL 里加上 updated_at = updated_at 就能挡住,MySQL 的规则是显式赋值不触发自动更新。Spring 侧还有第二处要一起改,不然前一处白做:getArticleById 先自增、又给托管实体 setViewCount(+1),实体被改脏,事务提交时 Hibernate 再发一次 UPDATE,而实体的 @PreUpdate 把 updatedAt 设成当前时间。这处改成不碰托管实体,只在返回的 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 加了 imageVariants 与 meilisearch 的状态和原因。上传目录那条做成了启动自检,两端启动时打印解析后的绝对根目录和文件数,再从库里抽一条图片引用核对文件在不在,不在就打 warn 并给出排查入口。
面板上显示「已配置」不等于这件事生效了,跨字段的依赖得在代码的出口兜住。
这两个方向里我明确没有做的一件事是 SITE_URL 为空时不拿别的值顶替。那样只是把静默失效换成静默猜一个域名。
验证要造出故障态
Meilisearch 那条是典型。旧实现下「密钥正确」和「密钥错误」两组对照得出的都是 ok,只跑一遍健康库证明不了任何事,结论碰巧对而已。后来是故意用错密钥跑出 unauthorized,这套判定才有区分度。图片变体那块也一样,我把 sharp 打桩、Spring 侧手工把 webp-imageio 从 classpath 里剔掉,真造出了编码器缺失的环境,而不是只读代码推断。
还没解决的地方
上面写到的五步是筛,产出取决于我事先想到了哪几类。这次能筛出十二条,是因为我按这五个角度各搜了一遍,换成没见过的问题,可能一步都靠不上。
后来我把其中能固定下来的几条挪进了上线前的脚本:scripts/preflight.mjs 检查已知的「配了但没生效」类型(密钥还是不是默认值、.env 有没有被 git 跟踪、四处时区有没有配对、收件人还是不是占位地址),scripts/smoke.mjs 检查跑起来之后的链路。它们只能覆盖我已经踩过的类型。
告警那部分也还粗糙。降级 warn 按每个进程只打一次做,为的是不刷日志,但容器反复重启时它也会跟着反复出现,什么粒度算合适我还没确定下来。


