重构前置 service 调用前没 grep 内部 SELECT,结果字段渲染漏字
一次「防止 token 超时」的性能重构,把消息发送 service 从链路尾部挪到了头部。重构后接口返回 errcode=0,状态字段写 status=1,一切看起来正常——直到运营反馈:收到的欢迎消息里,用户昵称那行是空的,出现了 "Hi~,..." 这样的漏字文本。
这是一个典型的「隐式契约破裂」事故,而且破裂得极其安静。
现象
- 重构前:链路顺序是「写入联系人表(含
name字段)→ 其他业务 → 发送消息」,消息内容渲染正常。 - 重构后:把「发送消息」挪到链路最前面(为了抢在 welcome_code 20 秒内消费),消息发出去了,但
%NICKNAME%占位符没被替换,成了空字符串。 - 最坑的地方:第三方 API 返回
errcode=0,日志无报错,单测全过,数据库状态字段也写了「成功」——整条链路对监控来说完全正常。
根因
WelcomeMsg::send() 的接口签名是:
public function send($externalUserId, $welcomeCode, $userId)看签名看不出任何问题。但 service 内部第一行就是:
$row = Db::name('external_contact')
->where('external_userid', $externalUserId)
->find();
$nickname = $row['name'] ?? '';
// ... str_replace('%NICKNAME%', $nickname, $template)这条 SELECT 隐式依赖调用方「已经在 external_contact 表里 INSERT 了这一行」。原链路满足这个契约(先 INSERT 再 send),重构后 send 前置,表里还没有这行,name 为 NULL,占位符替换成空字符串,消息静默发出。
重构「只挪调用顺序」时,开发者通常只看:
- service 接口签名(入参有没有变)
- 调用点 diff(有没有改入参)
但 service 内部可能 SELECT 调用方刚写的表字段——这个依赖没有类型系统约束、没有编译期检查、单测 mock 会默认填充,只有注释(如果有的话)和 grep 能发现它。
更致命的是:service 发现字段为空时通常不抛异常,而是静默用空值渲染,导致产物"看似成功"但内容错误。
排查
事后 grep service 源文件,5 秒内就看到了那条 SELECT:
grep -nE "Db::name|->find\(|->select\(|->where\(" application/service/WelcomeMsg.php而当时的重构 commit 只看了调用点,没有打开 service 源文件——5 分钟的重构,事后 5 秒的 grep,差了一个故障。
正解
重构前置调用前,必须跑完这 4 步 grep:
SERVICE_FILE="application/service/WelcomeMsg.php"
# 1. grep 所有 DB 读操作
grep -nE "Db::name|Db::table|->find\(|->select\(|->column\(|->value\(|->where\(" "$SERVICE_FILE"
# 2. grep 所有调用的 helper / Repository(递归查依赖)
grep -nE "^use [A-Z]|[A-Z][a-zA-Z]+::" "$SERVICE_FILE" | sort -u
# 3. grep 占位符常量(%NICKNAME%、{{name}} 这类直接对应 SELECT 字段)
grep -nE "'%[A-Z_]+%'|'\{\{[a-z_]+\}\}'" "$SERVICE_FILE"
# 4. grep cache/Redis 读(也可能是隐式依赖)
grep -nE "Cache::|Redis::|->cache" "$SERVICE_FILE"发现依赖后,处理方式有两种:
方案 A(推荐):在 service 调用前补一次 minimal INSERT,只写当前 service 必需的字段,后续再做完整写入。
方案 B(防御 fallback):在 service 内部加 fallback——SELECT 拿到空时,主动从外部 API 拉一次详情:
$nickname = $row['name'] ?? '';
if ($nickname === '') {
// fallback:SELECT 空说明调用方还没 INSERT,降级拉外部 API
try {
$detail = ExternalApi::getContactDetail($externalUserId);
$nickname = $detail['name'] ?? '';
} catch (\Throwable $e) {
Log::warning("fallback fetch fail: " . $e->getMessage());
}
}把每个「读字段」标注为「必须前置预取」或「可后置」,让 reviewer 和未来的维护者在 PR diff 里就能看到依赖图,而不是靠阅读 service 源码才能感知风险。
service 本身也应在方法头部加注释,明确写出隐式契约:
// ⚠️ 隐式契约:调用方必须在调用本方法前先写入 external_contact 表(至少含 name 字段)
// 若未预写入,方法不会抛异常,而是静默渲染空昵称一句话外卖
重构「只挪调用顺序」不等于无副作用——service 内部的 SELECT 是隐式前置条件,接口签名看不出来,只有 grep 才能发现。