从fail-fast和fail-safe看容错设计理念

从迭代器fail-fast和fail-safe看两种不同的策略在软件设计中的体现

最早看到这fail-fast的概念是在ArrayLIst的类注释中:

这是ArrayList所使用的迭代器所使用的一种策略,迭代器会在自己的成员变量modCount中记录修改次数,并且在创建迭代器的时候复制一份,并且命名为expectedModCount,在每次调用next()、previous()、set()….的时候,先去检查一下二者是否相等,如果不相等,证明modCount被修改了,然后抛出ConcurrentModificationException来中断不安全的操作,这样就能杜绝在遍历的时候集合被修改(即使是单线程环境),这就是fail-fast机制,保证任何错误在被发现的第一时间就产生后果。

而copyOnWriteArraylist就采用了不一样的设计,在遍历的时候,实际遍历的是在迭代器创建的时候就复制的数组,这样可以保证对集合的遍历不被打断,这就是fail-safe机制,使用必要的方式规避掉能遇到的错误和故障。

这两种设计和策略可以引申到软件的设计理念中,值得深究一下

第一部分:Fail-fast设计理念

针对fail-fast,Arraylist中给出的解释是:

Thus, in the face of concurrent modification, the iterator fails quickly and cleanly, rather than risking arbitrary, non-deterministic behavior at an undetermined time in the future.
因此,面对并发修改,迭代器会快速而干净地失败,而不是在未来不确定的时间冒任意、非确定性行为的风险。

我认为 所有业务代码中,对参数的校验,对上下文的校验都属于是一种fail-fast机制,这种判断逻辑已经在开发的每一个角落了,比如:

  • 对必要参数的强校验:鉴权、必要的业务key等等
  • 启动过程中的健康检查:检查数据库连接等

重点是可以非常棒的改善服务的健壮性,有问题就早早抛出来,哪怕程序因此无法启动,配合上灰度发布,也能有时间关闭流量、暂停当前分组的发布,定位并hotFix。而非眼看着迭代发布成功,却在运行时,或者在特定情况下才会抛出一些异常。这是故障的重灾区,更有甚者就是偷偷吞异常的,排查特别困难。建议最高人民法院立法杜绝这种情况,先崩后审。

而且在编码风格上也有一些帮助,尽早抛出/结束异常分支,能让代码简单、干净。

一样的逻辑 上下能形成两种情况

第二部分:Fail-safe设计理念


什么是fail-safe?

就是安全的失败,用一定程度的不一致来保证主流程的推进。

stackoverFlow上有个回复说的很棒,表示这种情况可以理解为弱一致性,即容忍一定程度的错误,以最小代价结束,在CopyOnWriteArrayList表现为遍历的元素可能会比实际的集合中的少,这就是我理解的fail-safe。


并且还可以由此推广到更大的领域:容错设计、防御性编程等,系统容错设计中,常用的冗余、降级、托底方案都是fail-safe的体现,诸如缓存未命中情况下的托底查询,以及:

  • 收单流程中,如果没有显式传入支付宝2088id是应该直接抛出异常阻断支付,还是根据请求的用户上下文中获取2088以支持继续业务流程?我认为如果后者获取的id是可信的,那么确实可以继续程序而非阻断。
  • mysql主从复制:主服务器处理所有的写操作,而一个或多个从服务器复制主服务器的数据变更。这样,如果主服务器发生故障,可以迅速将其中一个从服务器提升为新的主服务器,从而保证业务的连续性
  • 金融领域在金额计算的时候,一般都会建议在把金额对象传递给其他方法的时候,先克隆一份,防止对象被篡改。(其实不完全算是failsafe啦)
//金额传递给其他方法时,建议clone一份
MultiCurrencyMoney outAmount = new MultiCurrencyMoney("200.83", CurrencyEnum.CNY.getCurrencyValue());
request.setExchangeOutAmount(currencyMoney.clone());
request.setTransactionAmount(currencyMoney.clone());

这些我认为都会提高容错,让系统在指定的场景下提供更多的容错能力,让上游、用户无感的继续业务流程。

相对应要付出的代价就是:

  1. 接入方对自身错误无感知:A接入方的错误被下游B的容错弥补了,导致过度依赖下游B的容错,在下游修改容错机制、降低容错级别的时候,就会暴露出严重的问题,而且问题是暴漏在B,会导致A的排查路径十分麻烦。
  2. 降低了故障修复的优先级 。有了容错设计,即使负责人知道了故障的问题所在,也很可能主观上忽视了立刻修复故障的重要性。“这个问题可以再等等”,可能很多人会这样想。可是如果在此期间容错的设计也失效了,将导致整个系统的失效
  3. 额外消耗:需要提供额外的内存、调度资源等
javaDoc中也会特意提示一下这种复制机制对内存的消耗

第三部分:总结

不同场景下的选择:
系统的内部实现采用fail-fast,特别是重点场景,对于一些配置的检查要尽可能的风险前置,提前暴露。对外接口也尽可能减少非必要的托底,防止被滥用,

涉及稳定性或者重点流程,如果一定要托底,也要用日志+告警等方式让其暴露出来,推动上游去治理,不可听之任之。

但是这两者中间也是有一些灰色地带的,我觉得可以尽量采用fail-fast,减少系统维护、bug定位的心智成本,如果不是强诉求,就让问题在能暴露的时候尽量全暴露出来。

关于CopyOnWriteArrayList使用的弱一致性在,其实还有更多可说的,先留个坑,等积累和认知足够了来填上。

参考文献:

https://www.martinfowler.com/ieeeSoftware/failFast.pdf

https://anmolsehgal.medium.com/fail-fast-and-fail-safe-iterations-in-java-collections-11ce8ca4180e

https://stackoverflow.com/questions/17377407/what-are-fail-safe-fail-fast-iterators-in-java

发表回复

蒙ICP备2022000577号-1