故障档案 FA-020

没人报错,因为错误被吞了

一次上线前审查翻出的十四处静默失败:连接断了不重连、参数发错被吞吐、写入失败当成功、该拦的入口没拦,全都不产生任何日志。

STRUCTURE ── 篇幅剖面5 节 · 点击跳转

上线前把整个仓库过了一遍,一边审一边修。这一天的提交里,有一个反复出现的形态。

它们都不抛异常,不写日志,不改变任何可见状态。接口返回成功,或者返回一句和真实原因无关的话。我把它们按「错误从哪一步消失」分成了四类。

一、连不上,但不说

投递端断了不重连。

建站提交报「服务繁忙,请稍后重试」,PRD 卡在人工评审。两个功能,同一个根因。

投递端 Redis 连接断开后从不重连:

readonly retryStrategy: (_attempt: number) => null;

返回 null 是「彻底放弃重连」。这个设计配的假设写在测试标题里——「短生命周期 producer」,用完即弃自然不必重连。但实际用法是懒加载单例:

producerQueue ??= new Queue(...)

它活得和 api 进程一样久。Redis 抖一次、重启一次或主备切一次,投递端就再也连不回来。之后每次入队都抛错,路由回滚状态并返回 502,直到 pod 重启才恢复。

受影响的是全部 6 个用该连接的队列生产者:建站、PRD、公众号图文,以及画布的三个队列。这也解释了画布运行节点长期停滞。

改成退避重连,上限 3 秒:

retryStrategy: (attempt) => Math.min(attempt * 200, 3_000),

重连带来一个新问题。

上面这条修完后,第二个问题浮出来:冷启动或单例重建期间,BullMQ 的 waitUntilReady 只在 end 事件上 reject,而永续的 retryStrategy 让 ioredis 永不发 end。结果是 add / getJob 无限挂起,HTTP 请求堆积。

给入队操作加硬超时:

export async function withProducerDeadline<T>(
  operation: Promise<T>,
  label: string,
  timeoutMs = loadBullNumber("BULL_PRODUCER_OP_TIMEOUT_MS", 900),
): Promise<T> {
  const deadline = new Promise<never>((_, reject) => {
    timer = setTimeout(
      () => reject(new Error(`[bull-producer] ${label} 在 ${timeoutMs}ms 内未完成:Redis 暂不可用`)),
      timeoutMs,
    );
    timer.unref?.();
  });
  return await Promise.race([operation, deadline]);
}

默认 900 毫秒。超时只放弃这一次等待,连接层仍在后台按 retryStrategy 重连。两件事互不冲突:重连是连接层的后台行为,单条命令该失败照样立刻失败。

VIP 判定恒为假。

const isVip = await billing.getVipSummary?.(userId).then((s) => Boolean(s?.membershipVipBenefit));

真实 client 返回的是 { data: VipSummary },这里只读裸字段,取到 undefined。VIP 判定恒 false,加速队列从未生效。两种形状都认之后才通:

const raw = s as { membershipVipBenefit?: boolean; data?: { membershipVipBenefit?: boolean } } | null;
return Boolean(raw?.data?.membershipVipBenefit ?? raw?.membershipVipBenefit);

排队位次算错。

另一个入口(dub-enhance 包装渲染)入队时既不分配队列槽位,也不带 BullMQ priority。没有权重的任务反而插到全部加权任务之前,排队位次显示同时失真。

这个队列的公平调度设计值得记一笔。BullMQ 的 priority 是严格优先级:高优先级队列非空就一直取它,VIP 多的时候普通用户可能永远排不上。所以这里用「虚拟时间戳」当 priority——VIP 每个 +1、普通每个 +5,比例 5:1:

export const VIP_TO_NORMAL_RATIO = 5; // 每 N 个 VIP 放行 1 个普通

不带这个时间戳入队,等于把一个永远最小的值塞进队列,插到所有人前面。

二、发不出去,但不说

参数格式不对,上游拒收。

线上实况:电商主图 4 张全部「生成失败」。日志里上游收到的是:

resolution: 1536p  size: 1536x1536

原始像素尺寸。上一家服务商能吃这种写法,新接入的这家只认比例,于是主模型报 400 pricing rule not matched、兜底模型报 503 no available channel。两个模型双双打不通。

根因是各业务线各有一张自己的尺寸表。电商主图那张表 15 个尺寸里只有 2 个与共享预设表重合,而 upstreamImageOptions 对认不出的尺寸原样透传。

修法是认不出的像素尺寸一律按宽高比就近吸附到上游支持的 13 个比例之一,绝不再把像素尺寸发出去。吸附用对数距离:

Math.abs(Math.log(aspect / candidate.aspect))

