type
status
date
slug
summary
tags
category
icon
password
⚠️
本文是Manning的《微服务架构设计模式》一书的整理笔记,并加入了一些自己的想法,给尚未读过这本书的人探探路。这本书的代码部分是基于Java编写的,我个人希望脱离Java体系的捆绑,所以文中不会照搬书中的Java内容,只整理架构性、思路性的东西。
本文是笔记的第一部分,描述微服务的整体概念,主要讨论传统单体模式服务的缺陷,应该如何按照业务领域拆分服务,服务之间的通信方式以及分布式事务的实现等问题。

第1章:逃离单体地狱

单体地狱指的是大泥球模式,比如部署在Tomcat上的巨大WAR包,里面承载了累计多年的极端复杂的业务逻辑和应用架构,虽然这种模式在开发早期有方便快捷的优点,但时间长了,过度的业务复杂性会吓退开发者,复杂的应用架构也难以理解,由于牵一发动全身,开发速度缓慢,架构紧身衣导致难以扩展,致使开发周期变长,屎上雕花,也会导致CICD变慢、难以测试,系统交付变慢,且长期依赖单一的过时技术栈比如很老版本的Spring难以升级。
微服务架构是把应用程序功能性分解为一组服务的架构风格,服务作为模块化的单元,服务的API为它构建了边界,让每个服务可以单独水平扩展,并且每个服务具有自己的数据库(不是指机器),这实现了低耦合。微服务架构和SOA的区别是,一不使用事件总线而是REST或gRPC通信,二服务之间数据库独立,三每个服务体量更小。
微服务架构的好处是每个服务可以独立扩展、团队自治,CICD快,容易采纳新技术,容易测试、容错型高;问题在于如何拆分服务(可能涉及DDD),如何处理分布式系统复杂性,如何跨部门协作,什么时候转向微服务架构。

第2章:服务的拆分策略

传统三层架构的弊端可以用六边形架构解决,它给程序设置了多个出入站适配器,可能是REST接口,可能是DAO类,虽然将业务逻辑和外部世界解耦了,但它可能还是一个大而全的东西。而微服务架构则是从服务视角,基于业务能力或DDD的方式把服务切分成了很多松耦合的可能是六边形的模块,模块之间通过适配器通信。微服务架构的建立是一个逐渐演化的过程:
第一步要将需求识别为系统操作。在这一步中,抽象的需求将被转化为领域模型,产生词汇表,并描述每个系统操作的行为,套用DDD的思路,行为有命令型和查询型两种。
第二步要确定如何分解服务。可以采用业务架构学派的方式根据业务能力拆分服务,产物大概是数据驱动的,但是这可能被数据库反向捆绑服务拆分,更推荐采用领域驱动设计的方式明确界限上下文、实体、聚合等,让设计由行为驱动。不论如何划分,都要注意模块的单一职责原则和CCP(共同闭包)原则。
第三步要确定每个服务的API。在这一步要把系统操作分配给具体的服务,确定服务间协作需要的API,但是在这个过程中要考虑进程间通信和分布式一致性等复杂问题。

第3章:微服务架构中的进程间通信

本章分为三部分,分别介绍了通信概述、同步通信、异步通信三个话题。

通信概述

首先是服务之间的交互方式,分为同步和异步交互,同步交互只有一对一的请求响应模式,这会导致线程阻塞和进程间耦合,而异步模式在子线程或消息队列中进行,不会阻塞整个进程,又分为一对一和一对多两种情况,一对一的情况下,有异步请求响应模式和单向通知模式,一对多的情况下,有发布订阅模式(比如在AWS里通过SNS+多个SQS调用多个Lambda函数)和发布异步响应(事件驱动)模式。
进程间的通信依赖于API,类比编程语言中的接口,它屏蔽了底层实现细节。在单体服务中,两个服务之间的接口定义和调用不匹配会导致编译失败,但在微服务中无法精确控制调用端的更新,且API会演化,这就需要使用版本号加旧版本适配器(如果更细了大版本,表示版本不兼容)控制API的行为,并且要把版本号暴露在URL或MIME类型中。
消息的格式分为文本和二进制两种,文本格式指XML或JSON,它们有很好的可读性,但通信开销大,二进制格式有Protocol Buffer和Avro,它们通信开销小但不可读。二者都定义了消息格式,前者可以增加消息格式约束,后者通过IDL(接口描述文件)来确定消息的内部格式。

同步通信

