点个关注跟腾讯工程师学技能

祖传代码重构:从25万行到5万行的血泪史

祖传代码重构:从25万行到5万行的血泪史

导语| 近期,咱们接管并重构了十多年前的 Query 了解祖传代码,代码量削减80%,功能、安稳性、可观测性都得到大幅度进步。本文将介绍重构过程中系统完结、DIFF修正、coredump 修正等方面的优化经验。

祖传代码重构:从25万行到5万行的血泪史

布景

一、接手

7 月份组织架构调整后,咱们组接手了查找链路中的 Query 了解根底模块,包含本次重构目标 Query Optimizer,担任 query 的分词、词权、紧密度、目的识别。

二、为什么重构

面对一份 10年+ 前史包袱较重的代码,大多数开发者以为“老项目和人有一个能跑就行”,不愿意对其做较大的改动,而咱们选择重构,主要有这些原因:

1.生产东西落后,无法运用现代 C++,多项监控和 TRACE 能力缺失

2.单进程内存消耗巨大——114G

3.服务不定期呈现耗时毛刺

4.进程发动需求 18 分钟

5.研效低下,一个简略的功用需求开发 3 人天

依据上述原因,也缘于咱们热爱挑战、勇于折腾,咱们决议进行拆迁式的重构。

祖传代码重构:从25万行到5万行的血泪史

编码完结

一、重写与复用

咱们对老 QO 的代码做剖析,归纳考虑三个因素:是否在运用、是否Query了解功用、是否高频迭代,将代码拆分为四种处理类型:1、删除;2、lib库引进;3、子库房引进;4、重写引进。

祖传代码重构:从25万行到5万行的血泪史

二、全体架构

老服务代码架构可谓灾难,全体遵守“想到哪就写到哪,需求啥就复制啥”的规划原则,彻底不考虑单一职责、接口阻隔、最少常识、模块化、封装复用等。下图介绍老服务的抽象架构:

祖传代码重构:从25万行到5万行的血泪史

恳求进来先后履行 3 次分词:

1.不带标点符号的分词成果,用于后续紧密度词权算子的核算输入;

2.带标点符号的分词成果,用于后续依据规则的目的算子的核算输入;

3.不带标点符号的分词成果,用于终究成果 XML queryTokens 字段的输出。

1 和 3 的仅有区别,便是调用内核分词的代码方位不同。

下一个环节,恳求 Query 分词时,分词接口中居然包含了 RPC 恳求下流 GPU 模型服务获取目的。这是此服务迭代最频频的功用块,当想要试验模型调整、增减目的时,需求在 QO 库房进行试验参数解析,将参数万里长征传递到 word_segmentor 库房的分词接口里,再依据参数修正 RPC 目的调用逻辑。一个简略参数试验,要修正 2个库房中的多个模块。规划上不符合模块内聚的规划原理,会形成霰弹式代码修正,影响迭代功率,又由于 Query 分词是处理链路中的耗时最长过程,不必要的串行增加了服务耗时,可谓一举三失。

除此之外,老服务还有其他各类问题:多个函数超过一千行,圈复杂度破百,接口界说 50 多个参数并且毫无注释,代码满地随意复制,从以下 CodeCC 扫描成果可见一斑:

祖传代码重构:从25万行到5万行的血泪史

祖传代码重构:从25万行到5万行的血泪史

新的服务求追架构合理性,确保:

1.类和函数完结遵守单一职责原则,功用内聚;

2.接口规划符合最少常识原则,只传入所需数据;

3. 每个类、接口都附上功用注释,可读性高。

项目架构如下:

祖传代码重构:从25万行到5万行的血泪史

CodeCC 扫描成果:

祖传代码重构:从25万行到5万行的血泪史

三、核心完结

老服务的恳求处理流程:

祖传代码重构:从25万行到5万行的血泪史

老服务选用的是原始的线程池模型。服务发动时初始化 20 条线程,每条线程别离持有本身的分词和目的目标,监听使命池中的使命。服务接口收到恳求则投入使命池,等待恣意一条线程处理。单个恳求的处理根本是串行履行,只少数并行处理了几类目的核算。

新服务中,咱们完结了一套依据 tRPC Fiber 的简略 DAG 控制器:

1.用算子数初始化 FiberLatch,初始化算子使命间的依靠关系

