看板显示零失败、运营说全断了——回调 service 的「静默冰山」陷阱
接到运营反馈:「这两天消息一条都没收到」。我打开监控看板——24h 失败率 0 条,一片绿。
这就是最危险的一种故障形态:系统不报错、看板说正常、只有业务方才知道功能彻底断了。
现象
某个对接第三方开放平台的消息推送功能,在某次底层依赖故障(cache 文件权限被系统进程改掉)后,连续 38 小时没有推送任何消息。期间:
- 系统 file log 里有满屏
Permission deniedfatal - 业务 log 表(
send_log)新增行数为 0 - 看板「24h 失败统计」显示 0 条,持续绿色
- 直到运营今天手动测试才发现
根因
service 内部有一个 try/catch,catch 分支只做了 Log::write 然后静默 return:
// 反模式:catch 只写文件 log,不写业务表
class PushService {
public function send(...) {
try {
$token = $this->getToken(); // <- fatal: Permission denied
$resp = $this->callApi(...);
Db::name('send_log')->insert(['status' => 1, ...]); // <- 走不到
} catch (\Exception $e) {
Log::write($e->getMessage(), 'error'); // <- 只写文件
return; // <- 吞掉,调用方和看板都不知道
}
}
}结果是一个经典的「冰山」结构:
- 水面下(file log):满屏报错,开发能看到
- 水面上(业务表 / 看板):0 行写入,一片风平浪静
调用方的外层 try/catch 也接不到任何东西,因为 service 早就把异常吞掉了。
坑:catch 只 Log::write 不写业务表 = 制造静默冰山
「file log 有记录」≠「看板能感知」。运营看的是 dashboard,不看 log 文件。只要 catch 分支不写业务表,失败就在监控层完全不可见——直到用户投诉你才知道。
正解
双层 catch,职责分明:
service 最外层:强制 INSERT 业务表(status=0)+ 写 file log + rethrow
调用方:catch 住 rethrow,写一条 warning log,然后继续主链路(不让副作用阻塞主流程)
// service:最外层 catch 强制落表 + rethrow
class PushService {
public function send($userId, $code, $payload = []) {
try {
$token = $this->getToken();
$resp = $this->callApi($code, $payload);
Db::name('send_log')->insert([
'user_id' => $userId,
'status' => ($resp['errcode'] ?? -1) === 0 ? 1 : 0,
'errcode' => $resp['errcode'] ?? null,
'errmsg' => $resp['errmsg'] ?? null,
'sent_at' => date('Y-m-d H:i:s'),
]);
return $resp;
} catch (\Throwable $e) {
// ★ 强制写一条失败行,哨兵 errcode -999 区分内部异常
try {
Db::name('send_log')->insert([
'user_id' => $userId,
'status' => 0,
'errcode' => -999,
'errmsg' => 'INTERNAL: ' . $e->getMessage(),
'sent_at' => date('Y-m-d H:i:s'),
]);
} catch (\Throwable $logErr) {
// 业务表也写不进(DB 挂了)→ file log 兜底
Log::error('FATAL: send_log insert failed: ' . $logErr->getMessage());
}
Log::error('PushService::send fatal: ' . $e->getMessage());
throw $e; // ★ rethrow,让调用方决定是否阻塞主链路
}
}
}// 调用方:接住 rethrow,不让副作用阻断主流程
class EventHandler {
public function handleChange(...) {
// ... 主链路:upsert / follow / tag ...
try {
$this->pushService->send($userId, $code);
} catch (\Throwable $e) {
// 业务表在 service 内已经写入了,这里只补一条 warning
Log::warning('push fail but main flow continues: ' . $e->getMessage());
// 不 rethrow,让主链路继续
}
// ... 主链路后续操作 ...
}
}哨兵 errcode 让看板一眼区分失败原因:
errcode = 0 → 成功
errcode = 40001 → 第三方平台返回的真实错误码
errcode = -999 → 内部 fatal(权限/token/DB)
正解:service 最外层 catch 必须强制写业务表 + rethrow
失败路径 INSERT 一行 status=0, errcode=-999 是关键回调 service 的「最低安全线」。有了这行记录,看板才能说真话;rethrow 让调用方可以选择是否阻断主链路,职责清晰。
一句话外卖
关键回调 service 的 catch 分支,必须先落业务表再 rethrow——file log 是给开发看的,看板是给运营看的,两条路一条都不能省。