本文转载自微信公众号「二马读书」,作者二马读书。转载本文请联系二马读书公众号。
随着业务发展,很多系统需要经历服务拆分的过程。微服务化过程踩坑也是很正常的事。如果在服务拆分之前做好充分准备,能帮我们少走很多弯路。本文主要从服务依赖,接口版本,隔离,数据一致等方面说说微服务化过程应该注意的点。
循环依赖问题
微服务化之后服务之间会存在各种依赖关系,不过依赖需要遵循一定的规则,不能太随意。否则,就会出现循环依赖的问题,而且会让调用关系变得错综复杂难于维护。下面是服务依赖的几条规则:
1,上层服务可以调用下层服务。
2,同级服务之间不能产生依赖关系,及不能产生调用关系。
3,下层服务不能调用上层服务。
4,服务之间的调用关系只能是单向的。
例如,在电商系统里包括支付服务(Pay),库存服务(Inventory),订单服务(Order)。支付服务和库存服务属于基础服务,订单服务属于上层服务。支付服务和库存服务是同级的服务,他们之间不能存在调用关系。订单服务属于上层服务,订单服务可以调用支付服务和库存服务,但是支付服务和库存服务不能调用上层的订单服务。
假设我们不管这些规则,让Order和Pay可以互相调用。这样就会产生循环依赖,Order调用Pay,Pay也调用Order,这样彼此都会依赖对方。
循环依赖导致哪些问题?
1,无限递归调用
假如,Order调用Pay的A方法,Pay调用Order的B方法。然后,A方法里又调用了Order的B方法,B方法里又调用了Pay的A方法。这样就会产生无限的递归调用,后果自然不言而喻了。
- Order {
- void B(){
- Pay.A();
- }
- }
- Pay{
- void A(){
- Order.B();
- }
- }
2,部署依赖问题
假设Order,Pay,Inventory彼此之间都可以通过API互相调用。当API接口发生变更时,为了让其他服务能够正常调用,API需要重新编译。如果Order和Pay的API都有变化,上线发布时就需要特别小心。为了保证发布成功,就需要根据服务间API的依赖关系,详细考虑先打包部署哪个服务,后打包部署哪个服务,才不至于发布失败。如果有更多的服务呢?比如10几个,梳理依赖关系都会把人搞疯的。
3,另外,循环依赖会让服务间的调用关系变得错综复杂,系统难于维护。
接口版本兼容
一些初中级程序员往往会忽略接口变更的问题,经常会因为接口变更导致线上问题。比如某个小型电商平台的订单服务调用支付服务的某个接口,产品突然提了一个需求,这个需求需要在这个支付接口上加一个参数。开发这个需求的是个新手,他直接在原来的接口方法上实现了需求并加上了参数,联调测试通过后就发布上线了。结果刚上线订单服务就开始报错,因为方法变了,加了参数,订单服务找不到老的方法了。所以就会一直报错,直到订单服务上线为止。
所以我们一定要注意接口版本问题。我们可以新加一个方法去重载老的方法,在新方法里实现新的功能,新方法的定义除了多一个参数外,其他的和老方法一样。也就是给老方法加了一个新版本。
这样在支付服务上线后,订单服务上线之前就不会报错了,因为老方法仍然可用。订单服务上线后就直接切到了新版本的方法。
如果我们服务框架选用的是Dubbo,当一个接口的实现,出现不兼容升级时,可以用Dubbo的版本号过渡,版本号不同的服务相互间不引用。
可以按照以下的步骤进行版本迁移:
1. 在低压力时间段,先升级一半提供者为新版本
2. 再将所有消费者升级为新版本
3. 然后将剩下的一半提供者升级为新版本
老版本服务提供者配置:
- <dubbo:service interface="com.foo.BarService" version="1.0.0" />
新版本服务提供者配置:
- <dubbo:service interface="com.foo.BarService" version="2.0.0"
老版本服务消费者配置:
- <dubbo:reference id="barService" interface="com.foo.BarService" version="1.0.0" />
新版本服务消费者配置:
- <dubbo:reference id="barService" interface="com.foo.BarService" version="2.0.0" />
关于隔离的考虑
数据隔离:
实际上,服务化的其中一个基本原则就是数据隔离,不同服务应该有自己的专属数据库,而不应该共用相同的数据库,数据访问可以通过服务接口或者消息队列的方式。
很多公司微服务化后,只做了代码工程的拆分,不同服务对应的数据仍然存放在同一个数据库中。这样做至少存在四个问题:
1,数据安全问题。别人的服务不但可以访问你的数据,而且还能修改和删除你的数据。
2,导致数据库连接耗尽。一旦某个服务的开发者写了一个慢SQL,并且这个服务也没有合理限制连接数。可能会消耗掉所有的数据库连接,进而造成访问相同数据库的其他服务拿不到数据库连接,无法访问数据库。
3,表关联查询。无法避免其他服务的开发者,为了快速上线某些需求。直接查询其他服务的表,或者跨服务做表关联查询。这样会造成服务间的耦合越来越严重。
4,表结构变化的影响。如果某个服务直接依赖于其他服务的数据,一旦表结构发生任何变化,比如修改表名或者字段。很可能会产生灾难性后果。
部署隔离:
我们经常会遇到秒杀业务和日常业务依赖同一个服务,以及C端服务和内部运营系统依赖同一个服务的情况,比如说都依赖支付服务。而秒杀系统的瞬间访问量很高,可能会对服务带来巨大的压力,甚至压垮服务。内部运营系统也经常有批量数据导出的操作,同样会给服务带来一定的压力。这些都是不稳定因素。所以我们可以将这些共同依赖的服务分组部署,不同的分组服务于不同的业务,避免相互干扰。
业务隔离:
以秒杀为例。从业务上把秒杀和日常的售卖区分开来,把秒杀做为营销活动,要参与秒杀的商品需要提前报名参加活动,这样我们就能提前知道哪些商家哪些商品要参与秒杀,可以根据提报的商品提前生成商品详情静态页面并上传到CDN预热,提报的商品库存也需要提前预热,可以将商品库存在活动开始前预热到Redis,避免秒杀开始后大量访问穿透到数据库。
数据一致性问题
做了微服务拆分后,还可能会出现数据不一致的问题。比如支付服务中,支付状态发生变更后要通知订单服务修改对应订单的状态。如果支付服务没有正常通知到订单服务,或者订单服务接到通知后没能正常处理通知,就会导致支付服务的支付状态和订单服务的支付状态不一致,也就是数据会不一致。
那么如何避免数据不一致的问题产生呢?
我们通常所说的服务间数据一致性,主要包括数据强一致性和最终一致性。对于强一致性,使用的业务场景很少,而且会有明显的性能问题。所以这里我们主要讨论最终一致性。
一般我们可以采用如下几种方式来保证服务间数据的最终一致:
定时任务重试,同步调用接口
这种方式,采用定时任务去扫表,每次定时任务扫描所有未成功的记录,并发起重试。注意,要保证重试操作的幂等性。
这种方式的优点是:实现简单。缺点是:需要启动专门的定时任务,定时任务存在一定的时间间隔,实时性会比较差。而且同步接口调用的方式,耦合较重,有时无法避免循环依赖的问题。
比如,Order服务可以调用Pay,Pay做为基础服务不应该调用Order。当Pay的某笔交易状态发生变更后,需要通知Order。如果采用定时任务的方式就需要Order提供一个接口,定时任务扫描过程中同步调用这个接口去更新Order的订单状态。这样又违反了单向依赖的原则,形成了循环依赖。
异步消息队列,发送事务型消息
如上图,以电商下单流程为例。下单流程最后一步,通知WMS捡货出库,是异步消息走消息队列。
- public void makePayment() {
- orderService.updateStatus(OrderStatus.Payed); //订单服务更新订单为已支付状态
- inventoryService.decrStock(); //库存服务扣减库存
- couponService.updateStatus(couponStatus.Used); //卡券服务更新优惠券为已使用状态
- 发送MQ消息捡货出库; //发送消息通知WMS捡货出库
- }
按上面代码,大家不难发现问题!如果发送捡货出库消息失败,数据就会不一致!有人说我可以在代码上加上重试逻辑和回退逻辑,发消息失败就重发,多次重试失败所有操作都回退。这样一来逻辑就会特别复杂,回退失败要考虑,而且还有可能消息已经发送成功了,但是由于网络等问题发送方没得到MQ的响应。还有可能出现发送方宕机的情况。这些问题都要考虑进来!
幸好,有些消息队列帮我们解决了这些问题。比如阿里开源的RocketMQ(目前已经是Apache开源项目),4.3.0版本开始支持事务型消息(实际上早在贡献给Apache之前曾经支持过事务消息,后来被阉割了,4.3.0版本重新开始支持事务型消息)。
先看看RocketMQ发送事务型消息的流程:
1,发送半消息(所有事务型消息都要经历确认过程,从而确定最终提交或回滚(抛弃消息),未被确认的消息称为“半消息”或者“预备消息”,“待确认消息”)
2,半消息发送成功并响应给发送方
3,执行本地事务,根据本地事务执行结果,发送提交或回滚的确认消息
4,如果确认消息丢失(网络问题或者生产者故障等问题),MQ向发送方回查执行结果
5,根据上一步骤回查结果,确定提交或者回滚(抛弃消息)
看完事务型消息发送流程,有些读者可能没有完全理解,不要紧,我们来分析一下!
问题1:假如发送方发送半消息失败怎么办?
半消息(待确认消息)是消息发送方发送的,如果失败,发送方自己是知道的并可以做相应处理。
问题2:假如发送方执行完本地事务后,发送确认消息通知MQ提交或回滚消息时失败了(网络问题,发送方重启等情况),怎么办?
没关系,当MQ发现一个消息长时间处于半消息(待确认消息)的状态,MQ会以定时任务的方式主动回查发送方并获取发送方执行结果。这样即便出现网络问题或者发送方本身的问题(重启,宕机等),MQ通过定时任务主动回查发送方基本都能确认消息最终要提交还是回滚(抛弃)。当然出于性能和半消息堆积方面的考虑,MQ本身也会有回查次数的限制。
问题3:如何保证消费一定成功呢?
RocketMQ本身有ack机制,来保证消息能够被正常消费。如果消费失败(消息订阅方出错,宕机等原因),RocketMQ会把消息重发回Broker,在某个延迟时间点后(默认10秒后)重新投递消息。
结合上面几个同步调用hmily完整代码如下:
- //TransactionListener是rocketmq接口用于回调执行本地事务和状态回查
- public class TransactionListenerImpl implements TransactionListener {
- //执行本地事务
- @Override
- public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
- 记录orderID,消息状态键值对到共享map中,以备MQ回查消息状态使用;
- return LocalTransactionState.COMMIT_MESSAGE;
- }
- //回查发送者状态
- @Override
- public LocalTransactionState checkLocalTransaction(MessageExt msg) {
- String status = 从共享map中取出orderID对应的消息状态;
- if("commit".equals(status))
- return LocalTransactionState.COMMIT_MESSAGE;
- else if("rollback".equals(status))
- return LocalTransactionState.ROLLBACK_MESSAGE;
- else
- return LocalTransactionState.UNKNOW;
- }
- }
- //订单服务
- public class OrderService{
- //tcc接口
- @Hmily(confirmMethod = "confirmOrderStatus", cancelMethod = "cancelOrderStatus")
- public void makePayment() {
- 1,更新订单状态为支付中
- 2,冻结库存,rpc调用
- 3,优惠券状态改为使用中,rpc调用
- 4,发送半消息(待确认消息)通知WMS捡货出库 //创建producer时这册TransactionListenerImpl
- }
- public void confirmOrderStatus() {
- 更新订单状态为已支付
- }
- public void cancelOrderStatus() {
- 恢复订单状态为待支付
- }
- }
- //库存服务
- public class InventoryService {
- //tcc接口
- @Hmily(confirmMethod = "confirmDecr", cancelMethod = "cancelDecr")
- public void lockStock() {
- //防悬挂处理
- if (分支事务记录表没有二阶段执行记录)
- 冻结库存
- else
- return;
- }
- public void confirmDecr() {
- 确认扣减库存
- }
- public void cancelDecr() {
- 释放冻结的库存
- }
- }
- //卡券服务
- public class CouponService {
- //tcc接口
- @Hmily(confirmMethod = "confirm", cancelMethod = "cancel")
- public void handleCoupon() {
- //防悬挂处理
- if (分支事务记录表没有二阶段执行记录)
- 优惠券状态更新为临时状态Inuse
- else
- return;
- }
- public void confirm() {
- 优惠券状态改为Used
- }
- public void cancel() {
- 优惠券状态恢复为Unused
- }
- }
如果执行到TransactionListenerImpl.executeLocalTransaction方法,说明半消息已经发送成功了,也说明OrderService.makePayment方法的四个步骤都执行成功了,此时tcc也到了confirm阶段,所以在TransactionListenerImpl.executeLocalTransaction方法里可以直接返回LocalTransactionState.COMMIT_MESSAGE 让 MQ提交这条消息,同时将该订单信息和对应的消息状态保存在共享map里,以备确认消息发送失败时MQ回查消息状态使用。
3,采用TCC,SAGA,Seata等框架