2.StartFiberDetached 发动无依靠的算子使命,FiberLatch Wait 等待悉数算子完结

3.算子使命完结时,FiberLatch -1 并更新此算子的后置算子的前置依靠数

4.核算前置依靠数规 0 的使命,StartFiberDetached 发动使命

经过 DAG 调度,新服务的恳求处理流程如下,最大化的进步了算子并行度,优化服务耗时:

祖传代码重构:从25万行到5万行的血泪史

祖传代码重构:从25万行到5万行的血泪史

DIFF 抹平

完结功用模块迁移开发后,咱们进入 DIFF 测验修正期,确保新老模块产出的成果共同。本来估计一周的 DIFF 修正,实践花费三周。处理掉逻辑过错、功用缺失、字典遗失、依靠版别不共同等问题。怎么才能更快的修正 DIFF,咱们总结了几个方面:DIFF 比照东西、DIFF 定位办法、常见 DIFF 原因。

一、DIFF 比对东西

工欲善其事必先利其器,经过比对东西找出存在 DIFF 的字段,再针对性地处理。由于老服务对外接口运用 XML 协议,咱们开发依据 XML 比对的 DIFF 东西,并依据排查时遇到的问题,为东西增加了一些特性选项:依据XML解析的DIFF东西。

咱们依据排查时遇到的问题为东西增加了一些特性选项:

1.支撑线程数量与 qps 设置(一些 DIFF 问题或许在多线程下才能复现);

2.支撑单个 query 多轮比对(某些模块成果存在必定波动,比如下流超时了或者每次核算浮点数都有必定差值,初期排查对每个query可重复恳求 3-5 轮,恣意一轮对上则以为无 DIFF ,待大块 DIFF 收敛后再履行单轮比照测验);

3.支撑忽略浮点数漂移差错;

4.在核算成果中打印出存在 DIFF 的字段名、字段值、原始 query 以便排查、手动盯梢复现。

二、DIFF 定位办法

获取 DIFF 东西输出的核算成果后,接下来便是定位每个字段的 DIFF 原因。

  • 逻辑流梳理承认

梳理核算该字段的处理流,承认是否有短少处理过程。对流程的梳理也有利于下面的排查。

  • 对处理流的多阶段查看输入输出

一个字段的核算在处理流中必定是由多个阶段组成,查看各阶段的输入输出是否共同,以缩小排查规模,再针对性地到不共同的阶段排查细节。

例如原始的分词成果在 QO 上是调用分词库取得的,当发现终究返回的分词成果不共一同,首要查看该接口的输入与输出是否共同,假如输入输出都有 DIFF,那阐明是恳求处理逻辑有误,排查恳求处理阶段;假如输出无 DIFF,可是终究成果有DIFF,那阐明对成果的后处理中存在问题,再去排查后处理阶段。以此类推,选用二分法思维缩小排查规模,然后再到存在 DIFF 的阶段详尽排查、查看代码。

查看 DIFF 常见有两种方法:日志打印比对, GDB 断点盯梢。选用日志打印的话,需求在新老服务一同加日志,发版发动服务,而老服务发动需求 18 分钟,排查功率较低。因而咱们在排查过程中主要运用 GDB 深化到 so 库中打断点,比照变量值。

三、常见 DIFF 原因

  • 外部库的恳求共同,输出不共同

这是很头疼的 case,分明调用外部库接口输入的恳求与老模块是彻底共同的,可是从接口获取到的成果却是不共同,这种状况或许有以下原因:

1.初始化问题:遗失要害变量初始化、遗失字典加载、加载的字典有误,都有或许会形成该类DIFF,由于外部库不必定会由于遗失初始化而返回过错,乃至外部库的初始化函数加载错字典都不必定会返回 false,所以对于依靠文件数据这块需求详尽查看,确保需求的初始化函数及对应字典都是正确的。

有时或许知道是初始化有问题,但找不到是哪里初始化有误,此刻能够用 DIFF 的 query,深化到外部库的代码中去,新老两模块一同单步调试,看看成果从哪里开端呈现误差,再依据那附近的代码推测出或许原因。

2.环境依靠:外部库往往也会有许多依靠库,假如这些依靠库版别有 DIFF,也有或许会形成核算成果 DIFF。

  • 外部库的输出共同,处理后成果不共同

