返回博客

点击「审批通过」为什么会卡半秒?await 了一个不该等的通知

在某个后台管理系统的审批流页面,点击「审批通过」按钮后,Dialog 会有 0.5 到 1.5 秒的卡顿才弹出成功提示并关闭——偶尔甚至超过 2 秒。用户开始频繁反馈"审批很卡"。

打开 Network 面板一看,主写入接口 200 ms 内就返回了,时间明明够用。真正的延迟藏在它后面——一个站内信通知接口,每次都要等它跑完,成功提示才出现。

现象

async onApprove() {
  await this.submitApproval()                         // 主写入,~200 ms
  await notifyService.sendFlowReviewNotification({    // 通知接口,~800 ms
    userId: this.flowDetail.operateId,
    flowType: this.flowDetail.type,
    result: this.approvalType,
  })
  this.$message.success('审批通过')   // ← 被推迟了整整一个通知 API 的耗时
  this.closeDialog()
}

成功提示和 Dialog 关闭都被通知接口的 RTT 推迟了。更严重的情况:通知服务偶发超时或 5xx,整个审批操作在用户侧显示失败——但实际上主写入早就成功了。

根因

审批写入和站内信通知不是同一个一致性边界。主写入决定业务结果;通知只是告诉相关人"发生了一件事"。两者放在同一条 await 链里,用户的每一次点击都要等两个网络往返才能感知到成功。

更要命的是,通知 API 的可靠性远低于核心业务接口——限流、服务降级、短暂抖动都可能发生。用 await 强耦合,意味着通知服务的任何不稳定都会直接污染主流程的成功率。

坑:await 非关键副作用,把通知 API 的延迟叠进了主流程

站内信、Push、埋点、Webhook 这类通知是信息性副作用——它的失败不应该撤销主操作,它的延迟也不应该让用户等待。但只要代码里写了 await notifyService.send(),就把这个副作用纳入了主流程的关键路径。

结果:通知 API 慢,用户就感知到"审批很慢";通知 API 报错,用户就感知到"审批失败"——而实际上数据早就写进去了。

正解

去掉 await,改为 fire-and-forget:触发通知但不等它,挂上 .catch 让失败可观测但不冒泡。

async onApprove() {
  await this.submitApproval()   // 主写入必须成功
 
  // 副作用:点火不等待,失败只记录,不影响主流程
  notifyService.sendFlowReviewNotification({
    userId: this.flowDetail.operateId,
    flowType: this.flowDetail.type,
    result: this.approvalType,
    reason: this.approvalForm.message || '',
  }).catch(err => {
    console.error('站内信通知发送失败:', err)
    // 可上报到错误监控,但 ⚠️ 不要 this.$message.error 给用户
  })
 
  this.$message.success('审批通过')   // 立刻出现,不等通知
  this.closeDialog()
}

对于多目标扇出(同时发站内信 + Push + Webhook),不要用 Promise.all——某一个慢目标依然会拖住其他所有目标。各自独立 fire-and-forget:

notifyService.sendInAppMessage({ ... }).catch(log)
notifyService.sendPushNotification({ ... }).catch(log)
webhookService.notify({ ... }).catch(log)
this.$message.success('已提交')

服务端同理——PHP / Node 的主响应不应阻塞在通知 HTTP 调用上,应该投入队列异步消费:

// 主逻辑提交后,入队异步处理通知,立刻返回响应
Queue::push(SendApprovalMessage::class, [
    'user_id' => $userId,
    'flow_id' => $flowId,
]);
正解:fire-and-forget,副作用不阻塞主流程

去掉通知调用前的 await,挂上 .catch(log)。用户点击立刻看到成功反馈;通知在后台异步发出;失败只进日志/监控,不冒泡给用户。一定要保留 .catch——没有观测的 fire-and-forget 会悄无声息地隐藏回归。

何时不该用 fire-and-forget

以下场景不适合这个模式:通知本身是业务一致性的一部分(比如支付凭证必须送达);合规要求同步审计;通知失败需要回滚主操作。这些场景应该用事务性发件箱(Transactional Outbox)或 Saga 补偿,而不是 fire-and-forget。

一句话外卖

判断一个 await 该不该存在:问自己"如果这个 API 挂了,主操作要跟着失败吗"——如果答案是否,去掉 await,换成 .catch(log)