Anti-patterns & Patterns in Microservice Architecture
Last updated
Last updated
微服务架构下,服务之间的连接点 (Integration Point) 越来越多,每个连接点都可能成为故障的起点。连接点的两端通常通过网络相连,而问题可能发生在网络中的每一层抽象中,如应用层 (HTTP)、传输层 (TCP)、网络层 (IP)、数据链路层 (ARP) 等等。一个连接点的故障可能引发雪崩效应,快速地引发其它连接点的故障。因此在连接点之间,一定要合理地应用一些设计模式来控制风险。
设计模式:Circuit Breaker, Timeout, Decoupling Middleware, Handshaking
我们常常会通过“负载均衡 + 多副本”的方式来提升服务的可靠性、增加服务的吞吐能力:
当其中一个副本发生故障不可用时,该副本的流量会被均摊到其它副本上:
通常引起副本不可用的原因对于其它副本同样适用,而这些副本由于承受了更多的流量发生同样问题的概率也将提高,引起连锁反应。且当副本越来越少时,连锁反应的速度也将越来越快,最终导致整层业务不可用。
值得关注的是:
副本不可用的原因可能来自于内存泄漏、并发逻辑缺陷以及其它资源不足等问题,这些都是开发者需要尤为关注的地方。
通过 Autoscaling 能够在一定程度上掩盖或者减缓问题,但它并没有关注到问题的本质。
设计模式:Bulkheads
单个服务、数据库、中间件 (副本) 的故障,都可以比喻成是系统上出现的一道裂缝,在高压下这道裂缝可能会越来越大,可能引发其它服务的故障,如:
共享数据库集群:单个服务的大量 cpu/io 密集型的查询将数据库打满导致其它服务无法正常访问数据
服务同步依赖:单个服务的不可用导致上游服务的同步请求阻塞,进而将这种影响递归传递到入口
为了防止级联故障的发生,开发者需要关注依赖服务不可用时的降级逻辑,做好应对措施。经验上看,级联故障的首要故障源是资源池 (resource pool)。以连接池为例,当连接阻塞时,其它线程都将阻塞,从而导致服务不可用,引发级联故障。
设计模式:Timeouts, Circuit Breaker
用户是系统服务的对象,也是所有请求的发起者。当在线用户数量忽然增大或单个用户请求量增大时,系统的流量随即增大。在系统设计时,开发者就应该这些超额流量有所防范:
用户会话 (session) 占用内存:减少会话数据、使用弱引用 (weak reference) 可以减少资源损耗
用户异常、随机举动:如当系统故障时反复发送请求等等
恶意用户:恶意攻击、爬取信息
热点用户:大 V 发微博
设计模式:Circuit Breaker, Handshaking, Bulkheads
线程阻塞几乎是大部分故障的主要原因,通常遇到线程阻塞的服务会表现出:
启动时一切正常
慢慢服务的吞吐量、处理请求的速度下降
服务彻底阻塞
服务中发生线程阻塞后,常常引发连锁反应,进而导致级联故障,避免的手段也与二者类似:
检查资源池
使用已经被验证过的并发工具
合理运用超时机制
对第三方提供的代码保持警惕
设计模式:Timeouts
自我拒绝攻击其实与系统本身无关,它通常是因为系统的某种逻辑设计引发大量用户在某段较短的时间内大量并发请求。最经典的例子就是电商的秒杀活动。避免自我拒绝攻击的方式有很多,从后端架构上看:
利用 shared-nothing 架构实现服务的横向扩容
热点请求背后共享的资源同样需要支持横向扩容
使用解耦中间件来削峰
设计服务降级方案,如将悲观锁方案修改为乐观锁方案
使用连续多次活动将用户分流
设计模式:Decoupling Middleware, Bulkheads, Shed Loads
通常在开发环境和 QA 环境中,每个服务实例的数量很少,接收到的请求数量也很少,这与生产环境中的状况完全不同。在生产环境中,服务的流量通常要更大。因此一些在实际流量规模下才会被触发的问题就会被遗漏到生产环境中。
考虑这样一个场景:服务的之间需要点对点交流。在只有两个服务 (副本) 时,点对点开销很小,在生产环境中,服务(副本) 的数量成倍增加,点对点交流的复杂度则将呈指数级增加。当我们预料到生产环境存在规模效应时,就需要在方案设计阶段考虑好应对方案,如:
UDP 广播
TCP/UDP 多播
Publish/Subscribe Messaging
Message Queues
共享资源容易成为瓶颈,影响系统的稳定性。如果你的系统中存在某种类型的共享资源,尽量进行足够的压力测试,同时考虑当若共享资源响应变慢或者不可用时的降级方案。
设计模式:Test Harnesses, Circuit Breaker, Decoupling Middleware
容量不平衡的现象广泛地存在于上下游服务之间,在高峰期间,上游服务的容量可能数十倍地增长。如在 Users 一节中提到的秒杀活动。因此在实际项目中,开发团队需要关注上下游服务之间在生产环境的容量比,一些周期性的用户行为可能导致上下游服务周期性地出现容量差。即使在生产环境中的容量是固定的,也不要放弃在开发和 QA 环境中调整上下游服务的容量比例,进行压力测试,甚至将这些测试自动化。如果你是某后台系统的开发者,可以尝试将容量比调整成高峰期的十倍,并且命中代价最大的事务,来检查系统是否故障,响应是否变慢,在压力过后是否恢复正常;如果你是前端系统开发者,检查后端服务调用是否停止响应或者变得更慢。
设计模式:Test Harnesses, Fail Fast
断电恢复时,由于各家各户的电器仍然处于开启状态,瞬间将产生极高的峰值,造成二次故障,这种现象被称为 dogpile。开发者在设计程序时,要活用类似 CSMA/CD 中的退避 (backoff) 策略,尽量将不同请求分布到不同时段上。
基础架构中的自动管理工具如果不加以限制,在某些场景下,可能出现短时间内大范围修改系统的行为,如自动扩容可能在遇到瞬时高峰时无上限地启动大量副本,造成超额账单。这些自动控制系统通过对环境的感知来做出控制决定,但他们也可能被环境欺骗,开发者需要关注这些场景,有必要时将人工干预引入到系统中。
设计模式:Governor
从服务器端上看:当上游服务遇到下游服务响应变慢时,自身的响应也将变慢,如果超过超时时间将可能造成级联故障;从客户端上看:当系统响应变慢时,用户有可能不断地刷新重试,产生更大的流量造成系统进一步变慢,形成正向反馈,造成系统超载。导致这种现象的原因通常是内存泄露或者资源池紧张。
如果系统记录每个请求的响应时间,就可以发现响应变慢的情况,若系统发现响应时间以及超过上限,可以考虑直接返回失败响应。
设计模式:Fail Fast
如果开发环境的数据规模很小,则无法触发在大量数据下才可能出现的问题,因此在开发环境中应该尽量使用实际规模的数据集。例如列表查询,当数据规模很小时,你总是能很快地得到想要的结果,但是一旦进入到生产环境,数据规模增长,同样的查询就会命中更多的数据,引发故障。因此在开发时需要注意:
所有列表查询都尽量实现分页功能,并限制单次获取的数据上限
不要依赖于数据生产者,后者的逻辑可能会变,不能想当然地以为生产者会按照约定行事而埋下隐患
设计模式:Steady State
在连接点 (Integration Points)、线程阻塞 (Blocked Threads) 以及慢响应 (Slow Responses) 等反模式中,灵活运用超时模式来避免级联故障。通常情况下,上游服务只关心请求成败与否,不论请求成败只要能尽快响应即可,最怕出现长时间等待。另外要避免立即重试,一般故障不会立即自动解决,因此立即重试将可能继续引发相同的故障,继续造成超时。在需要重试的场景中,可以考虑将请求放入队列中,等待一段时间后再重试。
熔断器模式得名于在家庭电路控制系统中广泛使用熔断器。当电流过大时,熔断器自身的电阻材料发热熔断导致电路自动断开,防止电流继续增大。
微服务中的流量就类似电流,利用熔断器模式,当请求流量失败的数量攀升超过阈值时,熔断器就会打开,所有后续的请求都会直接失败,经过一段时间后,熔断器会试探性地置为半开 (Half-Open) 状态,让一个请求通过,如果这个请求最终成功,则熔断器关闭,否则继续打开。
熔断器是保护系统连接点的基本模式,利用超时模式判断请求失败与否,利用熔断器保护服务,避免超载。熔断器发生熔断意味着系统肯定存在异常,因此熔断信息必须被记录、上报、跟踪、解决,从而避免问题恶化。
bulkheads 是船上的隔断,当船体某处透水时,不至于让水充满整个船舱,将风险控制在单个隔断内。类似的技术可以被应用在微服务架构上,将系统合理隔离,让故障只在单个隔离区域内发生,不至于引发系统全局崩溃,常见用法如:
服务隔离
容器隔离
物理隔离
地域隔离
场景/功能隔离
用户隔离
bulkheads 设计模式使得系统在恶劣情况下也能保持部分功能正常运作。在不同的粒度下,bulkheads 都能起到相应的作用。
生产环境中,每次人工操作都可能成为重大事故的起因,合理的服务应该能够在没有人工干预的情况下稳定运行至少多个部署周期,即在各方面保持服务的稳态 (Steady State)。这就要求服务本身能够合理处理一些可能随着时间推进而吞噬资源的逻辑。
服务逻辑数据常常只增不减,当数据逐渐累积,开始填满数据库磁盘时,就可能影响数据库性能。这时候就需要清理没用的数据,即 Data Purging。因为数据表之间存在逻辑关联关系,DBA 无从了解相关信息,随意清理数据可能导致数据逻辑不一致。因此尽量在应用中实现数据清理逻辑,保证数据表的逻辑约束不受影响。有一种场景下 DBA 能够主动清理数据,当数据之间没有复杂关联,数据按时间分库分表时,DBA 就能够按照约定好的过期时间,将过期的数据直接清理,回收相应的磁盘空间。
内存中的缓存通常可以加速服务响应,提高性能,但缓存空间如果不受控制,就可能反过来将服务拖垮,因此缓存所占空间必须被加以限制,通过合理的置换策略保证缓存数据规模。
不要让日志文件无限制地增长,可以通过配置日志滚动策略达到这个目的。
如果服务无法满足 SLA 的要求,就必须迅速告诉调用方,而不是让调用方无意义地等待超时,然后得到必然会失败的结果;在实施服务逻辑时,应该将检查逻辑提前,尽早将不合理的、无法成功的请求过滤,避免资源浪费;在调用其它服务时,应该提前验证熔断逻辑是否成立,避免请求发出。
让服务崩溃听起来有点反直觉,但在很多时候它是让服务回到稳态的最佳方式,前提是重新启动服务的周期足够短。
类似 TCP 的握手,在调用方与服务方之间实现握手模式可以让双方有机会协商一些信息,增进互相了解,实现更合理的流量控制。类似地,利用健康检查接口可以实现服务与负载均衡器或者配置中心的握手逻辑。如果你需要实现一套基于 socket 的定制化通信协议,一定要将握手考虑在设计方案内。
在微服务架构系统开发中,必须通过集成测试 (integration test) 环境来验证功能的正确性。但在集成环境中,外部依赖服务的行为并不受控制,且通常他们的响应都是正常情况下的响应,这时候就无法测试一些规定的故障模式 (failure mode),如在 XXX 情况下返回 XXX 错误码。这时候我们就需要一套继承测试服务 (工具),部署在服务的连接点对面,帮助服务充分测试各种故障模式,如:
拒绝连接
被放入 Listen Queue 中直到超时
对端 SYN/ACK 之后不再发送任何数据
对端只发送 RESET 包
对端一直说缓冲区满了,但不消费缓冲区数据
连接已经建立,但对端不发送任何数据
连接已经建立,但数据包可能丢失,导致数据重传
连接已经建立,但对端从不 ACK 接收到任何数据,导致不断重传
对端发送响应头以后,不再发送任何数据
...
如果使用 Mock Objects 来完成这方面的测试,不仅实现麻烦,而且不通用,利用集成测试服务 (工具) 来做这件事情则方便得多。
Test Harnesses 可以帮助你模拟各种各样的、肮脏的、现实中的故障模式,也可以有意制造慢响应、无响应以及垃圾响应来测试应用逻辑。另外,没有必要为所有连接点的对端都构建一个集成测试服务,可以通过复用来减少资源使用。
上图总结了进程之间通信的方式,从相同进程到不同进程、从本地到远程、从同步到异步。与其它设计模式不同,利用中间件解耦的设计模式通常对系统架构设计影响很大,这种设计会对服务构建的方方面面产生影响。进程之间的解耦程度越高,系统在连接点上遇到级联故障、慢响应和线程阻塞的可能性就越低,服务的适配能力也就越高,与此相对的,在服务构建方面的思考、编码成本、架构运维复杂度的成本则将上升。
开发者应该熟悉更多的系统设计方案,在不同的场景下选择最适合的方案解决问题。
不论你的基础设施体量有多大,世界上都存在足够的用户和设备来产生你的架构能力范围外的流量,如果你的服务暴露在无法控制的巨量流量中,那么就必须学会在疯狂的事情发生之前减轻你的流量压力。在这种情况下,直接抛弃流量比降低响应时间来得更为直接,同时利用合理的错误码告诉外界系统处于高负荷中,请稍后再尝试。
Shed Load 针对的是系统外部流量,在系统边界内部,则可以考虑实施反向压力,让调用方感受到响应速度降低,从而做出合理的降级决策。实施反向压力时可以考虑利用队列来缓存请求,一定要为队列容量设置上限,当队列已满时则可以直接抛弃,类似 TCP 中的 Listen Queue。
如果把系统比作是铁轨,流量比作火车,那么在火车速度无法控制即将脱轨之前,我们常常会使用自动化工具来限制车速,但在 Force Multiplier 模式中,我们提到如果自动化工具被环境欺骗做出巨大动作时,就可能造成重大事故。机器更擅长重复性工作、人类更擅长全局性思考,因此在设计自动化工具时,我们一定要为人工干预留下空间。在工具执行自动调整时,设定调整上限,变动超过一定阈值时需人工审核,避免事故发生。