基于支付场景下的微服务改造与性能优化

程超 | 作者

本文节选自《高可用可伸缩微服务架构:基于Dubbo、Spring Cloud和Service Mesh》一书,程超 等著,由电子工业出版社博文视点出版,已获得授权。

近年来微服务架构已经成为大规模分布式架构的主流技术,越来越多的公司已经或开始转型为微服务架构。本书不以某一种微服务框架的使用为主题,而是对整个微服务生态进行系统性的讲解,并结合工作中的大量实战案例为读者呈现一本读完即可实际上手应用的工具书。

一、支付场景的介绍

本章主要介绍基于支付场景下的微服务实践,微服务体现的真谛最终还是要理解业务,只有深入理解了业务才能结合领域来重新定义微服务,下面就简单介绍一下互联网支付。

常见的互联网支付的使用场景主要有以下几种。

  • 刷卡支付:用户展示微信钱包内的“刷卡条码/二维码”给商户系统,扫描后直接完成支付,适用于线下面对面收银的场景,如超市、便利店等(被扫,线下)。
  • 扫码支付:商户系统按微信支付协议生成支付二维码,用户再用微信“扫一扫”来完成支付,适用于PC网站支付、实体店单品等场景(主扫,线上)。
  • 公众号支付:用户在微信中打开商户的H5页面,商户在H5页面通过调用微信支付提供的JSAPI接口调用微信支付模块来完成支付,适用于在公众号、朋友圈、聊天窗口等微信内完成支付的场景。
  • WAP支付:基于公众号基础开发的一种非微信内浏览器支付方式(需要单独申请支付权限),可以满足在微信外的手机H5页面进行微信支付的需求!简单来说,就是通过PC、手机网页来实现下单支付(俗称H5支付)。
  • App支付:商户通过在移动端应用App中集成开放SDK调用微信支付模块来完成支付。
  • 网关支付:用户需要开通网上银行后在线完成支付,主要对象是国内银行借记卡和信用卡,是银行系统为企业或个人提供的安全、快捷、稳定的支付服务。
  • 快捷支付:快捷支付指用户购买商品时,不需开通网银,只需提供银行卡卡号、户名、手机号码等信息,银行验证手机号码正确性后,第三方支付发送手机动态口令到用户手机号上,用户输入正确的手机动态口令即可完成支付。

在支付场景下实现微服务的最终目标:能够将单体支付系统按业务进行解耦,利用微服务生态来实施支付系统,并且能够保证系统的可靠性和并发能力,建设完整的运维体系以支撑日益庞大的微服务系统。

二、支付业务建模和服务划分

我们在第2章介绍了领域建模的相关知识,由此可以知道几个关键词:领域、子域、限界上下文。有些读者对领域、子域的概念比较容易理解,但是限界上下文就理解得比较模糊,这里再对这个关键词简单做一下介绍。

可以把限界上下文理解为:一个系统、一个应用、一个服务或一个组件,而它又存在于领域之中。举个生活中的例子:我每天上班都会坐地铁,从家里出发到单位需要换乘三次地铁,分别是5号线、8号线和2号线。那么地铁就可以理解为限界上下文,从5号线走到8号线这个过程就是领域事件,而为了到达目的地中间换乘地铁,这个过程叫作上下文切换。

再回到支付业务中,该如何根据业务和领域相关知识来划分服务呢?我们以一个业务架构示例来讲解,如图11-1所示。

当我们在工作中遇到一个完整的业务场景时,首先需要识别出一共有哪些领域,根据大的领域再来划分子域,最后将具有相同领域或子域的限界上下文进行归类。正确识别出领域其实是比较难的,需要设计人员前期对业务有大量的调研,有比较深入的了解后才能识别领域。

从图11-1中可以看到整个业务架构图分两大部分,中间的是业务核心领域,两边的是支撑子域。

我们重点介绍中间的部分,每一层就是一个领域,领域中又包括特定子域。

(1)对接业务层:主要是一些业务系统对接支付系统,包括电商业务、互金业务和一键支付三个限界上下文。

(2)统一接入网关层:主要功能是对请求入口进行加解密、分流、限流和准入控制等。

图11-1

(3)产品服务层。

  • 收银台:包括两个限界上下文,分别是PC收银台和手机手银台。
  • 商户:包括四个限界上下文,分别是分账、鉴权、担保和代扣。
  • 个人:包括两个限界上下文,分别是充值和提现。

(4)业务服务层:包括五个限界上下文,分别是交易服务、支付服务、退款服务、计费服务和风控服务。

(5)基础服务层。

  • 网关:包括三个限界上下文,分别是支付网关、鉴权和支付路由。
  • 资金处理平台:包括四个限界上下文—对账、清结算、备付金和会计。

三、支付场景下微服务架构的详解与分析

