第3集介绍了检测 — 系统如何发现某处出现了故障。第4集是下一个环节:检测告诉你出了问题,接下来怎么办?
星期六,第4轮
👦 侄子:叔叔,我按你说的在项目里设置了超时和日志。昨天一次支付网关调用真的超时了。我检测到了它。记录了日志。然后继续了。
👨🦳 叔叔:然后呢?
👦 侄子:就这些。没有其他事情发生了。
👨🦳 叔叔:那是分析调用,还是扣款?
👦 侄子:……是扣款。
👨🦳 叔叔:那么“记录日志然后继续”并不是处理。那只是在旁观。
👦 侄子:所以仅靠检测是不够的。
👨🦳 叔叔:检测告诉你出了问题。处理决定你实际上要做什么。今天有六种工具。
1. 重试
2. 指数退避
3. 降级
4. 队列
5. 熔断器(预览 — 完整内容在后面的集数)
6. 优雅降级
7. 死信队列
Enter fullscreen mode Exit fullscreen mode
第1部分 — 重试
👨🦳 叔叔:列表里最简单的想法。一次调用失败了,你就再试一次。有什么问题吗?
👦 侄子:没有吗?失败了就再试一次。
👨🦳 叔叔:总是这样?
👦 侄子:……我感觉你在设陷阱。
👨🦳 叔叔:你昨天那次支付网关调用 — 超时了。如果你立即重试,网关那边实际发生了什么?
👦 侄子:它超时了,所以……它失败了?
👨🦳 叔叔:是吗?还是你只是没及时收到响应?
👦 侄子:等等。这是两回事。
叔叔:非常不同。超时并不能告诉你扣款是否已完成。
你 支付网关
| -- 扣款请求 --> |
| | (成功处理)
| <-- X 响应丢失--| (网络丢弃了响应)
| -- 超时,重试-->|
| | (再次扣款!)
Enter fullscreen mode Exit fullscreen mode
👦 侄子:所以我可能会对客户重复扣款,而我的“处理”反而成了罪魁祸首。
叔叔:这就是陷阱。那么,哪些操作真正安全可以重试?
👦 侄子:……运行两次也不会改变任何东西的?
👨🦳 叔叔:完全正确。这个属性有个名字 — 幂等性,它是第3模块的一个独立模式。今天只要记住:重试那些安全的操作,对任何涉及金钱、库存或状态的操作都要保持警惕。
第2部分 — 指数退避
👨🦳 叔叔:假设重试在这里是安全的。你应该立即重试,还是连续重试三次?
👦 侄子:为什么不呢?恢复得更快。
👨🦳 叔叔:如果有10000个客户端都在请求一个负载过重的服务,而这10000个客户端都立即重试 — 那个服务会怎样?
👦 侄子:……它会受到更严重的冲击。我会让情况变得更糟。
👨🦳 叔叔:没错。所以你在每次失败后等待更长的时间,给服务喘息的空间。
第1次尝试失败 → 等待约2秒
第2次尝试失败 → 等待约4秒
第3次尝试失败 → 等待约8秒
第4次尝试失败 → 等待约16秒
第5次尝试失败 → 放弃,抛出错误
Enter fullscreen mode Exit fullscreen mode
👦 侄子:如果这10000个客户端都在同一秒失败 — 它们不会也按完全相同的节奏退避吗?它们不会又一起重试?
👨🦳 叔叔:你刚刚找到了抖动(jitter)存在的原因 — 在退避时间上再加上一个小的随机延迟,让重试分散开来,而不是再次形成洪水。
无抖动: 1000个客户端在完全相同的毫秒重试
有抖动: 1000个客户端的重试分散在一个小的随机窗口内
Enter fullscreen mode Exit fullscreen mode
第3部分 — 降级
👨🦳 叔叔:有时正确的响应不是“再试一次”。而是“改做其他事情”。
👦 侄子:比如什么?
👨🦳 叔叔:你的推荐服务宕机了。用户应该看到什么?
👦 侄子:一个错误,我猜?“推荐不可用。”
👨🦳 叔叔:你是更想看到错误,还是一个通用的“热门商品”列表?
👦 侄子:……当然是热门商品列表。没人想因为这么小的事看到错误。
👨🦳 叔叔:这就是降级 — 用持续可用的功能换取完美的功能。
理想路径: 用户 → 个性化推荐 (最佳体验)
失败路径: 用户 → 热门商品降级 (足够好的体验)
最差情况: 用户 → 错误页面 (尽可能避免)
Enter fullscreen mode Exit fullscreen mode
👦 侄子:但每个东西都值得做降级吗?
👨🦳 叔叔:你希望在支付失败时有降级吗 — “无法验证扣款,所以我们随机收取一个金额”?
👦 侄子:天哪,不要。
👨🦳 叔叔:没错。降级适用于“不够完美”胜过“什么都没有”的场景。有些故障需要直接、响亮且正确地失败。
第4部分 — 队列
👨🦳 叔叔:你的注册流程在请求内同步发送欢迎邮件。邮件服务宕机了五分钟。注册会怎样?
👦 侄子:它们会……失败?或者挂起,等待邮件服务?
👨🦳 叔叔:注册真的需要等待邮件发送吗?
👦 侄子:……不需要。账户在那时已经创建完成了。
👨🦳 叔叔:所以不要让它等待。把邮件任务推送到安全的地方,立即响应用户,然后让工作进程在邮件服务恢复后发送。
没有队列:
注册请求 → 等待邮件服务 → 响应 (慢,且在邮件服务宕机时失败)
有队列:
注册请求 → 推送任务到队列 → 立即响应
|
工作进程在邮件服务就绪时处理
Enter fullscreen mode Exit fullscreen mode
👦 侄子:所以队列把“必须立即成功”变成了“最终会成功”。
👨🦳 叔叔:完全正确 — 仅这一转变就消除了整个类别的故障。这是路线图中的 BullMQ + Redis 组合 — BullMQ 处理队列和重试逻辑,Redis 保存任务数据。
第5部分 — 熔断器(预览)
👨🦳 叔叔:再来一个快速预览 — 完整内容在第3模块后面的集数。下游服务完全死了。每个请求还在尝试它,等待,超时,失败。这幅画面有什么问题?
👦 侄子:你……每次都在为每个请求浪费等待时间?
👨🦳 叔叔:没错。所以在连续几次失败后,系统干脆停止尝试 — 持续一段时间。
前几次请求 → 尝试,失败,失败 → 熔断器“跳闸”(OPEN)
|
所有后续请求 → 立即失败,不浪费等待
|
冷却期后 → 熔断器允许一次测试请求通过
|
如果成功 → 熔断器关闭,正常流量恢复
如果失败 → 熔断器保持打开,等待更长时间
Enter fullscreen mode Exit fullscreen mode
👦 侄子:所以它是故意暂时放弃,而不是无休止地抱有希望?
👨🦳 叔叔:快速且廉价地放弃,胜过缓慢且昂贵地失败,对每个请求都是如此。完整状态和实现 — 单独一集。
第6部分 — 优雅降级
👨🦳 叔叔:这其实不是一个新工具。它是前面三部分背后的理念。当某件事失败时,你愿意失去的最小东西是什么?
完整系统:
[ 核心结账 ] [ 推荐 ] [ 评论 ] [ 在线客服 ]
如果推荐服务失败:
[ 核心结账 ] [ (降级:跳过) ] [ 评论 ] [ 在线客服 ]
↑ 仍可工作,用户仍可购买
Enter fullscreen mode Exit fullscreen mode
👦 侄子:所以这是降级背后的“为什么” — 即使20%的锦上添花功能坏了,也要让重要的80%保持运行。
👨🦳 叔叔:完全正确。降级、超时、熔断器 — 它们都只是服务于这一个问题的工具:“如果这个部分失败了,我的系统怎样才能以损失最小的方式继续运行?”
第7部分 — 死信队列
👨🦳 叔叔:回到你的队列。一个任务失败了。重试一次、两次、三次。仍然失败。现在它会怎样?
👦 侄子:……它会永远尝试吗?
👨🦳 叔叔:你希望它这样吗?
👦 侄子:不 — 那会永远在明显不会成功的事情上浪费资源。
👨🦳 叔叔:所以它会被默默丢弃?
👦 侄子:那感觉更糟。你会丢失任务却永远不知道它发生过。
👨🦳 叔叔:没错 — 两种选择都不可接受。所以它会去一个特定的地方:死信队列。不是删除,也不是无休止重试 — 被放在一边,留给人工查看。
任务失败 → 重试1 → 重试2 → 重试3 → 仍然失败
|
移至死信队列(不删除,不默默丢弃)
|
工程师稍后审查:“为什么这个特定任务一直失败?”
Enter fullscreen mode Exit fullscreen mode
👦 侄子:所以DLQ本质上是一个穿着队列外衣的检测工具。
👨🦳 叔叔:正是如此。它在处理和检测之间闭环。
第8部分 — 工具与故障匹配
👨🦳 叔叔:最后一次。我给你故障,你告诉我工具。一次安全的可重复调用上发生了单次网络抖动。
👦 侄子:带退避的重试。
👨🦳 叔叔:一个下游服务完全、彻底地宕机了。
👦 侄子:熔断器。没必要去锤一个已经死了的服务。
👨🦳 叔叔:一个非关键功能 — 比如推荐 — 刚刚失败了。
👦 侄子:降级。或者优雅降级,其实是同一个想法。
👨🦳 叔叔:不需要在这一毫秒内完成的工作。
👦 侄子:把它放入队列。
👨🦳 叔叔:无论你怎么尝试都一直失败的任务。
👦 侄子:死信队列 — 停止盲目重试,让人工查看。
👨🦳 叔叔:你自己把整张表都构建出来了。
| 场景 | 正确工具 |
|---|---|
| 一次安全的可重复调用上发生单次网络抖动 | 重试 + 退避 |
| 一个下游服务完全宕机 | 熔断器 |
| 一个非关键功能失败 | 降级 / 优雅降级 |
| 不需要立即完成的工作 | 队列 |
| 无论如何都一直失败的任务 | 死信队列 |
| 可能被重试且涉及金钱/状态变更的操作 | 幂等性(第3模块) |
👨🦳 叔叔:而今天整个课程其实可以总结为一行话:最可怕的重试不是失败的那一次 — 而是悄无声息地成功两次的那一次。
实用的 Node.js 实现
今天讲的六种工具,集中在一个地方的真实代码。
重试
async function callPaymentGateway(payload) {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
return await paymentApi.charge(payload);
} catch (err) {
if (attempt === 3) throw err;
}
}
}
Enter fullscreen mode Exit fullscreen mode
带抖动的指数退避
async function retryWithBackoff(fn, maxAttempts = 5) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === maxAttempts) throw err;
const delay = Math.min(1000 * 2 ** attempt, 30000);
const jitter = Math.random() * 300;
await new Promise(r => setTimeout(r, delay + jitter));
}
}
}
Enter fullscreen mode Exit fullscreen mode
降级
async function getRecommendations(userId) {
try {
return await recommendationService.getFor(userId);
} catch (err) {
logger.warn({ userId }, 'Recommendation service down, using fallback');
return getPopularItems();
}
}
Enter fullscreen mode Exit fullscreen mode
队列
const { Queue } = require('bullmq');
const emailQueue = new Queue('emails', { connection: redisConnection });
app.post('/signup', async (req, res) => {
const user = await createUser(req.body);
await emailQueue.add('welcome-email', { userId: user.id });
res.status(201).json(user);
});
Enter fullscreen mode Exit fullscreen mode
死信队列(通过 BullMQ 的内置失败状态)
const emailQueue = new Queue('emails', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 }
}
});
const failedJobs = await emailQueue.getFailed();
Enter fullscreen mode Exit fullscreen mode
👦 侄子:好的。所以我们已经检测到了,现在我们已经处理了。
👨🦳 叔叔:没错。
👦 侄子:那还剩下什么?危险已经被控制 — 系统如何真正恢复到完全健康?
👨🦳 叔叔:那就是恢复开始的地方。
👦 侄子:星期六?
👨🦳 叔叔:星期六。
第4集内容回顾
- 重试 — 强大但在非幂等操作(如支付)上危险
- 指数退避 + 抖动 — 在不让过载服务变得更糟的情况下重试
- 降级 — 用持续可用的功能换取完美的功能
- 队列(BullMQ + Redis)— 把“必须立即成功”变成“最终会成功”
- 熔断器 — 停止对已经宕机的服务进行浪费调用的预览
- 优雅降级 — 降级和熔断器背后的设计原则
- 死信队列 — 永久失败的任务去的地方,而不是消失或永远重试
- 将正确的处理工具匹配到正确的故障类型
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.