快速拒绝才是体贴
一句话总结
一个缓慢的目标把整个枢纽拖垮,称为级联故障。防止的办法是两层叠加——对已经死掉的目标不调用,直接拒绝(断路器),对活着但很慢的目标,按目标限制可同时发送的量(舱壁,bulkhead)。二者遵循同一个原理:“与其让人等待,不如快速拒绝”,也就是背压。
为什么需要它
假设账户系统停了。枢纽对每个请求都去连接账户系统并等待响应。如果第 4 模块的超时是 3 秒,那么在每秒进来 100 笔的期间,枢纽里会堆积 300 个等待的线程。每个线程都占着内存和套接字,所以枢纽先窒息,连与账户系统毫无关系的保险理赔交易,也在枢纽前面排起了队。一个账户系统的故障,经由枢纽,变成了所有交易的故障。
只靠超时是不够的。超时只回答“一笔要等多久”,并不回答“对一个明知已经死掉的目标,是否还要继续调用”。而且,对一个奄奄一息的目标继续倾泻请求,也剥夺了它恢复的机会。
工作原理
断路器。正如 Martin Fowler 的文章所总结的,包裹在受保护调用外面的对象会统计失败次数,达到阈值时就打开回路。打开期间不再调用,立即返回错误。Fowler 写道,这个模式因 Michael Nygard 的《Release It!》一书而广为人知。状态有三个。
| 状态 | 调用 | 转换 |
|---|---|---|
| CLOSED | 调用 | 连续失败达到阈值 → OPEN |
| OPEN | 不调用(立即 E904) | 超过重试时间 → HALF_OPEN |
| HALF_OPEN | 只做一次试探调用 | 成功 → CLOSED,失败 → OPEN(时间从头开始) |
半开(HALF_OPEN)是关键。如果把回路永远打开,目标恢复了也不知道;如果因为时间到了就一次全部关闭,积压的请求会涌向刚刚恢复的目标,再次把它压垮。先用一次试探,这一次成功就关闭。即使多个线程同时询问,试探调用也必须只有一次,所以状态变更要在锁内进行。
什么算作失败。余额不足(B201)是账户系统健康地作了答复。如果把它算作失败,仅仅因为月底余额不足的请求集中出现,就会自己把通往正常账户系统的路切断。失败是指目标说自己没能处理的(E500)、没有答复的(E901)、连连接都没有成功的(E902)——只统计系统错误。
舱壁(bulkhead)。就像船上的隔舱壁,一个舱进了水,其他舱仍保持干燥的设计。为每个目标单独设置并发处理上限(信号量)。如果账户系统的份额是 3,发往账户系统的请求最多同时进入 3 笔,第四笔起就不等待,直接以过载(E905)拒绝。理赔有自己的份额,所以即使账户系统变慢,理赔交易也能用自己的份额快速通过。如果让超出上限的请求排队等待,最终线程还是会堆积,回到最初的问题——快速拒绝,调用方(渠道)就可以在重试、替代路径和提示文案中自行选择。
断路器与重试。重试用来越过临时性的失败,断路器则用来在持续性的失败时停止调用。如果重试的一方连断路器的拒绝(E904)也去重试,断路器打开就没有意义了。所以要另设拒绝码,调用方对 E904、E905 不要立即重试。
数值怎么定。连续失败的阈值如果太低,瞬间的抖动也会使回路打开,太高则会长时间调用已死的目标。重试时间如果比目标通常恢复所需的时间更短,半开试探就会一直失败,太长则在恢复之后仍会拒绝很久。并发处理上限要设在不超过目标所能承受的并发度(连接池大小等)。没有标准答案的数值,先根据目标平时的指标确定,再对照转换记录来调整。
记录状态转换。断路器打开,这件事本身就是故障信号。如果把转换(CLOSED→OPEN、OPEN→HALF_OPEN、HALF_OPEN→CLOSED)连同时刻和目标一起留下,“账户系统在 14:02 打开、14:05 关闭”就一行可见。在生产环境中,要把这种转换接到告警上。
在现场相遇的样子
最常见的失误,是断路器只设一个。如果给整个枢纽只挂一个断路器,账户系统的故障会让理赔、银行卡交易也收到 E904——这不是隔离,而是全面阻断。第二种是只用一个线程池来设置并发处理上限。如果整个池都被一个目标锁住,结果与没有舱壁一样。第三种是把业务错误算作失败的断路器。第四种是没有半开,而是做成“30 秒后自动关闭”——关闭的瞬间,积压的请求会一起涌来。最后,如果没有断路器已打开的记录,运维人员只有在接到“E904 为什么会出现”的咨询之后才会知道。
下一项实验要做什么
在 breaker.py 中制作状态机,评分器会用假时钟拨动时间来确认转换。然后把它接到向两个目标(账户系统、理赔)中继的骨架(relay_base.py)上——对已死的目标立即 E904,超出并发上限则 E905,按目标分开隔离,记录状态转换,业务错误不算作失败。评分器通过账户系统夹具的调用数和并发处理最大值来确认。