使用微服务的核心是业务,没有业务进行支撑的微服务是“虚的”,但只有业务与微服务相结合的思想而没有微服务的架构体系也是无法将微服务落地的,所以本章重点介绍要做好微服务还需要完善哪些技术架构。

下面我们将以一个实际工作中的案例为出发点,分析在中小公司中如何落地微服务。如图11-2所示,左半部分是微服务的业务架构,右半部分是微服务的基础技术架构。

图11-2

3.1 业务架构分析

根据前面介绍的如何根据业务来划分领域可以看到,整个业务架构部分已经完成了领域的划分,我们重点来看服务层。服务层是一个核心域里面包含了多个子域,每个子域都是按功能进行划分的,比如支付中心子域里面包括支付服务、路由系统和银行渠道等限界上下文,这些限界上下文是一个服务,还是一个系统呢?这就要结合康威定律来综合考量团队的规模,小公司创业初期研发人员少,可以将支付中心子域定义为限界上下文,里面包括三个独立模块,分别是支付服务模块、路由模块和银行渠道模块,待人员逐步增加到一定规模后,多个项目组同时修改一个支付中心限界上下文会导致互相影响的时候,就需要将支付中心上升为一个业务领域,而将之前的三个独立模块拆分为独立系统,由不同的项目组分别接管,各自维护各自部署,如图11-3所示。

图11-3

可以看出左边是未拆分前的结构,交易服务想要调用支付模块就必须统一调用支付系统,然后才能调用支付模块,而右边是经过拆分后的结构,这时交易服务可以直接调用支付服务系统、路由系统和银行渠道系统中的任意一个,当然从业务流来讲肯定要先调用支付服务系统。

而数据层是根据业务进行数据库的拆分,拆分原则与应用拆分相同,如图11-4所示。

图11-4

可以看到业务、应用和数据库三者一体,物理上与其他业务隔离,不同应用服务的数据库是不能直接访问的,只能通过服务调用进行访问。

3.2 技术平台详解

当我们将整个支付业务根据微服务理念做了合理划分之后,业务架构的各层次就逐步清晰起来,而微服务架构的成功建设除了业务上面的划分,技术平台和运维体系的支撑也是非常重要的,图11-2的右半部分共分为三个层次,分别是统一平台业务层、微服务基础中间件层和自动化运维层。

1) 统一业务平台层

这一层主要是通用的平台业务系统,包括数据分析服务、商户运营服务、运维管控服务和进件报备服务,它们无法根据业务被归类到某一业务系统中,只能作为支撑域存在,所以放到统一业务平台层供所有业务线共同使用。

2)微服务基础中间件层

微服务本身是一个生态,为了支撑微服务这个庞大的体系,必须有很多基础中间件进行辅助才能使微服务平稳地运行。下面将根据笔者积累的实践经验对图中一些重要的组件进行技术选型方面的介绍,另外图中有很多组件在本书其他章节进行了详细介绍,这里就不再做说明。

  • 微服务框架

目前市面上非常流行Spring Boot+Spring Cloud的微服务框架,这套框架确实是微服务的集大成者,涵盖的范围广,可以支持动态扩展和多种插件。但是作为公司的管理者来说,并不能因为出了新的技术就立刻将公司核心业务用新的技术进行更替,这样在生产上所带来的风险将会非常大。比较合理的做法是,如果公司或部门是新成立的,还没有做技术框架的选型,又想在公司内部推广微服务的时候,尝试使用Spring Boot和Spring Cloud框架,可以节省出公司或部门的很多时间来攻关前端业务,而不需要将更多精力放在如何进行微服务的建设上来。

目前很多互联网公司在生产过程中使用的微服务框架并不是Spring Boot和Spring Cloud,会使用如Dubbo、gRPC、Thrift等RPC框架进行服务治理,而公司内部自己研发出很多微服务的外围组件,比如APM监控系统、分库分表组件、统一配置中心、统一定时任务等。在这种情况下公司内部已经自建了比较完善的基础架构平台就没必要整体更换为Spring Boot和Spring Cloud,否则代价极大,甚至会对公司的业务造成严重的后果。公司发展的策略一般都是以客户(用户)稳定优先,但公司技术也需要更新,可以先尝试在公司边缘业务中使用,达到认可后逐步推广,循环渐进。

笔者在进行微服务改造的过程中实际上是基于原有的Dubbo做的改进,将Duboo和Spring Boot相结合形成服务治理框架。

  • 消息服务

我们在谈技术选型的时候,不能脱离业务空谈选型,每种消息中间件必定有其优点和不足,我们可以根据自身的场景择优选择,下面笔者结合自己使用的两种类型的MQ简单说一下选型与使用场景。

