返回博客

看板显示零失败、运营说全断了——回调 service 的「静默冰山」陷阱

接到运营反馈:「这两天消息一条都没收到」。我打开监控看板——24h 失败率 0 条,一片绿。

这就是最危险的一种故障形态:系统不报错、看板说正常、只有业务方才知道功能彻底断了。

现象

某个对接第三方开放平台的消息推送功能,在某次底层依赖故障(cache 文件权限被系统进程改掉)后,连续 38 小时没有推送任何消息。期间:

  • 系统 file log 里有满屏 Permission denied fatal
  • 业务 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 是给开发看的,看板是给运营看的,两条路一条都不能省。