最早看到这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());
这些我认为都会提高容错,让系统在指定的场景下提供更多的容错能力,让上游、用户无感的继续业务流程。
相对应要付出的代价就是:
- 接入方对自身错误无感知:A接入方的错误被下游B的容错弥补了,导致过度依赖下游B的容错,在下游修改容错机制、降低容错级别的时候,就会暴露出严重的问题,而且问题是暴漏在B,会导致A的排查路径十分麻烦。
- 降低了故障修复的优先级 。有了容错设计,即使负责人知道了故障的问题所在,也很可能主观上忽视了立刻修复故障的重要性。“这个问题可以再等等”,可能很多人会这样想。可是如果在此期间容错的设计也失效了,将导致整个系统的失效。
- 额外消耗:需要提供额外的内存、调度资源等
第三部分:总结
不同场景下的选择:
系统的内部实现采用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