在讨论事件驱动之前我们先思考上一篇服务协同中用户注册例子描述的场景,其对应的用户服务伪代码如下:
// 用户注册
@Transaction
def register(user) {
// 完成注册
doRegister(user)
// 调用卡券服务生成新人优惠券
def sendCouponResult = http.put("/coupon/${user.id}", "{'kind':'register'}")
if (sendCouponResult.error) {
throw sendCouponResult.error
}
if (user.inviter) {
// 如果存在邀请人,调用积分服务添加邀请关系
def sendPointResult = http.put("/point/${user.inviter}", "{'kind':'register','regUser':'${user.id}'}")
if (sendPointResult.error) {
throw sendPointResult.error
}
}
return true
}
这是最简单直接的调用,从中我们可以发现有两个问题:
-
服务耦合: 用户服务在完成自身操作的同时,还需要调用卡券服务和积分服务,导致服务之间耦合度较高,用户服务需要感知和依赖于卡券服务及积分服务的具体实现。
-
性能损失和可用性降低: 由于存在服务间的同步调用,注册服务的接口响应时间受限于卡券服务及积分服务的性能。此外,如果卡券服务或积分服务宕机,将影响注册服务的可用性,即使注册本身成功,但依赖的服务不可用也会导致整体功能受影响。(后面讲降级时可以解决)
服务耦合上我们可以按上节逻辑引入“活动服务”以聚合发卡券和奖励邀请人,如:
// 用户注册
@Transaction
def register(user) {
// 完成注册
doRegister(user)
// 调用活动服务完成发卡券和奖励邀请人
def sendPromotionResult = http.put("/promotion/${user.id}", "{'kind':'register','inviter':'${user.inviter}'}")
if (sendPromotionResult.error) {
throw sendPromotionResult.error
}
true
}
这一做法有一定程度上缓解了用户服务与其关联服务的耦合,但也未实现真正的解耦,并且任然存在性能及可用性问题。
性能及可用性的问题我们可以简单地这样处理:
// 用户注册
@Transaction
def register(user) {
// 完成注册
doRegister(user)
async { // 使用异步代码块
// 异步调用活动服务完成发卡券和奖励邀请人
asyncHttp
.put("/promotion/${user.id}", "{'kind':'register','inviter':'${user.inviter}'}")
.onSuccess { result -> log.info("活动服务处理成功") }
.onFailure { result -> log.error("活动服务处理失败") }
}
true
}
我们将非核心逻辑放到新的线程中执行,用户注册在完成doRegister后即可返回,这可能也是我们比较常见的做法。
要注意的是异步代码块中的IO操作也都要异步化,否则如果活动服务或其依赖的卡券、积分服务下线了或因代码bug导致异常,那会造成请求堆积,非异步化下会导致线程堆积,大量地消耗CPU与内存,从而拖垮核心功能。
可以使用有界队列的线程池(如java的ArrayBlockingQueue)是否可以解决同步IO线程堆积导致拖垮核心功能的问题,但带来的问题是线程到达临界值后无法再添加任务进而导致逻辑不被执行。
上述做法存在的问题是对活动服务还是有依赖,如果活动服务宕机那将无法处理发卡券和加积分。另外有些场景下会要求注册成功后通知其它业务系统以进行用户同步之类的需求,越多的调用需求导致越多的耦合,所以这时我们就会想到用MQ,加上MQ后我们的代码变会成这样:
// 用户服务
@Transaction
def register(user) {
// 完成注册
doRegister(user)
// 发送用户注册成功事件
mq.publish("user.register.success", user)
}
我们在用户注册完成后发送用户注册成功事件,此时用户服务不知道也不需要知道哪些服务会消费这个事件,这样就完成了服务间的解耦,同时我们也不用担心接口性能受限三方服务的问题,而且这也完美地避开了请求重试导致数据一致性问题。
🔆 MQ的delivery guarantee(交付保证)一般有如下三个,不同的MQ实现支持程度不同:
At most once 消息可能会丢,但绝不会重复传输
At least once 消息绝不会丢,但可能会重复传输
Exactly once 每条消息肯定会被传输一次且仅传输一次,此方式对性能会有一定的影响,且支持的MQ有限
Exactly once推荐在业务代码中实现,这会比MQ中的保证更灵活。我们可以通过自行设计的幂等处理在At least once的基础上去重从而实现Exactly once,幂等处理后续会介绍。
细心的读者一定会想到这样一个问题:如果三方服务消费失败怎么回滚?这是个好问题,只是对于我们用户注册这个场景是不存在,因为只要注册成功,发优惠券及奖励邀请人是业务上要求必须成功的,即使失败也不应该导致注册失败。但这种场景是特例,引入MQ解耦后只有解决消费方错误可以引发生产方回滚才能有更大的适用场景。这个问题在接下来的分布式事务处理上再展开讨论。
我通过上面的例子可以发现,使用MQ后可以更好地解耦、异步化后可以更好的提升性能,而这正是事件驱动架构(EDA)的优势所在。当然并不是引入MQ就是EDA了,后者还需要很多的特性支持,比如核心CQRS、Domain Event、Event Sourcing等。EDA是个很大的话题,笔者坦言在这块上个人经验有限,无法展开叙述,当然这也不是本文的重点,之所以写这个章节一方面是让读者了解EDA中最核心的消息系统有什么优势,在很多场景下我们应该优先考虑使用MQ,另一方面EDA虽不为主流微服务架构采用,但它却是微服务不可忽视的实现方案,抛砖引玉,有兴趣的读者可以自行查阅相关文档。
🔆 事件驱动架构(Event-Driven Architecture)与SOA一样是一种软件架构风格。相较而言,SOA关注的是静态架构,而EDA关注的是动态的、数据流的架构。 详见此处
EDA与服务编制?
前面我们讲了微服务以服务协同的实现为主,但从本节分析EDA更像是服务编制的一种实现。的确,我们更可以把MQ看成ESB的一变体,所以说没有绝对好的差的方案,我们更应该以辨证的态度看待技术。
MQ的使用场景是什么?在不同场合下笔者看到好几次就这个问题展开的讨论。对这个问题我们首先应该肯定的是MQ或是事件驱动肯定是更优雅的方案,但它也存在一定的局限,比如几个核心限制:
-
性能与可用性: 引入消息队列(MQ)虽然通过异步化解决了非核心流程对核心活动的性能及可用性影响,但是MQ本身的性能和可用性也是一个考虑因素。MQ通常是相对中心化的服务,其可用性可能成为系统的瓶颈。一些观点认为,引入MQ可能是将风险从业务服务转嫁给了MQ,这种说法有一定的偏激成分。(其实核笔者理解是成本和可用程度权衡问题,即使现在阿里云的消息产品还是有可能会崩,但是这种厂商方案目的是在保证一定可用程度下可以有效降低公司成本。)
-
复杂性: MQ能够完美地解耦各个服务,但过多的异步调用可能使业务流程变得复杂,可读性下降。在设计中需要权衡解耦和复杂性之间的关系,确保使用MQ带来的解耦效果能够对系统整体的可维护性产生积极影响。
-
请求-响应模式支持有限: 主流的MQ中,只有RabbitMQ支持RPC(Remote Procedure Call)方式的请求-响应模式,其他MQ需要手工实现。引入MQ后,这种请求-响应模式可能会面临一定的性能损失。因此,在选择MQ时,需注意对请求-响应模式的需求,以确保选择的MQ能够满足系统的通信模式。(不推荐)
了解到这几个限制后我们可以对照下系统的需求,如果我们的系统对性能及可用性有着很高的要求,比如TPS几百万那么可能要慎重评估,否则市场上的MQ有侧重性能的Kafka、侧重一致性的RabbitMQ以及比较均衡的RocketMQ,还有ZeroMQ、Hazelcast等MQ的变种,一般情况下都可以找到适合需求的MQ,引入MQ后调用的复杂度问题是对架构及研发团队的考验,架构上要明确服务间调用,研发过程中有文档跟进,运维时要重点关注MQ的情况,对于普遍的请求-响应模式这不是MQ的强项,不要生搬硬套。
一般情况下我们都会引入MQ,笔者建议是在团队能力及工期允许的范围内尽可能地将大部分场景使用MQ实现服务调用异步化,并且如果存在如下场景那尽量要用事件解耦:
- 一个服务内存在核心与非核心调用,非核心调用的失败不影响核心调用且非核心调用的性能、可用性并不能保证,比如上文的注册场景
- 请求耗时长且请求方可以接受回调方式,比如风控请求场景
- 需要高可用保证,确保一致性的情况,比如支付请求
以车贷系统为例,我们在风控系统、贷款系统的各个服务中大量应用了事件解耦。但是这样代码可读性会有一定损失,对团队成员的通盘思考的能力要求也比较高,谨慎使用。
网友评论