RabbitMQ是使用Erlang编写的一个开源的消息队列,本身支持很多协议:AMQP、XMPP、SMTP、STOMP,也正是如此,使它变得非常重量级,更适合企业级的开发。RabbitMQ是AMQP协议领先的一个实现,它实现了代理(Broker)架构,意味着消息在发送到客户端之前可以在中央节点上排队。对路由(Routing)、负载均衡(Load balance)或数据持久化都有很好的支持。但是在集群中使用的时候,分区配置不当偶尔会有脑裂现象出现,总的来说,在支付行业用RabbitMQ还是非常多的。

Kafka是LinkedIn于2010年12月开发并开源的一个分布式MQ系统,现在是Apache的一个孵化项目,是一个高性能跨语言分布式Publish/Subscribe消息队列系统,其性能和效率在行业中是领先的,但是原先的版本经过大量测试,因为其主备Partition同步信息的机制问题,偶尔会造成数据丢失等问题,所以更多的应用场景还是在大数据、监控等领域。

目前市面上有很多支付公司都在使用RabbitMQ作为消息中间件,虽然很“重”但是却具有支付行业的不丢消息、MQ相对稳定等特点。缺点则是不像ActiveMQ那样可以使用Java实现定制化,比如想知道消息队列中有多少剩余消息没有消费,哪些通道获取过消息,共有多少条,是否可以手动或自动触发重试等,还有监控和统计信息,目前做得还不是太完善,只能满足基本功能的要求。

接下来我们再来说说消息队列在技术领域的使用场景。

(1)可以做延迟设计。

比如一些数据需要过五分钟后再使用,这时就需要使用延迟队列设计,比如在RabbitMQ中利用死信队列实现。

(2)异步处理。

主要应用在多任务执行的场景。

(3)应用解耦。

在大型微服务架构中,有一些无状态的服务经常考虑使用MQ做消息通知和转换。

(4)分布式事务最终一致性。

可以使用基于消息中间件的队列做分布式事务的消息补偿,实现最终一致性。

(5)流量削峰。

一般在秒杀或团抢活动中使用广泛,可以通过队列控制秒杀的人数和商品,还可以缓解短时间压垮应用系统的问题。

(6)日志处理。

我们在做监控或日志采集的时候经常用队列来做消息的传输和暂存。

  • 统一配置中心

目前市面有很多种开源的统一配置中心组件可供使用,如携程开源的Apollo、阿里的Diamond、百度的Disconf,每种组件都各有特点,我们在使用的过程中还需要根据实际情况来综合考量。笔者公司目前采用的微服务架构是Spring Boot+Dubbo的方式,Apollo的架构使用了Spring Boot+SpringCloud的方式,在架构方式上正好可以无缝对接,同时Apollo可以解决同城双活方面的问题,所以从这些角度来看比较适合目前的场景。

  • 银行通道监控与切换

由于每家银行提供的业务及产品不同,例如B2C、B2B、大额支付、银企直连、代收代付、快捷支付等,这些产品及服务并无统一的接口,要使用这些产品服务,支付机构只能一家家银行进行接入,当对接的银行通道过多时,每条通道的稳定性就是支付工作中的重中之重,这是涉及用户支付是否成功的关键,也是支付机构支付成功率的重要指标,基于此,要有针对性地进行银行通道稳定性的监控与故障切换系统的建设,如图11-5所示。

图11-5

图11-5是通道监控与切换系统的整体架构,通过在相应组件或应用上面增加Agent监控代理拦截通道的请求情况,经过Collector进行数据汇总,然后将通道评分数据发送给Redis集群,而支付路由系统在进行通道选取的时候会从Redis集群中获取通道的评分及通道相应的配置项进行综合评定从而选取合适的通道,另外采集所有的监控数据都会存放到InfluxDB中,通过Grafana进行预警展示,如果通道不可用则自动将通道关闭,同时通知研发部门进行问题排查。

四、从代码层面提升微服务架构的性能

很多架构变迁或演进方面的文章大多是针对架构方面的介绍,很少有针对代码级别的性能优化介绍,这就好比盖楼一样,楼房的基础架子搭得很好,但是盖房的工人不够专业,有很多需要注意的地方忽略了,在往里面添砖加瓦的时候出了问题,后果就是房子经常漏雨、墙上有裂缝等各种问题出现,虽然不至于楼房塌陷,但楼房已经变成了危楼。

判断一个项目是否具有良好的设计需要从优秀的代码和高可用架构两个方面来衡量,如图11-6所示。

图11-6

优秀的代码是要看程序的结构是否合理,程序中是否存在性能问题,依赖的第三方组件是否被正确使用等。而高可用架构是要看项目的可用性、扩展型,以及能够支持的并发能力。可以说一个良好的项目设计是由两部分组成的,缺一不可。

4.1 从代码和设计的角度看

