点击「审批通过」为什么会卡半秒?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 强耦合,意味着通知服务的任何不稳定都会直接污染主流程的成功率。
站内信、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,
]);去掉通知调用前的 await,挂上 .catch(log)。用户点击立刻看到成功反馈;通知在后台异步发出;失败只进日志/监控,不冒泡给用户。一定要保留 .catch——没有观测的 fire-and-forget 会悄无声息地隐藏回归。
以下场景不适合这个模式:通知本身是业务一致性的一部分(比如支付凭证必须送达);合规要求同步审计;通知失败需要回滚主操作。这些场景应该用事务性发件箱(Transactional Outbox)或 Saga 补偿,而不是 fire-and-forget。
一句话外卖
判断一个
await该不该存在:问自己"如果这个 API 挂了,主操作要跟着失败吗"——如果答案是否,去掉await,换成.catch(log)。