防御性架构:让系统不怕出错

防御性架构

做金融系统这些年,我越来越相信一件事:系统的可靠性,不是靠"不出错"实现的,而是靠"出错后还能活着"实现的。

这个区别很关键。前者追求的是完美,后者追求的是韧性。完美是不可能的,韧性是可以工程的。

我把这种设计理念叫做防御性架构。不是什么新理论,但在实践中,大部分前端团队只做到了"防御性编码"(判空、try-catch),远没有到架构层面。

防御性编码和防御性架构的区别

防御性编码是在代码层面防止程序崩溃:参数校验、异常捕获、默认值兜底。这些当然要做,但它们解决的是"这一行代码不报错"的问题。

防御性架构解决的是另一个问题:当系统的某个部分出了意料之外的问题时,用户还能不能完成核心操作?

举个例子。一个理财产品的详情页,需要调用三个接口:产品基本信息、历史收益曲线、用户持仓。如果历史收益接口超时了,页面怎么办?

大部分做法:整个页面 loading 转圈,直到超时,然后显示"加载失败,请重试"。

防御性做法:产品基本信息和持仓正常渲染,历史收益区域显示一个"暂时无法加载"的占位,用户依然可以看到自己持有多少、收益是多少。核心功能不受影响。

这不是一个 try-catch 能解决的,这是一个架构决策。

五层防线

我在实际工作中把防御性架构分成五层,从构建到运行,逐层递进。

第一层:构建防线

问题应该在到达生产环境之前被拦截。TypeScript 的严格模式、ESLint 规则、构建时的类型检查,这些都是构建防线的一部分。但大部分人只做到了语法层面的检查。

更有效的是契约检查。前后端之间应该有 API 契约(OpenAPI/Swagger),构建时自动校验前端调用是否与后端契约一致。类型变了、字段删了、结构改了,构建就应该失败。

在金融业务中,我们还加了一层:关键业务逻辑的单测覆盖率门槛。不是追求 100%,而是对核心计算(收益、费用、份额)相关的模块要求 80% 以上覆盖。这些模块一旦出错,影响的是用户的钱。

第二层:契约防线

前后端之间的数据契约,不应该只靠口头约定和文档。

我见过最常见的事故原因之一:后端改了一个字段的类型,从 number 变成了 string,前端没有感知,页面渲染出了问题。或者后端新增了一个必填字段,前端没传,接口报错。

解决方案不复杂:用 TypeScript 生成 API 客户端代码,或者在请求层加运行时类型校验(Zod、io-ts)。但关键是把这个动作嵌入到工程流程里,而不是依赖某个人"记得去检查"。

在 BFF 架构中,这一层更容易实现:BFF 本身就是契约的执行点。请求进来之前校验参数,响应出去之前校验结构。不合法的请求不转发,不合法的响应不返回。

第三层:运行时防线

这是大部分团队最熟悉的一层,但做得往往不够系统。

React 的 ErrorBoundary 是运行时防线的基础组件。但我在实践中发现,很多团队只在应用最外层放一个 ErrorBoundary,捕获所有错误,然后显示一个通用的"出错了"页面。

这等于没有防御。

正确的做法是分区隔离。页面的不同模块应该有独立的 ErrorBoundary,一个模块出错不应该影响其他模块。就像船的水密舱——一个舱进水了,其他舱还能保持浮力。

除了组件级别的隔离,还有数据级别的隔离。接口请求应该有超时、有重试、有降级。不是所有接口都同等重要,区分核心接口和非核心接口,对它们采用不同的容错策略。

产品基本信息是核心接口,超时 3 秒后重试一次,再失败显示缓存数据。推荐列表是非核心接口,超时 2 秒直接降级为默认推荐或隐藏该模块。

第四层:流量防线

这一层很多前端团队不参与,但我认为应该参与。

灰度发布不是运维的事,是工程的事。一个新功能上线,直接全量推开,等于把所有用户都变成了测试用户。在金融系统中,这是不可接受的风险。

合理的做法:新功能先推 1% 用户,观察错误率和核心指标,没有问题再逐步放量。前端应该有能力通过 Feature Flag 控制功能的开启和关闭,而不是依赖发版来开关功能。

更进一步的,是做预案机制。每一个可能出问题的功能点,都应该有一个预案:如果这个功能出了问题,怎么快速降级或关闭?这个预案不是写在文档里的,是写在代码里的。

一个开关,能在不发版的情况下,30 秒内关闭一个有问题的功能。这就是预案的工程化。

第五层:恢复防线

前面的防线都失效了,系统真的出问题了,怎么办?

这时候最重要的是两件事:知道出了问题,以及快速恢复。

监控告警不是"配了就行",而是要分级。什么是 P0 级别的错误(核心功能不可用)?什么是 P3 级别的错误(某个非关键图片加载失败)?不同级别的错误应该有不同的响应速度和通知方式。

恢复速度取决于预案的完备程度。如果每次出问题都要"排查原因→定位代码→修复→发版",那恢复时间至少是小时级的。如果有预案,一个开关切过去,分钟级恢复,原因可以后面再查。

先止血,再治病。

金融场景的特殊要求

金融系统对防御性架构的要求比普通业务高一个量级,原因很简单:出错影响的是用户的钱。

几个金融场景的特殊实践:

数据一致性:用户的持仓数据、收益数据,前后端必须严格一致。任何不一致都应该被视为事故,而不是"显示问题"。我们为此做了前后端数据对账机制——前端渲染的关键数值会发送到监控平台,与后端数据自动比对。

幂等性:涉及交易的操作(买入、卖出、赎回),必须做幂等处理。用户点了两次"确认购买",不能真的买两次。前端做防重复提交只是表面,后端的幂等设计才是根本。

审计日志:金融操作必须有完整的操作记录。谁在什么时间做了什么操作,请求参数是什么,响应结果是什么。这不是为了排查 bug(虽然也有这个作用),更是为了满足监管要求。

防御的成本

说到这里,你可能会觉得:这成本也太高了吧?每个接口都要做降级,每个功能都要做预案,每个模块都要做隔离。

确实有成本。防御性架构不是免费的。

但成本的判断标准不是"花了多少时间做防御",而是"不做防御的代价有多大"。

一个普通的内容展示型网站,挂了半小时,影响的是 PV。一个理财交易系统,挂了半小时,影响的是用户的资金操作和信任。代价完全不同,防御投入也应该不同。

我的建议是:分级防御

对核心链路(交易、支付、资产展示)做最高等级的防御,五层防线全部到位。对非核心功能(推荐、营销、活动页)做基础防御就够了,允许偶尔出错。

不是所有代码都值得同等的保护。把最多的防御资源放在最重要的地方,这是架构决策,也是资源决策。

写在最后

防御性架构的核心理念其实就一句话:假设一切都会出错,然后设计出在出错情况下仍然可用的系统。

这听起来有点悲观,但我觉得这恰恰是最务实的态度。与其花大量精力追求"不出错"(这不可能),不如花精力设计"出错后怎么办"。

一个好的系统,不是永远不出问题的系统,而是出了问题用户感知不到的系统。

对于前端开发者来说,这意味着你的工作不只是"把页面做出来",而是"确保页面在各种条件下都能正确工作"。这个认知转变,可能就是初级工程师和高级工程师之间最本质的差距。

You voted 1. Total votes: 12

添加新评论