在实战的过程中,不同的公司所研发的项目和场景也不一样,下面主要以支付场景为出发点,从代码和设计的角度总结一些常见的问题。

1)数据库经常发生死锁现象

以MySQL数据库为例,select......for update语句是手工加锁(悲观锁)语句,是一种行级锁。通常情况下单独使用select语句不会对数据库数据加锁,而使用for update语句则可以在程序层面实现对数据的加锁保护,如果for update语句使用不当,则非常容易造成数据库死锁现象的发生,如表11-1所示。

表11-1

时  间

会话A

会话B

1

事务开始

2

Select * from test where age = 10 for update

事务开始

3

Select * from test where age = 20 for update

4

Select * from test where age = 20 for update此时事务一直等待会话B

Select * from test where age = 10 for update数据库报错:Deadlock found when trying to get lock; try restarting transaction

在上述事例中,会话B会抛出死锁异常,死锁的原因就是A和B两个会话互相等待,出现这种问题其实就是我们在项目中混杂了大量的事务+for update语句并且使用不当所造成的。

MySQL数据库锁主要有三种基本锁。

  • Record Lock:单个行记录的锁。
  • Gap Lock:间隙锁,锁定一个范围,但不包括记录本身。
  • Gap Lock+Record Lock(next-key lock):锁定一个范围,并且也锁定记录本身。

当for update语句和gap lock、next-key lock锁相混合使用,又没有注意用法的时候,就非常容易出现死锁的情况。

2)数据库事务占用时间过长

先看一段伪代码:

public void test() {
    Transaction.begin //事务开启
    try {
        dao.insert //插入一行记录
       httpClient.queryRemoteResult() //请求访问
        dao.update //更新一行记录
        Transaction.commit()//事务提交
    } catch(Exception e) {
          Transaction.rollFor//事务回滚
    } 
}

项目中类似这样的程序有很多,经常把类似httpClient,或者有可能造成长时间超时的操作混在事务代码中,不仅会造成事务执行时间超长,而且会严重降低并发能力。

我们在使用事务的时候,遵循的原则是快进快出,事务代码要尽量小。针对以上伪代码,我们要把httpClient这一行拆分出来,避免同事务性的代码混在一起。

3)滥用线程池,造成堆和栈溢出

Java通过Executors提供了四种线程池可供我们直接使用。

  • newCachedThreadPool:创建一个可缓存线程池,这个线程池会根据实际需要创建新的线程,如果有空闲的线程,则空闲的线程也会被重复利用。
  • newFixedThreadPool:创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
  • newScheduledThreadPool:创建一个定长线程池,支持定时及周期性任务执行。
  • newSingleThreadExecutor:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO,LIFO,优先级)执行。

JDK提供的线程池从功能上替我们做了一些封装,也节省了很多参数设置的过程。如果使用不当则很容易造成堆和栈溢出的情况,示例代码如下所示。

private staticfinal ExecutorService executorService = Executors.newCachedThreadPool();
 /**
 * 异步执行短频快的任务
 * @param task
 */
 public static voidasynShortTask(Runnable task){
 executorService.submit(task);
  //task.run();
 }
 
 CommonUtils.asynShortTask(newRunnable() {
      @Override
      public void run() {
          String sms =sr.getSmsContent();
         sms = sms.replaceAll(finalCode, AES.encryptToBase64(finalCode,ConstantUtils.getDB_AES_KEY()));
         sr.setSmsContent(sms);
         smsManageService.addSmsRecord(sr);
      }
 });

以上代码的场景是每次请求过来都会创建一个线程,将DUMP日志导出进行分析,发现项目中启动了一万多个线程,而且每个线程都显示为忙碌状态,已经将资源耗尽。我们仔细查看代码会发现,代码中使用的线程池是使用以下代码来申请的。

private static final ExecutorServiceexecutorService = Executors.newCachedThreadPool();

在高并发的情况下,无限制地申请线程资源会造成性能严重下降,采用这种方式最大可以产生多少个线程呢?答案是Integer的最大值!查看如下源码:

public static ExecutorServicenewCachedThreadPool() {
      return newThreadPoolExecutor(0, Integer.MAX_VALUE,
                                       60L, TimeUnit.SECONDS,
                                       newSynchronousQueue<Runnable>());
  }

既然使用newCachedThreadPool可能带来栈溢出和性能下降,如果使用newFixedThreadPool设置固定长度是不是可以解决问题呢?使用方式如以下代码所示,设置固定线程数为50:

private static final ExecutorServiceexecutorService = Executors.newFixedThreadPool(50);

修改完成以后,并发量重新上升到100TPS以上,但是当并发量非常大的时候,项目GC(垃圾回收能力下降),分析原因还是因为Executors.newFixedThreadPool(50)这一行,虽然解决了产生无限线程的问题,但采用newFixedThreadPool这种方式会造成大量对象堆积到队列中无法及时消费,源码如下:

