返回博客

重构前置 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 前置,表里还没有这行,nameNULL,占位符替换成空字符串,消息静默发出。

坑:service 内部的 SELECT 依赖是看不见的隐式契约

重构「只挪调用顺序」时,开发者通常只看:

  1. service 接口签名(入参有没有变)
  2. 调用点 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());
    }
}
正解:重构前置前先 grep service 内部所有 SELECT,把依赖字段列表写进 PR 描述

把每个「读字段」标注为「必须前置预取」或「可后置」,让 reviewer 和未来的维护者在 PR diff 里就能看到依赖图,而不是靠阅读 service 源码才能感知风险。

service 本身也应在方法头部加注释,明确写出隐式契约:

// ⚠️ 隐式契约:调用方必须在调用本方法前先写入 external_contact 表(至少含 name 字段)
// 若未预写入,方法不会抛异常,而是静默渲染空昵称

一句话外卖

重构「只挪调用顺序」不等于无副作用——service 内部的 SELECT 是隐式前置条件,接口签名看不出来,只有 grep 才能发现。