微服务架构之服务间通信
在单体架构中,不同模块之间可以通过项目引用的方式直接使用接口调用,单次请求在同一台机器的同一个进程内进行,这种调用方式称为本地调用,但是在微服务架构中,每个服务都是独立的进程,并且通常部署在不同的服务器,无法简单的使用本地调用的方式,而是需要远程服务调用来实现服务间的通信。
通信模式的划分
目前有很多种进程间通信的技术供开发者选择,可以使用基于同步请求/响应的通信机制,例如:http Restful和 grpc。也可以使用异步的基于消息的通信机制,例如,AMQT,MQTT等。从服务端和客户端的交互方式上,可以将通信模式按两种维度划分。第一个维度关注的是一对一和一对多:
- 一对一:每个客户端请求由一个服务端处理,例如:同步请求/响应模式,异步请求/响应模式,单向通知模式。
- 一对多:每个客户端请求由多个服务端处理,例如:消息发布/订阅模式
第二个维度关注的同步和异步:
- 同步模式:客户端请求需要服务端实时响应,客户端等待响应时可能导致阻塞。
- 异步模式:客户端请求不会阻塞进程,服务端响应可以非实时的。
同步请求/响应模式
在实际的项目中,同步请求/响应模式是被应用最频繁的模式,底层的通信机制可以使用http协议或grpc。
由于http协议的简单,灵活,可靠,易于扩展等特性,被广泛应用于前/后端和服务间的通信,服务端通过http协议对外暴露API,RESTful API是目前比较成熟的一套互联网应用程序的API设计理论。REST中的一个关键概念是资源,它通常表示一个业务对象,REST使用http动词来操作资源,使用URL引用这些资源。关于Resulful风格API的详细内容可以参考我转载的阮一峰老师的文章:Restful API设计规范
由于http动词的限制,Restful API并不能总是满足实际的业务需求,在需要支持多种更新操作的场景时,Rest无法通过有限的动词实现,避免此问题的进程间通信技术是使用RPC框架,对于RPC框架一个通俗的描述是:客户端应用可以像调用本地对象一样直接调用另一台不同的机器上服务端应用的方法。gRPC就是众多RPC框架中的一种,它是一个高性能、开源和通用的 RPC 框架,基于ProtoBuf(Protocol Buffers) 序列化协议开发,且支持众多开发语言。面向服务端和移动端,基于 HTTP/2 设计,带来诸如双向流、流控、头部压缩、单 TCP 连接上的多复用请求等特。这些特性使得其在移动设备上表现更好,更省电和节省空间占用。
服务注册/发现
在微服务架构中,由于服务部署在多台服务器上,服务地址是不确定的,也可能有多个服务地址。客户端服务该如何获取服务端服务的地址,是系统需要面对的问题,在微服务架构中,使用服务注册/发现系统来解决服务寻址问题。想了解服务注册/发现系统可以参考我的另一篇文章:微服务架构之服务注册与发现(Consul+gliderlabs/registrator)
服务链路追踪
在微服务架构中,由于服务之间做了拆分,一次请求往往要涉及多个服务的调用,不同的服务可能由不同的团队开发,使用不同的编程语言,还有可能部署在不同的机器上,分布在不同的数据中心。当请求出现异常问题时,我们需要知道本次请求调用了哪些服务,又是哪个服务引起的错误,如果靠人肉的方式,到每个服务去查找代码,查看日志,那么无疑是十分痛苦的,微服务架构中引入服务链路追踪系统来解决以上问题。针对服务链路追踪系统我在 微服务架构之分布式追踪(.net6集成Zipkin) 中做了详细的介绍,感兴趣的朋友,可以当作学习参考或点评一二。
服务治理
由于网络丢包、延迟、抖动,以及服务提供者异常错误等原因,服务调用可能会经常出现调用失败、返回超时等问题,一个服务的不可用会影响所有调用这个系统所提供服务的服务消费者,如果不加以控制,严重的话就会引起整个系统雪崩。
- 超时:在实际项目中,为了避免依赖的服务迟迟没有返回调用结果,把服务消费者拖死,需要针对每个请求设置超时时间。超时时间建议设置为99.9% 或者 99.99% 的调用都在多少毫秒内返回的时间为准。
- 重试:虽然设置超时时间可以起到及时止损的效果,但是服务调用的结果毕竟是失败了,而大部分情况下,调用失败都是因为偶发的网络问题或者个别服务提供者节点有问题导致的,如果能换个节点再次访问说不定就能成功。所以在实际的项目中,通常会设置一个服务调用超时后的重试次数,对超时请求进行重试处理。由于重试的原因,服务提供者需要保证包含更新操作的接口的幂等性。
- 降级:降级也就是服务降级,当我们的服务器压力剧增为了保证核心功能的可用性 ,而选择性的降低一些功能的可用性,或者直接关闭该功能。这就是典型的丢车保帅了。一般而言都会建立一个独立的降级系统,可以灵活且批量的配置服务器的降级功能。当然也有用代码自动降级的,例如接口超时降级、失败重试多次降级等。具体失败几次,超时设置多久,由具体的业务因素决定。
- 熔断:熔断一般是指依赖的外部接口出现故障的情况断绝和外部接口的关系。例如A服务里面的一个功能依赖B服务,这时候B服务出问题了,返回的很慢。这种情况可能会因为这么一个功能而拖慢了A服务里面的所有功能,因此我们这时候就需要熔断!即当发现A要调用这B时就直接返回错误(或者返回其他默认值啊啥的),就不去请求B了。
- 限流:为了保证服务的可用性,必要的时候会对服务进行限流,只允许指定的事件进入系统,超过的部分将被拒绝服务,排队或者降级处理。
消息发布/订阅模式
想象一个会员注册的场景,会员注册成功后,平台需要给会员发送注册成功的短信,增加注册积分,推送会员指引消息等等。这些对实时性要求不高,并且一对多的场景,就可以采用消息发布/订阅的模式来实现服务间的解耦,同时提升服务的响应速度。消息队列是实现消息发布/订阅的模式的通用解决方案。
从本质上说消息队列就是一个队列结构的中间件,通过它可以实现服务间的解耦、流量的削峰填谷、系统整体吞吐量的提升。
当会员注册成功后,应用不直接调用发送短信、发送积分,推送指引消息的接口,而是向消息队列中推送一条会员注册成功的消息(其中包含会员Id,会员名字,注册时间等重要信息),然后直接返回,由于不需要等待其他几个服务的执行结果,大大减少了会员注册接口的响应时间。同时下游的短信、积分、消息服务向消息队列订阅会员注册成功的消息,当收到新的消息时,再根据消息内容执行具体的业务的逻辑,这样就实现了服务间的解耦。后面会员注册成功需要增加新的需求时,只需要增加一个新的服务订阅该消息,实现具体的业务逻辑即可,而不需要修改会员注册的代码,这样就大大的提升了系统的封闭性和扩展性。
如何保证数据一致性
在分布式环境中,由于网络错误,服务不可用等原因,网络请求无法做到百分百的有效,在使用消息发布/订阅模式时,为了保证系统间数据一致性,需要注意以下几点:
- 生产端:生产端在发布消息时,需采用消息确认模式,将消息推送至消息队列,并保证消息队列将消息持久化至磁盘后,返回成功,否则需要做数据补偿操作,重试或者将消息至为失败状态等待人工处理(推荐使用本地消息表方案)。
- 消息队列端:消息队列需要提供主题及消息的持久化存储机制,保证队列重启后不会有消息丢失,同时也可以通过集群模式的数据副本冗余保证消息的一致性。
- 消费端:消费端采用ACK机制,在成功执行完消息的处理逻辑后,向消息队列返回消费成功的ACK,消息队列在收到消息的ACK后删除消息,否则会将消息保留,在指定时间将消息重新推送给消费端处理。由于网络的不确定性,可能存在消息队列将处理成功的消息重复推送到消费端的情况,所以要求消费端的逻辑必须保证幂等性,即针对同一条消息,无论执行多少次,结果是一致的。