public static ExecutorService newFixedThreadPool(int nThreads,ThreadFactory threadFactory) {
        return newThreadPoolExecutor(nThreads, nThreads,
                                        0L, TimeUnit.MILLISECONDS,
                                         newLinkedBlockingQueue<Runnable>(),
                                         threadFactory);
 }

可以看到采用的是无界队列,也就是说队列可以无限地存放可执行的线程,造成大量对象无法释放和回收。

其实JDK还提供了原生的线程池ThreadPoolExecutor,这个线程池基本上把控制的权力交给了使用者,使用者设置线程池的大小、任务队列、拒绝策略、线程空闲时间等,不管使用哪种线程池,都是建立在我们对其精准把握的前提下才能真正使用好。

4)常用配置信息依然从数据库中读取

不管是什么业务场景的项目,只要是老项目,我们经常会遇到一个非常头疼的问题就是项目的配置信息是在本地项目的properties文件中存放的,或者是将常用的配置信息存放到数据库中,这样造成的问题是:

  • 如果使用本地properties文件,每次修改文件都需要一台一台地在线上环境中修改,在服务器数量非常多的情况下非常容易出错,如果修改错了则会造成生产事故。
  • 如果是用采集数据库来统一存放配置信息,在并发量非常大的情况下,每一次请求都要读取数据库配置则会造成大量的I/O操作,会对数据库造成较大的压力,严重的话对项目也会产生性能影响。

比较合理的解决方案之一:使用统一配置中心利用缓存对配置信息进行统一管理,具体的实现方案可以参考《深入分布式缓存》这本书。

5)从库中查询数据,每次全部取出

我们在代码中经常会看到如下SQL语句:

select * from order where status = 'init'

这句SQL从语法上确实看不出什么问题,但是放在不同的环境上却会产生不同的效果,如果此时我们的数据库中状态为init的数据只有100条,那么这条SQL会非常快地查询出来并返回给调用端,在这种情况下对项目没有任何影响。如果此时我们的数据库中状态为init的数据有10万条,那么这条SQL语句的执行结果将是一次性把10万记录全部返回给调用端,这样做不仅会给数据库查询造成沉重的压力,还会给调用端的内存造成极大的影响,带来非常不好的用户体验。

比较合理的解决方案之一:使用limit关键字控制返回记录的数量。

6)业务代码研发不考虑幂等操作

幂等就是用户对于同一操作发起的一次请求或多次请求所产生的结果是一致的,不会因为多次点击而产生多种结果。

以支付场景为例,用户在网上购物选择完商品后进行支付,因为网络的原因银行卡上面的钱已经扣了,但是网站的支付系统返回的结果却是支付失败,这时用户再次对这笔订单发起支付请求,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条,这种场景就不是幂等。

实际工作中的幂等其实就是对订单进行防重,防重措施是通过在某条记录上加锁的方式进行的。

针对以上问题,完全没有必要使用悲观锁的方式来进行防重,否则不仅对数据库本身造成极大的压力,对于项目扩展性来说也是很大的扩展瓶颈,我们采用了三种方法来解决以上问题:

  • 使用第三方组件来做控制,比如ZooKeeper、Redis都可以实现分布式锁。
  • 使用主键防重法,在方法的入口处使用防重表,能够拦截所有重复的订单,当重复插入时数据库会报一个重复错,程序直接返回。
  • 使用版本号(version)的机制来防重。

注意:以上三种方式都必须设置过期时间,当锁定某一资源超时的时候,能够释放资源让竞争重新开始。

7)使用缓存不合理,存在惊群效应、缓存穿透等情况

  • 缓存穿透

我们在项目中使用缓存通常先检查缓存中数据是否存在,如果存在则直接返回缓存内容,如果不存在就直接查询数据库,然后进行缓存并将查询结果返回。如果我们查询的某一数据在缓存中一直不存在,就会造成每一次请求都查询DB,这样缓存就失去了意义,在流量大时,可能DB就“挂掉”了,这就是缓存穿透,如图11-7所示。

图11-7

要是有黑客利用不存在的缓存key频繁攻击应用,就会对数据库造成非常大的压力,严重的话会影响线上业务的正常进行。一个比较巧妙的做法是,可以将这个不存在的key预先设定一个值,比如“key”“NULL”。在返回这个NULL值的时候,应用就可以认为这是不存在的key,应用就可以决定是继续等待访问,还是放弃掉这次操作。如果继续等待访问,则过一个时间轮询点后,再次请求这个key,如果取到的值不再是NULL,则可以认为这时候key有值了,从而避免透传到数据库,把大量的类似请求挡在了缓存之中。

  • 缓存并发