一个服务对另一个服务的远程过程调用(RPC)通过适配器(代理)实现,适配器中封装了底层通信协议细节,通信协议又分为REST(基于 HTTP+JSON)和gRPC(基于HTTP/2+Protobuf)两种。
首先是REST,最流行的RESTful接口规范是OpenAPI规范,它以资源(比如订单)为中心,通过GET、POST、PUT等操作谓词定义对资源的操作,并且可以对GET的结果做CDN缓存。但缺点在于,首先它是个类似于数据库高范式的紧身衣,难以遵守,最终沦为传统RPC风格,其次它不太擅长处理跨多资源的处理,容易让URL的设计长得十分难看偏离REST风格。(个人认为让RESTful彻底成功的点在领域建模,但是几乎没有团队能掌握DDD设计纯血模型)
然后是gRPC,它有RPC风格API简单容易设计契约清晰的优势,且性能快,但是要考虑老防火墙能不能放行HTTP2的问题。(书中说gRPC是REST的替代,我个人认为不能把系统设计风格和技术选型混为一谈)
断路器模式——当一个服务调用另一个服务时,被调用方可能会因为负载过高迟迟不响应,或者进程挂掉了,这会拖垮调用方(比如连接池),如果调用方一直重试,甚至拖垮API Gateway导致雪崩(云服务有保护设置,但自己部署就有问题)。处理方式是实现一个API Gateway级别的断路器。粗略来说,可以通过监控记录请求失败次数或超时情况,把失败的请求用一个预设好的失败来响应,而不是不给响应,之后再安排其他逻辑试探服务是否恢复了,让API Gateway继续指向被调用方。
服务发现——由于在云上服务可能会被部署到随机IP地址且随时改变,所以让调用者动态获取被调用者的位置成为了一种刚需,这就需要一个服务名称和地址的映射表,这个应该和表服务可以做再应用层,也可以做在infra层,它需要提供的服务有让服务自注册的API,让服务查询其他服务进行客户端发现的API,负责心跳监控和服务注销的机制,和一个数据映射表。在应用层有Spring Cloud/etcd等可以实现,但是这会带来技术栈的捆绑,做心跳检查也会带来服务间耦合,通常建议做在infra层,比如使用AWS Cloud Map,或者依赖负载均衡器,或者用k8s。

异步通信

在这种模式下服务之间通过消息通信,彼此约定好消息头和消息体的结构,服务之间的通信通常通过中间件比如SQS解耦,服务1把消息发给SQS1,服务2订阅SQS1处理消息,然后把响应消息发给SQS2,服务1订阅SQS2处理响应,两个消息之间共享相同的ID来标记业务上的关联性。中间件队列服务的选择则要考虑队列容量、消息顺序、消息分片、重复发送、失败重试、死信队列、消息延迟、可持久性、运维成本等多方面的权衡,避免让中间件本身成为瓶颈,还要考虑保持应用逻辑能够冪等地处理消息。
一个难点是会因为外部状态改变的事务性消息,比如写数据库后要给原始服务通过SQS返回消息,但是这两件事没法放在一个分布式事务里,此时如果想避免出现部分失败,则需要使用事务性发件箱模式(Transactional Outbox Pattern),思路是执行数据库操作时,在同一个事务里更新一个OUTBOX表存储消息(需要定期清理这张表),然后通过数据库本身的机制对外发消息,比如通过RDS CDC或DynamoDB Streams等数据库内建机制捕捉OUTBOX表的更新,驱动Lambda函数通知SQS将消息传递给下游服务,这样即便Lambda函数执行失败了,也能实现后续处理的可重放,且没有丢失数据。这个模式的代价是多了一张表,增加了DB压力,还要保障后续处理的幂等性。
异步通信带来的好处还有提高了程序整体的可用性,因为在由多个服务组成的系统中,整体的可用性取决于可用性最差的部分,如果希望最大化系统的可用性,就需要最大程度消除服务之间的同步性。

第4章:使用Saga管理事务

由于服务被分散到多个进程中,而每个进程各自拥有自己的可能是异构的数据库,还使用中间件通信,这导致无法执行传统开发中数据库的分布式事务,在这种情况下需要使用一种叫Saga的模式模拟分布式事务,Saga的一个挑战在于牺牲了ACID(原子性、一致性、隔离性、持久性)中的I(隔离性),只要保证最终一致性,那么就需要使用额外的手段尽量提高隔离性。Saga 实质上从强隔离走向语义隔离,它依赖业务语义(状态机、幂等、版本控制)来‘补偿’数据库的隔离缺口。
Saga的思路是,把一个事务中的一连串调用分成两类,一类是可补偿性事务,比如创建订单后,后续服务如果失败,则执行一个补偿事务对订单创建执行反向操作,另一类是关键性事务,这种事务通常被放在最后,一定成功,必须幂等,不会回滚。
Saga存在两种协调模式,一种是协作式Saga,请求由一个Server发出,并且通过事件在不同Server之间传递,这种方式简单,但难以理解,可能导致服务调用时的循环依赖,并且导致了两个服务要同时修改代码的紧耦合,另一种是编排式Saga,由一个中央类(或者AWS StepFunctions这类中间件)维护一个状态机,状态机启动服务,之后和每个服务通信,这种做法不会引入循环依赖(因为都依赖状态机),每个服务都不需要知道其他Saga参与方服务,改善了关注点隔离,但一个弊端在于把事务的业务逻辑都推进了状态机里,需要防止这里的架构腐化。通常境况下,推荐使用编排式Saga。
Saga模式缺乏对事务隔离性的支持,每个Saga事务做的更新都能被其他Saga看到,这可能会在并发情况下导致数据库异常,比如更新被其他事务覆盖,或者脏读等。有几个对策可以在编码层面缓解隔离性缺失的问题。比如采用语义锁,举例来说,在Saga事务开始时改变Order.state为Pendding,这样其他事务看到这个标记后就可以返回失败或者暂时阻塞;比如在更新之前重新读取数据验证一致性;比如添加版本号;比如把更新操作设计得可以交换顺序;比如通过调整调用时序减缓脏读等。