注册发现
业务场景:如何对几十个后台服务进行高效管理
依旧先来看一个实际的业务场景。在笔者团队负责过的某个系统中,已经拥有了50多个服务,并且很多服务之间都有调用关系,而这些服务是使用各种语言编写的,比如Java、Go、Node.js。目前流行的Spring Cloud、Dubbo这些微服务框架都是针对Java语言的,所以没有使用它们。那么,如何配置各个服务之间的调用关系呢?下面还原一下当时的配置过程。因为这50多个服务都有负载均衡,所以首先需要把服务的地址和负载均衡全部配置在Nginx上,类似这样:
而服务之间的调用关系主要通过本地配置文件配置,代码如下所示:
配置过程说明:先通过本地配置文件获取需调用服务的主机地址,再在代码中加上URI组装成URL,然后所有服务之间的调用都通过Nginx代理。调用关系的架构图如图8-1所示。
2 传统架构会出现的问题
配置烦琐
上线容易出错系统上线部署时,因为每次增服务、加机器、减机器时,Nginx都需要手工配置,而且每个环境都不一样,所以很容易出错。因此,服务器迁移或网络变动时,需要把这些配置重新梳理一遍,并进行多轮测试才能确保没问题。如果没有进行详细检查,某些节点的负载均衡出错了可能也不会被发现。系统架构是先有NetScalar,再由NetScalar负载均衡到4台Nginx,每台Nginx再负载均衡到后台服务,即后台服务→NetScalar→Nginx→后台服务注意,这个NetScalar是针对内网的,外网有自己的NetScalar。但是,人难免出错,比如user-servers配置了两个新的IP,然后在4台Nginx上都要修改这两个IP:
如果忘了修改其中一台,就不一定能发现,因为在测试的时候,NetScalar可能并没有把请求导向到那台错误的Nginx。这个疏忽会变成一个偶尔出现的错误,这种错误就更难被发现了。
加机器要重启
系统的流量增大后,通过监控发现有些服务需要增加机器,这个过程最能考验系统的抗压性,因为需要手工配置,稍不留神系统就会出错,比如多输入一个字符或没输对IP。而系统一旦出错,就需要重启Nginx。设想一下,如果你是运维人员,这时会选择重启吗?如果重启失败了,影响范围会很大。因此,需要在短时间内确保配置准确无误,而加机器又是一件很急的事情,不会留太多时间进行检查。时间紧,又不能出错,所以这个过程很难。
负载均衡
单点系统原来并不是先经过NetScalar再到Nginx,而是这样的:后台服务→Nginx→后台服务因为所有的服务都需要经过Nginx代理,所以Nginx很容易成为瓶颈。而如果Nginx配置出了问题,所有的服务就都不能用了,风险很大。但是,假如让每个服务拥有自己的Nginx,而不是所有后台服务共用一个Nginx,就会有很多的Nginx,维护起来更容易出错。
所以改成了上一小节提到的架构:后台服务→NetScalar→Nginx→后台服务NetScalar胜在性能稳定,毕竟是个商业产品,也有自己的灾备功能。
这样一来,Nginx单点的问题得到了解决,可是网络开销更大了
管理困难
在实际工作中,因为合规的要求,经常需要对全系统调用库进行升级,为了保证所有服务不被遗漏,就必须有一个后台服务清单。考虑到后台服务清单都是通过手工进行维护的,所以需要定期对其进行整理,这着实是个“苦力活”。为了解决这个问题,团队考虑过不少解决方案,分为3种。
1)每个服务自动将服务和IP注册到协调服务(比如ZooKeeper),然后这个协调服务将所有后台服务的清单及每种服务的服务器节点列表推送到所有的后台服务,后台服务会自己控制调用哪个服务的哪个节点,这就是Spring Cloud和Dubbo的做法
2)将所有的服务都部署到容器上,然后利用Kubernetes的Service与Pod的特性进行服务注册发现,如图8-2所示。具体操作如下。
①先在部署User服务的Pod上打上“User-App”标签,部署Order服务的Pod上打上“Order-App”标签。
②在Kubernetes上启动多个User的Pod和多个Order的Pod,然后启动两个Service(类似于Nginx的负载均衡),一个Service叫UserService,专门处理标签为“User-App”的Pod;另一个Service叫OrderService,专门处理标签为“Order-App”的Pod
③从Client发出的请求首先会到达OrderService,再自动负载均衡到某个Order服务的Pod。当Order的服务要调用User的服务时,它就会调用UserService,UserService会负载均衡到User其中的一个Pod。
3)每个服务会自动将服务和IP注册到协调服务(比如ZooKeeper),然后设计一个工具自动获取ZooKeeper中后台服务的机器列表,最终根据列表来自动更新Nginx的配置,更新完成后再重启。
目最终采用的是第一种解决方案。不用第二种解决方案的原因是那时团队对容器不熟悉,而且几年前,容器的生产环境还没有那么成熟,如果需要把所有的服务迁移到容器,代价跟风险都太大。
而不使用第三种解决方案的原因是,它并没有解决Nginx单点瓶颈、加机器后需要重启的问题。因此,最终的解决方案如图8-3所示。
通过图8-3可以发现,整个解决过程分为以下几个步骤。
1)每个后台服务自动把服务类型和IP注册到中心存储。
2)中心存储将服务列表推送到每个后台服务。
3)后台服务在本地做负载均衡,轮流访问同服务的不同节点。
3 新架构要点
针对以上所取解决方案,接下来看看都有哪些注意事项需要考虑。这里总结了4点。
.3.1 中心存储服务使用什么技术
通过上面的介绍可以发现,这个问题使用Redis就能解决,但还需要考虑以下两个需求。
1)服务变更的需求需要实时推送给所有后台服务。比如新增了一个服务器节点,服务器节点启动时会自动连接中央存储,当后台服务列表更新时,其他后台服务需要实时收到更新请求。
2)随时监听所有后台服务的状态,如果某个服务宕机了,及时通知其他服务。对于这两个需求,分布式协调服务这个中间件技术刚好能全部满足,所以最终使用分布式协调服务来存储服务器列表。
3.2 使用哪个分布式协调服务
关于使用哪个分布式协调服务的问题,有一个详细的对比表格
那么如何选择呢?其实,在实际技术选型过程中,不仅需要考虑技术本身,还需要考虑组织的背景。比如,笔者当时所在公司已经在使用ZooKeeper,对于运维团队而言,他们一般不会同时维护两种协调服务中间件,所以最终没有选择ZooKeeper以外的协调服务。
但是如果公司原来没有协调服务,则推荐使用Nacos:一是因为它还带有配置中心的功能,可以取代Spring Cloud Config,也就是一个中间件相当于两种中间件,性价比较高;二是因为在一致性里面它满足AP和CP,而其他的中间件只满足AP或者只满足CP(后面介绍ZooKeeper宕机的情况时会详细介绍CAP)。
3.3 基于ZooKeeper需要实现哪些功能
需要实现的几个主要功能如下。
1)服务启动的时候,将信息注册到ZooKeeper。
2)将所有的后台服务信息从ZooKeeper拉取下来。
3)监听ZooKeeper事件,如果后台服务信息出现变更,就更新本地列表。
4)调用其他服务时,如果需要实现一个负载均衡策略,一般用轮询(Ribbon)就可以了。总体来说,这些功能实现起来一点儿也不复杂。
4 ZooKeeper宕机了怎么办
因为后台服务都是多台部署的(比如某个节点宕机时,需要保证同服务的其他节点还可以正常工作),所以重点是保证ZooKeeper集群的高可用性(ZooKeeper本身就有集群的能力)。ZooKeeper本身为了一致性牺牲了高可用性,它同时兼作Leader、Follower和Observer这3种角色,如果Leader或半数的Follower宕机了,ZooKeeper就会进入漫长的恢复模式。而在这段时间里,ZooKeeper不接受客户端的任何请求。为什么ZooKeeper要这样设计呢?这里要解释一下CAP原则。
CAP原则是指在一个分布式系统中,Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容错性)三者不可得兼。
1)一致性(C):是指分布式系统中的所有数据副本在同一时刻是否一致,也就是说,访问所有数据副本的节点,是否都会返回一样的结果。
2)可用性(A):集群中一部分节点发生故障后,集群整体是否还能响应客户端的读写请求。
3)分区容错性(P):这个概念比较抽象,面试中也常问到。人们对此有不同的解释,下面的描述也只能说是一家之言。简单来说,假设集群有两个节点对外提供服务,理论上,这两个节点之间是可以互相通信的,因为要同步数据副本。但是,如果它们之间的网络出现问题,其数据就不能同步,从而出现不同的版本,这时怎么办?可以使用以下两种方案:①集群先停止服务,等其内部恢复,这个方案牺牲了A(可用性);②继续服务,让数据不一致,这个方案牺牲了C(一致性)。
回到ZooKeeper,它就是为了确保数据的一致性而牺牲了可用性。但是理论上来说,这个场景里面是可以牺牲一致性的,因为后台服务某一时刻可能彼此间有数万个请求,如果牺牲了可用性,就会导致这些请求都被拦截,而如果牺牲了一致性,最坏的情况就是几百个请求指向了错误的IP。
也就是说,在微服务间的协调这个场景里面,AP比CP更合适,所以Eureka比ZooKeeper更合适。而Nacos可以通过配置来选择AP或CP,这也是推荐Nacos的原因。不过没有什么问题是不能克服的。再回到ZooKeeper宕机的问题,此时容易出现以下3种情况。
1)假设后台服务此时已经在本地缓存了所有后台服务的清单,那么后续只需要保证这段时间新的后台服务器没有变更。
2)假设这段时间服务器刚好变更了,那就可能出现调用失败的情况。
3)假设后台服务在ZooKeeper恢复期间启动了,它便连不上ZooKeeper,也获取不到后台服务清单,这是最坏的情况。
遇到以上问题该怎么办呢?当时团队的做法是每次通过某个特定服务把所有服务清单同步一份到配置中心,新的后台服务获取不到服务清单时,再从配置中心获取。虽然没有完全解决问题,但这已经是一个性价比不错的方案了,而且到目前为止,运气还不错,微服务使用的ZooKeeper没出现过重新投票的问题(但是HBase使用的ZooKeeper出现过这个问题)。
5 小结
结合最终的方案回顾一下之前的旧架构问题。
1)配置烦琐,上线容易出错。实施新方案之后不再需要去每个Nginx配置后台服务的IP了,因为所有的后台服务会自动连接ZooKeeper,将IP注册上去。
2)加机器要重启。跟第一条一样,加了后台服务的机器后,新的机器自动将IP注册上去。ZooKeeper也不需要重启。
3)负载均衡单点。ZooKeeper本身就是一个集群,而且具有较好的高可用性。
4)管理困难。所有的后台服务类型和IP都可以从系统中直接查出,再也不需要手工整理了。所以原来的架构问题都解决了,而且因为少了两层(NetScalar和Nginx)的网络通信,性能也提高了。
其实这次的架构经历有点类似于自己造轮子,因为注册发现是Spring Cloud或Dubbo已经实现的功能。有时候新来的同事会问,为什么要自研?这时只要给他们看一下Go和Node.js的服务,他们就容易理解了。这个方案唯一的不足是重复造轮子这一点。有一些公司的做法是直接用Kubernetes的Service功能来解决,因为他们的运维人员对容器已经很熟悉了。
不过,重复造轮子的好处是能对微服务中服务注册发现的原理了解得更加透彻一些。本章就说到这里,接下来将讨论微服务架构中让人诟病的另一个问题—日志跟踪。
全链路日志
业务场景:
这个请求到底经历了什么当时公司的某一个业务线本来是基于自研的微服务架构,刚刚迁移到Spring Cloud。因为公司原来的微服务架构是基于ZooKeeper做注册发现的,为了复用原来的中间件,迁移到Spring Cloud后,服务的注册发现基于Spring Cloud ZooKeeper实现,不过组件方面只使用了Spring Cloud的服务间调用(Feign)。
迁移到微服务后,就得考虑日志跟踪的事情了。之前只是简单地把日志打印到本地文件上,然后使用ELK(ElasticSearch、Logstash和Kibana)进行日志收集和分析,因此日志记录比较随意,且没有形成一个统一的规范
。因为没有统一的规范,在做线上问卷调查的时候,难度非常大。比如有一次碰到了某一个用户总是登录失败的问题。服务调用链路是这样的:
UserAPI→AuthService→UserService
在UserAPI中还能找到登录信息,因为日志里面打印出了用户名,而后根据相应的时间点可以找到线程ID,那么这个时间点的这个线程ID下所有的日志就都属于要跟踪的活动了。但是要去AuthService查找这个请求的下一个服务的日志时就复杂了。因为同一时间点有多个服务器节点,每个节点有多个线程ID在活动,所以无法判断哪个服务器节点的哪个线程ID是用来处理UserAPI中在调查的那次请求的。
那怎么办?等没流量的时候运维人员又重试了几次,最终才定位到AuthSer vice中相应的日志。后来调查到的问题根源是,UserAPI调用AuthService的时候,有个参数因为含有特殊字符而被Tomcat自动摒弃了,导致AuthService收不到那个参数的值。
项目组商量后,决定把日志进一步规范化,于是总结了以下3点需求。
1)记录什么时候调用了缓存、MQ、ES等中间件,在哪个类的哪个方法中耗时多久。
2)记录什么时候调用了数据库,执行了什么SQL语句,耗时多久。
3)记录什么时候调用了另一个服务,服务名是什么,方法名是什么,耗时多久。一般来说,一个请求会跨多个服务节点,
针对这种情况又梳理了两条重要需求。
1)把同一个请求在全部服务中的以上所有记录进行串联,最终实现一个树状的记录。
2)设计一个基于这些基础数据的查询统计功能。
通过以上需求梳理并规范日志后,就可以在一个页面上看到每个请求的树状结构日志了,结果可参考下图
通过这样的设计,如果后期线上环境出现问题需要进一步调查,就有了更多的依据。需求确定后,就需要选择一款合适的开源技术进行方案实现,这就涉及技术选型过程。
2 技术选型在进行技术选型时
日志数据结构支持OpenTracing
平时日志行都是独立记录的,只能通过线程ID把它们关联起来。因此需要一个数据结构把每个请求在全部服务中的相关日志关联起来。目前已经有一种比较通用的全链路数据格式—OpenTracing,它的标准和API是由一个开源组织Cloud Native Computing Foundation(云原生计算基金会)进行维护的,这个开源组织也包含了一些全链路日志系统的维护者
OpenTracing通过提供一个与平台/厂商无关的API,使得开发人员能够更方便地添加(或更换)追踪系统,这样即使之前引入的全链路日志不好用,以后想换掉也是非常方便的。接下来解释一下OpenTracing标准,它主要包含两个概念:一个是Trace,一个是Span。先来看看下面的例子
到一个客户端调用Order API的请求时经历的整个流程(①~⑩),即一个Trace;Order API调用了Produc Service的整个过程(②~⑤),这就是一个Span。每个Span代表Trace中被命名且被计时的连续性执行片段。
Span中又包含了一个子Span,比如调用Product Service的过程中,Product Service会访问一次数据库(③④),这也是一个Span。因此可以得出,一个Span可以包含多个子Span,而Span与Span之间的关系就叫Reference。
在技术选型时,项目组都认可:必须保证系统的可替代性,尽量不要束缚于一项开源技术上。因为以前有过一次教训,当时强依赖了一个框架,结果那个框架不维护了,之后维护相关代码的人就非常痛苦,但是如果全部迁移,代价又太大且工作量也很大,付出与产出比不足以说服领导进行决策;如果不迁移,就只能一直用着过时的技术。所以这次选型使用了基于OpenTracing的日志系统。
2 支持Elasticsearch作为存储系统
诚然,因为流量大的原因,导致记录的日志数据量也很大,这就要求存储这些日志的系统必须支持海量数据且保证查询高效。最终,因为公司运维人员对Elasticsearch比较熟悉,所以提出可以使用Elasticsearch对日志进行存储。
3 保证日志的收集对性能无影响
当服务在记录日志时,需要确保日志的记录与收集对服务器的性能不会产生影响。比如之前调研过Pinpoint,当服务在记录日志时,Pinpoint的并发数达到一定数量时整体吞吐量少了一半,对服务器的性能影响很大,这是不能接受的。
4 查询统计功能的丰富程度
一般来说,查询统计功能越丰富越好,但必须首先满足一个基础功能:支持每个请求树状结构的全链路日志(如图9-4和图9-5所示),比如SkyWalking的功能就非常适用。查询统计系统除了满足基本功能以外,也要实现监控报警、指标统计等功能,以此帮助减轻二次开发的工作量。
项目组希望日志数据的收集过程对写业务代码的人保持透明,因此,一种比较理想的解决方案是使用Java的探针,通过字节码加强的方式进行埋点。
不过,这种方式对系统性能也会产生一定影响。而且在实际业务中,公司都会把访问数据库、Redis、MQ的代码进行封装,无法通过字节码加强的方式实现埋点,就只能尝试在封装的代码中实现,这样对开发业务代码的人来说同样透明。
5 使用案例
技术选型时,往往还需要了解哪些知名公司使用了这个技术,因为大公司的业务场景相对复杂些,经历的陷阱较多,一个技术如果被很多公司用过,那使用起来也就会稳定很多。以上就是技术选型的几个判断标准。
.6 最终选择
根据以上问题剖析及性能测试结果分析,可以发现SkyWalking比较符合需求。项目组做性能测试时发现,对于500线程压力以下的服务,是否使用SkyWalking对其吞吐量影响不大,一般相差不超过10%。SkyWalking官方测试报告中也提到:假如有500个并发用户,每个用户的每次请求间隔是10毫秒,TPS基本没什么变化
最后,根据笔者的实践经验,随着国内技术环境的改善和中国互联网的崛起,如今国产的很多开源框架并不比国外差,反而更贴近实际需求,比如VUE、Dubbo,这也是项目组选择SkyWalking的原因之一
3 注意事项
在使用SkyWalking之前,还需要考虑以下5个注意事项。
1 SkyWalking的数据收集机制
中间件在收集日志的时候,不可能是同步的。为什么呢?如果每次记录日志都要发一个请求到中间件,等中间件返回结果以后,才算日志记录完成,进入下一个动作,那么这个请求的响应时间肯定变慢。而且这种情况下,业务系统和日志系统是耦合的,业务系统要保证绝对高可用,而日志系统只是用来为研发人员调研问题提供方便的,对可用性的要求没有那么高。也不可能让高可用的系统依赖中可用的系统。
所以这个日志收集的过程必须是异步的,和业务流程解耦。
SkyWalking的数据收集机制是这样的:服务中有一个本地缓存,把收集的所有日志数据先存放在这个缓存中,然后后台线程通过异步的方式将缓存中的日志发送给SkyWalking服务端。这种机制使得在日志埋点的地方无须等待服务端接收数据,也就不影响系统性能。
2 如果SkyWalking服务端宕机了,会出现什么情况
如果服务端宕机了,理论上日志缓存中的数据会出现没人消费的情况,这样会不会导致数据越积越多,最终超出内存呢?
在SkyWalking中会设置缓存的大小,如果这部分数据超出了缓存大小,Trace不会保存,也就不会超出内存了。
流量较大时,如何控制日志的数据量
流量大时,不可能收集每个请求的日志,否则数据量会过大。那SkyWalking如何控制采样比例呢?SkyWalking会在每个服务器上配置采样比例,比如设置为100,代表1%的请求数据会被收集,代码如下所示。
这样就可以通过sampleRate来控制采样比例了。一般而言,流量越大,采样比例越小。不过,这里有两点需要特别注意。
1)一旦启用forceSampleErrorSegment,出现错误时就会收集所有的数据,此时sampleRate对出错的请求不再适用。
2)所有相关联服务的sampleRate最好保持一致,如果A调用B,然后A、B的采样比例不一样,就会出现一个Trace串不起来的情况。
日志的保存时间
一般来说,日志不需要永久保存,通常是保存3个月的数据,关于这一点大家结合公司的实际情况进行配置即可。
按照以前的设计方案,需要自己设计一个工具将数据进行定时清理,不过此时可以直接使用SkyWalking进行配置,代码如下所示。
集群配置:如何确保高可用先来看看SkyWalking官方文档给出的SkyWalking架构,如图9-7所示。在此架构中,需要关注SkyWalking的收集服务(Receiver)和聚合服务(Aggregator),它们支持集群模式。同时,在集群服务里,多个服务节点又需要一些协调服务来协调服务间的关系,它们支持Kubernetes、ZooKeeper、Consul、etcd、Nacos(开源的协调服务基本都支持)。
4 小结
在方案中使用SkyWalking后,对于问题排查帮助非常大。比如再碰到类似登录失败的问题时,根据关键字查到TraceID以后,基于TraceID调出所有的请求日志,到底发生了什么就会一目了然。
这个全链路日志系统上线以后,团队优化了很多慢请求,因为每个调用环节和耗时都列出来了,很容易就能找到瓶颈点并加以解决。基于这个系统,团队完成了多个可以汇报的亮点工作。
但是SkyWalking也存在不足,比如一开始使用的版本存在很多兼容性问题。不过现在它改善不少,只要使用最新版,基本上问题不大。
这次的架构经历不涉及太多的架构设计,主要是技术选型和一些注意事项。希望通过上面的讲解,帮助大家快速理解全链路日志,并针对技术选型问题快速做出决策。
熔断
1 业务场景:如何预防一个服务故障影响整个系统
在一个新零售架构系统中,有一个通用用户服务(很多页面都会使用),它包含两个接口。
1)第一个是用户状态接口,包含用户车辆所在位置。它在用户信息展示页面都会用到,比如客服系统中的用户信息页面。
2)第二个接口用于返回给用户一个可操作的权限列表,它包含一个通用权限,也包含用户定制权限,而且每次用户打开App时都会使用它。而这两个接口各自会碰到相应的问题,下面分别讨论。
1 第一个问题:请求慢用户
状态的接口、服务间的调用关系
在Basic Data Service(基础数据服务)中,接口/currentCarLocation需要调用第三方系统的数据,但第三方响应速度很慢,而且有时还会发生故障,导致响应时间更长,接口经常出现超时报错。
有一次,用户反馈App整体运行速度慢到无法接受的程度。运维人员通过后台监控查看了几个Thread Dump(线程转储),发现User API与Basic Data Service的线程请求数接近极限值,且所有的线程都在访问第三方接口(3rd Location API)。因为连接数满了,其他页面便不再受理User API的请求,最终导致App整体出现卡顿。
之前运维人员针对这个问题做过相关处理,考虑响应时间长,就把超时时间设置得很长,这样虽然超时报错少了,其他页面也保持正常,但是会导致客服后台查看用户信息的页面响应时间长。
2 第二个问题:流量洪峰缓存超时
户权限的接口、服务间的调用关系与上面类似,如图10-2所示。服务间的调用流程具体分为以下3个步骤。
1)APP访问User API。
2)User API访问Basic Data Service接口/commonAccesses。
3)Basic Data Service提供一个通用权限列表。因为权限列表对所有用户都一样,所以把它放在了Redis中,如果通用权限在Redis中找不到,再去数据库中查找。
接下来聊聊服务间的调用流程中笔者遇到过的一些问题。
有一次,因为历史代码的原因,在流量高峰时Redis中的通用权限列表超时了,那一瞬间所有的线程都需要去数据库中读取数据,导致数据库的CPU使用率升到了100%。
数据库崩溃后,紧接着Basic Data Service也停了,因为所有的线程都堵塞了,获取不到数据库连接,导致Basic Data Service无法接收新的请求。
而User API因调用Basic Data Service的线程而出现了堵塞,以至于User API服务的所有线程都出现堵塞,即User API也停止工作,使得App上的所有操作都不能使用,后果比较严重。
2 覆盖场景
为了解决以上两个问题,需要引入一种技术,这种技术还要满足以下两个条件。
1.线程隔离
首先针对第一个问题进行举例说明。假设User API中每个服务配置的最大连接数是1000,每次API调用Basic Data Service的/currentCarLocation时速度会很慢,所以调用/currentCarLocation的线程就会很慢,一直不释放。那么原因可能是,User API这个服务中的1000个连接线程全部都在调用/currentCarLocation这个服务。
因此,希望控制/currentCarLocation的调用请求数,保证不超过50条,以此保证至少还有950条连接可用于处理常规请求。如果/currentCarLocation的调用请求数超过50条,就设计一些备用逻辑进行处理,比如在页面上给用户提示。
熔断
针对第二个问题,因为此时数据库并没有死锁,流量洪峰缓存超时只是因为压力太大,所以可以使Basic Data Service暂缓服务、不接收新的请求,这样Redis的数据会被补上,数据库的连接也会降下来,服务也就没问题了。
总结一下,这个技术应能实现以下两点需求。
1)发现近期某个接口的请求经常出现异常时,先不访问接口的服务。
2)发现某个接口的请求总是超时时,先判断接口的服务是否不堪重负,如果是,就先别访问它。了解了这个技术需要满足的条件后,就可以有针对性地进行选型了。
3Sentinel和Hystrix
目前可以解决以上需求的比较流行的开源框架有两个:一个是Netflix开源的Hystrix,Spring Cloud默认使用这个组件;另一个是阿里开源的Sentinel。两者的对比见表。
这两个框架都能满足需求,但项目组最终使用了Hystrix,具体原因如下。
1)满足需求。
2)团队里有人用过Hystrix,并通读了它的源代码。
3)它是Spring Cloud默认自带的,项目组很多人都看过相关文档。
4 Hystrix的设计思路
下面从4个方面介绍一下Hystrix的设计思路。
1)线程隔离机制。
2)熔断机制。
3)滚动(滑动)时间窗口。
4)Hystrix调用接口的请求处理流程。
1 线程隔离机制
在Hystrix机制中,当前服务与其他接口存在强依赖关系,且每个依赖都有一个隔离的线程池。
如图10-3所示,当前服务调用接口A时,并发线程的最大个数是10,调用接口M时,并发线程的最大个数是5。
一般来说,当前服务依赖的一个接口响应慢时,正在运行的线程就会一直处于未释放状态,最终把所有的连接线程都卷入慢接口中。为此,在隔离线程的过程中,Hystrix的做法是每个依赖接口(也可以配置成几个接口共用)维护一个线程池,然后通过线程池的大小、排队数等隔离每个服务对依赖接口的调用,这样就不会出现前面的问题。
当然,在Hystrix机制中,除了使用线程池来隔离线程,还可以使用信号量(计数器)。
仍以调用接口A为例,因并发线程的最大个数是10,在信号量隔离的机制中,Hystix并不是使用size为10的线程池,而是使用一个信号量semaphoresA来隔离,每当调用接口A时即执行semaphoresA++,调用之后执行semaphoresA-,semaphoresA一旦超过10,就不再调用。
因为在使用线程池时经常需要切换线程,资源损耗较大,而信号量的优点恰巧就是切换快,所以正好能解决问题。不过它也有一个缺点,即接口一旦开始调用就无法中断,因为调用依赖的线程是当前请求的主线程,不像线程隔离那样调用依赖的是另外一个线程,当前请求的主线程可以根据超时时间把它中断。
至此,第一个问题就得到了解决,不会因为一个下游接口慢而将当前服务的所有连接数占满。
2 熔断机制
关于Hystrix熔断机制的设计思路,本小节将从以下几个方面来介绍。
1.在哪种条件下会触发熔断
熔断判断规则是某段时间内调用失败数超过特定的数量或比例时,就会触发熔断。
那这个数据是如何统计出来的呢?
在Hystrix机制中,会配置一个不断滚动的统计时间窗口metrics.rollingStats.timeInMilliseconds,在每个统计时间窗口中,若调用接口的总数量达到circuitBreakerRequestVolumeThreshold,且接口调用超时或异常的调用次数与总调用次数之比超过circuitBreakerErrorThresholdPercentage,就会触发熔断。
2.熔断了会怎么样
如果熔断被触发,在circuitBreakerSleepWindowInMilliseconds的时间内,便不再对外调用接口,而是直接调用本地的一个降级方法,代码如下所示。
3.熔断后怎么恢复
到达circuitBreakerSleepWindowInMilliseconds的时间后,Hystrix首先会放开对接口的限制(断路器状态为HALF-OPEN),然后尝试使用一个请求去调用接口,如果调用成功,则恢复正常(断路器状态为CLOSED),如果调用失败或出现超时等待,就需要重新等待circuitBreakerSleepWindowInMilliseconds的时间,之后再重试。
3 滚动(滑动)时间窗口
如把滚动事件的时间窗口设置为10秒,并不是说需要在1分10秒时统计一次,1分20秒时再统计一次,而是需要统计每一个10秒的时间窗口。
因此,还需要设置一个metrics.rollingStats.numBuckets。假设设置metrics.rollingStats.numBuckets为10,表示时间窗口划分为10小份,每份是1秒,然后就会在1分0秒~1分10秒统计一次、1分1秒~1分11秒统计一次、1分2秒~1分12秒统计一次……即每隔1秒都有一个时间窗口。
图10-4所示即为一个10秒时间窗口,它被分成了10个桶(Bucket)。
在每个桶中,Hystrix首先会统计调用请求的成功数、失败数、超时数和拒绝数,再单独统计每10个桶的数据(到了第11个桶时就是统计第2个桶~第11个桶的合计数据)。
4 Hystrix调用接口的请求处理流程
图10-5所示为一次调用成功的流程。
图10-6所示为一次调用失败的流程。
ystrix调用接口的请求处理流程结束后,就可以直接启用它了。在Spring Cloud中启用Hystrix的操作也比较简单,此处就不展开了。
另外,Hystrix还有requestcaching(请求缓存)和requestcollapsing(请求合并)这两个功能,因为它们与熔断关系不大,这里就不再讲解。
5 注意事项
明白Hystrix的设计思路后,使用它之前还需要考虑数据一致性、超时降级、用户体验、熔断监控等方面。
1 数据一致性
这里通过一个例子来帮助理解。假设服务A更新了数据库,在调用服务B时直接降级了,那么服务A的数据库更新是否需要回滚?
再举一个复杂点的例子,比如服务A调用了服务B,服务B调用了服务C,在服务A中成功更新了数据库并成功调用了服务B,而服务B调用服务C时降级了,直接调用了Fallback方法,此时就会出现两个问题:服务B向服务A返回成功还是失败?服务A的数据库更新是否需要回滚?
以上两个例子体现的就是数据一致性的问题。关于这个问题并没有一个固定的设计标准,只要结合具体需求进行设计即可。
2 超时降级
比如服务A调用服务B时,因为调用过程中B没有在设置的时间内返回结果,被判断超时了,所以服务A又调用了降级的方法,其实服务B在接收到服务A的请求后,已经在执行工作并且没有中断;等服务B处理成功后,还是会返回处理成功的结果给服务A,可是服务A已经使用了降级的方法,而服务B又已经把工作做完了,此时就会导致服务B中的数据出现异常。
3 用户体验
请求触发熔断后,一般会出现以下3种情况。
1)用户发出读数据的请求时遇到有些接口降级了,导致部分数据获取不到,就需要在界面上给用户一定的提示,或让用户发现不了这部分数据的缺失。
2)用户发出写数据的请求时,熔断触发降级后,有些写操作就会改为异步,后续处理对用户没有任何影响,但要根据实际情况判断是否需要给用户提供一定的提示。
3)用户发出写数据的请求时,熔断触发降级后,操作可能会因回滚而消除,此时必须提示用户重新操作。因此,服务调用触发了熔断降级时需要把这些情况都考虑到,以此来保证用户体验,而不是仅仅保证服务器不宕机。
因此,服务调用触发了熔断降级时需要把这些情况都考虑到,以此来保证用户体验,而不是仅仅保证服务器不宕机
4 熔断监控
熔断功能上线后,其实只是完成了熔断设计的第一步。因为Hystrix是一个事前配置的熔断框架,关于熔断配置对不对、效果好不好,只有实际使用后才知道。
为此,实际使用时,还需要从Hystrix的监控面板查看各个服务的熔断数据,然后根据实际情况再做调整,只有这样,才能将服务器的异常损失降到最低。
6 小结
引入Hystrix的项目方案一周就上线了,非常简单,下面两个问题很快就解决了。
1)下游接口慢导致当前服务所有连接池的线程被占满。
2)下游接口慢导致所有上游的接口雪崩。
之后系统就没有再出现相关的错误了。
但是Hystrix也有个不足。Hystrix的设计思想是事前配置熔断机制,也就是说,要事先预见流量是什么情况、系统负载能力如何,然后预先配置好熔断机制。但这种操作的缺点是,一旦实际流量或系统状况与预测的不一样,预先配置好的机制就达不到预期的效果。
所以这个项目上线以后,项目组又根据监控情况调整了几次参数。也因为这一点,开源Hystrix的公司Netflix想使用一个动态适应的更灵活的熔断机制。2018年后官方已不再为Hystrix开发新功能,转向开发Resilience4j了,对于Hystrix的原有功能只做简单维护。
再接着说熔断。目前的熔断框架已经设计得非常好了。对于使用熔断的人来说,虽然可以通过简单配置或代码编写实现应用,但是因为它是高并发中非常核心的一个技术,所以有必要理解清楚它的原理、机制及使用场景。本章主要讲解了熔断的基本原理,有兴趣的读者可以去钻研一下它的源代码。
限流
1 业务场景:如何保障服务器承受亿级流量
在某次秒杀活动中,总计有100个特价商品,且每个商品的价格都非常低,活动计划于当年10月10日晚上10点10分0秒开启。
当时,服务架构如图11-1所示,所有客户端的API请求先进入Nginx层,再由Nginx层转发至网关层(Java,使用Spring Cloud Zuul),最后转发至后台服务(Java)。
司预测到秒杀开始那一瞬间会有海量用户涌入,致使系统无法处理所有用户请求。为保障服务器承受住大流量,只能通过限流的方式将部分流量放入后台服务中。
那什么是限流呢?一说到限流,有些人会把它与熔断混在一起讨论,其实它们是有区别的。
熔断一般发生在服务调用方,比如服务A需要调用服务B,调用几次后发现服务B出现了问题且无法再调用,此时服务A必须立即触发熔断,在一段时间内不再调用服务B。
限流一般发生在服务被调用方,且主要在网关层做限流操作。比如一个电商网站的后台服务一秒内只能处理10万个请求,这时突然涌入了100万个请求,该怎么办?此时,可以把90%的请求全部抛弃且不做处理,然后重点处理其余10%的请求,以此保证至少10万人能正常操作(这个比例看起来有点夸张,但是在实际秒杀场景中,即使把99%的流量抛弃掉也不要紧)。
再回到这一章的业务场景中,这次项目的需求是在某个层级通过限流的方式将秒杀活动的交易TPS控制在100笔/秒(因为秒杀活动总计100个商品,也就是说最终的交易只有100笔,希望100笔交易在一秒内完成)。此时应该怎么做呢?这就需要用到限流的一些常用算法了。
2 限流算法
关于限流的算法分为固定时间窗口计数、滑动时间窗口计数、漏桶、令牌桶4种,下面分别进行说明。
固定时间窗口计数算法
假设需求是后台服务每5秒钟处理500个请求(以5秒为单位方便举例),那么每5秒钟就需要一个时间窗口来统计请求,
此时固定时间窗口算法看起来是可以满足需求的,不过它会存在一个问题。假设1~4秒有200个请求,5秒时有300个请求,6~9秒有499个请求,10秒时有1个请求,通过计算得知:1~5秒总计500个请求,6~10秒也是总计500个请求。
通过以上统计,流量并没有超出阈值。
但是如果计算一下5~9秒这个区间的请求数,会发现它已经达到了300+499=799个,也就是说5~9秒的请求数超标了299个,服务器明显支撑不住。
因此,固定时间窗口计数算法在现实中并不实用。
2滑动时间窗口计数算法
假设项目需求是后台服务在一秒内处理100个请求,滑动时间窗口计数算法就是每100毫秒设置一个时间区间,每个时间区间统计该区间内的请求数量,然后每10个时间区间合并计算请求总数,请求数超出最大数量时就把多余的请求数据抛弃,当时间节点进入下一个区间(比如第11个区间)时,便不再统计第1个区间的请求数量,而是将第2~11个区间的请求数量进行合并来计算出一个总数,并以此类推,如图11-2所示。
虽然滑动时间窗口计数算法并不能保证每秒的统计请求数都是精准的,但是可以大大减少单位时间内请求数超出阈值且检测不出来的概率。比如,请求都堆积在前100毫秒的尾端与后100毫秒的首端时,才可能出现请求数超出最大数量且不被发现的情况。
当然,可以将这个区间分得更细,比如设置10毫秒为一个区间。区间分得越细,计算数据就越精准,但是资源损耗也越多。
这个算法目前看来似乎已经能满足需求了。不过,场景是这样的:库存中只有100个商品,如果想把TPS控制在100笔/秒,将滑动时间窗口设置为1秒即可,即分成10个区间,每个区间100毫秒,此时就会出现在第一个100毫秒请求已经超出100个的情况,也就是说商品已经被秒光。
这时就有个问题,什么人能在100毫秒内完成点击购买、下单、提交订单的整个流程?可能只有机器人可以做到,也就是说秒杀商品基本是给机器人准备的,这并不是公司想要的结果。
3 漏桶算法
从图11-3中可以看到,漏桶算法的实现分为3个步骤。
1)任意请求进来后直接进入漏桶排队。
2)以特定的速度处理漏桶队列里面的请求。
3)超出漏桶负载范围的新请求直接抛弃掉,无法进入排队队列。
结合上一小节在一秒内控制100个请求的例子,可以把输出速度设置为100个/秒(即每10毫秒处理一次请求),再把桶的大小设置为100。因为漏桶算法是按先进先出的原则处理请求的,所以会出现最终被处理的请求还是前面那100个请求的情况,这就与滑动时间窗口计数算法遇到的问题一样了,最终商品都会被机器人买走。那如果把桶的大小设置为1,不就可以达到目的了吗?
4 令牌桶算法
令牌桶算法的实现思路如图11-4所示。
1)按照特定的速度产生令牌(Token)并存放在令牌桶中,如果令牌桶满了,新的令牌将不再产生。
2)新进来的请求如果需要处理,则需要消耗桶中的一个令牌。
3)如果桶中有令牌,直接消耗一个。
4)如果桶中没有令牌,进入一个队列中等待新的令牌。
5)如果等待令牌的队列满了,新请求就会直接被抛弃。
再结合上面在一秒内控制100个请求的例子,如果使用令牌桶算法,则需要先把令牌的产生速度设置为100个/秒,等待令牌的排队队列设为0,这样就能满足秒杀限流的需求了。
那令牌桶数量到底设置为多少呢?如果设置为100,假设令牌在秒杀前已经产生,那么秒杀开始时请求数已经是100了,前100个请求就会被放行,也就是说机器人又抢到了所有商品。
3 方案实现
理解限流的常见算法后,就可以进行方案实现了,需要考虑以下4个问题
1 使用令牌桶还是漏桶模式
刚提到令牌桶算法与漏桶算法都可以满足需求,但是做限流时,项目组希望这个算法不仅可以用于秒杀功能,还可以用于其他限流场景。
而使用漏桶算法存在一个缺陷:比如服务器空闲时,理论上服务器可以直接处理一次洪峰,但是漏桶的机制是请求的处理速度恒定,因此,前期服务器资源只能根据恒定的漏水速度逐步处理请求,无法用于其他限流场景。
如果使用令牌桶算法就不存在这个问题了,因为可以把令牌桶一下子装满。因此,针对这个项目,最终使用的是令牌桶。
在Nginx中实现限流还是在网关层中实现限流
在上述业务场景中,最终决定在网关层实现限流,原因有两点。
1)Nginx中有一个限流插件,它可以对单个用户的请求数做限制,不过它基于漏桶算法,而前面提过,这里希望使用令牌桶算法。
2)当时希望可以动态调整限流的相关配置,就是有一个界面,可以直接管理Nginx的配置。一般这种做法是通过Nginx+Lua实现的,但是因为团队对Lua不熟悉,所以配置人员无法直接操作Nginx中的数据。而团队对Java是很熟悉的。
基于以上两个原因,项目组最终选择在Java的网关层做限流。
3 使用分布式限流还是统一限流
网关层也是有负载均衡的,多个网关服务器节点可以共享一个令牌桶(统一限流),也可以每个节点有自己的令牌桶(分布式限流)。
如果使用分布式限流的方式,就需要提前计算服务器的数量,然后把100的TPS平分到各个服务器上进行一层换算。
如果使用统一限流的方式,可以把令牌桶的数据存放在Redis中,即每次请求都需要访问Redis,因秒杀开始时下单的请求数往往很大,Redis未必能承受住如此大的QPS。
所以统一限流有一个风险,就是一旦Redis崩溃,限流就会失效,那后台的服务器就会被拖垮。如果是分布式限流,假设有些节点失效了,那么其他节点还是可以正常工作的,这样导致的问题有两个。
1)部分网关层的负载增加。不管是统一限流还是分布式限流其实都有这个风险,因为在统一限流中网关服务器也可能崩溃。
2)后台处理100个请求的时间拉长。比如有10个网关,每个网关每秒通过10个请求,这样1秒内就有100个请求到后台服务器。假设其中5台失效,那么每秒只能通过50个请求,2秒才能放行100个请求。
不过这对当前的业务来说影响不大。通过对以上问题的衡量,项目组最终决定使用分布式限流方式。
4 使用哪个开源技术项目组
最终使用开源库Google-Guava中RateLimiter的相关类来实现限流,它是基于令牌桶算法的实现库。
这个库在限流场景中还是比较常用的。使用Google-Guava时,先定义一个Zuul的过滤器(filter),再使用Guava的RateLimiter对提交订单的API请求进行过滤。
在使用RateLimiter的过程中,需要配置以下3项。
1)permitsPerSecond:每秒允许的请求数。
2)warmupPeriod:令牌桶多久满。
3)tryAcquire的超时时间:当令牌桶为空时,可以等待新的令牌多久。
分别配置如下。
1)permitsPerSecond设置为100/10=10,100代表想达到的TPS,10代表网关节点为10台,说明每秒可以产生10个令牌。
2)warmupPeriod设置为100毫秒,代表从开始到令牌桶塞满需要100毫秒,即令牌桶的大小是1,如果有10台网关服务器,那么总令牌桶的大小就是10(前面提到过,为防止抢到物品的都是机器人,需要把令牌桶设置为10)。
3)tryAcquire的超时时间设置为0,即拿不到令牌的请求直接抛弃,无须等待。
4 限流方案的注意事项
在做限流方案时,项目组也遇到过不少的陷阱,下面会把相关的注意事项罗列一下。
1 限流返回给客户端的错误代码
为了给用户带来好的体验,用户界面上尽量不要出现错误,因此限流后被抛弃的请求应该返回一个特制的HTTPCODE,供客户端进行特殊处理。
而客户端拿到这个错误代码时,就可以展示专门的信息给用户,比如:很遗憾,商品已经秒光,您可以关注下次的秒杀活动。这是第一次秒杀活动的信息。
针对第二次秒杀活动,项目组又增加了如下提示:您可以在10分钟后过来,有些秒杀成功但是没有在10分钟内付款的用户,他们锁定的商品会被释放出来。
2 实时监控
在实际工作中,最好对限流日志随时做好记录并实时统计,这样有助于实时监控限流情况,一旦出现意外,可以及时处理。
3 实时配置
因为限流功能还需要应用到秒杀以外的场景,所以最好在配置中心就可以实现对令牌桶的动态管理+实时设置,这样也方便管理其他的限流场景。
4 秒杀以外的场景
限流配置在这次秒杀活动中,可以简单换算出需要控制数值为100的TPS,而在平时的限流场景中,TPS或QPS(其他场景可能不使用TPS)需要根据实际的压力测试结果来计算,从而进行限流的正确配置。