在美团的价值观中,以“客户为中心”被放在一个非常重要的位置,所以我们对服务出现故障越来越不能容忍。特别是目前公司业务正在高速增长阶段,每一次故障对公司来说都是一笔非常不小的损失。而整个IT基础设施非常复杂,包括网络、服务器、操作系统以及应用层面都可能出现问题。在这种背景下,我们必须对服务进行一次全方位的“体检”,从而来保障美团多个业务服务的稳定性,提供优质的用户服务体验。真正通过以下技术手段,来帮助大家吃的更好,生活更好:
全链路压测是基于线上真实环境和实际业务场景,通过模拟海量的用户请求,来对整个系统进行压力测试。早期,我们在没有全链路压测的情况下,主要的压测方式有:
但以上方式很难安全的对整个服务集群进行压测,如果以局部结果推算整个集群的健康状况,往往会“以偏概全”,无法评估整个系统的真实性能水平,主要的原因包括:
综合多种因素考虑,全链路压测是我们准确评估整个系统性能水平的必经之路。截止目前,全链路压测已在外卖、猫眼、酒旅、金融等10多个核心业务线全面投入使用,月平均压测次数达上万次,帮助业务平稳得度过了大大小小若干场高峰流量的冲击。
Quake (雷神之锤)作为公司级的全链路压测平台,它的目标是提供对链路进行安全、真实、便捷的压测,来帮助业务做出更精准的容量评估。因此我们对 Quake 提出了如下的要求:
Quake 集数据构造、压测隔离、场景管理、动态调控、过程监控、压测报告为一体,压测流量尽量模拟真实,具备分布式压测能力的全链路压测系统,通过模拟海量用户真实的业务操作场景,提前对业务进行高压力测试,全方位探测业务应用的性能瓶颈,确保平稳地应对业务峰值。
架构图
Quake 整体架构上分为:
传统的数据构造,一般由测试人员自己维护一批压测数据。但这种方式存在很大的弊端,一方面维护成本相对较高,另一方面,其构造出的数据多样性也不足够。在真实业务场景中,我们需要的是能直接回放业务高峰期产生的流量,只有面对这样的流量冲击,才能真实的反映系统可能会产生的问题。
Quake 主要提供了 HTTP 和 RPC 的两种数据构造方式:
对于 HTTP 服务,在 Nginx 层都会产生响应的访问日志,我们要做的是对这些日志进行统一接入,变成符合压测需要的流量数据。架构图如下:
底层使用了 Hive 作为数仓的工具,使业务在平台上可以通过简单的类 SQL 语言进行数据构造。Quake 会从数仓中筛选出相应的数据,作为压测所需的词表文件。
对于 RPC 服务,服务调用量远超 HTTP 的量级,所以在线上环境不太可能去记录相应的日志。这里我们使用对线上服务进行实时流量录制,结合 RPC 框架提供的录制功能,对集群中的某几台机器开启录制,根据要录制的接口和方法名,将请求数据上报到录制流量的缓冲服务(Broker)中,再由 Broker 生成最终的压测词表,上传到存储平台(S3)。
有些场景下,构造出来的流量是不能直接使用的,我们需要对用户 ID、手机号等信息进行数据偏移。Quake 也是提供了包含四则运算、区间限定、随机数、时间类型等多种替换规则。
数据构造产生的词表文件,我们需要进行物理上的分片,以保证每个分片文件大小尽可能均匀,并且控制在一定大小之内。这么做的主要原因是,后续压测肯定是由一个分布式的压测集群进行流量的打入,考虑到单机拉取拉取词表的速度和加载词表的大小限制,如果将词表进行分片的话,可以有助于任务调度更合理的进行分配。
做线上压测与线下压测最大不同在于,线上压测要保证压测行为安全且可控,不会影响用户的正常使用,并且不会对线上环境造成任何的数据污染。要做到这一点,首要解决的是压测流量的识别与透传机制。有了压测标识后,各服务与中间件就可以依据标识来进行压测服务分组与影子表方案的实施。
对于单服务来说,识别压测流量很容易,只要在请求头中加个特殊的压测标识即可,HTTP 和 RPC 服务是一样的。但是,要在整条完整的调用链路中要始终保持压测标识,这件事就非常困难。
对于涉及多线程调用的服务来说,要保证测试标识在跨线程的情况下不丢失。这里以 Java 应用为例,主线程根据压测请求,将测试标识写入当前线程的 ThreadLocal 对象中(ThreadLocal 会为每个线程创建一个副本,用来保存线程自身的副本变量),利用 InheritableThreadLocal 的特性,对于父线程 ThreadLocal 中的变量会传递给子线程,保证了压测标识的传递。而对于采用线程池的情况,同样对线程池进行了封装,在往线程池中添加线程任务时,额外保存了 ThreadLocal 中的变量,执行任务时再进行替换 ThreadLocal 中的变量。
对于跨服务的调用,架构团队对所有涉及到的中间件进行了一一改造。利用 Mtrace (公司内部统一的分布式会话跟踪系统)的服务间传递上下文特性,在原有传输上下文的基础上,添加了测试标识的属性,以保证传输中始终带着测试标识。下图是 Mtrace 上下游调用的关系图:
由于链路关系的复杂性,一次压测涉及的链路可能非常复杂。很多时候,我们很难确认间接依赖的服务又依赖了哪些服务,而任何一个环节只要出现问题,比如某个中间件版本不达标,测试标识就不会再往下进行透传。Quake 提供了链路匹配分析的能力,通过平台试探性地发送业务实际需要压测的请求,根据 Mtrace提供的数据,帮助业务快速定位到标记透传失败的服务节点。
一些大型的压测通常选择在深夜低峰时期进行,建议相关的人员要时刻关注各自负责的系统指标,以免影响线上的正常使用。而对于一些日常化的压测,Quake 提供了更加安全便捷的方式进行。在低峰期,机器基本都是处于比较空闲的状态。我们将根据业务的需求在线上对整条链路快速创建一个压测分组,隔出一批空闲的机器用于压测。将正常流量与测试流量在机器级别进行隔离,从而降低压测对服务带来的影响。
依赖标识透传的机制,在 Quake 平台上提供了基于 IP、机器数、百分比不同方式的隔离策略,业务只需提供所需隔离的服务名,由 Quake 进行一键化的开启与关闭。
还有一个比较棘手的问题是针对写请求的压测,因为它会向真实的数据库中写入大量的脏数据。我们借鉴了阿里最早提出的“影子表”隔离的方案。“影子表”的核心思想是,使用线上同一个数据库,包括共享数据库中的内存资源,因为这样才能更接近真实场景,只是在写入数据时会写在了另一张“影子表”中。
对于 KV 存储,也是类似的思路。这里讲一下 MQ(消息队列)的实现,MQ 包括生产和消费两端,业务可以根据实际的需要选择在生产端忽略带测试标识的消息,或者在消费端接收消息后再忽略两种选择。
调度中心作为整个压测系统的大脑,它管理了所有的压测任务和压测引擎。基于自身的调度算法,调度中心将每个压测任务拆分成若干个可在单台压测引擎上执行的计划,并将计划以指令的方式下发给不同的引擎,从而执行压测任务。
不同的压测场景,需要的机器资源不一样。以 HTTP 服务为例,在请求/响应体都在 1K 以内,响应时间在 50ms 以内和 1s 左右的两个请求,单个施压机能达到的极限值完全不同。影响压测能力的因素有很多,计算中心会依据压测模型的不同参数,进行资源的计算。
主要参考的数据包括:
因为整个压测过程一直处在动态变化之中,业务会根据系统的实际情况对压力进行相应的调整。在整个过程中产生的事件类型比较多,包括调整 QPS 的事件、触发熔断的事件、开启事故注入、开启代码级性能分析的事件等等,同时触发事件的情况也有很多种,包括用户手动触发、由于系统保护机制触等等。所以,我们在架构上也做了相应的优化,其大致架构如下:
在代码设计层面,我们采用了观察者和责任链模式,将会触发事件的具体情况作为观察主题,主题的订阅者会视情况类型产生一连串执行事件。而在执行事件中又引入责任链模式,将各自的处理逻辑进行有效的拆分,以便后期进行维护和能力扩充。
调度中心管理了所有的施压机资源,这些施压机分布在北京、上海的多个机房,施压机采用容器化方式进行部署,为后续的动态扩容、施压机灰度升级以及异常摘除的提供了基础保障。
业务对压测的需求有高低峰之分,所以平台也需要事先部署一部分机器用于日常的业务压测。当业务申请资源不足时,平台会按需通过容器化方式动态的进行扩容。这样做的好处,一方面是节省机器资源,另一方面就是便于升级。不难想象,升级50台机器相对升级200台机器,前者付出的代价肯定更小一些。
整个机器池维护着几百台机器,如果需要对这些机器进行升级操作,难度系数也比较高。我们以前的做法是,在没有业务压测的时候,将机器全部下线,然后再批量部署,整个升级过程既耗时又痛苦。为此,我们引入了灰度升级的概念,对每台施压机提供了版本的概念,机器选择时,优先使用稳定版的机器。根据机器目前使用的状态,分批替换未使用的机器,待新版本的机器跑完基准和回归测试后,将机器选择的策略改为最新版。通过这种方式,我们可以让整个升级过程,相对平顺、稳定,且能够让业务无感知。
调度中心维持了与所有施压机的心跳检测,对于异常节点提供了摘除替换的能力。机器摘除能力在压测过程中非常有必要,因为压测期间,我们需要保证所有的机器行为可控。不然在需要降低压力或停止压测时,如果施压机不能正常做出响应,其导致的后果将会非常严重。
在压测引擎的选择上,Quake 选择了自研压测引擎。这也是出于扩展性和性能层面的考虑,特别在扩展性层面,主要是对各种协议的支持,这里不展开进行阐述。性能方面,为了保证引擎每秒能产生足够多的请求,我们对引擎做了很多性能优化的工作。
通常的压测引擎,采用的是 BIO 的方式,利用多线程来模拟并发的用户数,每个线程的工作方式是:请求-等待-响应。
通信图:
这种方式主要的问题是,中间的等待过程,线程资源完全被浪费。这种组合模式下,性能问题也会更严重(组合模式:即模拟用户一连串的用户行为,以下单为例,请求组中会包含用户登录、加入购物车、创建订单、支付订单、查看支付状态。这些请求彼此间是存在先后关系的,下一个请求会依赖于上一个请求的结果。),若请求组中有5个串联请求,每个请求的时长是200ms,那完成一组请求就需要 1s 。这样的话,单机的最大 QPS 就是能创建的最大线程数。我们知道机器能创建的线程数有限,同时线程间频繁切换也有成本开销,致使这种通信方式能达到的单机最大 QPS 也很有限。
这种模型第二个问题是,线程数控制的粒度太粗,如果请求响应很快,仅几十毫秒,如果增加一个线程,可能 QPS 就上涨了将近100,通过增加线程数的方式无法精准的控制 QPS,这对探测系统的极限来说,十分危险。
我们先看下 NIO 的实现机制,从客户端发起请求的角度看,存在的 IO 事件分别是建立连接就绪事件(OP_CONNECT)、IO 就绪的可读事件 (OP_READ) 和 IO 就绪的可写事件(OP_WRITE),所有 IO 事件会向事件选择器(Selector)进行注册,并由它进行统一的监听和处理,Selector 这里采用的是 IO 多路复用的方式。
在了解 NIO 的处理机制后,我们再考虑看如何进行优化。整个核心思想就是根据预设的 QPS,保证每秒发出指定数量的请求,再以 IO 非阻塞的方式进行后续的读写操作,取消了 BIO 中请求等待的时间。优化后的逻辑如下:
这里主要耗时都在 IO 的读写事件上,为了达到单位时间内尽可能多的发起压测请求,我们将连接事件与读写事件分离。连接事件采用单线程 Selector 的方式来处理,读写事件分别由多个 Woker 线程处理,每个 Woker 线程也是以 NIO 方式进行处理,由各自的 Selector 处理 IO 事件的读写操作。这里每个 Worker 线程都有自己的事件队列,数据彼此隔离,这样做主要是为了避免数据同步带来的性能开销。
这里说的业务逻辑主要是针对请求结果的处理,包括对请求数据的采样上报,对压测结果的解析校验,对请求转换率的匹配等。如果将这些逻辑放在 Worker 线程中处理,必然会影响 IO 读取的速度。因为 Selector 在监听到 IO 就绪事件后,会进行单线程处理,所以它的处理要尽可能的简单和快速,不然会影响其他就绪事件的处理,甚至造成队列积压和内存问题。
压测引擎另一个重要的指标是 Full GC 的时间,因为如果引擎频繁出现 Full GC,那会造成实际压测曲线(QPS)的抖动,这种抖动会放大被压服务真实的响应时间,造成真实 QPS 在预设值的上下波动。严重的情况,如果是长时间出现 Full GC,直接就导致预压的 QPS 压不上去的问题。
下面看一组 Full GC 产生的压测曲线:
为了解决 GC 的问题,主要从应用自身的内存管理和 JVM 参数两个维度来进行优化。
引擎首先加载词表数据到内存中,然后根据词表数据生成请求对象进行发送。对于词表数据的加载,需要设置一个大小上限,这些数据是会进行“老年代”,如果“老年代”占用的比例过高,那就会频发出现 Full GC 的情况。这里对于词表数据过大的情况,可以考虑采用流式加载的方式,在队列中维持一定数量的请求,通过边回放边加载的方式来控制内存大小。
引擎在实际压测过程中,如果单机是 1W 的 QPS,那它每秒就会创建 1W 个请求对象,这些对象可能在下一秒处理完后就会进行销毁。如果销毁过慢,就会造成大量无效对象晋升老年代,所以在对响应结果的处理中,不要有耗时的操作,保证请求对象的快速释放。
这里放弃对象复用的原因是,请求的基本信息占用的内存空间比较小。可一旦转换成了待发送对象后,占用的内存空间会比原始数据大很多,在 HTTP 和 RPC 服务中都存在同样的问题。而且之前使用 Apache HttpAsyncClient 作为 HTTP 请求的异步框架时,发现实际请求的 Response 对象挂在请求对象身上。也就是说一个请求对象在接收到结果后,该对象内存上增加响应结果的大小空间,如果采用复用请求对象的方式,很容易造成内存泄露的问题。
这里以 JVM 的 CMS 收集器为例,对于高并发的场景,瞬间产生大量的对象,这些对象的存活时间又非常短,我们需要:
压测肯定会对线上服务产生一定的影响,特别是一些探测系统极限的压测,我们需要具备秒级监控的能力,以及可靠的熔断降级机制。
压测引擎会将每秒的数据汇总后上报给监控模块,监控模块基于所有上报来的数据进行统计分析。这里的分析需要实时进行处理,这样才能做到客户端的秒级监控。监控的数据包括各 TP 线的响应情况、QPS 曲线波动、错误率情况以及采样日志分析等等。
除了通过引擎上报的压测结果来进行相应的监控分析之外,Quake 还集成了公司内部非常成熟的监控组件,有监控机器指标的 Falcon 系统(小米开源),还有监控服务性能的 CAT系统(美团已经开源)。Quake 提供了统一的管理配置服务,让业务能在 Quake 上方便观察整个系统的健康状况。
Quake 提供了客户端和服务端两方面的熔断保护措施。
首先是客户端熔断,根据业务自定义的熔断阙值,Quake 会实时分析监控数据,当达到熔断阙值时,任务调度器会向压测引擎发送降低 QPS 或者直接中断压测的指令,防止系统被压挂。
被压服务同样也提供了熔断机制,Quake 集成了公司内部的熔断组件(Rhino),提供了压测过程中的熔断降级和限流能力。与此同时,Quake 还提供了压测故障演练的能力,在压测过程中进行人为的故障注入,来验证整个系统的降级预案。
最后,总结一下做 Quake 这个项目的一些心得:
其实在 Quake 出来之前,美团公司内部已有一个压测平台(Ptest ),它的定位是针对单服务的性能压测。我们分析了 Ptest 平台存在的一些问题,其压测引擎能力也非常有限。在美团发展早期,如果有两个大业务线要进行压测的话,机器资源往往会不足,这需要业务方彼此协调。因为准备一次压测,前期投入成本太高,用户需要自己构造词表,尤其是 RPC 服务,用户还需要自己上传 IDL 文件等等,非常繁琐。
Quake 针对业务的这些痛点,整个团队大概花费一个多月的时间开发出了第一个版本,并且快速实现了上线。当时,正面临猫眼十一节前的一次压测,那也是 Quake 的第一次亮相,而且取得了不错的成绩。后续,我们基本平均两周实现一次迭代,然后逐步加入了机器隔离、影子表隔离、数据偏移规则、熔断保护机制、代码级别的性能分析等功能。
项目刚线上时,客服面临问题非常多,不仅有使用层面的问题,系统自身也存在一些 Bug 缺陷。当时,一旦遇到业务线大规模的压测,我们团队都是全员待命,直接在现场解决问题。后续系统稳定后,我们组内实施了客服轮流值,每个迭代由一位同学专门负责客服工作,保障当业务遇到的问题能够做到快速响应。尤其是在项目上线初期,这点非常有必要。如果业务部门使用体验欠佳,项目口碑也会变差,就会对后续的推广造成很大的问题。
这应该是所有内部项目都会遇到的问题,很多时候,推广成果决定项目的生死。前期我们先在一些比较有代表性的业务线进行试点。如果在试点过程中遇到的问题,或者业务同学提供的一些好的想法和建议,我们能够快速地进行迭代与落地。然后再不断地扩大试点范围,包括美团外卖、猫眼、酒旅、金融等几个大的 BG 都在 Quake 上进行了几轮全流程、大规模的全链路压测。
随着Quake 整体功能趋于完善,同时解决了 Ptest(先前的压测系统)上的多个痛点,我们逐步在各个业务线进行了全面推广和内部培训。从目前收集的数据看,美团超过 90% 的业务已从 Ptest 迁移到了 Quake 。而且整体的统计数据,也比 Ptest 有了明显的提升。
Quake 目标是打造全链路的压测平台,但是在平台建设这件事上,我们并没有刻意去追求。公司内部也有部分团队走的比较靠前,他们也做一些很多“试水性”的工作。这其实也是一件好事,如果所有事情都依托平台来完成,就会面临做不完的需求,而且很多事情放在平台层面,也可能无解。
同时,Quake 也提供了很多 API 供其他平台进行接入,一些业务高度定制化的工作,就由业务平台独自去完成。平台仅提供基础的能力和数据支持,我们团队把核心精力聚焦在对平台发展更有价值的事情上。
其实,全链路压测整个项目涉及的团队非常之多,上述提到的很多组件都需要架构团队的支持。在跨团队的合作层面,我们应该有“双赢”的心态。像 Quake 平台使用的很多监控组件、熔断组件以及性能分析工具,有一些也是兄弟团队刚线上没多久的产品。 Quake 将其集成到平台中,一方面是减少自身重复造轮子;另一方面也可以帮助兄弟团队推动产品的研发工作。
耿杰,美团点评高级工程师。2017年加入美团点评,先后负责全链路压测项目和 MagicDB 数据库代理项目,目前主要负责这两个项目的整体研发和推广工作,致力于提升公司的整体研发效率与研发质量。
团队长期招聘 Java、Go、算法、AI 等技术方向的工程师,Base 北京、上海,欢迎有兴趣的同学投递简历到[email protected]。
发现文章有错误、对内容有疑问,都可以关注美团技术团队微信公众号(meituantech),在后台给我们留言。我们每周会挑选出一位热心小伙伴,送上一份精美的小礼品。快来扫码关注我们吧!