这种状况即是对成果的后处理存在问题,假如承认已有逻辑无误,那或许原因是老模块本地会有一些调整逻辑 或 屏蔽逻辑,把从外部库拿出来原始成果结合其他算子成果进行本地调整。例如老 QO 中的百科词权,它的原始值是分词库出的词权,结合老 QO 本地的老紧密度算子进行了 3 次成果调整才得到终究值。

  • 将老模块代码重写后输出不共同

重构过程中对大量的过期写法做重写,假如怀疑是重写导致的 DIFF,能够将原始函数代替掉重写的函数测一下,承认是重写函数带来的 DIFF 后,再详尽排查,实在看不出能够在原始函数上一小块一小块的重写。

  • 恳求输入不共同

或许原因包含:

1.短少 query 预处理逻辑:例如 QO 输入分词库的 query 是将原始 query 的各短语经过空格分隔的,且去除了引号;

2.query 编码有误:例如 QO 输入分词库的 query 的编码流程经过了:utf16le → gb13080 → gchar_t (内部自界说类型) → utf16le → char16_t;

3.短少接口恳求参数。

  • 预期内的随机 DIFF

某些库/事务逻辑本身存在预期内的不安稳,比如排序时未运用 stable_sort,数组元素分数共一同,不能确保两次核算得出的 Top1 是同一个元素。遇到 DIFF 率较低的字段,需依据终究成果的输入值,成果核算逻辑排除事务逻辑预期内的 DIFF。

祖传代码重构:从25万行到5万行的血泪史

coredump 问题修正

在进行 DIFF 抹平测验时,咱们的测验东西支撑多线程并发恳求测验,等于一同也在进行小规模安稳性测验。在这段期间,咱们根本每天都能发现新的 coredump 问题,其中部分问题较为稀有。下面介绍咱们遇到的一些典型 CASE。

一、栈内存被损坏,变量值随机反常

如第 2 章所述,分词库属于不涉及 RPC 且未来不迭代的模块,咱们将其在 GCC 8.3.1 下编译成 so 引进。在安稳性测验时,进程会在此库的多个不同代码方位溃散。没有修正一行代码挂载的 so,为什么老 QO 能安稳运转,而咱们会花式 coredump?本质上是由于此代码前史上未注重编译告警,代码存在潜藏缝隙,晋级 GCC 后才暴露出来,主要是如下两种缝隙:

1.界说了返回值的函数实践没有 return,栈内存数据反常。

2.sprintf 越界,栈内存数据反常。

排查这类问题时,需求归纳上下文查看。以下图老 QO 代码为例:

祖传代码重构:从25万行到5万行的血泪史

sprintf 将数字以 16 进制方法输出到 buf_1 ,输出内容占 8 个字节,加上 ‘\0’ 实践需 9 个字节,但 buf_1 和 buf_2 都只申请了 8 个字节的空间,此处将栈内存损坏,栈上的变量 query_words 值就反常了。

反常的表现方法为,while 循环的第一轮,query_words 的数组巨细是 x,下一轮 while 循环时,还没有 push 元素,数组巨细就变成了 y,因内存被写坏,导致反常新增了 y – x 个不明物体。在后续逻辑中,只需访问到这几个反常元素,就会发生溃散。

光盯着 query_words 数组,发现不了问题,由于数组的变幻直接不符合根本法。处理此类问题,需联络上下文剖析,最好是将代码单独提取出来,在单元测验/本地客户端测验复现,缩小代码规模,能够更快定位问题。而当代码量较少,编译器的 warning 提示也会愈加明显,辅助咱们定位问题。

上段代码的编译器提示信息如下:(开启了 -Werror 编译选项)

祖传代码重构:从25万行到5万行的血泪史

二、恳求处理中运用了线程不安全的目标

在代码接手时,咱们看到了老的分词模块“奇怪”的初始化姿势:一部分数据模型的初始化函数界说为 static 接口,在服务发动时大局调用一次;另一部分则界说为类的 public 接口,每个处理线程中结构一个目标去初始化,为什么不统必界说为 static,在服务发动时进行初始化?每个线程都持有一个目标,不是会糟蹋内存吗?没有深究这些问题,咱们也就错过了问题的答案:由于老的分词模块是线程不安全的,一个分词目标只能一同处理一个恳求。

