《美团:2022年美团技术年货——后端系列(575页).pdf》由会员分享,可在线阅读,更多相关《美团:2022年美团技术年货——后端系列(575页).pdf(575页珍藏版)》请在三个皮匠报告上搜索。
1、后端1可视化全链路日志追踪1设计模式二三事26基于代价的慢查询优化建议49Java 系列|远程热部署在美团的落地实践71日志导致线程 Block 的这些坑,你不得不防92基于 AI 算法的数据库异常监测系统的设计与实现154Replication(上):常见复制模型&分布式系统挑战171Replication(下):事务,一致性与共识197TensorFlow 在美团外卖推荐场景的 GPU 训练优化实践234CompletableFuture 原理与实践-外卖商家端 API 的异步化258工程效能 CI/CD 之流水线引擎的建设实践291美团外卖搜索基于 Elasticsearch 的优化实践
2、312美团图灵机器学习平台性能起飞的秘密(一)332提升资源利用率与保障服务质量,鱼与熊掌不可兼得?350标准化思想及组装式架构在后端 BFF 中的实践371外卖广告大规模深度学习模型工程实践|美团外卖广告工程实践专题连载392数据库全量 SQL 分析与审计系统性能优化之旅427目录数据库异常智能分析与诊断438美团外卖广告智能算力的探索与实践(二)458Linux 下跨语言调用 C+实践480GPU 在外卖场景精排模型预估中的应用实践509美团集群调度系统的云原生实践528广告平台化的探索与实践|美团外卖广告工程实践专题连载540可视化全链路日志追踪作者:海友怀宇亚平立森1.背景1.1业务系
3、统日益复杂随着互联网产品的快速发展,不断变化的商业环境和用户诉求带来了纷繁复杂的业务需求。业务系统需要支撑的业务场景越来越广、涵盖的业务逻辑越来越多,系统的复杂度也跟着快速提升。与此同时,由于微服务架构的演进,业务逻辑的实现往往需要依赖多个服务间的共同协作。总而言之,业务系统的日益复杂已经成为一种常态。1.2业务追踪面临挑战业务系统往往面临着多样的日常客诉和突发问题,“业务追踪”就成为了关键的应对手段。业务追踪可以看做一次业务执行的现场还原过程,通过执行中的各种记录还原出原始现场,可用于业务逻辑执行情况的分析和问题的定位,是整个系统建设中重要的一环。目前在分布式场景下,业务追踪的主流实现方式包
4、括两类,一类是基于日志的 ELK方案,一类是基于单次请求调用的会话跟踪方案。然而随着业务逻辑的日益复杂,上述方案越来越不适用于当下的业务系统。1.2.1传统的 ELK 方案日志作为业务系统的必备能力,职责就是记录程序运行期间发生的离散事件,并且在后端22022年美团技术年货事后阶段用于程序的行为分析,比如曾经调用过什么方法、操作过哪些数据等等。在分布式系统中,ELK 技术栈已经成为日志收集和分析的通用解决方案。如下图 1 所示,伴随着业务逻辑的执行,业务日志会被打印,统一收集并存储至 Elasticsearch(下称 ES)2。图 1业务系统 ELK 案例传统的 ELK 方案需要开发者在编写代
5、码时尽可能全地打印日志,再通过关键字段从ES 中搜集筛选出与业务逻辑相关的日志数据,进而拼凑出业务执行的现场信息。然而该方案存在如下的痛点:日志搜集繁琐:虽然 ES 提供了日志检索的能力,但是日志数据往往是缺乏结构性的文本段,很难快速完整地搜集到全部相关的日志。日志筛选困难:不同业务场景、业务逻辑之间存在重叠,重叠逻辑打印的业务日志可能相互干扰,难以从中筛选出正确的关联日志。日志分析耗时:搜集到的日志只是一条条离散的数据,只能阅读代码,再结合逻辑,由人工对日志进行串联分析,尽可能地还原出现场。综上所述,随着业务逻辑和系统复杂度的攀升,传统的 ELK 方案在日志搜集、日志筛选和日志分析方面愈加的
6、耗时耗力,很难快速实现对业务的追踪。后端2022年美团技术年货力,待审对象的审核需要经过“初审”和“复审”两个环节(两个环节关联相同的taskId),因此整个审核环节的执行调用了两次审核接口。如图左侧所示,完整的审核场景涉及众多“业务逻辑”的执行,而分布式会话跟踪只是根据两次 RPC 调用生成了右侧的两条调用链路,并没有办法准确地描述审核场景业务逻辑的执行,问题主要体现在以下几个方面:图 3分布式会话跟踪案例(1)无法同时追踪多条调用链路分布式会话跟踪仅支持单个请求的调用追踪,当业务场景包含了多个调用时,将生成多条调用链路;由于调用链路通过 traceId 串联,不同链路之间相互独立,因此给完
7、整的业务追踪增加了难度。例如当排查审核场景的业务问题时,由于初审和复审是不同的 RPC 请求,所以无法直接同时获取到 2 条调用链路,通常需要额外存储 2 个traceId 的映射关系。(2)无法准确描述业务逻辑的全景分布式会话跟踪生成的调用链路,只包含单次请求的实际调用情况,部分未执行的调用以及本地逻辑无法体现在链路中,导致无法准确描述业务逻辑的全景。例如同样是审核接口,初审链路 1 包含了服务 b 的调用,而复审链路 2 却并没有包含,这是因为后端2022年美团技术年货和动态串联,如下图 4 所示,此时离散的日志数据将会根据业务逻辑进行组织,绘制出执行现场,从而可以实现高效的业务追踪。图
8、4业务系统日志追踪案例新方案需要回答两个关键问题:如何高效组织业务日志,以及如何动态串联业务日志。下文将逐一进行回答。问题 1:如何高效组织业务日志?为了实现高效的业务追踪,首先需要准确完整地描述出业务逻辑,形成业务逻辑的全景图,而业务追踪其实就是通过执行时的日志数据,在全景图中还原出业务执行的现场。新方案对业务逻辑进行了抽象,定义出业务逻辑链路,下面还是以“审核业务场景”为例,来说明业务逻辑链路的抽象过程:逻辑节点:业务系统的众多逻辑可以按照业务功能进行拆分,形成一个个相互独立的业务逻辑单元,即逻辑节点,可以是本地方法(如下图 5 的“判断逻辑”节点)也可以是 RPC 等远程调用方法(如下图
9、 5 的“逻辑 A”节点)。逻辑链路:业务系统对外支撑着众多的业务场景,每个业务场景对应一个完整后端2022年美团技术年货的执行动态串联各个逻辑节点的日志,进而还原出完整的业务逻辑执行现场。由于逻辑节点之间、逻辑节点内部往往通过 MQ 或者 RPC 等进行交互,新方案可以采用分布式会话跟踪提供的分布式参数透传能力5实现业务日志的动态串联:通过在执行线程和网络通信中持续地透传参数,实现在业务逻辑执行的同时,不中断地传递链路和节点的标识,实现离散日志的染色。基于标识,染色的离散日志会被动态串联至正在执行的节点,逐渐汇聚出完整的逻辑链路,最终实现业务执行现场的高效组织和可视化展示。与分布式会话跟踪方
10、案不同的是,当同时串联多次分布式调用时,新方案需要结合业务逻辑选取一个公共 id 作为标识,例如图 5 的审核场景涉及 2 次 RPC 调用,为了保证 2 次执行被串联至同一条逻辑链路,此时结合审核业务场景,选择初审和复审相同的“任务 id”作为标识,完整地实现审核场景的逻辑链路串联和执行现场还原。2.2通用方案明确日志的高效组织和动态串联这两个基本问题后,本文选取图 4 业务系统中的“逻辑链路 1”进行通用方案的详细说明,方案可以拆解为以下步骤:图 6通用方案拆解2.2.1链路定义“链路定义”的含义为:使用特定语言,静态描述完整的逻辑链路,链路通常由多个逻辑节点,按照一定的业务规则组合而成,
11、业务规则即各个逻辑节点之间存在的执行关系,包括串行、并行、条件分支。后端2022年美团技术年货 ,“nodeName”:“Join”,“nodeType”:“join”,“joinOnList”:“B”,“C”,“nodeName”:“D”,“nodeType”:“decision”,“decisionCases”:“true”:“nodeName”:“E”,“nodeType”:“rpc”,“defaultCase”:“nodeName”:“F”,“nodeType”:“rpc”2.2.2链路染色“链路染色”的含义为:在链路执行过程中,通过透传串联标识,明确具体是哪条链路在执行,执行到了哪个
12、节点。链路染色包括两个步骤:步骤一:确定串联标识,当逻辑链路开启时,确定唯一标识,能够明确后续待执行的链路和节点。链路唯一标识=业务标识+场景标识+执行标识(三个标识共同决定“某个业务场景下的某次执行”)业务标识:赋予链路业务含义,例如“用户 id”、“活动 id”等等。后端2022年美团技术年货图 8链路上报图示如上图 8 所示,上报的日志数据包括:节点日志和业务日志。其中节点日志的作用是绘制链路中的已执行节点,记录了节点的开始、结束、输入、输出;业务日志的作用是展示链路节点具体业务逻辑的执行情况,记录了任何对业务逻辑起到解释作用的数据,包括与上下游交互的入参出参、复杂逻辑的中间变量、逻辑执
13、行抛出的异常。2.2.4链路存储“链路存储”的含义为:将链路执行中上报的日志落地存储,并用于后续的“现场还原”。上报日志可以拆分为链路日志、节点日志和业务日志三类:链路日志:链路单次执行中,从开始节点和结束节点的日志中提取的链路基本信息,包含链路类型、链路元信息、链路开始/结束时间等。节点日志:链路单次执行中,已执行节点的基本信息,包含节点名称、节点状态、节点开始/结束时间等。后端2022年美团技术年货场景。对于内容流水线中的三方,分别有如下需求:内容的生产方:希望生产的内容能在更多的渠道分发,收获更多的流量,被消费者所喜爱。内容的治理方:希望作为“防火墙”过滤出合法合规的内容,同时整合机器和
14、人工能力,丰富内容属性。内容的消费方:希望获得满足其个性化需求的内容,能够吸引其种草,或辅助其做出消费决策。生产方的内容模型各异、所需处理手段各不相同,消费方对于内容也有着个性化的要求。如果由各个生产方和消费方单独对接,内容模型异构、处理流程和输出门槛各异的问题将带来对接的高成本和低效率。在此背景下,点评内容平台应运而生,作为内容流水线的“治理方”,承上启下实现了内容的统一接入、统一处理和统一输出:图 10点评内容平台业务形态统一接入:统一内容数据模型,对接不同的内容生产方,将异构的内容转化为内容平台通用的数据模型。统一处理:统一处理能力建设,积累并完善通用的机器处理和人工运营能力,保证内容合
15、法合规,属性丰富。统一输出:统一输出门槛建设,对接不同的内容消费方,为下游提供规范且满足其个性化需求的内容数据。如下图 11 所示,是点评内容平台的核心业务流程,每一条内容都会经过这个流程,后端2022年美团技术年货点评内容平台日均处理百万条内容,涉及百万次业务场景的执行、高达亿级的逻辑节点的执行,而业务日志分散在不同的应用中,并且不同内容,不同场景,不同节点以及多次执行的日志混杂在一起,无论是日志的搜集还是现场的还原都相当繁琐耗时,传统的业务追踪方案越来越不适用于内容平台。点评内容平台亟需新的解决方案,实现高效的业务追踪,因此我们进行了可视化全链路日志追踪的建设,下面本文将介绍一下相关的实践
16、和成果。3.2实践与成果3.2.1实践点评内容平台是一个复杂的业务系统,对外支撑着众多的业务场景,通过对于业务场景的梳理和抽象,可以定义出实时接入、人工运营、任务导入、分发重算等多个业务逻辑链路。由于点评内容平台涉及众多的内部服务和下游依赖服务,每天支撑着大量的内容处理业务,伴随着业务的执行将生成大量的日志数据,与此同时链路上报还需要对众多的服务进行改造。因此在通用的全链路日志追踪方案的基础上,点评内容平台进行了如下的具体实践。(1)支持大数据量日志的上报和存储点评内容平台实现了图 12 所示的日志上报架构,支持众多服务统一的日志收集、处理和存储,能够很好地支撑大数据量下的日志追踪建设。后端2
17、022年美团技术年货需求分析选型优点OLTP 业务:逻辑链路的实时读写数据量很大:海量的记录数,且未来会持续增长写密集、读较少:日志上报峰值 QPS 较高业务场景简单:简单读写即可满足需求存储特性:支持横向扩展、快速扩充字段查询特性:支持精确和前缀匹配查询,且支持快速随机的访问经济成本:存储介质成本低廉整体而言,log_agent+Kafka+Flink+HBase 的日志上报和存储架构能够很好地支持复杂的业务系统,天然支持分布式场景下众多应用的日志上报,同时适用于高流量的数据写入。(2)实现众多后端服务的低成本改造点评内容平台实现了“自定义日志工具包”(即下图 13 的 TraceLogge
18、r 工具包),屏蔽链路追踪中的上报细节,实现众多服务改造的成本最小化。TraceLogger 工具包的功能包括:模仿 slf4j-api:工具包的实现在 slf4j 框架之上,并模仿 slf4j-api 对外提供相同的 API,因此使用方无学习成本。屏蔽内部细节,内部封装一系列的链路日志上报逻辑,屏蔽染色等细节,降低使用方的开发成本。上报判断:判断链路标识:无标识时,进行兜底的日志上报,防止日志丢失。判断上报方式:有标识时,支持日志和 RPC 中转两种上报方式。日志组装:实现参数占位、异常堆栈输出等功能,并将相关数据组装为Trace 对象,便于进行统一的收集和处理。异常上报:通过 ErrorA
19、PI 主动上报异常,兼容原日志上报中 ErrorAp-pender。日志上报:适配 Log4j2 日志框架实现最终的日志上报。后端2022年美团技术年货节点日志上报:支持 API、AOP 两种上报方式,灵活且成本低。public Response realTimeInputLink(long contentId)/链路开始:传递串联标识(业务标识+场景标识+执行标识)TraceUtils.passLinkMark(“contentId_type_uuid”);/./本地调用(API 上报节点日志)TraceUtils.reportNode(“contentStore”,contentId,St
20、atusEnums.RUNNING)contentStore(contentId);TraceUtils.reportNode(“contentStore”,structResp,StatusEnums.COMPLETED)/./远程调用 Response processResp=picProcess(contentId);/./AOP 上报节点日志 TraceNode(nodeName=”picProcess”)public Response picProcess(long contentId)/图片处理业务逻辑 /业务日志数据上报 TraceLogger.warn(“picProcess
21、failed,contentId:”,contentId);3.2.2成果基于上述实践,点评内容平台实现了可视化全链路日志追踪,能够一键追踪任意一条内容所有业务场景的执行,并通过可视化的链路进行执行现场的还原,追踪效果如下图所示:后端2022年美团技术年货【链路展示功能】:通过链路图可视化展示业务逻辑的全景,同时展示各个节点的执行情况。图 15链路展示后端2022年美团技术年货接入成本低:DSL 配置配合简单的日志上报改造,即可快速接入。追踪范围广:任意一条内容的所有逻辑链路,均可被追踪。使用效率高:管理后台支持链路和日志的可视化查询展示,简单快捷。4.总结与展望随着分布式业务系统的日益复杂,
22、可观测性对于业务系统的稳定运行也愈发重要6。作为大众点评内容流水线中的复杂业务系统,为了保障内容流转的稳定可靠,点评内容平台落地了全链路的可观测建设,在日志(Logging)、指标(Metrics)和追踪(Tracing)的三个具体方向上都进行了一定的探索和建设。其中之一就是本文的“可视化全链路日志追踪”,结合日志(Logging)与追踪(Trac-ing),我们提出了一套新的业务追踪通用方案,通过在业务执行阶段,结合完整的业务逻辑动态完成日志的组织串联,替代了传统方案低效且滞后的人工日志串联,最终可以实现业务全流程的高效追踪以及业务问题的高效定位。此外,在指标(Metrics)方向上,点评内
23、容平台实践落地了“可视化全链路指标监控”,支持实时、多维度地展示业务系统的关键业务和技术指标,同时支持相应的告警和异常归因能力,实现了对业务系统整体运行状况的有效把控。未来,点评内容平台会持续深耕,实现覆盖告警、概况、排错和剖析等功能的可观测体系7,持续沉淀和输出相关的通用方案,希望可以为业务系统(特别是复杂的业务系统),提供一些可观测性建设的借鉴和启发。5.参考文献1Metrics,tracing,andlogging2ELKStack:Elasticsearch,Logstash,Kibana|Elastic3Dapper,aLarge-ScaleDistributedSystemsTra
24、cingInfrastructure4OpenZipkin Adistributedtracingsystem5分布式会话跟踪系统架构设计与实践6凤凰架构-可观测性7万字破解云原生可观测性后端2022年美团技术年货设计模式二三事作者:嘉凯杨柳设计模式是众多软件开发人员经过长时间的试错和应用总结出来的,解决特定问题的一系列方案。现行的部分教材在介绍设计模式时,有些会因为案例脱离实际应用场景而令人费解,有些又会因为场景简单而显得有些小题大做。本文会结合在美团金融服务平台设计开发时的经验,结合实际的案例,并采用“师生对话”这种相对诙谐的形式去讲解三类常用设计模式的应用。希望能对想提升系统设计能力的同
25、学有所帮助或启发。引言话说这是在程序员世界里一对师徒的对话:“老师,我最近在写代码时总感觉自己的代码很不优雅,有什么办法能优化吗?”“嗯,可以考虑通过教材系统学习,从注释、命名、方法和异常等多方面实现整洁代码。”“然而,我想说的是,我的代码是符合各种编码规范的,但是从实现上却总是感觉不够简洁,而且总是需要反复修改!”学生小明叹气道。老师看了看小明的代码说:“我明白了,这是系统设计上的缺陷。总结就是抽象不够、可读性低、不够健壮。”“对对对,那怎么能迅速提高代码的可读性、健壮性、扩展性呢?”小明急不可耐地问道。老师敲了敲小明的头:“不要太浮躁,没有什么方法能让你立刻成为系统设计专家。但是对于你的问
26、题,我想设计模式可以帮到你。”后端2022年美团技术年货 request.setWaimaiReq(params);waimaiService.issueWaimai(request);else if(“Hotel”.equals(rewardType)HotelRequest request=new HotelRequest();request.addHotelReq(params);hotelService.sendPrize(request);else if(“Food”.equals(rewardType)FoodRequest request=new FoodRequest(para
27、ms);foodService.getCoupon(request);else throw new IllegalArgumentException(“rewardType error!”);小明很快写好了 Demo,然后发给老师看。“假如我们即将接入新的打车券,这是否意味着你必须要修改这部分代码?”老师问道。小明愣了一愣,没等反应过来老师又问:”假如后面美团外卖的发券接口发生了改变或者替换,这段逻辑是否必须要同步进行修改?”小明陷入了思考之中,一时间没法回答。经验丰富的老师一针见血地指出了这段设计的问题:“你这段代码有两个主要问题,一是不符合开闭原则,可以预见,如果后续新增品类券的话,需要直
28、接修改主干代码,而我们提倡代码应该是对修改封闭的;二是不符合迪米特法则,发奖逻辑和各个下游接口高度耦合,这导致接口的改变将直接影响到代码的组织,使得代码的可维护性降低。”小明恍然大悟:“那我将各个同下游接口交互的功能抽象成单独的服务,封装其参数组装及异常处理,使得发奖主逻辑与其解耦,是否就能更具备扩展性和可维护性?”“这是个不错的思路。之前跟你介绍过设计模式,这个案例就可以使用策略模式和适配器模式来优化。”小明借此机会学习了这两个设计模式。首先是策略模式:后端2022年美团技术年货/酒旅策略class Hotel implements Strategy private HotelService
29、 hotelService;Override public void issue(Object.params)HotelRequest request=new HotelRequest();request.addHotelReq(params);hotelService.sendPrize(request);/美食策略class Food implements Strategy private FoodService foodService;Override public void issue(Object.params)FoodRequest request=new FoodRequest(
30、params);foodService.payCoupon(request);然后,小明创建策略模式的环境类,并供奖励服务调用:/使用分支判断获取的策略上下文class StrategyContext public static Strategy getStrategy(String rewardType)switch(rewardType)case“Waimai”:return new Waimai();case“Hotel”:return new Hotel();case“Food”:return new Food();default:throw new IllegalArgumentEx
31、ception(“rewardType error!”);/优化后的策略服务class RewardService public void issueReward(String rewardType,Object.params)Strategy strategy=StrategyContext.getStrategy(rewardType);strategy.issue(params);小明的代码经过优化后,虽然结构和设计上比之前要复杂不少,但考虑到健壮性和拓后端31展性,还是非常值得的。“看,我这次优化后的版本是不是很完美?”小明洋洋得意地说。“耦合度确实降低了,但还能做的更好。”“怎么做?
32、”小明有点疑惑。“我问你,策略类是有状态的模型吗?如果不是是否可以考虑做成单例的?”“的确如此。”小明似乎明白了。“还有一点,环境类的获取策略方法职责很明确,但是你依然没有做到完全对修改封闭。”经过老师的点拨,小明很快也领悟到了要点:“那我可以将策略类单例化以减少开销,并实现自注册的功能彻底解决分支判断。”小明列出单例模式的要点:单例模式 1-5 设计模式属于创建型模式,它提供了一种创建对象的最佳方式。这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。最终,小明在策略环境类中使用一个注册
33、表来记录各个策略类的注册信息,并提供接口供策略类调用进行注册。同时使用饿汉式单例模式去优化策略类的设计:/策略上下文,用于管理策略的注册和获取class StrategyContext private static final Map registerMap=new HashMap();/注册策略 public static void registerStrategy(String rewardType,Strategy strategy)registerMap.putIfAbsent(rewardType,strategy);/获取策略 public static Strategy getS
34、trategy(String rewardType)return registerMap.get(rewardType);322022年美团技术年货 /抽象策略类abstract class AbstractStrategy implements Strategy /类注册方法 public void register()StrategyContext.registerStrategy(getClass().getSimpleName(),this);/单例外卖策略class Waimai extends AbstractStrategy implements Strategy private
35、 static final Waimai instance=new Waimai();private WaimaiService waimaiService;private Waimai()register();public static Waimai getInstance()return instance;Override public void issue(Object.params)WaimaiRequest request=new WaimaiRequest();/构建入参 request.setWaimaiReq(params);waimaiService.issueWaimai(
36、request);/单例酒旅策略class Hotel extends AbstractStrategy implements Strategy private static final Hotel instance=new Hotel();private HotelService hotelService;private Hotel()register();public static Hotel getInstance()return instance;Override public void issue(Object.params)HotelRequest request=new Hote
37、lRequest();request.addHotelReq(params);hotelService.sendPrize(request);/单例美食策略class Food extends AbstractStrategy implements Strategy private static final Food instance=new Food();private FoodService foodService;private Food()后端2022年美团技术年货开闭原则的设计。“老师,我开始学会利用设计模式去解决已发现的问题。这次我做得怎么样?”“合格。但是,依然要戒骄戒躁。”任务
38、模型的设计“之前让你设计奖励发放策略你还记得吗?”老师忽然问道。“当然记得。一个好的设计模式,能让工作事半功倍。”小明答道。“嗯,那会提到了活动营销的组成部分,除了奖励之外,貌似还有任务吧。”小明点了点头,老师接着说:“现在,我想让你去完成任务模型的设计。你需要重点关注状态的流转变更,以及状态变更后的消息通知。”小明欣然接下了老师给的难题。他首先定义了一套任务状态的枚举和行为的枚举:/任务状态枚举AllArgsConstructorGetterenum TaskState INIT(“初始化”),ONGOING(“进行中”),PAUSED(“暂停中”),FINISHED(“已完成”),EXPI
39、RED(“已过期”);private final String message;/行为枚举AllArgsConstructorGetterenum ActionType START(1,“开始”),STOP(2,“暂停”),ACHIEVE(3,“完成”),EXPIRE(4,“过期”);private final int code;private final String message;后端2022年美团技术年货“老师,我的代码还是和之前说的那样,不够优雅。”“哦,你自己说说看有什么问题?”“第一,方法中使用条件判断来控制语句,但是当条件复杂或者状态太多时,条件判断语句会过于臃肿,可读性差,且
40、不具备扩展性,维护难度也大。且增加新的状态时要添加新的 if-else 语句,这违背了开闭原则,不利于程序的扩展。”老师表示同意,小明接着说:“第二,任务类不够高内聚,它在通知实现中感知了其他领域或模块的模型,如活动和任务管理器,这样代码的耦合度太高,不利于扩展。”老师赞赏地说道:“很好,你有意识能够自主发现代码问题所在,已经是很大的进步了。”“那这个问题应该怎么去解决呢?”小明继续发问。“这个同样可以通过设计模式去优化。首先是状态流转的控制可以使用状态模式,其次,任务完成时的通知可以用到观察者模式。”收到指示后,小明马上去学习了状态模式的结构:状态模式1-5:对有状态的对象,把复杂的“判断逻
41、辑”提取到不同的状态对象中,允许状态对象在其内部状态发生改变时改变其行为。状态模式包含以下主要角色:环境类(Context)角色:也称为上下文,它定义了客户端需要的接口,内部维护一个当前状态,并负责具体状态的切换。抽象状态(State)角色:定义一个接口,用以封装环境对象中的特定状态所对应的行为,可以有一个或多个行为。具体状态(ConcreteState)角色:实现抽象状态所对应的行为,并且在需要的情况下进行状态切换。根据状态模式的定义,小明将 TaskState 枚举类扩展成多个状态类,并具备完成状态的流转的能力;然后优化了任务类的实现:后端2022年美团技术年货/任务过期状态class T
42、askExpired implements State Dataclass Task private Long taskId;/初始化为初始态 private State state=new TaskInit();/更新状态 public void updateState(ActionType actionType)state.update(this,actionType);小明欣喜地看到,经过状态模式处理后的任务类的耦合度得到降低,符合开闭原则。状态模式的优点在于符合单一职责原则,状态类职责明确,有利于程序的扩展。但是这样设计的代价是状态类的数目增加了,因此状态流转逻辑越复杂、需要处理的动作
43、越多,越有利于状态模式的应用。除此之外,状态类的自身对于开闭原则的支持并没有足够好,如果状态流转逻辑变化频繁,那么可能要慎重使用。处理完状态后,小明又根据老师的指导使用观察者模式去优化任务完成时的通知:观察者模式1-5:指多个对象间存在一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。这种模式有时又称作发布-订阅模式、模型-视图模式,它是对象行为型模式。观察者模式的主要角色如下。抽象主题(Subject)角色:也叫抽象目标类,它提供了一个用于保存观察者对象的聚集类和增加、删除观察者对象的方法,以及通知所有观察者的抽象方法。具体主题(ConcreteSubj
44、ect)角色:也叫具体目标类,它实现抽象目标中的通知方法,当具体主题的内部状态发生改变时,通知所有注册过的观察者对象。抽象观察者(Observer)角色:它是一个抽象类或接口,它包含了一个更新自己的抽象方法,当接到具体主题的更改通知时被调用。具体观察者(ConcreteObserver)角色:实现抽象观察者中定义的抽象方法,以便在得到目标的更改通知时更新自身的状态。后端39小明首先设计好抽象目标和抽象观察者,然后将活动和任务管理器的接收通知功能定制成具体观察者:/抽象观察者interface Observer void response(Long taskId);/反应/抽象目标abstrac
45、t class Subject protected List observers=new ArrayList();/增加观察者方法 public void add(Observer observer)observers.add(observer);/删除观察者方法 public void remove(Observer observer)observers.remove(observer);/通知观察者方法 public void notifyObserver(Long taskId)for(Observer observer:observers)observer.response(taskI
46、d);/活动观察者class ActivityObserver implements Observer private ActivityService activityService;Override public void response(Long taskId)activityService.notifyFinished(taskId);/任务管理观察者class TaskManageObserver implements Observer private TaskManager taskManager;Override public void response(Long taskId)
47、taskManager.release(taskId);最后,小明将任务进行状态类优化成使用通用的通知方法,并在任务初始态执行状态流转时定义任务进行态所需的观察者:402022年美团技术年货/任务进行状态class TaskOngoing extends Subject implements State Override public void update(Task task,ActionType actionType)if(actionType=ActionType.ACHIEVE)task.setState(new TaskFinished();/通知 notifyObserver(ta
48、sk.getTaskId();else if(actionType=ActionType.STOP)task.setState(new TaskPaused();else if(actionType=ActionType.EXPIRE)task.setState(new TaskExpired();/任务初始状态class TaskInit implements State Override public void update(Task task,ActionType actionType)if (actionType=ActionType.START)TaskOngoing taskOng
49、oing=new TaskOngoing();taskOngoing.add(new ActivityObserver();taskOngoing.add(new TaskManageObserver();task.setState(taskOngoing);最终,小明设计完成的结构类图如下:任务模型设计 _ 类图通过观察者模式,小明让任务状态和通知方实现松耦合(实际上观察者模式还没能做后端2022年美团技术年货 id=0L;public Activity(String type,Long id)this.type=type;this.id=id;public Activity(String
50、type,Long id,Integer scene)this.type=type;this.id=id;this.scene=scene;public Activity(String type,String name,Integer scene,String material)this.type=type;this.scene=scene;this.material=material;/name 的构建完全依赖于活动的 type if(“period”.equals(type)this.id=0L;this.name=“period”+name;else this.name=“normal”
51、+name;/参与活动 Override public void participate(Long userId)/do nothing /任务型活动class TaskActivity extends Activity private Task task;public TaskActivity(String type,String name,Integer scene,String material,Task task)super(type,name,scene,material);this.task=task;/参与任务型活动 Override public void participat
52、e(Long userId)/更新任务状态为进行中 task.getState().update(task,ActionType.START);经过自主分析,小明发现活动的构造不够合理,主要问题表现在:后端2022年美团技术年货 public Activity(String type,Long id,String name,Integer scene,String material)this.type=type;this.id=id;this.name=name;this.scene=scene;this.material=material;Override public void parti
53、cipate(Long userId)/do nothing /静态建造器类,使用奇异递归模板模式允许继承并返回继承建造器类 public static class BuilderT extends Builder protected String type;protected Long id;protected String name;protected Integer scene;protected String material;public T setType(String type)this.type=type;return(T)this;public T setId(Long id
54、)this.id=id;return(T)this;public T setId()if(“period”.equals(this.type)this.id=0L;return(T)this;public T setScene(Integer scene)this.scene=scene;return(T)this;public T setMaterial(String material)this.material=material;return(T)this;public T setName(String name)if(“period”.equals(this.type)this.name
55、=“period”+name;else this.name=“normal”+name;return(T)this;后端45 public Activity build()return new Activity(type,id,name,scene,material);/任务型活动class TaskActivity extends Activity protected Task task;/全参构造函数 public TaskActivity(String type,Long id,String name,Integer scene,String material,Task task)sup
56、er(type,id,name,scene,material);this.task=task;/参与任务型活动 Override public void participate(Long userId)/更新任务状态为进行中 task.getState().update(task,ActionType.START);/继承建造器类 public static class Builder extends Activity.Builder private Task task;public Builder setTask(Task task)this.task=task;return this;pu
57、blic TaskActivity build()return new TaskActivity(type,id,name,scene,material,task);小明发现,上面的建造器没有使用诸如抽象建造器类等完整的实现,但是基本是完成了活动各个组件的建造流程。使用建造器的模式下,可以先按顺序构建字段 type,然后依次构建其他组件,最后使用 build 方法获取建造完成的活动。这种设计一方面封装性好,构建和表示分离;另一方面扩展性好,各个具体的建造者相互独立,有利于系统的解耦。可以说是一次比较有价值的重构。在实际的应用中,如果字段类型多,同时各个字段只需要简单的赋值,可以直接引用 Lom
58、bok 的 Builder 注解来实现轻量的建造者。重构完活动构建的设计后,小明开始对参加活动方法增加风控。最简单的方式肯定是462022年美团技术年货直接修改目标方法:public void participate(Long userId)/对目标用户做风险控制,失败则抛出异常 Risk.doControl(userId);/更新任务状态为进行中 task.state.update(task,ActionType.START);但是考虑到,最好能尽可能避免对旧方法的直接修改,同时为方法增加风控,也是一类比较常见的功能新增,可能会在多处使用。“老师,风险控制会出现在多种活动的参与方法中吗?”“
59、有这个可能性。有的活动需要风险控制,有的不需要。风控像是在适当的时候对参与这个方法的装饰。”“对了,装饰器模式!”小明马上想到用装饰器模式来完成设计:装饰器模式1-5的定义:指在不改变现有对象结构的情况下,动态地给该对象增加一些职责(即增加其额外功能)的模式,它属于对象结构型模式。装饰器模式主要包含以下角色:抽象构件(Component)角色:定义一个抽象接口以规范准备接收附加责任的对象。具体构件(ConcreteComponent)角色:实现抽象构件,通过装饰角色为其添加一些职责。抽象装饰(Decorator)角色:继承抽象构件,并包含具体构件的实例,可以通过其子类扩展具体构件的功能。具体装
60、饰(ConcreteDecorator)角色:实现抽象装饰的相关方法,并给具体构件对象添加附加的责任。小明使用了装饰器模式后,新的代码就变成了这样:后端2022年美团技术年货和迭代。“老师,我已经能做到自主分析功能特点,并合理应用设计模式去完成程序设计和代码重构了,实在太感谢您了。”“设计模式作为一种软件设计的最佳实践,你已经很好地理解并应用于实践了,非常不错。但学海无涯,还需持续精进!”结语本文以三个实际场景为出发点,借助小明和老师两个虚拟的人物,试图以一种较为诙谐的“对话”方式来讲述设计模式的应用场景、优点和缺点。如果大家想要去系统性地了解设计模式,也可以通过市面上很多的教材进行学习,都介
61、绍了经典的 23 种设计模式的结构和实现 2022 年 3 月 11 日。不过,很多教材的内容即便配合了大量的示例,但有时也会让人感到费解,主要原因在于:一方面,很多案例比较脱离实际的应用场景;另一方面,部分设计模式显然更适用于大型复杂的结构设计,而当其应用到简单的场景时,仿佛让代码变得更加繁琐、冗余。因此,本文希望通过这种“对话+代码展示+结构类图”的方式,以一种更易懂的方式来介绍设计模式。当然,本文只讲述了部分比较常见的设计模式,还有其他的设计模式,仍然需要同学们去研读经典著作,举一反三,学以致用。我们也希望通过学习设计模式能让更多的同学在系统设计能力上得到提升。参考资料1GammaE.设
62、计模式:可复用面向对象软件的基础M.机械工业出版社,2007.2弗里曼.HeadFirst设计模式M.中国电力出版社,4java-design-5Java 设计模式:23 种设计模式全面解析作者简介嘉凯、杨柳,来自美团金融服务平台/联名卡研发团队。后端 2021-07-06,到底选择 IX(name)、IX(dt)、IX(dt,name)还是 IX(name,dt),该方法也无法给出准确的回答。更别说像多表 Join、子查询这样复杂的场景了。所以采用基于代价的推荐来解决该问题会更加普适,因为基于代价的方法使用了和数据库优化器相同的方式,去量化评估所有的可能性,选出的是执行 SQL 耗费代价最小
63、的索引。2.基于代价的优化器介绍2.1SQL 执行与优化器一条 SQL 在 MySQL 服务器中执行流程主要包含:SQL 解析、基于语法树的准备工作、优化器的逻辑变化、优化器的代价准备工作、基于代价模型的优化、进行额外的优化和运行执行计划等部分。具体如下图所示:502022年美团技术年货SQL 执行与优化器2.2代价模型介绍而对于优化器来说,执行一条 SQL 有各种各样的方案可供选择,如表是否用索引、选择哪个索引、是否使用范围扫描、多表 Join 的连接顺序和子查询的执行方式等。如何从这些可选方案中选出耗时最短的方案呢?这就需要定义一个量化数值指标,这个指标就是代价(Cost),我们分别计算出
64、可选方案的操作耗时,从中选出最小值。代价模型将操作分为 Server 层和 Engine(存储引擎)层两类,Server 层主要是CPU 代价,Engine 层主要是 IO 代价,比如 MySQL 从磁盘读取一个数据页的代价io_block_read_cost 为 1,计算符合条件的行代价为 row_evaluate_cost 为 0.2。除此之外还有:1.memory_temptable_create_cost(default2.0)内存临时表的创建代价。后端 2021-07-06 为例,我们看看 MySQL 优化器是如何根据代价模型选择索引的。首先,我们直接在建表时加入四个候选索引。Cre
65、ate Table:CREATE TABLE sync_test1(id int(11)NOT NULL AUTO_INCREMENT,cid int(11)NOT NULL,phone int(11)NOT NULL,522022年美团技术年货 name varchar(10)NOT NULL,address varchar(255)DEFAULT NULL,dt datetime DEFAULT NULL,PRIMARY KEY(id),KEY IX_name(name),KEY IX_dt(dt),KEY IX_dt_name(dt,name),KEY IX_name_dt(name,d
66、t)ENGINE=InnoDB通过执行 explain 看出 MySQL 最终选择了 IX_name 索引。mysql explain select*from sync_test1 where name like Bobby%and dt 2021-07-06;+-+-+-+-+-+-+-+-+-+-+-+-+|id|select_type|table|partitions|type|possible_keys|key|key_len|ref|rows|filtered|Extra|+-+-+-+-+-+-+-+-+-+-+-+-+|1|SIMPLE|sync_test1|NULL|range
67、|IX_name,IX_dt,IX_dt_name,IX_name_dt|IX_name|12|NULL|572|36.83|Using index condition;Using where|+-+-+-+-+-+-+-+-+-+-+-+-+然后再打开 MySQL 追踪优化器 Trace 功能。可以看出,没有选择其他三个索引的原因均是因为在其他三个索引上使用 rangescan 的代价均=IX_name。mysql select*from INFORMATION_SCHEMA.OPTIMIZER_TRACEG;*1.row*TRACE:.“rows_estimation”:“table”:“
68、sync_test1”,“range_analysis”:“table_scan”:“rows”:105084,“cost”:21628,.“analyzing_range_alternatives”:“range_scan_alternatives”:后端53 “index”:“IX_name”,“ranges”:“Bobbyu0000u0000u0000u0000u0000=name=Bobby”,“index_dives_for_eq_ranges”:true,“rowid_ordered”:false,“using_mrr”:false,“index_only”:false,“rows
69、”:572,“cost”:687.41,“chosen”:true ,“index”:“IX_dt”,“ranges”:“0 x99aa0c0000 dt”,“index_dives_for_eq_ranges”:true,“rowid_ordered”:false,“using_mrr”:false,“index_only”:false,“rows”:38698,“cost”:46439,“chosen”:false,“cause”:“cost”,“index”:“IX_dt_name”,“ranges”:“0 x99aa0c0000 dt”,“index_dives_for_eq_rang
70、es”:true,“rowid_ordered”:false,“using_mrr”:false,“index_only”:false,“rows”:38292,“cost”:45951,“chosen”:false,“cause”:“cost”,“index”:“IX_name_dt”,“ranges”:“Bobbyu0000u0000u0000u0000u0000=name 2022年美团技术年货 “rows”:572,“cost”:687.41,“chosen”:false,“cause”:“cost”,“analyzing_roworder_intersect”:“usable”:fa
71、lse,“cause”:“too_few_roworder_scans”,“chosen_range_access_summary”:“range_access_plan”:“type”:“range_scan”,“index”:“IX_name”,“rows”:572,“ranges”:“Bobbyu0000u0000u0000u0000u0000=name=Bobby”,“rows_for_plan”:572,“cost_for_plan”:687.41,“chosen”:true.下面我们根据代价模型来推演一下代价的计算过程:走全表扫描的代价:io_cost+cpu_cost=(数据页个
72、数*io_block_read_cost)+(数据行数*row_evaluate_cost+1.1)=(data_length/block_size+1)+(rows*0.2+1.1)=(9977856/16384+1)+(105084*0.2+1.1)=21627.9。走二级索引 IX_name 的代价:io_cost+cpu_cost=(预估范围行数*io_block_read_cost+1)+(数据行数*row_evaluate_cost+0.01)=(572*1+1)+(572*0.2+0.01)=687.41。走二级索引 IX_dt 的代价:io_cost+cpu_cost=(预估范
73、围行数*io_block_read_cost+1)+(数据行数*row_evaluate_cost+0.01)=(38698*1+1)+(38698*0.2+0.01)=46438.61。后端2022年美团技术年货基于代价的索引推荐思路因为 MySQL 本身就支持自定义存储引擎,所以索引推荐思路是构建一个支持虚假索引的存储引擎,在它上面建立包含候选索引的空表,再采集样本数据,计算出统计数据提供给优化器,让优化器选出最优索引,整个调用关系如下图所示:基于代价的索引推荐思路后端2022年美团技术年货调用链路下面将重点阐述核心逻辑 Go-Server 部分,主要流程步骤如下。3.1前置校验首先根据经
74、验规则,排除一些不支持通过添加索引来提高查询效率的场景,如查系统库的 SQL,非 select、update、deleteSQL 等。3.2提取关键列名这一步提取 SQL 可用来添加索引的候选列名,除了选择给出现在 where 中的列添加索引,MySQL 对排序、聚合、表连接、聚合函数(如 max)也支持使用索引来提高查询效率。我们对 SQL 进行语法树解析,在树节点的 where、join、orderby、groupby、聚合函数中提取列名,作为索引的候选列。值得注意的是,对于某些SQL,还需结合表结构才能准确地提取,比如:select*from tb1,tb2 where a=1,列 a
75、归属 tb1 还是 tb2 取决于谁唯一包含列 a。select*from tb1 natural join tb2 where tb1.a=1,在自然连接中,tb1 和 tb2 默认使用了相同列名进行连接,但 SQL 中并没有暴露出这些可用于添加索引的列。3.3生成候选索引将提取出的关键列名进行全排列即包含所有的索引组合,如列 A、B、C 的所有索引 组 合 是 A,B,C,AB,AC,BA,BC,CA,CB,ABC,ACB,BAC,BCA,CAB,CBA,但还需排除一些索引才能得到所有的候选索引,比如:后端2022年美团技术年货下面介绍样本数据的采样算法,好的采样算法应该尽最大可能采集到符
76、合原表数据分布的样本。比如基于均匀随机采样的方式 select*from table where rand()=1000 and id=10000 and id 100 and B 100 有多少行数,在索引页 B 上估计 B1000 的行数,例如满足条件的 A 有 200 行,B 有 50 行,那么优化器会优先选择使用索引 B。对于假索引来说,我们按照该公式:样本满足条件的范围行数*(原表行数/样本表行数),直接样本数据中查找,然后按照采样比例放大即可估算出原表中满足条件的范围行数。其次是用于计算索引区分度的 cardinality。如果直接套用上述公式:样本列上不同值个数*(原表行数/样本
77、表行数),如上述的候选索引 A,根据样本统计出共有100 个不同值,那么在原表中,该列有多少不同值?一般以为是 10,000=100*(1,000,000/100,000)。但这样计算不适用某些场景,比如状态码字段,可能最多100 个不同值。针对该问题,我们引入斜率和两趟计算来规避,流程如下:后端2022年美团技术年货值得注意的是,MySQL 表最多建 64 个索引(二级索引),计算所有候选索引的可能时,使用的是增幅比指数还恐怖的全排列算法。如下图所示,随着列数的增加,候选索引数量急剧上升,在 5 个候选列时的索引组合数量就超过了 MySQL 最大值,显然不能满足一些复杂 SQL 的需求。统计
78、美团线上索引列数分布后,我们发现,95%以上的索引列数都=3 个。同时基于经验考虑,3 列索引也可满足绝大部分场景,剩余场景会通过其他方式,如库表拆分来提高查询性能,而不是增加索引列个数。候选索引代价评估但即便最多推荐 3 列索引,在 5 个候选列时其排列数量 85=$A51+A52+A_53$也远超 64。这里我们采用归并思路。如下图所示,将所有候选索引拆分到多个表中,采用两次计算,先让 MySQL 优化器选出批次一的最佳索引,可采用并行计算保证时效性,再 MySQL 选出批次一所有最佳索引的最佳索引,该方案可以最多支持 4096 个候选索引,结合最大索引 3 列限制,可以支持计算出 17
79、个候选列的最佳索引。后端2022年美团技术年货建议质量保证从结果可以看出,系统基本能覆盖到大部分的慢查询。但还是会出现无效的推荐,大致原因如下:索引推荐计算出的 Cost 严重依赖样本数据的质量,在当表数据分布不均或数据倾斜时会导致统计数据出现误差,导致推荐出错误索引。索引推荐系统本身存在缺陷,从而导致推荐出错误索引。MySQL 优化器自身存在的缺陷,导致推荐出错误索引。因此,我们在业务添加索引前后增加了索引的有效性验证和效果追踪两个步骤,整个流程如下所示:全链路4.1有效性验证因为目前还不具备大规模数据库备份快速还原的能力,所以无法使用完整的备份数据做验证。我们近似地认为,如果推荐索引在业务
80、库上取得较好的效果,那么在样本库也会取得不错效果。通过真正地在样本库上真实执行 SQL,并添加索引来验证其有效性,验证结果展示如下:后端2022年美团技术年货4.3仿真环境当推荐链路出现问题时,直接在线上排查验证问题的话,很容易给业务带来安全隐患,同时也降低了系统的稳定性。对此我们搭建了离线仿真环境,利用数据库备份构建了和生产环境一样的数据源,并完整复刻了线上推荐链路的各个步骤,在仿真环境回放异常案例,复现问题、排查根因,反复验证改进方案后再上线到生产系统,进而不断优化现有系统,提升推荐质量。仿真环境4.4测试案例库在上线过程中,往往会出现改进方案修复了一个 Bug,带来了更多 Bug 的情况
81、。能否做好索引推荐能力的回归测试,直接决定了推荐质量的稳定性。于是,我们参考了阿里云的技术方案,计划构建一个尽可能完备的测试案例库用于衡量索引推荐服务能力强弱。但考虑影响 MySQL 索引选择的因素众多,各因素间的组合,SQL 的复杂性,如果人为去设计测试用例是是不切实际的,我们通过下列方法自动化收集测试用例:利用美团线上的丰富数据,以影响 MySQL 索引选择的因素特征为抓手,直接从全量 SQL 和慢 SQL 中抽取最真实的案例,不断更新现有测试案例库。在生产的推荐系统链路上埋点,自动收集异常案例,回流到现有的测试案例库。对于现有数据没有覆盖到的极端场景,采用人为构造的方案,补充测试用例。后
82、端2022年美团技术年货5.2现在-新增慢查询这类慢查询属于当前产生的,数量较少,属于治理的重点,也可通过实时收集慢查询日志发现,分成两类接入:影响程度一般的慢查询:可通过实时分析慢查询日志,对比历史慢查询,识别出新增慢查询,并生成优化建议,为用户创建数据库风险项,跟进治理。影响程度较大的慢查询:该类通常会引发数据库告警,如慢查询导致数据库Load 过高,可通过故障诊断根因系统,识别出具体的慢查询 SQL,并生成优化建议,及时推送到故障处理群,降低故障处理时长。5.3未来-潜在慢查询这类查询属于当前还没被定义成慢查询,随着时间推进可能变成演变成慢查询,对于一些核心业务来说,往往会引发故障,属于
83、他们治理的重点,分成两类接入:未上线的准慢查询:项目准备上线而引入的新的准慢查询,可接入发布前的集成测试流水线,Java 项目可通过agentmain 的代理方式拦截被测试用例覆盖到的 SQL,再通过经验+explain 识别出慢查询,并生成优化建议,给用户在需求管理系统上创建缺陷任务,解决后才能发布上线。已上线的准慢查询:该类属于当前执行时间较快的 SQL,随着表数据量的增加,会演变成慢查询,最常见的就是全表扫描,这类可通过增加慢查询配置参数 log_queries_not_using_indexes 记录到慢日志,并生成优化建议,为用户创建数据库风险项,跟进治理。6.项目运行情况当前,主要
84、以新增慢查询为突破点,重点为全表扫描推荐优化建议。目前我们已经灰度接入了一小部分业务,共分析了六千多条慢查询,推荐了一千多条高效索引建议。另外,美团内部的研发同学也可通过数据库平台自助发起 SQL 优化建议工单,如下图所示:后端2022年美团技术年货7.未来规划考虑到美团日均产生近亿级别的慢查询数据,为了实现对它们的诊断分析,我们还需要提高系统大规模的数据并发处理的能力。另外,当前该系统还是针对单 SQL 的优化,没有考虑维护新索引带来的代价,如占用额外的磁盘空间,使写操作变慢,也没有考虑到 MySQL 选错索引引发其他 SQL 的性能回退。对于业务或者 DBA 来说,我们更多关心的是整个数据
85、库或者集群层面的优化。业界如阿里云的 DAS 则是站在全局的角度考量,综合考虑各个因素,输出需要创建的新索引、需要改写的索引、需要删除的索引,实现数据库性能最大化提升,同时最大化降低磁盘空间消耗。未来我们也将不断优化和改进,实现类似基于 Workload 的全局优化。参考资料MySQLWritingaCustomStorageEngineMySQLOptimizerGuideMySQL直方图Golangcgo阿里云-DAS 之基于 Workload 的全局自动优化实践SQL 诊断优化,以后就都交给数据库自治服务 DAS 吧MySQL 索引原理及慢查询优化本文作者粟含,美团基础研发平台/基础技术
86、部/数据库平台研发组工程师。后端2022年美团技术年货图 11.2.1开发自测场景一般来讲,在用插件之前,开发者修改完代码还需等待 38 分钟启动时间,然后手动构造请求或协调上游发请求,耗时且费力。在使用完热部署插件后,修改完代码可以一键增量部署,让变更“秒级”生效,能够做到快速自测。而对于那些无法本地启动项目,也可以通过远程热部署功能使代码变更“秒级”生效。图 21.2.2联调场景通常情况下,在使用插件之前,开发者修改代码经过 2035 分钟的漫长部署,需要联系上游联调开发者发起请求,一直要等到远程服务器查看日志,才能确认代码生效。在使用热部署插件之后,开发者修改代码远程热部署能够秒级(21
87、0s)生效,开发者直接发起服务调用,可以节省大量的碎片化时间(热部署插件还具备流量回放、远程调用、远程反编译等功能,可配合进行使用)。后端2022年美团技术年货图 41.4Sonic 可以做什么Sonic 是美团内部研发设计的一款 IDEA 插件,旨在通过低代码开发辅助远程/本地热部署,解决 Coding、单测编写执行、自测联调等阶段的效率问题,提高开发者的编码产出效率。数据统计表明,开发者日常大概有 35%时间用于编码的产出。如果想提高研发效率,要么扩大编码产出的时间占比,要么提高编码阶段的产出效率,而Sonic 则聚焦提高编码阶段的产出效率。目前,使用 Sonic 热部署可以解决大部分代码
88、重复构建的问题。Sonic 可以使用户在本地编写代码一键部署到远程环境,修改代码、部署、联调请求、查看日志,循环反复。如果不考虑代码修改时间,通常一个循环需要 2035 分钟,而使用 Sonic 可以把整个时长缩短至 510 秒,而且能够给开发者带来高效沉浸式的开发体验。在实际编码工作中,多文件修改是家常便饭,Sonic 对多文件的热部署能力尤为突出,它可以通过依赖分析等手段来对多文件批量进行远程热部署,并且支持 SpringBeanClass、普通 Class、SpringXML、MyBatisXML 等多类型文件混合热部署。那么跟业界现有的产品相比,Sonic 有哪些优劣势呢?下面我们尝试
89、给出几种产品的对比,仅供大家参考:后端2022年美团技术年货上表未把 Sofa-Ark、Osgi、Arthas 列举,此类属于插件化、模块化应用框架,以及 Java 在线诊断工具,核心能力非热部署。值得注意的是,SpringBootDevTools只能应用在 SpringBoot 项目中,并且它不是增量热部署,而是通过 Classloader迭代的方式重启项目,对大项目而言,性能上是无法接受的。虽然,JRebel 支持三方插件较多,生态庞大,但是对于国产的插件不支持,例如 FastJson 等,同时它还存在远程热部署配置局限,对于公司内部的中间件需要个性化开发,并且是商业软件,整体的使用成本较
90、高。1.5Sonic 远程热部署落地推广的实践经验相信大家都知道,对于技术产品的推广,尤其是开发、测试阶段使用的产品,由于远离线上环境,推动力、执行力、产品功能闭环能否做好,是决定着该产品是否能在企业内部落地并得到大多数人认可的重要的一环。此外,因为很多开发者在开发、测试阶段已逐渐形成了“固化动作”,如何改变这些用户的行为,让他们拥抱新产品,也是 Sonic 面临的艰巨挑战之一。我们从主动沟通、零成本(或极低成本)快速接入、自动化脚本,以及产品自动诊断、收集反馈等方向出发,践行出了四条原则。图 62.整体设计方案2.1Sonic 结构Sonic 插件由 4 大部分组成,包括脚本端、插件端、Ag
91、ent 端,以及 Sonic 服务端。脚本端负责自动化构建 Sonic 启动参数、服务启动等集成工作;IDEA 插件端集成环后端2022年美团技术年货 boolean removeTransformer(ClassFileTransformer transformer);/是否允许对 class retransform boolean isRetransformClassesSupported();/在类加载之后,重新定义 Class。这个很重要,该方法是 1.6 之后加入的,事实上,该方法是 update 了一个类。void retransformClasses(Class.classes)
92、throws UnmodifiableClassException;/是否允许对 class 重新定义 boolean isRedefineClassesSupported();/此方法用于替换类的定义,而不引用现有的类文件字节,就像从源代码重新编译以进行修复和继续调试时所做的那样。/在要转换现有类文件字节的地方(例如在字节码插装中),应该使用retransformClasses。/该方法可以修改方法体、常量池和属性值,但不能新增、删除、重命名属性或方法,也不能修改方法的签名 void redefineClasses(ClassDefinition.definitions)throws Cla
93、ssNotFoundException,UnmodifiableClassException;/获取已经被 JVM 加载的 class,有 className 可能重复(可能存在多个classloader)SuppressWarnings(“rawtypes”)Class getAllLoadedClasses();2.2.2Instrument 简介Instrument 的底层实现依赖于 JVMTI(JVMToolInterface),它是 JVM 暴露出来的一些供用户扩展的接口集合,JVMTI 是基于事件驱动的,JVM 每执行到一定的逻辑就会调用一些事件的回调接口(如果存在),这些接口可以
94、供开发者去扩展自己的逻辑。JVMTIAgent 是一个利用 JVMTI 暴露出来的接口提供了代理启动时加载(AgentOnLoad)、代理通过 Attach 形式加载(AgentOnAttach)和代理卸载(AgentOnUnload)功能的动态库。而 InstrumentAgent 可以理解为一类 JVMTIAgent动 态 库,别 名 是 JPLISAgent(JavaProgrammingLanguageInstrumentationServicesAgent),也就是专门为 Java 语言编写的插桩服务提供支持的代理。后端2022年美团技术年货2.4Sonic 如何解决 Instrum
95、entation 的局限性由于 JVM 限制,JDK7 和 JDK8 都不允许改类结构,比如新增字段,新增方法和修改类的父类等,这对于 Spring 项目来说是致命的。比如开发同学想修改一个 SpringBean,新增一个 Autowired 字段,此类场景在实际应用时很多,所以 Sonic 对此类场景的支持必不可少。那么,具体是如何做到的呢?这里要提一下“大名鼎鼎”的 Dcevm。Dcevm(DynamicCodeEvolutionVirtualMachine)是 JavaHostspot 的补丁(严格上来说是修改),允许(并非无限制)在运行环境下修改加载的类文件。当前虚拟机只允许修改方法体
96、(Method,Body),而 Decvm 可以增加、删除类属性、方法,甚至改变一个类的父类,Dcevm 是一个开源项目,遵从 GPL2.0 协议。更多关于 Dcevm 的介绍,大家可以参考:Wuerthinger10a 以及 GitHubDecvm。值得一提的是,在美团内部,针对 Dcevm 的安装,Sonic 已经打通 HULK,集成发布镜像即可完成(本地热部署可结合插件功能实现一键安装热部署环境)。3.Sonic 热部署技术解析3.1Sonic 整体架构模型上一章节我们主要介绍了 Sonic 的组成。下图详细介绍了 Sonic 在运行期间各个组成部分的工作职责,由它们形成一整套完备的技术
97、产品落地闭环方案:后端2022年美团技术年货3.3文件监听Sonic 首先会在本地和远程预定义两个目录,/var/tmp/sonic/extraClass-path 和/var/tmp/sonic/classes。extraClasspath 为 Sonic 自 定 义 的 拓 展ClasspathURL,classes 为 Sonic 监听的目录,当有文件变更时,通过 IDEA 插件来部署到远程/本地,触发 Agent 的监听目录,来继续下面的热加载逻辑:图 11为什么 Sonic 不直接替换用户 ClassPath 下面的资源文件呢?因为考虑到业务方WAR 包的 API 项目、Spring
98、Boot、Tomcat 项目、Jetty 项目等,都是以 JAR 包来启动的,这样是无法直接修改用户的 Class 文件的。即使是用户项目可以修改,直后端2022年美团技术年货JVM 重载,重载过后将触发初始化时 Spring 插件注册的 Transfrom。接下来,我们简单讲解一下 Spring 是怎么重载的。新增 classSonic 如何保证可以加载到 Classloader 上下文中?由于项目在远程执行,所以运行环境复杂,有可能是 JAR 包方式启动(SpringBoot),也有可能是普通项目,也有可能是 WarWeb 项目,针对此类情况 Sonic 做了一层 ClassloaderU
99、RL 拓展。图 13UserClassLoader 是框架自定义的 ClassLoader 统称,例如 Jetty 项目是 We-bAppclassLoader。其中 Urlclasspath 为当前项目的 lib 文件件下,例如 SpringBoot 项目也是从当前项目 BOOT-INF/lib/路径中加载 CLass 等等,不同框架的自定义位置稍有不同。所以针对此类情况,Agent 必须拿到用户的自定义 Class-loader,如果是常规方式启动的,比如普通 SpringXML 项目,借助 Plus(美团内部服务发布平台)发布,此类没有自定义 Classloader,是默认 AppCla
100、ssLoader,所以 Agent 在用户项目启动过程中,借助字节码增强的方式来获取到真正的用户Classloader。后端2022年美团技术年货图 15考虑这样一个场景,框架自定义类加载器中有 ClassA,此时用户新增 ClassB 需要热加载,BClass 里面有 A 的引用关系,如果增强 AppClassLoader,初始化 B 实例时 ClassLoader。loadclass 首先从 UserClassLoader 开始加载 ClassB 的字节码,依靠双亲委派原则,B 被 Appclassloader 加载,因为 B 依赖类 A,所以当前AppClassLoader 加载 B 一
101、定是加载不到的,此时会抛出 ClassNotFoundExcep-tion 异常。所以对类加载器拓展,一定要拓展最上层的类加载器,这样才会达到使用者想要的效果。3.5SpringBean 重载SpringBeanReload 过程中,Bean 的销毁和重启流程,主要内容如下图展示:后端2022年美团技术年货若变更范围涉及到 BeanB 时,需要重新更新子上下文中的依赖关系,当有多上下文关联时需要维护多上下文环境,且当前上下文环境入口需要 Reload。这里的入口是指:SpringMVCController、Mthrift 和 Pigeon,对不同的流量入口,采用不同的Reload 策略。RPC
102、 框架入口主要操作为解绑注册中心、重新注册、重新加载启动流程等等,对 SpringMVCController,主要是解绑和注册 URLMappping 来实现流量入口类的变化切换。3.6SpringXML 重载当用户修改/新增 SpringXML 时,需要对 XML 中所有 Bean 进行重载。图 17重新 Reload 之后,将 Spring 销毁后重启。需要注意的是:XML 修改方式改动较大,可能涉及到全局的 AOP 的配置以及前置和后置处理器相关的内容,影响范围为全局,所以目前只放开普通的 XMLBean 标签的新增/修改,其他能力酌情逐步放开。3.7MyBatis热部署SpringMy
103、Batis 热部署的主要处理流程是在启动期间获取所有 Configuration 路后端2022年美团技术年货图 19美团内部框架以及常用开源框架截止目前,Sonic 已经支持绝大部分常用第三方框架的热加载,常规业务开发几乎无需重启服务。并且在美团内部的成功率已经高达 99.9%以上,真正地让热部署来代替常规部署构建成为一种可能。4.2IDE 插件集成Sonic 也提供了功能强大的 IDEA 插件,让用户进行沉浸式开发,远程热部署也变得更加便利。后端2022年美团技术年货日志导致线程 Block 的这些坑,你不得不防作者:志洋陈超李敏凯晖殷琦1.前言日志对程序的重要性不言而喻。它很“大”,我们
104、在项目中经常通过日志来记录信息和排查问题,相关代码随处可见。它也很“小”,作为辅助工具,日志使用简单、上手快,我们通常不会花费过多精力耗在日志上。但看似不起眼的日志也隐藏着各种各样的“坑”,如果使用不当,它不仅不能帮助我们,反而还可能降低服务性能,甚至拖垮我们的服务。日志导致线程 Block 的问题,相信你或许已经遇到过,对此应该深有体会;或许你还没遇到过,但不代表没有问题,只是可能还没有触发而已。本文主要介绍美团统一API 网关服务 Shepherd(参见百亿规模 API 网关服务 Shepherd 的设计与实现一文)在实践中所踩过的关于日志导致线程 Block 的那些“坑”,然后再分享一些
105、避“坑”经验。2.背景API 网关服务 Shepherd 基于 Java 语言开发,使用业界大名鼎鼎的 ApacheLog4j2作为主要日志框架,同时使用美团内部的 XMD-LogSDK 和 Scribe-LogSDK 对日志内容进行处理,日志处理整体流程如下图 1 所示。业务打印日志时,日志框架基于 Logger 配置来决定把日志交给 XMDFile 处理还是 Scribe 处理。其中,XMDFile是 XMD-Log 内部提供的日志 Appender 名称,负责输出日志到本地磁盘,Scribe是 Scribe-Log 内部提供的日志 Appender 名称,负责上报日志到远程日志中心。后端
106、93图 1日志处理流程示意图随着业务的快速增长,日志导致的线程 Block 问题愈发频繁。比如调用后端 RPC 服务超时,导致调用方大量线程 Block;再比如,业务内部输出异常日志导致服务大量线程 Block 等,这些问题严重影响着服务的稳定性。因此,我们结合项目在过去一段时间暴露出来的各种由于日志导致的线程 Block 问题,对日志框架存在的稳定性风险因素进行了彻底的排查和修复,并在线下、线上环境进行全方位验证。在此过程中,我们总结了一些日志使用相关的实践经验,希望分享给大家。在进入正文前,首先介绍项目当时的运行环境和日志相关配置信息。JDK 版本java version“1.8.0_45
107、”Java(TM)SE Runtime Environment(build 1.8.0_45-b14)Java HotSpot(TM)64-Bit Server VM(build 25.45-b02,mixed mode)日志依赖版本 org.apache.logging.log4j log4j-api 2.7 org.apache.logging.log4j log4j-core 2.7 org.apache.logging.log4j942022年美团技术年货 log4j-slf4j-impl 2.7日志配置文件 !-data_update_test_lc-后端2022年美团技术年货图 3
108、持有锁的 Runnable 线程堆栈从 Blocked 线程堆栈不难看出这跟日志打印相关,而且是 INFO 级别的日志,遂即登陆机器查看日志是否有异样,发现当时日志量非常大,差不多每两分钟就写满一个500MB 的日志文件。那大量输出日志和线程 Block 之间会有怎样的关联呢?接下来本章节将结合如下图 4所示的调用链路深入分析线程 Block 的根因。图 4日志调用链路后端973.1.2为什么会 Block 线程?从 Blocked 线程堆栈着手分析,查看 PrintStream 相关代码片段如下图 5 所示,可以看到被阻塞地方有 synchronized 同步调用,再结合上文发现每两分钟写满
109、一个500MB 日志文件的现象,初步怀疑是日志量过大导致了线程阻塞。图 5PringStream 代码片段但上述猜测仍有一些值得推敲的地方:1.如果仅仅因为日志量过大就导致线程 Block,那日志框架也太不堪重用了,根本没法在高并发、高吞吐业务场景下使用。2.日志配置里明明是输出日志到文件,怎么会输出到 ConsolePrintStream?3.1.3为什么会输出到 Console?继续沿着线程堆栈调用链路分析,可以看出是 AsyncAppender 调用 append 方法追加日志时发生了错误,相关代码片段如下:/org.apache.logging.log4j.core.appender.
110、AsyncAppender/内部维护的阻塞队列,队列大小默认是 128private final BlockingQueue queue;Overridepublic void append(final LogEvent logEvent)if(!isStarted()throw new IllegalStateException(“AsyncAppender“+getName()+“is not active”);if(!Constants.FORMAT_MESSAGES_IN_BACKGROUND)/LOG4J2-898:user may choose logEvent.getMessag
111、e().getFormattedMessage();/LOG4J2-763:ask 982022年美团技术年货message to freeze parameters final Log4jLogEvent memento=Log4jLogEvent.createMemento(logEvent,includeLocation);/日志事件转入异步队列 if(!transfer(memento)/执行到这里说明队列满了,入队失败,根据是否 blocking 执行具体策略 if(blocking)/阻塞模式,选取特定的策略来处理,策略可能是“忽略日志”、”日志入队并阻塞”、”当前线程打印日志”/
112、delegate to the event router(which may discard,enqueue and block,or log in current thread)final EventRoute route=asyncQueueFullPolicy.getRoute(thread.getId(),memento.getLevel();route.logMessage(this,memento);else /非阻塞模式,交由 ErrorHandler 处理失败日志 error(“Appender“+getName()+“is unable to write primary ap
113、penders.queue is full”);logToErrorAppenderIfNecessary(false,memento);private boolean transfer(final LogEvent memento)return queue instanceof TransferQueue?(TransferQueue)queue).tryTransfer(memento):queue.offer(memento);public void error(final String msg)handler.error(msg);AsyncAppender 顾名思义是个异步 Appe
114、nder,采用异步方式处理日志,在其内部维护了一个 BlockingQueue 队列,每次处理日志时,都先尝试把 Log4jLogEvent事件存入队列中,然后交由后台线程从队列中取出事件并处理(把日志交由 Asyn-cAppender 所关联的 Appender 处理),但队列长度总是有限的,且队列默认大小是128,如果日志量过大或日志异步线程处理不及时,就很可能导致日志队列被打满。当日志队列满时,日志框架内部提供了两种处理方式,具体如下:如果 blocking 配置为 true,会选择相应的处理策略,默认是 SYNCHRO-后端 EXCEPTION_INTERVAL|exceptionCo
115、unt+2022年美团技术年货DefaultErrorHandler 内部在处理异常日志时增加了条件限制,只有下述两个条件任一满足时才会处理,从而避免大量异常日志导致的性能问题。两条日志处理间隔超过 5min。异常日志数量不超过 3 次。但项目所用日志框架版本的默认实现看起来存在一些不太合理的地方:lastException 用于标记上次异常的时间戳,该变量可能被多线程访问,无法保证多线程情况下的线程安全。exceptionCount 用于统计异常日志次数,该变量可能被多线程访问,无法保证多线程情况下的线程安全。所以,在多线程场景下,可能有大量异常日志同时被 DefaultErrorHandl
116、er 处理,带来线程安全问题。值得一提的是,该问题已有相关 Issue:DefaultErrorHandlercannotsharevaluesacrossthreads 反馈给社区,并在 2.15.0 版本中进行了修复。从上述 DefaultErrorHandler 代码中可以看到,真正负责处理日志的是 StatusLog-ger,继续跟进代码进入 logMessage 方法,方法执行逻辑如下:如果 StatusLogger 内部注册了 StatusListener,则由对应的 StatusListen-er 负责处理日志。否则由 SimpleLogger 负责处理日志,直接输出日志到 Sy
117、stem.err 输出流。/org.apache.logging.log4j.status.StatusLoggerprivate static final StatusLogger STATUS_LOGGER=new StatusLogger(StatusLogger.class.getName(),ParameterizedNoReferenceMessageFactory.INSTANCE);/StatusListenerprivate final Collection listeners=new CopyOnWriteArrayList();private final SimpleLo
118、gger logger;private StatusLogger(final String name,final MessageFactory messageFactory)后端 0)/如果系统注册了 listener,由 StatusConsoleListener 处理日志 for(final StatusListener listener:listeners)if(data.getLevel().isMoreSpecificThan(listener.getStatusLevel()listener.log(data);else /否则由 SimpleLogger 处理日志,直接输出到 S
119、ystem.err logger.logMessage(fqcn,level,marker,msg,t);1022022年美团技术年货从上述 Blocked 线程堆栈来看,是 StatusConsoleListener 负责处理日志,而StatusConsoleListener 是 StatusListener 接 口 的 实 现 类,那 么 StatusCon-soleListener 是如何被创建的?3.1.4StatusConsoleListener 是怎么来的?通常来说,每个项目都会有一个日志配置文件(如 log4j2.xml),该配置对应 Log4j2日志框架中的 Configura
120、tion 接口,不同的日志配置文件格式有不同的实现类:XmlConfiguration,即 XML 格式日志配置JsonConfiguration,即 JSON 格式日志配置XMDConfiguration,即美团内部日志组件 XMD-Log 定义的日志配置(XML格式)log4j2.xml示例配置(仅做示例,请勿实际项目中使用该配置)。target/rolling1/rollingtest-$sd:type.log%d%p%c1.%t%m%n 后端103 Log4j2 在启动时会加载并解析 log4j2.xml 配置文件,由对应的 ConfigurationFac-tory 创建具体 Con
121、figuration 实例。/org.apache.logging.log4j.core.config.xml.XmlConfigurationpublic XmlConfiguration(final LoggerContext loggerContext,final ConfigurationSource configSource)super(loggerContext,configSource);final File configFile=configSource.getFile();byte buffer=null;try final InputStream configStream=
122、configSource.getInputStream();try buffer=toByteArray(configStream);finally Closer.closeSilently(configStream);final InputSource source=new InputSource(new ByteArrayInputStream(buffer);source.setSystemId(configSource.getLocation();final DocumentBuilder documentBuilder=newDocumentBuilder(true);Documen
123、t document;try /解析 xml 配置文件 document=documentBuilder.parse(source);catch(final Exception e)/LOG4J2-1127 final Throwable throwable=Throwables.getRootCause(e);if(throwable instanceof UnsupportedOperationException)1042022年美团技术年货 LOGGER.warn(“The DocumentBuilder does not support an operation:.”+“Trying
124、again without XInclude.”,documentBuilder,e);document=newDocumentBuilder(false).parse(source);else throw e;rootElement=document.getDocumentElement();/处理根节点属性配置,即 节点 final Map attrs=processAttributes(rootNode,rootElement);/创建 StatusConfiguration final StatusConfiguration statusConfig=new StatusConfigu
125、ration().withVerboseClasses(VERBOSE_CLASSES).withStatus(getDefaultStatus();for(final Map.Entry entry:attrs.entrySet()final String key=entry.getKey();final String value=getStrSubstitutor().replace(entry.getValue();/根据配置文件中的 status 属性值,来设置 StatusConfiguration 的 status level if(“status”.equalsIgnoreCas
126、e(key)statusConfig.withStatus(value);/根据配置文件中的 dest 属性值,来设置 StatusConfiguration 的日志输出 destination else if(“dest”.equalsIgnoreCase(key)statusConfig.withDestination(value);else if(“shutdownHook”.equalsIgnoreCase(key)isShutdownHookEnabled=!”disable”.equalsIgnoreCase(value);else if(“verbose”.equalsIgnor
127、eCase(key)statusConfig.withVerbosity(value);else if(“packages”.equalsIgnoreCase(key)pluginPackages.addAll(Arrays.asList(value.split(Patterns.COMMA_SEPARATOR);else if(“name”.equalsIgnoreCase(key)setName(value);else if(“strict”.equalsIgnoreCase(key)strict=Boolean.parseBoolean(value);else if(“schema”.e
128、qualsIgnoreCase(key)schemaResource=value;else if(“monitorInterval”.equalsIgnoreCase(key)final int intervalSeconds=Integer.parseInt(value);if(intervalSeconds 0)getWatchManager().后端105setIntervalSeconds(intervalSeconds);if(configFile!=null)final FileWatcher watcher=new ConfiguratonFileWatcher(this,lis
129、teners);getWatchManager().watchFile(configFile,watcher);else if(“advertiser”.equalsIgnoreCase(key)createAdvertiser(value,configSource,buffer,“text/xml”);/初始化 StatusConfiguration statusConfig.initialize();catch(final SAXException|IOException|ParserConfigurationException e)LOGGER.error(“Error parsing“
130、+configSource.getLocation(),e);if(getName()=null)setName(configSource.getLocation();/忽略以下内容/org.apache.logging.log4j.core.config.status.StatusConfigurationprivate static final PrintStream DEFAULT_STREAM=System.out;private static final Level DEFAULT_STATUS=Level.ERROR;private static final Verbosity D
131、EFAULT_VERBOSITY=Verbosity.QUIET;private final Collection errorMessages=Collections.synchronizedCollection(new LinkedList();/StatusLoggerprivate final StatusLogger logger=StatusLogger.getLogger();private volatile boolean initialized=false;private PrintStream destination=DEFAULT_STREAM;private Level
132、status=DEFAULT_STATUS;private Verbosity verbosity=DEFAULT_VERBOSITY;public void initialize()if(!this.initialized)if(this.status=Level.OFF)this.initialized=true;else final boolean configured=1062022年美团技术年货configureExistingStatusConsoleListener();if(!configured)/注册新 StatusConsoleListener registerNewSt
133、atusConsoleListener();migrateSavedLogMessages();private boolean configureExistingStatusConsoleListener()boolean configured=false;for(final StatusListener statusListener:this.logger.getListeners()if(statusListener instanceof StatusConsoleListener)final StatusConsoleListener listener=(StatusConsoleLis
134、tener)statusListener;/StatusConsoleListener 的 level 以 StatusConfiguration 的 status 为准 listener.setLevel(this.status);this.logger.updateListenerLevel(this.status);if(this.verbosity=Verbosity.QUIET)listener.setFilters(this.verboseClasses);configured=true;return configured;private void registerNewStatu
135、sConsoleListener()/创建 StatusConsoleListener,级别以 StatusConfiguration 为准 /默认 status 是 DEFAULT_STATUS 即 ERROR /默认 destination 是 DEFAULT_STREAM 即 System.out final StatusConsoleListener listener=new StatusConsoleListener(this.status,this.destination);if(this.verbosity=Verbosity.QUIET)listener.setFilters(
136、this.verboseClasses);this.logger.registerListener(listener);/org.apache.logging.log4j.status.StatusConsoleListenerprivate Level level=Level.FATAL;/级别private String filters;private final PrintStream stream;/输出流public StatusConsoleListener(final Level level,final PrintStream 后端107stream)if(stream=null
137、)throw new IllegalArgumentException(“You must provide a stream to use for this listener.”);this.level=level;this.stream=stream;以 XmlConfiguration 为 例,分 析 上 述 日 志 配 置 解 析 代 码 片 段 可 以 得 知,创建 XmlConfiguration 时,会 先 创 建 StatusConfiguration,随 后 在 初 始 化StatusConfiguration 时创建并注册 StatusConsoleListener 到 St
138、atusLogger 的listeners 中,日志配置文件中 标签的属性值通过 XmlConfigura-tion-StatusConfiguration-StatusConsoleListener 这样的关系链路最终影响StatusConsoleListener 的行为。日志配置文件中的 标签可以配置属性字段,部分字段如下所示:status,可选值包括 OFF、FATAL、ERROR、WARN、INFO、DEBUG、TRACE、ALL,该值决定 StatusConsoleListener 级别,默认是 ERROR。dest,可选值包括 out、err、标准的 URI 路径,该值决定 Sta
139、tusCon-soleListener 输出流目的地,默认是 System.out。在本项目的日志配置文件中可以看到并没有设置 Configuration 的 dest 属性值,所以日志直接输出到 System.out。3.1.5StatusLogger 有什么用?上文提到 StatusConsoleListener 是注册在 StatusLogger 中,StatusLogger 在交由 StatusListener 处理日志前,会判断日志级别,如果级别条件不满足,则忽略此日志,StatusConsoleListener 的日志级别默认是 ERROR。/org.apache.logging.
140、log4j.status.StatusLogger Overridepublic void logMessage(final String fqcn,final Level level,final 1082022年美团技术年货Marker marker,final Message msg,final Throwable t)StackTraceElement element=null;if(fqcn!=null)element=getStackTraceElement(fqcn,Thread.currentThread().getStackTrace();final StatusData da
141、ta=new StatusData(element,level,msg,t,null);msgLock.lock();try messages.add(data);finally msgLock.unlock();/系统注册了 listener,由 StatusConsoleListener 处理日志 if(listeners.size()0)for(final StatusListener listener:listeners)/比较当前日志的 leve 和 listener 的 level if(data.getLevel().isMoreSpecificThan(listener.get
142、StatusLevel()listener.log(data);else logger.logMessage(fqcn,level,marker,msg,t);我们回头再来看下 StatusLogger,StatusLogger 采用单例模式实现,它输出日志到 Console(如 System.out 或 System.err),从上文分析可知,在高并发场景下非常容易导致线程 Block,那么它的存在有什么意义呢?看官方介绍大意是说,在日志初始化完成前,也有打印日志调试的需求,Sta-tusLogger 就是为了解决这个问题而生。Troubleshootingtipfortheimpatien
143、t:Fromlog4j-2.9onward,log4j2willprintallinternalloggingtotheconsoleifsystempropertylog4j2.debugisdefined(withanyornovalue).Priortolog4j-2.9,therearetwoplaceswhereinternalloggingcanbecontrolled:后端109Beforeaconfigurationisfound,statusloggerlevelcanbecontrolledwithsystempropertyorg.apache.logging.log4j
144、.simplelog.StatusLogger.level.Afteraconfigurationisfound,statusloggerlevelcanbecontrolledintheconfigurationfilewiththe“status”attribute,forexample:.Justasitisdesirabletobeabletodiagnoseproblemsinapplications,itisfrequentlynecessarytobeabletodiagnoseproblemsintheloggingconfigurationorintheconfiguredc
145、omponents.Sincelogginghasnotbeenconfigured,“normal”loggingcannotbeusedduringinitialization.Inaddition,normalloggingwithinappenderscouldcreateinfiniterecursionwhichLog4jwilldetectandcausetherecursiveeventstobeignored.Toaccomodatethisneed,theLog4j2APIincludesaStatusLogger.3.1.6问题小结日志量过大导致 AsyncAppende
146、r 日志队列被打满,新的日志事件无法入队,进而由 ErrorHandler 处理日志,同时由于 ErrorHandler 存在线程安全问题,导致大量日志输出到了 Console,而 Console 在输出日志到 PrintStream 输出流时,存在synchronized 同步代码块,所以在高并发场景下导致线程 Block。3.2AsyncAppender 导致线程 Block3.2.1问题现场收到“jvm.thread.blocked.count”告警后立刻通过监控平台查看线程监控指标,当时的线程堆栈如下图 6 和图 7 所示。1102022年美团技术年货图 6等待锁的 Blocked 线
147、程堆栈图 7持有锁的 Runnable 线程堆栈从 Blocked 线程堆栈不难看出是跟日志打印相关,由于是 ERROR 级别日志,查看具体报错日志,发现有两种业务异常,分别如下图 8 和图 9 所示:后端2022年美团技术年货图 10日志调用链路3.2.2为什么会 Block 线程?从 Blocked 线程堆栈中可以看出,线程阻塞在类加载流程上,查看 WebAppClass-Loader 相关代码片段如下图 11 所示,发现加载类时确实会根据类名来加 synchro-nized 同步块,因此初步猜测是类加载导致线程 Block。图 11WebAppClassLoader后端113但上述猜测还
148、有一些值得推敲的地方:1.项目代码里只是普通地输出一条 ERROR 日志而已,为何会触发类加载?2.通常情况下类加载几乎不会触发线程 Block,不然一个项目要加载成千上万个类,如果因为加载类就导致 Block,那项目就没法正常运行了。3.2.3为什么会触发类加载?继续从 Blocked 线程堆栈着手分析,查看堆栈中的 ThrowableProxy 相关代码,发现其构造函数会遍历整个异常堆栈中的所有堆栈元素,最终获取所有堆栈元素类所在的 JAR 名称和版本信息。具体流程如下:1.首先获取堆栈元素的类名称。2.再通过 loadClass 的方式获取对应的 Class 对象。3.进一步获取该类所在
149、的 JAR 信息,从 CodeSource 中获取 JAR 名称,从Package 中获取 JAR 版本。/org.apache.logging.log4j.core.impl.ThrowableProxy private ThrowableProxy(final Throwable throwable,final Set visited)this.throwable=throwable;this.name=throwable.getClass().getName();this.message=throwable.getMessage();this.localizedMessage=throw
150、able.getLocalizedMessage();final Map map=new HashMap();final StackClass stack=ReflectionUtil.getCurrentStackTrace();/获取堆栈扩展信息 this.extendedStackTrace=this.toExtendedStackTrace(stack,map,null,throwable.getStackTrace();final Throwable throwableCause=throwable.getCause();final Set causeVisited=new Hash
151、Set(1);this.causeProxy=throwableCause=null?null:new ThrowableProxy(throwable,stack,map,throwableCause,visited,causeVisited);this.suppressedProxies=this.toSuppressedProxies(throwable,visited);ExtendedStackTraceElement toExtendedStackTrace(final StackClass stack,final Map map,final 1142022年美团技术年货Stack
152、TraceElement rootTrace,final StackTraceElement stackTrace)int stackLength;if(rootTrace!=null)int rootIndex=rootTrace.length-1;int stackIndex=stackTrace.length-1;while(rootIndex=0&stackIndex=0&rootTracerootIndex.equals(stackTracestackIndex)-rootIndex;-stackIndex;monElementCount=stackTrace.length-1-st
153、ackIndex;stackLength=stackIndex+1;else monElementCount=0;stackLength=stackTrace.length;final ExtendedStackTraceElement extStackTrace=new ExtendedStackTraceElementstackLength;Class clazz=stack.isEmpty()?null:stack.peek();ClassLoader lastLoader=null;for(int i=stackLength-1;i=0;-i)/遍历 StackTraceElement
154、 final StackTraceElement stackTraceElement=stackTracei;/获取堆栈元素对应的类名称 final String className=stackTraceElement.getClassName();/The stack returned from getCurrentStack may be missing entries for java.lang.reflect.Method.invoke()/and its implementation.The Throwable might also contain stack entries tha
155、t are no longer /present as those methods have returned.ExtendedClassInfo extClassInfo;if(clazz!=null&className.equals(clazz.getName()final CacheEntry entry=this.toCacheEntry(stackTraceElement,clazz,true);extClassInfo=entry.element;lastLoader=entry.loader;stack.pop();clazz=stack.isEmpty()?null:stack
156、.peek();else /对加载过的 className 进行缓存,避免重复加载 final CacheEntry cacheEntry=map.get(className);if(cacheEntry!=null)final CacheEntry entry=cacheEntry;extClassInfo=entry.element;if(entry.loader!=null)lastLoader=entry.loader;后端115 else /通过加载类来获取类的扩展信息,如 location 和 version 等 final CacheEntry entry=this.toCach
157、eEntry(stackTraceElement,/获取 Class 对象 this.loadClass(lastLoader,className),false);extClassInfo=entry.element;map.put(stackTraceElement.toString(),entry);if(entry.loader!=null)lastLoader=entry.loader;extStackTracei=new ExtendedStackTraceElement(stackTraceElement,extClassInfo);return extStackTrace;/*C
158、onstruct the CacheEntry from the Class s information.*param stackTraceElement The stack trace element*param callerClass The Class.*param exact True if the class was obtained via Reflection.getCallerClass.*return The CacheEntry.*/private CacheEntry toCacheEntry(final StackTraceElement stackTraceEleme
159、nt,final Class callerClass,final boolean exact)String location=“?”;String version=“?”;ClassLoader lastLoader=null;if(callerClass!=null)try /获取 jar 文件信息 final CodeSource source=callerClass.getProtectionDomain().getCodeSource();if(source!=null)final URL locationURL=source.getLocation();if(locationURL!
160、=null)final String str=locationURL.toString().replace(,/);int index=str.lastIndexOf(“/”);if(index=0&index=str.length()-1)index=str.lastIndexOf(“/”,index-1);location=str.substring(index+1);else 1162022年美团技术年货 location=str.substring(index+1);catch(final Exception ex)/Ignore the exception./获取类所在 jar 版本
161、信息 final Package pkg=callerClass.getPackage();if(pkg!=null)final String ver=pkg.getImplementationVersion();if(ver!=null)version=ver;lastLoader=callerClass.getClassLoader();return new CacheEntry(new ExtendedClassInfo(exact,location,version),lastLoader);从上述代码中可以看到,ThrowableProxy#toExtendedStackTrace 方
162、法通过 Map缓存当前堆栈元素类对应的 CacheEntry,来避免重复解析 CacheEntry,但是由于Map 缓存 put 操作使用的 key 来自于 StackTraceElement.toString 方法,而 get操作使用的 key 却来自于 StackTraceElement.getClassName 方法,即使对于同一个 StackTraceElement 而言,其 toString 和 getClassName 方法对应的返回结果也不一样,所以此 map 形同虚设。/java.lang.StackTraceElement public String getClassName
163、()return declaringClass;public String toString()return getClassName()+“.”+methodName+(isNativeMethod()?“(Native Method)”:(fileName!=null&lineNumber=0?“(“+fileName+“:”+lineNumber+“)”:(fileName!=null?“(“+fileName+”)”:“(Unknown Source)”);该问题已有相关 Issue:fixtheCacheEntrymapinThrowableProxy#toExtend-后端2022
164、年美团技术年货图 13业务异常堆栈二对比如图 12 和图 13 所示的两份业务异常堆栈,我们可以看到两份堆栈基本相似,且大多数类都是很普通的类,但是唯一不同的地方在于:1.sun.reflect.NativeMethodAccessorImpl(参见图 12)。2.sun.reflect.GeneratedMethodAccessor261(参见图 13)。从字面信息中不难猜测出这与反射调用相关,但问题是这两份堆栈对应的其实是同一份业务代码,为什么会产生两份不同的异常堆栈?查阅相关资料得知,这与 JVM 反射调用相关,JVM 对反射调用分两种情况:1.默认使用 native 方法进行反射操作。
165、2.一定条件下会生成字节码进行反射操作,即生成 sun.reflect.Generated-MethodAccessor 类,它是一个反射调用方法的包装类,代理不同的方法,类后缀序号递增。JVM 反射调用的主要流程是获取 MethodAccessor,并由 MethodAccessor 执行invoke 调用,相关代码如下:/java.lang.reflect.Method CallerSensitivepublic Object invoke(Object obj,Object.args)throws IllegalAccessException,IllegalArgumentExcepti
166、on,InvocationTargetException if(!override)后端119 if(!Reflection.quickCheckMemberAccess(clazz,modifiers)Class caller=Reflection.getCallerClass();checkAccess(caller,clazz,obj,modifiers);MethodAccessor ma=methodAccessor;/read volatile if(ma=null)/获取 MethodAccessor ma=acquireMethodAccessor();/通过 MethodAc
167、cessor 调用 return ma.invoke(obj,args);private MethodAccessor acquireMethodAccessor()MethodAccessor tmp=null;if(root!=null)tmp=root.getMethodAccessor();if(tmp!=null)methodAccessor=tmp;else /通过 ReflectionFactory 创建 MethodAccessor tmp=reflectionFactory.newMethodAccessor(this);setMethodAccessor(tmp);retu
168、rn tmp;当 noInflation 为 false(默认为 false)或者反射方法所在类是 VM 匿名类(类名中包括斜杠“/”)的情况下,ReflectionFactory 会返回一个 MethodAccessor 代理类,即 DelegatingMethodAccessorImpl。/sun.reflect.ReflectionFactorypublic MethodAccessor newMethodAccessor(Method method)/通过启动参数获取并解析 noInflation 和 inflationThreshold 值 /noInflation 默认为 fals
169、e /inflationThreshold 默认为 15 checkInitted();if(noInflation&!ReflectUtil.isVMAnonymousClass(method.getDeclaringClass()return new MethodAccessorGenerator().generateMethod(method.getDeclaringClass(),method.getName(),1202022年美团技术年货 method.getParameterTypes(),method.getReturnType(),method.getExceptionTyp
170、es(),method.getModifiers();else NativeMethodAccessorImpl acc=new NativeMethodAccessorImpl(method);DelegatingMethodAccessorImpl res=new DelegatingMethodAccessorImpl(acc);acc.setParent(res);/返回代理 DelegatingMethodAccessorImpl return res;private static void checkInitted()if(initted)return;AccessControll
171、er.doPrivileged(new PrivilegedAction()public Void run()/Tests to ensure the system properties table is fully /initialized.This is needed because reflection code is /called very early in the initialization process(before /command-line arguments have been parsed and therefore /these user-settable prop
172、erties installed.)We assume that /if System.out is non-null then the System class has been /fully initialized and that the bulk of the startup code /has been run.if(System.out=null)/java.lang.System not yet fully initialized return null;String val=System.getProperty(“sun.reflect.noInflation”);if(val
173、!=null&val.equals(“true”)noInflation=true;val=System.getProperty(“sun.reflect.inflationThreshold”);if(val!=null)try inflationThreshold=Integer.parseInt(val);catch(NumberFormatException e)throw new RuntimeException(“Unable to parse property sun.reflect.inflationThreshold”,e);后端2022年美团技术年货class Delega
174、tingMethodAccessorImpl extends MethodAccessorImpl /内部代理 MethodAccessorImpl private MethodAccessorImpl delegate;DelegatingMethodAccessorImpl(MethodAccessorImpl delegate)setDelegate(delegate);public Object invoke(Object obj,Object args)throws IllegalArgumentException,InvocationTargetException return d
175、elegate.invoke(obj,args);void setDelegate(MethodAccessorImpl delegate)this.delegate=delegate;/sun.reflect.NativeMethodAccessorImplclass NativeMethodAccessorImpl extends MethodAccessorImpl private final Method method;private DelegatingMethodAccessorImpl parent;private int numInvocations;NativeMethodA
176、ccessorImpl(Method method)this.method=method;public Object invoke(Object obj,Object args)throws IllegalArgumentException,InvocationTargetException /We can t inflate methods belonging to vm-anonymous classes because /that kind of class can t be referred to by name,hence can t be /found from the gener
177、ated bytecode./每次调用时 numInvocations 都会自增加 1,如果超过阈值(默认是15 次),就会修改父类的代理对象,从而改变调用链路 if(+numInvocations ReflectionFactory.inflationThreshold()&!ReflectUtil.isVMAnonymousClass(method.getDeclaringClass()MethodAccessorImpl acc=(MethodAccessorImpl)/动态生成字节码,优化反射调用速度 new MethodAccessorGenerator().generateMeth
178、od(method.getDeclaringClass(),method.getName(),后端123 method.getParameterTypes(),method.getReturnType(),method.getExceptionTypes(),method.getModifiers();/修改父代理类的代理对象 parent.setDelegate(acc);return invoke0(method,obj,args);void setParent(DelegatingMethodAccessorImpl parent)this.parent=parent;private s
179、tatic native Object invoke0(Method m,Object obj,Object args);从 MethodAccessorGenerator#generateName 方法可以看到,字节码生成的类名称规则是 sun.reflect.GeneratedConstructorAccessor,其中 N 是从 0 开始的递增数字,且生成类是由 DelegatingClassLoader 类加载器定义,所以其他类加载器无法加载该类,也就无法生成类缓存数据,从而导致每次加载类时都需要遍历JarFile,极大地降低了类查找速度,且类加载过程是 synchronized 同步
180、调用,在高并发情况下会更加恶化,从而导致线程 Block。/sun.reflect.MethodAccessorGeneratorpublic MethodAccessor generateMethod(Class declaringClass,String name,Class parameterTypes,Class returnType,Class checkedExceptions,int modifiers)return(MethodAccessor)generate(declaringClass,name,parameterTypes,returnType,checkedExcep
181、tions,modifiers,false,false,1242022年美团技术年货 null);private MagicAccessorImpl generate(final Class declaringClass,String name,Class parameterTypes,Class returnType,Class checkedExceptions,int modifiers,boolean isConstructor,boolean forSerialization,Class serializationTargetClass)final String generatedN
182、ame=generateName(isConstructor,forSerialization);/忽略以上代码 return AccessController.doPrivileged(new PrivilegedAction()public MagicAccessorImpl run()try return(MagicAccessorImpl)ClassDefiner.defineClass (generatedName,bytes,0,bytes.length,declaringClass.getClassLoader().newInstance();catch(Instantiatio
183、nException|IllegalAccessException e)throw new InternalError(e););/生成反射类名,看到了熟悉的 sun.reflect.GeneratedConstructorAccessorprivate static synchronized String generateName(boolean isConstructor,boolean forSerialization)if(isConstructor)if(forSerialization)int num=+serializationConstructorSymnum;return“s
184、un/reflect/GeneratedSerializationConstructorAccessor”+num;else 后端125 int num=+constructorSymnum;return“sun/reflect/GeneratedConstructorAccessor”+num;else int num=+methodSymnum;return“sun/reflect/GeneratedMethodAccessor”+num;/sun.reflect.ClassDefiner static Class defineClass(String name,byte bytes,in
185、t off,int len,final ClassLoader parentClassLoader)ClassLoader newLoader=AccessController.doPrivileged(new PrivilegedAction()public ClassLoader run()return new DelegatingClassLoader(parentClassLoader););/通过 DelegatingClassLoader 类加载器定义生成类 return unsafe.defineClass(name,bytes,off,len,newLoader,null);那
186、么,JVM 反射调用为什么要做这么做?其实这是 JVM 对反射调用的一种优化手段,在 sun.reflect.ReflectionFactory 的文档注释里对此做了解释,这是一种“Inflation”机制,加载字节码的调用方式在第一次调用时会比 Native 调用的速度要慢 34 倍,但是在后续调用时会比 Native 调用速度快 20 多倍。为了避免反射调用影响应用的启动速度,所以在反射调用的前几次通过 Native 方式调用,当超过一定调用次数后使用字节码方式调用,提升反射调用性能。“Inflation”mechanism.LoadingbytecodestoimplementMetho
187、d.invoke()andConstructor.newInstance()currentlycosts3-4xmorethananinvocationvianativecodeforthefirstinvocation(thoughsubsequentinvocationshavebeenbenchmarkedtobeover20 xfaster).Unfortu-natelythiscostincreasesstartuptimeforcertainapplicationsthatusereflectionintensively(butonlyonceperclass)tobootstra
188、pthemselves.1262022年美团技术年货ToavoidthispenaltywereusetheexistingJVMentrypointsforthefirstfewinvocationsofMethodsandConstructorsandthenswitchtothebytecode-basedimplementations.至此,总算理清了类加载导致线程 Block 的直接原因,但这并非根因,业务代码中普普通通地打印一条 ERROR 日志,为何会导致解析、加载异常堆栈类?3.2.5为什么要解析异常堆栈?图 15AsyncAppender 处理日志流程AsyncAppende
189、r 处 理 日 志 简 要 流 程 如 上 图 15 所 示,在 其 内 部 维 护 一 个BlockingQueue 队列和一个 AsyncThread 线程,处理日志时先把日志转换成Log4jLogEvent 快照然后入队,同时 AsyncThread 线程负责从队列里获取元素来异步处理日志事件。/org.apache.logging.log4j.core.appender.AsyncAppenderOverridepublic void append(final LogEvent logEvent)if(!isStarted()throw new IllegalStateExceptio
190、n(“AsyncAppender“+getName()+“is not active”);后端2022年美团技术年货/生成 Log4jLogEvent 快照public static Log4jLogEvent createMemento(final LogEvent event,final boolean includeLocation)/TODO implement Log4jLogEvent.createMemento()return deserialize(serialize(event,includeLocation);public static Serializable seria
191、lize(final LogEvent event,final boolean includeLocation)if(event instanceof Log4jLogEvent)/确保 ThrowableProxy 已完成初始化 event.getThrownProxy();/ensure ThrowableProxy is initialized /创建 LogEventProxy return new LogEventProxy(Log4jLogEvent)event,includeLocation);/创建 LogEventProxy return new LogEventProxy(
192、event,includeLocation);Overridepublic ThrowableProxy getThrownProxy()if(thrownProxy=null&thrown!=null)thrownProxy=new ThrowableProxy(thrown);return thrownProxy;public LogEventProxy(final LogEvent event,final boolean includeLocation)this.loggerFQCN=event.getLoggerFqcn();this.marker=event.getMarker();
193、this.level=event.getLevel();this.loggerName=event.getLoggerName();final Message msg=event.getMessage();this.message=msg instanceof ReusableMessage?memento(ReusableMessage)msg):msg;this.timeMillis=event.getTimeMillis();this.thrown=event.getThrown();/创建 ThrownProxy 实例 this.thrownProxy=event.getThrownP
194、roxy();this.contextData=memento(event.getContextData();this.contextStack=event.getContextStack();this.source=includeLocation?event.getSource():null;this.threadId=event.getThreadId();this.threadName=event.getThreadName();this.threadPriority=event.getThreadPriority();this.isLocationRequired=includeLoc
195、ation;后端2022年美团技术年货图 16等待锁的 Blocked 线程堆栈后端2022年美团技术年货本案例的 Blocked 线程堆栈和上述“AsyncAppender 导致线程 Block”案例一样,那么导致线程 Block 的罪魁祸首会是业务异常吗?接下来本章节将结合下图 19 所示的调用链路深入分析线程 Block 的根因。图 19日志调用链路3.3.2为什么会 Block 线程?从 Blocked 线程堆栈中可以看出,线程阻塞在类加载上,该线程堆栈和上述“Asyn-cAppender 导致线程 Block”案例相似,这里不再重复分析。3.3.3为什么会触发类加载?原因和上述“As
196、yncAppender 导致线程 Block”案例相似,这里不再重复分析。3.3.4到底什么类加载不了?上述“AsyncAppender 导致线程 Block”案例中,类加载器无法加载由 JVM 针对反射调用优化所生成的字节码类,本案例是否也是该原因导致,还待进一步具体分析。要找到具体是什么类无法加载,归根结底还是要分析业务异常的具体堆栈。从业务堆栈中可以明显看出来,没有任何堆栈元素和 JVM 反射调用相关,因此排除 JVM 反后端2022年美团技术年货NoClassDefFounderrorintransforminglambdasJVMTIRedefineClassesdoesn than
197、dleanonymousclassesproperly值得一提的是,该 Bug 在 JDK9 版本已经修复,实际测试中发现,在 JDK8 的高版本如 8U171 等已修复该 Bug,异常堆栈中不会有类似$Lambda$的堆栈信息,示例如下图 21 所示:图 21JDK8U171 版本下 Lambda 异常堆栈示例3.3.5为什么要解析异常堆栈?原因和上述“AsyncAppender 导致线程 Block”案例相似,不再重复分析。3.3.6问题小结Log4j2 打印异常日志时,AsyncAppender 会先创建日志事件快照,并进一步触发解析、加载异常堆栈类。JDK8 低版本中使用 Lambda
198、 表达式所生成的异常堆栈类无法被 WebAppClassLoader 类加载器加载,因此,当大量包含 Lambda 表达式调用的异常堆栈被输出到日志时,会频繁地触发类加载,由于类加载过程是synchronized 同步加锁的,且每次加载都需要读取文件,速度较慢,从而导致了线程 Block。后端2022年美团技术年货图 23持有锁的 Runnable 线程堆栈从 Blocked 线程堆栈不难看出是和日志打印相关,本案例的业务异常和上述“Asyn-cAppender 导致线程 Block”的业务异常一样,这里不再重复介绍。那么,到底是什么原因导致线程 Block 呢?接下来本章节将结合下图 24
199、所示的调用链路深入分析线程 Block 的根因。图 24日志调用链路后端1373.4.2为什么会 Block 线程?原因和上述“AsyncAppender 导致线程 Block”案例相似,这里不再重复分析。3.4.3为什么会触发类加载?原因和上述“AsyncAppender 导致线程 Block”案例相似,这里不再重复分析。3.4.4到底是什么类加载不了?原因和上述“AsyncAppender 导致线程 Block”案例相似,这里不再重复分析。3.4.5为什么要解析异常堆栈?在开始分析原因之前,先理清楚 Log4j2 关于日志的几个重要概念:,日志配置标签,用于 XML 日志配置文件中,对应
200、Log4j2 框架中的 LoggerConfig 类,同步分发日志事件到对应 Appender。,日志配置标签,用于 XML 日志配置文件中,对应 Log4j2框架中的 AsyncLoggerConfig 类,内部使用 Disruptor 队列异步分发日志事件到对应 Appender。Logger,同步日志类,用于创建同步日志实例,同步调用 ReliabilityStrategy处理日志。AsyncLogger,异步日志类,用于创建异步日志实例,内部使用 Disruptor 队列实现异步调用 ReliabilityStrategy 处理日志。总的来说,标签和 Logger 类是完全不同的两个概
201、念,标签和 AsyncLogger 类也是完全不同的两个概念,不可混淆。由于项目并未配置 Log4jContextSelector 参数,所以使用的是同步 Logger,即通过 LoggerFactory.getLogger 方法获取的是 Logger 类实例而不是 AsyncLogger类实例,同时由于项目的 log4j2.xml 配置文件里配置了 标签,所以其底层是 Logger 和 AsyncLoggerConfig 组合。AsyncLoggerConfig 处理日志事件简要流程如下图 25 所示,内部使用 Disruptor1382022年美团技术年货队列,在生成队列元素时,由 tra
202、nslator 来负责填充元素字段,并把填充后的元素放入 RingBuffer 中,于此同时,独立的异步线程从 RingBuffer 中消费事件,并调用配置在该 AsyncLoggerConfig 上的 Appender 处理日志请求。图 25AsyncLoggerConfig 处理流程AsyncLoggerConfig 提 供 了 带 有 Disruptor 队 列 实 现 的 代 理 类 即 AsyncLog-gerConfigDisruptor,在 日 志 事 件 进 入 RingBuffer 时,由 于 项 目 使 用 的 是ReusableLogEventFactory,所以由 MU
203、TABLE_TRANSLATOR 负责初始化日志事件,在此过程中会调用 getThrownProxy 方法创建 ThrowableProxy 实例,进而在 ThrowableProxy 构造函数内部触发解析、加载异常堆栈类。/org.apache.logging.log4j.core.async.AsyncLoggerConfigDisruptor$EventTranslatorTwoArg/*Object responsible for passing on data to a RingBuffer event with a MutableLogEvent.*/private static
204、final EventTranslatorTwoArg MUTABLE_TRANSLATOR=new EventTranslatorTwoArg()Override public void translateTo(final Log4jEventWrapper ringBufferElement,final long sequence,final LogEvent logEvent,final AsyncLoggerConfig loggerConfig)/初始化 Disruptor RingBuffer 日志元素字段 (MutableLogEvent)ringBufferElement.ev
205、ent).initFrom(logEvent);ringBufferElement.loggerConfig=loggerConfig;后端2022年美团技术年货因此当大量包含反射调用的异常堆栈被输出到日志时,会频繁地触发类加载,由于类加载过程是 synchronized 同步加锁的,且每次加载都需要读取文件,速度较慢,从而导致线程 Block。4.避坑指南本章节主要对上述案例中导致线程 Block 的原因进行汇总分析,并给出相应的解决方案。4.1问题总结图 26日志异步处理流程日志异步处理流程示意如图 26 所示,整体步骤如下:1.业务线程组装日志事件对象,如创建日志快照或者初始化日志字段等
206、。2.日志事件对象入队,如 BlockingQueue 队列或 DisruptorRingBuffer 队列等。3.日志异步线程从队列获取日志事件对象,并输出至目的地,如本地磁盘文件或远程日志中心等。对应地,Log4j2 导致线程 Block 的主要潜在风险点如下:1.如上图标号所示,日志事件对象在入队前,组装日志事件时触发了异常堆栈类解析、加载,从而引发线程 Block。2.如上图标号所示,日志事件对象在入队时,由于队列满,无法入队,从而引发线程 Block。后端2022年美团技术年货2.日志配置文件中不使用 标签,可以使用 标签来代替。下面具体分析方案可行性:1.日志事件入队前避免触发异常
207、堆栈类解析、加载操作如果在日志事件入队前,能避免异常堆栈类解析、加载操作,则可从根本上解决该问题,但在 Log4j2 的 2.17.1 版本中 AsyncAppender 和 AsyncLoggerConfig 仍存在该问题,此时:对于 AsyncAppender 场景来说,可以通过自定义 Appender 实现,在生成日志事件快照时避免触发解析、加载异常堆栈类,并在配置文件中使用自定义的 Appender 代替 Log4j2 提供的 AsyncAppender。自定义 AsyncScribe-Appender 相关代码片段如下。/org.apache.logging.log4j.scribe
208、.appender.AsyncScribeAppenderOverridepublic void append(final LogEvent logEvent)/.以上部分忽略.Log4jLogEvent.Builder builder=new Log4jLogEvent.Builder(event);builder.setIncludeLocation(includeLocation);/创建日志快照,避免解析、加载异常堆栈类 final Log4jLogEvent memento=builder.build();/.以下部分忽略.对于 AsyncLoggerConfig 场景来说,可以考虑
209、使用非 ReusableLogEvent-Factory 类型的 LogEventFactory 来规避该问题,除此之外也可以考虑换用LoggerConfig 来避免该问题。2.禁用 JVM 反射调用优化调大 inflationThreshold(其类型为int)值到 int 最大值,如此,虽然一定范围内(反射调用次数不超过 int 最大值时)避免了类加载 Block 问题,但损失了反射调用性后端2022年美团技术年货/org.apache.logging.log4j.scribe.appender.AsyncScribeAppenderOverridepublic void append(f
210、inal LogEvent logEvent)/.以上部分忽略.if(!transfer(memento)if(blocking)/delegate to the event router(which may discard,enqueue and block,or log in current thread)final EventRouteAsyncScribe route=asyncScribeQueueFullPolicy.getRoute(processingThread.getId(),memento.getLevel();route.logMessage(this,memento)
211、;else /自定义 printDebugInfo 参数,控制是否输出 error 信息,默认为 false if(printDebugInfo)error(“Appender“+getName()+“is unable to write primary appenders.queue is full”);logToErrorAppenderIfNecessary(false,memento);/.以下部分忽略.2.Appender 使用自定义的 ErrorHandler 实现处理日志自定义 ErrorHandler,Appender 内设置 handler 为自定义 ErrorHandler
212、 实例即可,但该方式仅适用于通过 Log4j2API 方式创建的 Logger,不支持日志配置文件的使用方式。由于大多数用户都使用配置文件方式,所以该方案使用场景有限,不过可以期待后续日志框架支持配置文件自定义 ErrorHandler,已有相关 Issue:ErrorHandlersonAppenderscannotbeconfigured 反馈给社区。3.关闭 StatusConfigListener 日志输出配置文件中设置 Configuration 的 status 属性值为 off,则不会创建 Status-ConfigListener,但此时 StatusLogger 会调用 Si
213、mpleLogger 来输出日志到System.err,仍不解决问题。配置文件中设置 Configuration 的 status 属性值为 fatal,则只有 fatal 级别的日志才会输出,普通的 error 日志直接忽略,但 fatal 条件过于严苛,可能会后端PatternFormatter-LogEventPattern-Converter。其中 LogEventPatternConverter 是个抽象类,有两个处理异常的格式化转换具体实现类,分别是 ThrowablePatternConverter 和 ExtendedThrow-ablePatternConverter。/or
214、g.apache.logging.log4j.core.layout.PatternLayout/将 LogEvent 转换为可以输出的 StringOverridepublic String toSerializable(final LogEvent event)/由 PatternSerializer 对日志事件格式化处理 return eventSerializer.toSerializable(event);/org.apache.logging.log4j.core.layout.PatternLayout.PatternSerializerOverridepublic String
215、 toSerializable(final LogEvent event)final StringBuilder sb=getStringBuilder();try return toSerializable(event,sb).toString();finally trimToMaxSize(sb);Overridepublic StringBuilder toSerializable(final LogEvent event,final StringBuilder buffer)final int len=formatters.length;1462022年美团技术年货 for(int i
216、=0;i len;i+)/由 PatternFormatter 对日志事件格式化处理 formattersi.format(event,buffer);if(replace!=null)/creates temporary objects String str=buffer.toString();str=replace.format(str);buffer.setLength(0);buffer.append(str);return buffer;/org.apache.logging.log4j.core.pattern.PatternFormatterpublic void format(
217、final LogEvent event,final StringBuilder buf)if(skipFormattingInfo)/由 LogEventPatternConverter 对日志事件进行格式化处理 converter.format(event,buf);else formatWithInfo(event,buf);private void formatWithInfo(final LogEvent event,final StringBuilder buf)final int startField=buf.length();/由 LogEventPatternConverte
218、r 对日志事件进行格式化处理 converter.format(event,buf);field.format(startField,buf);/org.apache.logging.log4j.core.pattern.LogEventPatternConverterpublic abstract class LogEventPatternConverter extends AbstractPatternConverter /*将日志事件 LogEvent 转换为 String *Formats an event into a string buffer.*param event event
219、 to format,may not be null.*param toAppendTo string buffer to which the formatted event will be appended.May not be null.*/public abstract void format(final LogEvent event,final StringBuilder toAppendTo);后端2022年美团技术年货 StackTraceElement trace;StackTraceElement throwingMethod=null;int len;if(t!=null)t
220、race=t.getStackTrace();if(trace!=null&trace.length 0)throwingMethod=trace0;if(t!=null&throwingMethod!=null)String toAppend=Strings.EMPTY;if(ThrowableFormatOptions.CLASS_NAME.equalsIgnoreCase(rawOption)toAppend=throwingMethod.getClassName();else if(ThrowableFormatOptions.METHOD_NAME.equalsIgnoreCase(
221、rawOption)toAppend=throwingMethod.getMethodName();else if(ThrowableFormatOptions.LINE_NUMBER.equalsIgnoreCase(rawOption)toAppend=String.valueOf(throwingMethod.getLineNumber();else if(ThrowableFormatOptions.MESSAGE.equalsIgnoreCase(rawOption)toAppend=t.getMessage();else if(ThrowableFormatOptions.LOCA
222、LIZED_MESSAGE.equalsIgnoreCase(rawOption)toAppend=t.getLocalizedMessage();else if(ThrowableFormatOptions.FILE_NAME.equalsIgnoreCase(rawOption)toAppend=throwingMethod.getFileName();len=buffer.length();if(len 0&!Character.isWhitespace(buffer.charAt(len-1)buffer.append();buffer.append(toAppend);if(Stri
223、ngs.isNotBlank(suffix)buffer.append();buffer.append(suffix);后端 0&!Character.isWhitespace(buffer.charAt(len-1)buffer.append();if(!options.allLines()|!Strings.LINE_SEPARATOR.equals(options.getSeparator()|Strings.isNotBlank(suffix)final StringBuilder sb=new StringBuilder();final String array=w.toString
224、().split(Strings.LINE_SEPARATOR);final int limit=options.minLines(array.length)-1;final boolean suffixNotBlank=Strings.isNotBlank(suffix);for(int i=0;i=limit;+i)sb.append(arrayi);if(suffixNotBlank)sb.append();sb.append(suffix);if(i 2022年美团技术年货 for(int i=0,size=formatters.size();i 0&!Character.isWhit
225、espace(toAppendTo.charAt(len 后端151-1)toAppendTo.append();toAppendTo.append(extStackTrace);5.最佳实践本章节主要结合项目在日志使用方面的一系列踩坑经历和实践经验,总结了一份关于日志配置的最佳实践,供大家参考。1.建议日志配置文件中对所有 Appender 的 PatternLayout 都增加%ex 配置,因为如果没有显式配置%ex,则异常格式化输出的默认配置是%xEx,此时会打印异常的扩展信息(JAR 名称和版本),可能导致业务线程 Block。2.不建议日志配置文件中使用 AsyncAppender,
226、建议自定义 Appender 实现,因为 AsyncAppender 是日志框架默认提供的,目前最新版本中仍然存在日志事件入队前就触发加载异常堆栈类的问题,可能导致业务线程 Block。3.不建议生产环境使用 ConsoleAppender,因为输出日志到 Console 时有synchronized 同步操作,高并发场景下非常容易导致业务线程 Block。4.不建议在配置文件中使用 标签,因为日志事件元素在入队前就会触发加载异常堆栈类,可能导致业务线程 Block。如果希望使用Log4j2 提供的异步日志 AsyncLogger,建议配置 Log4jContextSelec-tor=org.
227、apache.logging.log4j.core.async.AsyncLoggerContextSelec-tor 参数,开启异步日志。下面提供一份 log4j2.xml 配置示例:1522022年美团技术年货 !-data_update_test_lc-6.作者简介志洋、陈超、李敏、凯晖、殷琦等,均来自美团基础技术部-应用中间件团队。后端2022年美团技术年货基于 AI 算法的数据库异常监测系统的设计与实现作者:曹臻威远1.背景数据库被广泛用于美团的核心业务场景上,对稳定性要求较高,对异常容忍度非常低。因此,快速的数据库异常发现、定位和止损就变得越来越重要。针对异常监测的问题,传统的固定
228、阈值告警方式,需要依赖专家经验进行规则配置,不能根据不同业务场景灵活动态调整阈值,容易让小问题演变成大故障。而基于 AI 的数据库异常发现能力,可以基于数据库历史表现情况,对关键指标进行7*24 小时巡检,能够在异常萌芽状态就发现风险,更早地将异常暴露,辅助研发人员在问题恶化前进行定位和止损。基于以上这些因素的考量,美团数据库平台研发组决定开发一套数据库异常检测服务系统。接下来,本文将会从特征分析、算法选型、模型训练与实时检测等几个维度阐述我们的一些思考和实践。2.特征分析2.1找出数据的变化规律在具体进行开发编码前,有一项非常重要的工作,就是从已有的历史监控指标中,发现时序数据的变化规律,从
229、而根据数据分布的特点选取合适的算法。以下是我们从历史数据中选取的一些具有代表性的指标分布图:后端2022年美团技术年货峰,取横坐标的间隔为周期(如果该周期点对应的自相关值小于给定阈值,则认为无显著周期性)。具体过程如下:图 2周期提取流程示意2.1.2漂移性变化对于待建模的序列,通常要求它不存在明显的长期趋势或是存在全局漂移的现象,否则生成的模型通常无法很好地适应指标的最新走势2。我们将时间序列随着时间的变化出现均值的显著变化或是存在全局突变点的情况,统称为漂移的场景。为了能够准确地捕捉时间序列的最新走势,我们需要在建模前期判断历史数据中是否存在漂移的现象。全局漂移和周期性序列均值漂移,如下示
230、例所示:后端2022年美团技术年货增(递减)序列。b.如果序列存在严格递增或是严格递减的性质,则指标明显存在长期趋势,此时可提前终止。3.遍历平滑序列,利用如下两个规则来判断是否存在漂移的现象a.当前样本点左边序列的最大值小于当前样本点右边序列的最小值,则存在突增漂移(上涨趋势)。b.当前样本点左边序列的最小值大于当前样本点右边序列的最大值,则存在突降漂移(下跌趋势)。2.1.3平稳性变化对于一个时序指标,如果其在任意时刻,它的性质不随观测时间的变化而变化,我们认为这条时序是具备平稳性的。因此,对于具有长期趋势成分亦或是周期性成分的时间序列而言,它们都是不平稳的。具体示例如下图所示:图 4数据
231、平稳示意后端2022年美团技术年货我们没有选择 3Sigma 的主要原因是它对异常容忍度较低,而绝对中位差从理论上而言具有更好的异常容忍度,所以在数据呈现高对称分布时,通过绝对中位差(MAD)替代 3Sigma 进行检测。我们对不同数据的分布分别采用了不同的检测算法(关于不同算法的原理可以参考文末附录的部分,这里不做过多的阐述):1.低偏态高对称分布:绝对中位差(MAD)2.中等偏态分布:箱形图(Boxplot)3.高偏态分布:极值理论(EVT)有了如上的分析,我们可以得出具体的根据样本输出模型的流程:图 6算法建模流程算法的整体建模流程如上图所示,主要涵盖以下几个分支环节:时序漂移检测、时序
232、平稳性分析、时序周期性分析和偏度计算。下面分别进行介绍:后端t。2.时序平稳性分析。如果输入时序 S 满足平稳性检验,则直接通过箱形图(默认)或是绝对中位差的方式来进行建模。3.时序周期性分析。存在周期性的情况下,将周期跨度记为 T,将输入时序 S根据跨度 T 进行切割,针对各个时间索引 j 0,1,T1 所组成的数据桶进行建模流程。不存在周期性的情况下,针对全部输入时序 S 作为数据桶进行建模流程。案例:给定一条时间序列 ts=t0,t1,tn,假定其存在周期性且周期跨度为 T,对于时间索引 j 而言,其中 j 0,1,T1,对其建模所需要的样本点由区间 tjkTm,tjkT+m 构成,其中
233、 m 为参数,代表窗口大小,k 为整数,满足 jkTm 0,jkT+m n。举例来说,假设给定时序自 2022/03/0100:00:00 至 2022/03/0800:00:00 止,给定窗口大小为 5,周期跨度为一天,那么对于时间索引 30 而言,对其建模所需要的样本点将来自于如下时间段:03/0100:25:00,03/0100:35:0003/0200:25:00,03/0200:35:0003/0700:25:00,03/0700:35:001.偏度计算。时序指标转化为概率分布图,计算分布的偏度,若偏度的绝对值超过阈值,则通过极值理论进行建模输出阈值。若偏度的绝对值小于阈值,则通过箱
234、形图或是绝对中位差的方式进行建模输出阈值。1622022年美团技术年货3.2案例样本建模这里选取了一个案例,展示数据分析及建模过程,便于更清晰的理解上述过程。其中图(a)为原始序列,图(b)为按照天的跨度进行折叠的序列,图 为图(b)中某时间索引区间内的样本经过放大后的趋势表现,图(d)中黑色曲线为图 中时间索引所对应的下阈值。如下是针对某时序的历史样本进行建模的案例:图 7建模案例上图 区域内的样本分布直方图以及阈值(已剔除其中部分异常样本),可以看到,在该高偏分布的场景中,EVT 算法计算的阈值更为合理。后端2022年美团技术年货以下是具体的离线训练和在线检测技术设计:图 9离线训练和在线
235、检测技术设计4.2异常检测过程异常检测算法整体采用分治思想,在模型训练阶段,根据历史数据识别提取特征,选定合适的检测算法。这里分为离线训练和在线检测两部分,离线主要根据历史情况进行数据预处理、时序分类和时序建模。在线主要加载运用离线训练的模型进行在线实时异常检测。具体设计如下图所示:后端2022年美团技术年货图 11运营流程目前,异常检测算法指标如下:精准率:随机选择一部分检测出异常的案例,人工校验其中确实是异常的比例,为 81%。召回率:根据故障、告警等来源,审查对应实例各指标异常情况,对照监测结果计算召回率,为 82%。F1-score:精准率和召回率的调和平均数,为 81%。6.未来展望
236、目前,美团数据库异常监测能力已基本构建完成,后续我们将对产品继续进行优化和拓展,具体方向包括:1.具有异常类型识别能力。可以检测出异常的类型,如均值变化、波动变化、尖刺等,支持按异常类型进行告警订阅,并作为特征输入后续诊断系统,完善数据库自治生态4。2.构建 Human-in-Loop 环境。支持根据反馈标注自动学习,保障模型持续优化5。后端2022年美团技术年货图 12箱线图将 Q1与 Q3之间的间距称为 IQR,当样本偏离上四分位 1.5 倍的 IQR(或是偏离下四分位数 1.5 倍的 IQR)的情况下,将样本视为是一个离群点。不同于基于正态假设的三倍标准差,通常情况下,箱形图对于样本的潜
237、在数据分布没有任何假定,能够描述出样本的离散情况,且对样本中包含的潜在异常样本有较高的容忍度。对于有偏数据,Boxplot 进行校准后建模更加符合数据分布7。7.3极值理论真实世界的数据很难用一种已知的分布来概括,例如对于某些极端事件(异常),概率模型(例如高斯分布)往往会给出其概率为 0。极值理论8是在不基于原始数据的任何分布假设下,通过推断我们可能会观察到的极端事件的分布,这就是极值分布(EVD)。其数学表达式如下(互补累积分布函数公式):其中 t 代表样本的经验阈值,对于不同场景可以设置不同取值,分别是广义帕累托分布中的形状参数与尺度参数,在给定样本超过人为设定的经验阈值 t 的情况下,
238、随机变量 X-t 是服从广义帕累托分布的。通过极大似然估计方法我们可以计算获得参后端0 的样本数量。由于通常情况下对于经验阈值 t 的估计没有先验的信息,因此可以使用样本经验分位数来替代数值 t,这里经验分位数的取值可以根据实际情况来选择。8.参考资料1Ren,H.,Xu,B.,Wang,Y.,Yi,C.,Huang,C.,Kou,X.,&Zhang,Q.(2019,July).Time-seriesanomalydetectionserviceatmicrosoft.InProceedingsofthe25thACMSIGKDDinternationalconferenceonknowled
239、gediscovery&datamining(pp.3009-3017).2Lu,J.,Liu,A.,Dong,F.,Gu,F.,Gama,J.,&Zhang,G.(2018).Learningunderconceptdrift:Areview.IEEETransactionsonKnowledgeandDataEngineering,31(12),2346-2363.3Mushtaq,R.(2011).Augmenteddickeyfullertest.4Ma,M.,Yin,Z.,Zhang,S.,Wang,S.,Zheng,C.,Jiang,X.,&Pei,D.(2020).Diagnos
240、ingrootcausesofintermittentslowqueriesinclouddatabases.ProceedingsoftheVLDBEndowment,13(8),1176-1189.5Holzinger,A.(2016).Interactivemachinelearningforhealthinformatics:whendoweneedthehuman-in-the-loop?.BrainInformatics,3(2),119-131.6Leys,C.,Ley,C.,Klein,O.,Bernard,P.,&Licata,L.(2013).Detectingoutlie
241、rs:Donotusestandarddeviationaroundthemean,useabsolutedeviationaroundthemedian.Journalofexperimentalsocialpsychology,49(4),764-766.7Hubert,M.,&Vandervieren,E.(2008).Anadjustedboxplotforskeweddistributions.Computationalstatistics&dataanalysis,52(12),5186-5201.8Siffer,A.,Fouque,P.A.,Termier,A.,&Largoue
242、t,C.(2017,August).Anomalydetectioninstreamswithextremevaluetheory.InProceedingsofthe23rdACMSIGKDDInternationalConferenceonKnowledgeDiscoveryandDataMining(pp.1067-1075).1702022年美团技术年货关于团队美团基础技术部/数据库研发中心/数据库平台研发组,负责为美团各个业务线提供高效便捷的数据库使用入口,帮助美团 DBA 稳定快捷地维护数据库,同时提供分析诊断平台,实现数据库自治。后端2022年美团技术年货图 1常见数据分布复制的
243、目标需要保证若干个副本上的数据是一致的,这里的“一致”是一个十分不确定的词,既可以是不同副本上的数据在任何时刻都保持完全一致,也可以是不同客户端不同时刻访问到的数据保持一致。一致性的强弱也会不同,有可能需要任何时候不同客端都能访问到相同的新的数据,也有可能是不同客户端某一时刻访问的数据不相同,但在一段时间后可以访问到相同的数据。因此,“一致性”是一个值得单独抽出来细说的词。在下一篇文章中,我们将重点介绍这个词在不同上下文之间的含义。此时,大家可能会有疑问,直接让所有副本在任意时刻都保持一致不就行了,为啥还要有各种不同的一致性呢?我们认为有两个考量点,第一是性能,第二则是复杂性。性能比较好理解,
244、因为冗余的目的不完全是为了高可用,还有延迟和负载均衡这类提升性能的目的,如果只一味地为了地强调数据一致,可能得不偿失。复杂性是因为分布式系统中,有着比单机系统更加复杂的不确定性,节点之间由于采用不大可靠的网络进行传输,并且不能共享统一的一套系统时间和内存地址(后文会详细进行说明),后端2022年美团技术年货图 2同步复制与异步复制如上图所示,在这个时间窗口中,任何情况都有可能发生。在这种情况下,客户端何时算写入完成,会决定其他客户端读到数据的可能性。这里我们假设这份数据有一个主副本和一个从副本,如果主副本保存后即向客户端返回成功,这样叫做异步复制(1)。而如果等到数据传送到从副本 1,并得到确
245、认之后再返回客户端成功,称为同步复制(2)。这里我们先假设系统正常运行,在异步同步下,如果从副本承担读请求,假设 reader1 和 reader2 同时在客户端收到写入成功后发出读请求,两个 reader 就可能读到不一样的值。为了避免这种情况,实际上有两种角度的做法,第一种角度是让客户端只从主副本读取数据,这样,在正常情况下,所有客户端读到的数据一定是一致的(Kafka 当前的做法);另一种角度则是采用同步复制,假设使用纯的同步复制,当有多个副本时,任何一个副本所在的节点发生故障,都会使写请求阻塞,同时每次写请求都需要等待所有节点确认,如果副本过多会极大影响吞吐量。而如果仅采用异步复制并由
246、主副本承担读请求,当主节点故障发生切换时,一样会发生数据不一致的问题。后端2022年美团技术年货(Truncate)到 HW 对应的 offset 上,然后从这个 offset 开始从 Leader 副本拉取数据,直到认追上 Leader,被加入到 ISR 集合中主节点失效节点切换主节点失效则会稍稍复杂一些,需要经历三个步骤来完成节点的切换。1.确认主节点失效,由于失效的原因有多种多样,大多数系统会采用超时来判定节点失效。一般都是采用节点间互发心跳的方式,如果发现某个节点在较长时间内无响应,则会认定为节点失效。具体到 Kafka 中,它是通过和Zookeeper(下文简称 ZK)间的会话来保持
247、心跳的,在启动时 Kafka 会在ZK 上注册临时节点,此后会和 ZK 间维持会话,假设 Kafka 节点出现故障(这里指被动的掉线,不包含主动执行停服的操作),当会话心跳超时时,ZK上的临时节点会掉线,这时会有专门的组件(Controller)监听到这一信息,并认定节点失效。2.选举新的主节点。这里可以通过通过选举的方式(民主协商投票,通常使用共识算法),或由某个特定的组件指定某个节点作为新的节点(Kafka 的Controller)。在选举或指定时,需要尽可能地让新主与原主的差距最小,这样会最小化数据丢失的风险(让所有节点都认可新的主节点是典型的共识问题)这里所谓共识,就是让一个小组的节点
248、就某一个议题达成一致,下一篇文章会重点进行介绍。3.重新配置系统是新的主节点生效,这一阶段基本可以理解为对集群的元数据进行修改,让所有外界知道新主节点的存在(Kafka 中 Controller 通过元数据广播实现),后续及时旧的节点启动,也需要确保它不能再认为自己是主节点,从而承担写请求。问题虽然上述三个步骤较为清晰,但在实际发生时,还会存在一些问题:1.假设采用异步复制,在失效前,新的主节点与原主节点的数据存在 Gap,选后端2022年美团技术年货节点,因为只存在一个主节点,就很容易出现性能问题。虽然有从节点作为冗余应对容错,但对于写入请求实际上这种复制方式是不具备扩展性的。此外,如果客户
249、端来源于多个地域,不同客户端所感知到的服务相应时间差距会非常大。因此,有些系统顺着传统主从复制进行延伸,采用多个主节点同时承担写请求,主节点接到写入请求之后将数据同步到从节点,不同的是,这个主节点可能还是其他节点的从节点。复制模式如下图所示,可以看到两个主节点在接到写请求后,将数据同步到同一个数据中心的从节点。此外,该主节点还将不断同步在另一数据中心节点上的数据,由于每个主节点同时处理其他主节点的数据和客户端写入的数据,因此需要模型中增加一个冲突处理模块,最后写到主节点的数据需要解决冲突。图 3多主节点复制使用场景a.多数据中心部署一般采用多主节点复制,都是为了做多数据中心容灾或让客户端就近访
250、问(用一个高后端2022年美团技术年货c.协同编辑这里我们对离线客户端操作进行扩展,假设我们所有人同时编辑一个文档,每个人通过 Web 客户端编辑的文档都可以看做一个主节点。这里我们拿美团内部的学城(内部的 Wiki 系统)举例,当我们正在编辑一份文档的时候,基本上都会发现右上角会出现“xxx 也在协同编辑文档”的字样,当我们保存的时候,系统就会自动将数据保存到本地并复制到其他主节点上,各自处理各自端上的冲突。另外,当文档出现了更新时,学城会通知我们有更新,需要我们手动点击更新,来更新我们本地主节点的数据。书中说明,虽然不能将协同编辑完全等同于数据库复制,但却是有很多相似之处,也需要处理冲突问
251、题。冲突解决通过上面的分析,我们了解到多主复制模型最大挑战就是解决冲突,下面我们简单看下DDIA中给出的通用解法,在介绍之前,我们先来看一个典型的冲突。a.冲突实例图 5冲突实例后端2022年美团技术年货当然,我们可以用某种方式做拼接,或利用预先定义的格式保留冲突相关信息,然后由用户自行解决。3.用户自行处理其实,把这个操作直接交给用户,让用户自己在读取或写入前进行冲突解决,这种例子也是屡见不鲜,Github 采用就是这种方式。这里只是简单举了一些冲突的例子,其实冲突的定义是一个很微妙的概念。DDIA第七章介绍了更多关于冲突的概念,感兴趣同学可以先自行阅读,在下一篇文章中也会提到这个问题。c.
252、处理细节介绍此外,在书中将要结束复制这一章时,也详细介绍了如何进行冲突的处理,这里也简单进行介绍。这里我们可以思考一个问题,为什么会发生冲突?通过阅读具体的处理手段后,我们可以尝试这样理解,正是因为我们对事件发生的先后顺序不确定,但这些事件的处理主体都有重叠(比如都有设置某个数据的值)。通过我们对冲突的理解,加上我们的常识推测,会有这样几种方式可以帮我们来判断事件的先后顺序。1.直接指定事件顺序对于事件发生的先后顺序,我们一个最直观的想法就是,两个请求谁新要谁的,那这里定义“最新”是个问题,一个很简单的方式是使用时间戳,这种算法叫做最后写入者获胜 LWW。但分布式系统中没有统一的系统时钟,不同
253、机器上的时间戳无法保证精确同步,那就可能存在数据丢失的风险,并且由于数据是覆盖写,可能不会保留中间值,那么最终可能也不是一致的状态,或出现数据丢失。如果是一些缓存系统,覆盖写看上去也是可以的,这种简单粗暴的算法是非常好的收敛冲突的方式,但如果我们对数据一致性要求较高,则这种方式就会引入风险,除非数据写入一次后就不会发生改变。后端2022年美团技术年货我们可能在 JVM 的内存模型(JMM)中听到过这个词,在 JMM 中,表达的也是多个线程操作的先后顺序关系。这里,如果我们把线程或者请求理解为对数据的操作(区别在于一个是对本地内存数据,另一个是对远程的某处内存进行修改),线程或客户端都是一种执行
254、者(区别在于是否需要使用网络),那这两种 Happens-before 也就可以在本质上进行统一了,都是为了描述事件的先后顺序而生。书中给出了检测这类事件的一种算法,并举了一个购物车的例子,如图所示(以餐厅扫码点餐的场景为例):图 7扫码点餐示例图中两个客户端同时向购物车里放东西,事例中的数据库假设只有一个副本。1.首先 Client1 向购物车中添加牛奶,此时购物车为空,返回版本 1,Value 为 牛奶。2.此时 Client2 向其中添加鸡蛋,其并不知道 Client1 添加了牛奶,但服务器可以知道,因此分配版本号为 2,并且将鸡蛋和牛奶存成两个单独的值,最后将后端2022年美团技术年货
255、额外做些事情。在购物车这个例子中,比较合理的是合并新值和旧值,即最后的值是 牛奶,鸡蛋,面粉,火腿,培根,但这样也会导致一个问题,假设其中的一个用户删除了一项商品,但是 union 完还是会出现在最终的结果中,这显然不符合预期。因此可以用一个类似的标记位,标记记录的删除,这样在合并时可以将这个商品踢出,这个标记在书中被称为墓碑(Tombstone)。2.3无主节点复制之前介绍的复制模式都是存在明确的主节点,从节点的角色划分的,主节点需要将数据复制到从节点,所有写入的顺序由主节点控制。但有些系统干脆放弃了这个思路,去掉了主节点,任何副本都能直接接受来自客户端的写请求,或者再有一些系统中,会给到一
256、个协调者代表客户端进行写入(以 GroupCommit 为例,由一个线程积攒所有客户端的请求统一发送),与多主模式不同,协调者不负责控制写入顺序,这个限制的不同会直接影响系统的使用方式。处理节点失效假设一个数据系统拥有三个副本,当其中一个副本不可用时,在主从模式中,如果恰好是主节点,则需要进行节点切换才能继续对外提供服务,但在无主模式下,并不存在这一步骤,如下图所示:后端2022年美团技术年货读写 Quorum上文中的实例我们可以看出,这种复制模式下,要想保证读到的是写入的新值,每次只从一个副本读取显然是有问题的,那么需要每次写几个副本呢,又需要读取几个副本呢?这里的一个核心点就是让写入的副本
257、和读取的副本有交集,那么我们就能够保证读到新值了。直接上公式:。其中 N 为副本的数量,w 为每次并行写入的节点数,r为每次同时读取的节点数,这个公式非常容易理解,就不做过多赘述。不过这里的公式虽然看着比较直白也简单,里面却蕴含了一些系统设计思考:一般配置方法,取w,r 与 N 的关系决定了能够容忍多少的节点失效假设 N=3,w=2,r=2,可以容忍 1 个节点故障。假设 N=5,w=3,r=3可以容忍 2 个节点故障。N 个节点可以容忍可以容忍个节点故障。在实际实现中,一般数据会发送或读取所有节点,w 和 r 决定了我们需要等待几个节点的写入或读取确认。Quorum 一致性的局限性看上去这个
258、简单的公式就可以实现很强大的功能,但这里有一些问题值得注意:首先,Quorum 并不是一定要求多数,重要的是读取的副本和写入副本有重合即可,可以按照读写的可用性要求酌情考虑配置。另外,对于一些没有很强一致性要求的系统,可以配置 w+rN 的情况下,实际上也存在边界问题导致一些一致性问题:首先假设是 SloppyQuorum(一个更为宽松的 Quorum 算法),写入的 w后端2022年美团技术年货3.1部分失效这是分布式系统中特有的一个名词,这里先看一个现实当中的例子。假设老板想要处理一批文件,如果让一个人做,需要十天。但老板觉得有点慢,于是他灵机一动,想到可以找十个人来搞定这件事,然后自己把
259、工作安排好,认为这十个人一天正好干完,于是向他的上级信誓旦旦地承诺一天搞定这件事。他把这十个人叫过来,把任务分配给了他们,他们彼此建了个微信群,约定每个小时在群里汇报自己手上的工作进度,并强调在晚上 5 点前需要通过邮件提交最后的结果。于是老版就去愉快的喝茶去了,但是现实却让他大跌眼镜。首先,有个同学家里信号特别差,报告进度的时候只成功报告了 3 个小时的,然后老板在微信里问,也收不到任何回复,最后结果也没法提交。另一个同学家的表由于长期没换电池,停在了下午四点,结果那人看了两次表都是四点,所以一点都没着急,中间还看了个电影,慢慢悠悠做完交上去了,他还以为老板会表扬他,提前了一小时交,结果实际
260、上已经是晚上八点了。还有一个同学因为前一天没睡好,效率极低,而且也没办法再去高强度的工作了。结果到了晚上 5 点,只有 7 个人完成了自己手头上的工作。这个例子可能看起来并不是非常恰当,但基本可以描述分布式系统特有的问题了。在分布式的系统中,我们会遇到各种“稀奇古怪”的故障,例如家里没信号(网络故障),不管怎么叫都不理你,或者断断续续的理你。另外,因为每个人都是通过自己家的表看时间的,所谓的 5 点需要提交结果,在一定程度上旧失去了参考的绝对价值。因此,作为上面例子中的“老板”,不能那么自信的认为一个人干工作需要 10天,就可以放心交给 10 个人,让他们一天搞定。我们需要有各种措施来应对分派
261、任务带来的不确定性,回到分布式系统中,部分失效是分布式系统一定会出现的情况。作为系统本身的设计人员,我们所设计的系统需要能够容忍这种问题,相对单机系统来说,这就带来了特有的复杂性。后端2022年美团技术年货TCP,TCP 是个端到端的协议,是需要发送端和接收端两端内核中明确维护数据结构来维持连接的,如果应用层发生了下面的问题,那么网络包就会在内核的 SocketBuffer 中排队得不到处理,或响应得不到处理。1.应用程序 GC。2.处理节点在进行重的磁盘 I/O,导致 CPU 无法从中断中恢复从而无法处理网络请求。3.由于内存换页导致的颠簸。这些问题和网络本身的不稳定性相叠加,使得外界认为的
262、网络不靠谱的程度更加严重。因此这些不靠谱,会极大地加重上一章中的复制滞后性,进而带来各种各样的一致性问题。应对之道网络异常相比其他单机上的错误而言,可能多了一种不确定的返回状态,即延迟,而且延迟的时间完全无法预估。这会让我们写起程序来异常头疼,对于上一章中的问题,我们可能无从知晓节点是否失效,因为你发的请求压根可能不会有人响应你。因此,我们需要把上面的“不确定”变成一种确定的形式,那就是利用“超时”机制。这里引申出两个问题:1.假设能够检测出失效,我们应该如何应对?a.负载均衡需要避免往失效的节点上发数据(服务发现模块中的健康检查功能)。b.如果在主从复制中,如果主节点失效,需要出发选举机制(
263、Kafka 中的临时节点掉线,Controller 监听到变更触发新的选举,Controller 本身的选举机制)。c.如果服务进程崩溃,但操作系统运行正常,可以通过脚本通知其他节点,以便新的节点来接替(Kafka 的僵尸节点检测,会触发强制的临时节点掉线)。d.如果路由器已经确认目标节点不可访问,则会返回 ICMP 不可达(ping 不通走下线)。2.如何设置超时时间是合理的?后端2022年美团技术年货图 10不可靠的时钟这里我们发现,Node1 的时钟比 Node3 快,当两个节点在处理完本地请求准备写Node2 时发生了问题,原本 ClientB 的写入明显晚于 ClientA 的写入,
264、但最终的结果,却由于 Node1 的时间戳更大而丢弃了本该保留的 x+=1,这样,如果我们使用LWW,一定会出现数据不符合预期的问题。由于时钟不准确,这里就引入了统计学中的置信区间的概念,也就是这个时间到底在一个什么样的范围里,一般的 API 是无法返回类似这样的信息的。不过,Google的 TrueTimeAPI 则恰恰能够返回这种信息,其调用结果是一个区间,有了这样的API,确实就可以用来做一些对其有依赖的事情了,例如 Google 自家的 Spanner,就是使用 TrueTime 实现快照隔离。如何在这艰难的环境中设计系统上面介绍的问题是不是挺“令人绝望”的?你可能发现,现在时间可能是
265、错的,测量可能是不准的,你的请求可能得不到任何响应,你可能不知道它是不是还活着这种环境真的让设计分布式系统变得异常艰难,就像是你在 100 个人组成的大部门里面协调一些工作一样,工作量异常的巨大且复杂。但好在我们并不是什么都做不了,以协调这件事为例,我们肯定不是武断地听取一个后端2022年美团技术年货2.崩溃-恢复模型:节点可能在任何时刻发生崩溃,可能会在一段时间后恢复,并再次响应,在该模型中假设,在持久化存储中的数据将得以保存,而内存中的数据会丢失。而多数的算法都是基于半同步模型+崩溃-恢复模型来进行设计的。SafetyandLiveness这两个词在分布式算法设计时起着十分关键的作用,其中
266、安全性(Safety)表示没有意外发生,假设违反了安全性原则,我们一定能够指出它发生的时间点,并且安全性一旦违反,无法撤销。而活性(Liveness)则表示“预期的事情最终一定会发生”,可能我们无法明确具体的时间点,但我们期望它在未来某个时间能够满足要求。在进行分布式算法设计时,通常需要必须满足安全性,而活性的满足需要具备一定的前提。7.总结以上就是第一篇文章的内容,简单做下回顾,本文首先介绍了复制的三种常见模型,分别是主从复制、多主复制和无主复制,然后分别介绍了这三种模型的特点、适用场景以及优缺点。接下来,我们用了一个现实生活中的例子,向大家展示了分布式系统中常见的两个特有问题,分别是节点的
267、部分失效以及无法共享系统时钟的问题,这两个问题为我们设计分布式系统带来了比较大的挑战。如果没有一些设计特定的措施,我们所设计的分布式系统将无法很好地满足设计的初衷,用户也无法通过分布式系统来完成自己想要的工作。以上这些问题,我们会下篇文章Replication(下):事务,一致性与共识中逐一进行解决,而事务、一致性、共识这三个关键词,会为我们在设计分布式系统时保驾护航。8.作者简介仕禄,美团基础研发平台/数据科学与平台部工程师。后端2022年美团技术年货3.事务&外部一致性说到事务,相信大家都能简单说出个一二来,首先能本能做出反应出的,应该就是所谓的“ACID”特性了,还有各种各样的隔离级别。
268、是的,它们确实都是事务需要解决的问题。在这一章中,我们会更加有条理地理解下它们之间的内在联系,详细看一看事务究竟要解决什么问题。在DDIA一书中有非常多关于数据库事务的具体实现细节,但本文中会弱化它们,毕竟本文不想详细介绍如何设计一款数据库,我们只需探究问题的本身,等真正寻找解决方案时再去详细看设计,效果可能会更好。下面我们正式开始介绍事务。3.1事务的产生系统中可能会面临下面的问题:1.程序依托的操作系统层,硬件层可能随时都会发生故障(包括一个操作执行到一半时)。2.应用程序可能会随时发生故障(包括操作执行到一半时)。3.网络中断可能随时会发生,它会切断客户端与服务端的链接或数据库之间的链接
269、。4.多个客户端可能会同时访问服务端,并且更新统一批数据,导致数据互相覆盖(临界区)。5.客户端可能会读到过期的数据,因为上面说的,可能操作执行一半应用程序就挂了。假设上述问题都会出现在我们对于存储系统(或者数据库)的访问中,这样我们在开发自己应用程序的同时,还需要额外付出很大代价处理这些问题。事务的核心使命就是尝试帮我们解决这些问题,提供了从它自己层面所看到的安全性保证,让我们在访问存储系统时只专注我们本身的写入和查询逻辑,而非这些额外复杂的异常处理。而说起解决方式,正是通过它那大名鼎鼎的 ACID 特性来进行保证的。后端2022年美团技术年货我们需要区分不同语境中一致性所表达含义的区别,也
270、希望大家看完今天的分享,能更好地帮助大家记住这些区别。话说回来,这里的一致性指的是对于数据一组特定陈述必须成立,即“不变式”,这里有点类似于算法中的“循环不变式”,即当外界环境发生变化时,这个不变式一定需要成立。书中强调,这个里面的一致性更多需要用户的应用程序来保证,因为只有用户知道所谓的不变式是什么。这里举一个简单的小例子,例如我们往 Kafka 中 append 消息,其中有两条消息内容都是 2,如果没有额外的信息时,我们也不知道到底是客户端因为故障重试发了两次,还是真的就有两条一模一样的数据。如果想进行区分,可以在用户程序消费后走自定义的去重逻辑,也可以从 Kafka 自身出发,客户端发
271、送时增加一个“发号”环节标明消息的唯一性(高版本中 Kafka 事务的实现大致思路)这样引擎本身就具备了一定的自己设置“不变式”的能力。不过如果是更复杂的情况,还是需要用户程序和调用服务本身共同维护。I:隔离性(Isolation):隔离性实际上是事务的重头戏,也是门道最多的一环,因为隔离性解决的问题是多个事务作用于同一个或者同一批数据时的并发问题。一提到并发问题,我们就知道这一定不是个简单的问题,因为并发的本质是时序的不确定性,当这些不确定时序的作用域有一定冲突(Race)时就可能会引发各种各样的问题,这一点和多线程编程是类似的,但这里面的操作远比一条计算机指令时间长得多,所以问题会更严重而
272、且更多样。这里给一个具体的实例来直观感受下,如下图展示了两个客户端并发的修改 DB 中的一个 counter,由于 User2 的 getcounter 发生的时刻在 User1 更新的过程中,因此读到的 counter 是个旧值,同样 User2 更新也类似,所以最后应该预期 counter值为 44,结果两个人看到的 counter 都是 43(类似两个线程同时做 value+)。一个完美的事务隔离,在每个事务看来,整个系统只有自己在工作,对于整个系统而言这些并发的事务一个接一个的执行,也仿佛只有一个事务,这样的隔离成为“可序列化(Serializability)”。当然,这样的隔离级别会
273、带来巨大的开销,因此出现了各种后端2022年美团技术年货a.数据库是否会存在 10KB 没法解析的脏数据。b.如果恢复之后数是否能接着继续写入。c.另一个客户端读取这个文档,是否能够看到恢复后的最新值,还是读到一堆乱码。2.另一种则是类似上图中 Counter 做自增的功能。这种事务的解决方法一般是通过日志回放(原子性)、锁(隔离性)、CAS(隔离性)等方式来进行保证。多对象事务:这类事务实际上是比较复杂的,比如可能在某些分布式系统中,操作的对象可能会跨线程、跨进程、跨分区,甚至跨系统。这就意味着,我们面临的问题多于上一篇文章提到的那些分布式系统特有的问题,处理那些问题显然要更复杂。有些系统干
274、脆把这种“锅”甩给用户,让应用程序自己来处理问题,也就是说,我们可能需要自己处理因没有原子性带来的中间结果问题,因为没有隔离性带来的并发问题。当然,也有些系统实现了这些所谓的分布式事务,后文中会介绍具体的实现手段。另一个需要特别强调的点是重试,事务的一个核心特性就是当发生错误时,客户端可以安全的进行重试,并且不会对服务端有任何副作用,对于传统的真的实现 ACID 的数据库系统,就应该遵循这样的设计语义。但在实际实践时,如何保证上面说的能够“安全的重试”呢?书中给出了一些可能发生的问题和解决手段:1.假设事务提交成功了,但服务端 Ack 的时候发生了网络故障,此时如果客户端发起重试,如果没有额外
275、的手段,就会发生数据重复,这就需要服务端或应用程序自己提供能够区分消息唯一性的额外属性(服务端内置的事务 ID 或者业务自身的属性字段)。2.由于负载太大导致了事务提交失败,这是贸然重试会加重系统的负担,这时可在客户端进行一些限制,例如采用指数退避的方式,或限制一些重试次数,放入客户端自己系统所属的队列等。3.在重试前进行判断,尽在发生临时性错误时重试,如果应用已经违反了某些定义好的约束,那这样的重试就毫无意义。4.如果事务是多对象操作,并且可能在系统中发生副作用,那就需要类似“两后端2022年美团技术年货1.如果是单对象事务,客户端会看到一个一会即将可能被回滚的值,如果我需要依据这个值做决策
276、,就很有可能会出现决策错误。2.如果是多对象事务,可能客户端对于不同系统做访问时一部分数据更新,一部分未更新,那样用户可能会不知所措。脏写如果一个客户端覆盖了另一个客户端尚未提交的写入,我们就称这样的现象为脏写。这里同样给个实例,对于一个二手车的交易,需要更新两次数据库实现,但有两个用户并发的进行交易,如果像图中一样不禁止脏写,就可能存在销售列表显示交易属于Bob 但发票却发给了 Alice,因为两个事务对于两个数据的相同记录互相覆盖。图 3脏写读偏差(不可重复读)直接上例子,Alice 在两个银行账户总共有 1000 块,每个账户 500,现在她想从一个账户向另一个账户转账 100,并且她想
277、一直盯着自己的两个账户看看钱是否转成功了。不巧的是,他第一次看账户的时候转账还没发生,而成功后只查了一个账户的值,正好少了 100,所以最后加起来会觉得自己少了 100 元。如果只是这种场景,其实只是个临时性的现象,后面再查询就会得到正确的值,但是后端2022年美团技术年货写偏差&幻读这种问题描述的是,事务的写入需要依赖于之前判断的结果,而这个结果可能会被其他并发事务修改。图 6幻读实例中有两个人 Alice 和 Bob 决定是否可以休班,做这个决定的前提是判断当前是否有两个以上的医生正在值班,如果是则自己可以安全的休班,然后修改值班医生信息。但由于使用了快照隔离(后面会介绍)机制,两个事务返
278、回的结果全都是 2,进入了修改阶段,但最终的结果其实是违背了两名医生值班的前提。造成这个问题的根本原因是一种成为“幻读”的现象,也就是说两个并发的事务,其中一个事务更改了另一个事物的查询结果,这种查询一般都是查询一个聚合结果,例如上文中的 count 或者 max、min 等,这种问题会在下面场景中出现问题。抢订会议室后端2022年美团技术年货的。这里面书中给了三种不同类型的一致性问题。我们分别来看这些事例:图 7复制滞后问题第一张图给出的是一个用户先更新,然后查看更新结果的事例,比如用户对某一条博客下做出了自己的评论,该服务中的 DB 采用纯的异步复制,数据写到主节点就返回评论成功,然后用户
279、想刷新下页面看看自己的评论能引发多大的共鸣或跟帖,这是由于查询到了从节点上,所以发现刚才写的评论“不翼而飞”了,如果系统能够避免出现上面这种情况,我们称实现了“写后读一致性”(读写一致性)。上面是用户更新后查看的例子,下一张图则展示了另一种情况。用户同样是在系统中写入了一条评论,该模块依旧采用了纯异步复制的方法实现,此时有另一位用户来看,首先刷新页面时看到了 User1234 的评论,但下一次刷新,则这条评论又消失了,好像时钟出现了回拨,如果系统能够保证不会让这种情况出现,说明系统实现了“单调读”一致性(比如腾讯体育的比分和详情页)。后端2022年美团技术年货这个问题会比前面的例子看上去更荒唐
280、,这里有两个写入客户端,其中 Poons 问了个问题,然后 Cake 做出了回答。从顺序上,MrsCake 是看到 Poons 的问题之后才进行的回答,但是问题与回答恰好被划分到了数据库的两个分区(Partition)上,对于下面的 Observer 而言,Partition1 的 Leader 延迟要远大于 Partition2 的延迟,因此从 Observer 上看到的是现有答案后有的问题,这显然是一个违反自然规律的事情,如果能避免这种问题出现,那么可称为系统实现了“前缀读一致性”。在上一篇中,我们介绍了一可以检测类似这种因果的方式,但综上,我们可以看到,由于复制的滞后性,带来的一个后果就
281、是系统只是具备了最终一致性,由于这种最终一致性,会大大的影响用户的一些使用体验。上面三个例子虽然代表了不同的一致性,但都有一个共性,就是由于复制的滞后性带来的问题。所谓复制,那就是多个客户端甚至是一个客户端读写多个副本时所发生的的问题。这里我们将这类一致性问题称为“内部一致性(内存一致性)”,即表征由于多个副本读写的时序存在的数据不一致问题。4.2内部一致性概述实际上,内部一致性并不是分布式系统特有的问题,在多核领域又称内存一致性,是为了约定多处理器之间协作。如果多处理器间能够满足特定的一致性,那么就能对多处理器所处理的数据,操作顺序做出一定的承诺,应用开发人员可以根据这些承诺对自己的系统做出
282、假设。如下图所示:后端2022年美团技术年货序”,也就是说允许一些并发操作间不比较顺序,按所有可能的排列组合执行。4.3举一反三:分布式系统中的内部一致性如下图所示:图 12内存一致性分布式中的内部一致性主要分为 4 大类:线性一致性 顺序一致性 因果一致性 处理器一致性,而从偏序与全序来划分,则划分为强一致性(线性一致性)与最终一致性。但需要注意的是,只要不是强一致的内部一致性,但最终一致性没有任何的偏序保障。图中的这些一致性实际都是做了一些偏序的限制,比朴素的最终一致性有更强的后端r 表示尝试更新 x 的值为 v,返回更新结果 r。2.read(x)=v 表示读取 x 的值,返回 x 的值
283、为 v。如图中所示,在 C 更新 x 的值时,A 和 B 反复查询 x 的最新值,比较明确的结果是由于 ClientA 在 ClientC 更新 x 之前读取,所以第一次 read(x)一定会为 0,而ClientA 的最后一次读取是在 ClientC 成功更新 x 的值后,因此一定会返回 1。而剩下的读取,由于不确定与 write(x,1)的顺序(并发),因此可能会返回 0 也可能返回1。对于线性一致性,我们做了下面的规定:2142022年美团技术年货图 14线性一致性在一个线性一致性系统中,在写操作调用到返回之前,一定有一个时间点,客户端调用 read 能读到新值,在读到新值之后,后续的所
284、有读操作都应该返回新值。(将上面图中的操作做了严格的顺序,及 ClientAread-ClientBread-ClientCwrite-ClientAread-clientBread-clientAread)这里为了清晰,书中做了进一步细化。在下面的例子中,又增加了一种操作:cas(x,v_old,v_new)=r及如果此时的值时 v_old 则更新 x 的值为 v_new,返回更新结果。如图:每条数显代表具体事件发生的时点,线性一致性要求:如果连接上述的竖线,要求必须按照时间顺序向前推移,不能向后回拨(图中的 read(x)=2 就不满足线性化的要求,因为 x=2 在 x=4 的左侧)。后端
285、2022年美团技术年货图 16跨通道线性一致性比如用户上传图片,类似后端存储服务可能会根据全尺寸图片生成低像素图片,以便增加用户服务体验,但由于 MQ 不适合发送图片这种大的字节流,因此全尺寸图片是直接发给后端存储服务的,而截取图片则是通过 MQ 在后台异步执行的,这就需要 2中上传的文件存储服务是个可线性化的存储。如果不是,在生成低分辨率图像时可能会找不到,或读取到半张图片,这肯定不是我们希望看到的。线性化不是避免竞争的唯一方法,与事务隔离级别一样,对并发顺序的要求,可能会根据场景不同有不同的严格程度。这也就诞生了不同级别的内部一致性级别,不同的级别也同样对应着不同的开销,需要用户自行决策。
286、4.6实现线性化系统说明了线性化系统的用处,下面我们来考虑如何实现这样的线性化系统。根据上文对线性化的定义可知,这样系统对外看起来就像只有一个副本,那么最容易想到的方式就是,干脆就用一个副本。但这又不是分布式系统的初衷,很大一部分用多副本是为了做容错的,多副本的实现方式是复制,那么我们来看看,上一篇分享中那些常见的复制方式是否可以实现线性系统:1.主从复制(部分能实现):如果使用同步复制,那样系统确实是线性化的,但有一些极端情况可能会违反线性化,比如由于成员变更过程中的“脑裂”问后端2022年美团技术年货统的需求了,所以不要迷信这个理论,还是需要根据具体的实际情况去做分析。层层递进实现线性化系
287、统从对线性一致性的定义我们可以知道,顺序的检测是实现线性化系统的关键,这里我们跟着书中的思路一步步地来看:我们怎么能对这些并发的事务定义出它们的顺序。a.捕捉因果关系与上一次分享的内容类似,并发操作间有两种类型,可能有些操作间具有天然逻辑上的因果关系,还有些则没法确定,这里我们首先先尝试捕获那些有因果关系的操作,实现个因果一致性。这里的捕获我们实际需要存储数据库(系统)操作中的所有因果关系,我们可以使用类似版本向量的方式(忘记的同学,可以回看上一篇中两个人并发操作购物车的示例)。b.化被动为主动主动定义上面被动地不加任何限制的捕捉因果,会带来巨大的运行开销(内存,磁盘),这种关系虽然可以持久化
288、到磁盘,但分析时依然需要被载入内存,这就让我们有了另一个想法,我们是否能在操作上做个标记,直接定义这样的因果关系?最最简单的方式就是构建一个全局发号器,产生一些序列号来定义操作间的因果关系,比如需要保证 A 在 B 之前发生,那就确保 A 的全序 ID 在 B 之前即可,其他的并发操作顺序不做硬限制,但操作间在处理器的相对顺序不变,这样我们不但实现了因果一致性,还对这个限制进行了增强。c.Lamport 时间戳上面的设想虽然比较理想,但现实永远超乎我们的想象的复杂,上面的方式在主从复制模式下很容易实现,但如果是多主或者无主的复制模型,我们很难设计这种全局的序列号发号器,书中给出了一些可能的解决
289、方案,目的是生成唯一的序列号,比如:1.每个节点各自产生序列号。2.每个操作上带上时间戳。后端2022年美团技术年货d.我们可以实现线性化了吗全序广播到此我们可以确认,有了 Lamport 时间戳,我们可以实现因果一致性了,但仍然无法实现线性化,因为我们还需要让这个全序通知到所有节点,否则可能就会无法做决策。举个例子,针对唯一用户名这样的场景,假设 ABC 同时向系统尝试注册相同的用户名,使用 Lamport 时间戳的做法是,在这三个并发请求中最先提交的返回成功,其他返回失败,但这里面我们因为有“上帝视角”,知道 ABC,但实际请求本身在发送时不知道有其他请求存在(不同请求可能被发送到了不同的
290、节点上)这样就需要系统做这个收集工作,这就需要有个类似协调者来不断询问各个节点是否有这样的请求,如果其中一个节点在询问过程中发生故障,那系统无法放心决定每个请求具体的RSP 结果。所以最好是系统将这个顺序广播到各个节点,让各个节点真的知道这个顺序,这样可以直接做决策。假设只有单核 CPU,那么天然就是全序的,但是现在我们需要的是在多核、多机、分布式的情况下实现这个全序的广播,就存在这一些挑战。主要挑战是两个:多机分布式对于多机,实际上实现全序广播最简单的实现方式使用主从模式的复制,让所有的操作顺序让主节点定义,然后按相同的顺序广播到各个从节点。对于分布式环境,需要处理部分失效问题,也就是如果主
291、节点故障需要处理主成员变更。下面我们就来看看书中是怎么解决这个问题的。这里所谓的全序一般指的是分区内部的全序,而如果需要跨分区的全序,需要有额外的工作。对于全序广播,书中给了两条不变式:1.可靠发送:需要保证消息做到 all-or-nothing 的发送(想想上一章)。2.严格有序:消息需要按完全相同的顺序发给各个节点。后端2022年美团技术年货3.如果表名第一次注册的回复来自当前节点,提交这条日志,并返回成功,否则如果这条回复来自其他节点,直接向客户端返回失败。而这些日志条目会以相同的顺序广播到所有节点,如果出现并发写入,就需要所有节点做决策,是否同意,以及同意哪一个节点对这个用户名的占用。
292、以上我们就成功实现了一个对线性 CAS 的写入的线性一致性。然而对于读请求,由于采用异步更新日志的机制,客户端的读取可能会读到旧值,这可能需要一些额外的工作保证读取的线性化。1.线性化的方式获取当前最新消息的位置,即确保该位置之前的所有消息都已经读取到,然后再进行读取(ZK 中的 sync())。2.在日志中加入一条消息,收到回复时真正进行读取,这样消息在日志中的位置可以确定读取发生的时间点。3.从保持同步更新的副本上读取数据。4.7共识上面我们在实现线性化系统时,实际上就有了一点点共识的苗头了,即需要多个节点对某个提议达成一致,并且一旦达成,不能被撤销。在现实中很多场景的问题都可以等价为共识
293、问题:可线性化的 CAS原子事务提交全序广播分布式锁与租约成员协调唯一性约束实际上,为以上任何一个问题找到解决方案,都相当于实现了共识。后端2022年美团技术年货2.应用程序在每个参与节点上执行单节点事务,并将这个 ID 附加到操作上,这是读写操作都是单节点完成,如果发生问题,可以安全的终止(单节点事务保证)。3.当应用准备提交时,协调者向所有参与者发送 Prepare,如果这是有任何一个请求发生错误或超时,都会终止事务。4.参与者收到请求后,将事务数据写入持久化存储,并检查是否有违规等,此时出现了第一个承诺:如果参与者向协调者发送了“是”意味着该参与者一定不会再撤回事务。5.当协调者收到所有
294、参与者的回复后,根据这些恢复做决策,如果收到全部赞成票,则将“提交”这个决议写入到自己本地的持久化存储,这里会出现第二个承诺:协调者一定会提交这个事务,直到成功。6.假设提交过程出现异常,协调者需要不停重试,直到重试成功。正是由于上面的两个承诺保证了 2PC 能达成原子性,也是这个范式存在的意义所在。b.局限性1.协调者要保存状态,因为协调者在决定提交之后需要担保一定要提交事务,因此它的决策一定需要持久化。2.协调者是单点,那么如果协调者发生问题,并且无法恢复,系统此时完全不知道应该提交还是要回滚,就必须交由管理员来处理。3.两阶段提交的准备阶段需要所有参与者都投赞成票才能继续提交,这样如果参
295、与者过多,会导致事务失败概率很大。更为朴素的共识算法定义看完了一个特例,书中总结了共识算法的几个特性:1.协商一致性:所有节点都接受相同的提议。2.诚实性:所有节点一旦做出决定,不能反悔,不能对一项提议不能有两次不同的决议。后端2022年美团技术年货同样的,在主节点做决策之前,也需要判断有没有更高 Epoch 的节点同时在进行决策,如果有,则代表可能发生冲突(Kafka 中低版本只有 Controller 有这个标识,在后面的版本中,数据分区同样带上了类似的标识)。此时,节点不能仅根据自己的信息来决定任何事情,它需要收集 Quorum 节点中收集投票,主节点将提议发给所有节点,并等待 Quor
296、um 节点的返回,并且需要确认没后更高 Epoch 的主节点存在时,节点才会对当前提议做投票。详细看这里面涉及两轮投票,使用 Quorum 又是在使用所谓的重合,如果某个提议获得通过,那么投票的节点中一定参加过最近一轮主节点的选举。这可以得出,此时主节点并没有发生变化,可以安全的给这个主节点的提议投票。另外,乍一看共识算法全都是好处,但看似好的东西背后一定有需要付出的代价:1.在达成一致性决议前,节点的投票是个同步复制,这会使得共识有丢消息的风险,需要在性能和线性一直间权衡(CAP)。2.多数共识架设了一组固定的节点集,这意味着不能随意的动态变更成员,需要深入理解系统后才能做动态成员变更(可能
297、有的系统就把成员变更外包了)。3.共识对网络极度敏感,并且一般采用超时来做故障检测,可能会由于网络的抖动导致莫名的无效选主操作,甚至会让系统进入不可用状态。外包共识虽然,可以根据上面的描述自己来实现共识算法,但成本可能是巨大的,最好的方式可能是将这个功能外包出去,用成熟的系统来实现共识,如果实在需要自己实现,也最好是用经过验证的算法来实现,不要自己天马行空。ZK 和 etcd 等系统就提供了这样的服务,它们不仅自己通过共识实现了线性化存储,而且还对外提供共识的语义,我们可以依托这些系统来实现各种需求:1.线性化 CAS2.操作全序3.故障检测后端2022年美团技术年货分布式系统相比单机系统,具
298、有两个独有的特点。部分失效缺少全局时钟面对这么多问题,如果一个理想的分布式数据系统,如果不考虑任何性能和其他的开销,我们期望实现的系统应该是这样的:整个系统的数据对外看起来只有一个副本,这样用户并不用担心更改某个状态时出现任何的不一致(线性一致性)。整个系统好像只有一个客户端在操作,这样就不用担心和其他客户端并发操作时的各种冲突问题(串行化)。所以我们知道,线性一致性和串行化是两个正交的分支,分别表示外部一致性中的最高级别以及内部一致性的最高级别。如果真的实现这个,那么用户操作这个系统会非常轻松。但很遗憾,达成这两方面的最高级别都有非常大的代价,因此由着这两个分支衍生出各种的内部一致性和外部一
299、致性。用 Jepsen 官网对这两种一致性的定义来说,内部一致性约束的是单操作对单对象可能不同副本的操作需要满足时间全序,而外部一致性则约束了多操作对于多对象的操作。这类比于 Java 的并发编程,内部一致性类似于 volatile 变量或 Atomic 的变量用来约束实现多线程对同一个变量的操作,而外部一致性则是类似于 synchronize或者 AQS 中的各种锁来保证多线程对于一个代码块(多个操作,多个对象)的访问符合程序员的预期。后端2022年美团技术年货型、内部一致性、外部一致性等角度来看。Kafka 中与复制模式相关的配置大致有下面几个:1.复制因子(副本数)2.min.insyn
300、c.replicas3.acks用户首先通过配置 acks 先大体知道复制模式,如果 ack=1 或者 0,则表示完全的异步复制;如果 acks=all 则代表完全的同步复制。而如果配置了异步复制,那么单分区实际上并不能保证线性一致性,因为异步复制的滞后性会导致一旦发生 Leader 变更可能丢失已经提交的消息,导致打破线性一致性的要求。而如果选择 ack=-1,则代表纯的同步复制,而此时如果没有 min.insync.replicas的限制,那样会牺牲容错,多副本本来是用来做容错,结果则是有一个副本出问题系统就会牺牲掉 Liveness。而 min.insync.replicas 参数给了用
301、户做权衡的可能,一般如果我们要保证单分区线性一致性,需要满足多数节点正常工作,因此我们需要配置min.insync.replicas 为 majority。而针对部分失效的处理,在实现复制时,kafka 将成员变更进行了外包,对于数据节点而言,托管给 Controller,直接由其指定一个新的主副本。而对于 Controller 节点本身,则将这个职责托管给了外部的线性存储 ZK,利用 ZK 提供的锁于租约服务帮助实现共识以达成主节点选举,而在高版本中,Kafka 去掉了外部的共识服务,而转而自己用共识算法实现 Controller 选主,同时元数据也由原来依赖 ZK 变为自主的Kraft 实
302、现的线性化存储进行自治。而在外部一致性范畴,目前低版本 Kafka 并没有类似事务的功能,所以无法支持多对象的事务,而高版本中,增加了事务的实现(详见 blog)。由于对象跨越多机,因此需要实现 2PC,引入了 TransactionCoordinator 来承担协调者,参考上面 2PC 的基本流程。一个大致的实现流程基本如下:首先向协调者获取事务 ID(后文统称 TID),然后向后端2022年美团技术年货图 21JepsenJepsen 主要有下面几个模块构成:1.DBNode(引擎本身的节点,存储节点)。2.ControlNode 控制节点,负责生成客户端,生成操作,生成故障等,其与DBN
303、ode 通常是 SSH 免密的。3.Client 客户端用于进行正常读写操作。4.Generator 用来生成计划。5.Nemesis 故障制造者。6.Checker 用来进行最后的一致性校验。我们团队使用 Jepsen 测试了 Kafka 系统的一致性,其中 Kafka 客户端与服务端的配置分别为:同步复制(ack=-1),3 复制因子(副本数),最小可用副本为 2(min.后端2022年美团技术年货TensorFlow 在美团外卖推荐场景的GPU 训练优化实践作者:家恒国庆等1.背景在推荐系统训练场景中,美团内部深度定制的 TenorFlow(简称 TF)版本1,通过CPU 算力支撑了美团
304、内部大量的业务。但随着业务的发展,模型单次训练的样本量越来越多,结构也变得越来越复杂。以美团外卖推荐的精排模型为例,单次训练的样本量已达百亿甚至千亿,一次实验要耗费上千核,且优化后的训练任务 CPU 使用率已达 90%以上。为了支持业务的高速发展,模型迭代实验的频次和并发度都在不断增加,进一步增加了算力使用需求。在预算有限的前提下,如何以较高的性价比来实现高速的模型训练,从而保障高效率的模型研发迭代,是我们迫切需要解决的问题。近几年,GPU 服务器的硬件能力突飞猛进,新一代的 NVIDIAA10080GBSXMGPU服务器(8 卡)2,在存储方面可以做到:显存 640GB、内存 12TB、SS
305、D10+TB,在通信方面可以做到:卡间双向通信 600GB/s、多机通信 8001000Gbps/s,在算力方面可以做到:GPU1248TFLOPS(TF32TensorCores),CPU96128 物理核。如果训练架构能充分发挥新硬件的优势,模型训练的成本将会大大降低。但TensorFlow 社区在推荐系统训练场景中,并没有高效和成熟的解决方案。我们也尝试使用优化后的 TensorFlowCPUParameterServer3(简称 PS)+GPUWorker的模式进行训练,但只对复杂模型有一定的收益。NVIDIA 开源的 HugeCTR4虽然在经典的深度学习模型上性能表现优异,但要在美团
306、的生产环境直接使用起来,还需要做较多的工作。美团基础研发机器学习平台训练引擎团队,联合到家搜推技术部算法效能团队、NVIDIADevTech 团队,成立了联合项目组。在美团内部深度定制的 TenorFlow以及 NVIDIAHugeCTR 的基础上,研发了推荐系统场景的高性能 GPU 训练架构后端2022年美团技术年货总结来说,CV、NLP 等场景的模型训练属于计算密集型任务,而且大多模型单张卡的显存都可以装下,这和 GPU 服务器的优势非常好地进行了匹配。但在推荐系统场景中,由于模型相对没有那么复杂,远端读取的样本量大,特征处理耗费 CPU 多,给单机 CPU 和网络带来较大的压力。同时面对
307、模型参数量大的情况,单机的 GPU显存是无法放下的。这些 GPU 服务器的劣势,恰恰都被推荐系统场景命中。好在 NVIDIAA100GPU 服务器,在硬件上的升级弥补了显存、CPU、带宽这些短板,但如果系统实现和优化不当,依然不会有太高的性价比收益。在落地 Booster 架构的过程中,我们主要面临如下挑战:数据流系统:如何利用好多网卡、多路 CPU,实现高性能的数据流水线,让数据的供给可以跟上 GPU 的消费速度。混合参数计算:对于大规模稀疏参数,GPU 显存直接装不下的情况,如何充分利用 GPU 高算力、GPU 卡间的高带宽,实现一套大规模稀疏参数的计算,同时还需要兼顾稠密参数的计算。3.
308、系统设计与实现面对上面的挑战,如果纯从系统的的角度去设计,难度较大。Booster 采用了“算法+系统”Co-design 的设计思路,让这代系统的设计大大得到简化。在系统实施路径上,考虑到业务预期交付时间、实施风险,我们并没有一步到位落地 Booster 的多机多卡版本,而是第一版先落地了 GPU 单机多卡版本,本文重点介绍的也是单机多卡的工作。另外,依托于 NVIDIAA100GPU 服务器强大的计算能力,单机的算力可以满足美团绝大多数业务的单次实验需求。3.1参数规模的合理化大规模稀疏离散特征的使用,导致深度预估模型的 Embedding 参数量急剧膨胀,数 TB 大小的模型一度流行于业
309、界推搜的各大头部业务场景。但是业界很快意识到,在硬件成本有限的情况下,过于庞大的模型给生产部署运维和实验迭代创新增添了沉重的负担。学术研究表明 10-13,模型效果强依赖于模型的信息容量,并非后端2022年美团技术年货3.2系统架构基于 GPU 系统的架构设计,要充分考虑硬件的特性才能充分发挥性能的优势。我们NVIDIAA100 服务器的硬件拓扑和 NVIDIADGXA1006比较类似,每台服务器包含:2 颗 CPU,8 张 GPU,8 张网卡。Booster 架构的架构图如下所示:图 1系统架构整个系统主要包括三个核心模块:数据模块,计算模块,通信模块:数据模块:美团自研了一套支持多数据源、
310、多框架的数据分发系统,在 GPU系统上,我们改造数据模块支持了多网卡数据下载,以及考虑到 NUMAAwareness 的特性,在每颗 CPU 上都部署了一个数据分发服务。计算模块:每张 GPU 卡启动一个 TensorFlow 训练进程执行训练。通信模块:我们使用了 Horovod7 来做分布式训练的卡间通信,我们在每个节点上启动一个 Horovod 进程来执行对应的通信任务。上述的设计,符合 TensorFlow 和 Horovod 原生的设计范式。几个核心模块可以相互解耦,独立迭代,而且如果合并开源社区的最新特性,也不会对系统造成架构性的冲击。我们再来看一下整个系统的简要执行流程,每张 G
311、PU 卡上启动的 TensorFlow 进程内部的执行逻辑如下图:后端2022年美团技术年货3.3关键实现3.3.1参数存储早在 CPU 场景的 PS 架构下,我们就实现了大规模稀疏参数的整套逻辑,现在要把这套逻辑搬到 GPU 上,首先要实现的就是 GPU 版本的 HashTable。我们调研了业界多种 GPUHashTable 的实现,如 cuDF、cuDPP、cuCollections、WarpCore等,最终选择了基于 cuCollections 实现 TensorFlow 版本的 GPUHashTable。究其原因,主要是因为实际业务场景中,大规模稀疏特征的总量通常是未知的,并且随时可
312、能出现特征交叉,从而致使稀疏特征的总量变化很大,这就导致“动态扩容”能力将成为我们 GPUHashTable 的必备功能,能够做到动态扩容的只有 cuCollec-tions 的实现。我们在 cuCollections 的 GPUHashTable 基础上实现了特殊接口(find_or_insert),对大规模读写性能进行了优化,然后封装到了 TensorFlow 中,并在其上实现了低频过滤的功能,能力上对齐 CPU 版本的稀疏参数存储模块。3.3.2优化器目前,稀疏参数的优化器与稠密参数的优化器并不兼容,我们在 GPUHashTable的基础上,实现了多种稀疏优化器,并且都做了优化器动量 F
313、usion 等功能,主要实现了 Adam、Adagrad、FTRL、Momentum 等优化器。对实际业务场景来说,这些优化器已经能够覆盖到绝大多数业务的使用。稠密部分参数可以直接使用TensorFlow 原生支持的稀疏/稠密优化器。3.3.2卡间通信实际训练期间,对于不同类型的特征,我们的处理流程也有所不同:稀疏特征(ID 类特征,规模较大,使用 HashTable 存储):由于每张卡的输入样本数据不同,因此输入的稀疏特征对应的特征向量,可能存放在其他 GPU 卡上。具体流程上,训练的前向我们通过卡间 AllToAll 通信,将每张卡的 ID 特征以 Modulo 的方式 Partition
314、 到其他卡中,每张卡再去卡内的GPUHashTable 查询稀疏特征向量,然后再通过卡间 AllToAll 通信,将第一后端2022年美团技术年货了 PS 架构下 PS/Worker 角色的功能。通 信 方 式:PS 架 构 下 PS/Worker 间 通 信 走 的 是 TCP(Grpc/Seastar),Booster 架构下 Worker 间通信走的是 NVSwitch(NCCL),任意两卡间双向带宽 600GB/s,这也是 Booster 架构的训练速度取得较大提升的原因之一。由于每张卡的输入数据不同,并且模型参数既有在卡间 Partition 存储的,也有在卡间 Replica 存储
315、的,因此 Booster 架构同时存在模型并行、数据并行。此外,由于NVIDIAA100 要求 CUDA 版本=11.0,而 TensorFlow1.x 版本只有 NV1.15.4才支持 CUDA11.0。美团绝大多数业务场景都还在使用 TensorFlow1.x,因此我们所有改造都是在 NV1.15.4 版本基础上开发的。以上就是 Booster 整体系统架构及内部执行流程的介绍。下文主要介绍在初步实现的Booster 架构的基础上,我们所做的一些性能优化工作。4.系统性能优化基于上述的设计实现完第一版系统后,我们发现端到端性能并不是很符合预期,GPU 的 SM 利用率(SMActivity
316、 指标)只有 10%20%,相比 CPU 并没有太大的优势。为了分析架构的性能瓶颈,我们使用 NVIDIANsightSystems(以下简称nsys)、Perf、uPerf 等工具,通过模块化压测、模拟分析等多种分析手段,最终定位到数据层、计算层、通信层等几方面的性能瓶颈,并分别做了相应的性能优化。以下我们将以美团外卖某推荐模型为例,分别从 GPU 架构的数据层、计算层、通信层,逐个介绍我们所做的性能优化工作。4.1数据层如前文所述,推荐系统的深度学习模型,样本量大,模型相对不复杂,数据 I/O 本身就是瓶颈点。如果几十台 CPU 服务器上的数据 I/O 操作,都要在单台 GPU 服务器上完
317、成,那么数据 I/O 的压力会变得更大。我们先看一下在当前系统下的样本数据流程,如下图所示:后端2022年美团技术年货图 5样本解析 profiling 结果究其原因,CTR 场景的训练样本通常包含了大量的 int64 类型的特征,int64 在 PB中是以 Varint64 类型数据存储的,ReadVarint64Fallback 方法就是用来解析 int64类型的特征。普通的 int64 数据类型需要占用 8 个字节,而 Varint64 针对不同的数据范围,使用了变长的存储长度。PB 在解析 Varint 类型数据时,首先要确定当前数据的长度,Varint 用 7bit 存储数据,高位
318、1bit 存储标记位,该标记位表示下一个字节是否有效,如果当前字节最高位为 0,则说明当前 Varint 数据在该字节处结束。我们实际业务场景的 ID 特征大多是经过 Hash 后的值,用 Varint64 类型表达会比较长,这也就导致在特征解析过程中要多次判断数据是否结束,以及多次位移和拼接来生成最终数据,这使得 CPU 在解析过程中存在大量的分支预测和临时变量,非常影响性能。以下是 4 字节 Varint 的解析流程图:图 6ProtoBuf Varint 解析流程图后端2022年美团技术年货4.1.3MemcpyH2D 流水线解析完样本得到特征数据后,需要将特征数据拉到 GPU 中才能执
319、行模型计算,这里需要通过 CUDA 的 MemcpyH2D 操作。我们通过 nsys 分析这块的性能,发现GPU 在执行期间有较多的停顿时间,GPU 需要等待特征数据 Memcpy 到 GPU 上之后才能执行模型训练,如下图所示:图 8nsys profiling 结果对于 GPU 系统的数据流,需要提前传输到离 GPU 处理器最近的显存中,才能发挥GPU 的计算能力。我们基于 TensorFlow 的 prefetch 功能,实现了 GPU 版本的PipelineDataset,在计算之前先把数据拷贝到了 GPU 显存中。需要注意的是 CPU内存拷贝到 GPU 显存这个过程,CPU 内存需要
320、使用 PinnedMemory,而非原生的PagedMemory,可以加速 MemcpyH2D 流程。4.1.4硬件调优在数据层的性能优化期间,美团内部基础研发平台的服务器组、网络组、操作系统组也帮助我们做了相关的调优:在网络传输方面,为了减少网络协议栈处理开销,提高数据拷贝的效率,我们通过优化网卡配置,开启 LRO(Large-Receive-Offload)、TCFlower 的硬件卸载、Tx-Nocache-Copy 等特性,最终网络带宽提升了 17%。在 CPU 性能优化方面,经过性能 profiling 分析,发现内存延迟和带宽是瓶颈。于是我们尝试了 3 种 NPS 配置,综合业务场
321、景和 NUMA 特性,选择了后端2022年美团技术年货我们把这个流程在 GPU 架构下也实现了一遍,并在其中加入了卡间同步流程,大规模稀疏特征的 AllToAll 通信及其反向梯度的 AllToAll 通信都在 EG 中执行,普通稀疏特征的反向梯度的卡间 AllGather 同步、稠密参数的反向梯度的卡间 AllReduce同步都在 MG 中执行。需要注意的是,在 GPU 场景中,EG、MG 是在同一个GPUStream 上执行 CUDAKernel 的,我们尝试过 EG、MG 分别在独立的 GPUStream 上执行,性能会变差,深层原因与 CUDA 底层实现有关,这个问题本身还在等待解决。
322、4.2.2算子优化及 XLA相比 CPU 层面的优化,GPU 上的优化更加复杂。首先对于 TensorFlow 的算子,还有一些没有 GPU 的实现,当模型中使用了这些 CPU 算子,会跟上下游的 GPU算子出现内存和显存之间的数据来回拷贝,影响整体性能,我们在 GPU 上实现了使用较为频繁、影响较大的算子。另外,对于 TensorFlow 这代框架,算子粒度是非常细的,可以方便用户灵活搭建各种复杂的模型,但这对 GPU 处理器来说却是一个灾难,大量的 KernelLaunch 以及访存开销导致不能充分利用 GPU 算力。对于 GPU上的优化,通常有两个方向,手工优化和编译优化。在手工优化方面
323、,我们重新实现了一些常用的算子和层(Unique、DynamicPartition、Gather 等)。以 Unique 算子为例,原生 TensorFlow 的 Unique 算子要求输出元素的顺序与输入元素的顺序一致,而在实际场景中,我们并不需要这个限制,我们修改了 Unique 算子的 GPU 实现,减少了因输出有序导致的额外执行的 GPUKernel。在编译优化方面,目前我们主要使用 TensorFlow 社区提供的 XLA9来做一些自动优化。原生 TensorFlow1.15 中的 XLA 正常开启可获得 10 20%端到端的性能提升。但 XLA 对算子动态 shape 不能很好地进
324、行支持,而推荐系统场景的模型中这种情况却非常常见,这就导致 XLA 加速性能不符合预期,甚至是负优化,因此我们做了如下的缓解工作:局部优化:对于我们手动引入的动态 shape 算子(如 Unique),我们进行了子后端2022年美团技术年货图 10nsys profiling 结果从图中可以看出,训练期间卡间通信耗时比较长,同时在通信期间 GPU 使用率也非常低,卡间通信是影响训练性能提升的关键瓶颈点。我们对通信过程进行拆解打点后发现,卡间通信(AllToAll、AllReduce、AllGather 等)协商的时间远远高于数据传输的时间:图 11Horovod timeline 结果分析具体
325、原因,以负责大规模稀疏参数通信的 AllToAll 为例,我们通过 NsightSystems 工具,观察到通信协商时间长主要是由于某张卡上的算子执行时间比较晚导致的。由于 TensorFlow 算子调度并不是严格有序,同一个特征的 embedding_lookup 算子,在不同卡上真正执行的时间点也不尽相同,某张卡上第一个执行embedding_lookup 算子在另一张卡上可能是最后一个执行,因此我们怀疑不同卡上算子调度的不一致性,导致了各张卡发起通信的时刻不同,并最终导致了通信协商时间过长。我们通过几组模拟实验也论证了确实是由算子调度导致的。对于这个问后端2022年美团技术年货它反向梯度
326、是 IndexedSlices 对象,卡间同步默认走的是 AllGather 通信,如果业务模型中对于 SparseVariables 的优化采用的是 Lazy 优化器,即每个 Step 只优化更新 Variable 中的某些行,此时对 SparseVariables 做合并,会导致其反向梯度从 IndexedSlices 对象转为 Tensor 对象,卡间同步变成 AllReduce 过程,就可能会影响到算法效果。对于这种情况,我们提供了一个开关,由业务去控制是否合并SparseVariables。经过我们的实测,在某推荐模型上合并 SparseVariables 会提高 5 10%的训练性
327、能,而对实际业务效果的影响在一个千分点以内。这两种算子融合的优化,不仅优化了卡间通信性能,对卡内计算性能也有一定的提升。经过这两种算子融合的优化,GPU 架构端到端训练速度提升了 85%,同时不影响业务算法的效果。4.4性能指标完成了数据层、计算层、通信层的性能优化后,对比我们的 TensorFlow3CPU场景,GPU 架构取得了 2 4 倍的性价比收益(不同业务模型收益不同)。我们基于美团外卖某推荐模型,使用单台 GPU 节点(A100 单机八卡)和同成本的 CPUCluster,分别对比了原生 TensorFlow1.15 和我们优化后的 TensorFlow1.15 的训练性能,具体数
328、据如下:后端2022年美团技术年货码本身的开发。我们将架构改动全都封装到了引擎内部,用户只需要一行代码就能从CPU 场景迁移到 GPU 架构:tf.enable_gpu_booster()实际业务场景,用户通常会使用 train_and_evaluate 模式,在跑训练任务的过程中同时评估模型效果。上了 Booster 架构后,由于训练跑的太快,导致 Evaluate 速度跟不上训练正常产出 Checkpoint 的速度。我们在 GPU 训练架构的基础上,支持了EvaluateonGPU 的能力,业务可以申请一颗 A100GPU 专门用来做 Evaluate,单颗 GPU 做 Evaluate
329、 的速度是 CPU 场景下单个 Evaluate 进程的 40 倍。同时,我们也支持了 PredictonGPU 的能力,单机八卡 Predict 的速度是同等成本下CPU 的 3 倍。此外,我们在任务资源配置上也提供了比较完善的选项。在单机八卡(A100 单台机器至多配置 8 张卡)的基础上,我们支持了单机单卡、双卡、四卡任务,并打通了单机单卡/双卡/四卡/八卡/CPUPS 架构的 Checkpoint,使得用户能够在这几种训练模式间自由切换、断点续训,方便用户选择合理的资源类型、资源量跑实验,同时业务也能够从已有模型的 Checkpoint 来 WarmStart 训练新的模型。5.2训练
330、效果相较 PS/Worker 异步模式的 CPU 训练,单机多卡训练时卡间是全同步的,因而避免了异步训练梯度更新延迟对训练效果的影响。然而,由于同步模式下每一步迭代的实际 BatchSize 是每张卡样本数的总和,并且为了充分利用 A100 卡的算力,我们会将每张卡的 BatchSize(单步迭代的样本数)尽量调大。这使得实际训练的 BatchSize(1 万 10 万)比 PS/Worker 异步模式(1 千 1 万)大很多。我们需要面临大Batch 下训练超参调优的问题 26,27:在保证 Epoch 不变的前提下,扩大 BatchSize 会导致参数有效更新次数减少,可能导致模型训练的效
331、果变差。我们采用 LinearScalingRule28的原则指导调整学习率。如果训练 BatchSize 较PS/Worker 模式的 BatchSize 增大 N 倍,将学习率也放大 N 倍即可。这种方式简后端2022年美团技术年货3https:/www.usenix.org/system/files/conference/osdi14/osdi14-paper-li_mu.pdf4https:/ YannLeCun,JohnS.Denker,andSaraA.Solla.Optimalbraindamage.InNIPS,pp.598605.MorganKaufmann,1989.11
332、KenjiSuzuki,IsaoHoriba,andNoboruSugie.Asimpleneuralnetworkpruningalgorithmwithapplicationtofiltersynthesis.NeuralProcess.Lett.,13(1):4353,2001.12 SurajSrinivasandR.VenkateshBabu.Data-freeparameterpruningfordeepneuralnetworks.InBMVC,pp.31.131.12.BMVAPress,2015.13 JonathanFrankleandMichaelCarbin.Thelo
333、tterytickethypothesis:Findingsparse,trainableneuralnetworks.In7thInternationalConferenceonLearningRepresentations,ICLR2019,NewOrleans,LA,USA,May6-9,2019.OpenR,2019.14 Hao-JunMichaelShi,DheevatsaMudigere,MaximNaumov,andJiyanYang.Compositionalembeddingsusingcomplementarypartitionsformemory-efficientrecommendationsystems.InProceedingsofthe26thACMSIGKDDInternationalConferenceonKnowledgeDiscovery&DataM