本文转载自微信公众号「二马读书」,作者二马读书。转载本文请联系二马读书公众号。
随着业务发展,很多系统需要经历服务拆分的过程。微服务化过程踩坑也是很正常的事。如果在服务拆分之C ) _ L 8 0 –前做好充分准1 / N * _ G备,能帮我们少走很多弯路。本文主要从服务依赖,接口版本,隔离,数据一致等方面说说微服务化过0 ; % b _ % W a l程应该注意的点。
循环依赖问题
微服务化之后服务之间会存在各种依赖% K i a关系,不过依赖需要遵循一定的规则,不能太随意。否则,q Y e就会U Z v出现循环依赖的问题,而且会让调用关系变得错综复杂难于维护。下面是服务依赖的几条规则:
1,上层服务可以调用下层服务。
2,同级服务之间不能产生依赖关系,及不能产生调用关系。
3,下层服务不能调用上层服务。
4,服务之间的调用关系只能是单向的。
例如,在电商系统里包括支付服务(Pay),库存服务(Inventory),订单i s G N m M _ I服务(Order)。支付服务和库存服务属于基础服务,订单服务属于上层服务。支付服务和q e 4 H u M 5 w库存服务是同级的服务,他们之间不能存在调+ D H m用关系。订单服务属于上层服务,订单服务可以调用支付服务和库存服务,但F @ * b 9 r E 2是支付服务和库存服务不能调用上层的订单服务。
假设我们不管这些规则,让Order和Pay可以互相调用。这样就会产生循环依赖,Order调用Pay,Pay也调用Order,这样彼此都会依赖对方。
循环依赖导致哪些问题?
1V $ 0 ? v,无限递归调用
假如,Order调用Pay的AA T j & @ K方法,Pay调用Order的B方u 2 8 v K法。然后,A方法里又调用了Order的B方法,B方法里又调用了Pay的A方法j D 6。这样就会产生无限的递归调用,后果自然不言而喻了。
- Ord( D } Ger{
- voidB(){
- Pay.A();
- }
- }
- Pay{
- voidA(){
- Order.B();
- }
- }
2C z Y Z 6 : v |,部署依赖问题
假设Order,Pay,Inventory彼此之间都可i r 5 N Y X以通过API互相调用。当API接口发生变更时,为了让其他服务能够正常调用,API需要重+ _ r 8 g K新编译。如果Order和Pay的API都有变化,上线发布时就需要特别小心。为了保n g 0 4 – | =证发布成功,就需要根据服+ } o / ^ :务间API的依赖关系,详细考虑先打包部署哪个服务,后打包部署哪个服务,才不至于发5 M ^ R 0 G l布失败。如果有更多的服务呢?比如10几个,梳6 } i \ l B理依赖关系都会c 0 v ]把人搞疯的。
3,另外,循环依赖会让服务间的调用关系变得错综复杂,系统难于维护。
接口版本兼容
一些初中级程序员往往会忽略接口变更的问题,经常会因为接口变更导T P Z J = j Q致线上问题。# n E K – s .比如某个小型电商平台的订单服务调用支付服务的某个接口,产品突F V = p \ \ R然提了一个需求,这个需求_ & C V @需要+ z p o W ; K在这个支付接口上加一个参数。开发这个需求的是个新手,他直接在原来的接口方法上实现了需求并加上了参数,联调测试通过后就发布上线了。结果刚上线订单服务就开始报错,因为方法变了,加了参数,订单服务找不到老的方法了。所以就会一直报错,直到订单服务上线为止。
所以我们一定要注意接口版本问题。我们可O l = 3 4 s ^ )以新加一个方法去重载老的方法,在新方法里实现新的功能,新方法的定义除了多一个参数外,其他的和老方法一样。也就是给老方法加了一个新版本。
这样在支@ 0 m |付服务上线后,订单服务上线之前就不会报错了,因为老方法仍然可用。K W j I订单服f B M务上线后就直接切到了新版本的方法。
如果我们服务框架选用的是Dubbo,当一\ . I s N I f G个接口的实现,出现不兼容升级时,可以用Dubbo的版本号过渡,版本号不同q & D , ^ } # 8的服务相互间不引用。
可以按照以下( r U Y u F $的步骤: R + p &进行版本迁移:
1._ Q W R d 在低压力时间段,先升级一半B t q t i B提供者为新版本
2. 再将所有消费者升级为新版本
3. 然后将剩下的一半提供者升级为新版本
老版本服务提供者配置:
- <dubbo:serviceinterface="com.foo.BarService"version="1.0.0"/>
新版本服务提供者配置:
- <dubbo:serviceinterface="com.foo.BarService"v+ 8 & g B 4 i eersion="2.0.0"
老版本服务消费者配置:
- <dubbo:referenceid="barService"interface="com.foo.BarService"ve[ s 9 J / (rsion="1.0.0"/>
新版本服务消费者配置:
- <dubbo:referenceid="barSer} \ B X ^vice"interface="com.foo.BarService"version="2.0.0"/>
关于隔离的考虑
数据隔离:
实际上,服务化的其中一个基本原则就是数据隔离,不同服务应该有自己的专属数据库,而不应该共用相同的数据库,数据访问可以通过服务接口或者消息队列的方式。
很多公司微服务化后,只做了代码工程的拆u N . k $ a \ D /分,不i C ~ x @ v m v同服务对应的数据仍然存放在同一个数? Y 7 d据库中。这样做至少存在四个问题:
1,数据安全问题t { 0。别人的服务不但可以访问你的数据,而且还能修改和删除你的数据。
2,导致数据库R T . ! _ ^ P c ?连接耗尽。一旦某个服务的开发者写了一个慢SQL,并且& \ { 3 r : L 3 j这个服务也没有合^ y Q }理限制连接数。可能会消耗掉所有的数据库连接,进而造成访问相同数据库的其他服务拿不到数据库连接,无法访问数据库。
3,表关联查询。无法避免其他服务的开发者,为了快速上线某些需A h o求。直接查询其他服务的表,或者跨服务做表关联查询。这样会造成服务间的耦合越来越严重。
4,表结构变化的影响。如果某个服务直接依赖于其他服k : ` , k务的数据,一旦表结构发生任何变化z H { D G 6 (,比如修改表名或者字段。很可能会产生灾难性后果。
部署隔离:
我们经常会遇到秒杀业务和日常业务依赖同一个服务,以及C端服务和内部运营系统依赖同一个服务的情况,比如说都依赖支付服务。而秒杀系统的瞬X z C # : M !间访问量很高,可能会对服务带来巨大的压力,甚至压垮服务。内部运营系统也经常有批量数据导出的操作,同= 4 M样会给服务带来一定的压力。这Z j L o 2些都是不稳定因素。所以我们可以将这些共同依赖的服务分组部署,不同的分组服务于不同的业务,避免相互干扰。
业务隔离:
以秒杀为例。从业务s / K上把秒杀和日常的售卖区分开来,把秒杀做为营销活动,要参与秒杀的商品需要提前报名参加活动,这样我们就能提前知道哪些商家哪些商品要参与秒杀,可以根据提报的商品提前生成商品详情静态页面并上传到CY w [DN预热,提报的商品库存也需要提前预热,可以将商品库存在活动开始前预热到Redis,避免秒杀开始后大量访问穿透到数据库。
数据一致性问题
做了微服务拆分后,还可能$ N Q ~ Y = X =会出现数据不一致的问题。比如支付服务中,支付状态发生变更后要通知订单服务修改对应订单的状态a b : _ A K p y f。如果支付服务没有[ r v Q . :正常通知到_ U _ |订单服务,或者订单服务接到通知后没能正常处理通知,就会导致支付服务的支付状态和订单服务的支付状态不一致,也就是数据会不# S q G 9 s : V 7一致。
那么如T 6 X [ n何避免数据不一致的问题产生呢?
我们通常所说的服务间数据一致性,主要包括数据强一致性和最终一致性W q Q Y a w。对于强一致性,使用的业务场景很少,z O a Z % * W t而且会有明显的性能问题。所以这里我们主要讨论最终一F g S致性。
一般我们可以采用如下几种方式来保证服务间数据的最终一致:
定时任务重试,同步调用接口
这种方式,采用定时任务去扫表,每次定时任务扫描所有未成功的记录,并发起重试。注意,要保` z D v A ) R F证重试操作的幂等性。
这种方式的优点是:实现简单。缺点是:需要启动专门的定时任务,定时任y E d b务存在一定的时间间隔,实时性会比较差。而且同步接口调用的方式,耦合6 U ? 1 r q , $ i较重,有时无法避免循环依赖的问题。
比如^ n % , @ J ? a,Order服务可以调用Pay,Pay做为基础服F l S G务不应该调用Order。当Pay的某笔交易状态发生变更后,需要通知Ordb 8 eer。如果采用定时任务的方式就需要Order提供一个接口,定时任务扫描过# F i S % A w }程中同步调用这个接口去更新Order的订单状态。这样又违反了单向依赖的原则,形成了循环依赖。
异步消息队列,发送事务型消息
如上图,以电商下单流程为例。下单流程最后一步,通知WMS捡货出库,是异步消息走消息队列。
- publicvoidmakePayme- h ynt(){
- orderService.updateStatus(OrderStatus.Payed);//订单服务更新订单为已支付状1 ) k 0 7 / # ^ M态
- invH [ a L ) = 5 RentoryService.decrStock();# N T Z//库存服务扣减库存
- couponService.updateStatus(couponStatus.Used);//卡券服务更新优惠券为已使用状态
- 发送MQ消息* g m 0 N p u ;捡货出库;//发送消息通知WMS捡货出库
- }
按上面代码,大家不难发现问题!如果发送捡货出库消息失败,数据就会不一致!有人说我可以在代码上加上重试逻辑和回退逻辑,发消% v M息~ b + g ; _ |失败就重发,多次重试失败所有操作都回退。这样一来逻辑就会特别复杂,回退失败要考虑,而且还有可能消息已经发送成功了,但是由于网络等问题发送方没得到MQ的响应。还有可能出现发送方宕机的~ @ 7 u W 5 Y @ N情况。这些问题都要考y ? = G Y虑进来!
幸好,有些消息队列帮我们解决了这些问题。比如阿里开源的RocketMQ(目前已经是Apache开源项2 u E l O t W目),4.3.0版本开始支持事务型消息(实际上早在贡献给; \ M L x pApache之前曾经支持过事务消息,后来被阉割了N B ` G y,4.3.0版本重新开始支持事务型消息)。
先看看RocketMQ发送事务型消息的流程:
1,发送半消息(所有事务型消息都要经历确认过程,从而确定最终提交或回滚(抛弃消息),未被确认的消息称为“半消息”或者“预备消息”,“待确认消息”)
2,半消息发送成功并响应给发送方
3,执行本地事务,根据本地事务执行结果,发送提交或回滚的确认消息
4,如果确认消息丢失(网络问题或者生产者故障等问题),MQ向发送方回查执行结果
5,根据上一步骤回查结果,确定提交或者回滚(抛弃消息)
看完事务型消息发送流程,有些读者可能没有完全理解,不要紧,我们来分析一下!
问题1:假如发送方发送半消息失败怎么办?
半消息(待确认消息)是消息发送方发送的,q b – J Z f如果失败,发送方自己是知道的并可以做相应处理` \ B L .。
问题2:假如发送方执行完本地事务后Y : n w 8 a c l,发送确认消息通L ~ w ( 8 \ F 9 H知MQ提交或回滚消息时失败了(网络问题,x c @ E b ~ R } j发送方重启等情况),怎么办?
没关系,当MQ发现一个消息长时间处于半消息(待确认消息)的状态,MQ会以定时任务的方式主动回查发送方并获取发送方执行结果。这r i C = n Q u $样即便出现网络问题或者发送方本身的问题(重启,宕机等),MQ通过定时任务主动回查发送方基本都能确认消息最0 d Q I N终要提交还是回滚(抛弃)。当然出于性能和半消息堆积方面的考虑,MQ本身也会有回查次数的限制。
问题3:如何保证消费一y + = h U f $定成功! s N 4 _ Z 7 t G呢?
RocketMQ本身有ack机制,来! 5 y C D保证消息能够被正常消费。如果消费失败(消息订阅方出错,宕机等原因),RocketMQ会把消息重发回Broker,在某个延迟时间点后(默认10秒后)重新投递消息。
结合上面几个同步调用hmily完整代码如下:
- //T{ y U 6 g z W B @ransactionListener是ro$ M n y $ _ % ; dcketmq接口用于回调执行本地事务和状态回查
- publicclassTransactionListenerImplimplementsTransactionListener{
- //执$ y * v Q v * / 0行本地X / + e 7 5事务
- @Override
- publiC T o W 2cLocalTransactionSt) U j , TaQ ] 4 ; s u t 7 =teexecuteLocalTransaction(MessaI ~ / @ b 6 \gemsg,Ob_ Y q * W p M J cjectarg){
- 记录orderID,消息状态键值对到共享map中,以备MQ回查消息状态使用;
- returnLocalTransactionState.COMMIT_MESSAGE;
- }
- //回查发送者状态
- @Override
- publicLocalTransactionStatecheY q * 9 e b H }ckLocalTransaction(MessageExtmsg){
- Stringst^ g 8 C , oatus=从共享map中取出orderID对应的消息状态;
- if("commit".equals(status))
- returnLocalTransa` s g / : [ } * NctionState.COMMIT_MESSAGE;
- elseif("rollback".equals(status))
- returnLocalTransactionState.ROLLBA= \ T % l : @ .CK_MESSAGE;
- else
- returnLocalTransactiR R _ $ h 2 wonState.UNKNOW~ & ~;
- }
- }
- //订单服务
- publicclassOrderService{
- //tcc接口
- @Hmily(confirmMethod="confirmOrderStatus",cancelMethod="cancelOrderStatus")
- publicvoidmakePayment(){
- 1,更新订单状态为支付中
- 2,冻结库存,rpc调用
- 3,优惠券状态改为使用中,rpc调用
- 4,^ : ) f m 0 i发送半消息(待确认消息)通知WM, j S ; Y hS捡货出库//创建producer时这册- Q ( o O ` 3 G lTransactionListenerImpl
- }
- publicvoid! c / Y G - yconfir8 T 1mOrderStatus(){
- 更新订单状态为已支付
- }
- publicvoidcancelOrderStatus(){
- 恢复订单状态为待支付
- }
- }
- //库存服务
- publicclassInventoryService{
- //tcc接口
- @Hmily(cou y ?nfirmMethod="confirmDecr",cancelMethod="cancelc 7 S O & # [Decr")
- publicvoidlockStock(){
- //防悬挂处理
- ifq a u ` D |(分支事务记9 c X T V @ a !录表没有二阶段执行记录)
- 冻结库存
- else
- ret: T ; K u Purn;
- }
- publicvoidconfirmDecr(){
- 确认扣减库存
- }
- publicvoidcancelDecr(){
- 释放冻结的库存
- }
- }
- //卡券服务
- publicclassCouponService{
- //tcc接口
- @Hmily(confirmMethod="confirm",cancelMethod="cancel")
- pK 5 & Z 6ublicvoidhandleCoupon(){
- //防悬挂处理
- if(分支事务记录表没有二阶段执行记录q \ ])
- 优惠券状态更新为临时状态Inuse
- else
- ret) U 5 } B s * @urn;& \ X E s
- }
- publicvoidconfirm(){
- 优惠券状态改为U@ m ~ C pseT v . ~ + \ n /d
- }
- publicvoidcancel(){
- 优惠券状态恢复为Unused
- }
- }
如果执行到TransactionListenerImpl.executeLocalp } M q ATransaction方法,说明半消息已经发送成功了,也说明OrderService.makePayment方f ! ^ Y } = V P j法的四个步骤都执行成功了,此时tcc也到了confirm阶段,所以在TransactionListenerImpl.executeLocalTransaction方法里可以直接返回LocalTransactionState.COMMIT_MESSAGE 让 MQ提交这条消息,同时将该订单信息和对应的消息状态保存在共享map里,以备确认消息发送失败时MQ回查消息状6 \ / s 4 C V z态使用。
3,采用TCC,SAGA,Seata等框架