看完上面的缓存穿透方案后,可能会有读者提出疑问,如果第一次使用缓存或缓存中暂时没有需要的数据,那么又该如何处理呢?

在这种场景下,客户端从缓存中根据key读取数据,如果读到了数据则流程结束,如果没有读到数据(可能会有多个并发都没有读到数据),则使用缓存系统中的setNX方法设置一个值(这种方法类似加锁),没有设置成功的请求则“sleep”一段时间,设置成功的请求则读取数据库获取值,如果获取到则更新缓存,流程结束,之前sleep的请求唤醒后直接从缓存中读取数据,此时流程结束,如图11-8所示。

图11-8

这个流程里面有一个漏洞,如果数据库中没有我们需要的数据该怎么处理?如果不处理请求则会造成死循环,不断地在缓存和数据库中查询,这时就可以结合缓存穿透的思路,这样其他请求就可以根据“NULL”直接进行处理,直到后台系统在数据库成功插入数据后同步更新清理NULL数据和更新缓存。

  • 缓存过期导致惊群效应

我们在使用缓存组件的时候,经常会使用缓存过期这一功能,这样可以不定期地释放使用频率很低的缓存,节省出缓存空间。如果很多缓存设置的过期时间是一样的,就会导致在一段时间内同时生成大量的缓存,然后在另外一段时间内又有大量的缓存失效,大量请求就直接穿透到数据库中,导致后端数据库的压力陡增,这就是“缓存过期导致的惊群效应”!

比较合理的解决方案之一:为每个缓存的key设置的过期时间再加一个随机值,可以避免缓存同时失效。

  • 最终一致性

缓存的最终一致性是指当后端的程序在更新数据库数据完成之后,同步更新缓存失败,后续利用补偿机制对缓存进行更新,以达到最终缓存的数据与数据库的数据是一致的状态。

常用的方法有两种,分别是基于MQ和基于binlog的方式。

(1)基于MQ的缓存补偿方案。

这种方案是当缓存组件出现故障或网络出现抖动的时候,程序将MQ作为补偿的缓冲队列,通过重试的方式机制更新缓存,如图11-9所示。

图11-9

说明:

  • 应用同时更新数据库和缓存。
  • 如果数据库更新成功,则开始更新缓存;如果数据库更新失败,则整个更新过程失败。
  • 判断更新缓存是否成功,如果成功则返回。
  • 如果缓存没有更新成功,则将数据发到MQ中。
  • 应用监控MQ通道,收到消息后继续更新Redis。

问题点:

如果更新Redis失败,同时在将数据发到MQ之前应用重启了,那么MQ就没有需要更新的数据,如果Redis对所有数据没有设置过期时间,同时在读多写少的场景下,那么只能通过人工介入来更新缓存。

(2)基于binlog的方式来实现统一缓存更新方案。

第一种方案对于应用的研发人员来讲比较“重”,需要研发人员同时判断据库和Redis是否成功来做不同的考虑,而使用binlog更新缓存的方案能够减轻业务研发人员的工作量,并且也有利于形成统一的技术方案,如图11-10所示。

图11-10

说明:

  • 应用直接写数据到数据库中。
  • 数据库更新binlog日志。
  • 利用Canal中间件读取binlog日志。
  • Canal借助于限流组件按频率将数据发到MQ中。
  • 应用监控MQ通道,将MQ的数据更新到Redis缓存中。

可以看到这种方案对研发人员来说比较轻量,不用关心缓存层面,虽然这个方案实现起来比较复杂,但却容易形成统一的解决方案。

问题点:

这种方案的弊端是需要提前约定缓存的数据结构,如果使用者采用多种数据结构来存放数据,则方案无法做成通用的方式,同时极大地增加了方案的复杂度。

8)程序中打印了大量的无用日志,并且引起性能问题

先来看一段伪代码:

QuataDTO quataDTO = null;
try {
   quataDTO = getRiskLimit(payRequest.getQueryRiskInfo(),payRequest.getMerchantNo(), payRequest.getIndustryCatalog(),cardBinResDTO.getCardType(), cardBinResDTO.getBankCode(), bizName);
} catch (Exception e) {
    logger.info("获取风控限额异常", e);
}

通过上面的代码,发现了以下需要注意的点:

  • 日志的打印必须以logger.error或logger.warn的方式打印出来。
  • 日志打印格式:[系统来源] 错误描述 [关键信息],日志信息要打印出能看懂的信息,有前因和后果。甚至有些方法的入参和出参也要考虑打印出来。
  • 在输入错误信息的时候,Exception不要以e.getMessage的方式打印出来。

合理地日志打印,可以参考如下格式:

logger.warn("[innersys] - ["+ exceptionType.description + "] - [" + methodName + "] - "
                +"errorCode:[" + errorCode + "], "
                +"errorMsg:[" + errorMsg + "]", e);
 
