微服务越来越火。很多互联网公司,甚至一些传统行业的系统都采用了微服务架构。体会到微服务带来好处的同时,很多公司也明显感受到微服务化带来的一系列让人头疼的问题。本文是笔者对自己多年微服务化经历的总结。如果你正准备做微服务转型,或者在微服务化过程中遇到了困难。此文很可能会帮到你!
写在前面
正文开始前,为了让各位读友更好的理解本文内容,先花两分钟了解一下微服务的优缺点。
聊起微服务,很多朋友都了解微服务带来的好处,罗列几点:
模块化,降低耦合。将单体应用按业务模块拆分成多个服务,如果某个功能需要改动,大多数情况,我们只需要弄清楚并改动对应的服务即可。只改动一小部分就能满足要求,降低了其他业务模块受影响的可能性。从而降低了业务模块间的耦合性。
屏蔽与自身业务无关技术细节。例如,很多业务需要查询用户信息,在单体应用的情况下,所有业务场景都通过DAO去查询用户信息,随着业务发展,并发量增加,用户信息需要加缓存,这样所有业务场景都需要关注缓存,微服务化之后,缓存由各自服务维护,其他服务调用相关服务即可,不需要关注类似的缓存问题
数据隔离,避免不同业务模块间的数据耦合。不同的服务对应不同数据库表,服务之间通过服务调用的方式来获取数据
业务边界清晰,代码边界清晰。单体架构中不同的业务,代码耦合严重,随着业务量增长,业务复杂后,一个小功能点的修改就可能影响到其他业务点,开发质量不可控,测试需要回归,成本持续提高
显著减少代码冲突。在单体应用中,很多人在同一个工程上开发,会有大量的代码冲突问题。微服务化后,按业务模块拆分成多个服务,每个服务由专人负责,有效减少代码冲突问题
可复用,显著减少代码拷贝现象
微服务确实带来不少好处,那么微服务有没有什么问题呢?答案是肯定的!例如:
微服务系统稳定性问题。微服务化后服务数量大幅增加,一个服务故障就可能引发大面积系统故障。比如服务雪崩,连锁故障。当一个服务故障后,依赖他的服务受到牵连也发生故障。
服务调用关系错综复杂,链路过长,问题难定位。微服务化后,服务数量剧增,大量的服务管理起来会变的更加复杂。由于调用链路变长,定位问题也会更加困难。
数据一致性问题。微服务化后单体系统被拆分成多个服务,各服务访问自己的数据库。而我们的一次请求操作很可能要跨多个服务,同时要操作多个数据库的数据,我们发现以前用的数据库事务不好用了。跨服务的数据一致性和数据完整性问题也就随之而来了。
微服务化过程中,用户无感知数据库拆分、数据迁移的挑战
如何保障微服务系统稳定性?
互联网系统为大量的C端用户提供服务,如果隔三差五的出问题宕机,会严重影响用户体验,甚至导致用户流失。所以稳定性对互联网系统非常重要!接下来笔者根据自己的实际经验来聊聊基于微服务的互联网系统的稳定性。
雪崩效应产生原因,如何避免?
微服务化后,服务变多,调用链路变长,如果一个调用链上某个服务节点出问题,很可能引发整个调用链路崩溃,也就是所谓的雪崩效应。
举个例子,详细理解一下雪崩。如上图,现在有A,B,C三个服务,A调B,B调C。假如C发生故障,B方法1调用C方法1的请求不能及时返回,B的线程会发生阻塞等待。B会在一定时间后因为线程阻塞耗尽线程池所有线程,这时B就会无法响应A的请求。A调用B的请求不能及时返回,A的线程池线程资源也会逐渐被耗尽,最终A也无法对外提供服务。这样就引发了连锁故障,发生了雪崩。纵向:C故障引发B故障,B故障引发A故障,最终发生连锁故障。横向:方法1出问题,导致线程阻塞,进而线程池线程资源耗尽,最终服务内所有方法都无法访问,这就是“线程池污染”
为了避免雪崩效应,我们可以从两个方面考虑:
- 在服务间加熔断。解决服务间纵向连锁故障问题。比如在A服务加熔断,当B故障时,开启熔断,A调用B的请求不再发送到B,直接快速返回。这样就避免了线程等待的问题。当然快速返回什么,fallback方案是什么,也需要根据具体场景,比如返回默认值或者调用其他备用服务接口。如果你的场景适合异步通信,可以采用消息队列,这样也可以有效避免同步调用的线程等待问题。
- 服务内(JVM内)线程隔离。解决横向线程池污染的问题。为了避免因为一个方法出问题导致线程等待最终引发线程资源耗尽的问题,我们可以对tomcat,dubbo等的线程池分成多个小线程组,每个线程组服务于不同的类或方法。一个方法出问题,只影响自己不影响其他方法和类。
常用开源熔断隔离组件:Hystrix,Resilience4j
如何应对突发流量对服务的巨大压力?
促销活动或秒杀时,访问量往往会猛增数倍。技术团队在活动开始前一般都会根据预估访问量适当增加节点,但是假如流量预估少了(实际访问量远大于预估的访问量),系统就可能会被压垮。所以我们可以在网关层(Zuul,Gateway,Nginx等)做限流,如果访问量超出系统承载能力,就按照一定策略抛弃超出阈值的访问请求(也要注意用户体验,可以给用户返回一个友好的页面提示)。
可以从全局,IP,userID等多维度做限流。限流的两个主要目的:1,应对突发流量,避免系统被压垮(全局限流和IP限流)2,防刷,防止机器人脚本等频繁调用服务(userID限流和IP限流)
数据冗余
在核心链路上,服务可以冗余它依赖的服务的数据,依赖的服务故障时,服务尽量做到自保。比如订单服务依赖库存服务。我们可以在订单服务冗余库存数据(注意控制合理的安全库存,防超卖)。下单减库存时,如果库存服务挂了,我们可以直接从订单服务取库存。可以结合熔断一起使用,作为熔断的Fallback(后备)方案。
服务降级
可能很多人都听过服务降级,但是又不知道降级是怎么回事。实际上,上面说的熔断,限流,数据冗余,都属于服务降级的范畴。还有手动降级的例子,比如大促期间我们会关掉第三方物流接口,页面上也关掉物流查询功能,避免拖垮自己的服务。这种降级的例子很多。不管什么降级方式,目的都是让系统可用性更高,容错能力更强,更稳定。关于服务降级详见本文后面的内容。
缓存要注意什么?
缓存穿透。对于数据库中根本不存在的值,请求缓存时要在缓存记录一个空值,避免每次请求都打到数据库
缓存雪崩。在某一时间缓存数据集中失效,导致大量请求穿透到数据库,将数据库压垮。可以在初始化数据时,差异化各个key的缓存失效时间,失效时间 = 一个较大的固定值 + 较小的随机值
缓存热点。有些热点数据访问量会特别大,单个缓存节点(例如Redis)无法支撑这么大的访问量。如果是读请求访问量大,可以考虑读写分离,一主多从的方案,用从节点分摊读流量;如果是写请求访问量大,可以采用集群分片方案,用分片分摊写流量。以秒杀扣减库存为例,假如秒杀库存是100,可以分成5片,每片存20个库存。
关于隔离的考虑
部署隔离:我们经常会遇到秒杀业务和日常业务依赖同一个服务,以及C端服务和内部运营系统依赖同一个服务的情况,比如说都依赖订单服务。而秒杀系统的瞬间访问量很高,可能会对服务带来巨大的压力,甚至压垮服务。内部运营系统也经常有批量数据导出的操作,同样会给服务带来一定的压力。这些都是不稳定因素。所以我们可以将这些共同依赖的服务分组部署,不同的分组服务于不同的业务,避免相互干扰。
数据隔离:极端情况下还需要缓存隔离,数据库隔离。以秒杀为例,库存和订单的缓存(Redis)和数据库需要单独部署!数据隔离后,秒杀订单和日常订单不在相同的数据库,之后的订单查询怎么展示?可以采用相应的数据同步策略。比如,在创建秒杀订单后发消息到消息队列,日常订单服务收到消息后将订单写入日常订单库。注意,要考虑数据的一致性,可以使用事务型消息。
-
业务隔离:还是以秒杀为例。从业务上把秒杀和日常的售卖区分开来,把秒杀做为营销活动,要参与秒杀的商品需要提前报名参加活动,这样我们就能提前知道哪些商家哪些商品要参与秒杀,可以根据提报的商品提前生成商品详情静态页面并上传到CDN预热,提报的商品库存也需要提前预热,可以将商品库存在活动开始前预热到Redis,避免秒杀开始后大量访问穿透到数据库。
CI测试&性能测试
CI测试,持续集成测试,在我们每次提交代码到发布分支前自动构建项目并执行所有测试用例,如果有测试用例执行失败,拒绝将代码合并到发布分支,本次集成失败。CI测试可以保证上线质量,适用于用例不会经常变化的稳定业务。
性能测试,为了保证上线性能,所有用户侧功能需要进行性能测试。上线前要保证性能测试通过。而且要定期做全链路压测,有性能问题可以及时发现。
监控
我们需要一套完善的监控系统,系统出问题时能够快速告警,最好是系统出问题前能提前预警。包括系统监控(CPU,内存,网络IO,带宽等监控),数据库监控(QPS,TPS,慢查询,大结果集等监控),缓存中间件监控(如Redis),JVM监控(堆内存,GC,线程等监控),全链路监控(pinpoint,skywaking,cat等),各种接口监控(QPS,TPS等)
CDN
可以充分利用CDN。除了提高用户访问速度之外,页面静态化之后存放到CDN,用CDN扛流量,可以大幅减少系统(源站)的访问压力。同时也减少了网站带宽压力。对系统稳定性非常有好处。
避免单点问题
除了服务要多点部署外,网关,数据库,缓存也要避免单点问题,至少要有一个Backup,而且要可以自动发现上线节点和自动摘除下线和故障节点。
网络带宽
避免带宽成为瓶颈,促销和秒杀开始前提前申请带宽。不光要考虑外网带宽,还要考虑内网带宽,有些旧服务器网口是千兆网口,访问量高时很可能会打满。
此外,一套完善的灰度发布系统,可以让上线更加平滑,避免上线大面积故障。DevOps工具,CI,CD对系统稳定性也有很大意义。
关于服务降级
提起服务降级,估计很多人都听说过,但是又因为亲身经历不多,所以可能不是很理解。下面结合具体实例从多方面详细阐述服务降级。
互联网分布式系统中,经常会有一些异常状况导致服务器压力剧增,比如促销活动时访问量会暴增,为了保证系统核心功能的稳定性和可用性,我们需要一些应对策略。这些应对策略也就是所谓的服务降级。下面根据笔者的实际经历,跟大家聊聊服务降级那些事儿。希望对大家有所启发!
关闭次要功能
在服务压力过大时,关闭非核心功能,避免核心功能被拖垮。
例如,电商平台基本都支持物流查询功能,而物流查询往往要依赖第三方物流公司的系统接口。物流公司的系统性能往往不会太好。所以我们经常会在双11这种大型促销活动期间把物流接口屏蔽掉,在页面上也关掉物流查询功能。这样就避免了我们自己的服务被拖垮,也保证了重要功能的正常运行。
降低一致性之读降级
对于读一致性要求不高的场景。在服务和数据库压力过大时,可以不读数据库,降级为只读缓存数据。以这种方式来减小数据库压力,提高服务的吞吐量。
例如,我们会把商品评论评价信息缓存在Redis中。在服务和数据库压力过大时,只读缓存中的评论评价数据,不在缓存中的数据不展示给用户。当然评论评价这种不是很重要的数据可以考虑用NOSQL数据库存储,不过我们曾经确实用Mysql数据库存储过评论评价数据。
降低一致性之写入降级
在服务压力过大时,可以将同步调用改为异步消息队列方式(注意这里必须使用rocketmq队列,别的队列不能保证一致性),来减小服务压力并提高吞吐量。既然把同步改成了异步也就意味着降低了数据一致性,保证数据最终一致即可。
例如,秒杀场景瞬间生成订单量很高。我们可以采取异步批量写数据库的方式,来减少数据库访问频次,进而降低数据库的写入压力。详细步骤:后端服务接到下单请求,直接放进消息队列,消费端服务取出订单消息后,先将订单信息写入Redis,每隔100ms或者积攒100条订单,批量写入数据库一次。前端页面下单后定时向后端拉取订单信息,获取到订单信息后跳转到支付页面。用这种异步批量写入数据库的方式大幅减少了数据库写入频次,从而明显降低了订单数据库写入压力,同时增加系统吞吐量。不过,因为订单是异步写入数据库的,就会存在数据库订单和相应库存数据暂时不一致的情况,以及用户下单后不能及时查到订单的情况。因为是降级方案,可以适当降低用户体验,所以我们保证数据最终一致即可。流程如下图:
屏蔽写入
很多高并发场景下,查询请求都会走缓存,这时数据库的压力主要是写入压力。所以对于某些不重要的服务,在服务和数据库压力过大时,可以关闭写入功能,只保留查询功能。这样可以明显减小数据库压力。
例如,商品的评论评价功能。为了减小压力,大促前可以关闭评论评价功能,关闭写接口,用户只能查看评论评价。而大部分查询请求走查询缓存,从而大幅减小数据库和服务的访问压力。
数据冗余
服务调用者可以冗余它所依赖服务的数据。当依赖的服务故障时,服务调用者可以直接使用冗余数据。
例如,我之前在某家自营电商公司。当时的商品服务依赖于价格服务,获取商品信息时,商品服务要调用价格服务获取商品价格。因为是自营电商,商品和SKU数量都不太多,一两万的样子。所以我们在商品服务冗余了价格数据。当价格服务故障后,商品服务还可以从自己冗余的数据中取到价格。当然这样做价格有可能不是最新的,但毕竟这是降级方案,牺牲一些数据准确性,换来系统的可用性还是很有意义的!(注:由于一个商品会有多个价格,比如普通价,会员价,促销直降价,促销满减价,所以我们把价格做成了单独的服务)
数据冗余可以结合熔断一起使用,实现自动降级。下面的熔断部分会详细说明。
熔断和Fallback
熔断是一种自动降级手段。当服务不可用时,用来避免连锁故障,雪崩效应。发生在服务调用的时候,在调用方做熔断处理。熔断的意义在于,调用方快速失败(Fail Fast),避免请求大量阻塞。并且保护被调用方。
详细解释一下,假设A服务调用B服务,B发生故障后,A开启熔断:
对于调用方A:请求在A直接快速返回,快速失败,不再发送到B。 避免因为B故障,导致A的请求线程持续等待,进而导致线程池线程和CPU资源耗尽,最终导致A无法响应,甚至整条调用链故障。
对于被调用方B:熔断后,请求被A拦截,不再发送到B,B压力得到缓解,避免了仍旧存活的B被压垮,B得到了保护。
还是以电商的商品和价格服务为例。获取商品信息时,商品服务要调用价格服务获取商品价格。为了提高系统稳定性,我们要求各个服务要尽量自保。所以我们在商品服务加了熔断,当价格服务故障时,商品服务请求能够快速失败返回,保证商品服务不被拖垮,进而避免连锁故障。
看到这,可能有读者会问,快速失败后价格怎么返回呢?因为是自营电商,商品和SKU数量都不太多,一两万的样子。所以我们做了数据冗余,在商品服务冗余了价格数据。这样我们在熔断后获取价格的fallback方案就变成了从商品服务冗余的数据去取价格。下图为商品服务熔断关闭和开启的对比图。
开源熔断组件:Hystrix,Resilience4j等
限流
说起服务降级,就不可避免的要聊到限流。我们先考虑一个场景,例如电商平台要搞促销活动,我们按照预估的峰值访问量,准备了30台机器。但是活动开始后,实际参加的人数比预估的人数翻了5倍,这就远远超出了我们的服务处理能力,给后端服务、缓存、数据库等带来巨大的压力。随着访问请求的不断涌入,最终很可能造成平台系统崩溃。对于这种突发流量,我们可以通过限流来保护后端服务。因为促销活动流量来自于用户,用户的请求会先经过网关层再到后端服务,所以网关层是最合适的限流位置,如下图。
另外,考虑到用户体验问题,我们还需要相应的限流页面。当某些用户的请求被限流拦截后,把限流页面返回给用户。页面如下图。
另外一个场景,假如有一个核心服务,有几十个服务都调用他。如果其中一个服务调用者出了Bug,频繁调用这个核心服务,可能给这个核心服务造成非常大的压力,甚至导致这个核心服务无法响应。同时也会影响到调用他的几十个服务。所以每个服务也要根据自己的处理能力对调用者进行限制。
对于服务层的限流,我们一般可以利用spring AOP,以拦截器的方式做限流处理。这种做法虽然可以解决问题,但是问题也比较多。比如一个服务中有100个接口需要限流,我们就要写100个拦截器。而且限流阈值经常需要调整,又涉及到动态修改的问题。为了应对这些问题,很多公司会有专门的限流平台,新增限流接口和阈值变动可以直接在限流平台上配置。
关于限流,还有很多细节需要考虑,比如限流算法、毛刺现象等。篇幅原因,这些问题就不在本文讨论了。
开源网关组件:Nginx,Zuul,Gateway,阿里Sentinel等
服务降级总结和思考
上面我们结合具体案例解释了多种降级方式。实际上,关于服务降级的方式和策略,并没有什么定式,也没有标准可言。上面的降级方式也没有涵盖所有的情况。不同公司不同平台的做法也不完全一样。不过,所有的降级方案都要以满足业务需求为前提,都是为了提高系统的可用性,保证核心功能正常运行。
降级分类
一般我们可以把服务降级分为手动和自动两类。手动降级应用较多,可以通过开关的方式开启或关闭降级。自动降级,比如熔断和限流等属于自动降级的范畴。大多手动降级也可以做成自动的方式,可以根据各种系统指标设定合理阈值,在相应指标达到阈值上限自动开启降级。在很多场景下,由于业务过于复杂,需要参考的指标太多,自动降级实现起来难度会比较大,而且也很容易出错。所以在考虑做自动降级之前一定要充分做好评估,相应的自动降级方案也要考虑周全。
大规模分布式系统如何降级?
在大规模分布式系统中,经常会有成百上千的服务。在大促前往往会根据业务的重要程度和业务间的关系批量降级。这就需要技术和产品提前对业务和系统进行梳理,根据梳理结果确定哪些服务可以降级,哪些服务不可以降级,降级策略是什么,降级顺序怎么样。大型互联网公司基本都会有自己的降级平台,大部分降级都在平台上操作,比如手动降级开关,批量降级顺序管理,熔断阈值动态设置,限流阈值动态设置等等。
本节的主要目的是通过具体实例,让大家了解服务降级,并提供一些降级的思路。具体的降级方式和方案还是要取决于实际的业务场景和系统状况。
微服务架构下数据一致性问题
服务化后单体系统被拆分成多个服务,各服务访问自己的数据库。而我们的一次请求操作很可能要跨多个服务,同时要操作多个数据库的数据,我们发现以前用的数据库事务不好用了。那么基于微服务的架构如何保证数据一致性呢?
好,咱们这次就盘一盘分布式事务,最终一致,补偿机制,事务型消息!
提起这些,大家可能会想到两阶段提交,XA,TCC,Saga,还有最近阿里开源的Seata(Fescar),这些概念网上一大堆文章,不过都太泛泛,不接地气,让人看了云里雾里。
我们以TCC分布式事务和RocketMQ事务型消息为例,做详细分享!这个弄明白了,也就清楚分布式事务,最终一致,补偿机制这些概念啦!
TCC分布式事务
TCC(Try-Confirm-Cancel)是分布式事务的一种模式,可以保证不同服务的数据最终一致。目前有不少TCC开源框架,比如Hmily,ByteTCC,TCC-Transaction (我们之前用过Hmily和公司架构组自研组件)
下面以电商下单流程为例对TCC做详细阐述
流程图如下:
基本步骤如下:
1,修改订单状态为“已支付”
2,扣减库存
3,扣减优惠券
4,通知WMS(仓储管理系统)捡货出库(异步消息)
我们先看扣减库存,更新订单状态和扣减优惠券这三步同步调用,通知WMS的异步消息会在后面的“基于消息的最终一致”部分详细阐述!
下面是伪代码。不同公司的产品逻辑会有差异,相关代码逻辑也可能会有不同,大家不用纠结代码逻辑正确性。
public void makePayment() {
orderService.updateStatus(OrderStatus.Payed); //订单服务更新订单为已支付状态
inventoryService.decrStock(); //库存服务扣减库存
couponService.updateStatus(couponStatus.Used); //卡券服务更新优惠券为已使用状态
}
看完这段代码,大家可能觉得很简单!那么有什么问题吗?
答案是肯定的。没法保证数据一致性,也就是说不能保证这几步操作全部成功或者全部失败!因为这几步操作是在分布式环境下进行的,每个操作分布在不同的服务中,不同的服务又对应不同的数据库,本地事务已经用不上了!假如第一步更新订单为“已支付”成功了,第二步扣减库存时,库存服务挂了或者网络出问题了,导致扣减库存失败。你告诉用户支付成功了,但是库存没扣减。这怎么能行!
TCC
接下来,我们来看看TCC是如何帮我们解决这个问题并保证数据最终一致的。
TCC分为两个阶段:
阶段一:Try(预留冻结相关业务资源,设置临时状态,为下个阶段做准备)
阶段二:Confirm 或者 Cancel(Confirm:对资源进行最终操作,Cancel:取消资源)
第一阶段:Try
更新订单状态:此时因为还没真正完成整个流程,订单状态不能直接改成已支付状态。可以加一个临时状态Paying,表明订单正在支付中,支付结果暂时还不清楚!
冻结库存:假设现在可销售库存stock是10,这单扣减1个库存,别直接把库存减掉,而是在表中加一个冻结字段locked_stock,locked_stock 加 1,再给stock减1,这样就相当于冻结了1个库存。两个操作放在一个本地事务里。
更新优惠券状态:优惠券加一个临时状态Inuse,表明优惠券正在使用中,具体有没有正常被使用暂时还不清楚!
第二阶段:Confirm
假如第一阶段几个try操作都成功了!既然第一阶段已经预留了库存,而且订单状态和优惠券状态也设置了临时状态,第二阶段的确认提交从业务上来说应该没什么问题了。
Confirm 阶段我们需要做下面三件事:
先将订单状态从Paying改为已支付Payed,订单状态也完成了。
再将冻结的库存恢复locked_stock减1,stock第一阶段已经减掉1是9了,到此扣减库存就真正完成了。
再将优惠券状态从Inuse改为Used,表明优惠券已经被正常使用。
第二阶段:Cancel
假如第一阶段失败了,
先将订单状态从Paying恢复为待支付UnPayed。
再将冻结的库存还回到可销售库存中,stock加1恢复成10, locked_stock减1,可以放在一个本地事务完成。
再将优惠券状态从Inuse恢复为未使用Unused。
基于Hmily框架的代码:
//订单服务
public class OrderService{
//tcc接口
@Hmily(confirmMethod = "confirmOrderStatus", cancelMethod = "cancelOrderStatus")
public void makePayment() {
更新订单状态为支付中
冻结库存,rpc调用
优惠券状态改为使用中,rpc调用
}
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
}
}
疑问?
问题1:有些朋友可能会问了!这些关于流程的逻辑也要手动编码吗?这也太麻烦了吧!
实际上TCC分布式事务框架帮我们把这些事都干了。比如我们前面提到的Hmily,ByteTCC,TCC-transaction 这些框架。因为try,confirm,cancel这些操作都在TCC分布式事务框架控制范围之内,所以try的各个步骤成功了或者失败了,框架本身都知道,try成功了框架就会自动执行各个服务的confirm,try失败了框架就会执行各个服务的cancel(各个服务内部的TCC分布式事务框架会互相通信)。所以我们不用关心流程,只需要关注业务代码就可以啦!
问题2:仔细想想,好像还有问题!假如confirm阶段更新订单状态成功了,但是扣减库存失败了怎么办呢?
比如网络出问题了或者库存服务(宕机,重启)出问题了。当然,分布式事务框架也会考虑这些场景,框架会记录操作日志,假如confirm阶段扣减库存失败了,框架会不断重试调用库存服务直到成功(考虑性能问题,重试次数应该有限制)。cancel过程也是一样的道理。注意,既然需要重试,我们就要保证接口的幂等性。什么?不太懂幂等性。简单说:一个操作不管请求多少次,结果都要保证一样。这里就不详细介绍啦!
再考虑一个场景,try阶段冻结库存的时候,因为是rpc远程调用,在网络拥塞等情况下,是有可能超时的。假如冻结库存时发生超时,tcc框架会回滚(cancel)已经执行的分布式事务操作,也就是下单操作。过了一段时间网络通畅了,之前的冻结库存请求才到达参与者(库存服务)并执行了冻结库存操作,由于已经执行了cancle,所以此时被冻结的库存后续没办法处理(恢复)了。这种情况称之为“悬挂”,也就是说预留的资源后续无法处理。解决方案:第二阶段已经执行,第一阶段就不再执行了,可以加一个“分支事务记录表”,如果表里有相关第二阶段的执行记录,就不再执行try(上面代码有防悬挂处理)。有人可能注意到还有些小纰漏,对,加锁,分布式环境下,我们可以考虑对第二阶段执行记录的查询和插入加上分布式锁,确保万无一失。
rocketmq
基于消息的最终一致
还是以上面的电商下单流程为例
上图,下单流程最后一步,通知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发送事务型消息的流程:
概念介绍
- 事务消息:消息队列RocketMQ版提供类似X或Open XA的分布式事务功能,通过消息队列RocketMQ版事务消息能达到分布式事务的最终一致。
- 半事务消息:暂不能投递的消息,发送方已经成功地将消息发送到了消息队列RocketMQ版服务端,但是服务端未收到生产者对该消息的二次确认,此时该消息被标记成“暂不能投递”状态,处于该种状态下的消息即半事务消息。
- 消息回查:由于网络闪断、生产者应用重启等原因,导致某条事务消息的二次确认丢失,消息队列RocketMQ版服务端通过扫描发现某条消息长期处于“半事务消息”时,需要主动向消息生产者询问该消息的最终状态(Commit或是Rollback),该询问过程即消息回查。
事务消息发送步骤如下:
- 发送方将半事务消息发送至消息队列RocketMQ版服务端。
- 消息队列RocketMQ版服务端将消息持久化成功之后,向发送方返回Ack确认消息已经发送成功,此时消息为半事务消息。
- 发送方开始执行本地事务逻辑。
- 发送方根据本地事务执行结果向服务端提交二次确认(Commit或是Rollback),服务端收到Commit状态则将半事务消息标记为可投递,订阅方最终将收到该消息;服务端收到Rollback状态则删除半事务消息,订阅方将不会接受该消息。
事务消息回查步骤如下:
- 在断网或者是应用重启的特殊情况下,上述步骤4提交的二次确认最终未到达服务端,经过固定时间后服务端将对该消息发起消息回查。
- 发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
- 发送方根据检查得到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤4对半事务消息进行操作。
注意事项
- 事务消息的Group ID不能与其他类型消息的Group ID共用。与其他类型的消息不同,事务消息有回查机制,回查时消息队列RocketMQ版服务端会根据Group ID去查询客户端。
- 通过ONSFactory.createTransactionProducer创建事务消息的Producer时必须指定LocalTransactionChecker的实现类,处理异常情况下事务消息的回查。
- 事务消息发送完成本地事务后,可在execute方法中返回以下三种状态:
- TransactionStatus.CommitTransaction:提交事务,允许订阅方消费该消息。
- TransactionStatus.RollbackTransaction:回滚事务,消息将被丢弃不允许消费。
- TransactionStatus.Unknow:暂时无法判断状态,等待固定时间以后消息队列RocketMQ版服务端向发送方进行消息回查。
- 可通过以下方式给每条消息设定第一次消息回查的最快时间:
Message message = new Message();
// 在消息属性中添加第一次消息回查的最快时间,单位秒。例如,以下设置实际第一次回查时间为120秒~125秒之间message.putUserProperties(PropertyKeyConst.CheckImmunityTimeInSeconds,"120");
// 以上方式只确定事务消息的第一次回查的最快时间,实际回查时间向后浮动0秒~5秒;如第一次回查后事务仍未提交,后续每隔5秒回查一次
看完事务型消息发送流程,有些读者可能没有完全理解,不要紧,我们来分析一下!
疑问?
问题1:假如发送方发送半消息失败怎么办?
半消息(待确认消息)是消息发送方发送的,发送后mq服务端会返回ack确认消息,发送方可根据返回的ack消息并可以做相应处理。
问题2:假如发送方执行完本地事务后,发送确认消息通知MQ提交或回滚消息时失败了(网络问题,发送方重启等情况),怎么办?
流程图的第5步回查就是解决这个问题的,当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) {
try{
DB::beginTransaction();
//执行本地本地事务
//记录orderID,消息状态键值对到共享map中,以备MQ回查消息状态使用;
DB::commit();
return LocalTransactionState.COMMIT_MESSAGE;
}catch (\Exception $exception){
//记录orderID,消息状态键值对到共享map中,以备MQ回查消息状态使用;
Log::error('transaction',['error'=>$exception->getMessage()]);
throw new Exception($exception->getMessage());
DB::rollBack();
return LocalTransactionState. RollbackTransaction;
}
}
//回查发送者状态
@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方法,说明半消息已经发送成功了,也就是执行到流程中的第三步了,然后根据本地事务的执行情况返回结果,如果返回TransactionStatus.CommitTransaction,则消息订阅费将开始投递消息到mq,同时执行tcc到了confirm阶段。如果返回TransactionStatus.RollbackTransaction,则消息订阅费丢弃消息,同时执行tcc到了cancle阶段。如果本地事务没有执行第4步,可能情况是节点宕机,也就是没有给mq服务端回复消息,此时就会回查,所以我们需要在executeLocalTransaction方法内记录一个map,方便在回查的时候判断后续执行操作。
微服务化过程,无感知数据迁移
微服务化,其中一个重要意义在于数据隔离,即不同的服务对应各自的数据库表,避免不同业务模块间数据的耦合。这也就意味着微服务化过程要拆分现有数据库,把单体数据库根据业务模块拆分成多个,进而涉及到数据迁移。
其次是数据库单体容量过大时,需要分表或者分片时,也需要将原来的数据重新的数据库内,进而也涉及到了数据迁移。
数据迁移过程我们要注意哪些关键点呢?第一,保证迁移后数据准确不丢失,即每条记录准确而且不丢失记录;第二,不影响用户体验(尤其是访问量高的C端业务需要不停机平滑迁移);第三,保证迁移后的性能和稳定性。
针对以上俩种场景,我们提出下面俩种方法:
挂从库
这种方式适合数据结构不变,而且空闲时间段流量很低,允许停机迁移的场景。在空闲时段,几分钟的停机时间,对用户影响很小,业务方是可以接受的。所以我们可以采用停机迁移的方案。步骤如下:
1,新建从库(新数据库),数据开始从主库向从库同步。
2,数据同步完成后,找一个空闲时间段。为了保证主从数据库数据一致,需要先停掉服务,然后再把从库升级为主库。如果访问数据库用的是域名,直接解析域名到新数据库(从库升级成的主库),如果访问数据库用的是IP,将IP改成新数据库IP。
- 针对微服务场景,新库就是一个单独的数据库,只要留下本服务需要的表即可,其他表即可删除。针对分片场景,新库就是一个已经具备分片功能的数据库,数据写入该库后会按照分片规则自动倒入分片节点内。
4,最后启动服务,整个迁移过程完成。
这种迁移方案的优势是迁移成本低,迁移周期短。缺点是,切换数据库过程需要停止服务。
双写
老库和新库同时写入,然后将老数据批量迁移到新库,最后流量切换到新库并关闭老库读写。
这种方式适合数据结构发生变化,不允许停机迁移的场景。一般发生在系统重构时,数据结构会发生变化,如表结构改变或者分库分表等场景。有些大型互联网系统,平常并发量很高,即便是空闲时段也有相当的访问量。几分钟的停机时间,对用户也会有明显的影响,甚至导致一定的用户流失,这对业务方来说是无法接受的。所以我们需要考虑一种用户无感知的不停机迁移方案,不停机迁移不需要保证老数据的正确迁移同时也要保证新数据的写入。
以笔者之前经历的用户系统重构为例,聊一下具体方案。当时的场景是这样的,用户表记录数达到3000万时,系统性能和可维护性变差,于是我们将用户中心从单体工程中拆分出来并做了重构,重新设计了表结构,而且业务方要求不停机上线!就需要注意下面是我们当时的方案,步骤如下:
代码准备。在服务层对用户表进行增删改的地方,要同时操作新库和老库,需要修改相应的代码(同时写新库和老库)。准备迁移程序脚本,用于做老数据迁移。准备校验程序脚本,用于校验新库和老库的数据是否一致。
对于第一种微服务场景,新库可以是一个单独的数据库,对于第二种场景新库就是一个已经搭建好具备分片功能的数据库。
开启双写,老库和新库同时写入。注意:任何对数据库的增删改都要双写;对于更新操作,如果新库没有相关记录,需要先从老库查出记录,将更新后的记录写入新库,如果新库有记录则新库老库一起更新;对于删除操作,如果新库没有数据则删除老库数据即可,如果新库有数据则新库老库一起删除。对于新增操作则保证新库和老库都新增成功。
为了保证写入性能,老库写完后,可以采用消息队列异步写入新库。
注意:双写的操作,需要保证数据的id(主键也要相同),比如老库新增老一条id为100的数据,那么在新库中这条数据的id(主键)也是100。利用脚本程序,将某一时间戳之前的老数据迁移到新库。注意:1,时间戳一定要选择开启双写后的时间点(比如开启双写后10分钟的时间点),避免部分老数据被漏掉;2,迁移过程遇到记录冲突直接忽略,产生冲突的原因可能是迁移迁移之前老库的数据已经存在于新库了(存在主键冲突的时间段就是双写开始时间到迁移老数据开始时间,这段时间新库已经存在了老库的一些数据),当迁移操作开始后在往新库插入相同数据将会报主键冲突等问题;3,迁移过程一定要记录日志,尤其是错误日志,如果有双写失败的情况,我们可以通过日志恢复数据,以此来保证新老库的数据一致。4.老表迁移到新表的数据的主键Id也一定要一致,比如老表的数据Id为100,那么这条数据迁移到新表的id也应该为100
第3步完成后,我们还需要通过脚本程序检验数据,看新库数据是否准确以及有没有漏掉的数据
对比办法:读取所有字段,根据字段名称+字段值进行拼接,拼接MD5是否相同,相同则不追究,不相同用原表进行覆盖
public function run()
{
$oldData = oldSheet::findRows(['*'],[]);
$newData = newSheet::findRows(['*'],[]);
//主键=>list
$id2New = array_column($newData,null,'id');
foreach ($oldData as $list){
$ret = $this->isMatch($id2New[$list['id']],$list);
if($ret == 1){
//可能发生主键冲突,忽略
newSheet::insert($list,true);
}
if($ret == 3){
newSheet::deleteAll([$list['id']]);
newSheet::insert($list,true);
}
}
}
public function isMatch(array $newRow, array $oldRow)
{
$newStr = $oldStr = '';
if(empty($newRow)){
//表示新表漏掉了数据,新表不存在
return 1;
}
foreach ($oldRow as $field => $value) {
$oldStr .= $field . $value;
$newStr .= $field . $newRow[$field];
}
if(md5($newStr) != md5($oldStr)){
//表示新表和老表数据一致
return 2;
}else{
//表示新表和老表数据不一致
return 3;
}
}
数据校验没问题后,开启双读,起初给新库放少部分流量,新库和老库同时读取。由于延时问题,新库和老库可能会有少量数据记录不一致的情况,所以新库读不到时需要再读一遍老库。逐步将读流量切到新库,相当于灰度上线的过程。遇到问题可以及时把流量切回老库
读流量全部切到新库后,关闭老库写入(可以在代码里加上热配置开关),只写新库
-
迁移完成,后续可以去掉双写双读相关无用代码。
切回单写及数据表rename
当切换的新表和老表属于同一个库时,这里最好进行rename操作,也就是新表rename成老表。
好处:如果老表的数据来自上游写入,那么我只需要将新表rename为老表即可,上游表对应不需要更新了。
坏处:这个操作需要dba进行,且rename期间服务不可用
目前各云服务平台也提供数据迁移解决方案,大家有兴趣也可以了解一下!
全链路APM监控
在体会到微服务带来好处的同时,很多公司也会明显感受到微服务化后那些让人头疼的问题。比如,服务化之后调用链路变长,排查性能问题可能要跨多个服务,定位问题更加困难;服务变多,服务间调用关系错综复杂,以至于很多工程师不清楚服务间的依赖和调用关系,之后的系统维护过程也会更加艰巨。诸如此类的问题还很多!
这时就迫切需要一个工具帮我们解决这些问题,于是APM全链路监控工具就应运而生了。有开源的Pinpoint、Skywalking等,也有收费的Saas服务听云、OneAPM等。有些实力雄厚的公司也会自研APM。
下面我们介绍一下如何利用开源APM工具Pinpoin
t应对上述问题。
拓扑图
微服务化后,服务数量变多,服务间调用关系也变得更复杂,以至于很多工程师不清楚服务间的依赖和调用关系,给系统维护带来很多困难。通过拓扑图我们可以清晰地看到服务与服务,服务与数据库,服务与缓存中间件的调用和依赖关系。对服务关系了如指掌之后,也可以避免服务间循依赖、循环调用的问题。
请求调用栈(Call Stack)监控
微服务化后,服务变多,调用链路变长,跨多个服务排查问题会更加困难。上图是一个请求的调用栈,我们可以清晰看到一次请求调用了哪些服务和方法、各个环节的耗时以及发生在哪个服节点。上图的请求耗时过长,根据监控(红框部分)我们可以看到时间主要消耗在数据库SQL语句上。点击数据库图表还可以看详细sql语句,如下图:
如果发生错误,会显示为红色,错误原因也会直接显示出来。如下图:
类似性能问题和错误的线上排查。我们如果通过查日志的传统办法,可能会耗费大量的时间。但是通过APM工具分分钟就可以搞定了!
请求Server Map
Server Map是Pinpoint另一个比较重要的功能。如上图,我们不但能清晰地看到一个请求的访问链路,而且还能看到每个节点的访问次数,为系统优化提供了有力的依据。如果一次请求访问了多次数据库,说明代码逻辑可能有必要优化了!
JVM监控
此外,Pinpoint还支持堆内存,活跃线程,CPU,文件描述符等监控。