错误隔离
一个环节失败,整个系统瘫痪
支付服务挂了。用户无法下单。 推荐引擎挂了。整个首页白屏。 评论功能挂了。文章都无法打开。
这些场景很常见。一个功能失败,拖垮整个系统。
问题不在于支付服务会失败——所有服务都会失败。问题在于,为什么支付失败会导致整个下单流程卡死?为什么推荐引擎的问题会让首页完全不可用?
没有隔离。
失败像病毒一样传播。一个组件的错误,蔓延到调用它的组件,再蔓延到更上层的组件。最终,整个系统都被感染。
💡 Click the maximize icon to view in fullscreen
隔离的本质
错误隔离不是防止失败,而是限制失败的范围。
一个服务挂了,只影响那个服务本身。不影响调用它的服务,不影响整个系统。
这需要三个前提:
1. 快速失败
不要等待一个失败的服务慢慢超时。给它一个明确的时间限制。2秒、5秒,超过就立即放弃。
// ❌ 没有超时,可能永远等待
async function callPaymentService() {
return await paymentAPI.charge()
}
// ✅ 2秒超时,快速失败
async function callPaymentService() {
return await Promise.race([
paymentAPI.charge(),
timeout(2000)
])
}
等待 30 秒才发现失败,这 30 秒里,调用方被阻塞,资源被占用,其他请求排队等待。快速失败,把损失控制在最小。
2. 断路器(Circuit Breaker)
如果一个服务已经失败了 10 次,第 11 次调用它大概率还会失败。与其继续尝试,不如直接跳过。
断路器会监控失败率。当失败率超过阈值,断路器打开,所有请求直接返回失败,不再调用下游服务。
过一段时间,断路器会尝试放一个请求过去,看看服务是否恢复。如果恢复了,逐渐恢复流量。如果还是失败,继续阻断。
class CircuitBreaker {
constructor(threshold = 5) {
this.failureCount = 0
this.threshold = threshold
this.state = 'CLOSED' // CLOSED, OPEN, HALF_OPEN
}
async call(fn) {
if (this.state === 'OPEN') {
throw new Error('Circuit breaker is OPEN')
}
try {
const result = await fn()
this.onSuccess()
return result
} catch (error) {
this.onFailure()
throw error
}
}
onSuccess() {
this.failureCount = 0
this.state = 'CLOSED'
}
onFailure() {
this.failureCount++
if (this.failureCount >= this.threshold) {
this.state = 'OPEN'
setTimeout(() => this.state = 'HALF_OPEN', 60000)
}
}
}
这不是放弃,而是止损。给失败的服务时间恢复,而不是继续给它压力。
3. 优雅降级
支付服务挂了,不代表用户不能浏览商品。推荐引擎挂了,可以显示默认内容。评论功能挂了,文章依然可以阅读。
核心功能保持可用,非核心功能降级或隐藏。
async function renderPage() {
// 核心内容:必须成功
const article = await getArticle()
// 次要功能:失败后降级
let recommendations = []
try {
recommendations = await getRecommendations()
} catch (error) {
// 降级:使用默认推荐
recommendations = getDefaultRecommendations()
}
// 可选功能:失败后隐藏
let comments = null
try {
comments = await getComments()
} catch (error) {
// 降级:不显示评论区
comments = null
}
return render({ article, recommendations, comments })
}
完美的体验固然好,但不完美的体验胜过完全不可用。
资源隔离:不同功能使用独立资源池
一艘船被分成多个舱室。其中一个舱室漏水,其他舱室仍然密封。船不会沉。
在系统设计中,舱壁模式意味着:不同的功能使用独立的资源。
// ❌ 所有请求共享一个连接池
const pool = createConnectionPool({ max: 10 })
async function handleUserRequest() {
return await pool.query('SELECT * FROM users')
}
async function handleReportRequest() {
return await pool.query('SELECT * FROM analytics')
}
// 如果报表查询很慢,占满了连接池,
// 用户请求也会被阻塞
// ✅ 不同功能使用独立连接池
const userPool = createConnectionPool({ max: 8 })
const reportPool = createConnectionPool({ max: 2 })
async function handleUserRequest() {
return await userPool.query('SELECT * FROM users')
}
async function handleReportRequest() {
return await reportPool.query('SELECT * FROM analytics')
}
// 报表查询再慢,也不会影响用户查询
关键资源(CPU、内存、连接、线程)都可以分舱。一个功能出问题,不会拖累其他功能。
隔离的代价
错误隔离不是免费的。
你需要更多的监控。要知道哪个服务失败了,失败率多高,何时恢复。
你需要更复杂的逻辑。要处理降级,要管理断路器状态,要区分不同级别的失败。
你需要接受不完美。系统不再是"全有或全无",而是"能用多少算多少"。
但这个代价是值得的。
因为在分布式系统中,失败是常态。网络会断,服务会挂,依赖会超时。
你无法消除失败,但你可以控制失败的影响范围。
隔离让系统变得有韧性。即使部分组件失败,整体仍然可用。这种可用性,比完美的性能更重要。
不只是技术
错误隔离的思想不局限于代码。
团队协作也需要隔离。一个项目延期,不应该阻塞其他所有项目。一个人请假,不应该导致整个团队停摆。
人际关系也需要隔离。一次争吵,不应该毁掉整段友谊。一个误解,不应该推翻所有信任。
隔离不是冷漠,而是保护。
它承认:失败会发生。错误会出现。但我们可以限制它们的破坏力。
一个环节出问题,不代表全盘崩溃。系统仍然可以运转,只是稍微降级而已。
接受不完美
追求完美的系统,是一种执念。
每个功能都必须可用,每个服务都必须在线,每个请求都必须成功。
但现实不是这样。完美是脆弱的。任何一个环节出错,整个系统就崩溃。
错误隔离是在说:我们不需要完美,我们需要韧性。
支付功能挂了?用户还可以浏览。 推荐引擎挂了?用户还可以搜索。 评论功能挂了?用户还可以阅读。
系统依然在运行。也许不是最佳状态,但它还活着。
在一个充满不确定性的世界里,能够在部分失败的情况下继续运转,本身就是一种成功。
在什么情况下,错误隔离反而会降低系统可靠性?
如何判断一个功能是"核心功能"还是"可降级功能"?这个边界如何划定?
断路器的阈值如何设置?失败多少次打开?恢复时间多长?不同场景有什么不同?
如果所有下游服务都失败了,系统还能提供什么价值?降级的底线在哪里?
错误隔离与用户体验的权衡:什么时候应该选择"完全失败并明确告知",而不是"部分降级"?
在团队协作中,如何实现"错误隔离"?如何避免一个人的失误影响整个团队?
相关阅读:
博客内链
- 队列、可靠性与系统边界 - 通过队列实现解耦与隔离
- 指数退避超时 - 防止失败蔓延的重试策略
- 幂等性检查 - 在重试场景下保证系统一致性
Related Posts
Articles you might also find interesting
幂等性检查
在不确定的系统中,幂等性检查是对重复的容忍,是对稳定性的追求,也是对失败的预期与接纳
文档标准是成本计算的前提
API文档不只是写给开发者看的。它定义了系统的边界、成本结构和可维护性。统一的文档标准让隐性成本变得可见。
CRUD 操作
四个字母背后,是数据的生命周期,是权限的边界,也是系统设计的基础逻辑
实现幂等性处理,忽略已处理的任务
在代码层面识别和忽略已处理的任务,不是简单的布尔检查,而是对时序、并发和状态的深刻理解
单例模式管理 Redis 连接
连接不是技术细节,而是系统与外部世界的第一次握手,是可靠性的起点
监控观察期法
部署不是结束,而是验证的开始。修复代码只是假设,监控数据才是证明。48小时观察期:让错误主动暴露,让数据证明修复。
监听 Redis 连接事件 - 让不可见的脆弱变得可见
连接看起来应该是透明的。但当它断开时,你才意识到透明不等于可靠。监听不是多余,而是对脆弱性的承认。
指数退避超时 - 防止无限重试循环
失败后立即重试是本能。但有些失败,需要时间来消化。指数退避不是逃避失败,而是尊重失败。
管理后台需要两次设计
第一次设计回答"发生了什么",第二次设计回答"我能做什么"。在第一次就试图解决所有问题,结果是功能很多但都不够深入。
告警分级与响应时间
不是所有问题都需要立即响应。RPC失败会在凌晨3点叫醒人。安全事件每15分钟检查一次。支付成功只记录,不告警。系统的响应时间应该匹配问题的紧急程度。
API 测试各种边界情况
边界情况是系统最脆弱的地方,也是最容易被忽略的地方。测试边界情况不是为了追求完美,而是为了理解系统的真实边界。
BullMQ 队列
队列不是技术选型,而是对时间的承认,对顺序的尊重,对不确定性的应对
BullMQ Worker
Worker 的本质是对时间的重新分配,是对主线的解放,也是对专注的追求
配置不会自动同步
视频生成任务永远pending,代码完美部署,队列正确配置。问题不在代码,在于配置的独立性被低估。静默失败比错误更危险。
数据库参数国际化:从 13 个迁移学到的设计原则
数据不该懂语言。当数据库参数嵌入中文标签时,系统的边界就被语言限制了。这篇文章从 13 个参数对齐迁移中提炼出设计原则——国际化不是功能,是系统设计的底层约束。
Stripe Webhook中的防御性编程
三个Bug揭示的真相:假设是代码中最危险的东西。API返回类型、环境配置、变量作用域——每个看似合理的假设都可能导致客户损失。
让文档跟着代码走
文档过时是熵增的必然。对抗衰败的方法不是更频繁的手工维护,而是让文档"活"起来——跟随代码自动更新。三种文档形态,三种生命周期。
双重验证:Stripe生产模式的防御性切换
从测试到生产不是更换API keys,而是建立一套双重验证系统。每一步都在两个环境中验证,确保真实支付不会因假设而失败。
端到端 Postback 模拟测试
真实的测试不是模拟完美的流程,而是重现真实世界的混乱。Postback 测试的价值在于发现系统在不确定性中的表现。
在运行的系统上生长新功能
扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。
缺失值的级联效应
一个NULL值如何在调用链中传播,最终导致错误的错误消息。理解防御层的设计,在失败传播前拦截。
Props Drilling
数据在组件树中层层传递,每一层都不需要它,却必须经手。这不是技术债,是架构对真实需求的误解。
队列生产者实例的工厂函数
工厂函数不是设计模式的炫技,而是对重复的拒绝,对集中管理的追求,对变化的准备
资源不会消失,只会泄露
在积分系统中,用户的钱不会凭空消失,但会因为两个时间窗口而泄露:并发请求之间的竞争,和回调永不到达的沉默。
RPC函数的原子化处理
当一个远程函数做太多事情,失败就变得难以理解