logger.info("[innersys] - [入参] - [" +methodName + "] - "
                    + LogInfoEncryptUtil.getLogString(arguments)+ "]");
 
logger.info("[innersys] - [返回结果] - [" +methodName + "] - " + LogInfoEncryptUtil.getLogString(result));

在程序中大量地打印日志,虽然能够打印很多有用信息帮助我们排查问题,但日志量太多不仅影响磁盘I/O,还会造成线程阻塞,对程序的性能造成较大影响。在使用Log4j1.2.14设置ConversionPattern的时候,使用如下格式:

%d %-5p %c:%L [%t] - %m%n

在对项目进行压测的时候却发现了大量的锁等待,如图11-11所示。

图11-11

对Log4j进行源码分析,发现在org.apache.log4j.spi.LocationInfo类中有如下代码:

String s;
// Protect against multiple access to sw.
synchronized(sw) {
 t.printStackTrace(pw);
 s = sw.toString();
 sw.getBuffer().setLength(0);
}
//System.out.println("s is ["+s+"].");
int ibegin, iend;

可以看出在该方法中用了synchronized锁,然后又通过打印堆栈来获取行号,于是将ConversionPattern的格式修改为%d %-5p %c [%t] - %m%n后,线程大量阻塞的问题解决了,极大地提高了程序的并发能力。

9)关于索引的优化

  • 组合索引的原则是偏左原则,所以在使用的时候需要多加注意。
  • 不需要过多地添加索引的数量,在添加的时候要考虑聚集索引和辅助索引,两者的性能是有区别的。
  • 索引不会包含NULL值的列。

只要列中包含NULL值都不会被包含在索引中,复合索引中只要有一列含有NULL值,那么这一列对于此复合索引就是无效的。所以我们在设计数据库时不要让字段的默认值为NULL。

  • MySQL索引排序。

MySQL查询只使用一个索引,如果where子句中已经使用了索引,那么order by中的列是不会使用索引的。因此数据库默认排序可以在符合要求的情况下不使用排序操作;尽量不要包含多个列的排序,如果需要,最好给这些列创建复合索引。

  • 使用索引的注意事项。

以下操作符可以应用索引:

m 大于等于;

m Between;

m IN;

m LIKE 不以%开头。

以下操作符不能应用索引:

m NOT IN;

m LIKE %_开头。

4.2 从整体架构的角度看

1)采用单体集群的部署模式

当团队和项目发展到一定规模后,就需要根据业务和团队人数进行适当拆分。如果依然使用单体项目做整体部署,则项目之间互相影响极大,再加上团队人员达到一定规模后,没有办法进行项目的维护和升级。

2)采用单机房的部署方式

现在互联网项目对稳定性的要求越来越高,采用单机房部署的风险性也越来越高,像黑客恶意攻击、机房断电、网线损坏等不可预知的故障发生时,单机房是无法提供稳定性保障的,这就需要互联网企业开始建设同城双活、异步多活等确保机房的稳定性。

3)采用Nginx+Hessian的方式实现服务化

Hessian是一个轻量级的Remoting on HTTP框架,采用的是Binary RPC协议。因为其易用性等特点,直到现在依然有很多企业还在使用Hessian作为远程通信工具,但Hessian并不具备微服务的特点,只作为远程通信工具使用,而且Hessian多偏重于数据如何打包、传输与解包,所以很多时候需要借助Nginx来做服务路由、负载和重试等,而且还需要在Nginx中进行配置,也不能动态对服务进行加载和卸载,所以在业务越来越复杂,请求量越来越多的情况下,Hessian不太适合作为微服务的服务治理框架,这时就需要Spring Cloud或Dubbo了。

4)项目拆分不彻底,一个Tomcat共用多个应用(见图11-12)

图11-12

注:一个Tomcat中部署多个应用war包,彼此之间互相牵制,在并发量非常大的情况下性能降低非常明显,如图11-13所示。

图11-13

注:拆分前的这种情况其实还是挺普遍的,之前一直认为项目中不会存在这种情况,但事实上还是存在了。解决的方法很简单,每一个应用war只部署在一个Tomcat中,这样应用程序之间就不会存在资源和连接数的竞争情况,性能和并发能力提升较为明显。

5)无服务降级策略

举个例子来说明什么是服务降级,我们要出门旅游但只有一只箱子,我们想带的东西太多了把箱子都塞满了,结果发现还有很多东西没有放,于是只能把所有东西全部再拿出来做对比和分类,找到哪些是必须要带的,哪些是非必需的,最终箱子里面放满了必需品,为了防止这种情况再次发生,下次再旅游的时候就可以提前多准备几只箱子。其实服务降级也是类似的思路,在资源有限的情况下舍弃一些东西以保证更重要的事情能够进行下去。