新服务的恳求处理完结是,界说大局管理器,管理器内挂载一个仅有分词目标;恳求进来后一致调用此分词目标履行分词接口。当 QPS 稍高,两个恳求一同进入到线程不安全的函数内部时,就或许把内存数据写坏,从而发生 coredump。

为处理此问题,咱们引进了 tRPC 内支撑使命盗取的 MQ 线程池,运用 c++11 的 thread_local 特性,为线程池中的每个线程都创建线程私有的分词目标。恳求进入后,往线程池内抛入分词使命,单个线程一同只处理一个恳求,处理了线程安全问题。

三、tRPC 结构运用问题

  • 函数内局部变量较大 && v0.13.3 版 tRPC 无法正确设置栈巨细

安稳性测验过程中,咱们发现服务会概率性的 coredump 在老朋友分词 so 里,20 个字以内的 Query 能够安稳运转,超过 20 个字则有或许会溃散,但老服务的 Query 最大长度是 40 个字。从代码来看,函数中依据 Query 长度界说了不同长度的字节数组,Query 越长,暂时变量占据内存越大,那么或许是栈空间缺乏,引发的 coredump。

依据这个剖析,咱们首要尝试运用 ulimit -s 指令调整系统栈巨细限制,毫无效果。经过在码客上搜寻,了解到 tRPC Fiber 模型有独立的 stack size 参数,咱们又满怀希望的给结构配置加上了 fiber stack size 特点,然而还是毫无效果。

无计可施之下,咱们将溃散处相关的函数提取到本地,别离用纯粹客户端(不运用 tRPC), tRPC Future 模型, tRPC Fiber 模型承载这段代码逻辑,循环测验。成果只要 Fiber 模型的测验程序会溃散,而 Future / 本地客户端的都能够安稳运转。

终究经过在码客咨询,得知咱们选用的结构版别 Fiber Stack Size 设置功用恰好有问题,无法正确设置为事务配置值,晋级版别后,问题处理。

  • Redis 衔接池形式,不能一同运用一应一答和单向调用的接口

咱们尝试打开成果缓存开关后,“惊喜”的发现新的 coredump,并且是 core 在了 tRPC 结构层。与 tRPC 结构开发同事协作排查,发现原因是 Redis 采纳衔接池形式衔接时,不行一同运用一应一答接口和单向调用接口。而咱们为了极致功能,在读取缓存履行 Get 指令时运用的是一应一答接口,在缓存更新履行 Set 指令时,选用的是单向调用方法,引发了 coredump。

快速处理此问题,咱们将缓存更新履行 Set 指令也改为了应对调用,后续调优再改为异步 Detach 使命方法。

祖传代码重构:从25万行到5万行的血泪史

重构效果

终究,咱们的成果如下:

【DIFF】

– 算子功用成果无 DIFF

【功能】

– 均匀耗时:优化 28.4% (13.01 ms -> 9.31 ms)

– P99 耗时:优化 16.7%(30ms -> 25ms)

– 吞吐率:优化 12%(728qps—>832qps)

【安稳性】

– 上游主调成功率从 99.7% 进步至 99.99% ,消除不定期的 P99 毛刺问题

– 服务发动速度从 18 分钟 优化至 5 分钟

– 可观察可盯梢性进步:建造服务主调监控,缓存命中率监控,支撑 trace

– 规范研制流程:单元测验覆盖率从 0% 进步至 60%+,建造完好的 CICD 流程

【成本】

– 内存运用下降 40 G(114 GB -> 76 GB)

– CPU 运用率:根本持平

– 代码量:削减 80%(25 万行—> 5万行)

【研制功率】

– 需求 LeadTime 由 3 天下降至 1 天内

附-功能压测:

(1)不带cache:新 QO 优化均匀耗时 26%(13.199ms->9.71ms),优化内存 32%(114.47G->76.7G),进步吞吐率 10%(695qps->775qps)

祖传代码重构:从25万行到5万行的血泪史

(2)带cache:新 QO 优化均匀耗时 28%(11.15ms->8.03ms),优化内存 33%(114G->76G),进步吞吐率 12%(728qps->832qps)

祖传代码重构:从25万行到5万行的血泪史

腾讯工程师技能干货直达:

1.超强总结!GPU 烘托管线和硬件架构

2.从鹅厂实例动身!剖析Go Channel底层原理

3.快收藏!最全GO语言完结规划形式【下】

4.怎么成为优秀工程师之软技能篇

阅览原文