顺带发现一处吸附判错:896x1152(真实比值 0.778)被判成 4:5,而不是用户选的 3:4。显式登记电商那 15 个尺寸之后解决。

重试链被自己的去重压短。

生图失败的重试链配置形如 gpt-image-2-vip,gpt-image-2-vip,...,gpt-image-2。链解析里原先带了去重,本意是防配置手滑写成 vip,vip

但「同一模型隔一会儿再试一次」正是这条链的主要用法——那个模型只是偶发失败,隔开再试命中率高。去重会把配置的 5 次重试静默压成 2 次,且没有任何提示。

去重删掉:配几次就重试几次。静默缩水比手滑危险。

三、写不进去,但不说

这一类最多。

站点解绑失败后照删记录。 托管侧的绑定会永远残留,而且再无重试机会——回收任务只扫在线和离线两种状态。改成抛错留给下一轮重试。

APK 幂等检查把故障当成「不存在」。 对象存储的 List 对「不存在」返回空结果而不是报错。代码走到这里只可能是存储故障。当成「不存在」去重复出包,既浪费构建,上传大概率同样失败。改为如实上抛,让调用方重试。

并发删除返回假成功。 记忆更新要先算向量再写库,中间用户可能把这条删了。写库时 0 行受影响,但函数返回成功,接口回 200。改成如实 404:

const affected = await prisma.$executeRawUnsafe(`UPDATE "Memory" SET ...`);
return affected > 0;

验证码会复活。 取验证码与读 TTL 之间键刚好过期,代码会用整段 TTL 把已过期的码续回来。改成直接删除并返回「无验证码」。

临时文件越积越多。 桌面端写文件失败时不清临时文件,.tmp 在目录里堆着。修法是无论吞不吞错,写失败都清掉。

关闭 socket 抛错了。 这个方向写反了:

// 旧写法只吞 Error 实例、反而把非 Error 抛出去,方向写反了——
// 在 error 事件回调里外溢会直接崩掉主进程。

try { sock.close() } catch { } 要吞的是全部,因为关闭是尽力而为。

四、该拦的入口没拦

唯一漏掉来源校验的 IPC handler,经手账号密码。

桌面端有一组 IPC handler,每个都要先做来源校验。yc:pair 是全文件唯一漏掉的,而它正是收账号密码的那个:

ipcMain.handle("yc:pair", async (event, args) => {
  // 全文件唯一漏掉来源校验的 handler,且经手账号密码——补齐与其余 handler 一致的信任闸
  assertTrustedSender(event);
  // ...
});

用户可控的文件名直接进对象存储的 key。 知识库摄取有三种模式:文件、文本、URL。只有文件模式走了文件名消毒,文本与 URL 模式没走。两种模式的文件名同样来自用户输入:

// name 是用户可控输入且直接进 S3 key,与 FILE 模式同样必须消毒(防目录穿越)
filename = sanitizeFilename((bodyObj.name as string) ?? "document.txt");

批量管理操作没有幂等键。 批量封禁与调点、送卡不同,不带批次的幂等键,审计没法按批串联。补齐之后,同一批操作共用一个 key,重试可以辨认。

五、为什么这类错误特别难查

静默失败的共同点是:它产生一个看起来正常的结果

  • 接口返回 502,文案是「服务繁忙,请稍后重试」——用户重试,还是 502,然后就去问是不是系统在维护。
  • 上游 400,日志里记的是应用侧的成功调用,失败挂在上游账户下。要捞到那条原始报文才有线索。
  • 重试次数从 5 变成 2,没有任何输出。只有对比配置和实际日志条数才能看出来。
  • 站点解绑失败后记录照删,之后连重试的入口都没了。

排查时最容易走错的一步是相信「没有报错」等于「没有失败」。这一天修的地方里,有四处是先看到线上现象、再去代码里找到那个 catch 的——现象和根因之间没有任何日志把它们连起来。

这套修改的走向是一致的:把吞掉改成留痕,把「猜」改成「读」。

  • 51 处 .catch(() => undefined) 改成带标签的日志。行为不变,但下次失败能对上账。
  • 队列投递失败时记录真实原因,用户可见文案不变。
  • 上游账户错误与用户余额不足分开报。原先上游渠道商欠费会被透传成「余额不足」,用户跑去充值页面发现余额好好的。
  • 认不出的模型一律按「不支持」处理,不用「兼容」处理。拦错用户看得见,静默丢掉没人知道。

最后一条是这一天所有修改里唯一带判断的:在不确定的行为之间,选那个会说出来的

星野的头像

星野 XINGYE

全栈工程师。这里记录 82 篇复盘:24 份故障档案、OTA、架构演进与工作流。