服务降级的主要应用场景就是当微服务架构整体的负载超出了预设的上限阈值或即将到来的流量预计超过预设的阈值时,为了保证重要的服务能正常运行,将一些不重要、不紧急的服务延迟或暂停使用。

6)支付运营报表,大数据量查询

我们先来回顾一下微服务的数据去中心化核心要点:

  • 每个微服务有自己私有的数据库。
  • 每个微服务只能访问自己的数据库,而不能访问其他服务的数据库。
  • 某些业务场景下,请求除了要操作自己的数据库,还要对其他服务的数据库进行添加、删除和修改等操作。在这种情况下不建议直接访问其他服务的数据库,而是通过调用每个服务提供的接口完成操作。
  • 数据的去中心化进一步降低了微服务之间的耦合度。

通过上述核心要点可以看到,微服务中关于数据的描述是去中心化,也就是说要根据业务属性独立拆分数据库,使其业务领域与数据库的关系是一一对应的。我们还是以支付业务场景为例,单体支付项目进行微服务改造后,业务架构如图11-14所示。

图11-14

可以看到将单体支付项目进行微服务改造后增加了多个服务项目,我们可以把每个服务项目都理解为一个限界上下文,每个服务项目又对应一个数据库,这样数据库由原来适应单体支付系统的大库拆分成了多个独立的数据库。问题来了,对于后台运营统计来说这就是噩梦的开始,因为运营报表经常会跨业务进行统计和汇总,在原有运营系统上面做报表会给运营人员额外增加巨大的工作量,需要逐库进行统计,然后进行汇总。

凡事都有两面性,微服务给我们带来去中心化高度解耦的同时,也会带来报表数据及历史数据无法统一汇总和查询的问题,这时我们就需要从各个服务数据库中抽取数据到大数据平台做数据集中化,如图11-15所示。

图11-15

通常大数据平台也会和每个服务的读库配合使用,大数据平台存放的往往是大而全的数据。可以把大数据平台理解为一个数据仓库里面存放若干年的数据,研发人员可以根据数据量的大小及业务情况合理利用服务的读库,这样也可以减轻查询大数据平台的压力。比如用户要查询某个服务一周内的订单情况,则可以直接从读库中进行查询,这样既可以查询到最新的订单详细信息,也可以充分发挥读库的作用。如果用户要查询半年以上的数据,因为数据量大的原因历史数据早已经被迁移走,这时可以在大数据平台进行查询。

7)运维手动打包和上线

微服务架构的顺利实施还需要强有力的运维做支撑,这就相当于一辆宝马车表面看上去特别豪华,但里面装的却是老旧的发动机。这时就需要将DevOps在全公司推广,让自动化运维和部署成为微服务的“发动机”。

五、微服务架构中常见的一些故障分析技巧

1)开发者的自测利器——hprof命令

示例程序如下所示。

注:这是一段测试代码,通过sleep方法进行延时。

如何分析程序中哪块代码出现延时故障呢?

在程序中加上如下运行参数:

再次运行程序,发现在工程目录里面多了一个文本文件java.hprof.txt,打开文件,内容如下所示。

注:通过上面内容可以看到是哪个类的方法执行时间长,耗费了CPU时间,一目了然,方便我们快速定位问题。

hprof不是独立的监控工具,它只是一个Java Agent工具,它监控Java应用程序在运行时的CPU信息和堆内容,使用Java -agentlib:hprof=help命令可以查看hprof的使用文档。

上面的例子统计的是CPU时间,同样我们还可以统计内存占用的dump信息。例如:-agentlib:hprof=heap,format=b,file=/test.hprof。

我们在用JUnit自测代码的时候结合hprof,既可以解决业务上的bug,又能够在一定程度上解决可发现的性能问题,非常实用。

2)性能排查工具——pidstat

示例代码如下所示。

将示例代码运行起来后,在命令行中输入:

pidstat -p 843 1 3 -u -t/*-u:代表对CPU使用率的监控参数1 3代表每秒采样一次,一共三次-t:将监控级别细化到线程*/

结果如图11-16所示。

图11-16

注:其中TID就是线程ID,%usr表示用户线程使用率,从图中可以看到855这个线程的CPU占用率非常高。

再次在命令行中输入命令:

jstack -l 843 > /tmp/testlog.txt

查看testlog.txt,显示如下所示的内容。

注:我们关注的是日志文件的NID字段,它对应的就是上面说的TID,NID是TID的16进制表示,将上面的十进制855转换成十六进制为357,在日志中进行搜索看到如下内容。

以此可以推断出有性能瓶颈的问题点。

<完>

本文分享自微信公众号 - 服务端思维(gh_c3775931ac9d)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-05-28

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券