👉腾小云导读
老系统的代码,是每一个程序员都不想去触碰的领域,秉着能跑就行的原则,任由其自生自灭。本期就给大家讲讲,接手一套故障频发的复杂老系统需要从哪些地方着手。内容包括:代码串讲、监控建设和告警治理、代码缺陷修复、研发流程建设。在细节上,结合腾讯研发生态,介绍有哪些工具可以使用,也介绍一些告警治理、代码 bug 修复的经验、研发流程建设等。欢迎阅读。
👉看目录,点收藏
1 项目背景
2 服务监控
2.1 平台自带监控
2.2 业务定制监控
3 串讲文档
3.1 串讲文档是什么
3.2 为什么需要串讲文档
3.3 怎么输出串讲文档
4 代码质量
4.1 业务逻辑 bug
4.2 防御编程
4.3 Go-python 内存泄露问题
4.4 正确使用外部库
4.5 避免无限重试导致雪崩
4.6 真实初始化
4.7 资源隔离
4.8 数据库压力优化
4.9 互斥资源管理
5 警告治理
5.1 全链路超时时间合理设置
5.2 基于业务分 set 进行资源隔离
5.3 高耗时计算使用线程池
6 研发流程
7 优化效果总结
7.1 健全的 CICD
7.2 更完备的可观测性
7.3 代码 bug 修复
7.4 服务被调成功率优化
7.5 外部存储使用优化
7.6 CPU 用量下降
7.7 代码质量提升
7.8 其他优化效果
01、项目背景
内容架构为 QB 搜索提供内容接入、计算、分发的全套服务。经过多年的快速迭代,内容架构包括 93 个服务,光接入主链路就涉及 7 个服务,支持多种接口类型。其分支定制策略多且散,数据流向混杂,且有众多 bug。
项目组接手这套架构的早期,每天收到大量的业务故障反馈以及服务自身告警。即便投入小组一半的人力做运维支持,依旧忙得焦头烂额。无法根治系统稳定性缺陷的同时,项目组还需要继续承接新业务,而新业务又继续暴露系统缺陷,陷入不断恶化的负循环,漏洞百出。没有人有信心去承诺系统稳定性,团队口碑、开发者的信心都处于崩溃的边缘。
在此严峻的形势下,项目组全员投入启动稳定性治理专项,让团队进入系统稳定的正循环。
02、服务监控
监控可以帮助我们掌握服务运行时状态,发现、感知服务异常情况。通过看到问题 – 定位问题 – 修复问题来更快的熟悉模块架构和代码实现细节。下面分两部分介绍,如何利用监控达成稳定性优化。
2.1 平台自带监控
若服务的部署、发布、运行托管于公共平台,则这些平台可能会提供容器资源使用情况的监控,包括:CPU 利用率监控、内存使用率监控、磁盘使用率监控等服务通常应该关注的核心指标。我们的服务部署在123 平台(司内平台),有如下常用的监控。
平台自带监控:
外部资源平台监控:
- 数据库连接数监控: 检查服务使用 DB 是否全是长连接,使用完没有及时 disconnect 。
- 数据库慢查询监控: SQL 命令是否不合理,DB 表是否索引设置不合理。
- 数据库 CPU 监控: 检查服务是否全部连的 DB 主机,对于只读的场景可选择用只读账号优先读备机,降低 DB 压力。
其他诸如腾讯云 Redis 等外部资源也有相关的慢查询监控,亦是对应检查。
2.2 业务定制监控
平台自带的监控让我们掌控服务基本运行状态。我们还需在业务代码中增加业务自定义监控,以掌控业务层面的运转情况。
下面介绍常见的监控完善方法,以让各位对于业务运行状态尽在掌握之中。
2.2.1 在主/被调监控中增加业务错误码
一般来说,后台服务如果无法正常完成业务逻辑,会将错误码和错误详情写入到业务层的回包结构体中,然后在框架层返回成功。
这种实现导致我们无法从平台自带的主/被调监控中直观看出有多少请求是没有正常结果的。一个服务可能看似运行平稳,基于框架层判断的被调成功率 100%,但其中却有大量没有正常返回结果的请求,在我们的监控之外。
此时如果我们想要对这些业务错误做监控,需要上报这些维度:请求来源(主调服务、主调容器、主调 IP、主调 SET)、被调容器、错误码、错误数,这和被调监控有极大重合,存在资源浪费,并且主、被调服务都有开发成本。
若服务框架支持,我们应该尽可能使用框架层状态包来返回业务自定义的错误码。以tRPC框架为例服务的中,会上报框架级错误码和业务错误码:框架错误码是纯数字,业务错误码是。如此一来,就可以在主/被调服务的平台自带监控中看到业务维度的返回码监控。
2.2.2 在主/被调监控中注入业务标识
有时候一个接口会承担多个功能,用请求参数区分执行逻辑A / 逻辑B。在监控中,我们看到这个接口失败率高,需要进一步下钻是 A 失败高还是 B 失败高、A/B 分别占了多少请求量等等。
对这种诉求,我们可以在业务层上报一个带有各种主调属性的多维监控以及接口参数维度用于下钻分析。但这种方式会造成自定义监控和被调监控字段的重叠浪费。
更好的做法是:将业务维度注入到框架的被调监控中,复用被调监控中的主调服务、节点、SET、被调接口、IP、容器等信息。
2.2.3 单维属性上报升级成多维属性上报
单维属性监控指的是上报单个字符串(维度)、一个浮点数值以及这个浮点数值对应的统计策略。多维监控指的是上报多个字符串(维度)、多个浮点数值以及每个浮点数值对应的统计策略。
下面以错误信息上报场景为例,说明单维监控的缺点以及如何切换为多维上报。
作为后台服务,处理逻辑环节中我们需要监控上报各类关键错误,以便及时感知错误、介入处理。内容架构负责多个不同业务的接入处理,每个业务具有全局唯一的资源标识,下面称为 ResID。当错误出现时,做异常上报时,我们需要上报 “哪个业务”,出现了“什么错误”。
使用单维上报时,我们只能将二者拼接在一起,上报 string (ResID + “.” + ErrorMsg)。对不同的 ResID 和不同的 ErrorMsg,监控图是铺平展开的,只能逐个查看,无法对 ResID 下钻查看每类错误分别出现了多少次或者对 ErrorMsg 下钻,查看此错误分别在哪些 ID 上出现。
除了查看上的不便,多维属性监控配置告警也比单维监控便利。对于单维监控,我们需要对每一个可能出现的组合配置条件:维度值 = XXX,错误数 > N 则告警。一旦出现了新的组合,比如新接入了一个业务,我们就需要再去加告警配置,十分麻烦且难以维护。
而对于多维监控表,我们只需设置一条告警配置:对 ResID && 错误维度 下钻,错误数 > N 则告警。新增 ResID 或新增错误类型时,均会自动覆盖在此条告警配置内,维护成本低,配置简单。
03、串讲文档
监控可以帮助我们了解服务运行的表现,想要“深度清理”服务潜在问题,我们还需要对项目做代码级接手。在稳定性治理专项中,我们要求每个核心模块都产出一份串讲文档,而后交叉学习,使得开发者不单熟悉自己负责的模块,也对完整系统链路各模块功能有大概的理解,避免窥豹一斑。
3.1 串讲文档是什么
代码串讲指的是接手同学在阅读并理解模块代码后,系统的向他人介绍对该模块的掌握情况。代码串讲文档则是贯穿串讲过程中的分享材料,需要对架构设计、代码实现、部署、监控、理想化思考等方面进行详细介绍,既帮助其他同学更好的理解整个模块,也便于评估接手同学对项目的理解程度。
代码串讲文档通常包括以下内容:模块主要功能,上下游关系、整体架构、子模块的详细介绍、模块研发和上线流程、模块的关键指标等等。在写串讲文档的时候也通常需要思考很多问题:这个功能为什么需要有?设计思路是这样的?技术上如何实现?最后是怎么应用的?
3.2 为什么需要串讲文档
3.3 怎么输出串讲文档
增代码串讲文档的时候,需要从2个方面进行考虑——读者角度和作者角度。
作者角度: 需要阐述作者对系统和代码的理解和把握,同时也需要思考各项细节:这个功能为什么需要有、设计思路是怎样的、技术上如何实现、最后是怎么应用的等等。
读者角度: 需要考虑目标受众是哪些,尽可能地把读者当成技术小白,思考读者需要了解什么信息,如何才能更好地理解代码的实现和作用。
通常,代码串讲文档可以包含以下几个部分:
04、代码质量
代码质量很大程度上决定服务的稳定性。在对代码中业务逻辑 bug 进行修复的同时,我们也对服务的启动、数据库压力及互斥资源管理做优化。
4.1 业务逻辑bug
4.1.1 内存泄漏
如下图代码所示,使用 malloc 分配内存,但没有 free,导致内存泄露。该代码为 C 语言风格,现代 C++ 使用智能指针可以避免该问题。
4.1.2 空指针访问成员变量
如下图所示的代码,如果一个非虚成员函数没有使用成员变量,因编译期的静态绑定,空指针也可以成功调用该成员函数。但如果该成员函数使用了成员变量,那么空指针调用该函数时则会 core。该类问题在接入系统仓库中比较普遍,建议所有指针都要进行合理的初始化。
4.2 防御编程
4.2.1 输入防御
如下图所示,如果发生了错误且没有提前返回,request 将引发 panic。针对输入,在没有约定的情况下,建议加上常见的空指针判断及异常判断。
4.2.2 数组长度防御-1
如下图所示,当 url 长度超过 512 时,将会被截断,导致产出错误的url。建议针对字符串数组的长度进行合理的初始化,亦或者使用string来替代字符数组。
4.2.3 数组长度防御-2
如下图所示,老代码不判断数组长度,直接取值。当出现异常数据时,该段代码则会core。建议在每次取值时,基于上下文做防御判断。
4.2.4 野指针问题
下图中的ts指针指向内容和 create_time 一致。当 create_time 被 free 之后,ts 指针就变成了野指针。
该代码为 C 语言风格代码,很容易出现内存方面的问题。建议修改为现代 C++风格。
下图中,临时变量存储的是 queue 中的值的引用。当 queue pop 后,此值会被析构;而变量引用的存储空间也随之释放,访问此临时变量可能出现未定义的行为。
4.2.5 全局资源写防护
同时读写全局共有资源,尤其生成唯一 id,要考虑并发的安全性。
这里直接通过查询 DB 获取最大的 res_id,转成 int 后加一,作为新增资源的唯一 id。如果并发度超过 1,很可能会出现 id 重复,影响后续操作逻辑。
4.2.6 lua 添加 json 解析防御
如下图所示的 lua 脚本中,使用 cjson 将字符串转换 json_object。当 data_obj 不是合法的 json 格式字符串时,decode 接口会返回 nil。修复前的脚本未防御返回值为空的情况,一旦传入非法字符串,lua 脚本就会引发 coredump。
4.3 Go-python 内存泄露问题
如下图 85-86 行代码所示,使用 Go-python 调用 python 脚本,将 Go 的 string 转为PyString,此时 kv 为 PyObject。部分 PyObject 需要在函数结束时调用 DecRef,减少引用计数,以确保资源释放,否则会造成内存泄露。
判定依据是直接看 python sdk 的源码注释,如果有 New Reference , 那么必须在使用完毕时释放内存,Borrowed Reference 不需要手动释放。
4.4 正确使用外部库
4.4.1 Kafka Message 结束字符
当生产者为 Go 服务时,写入 kafka 的消息字符串不会带有结束字符 ”。当生产者为 C++ 服务时,写入 kafka 的消息字符串会带有结束字符 ”。
如下图 481 行代码所示,C++中使用 librdkafka 获取消费数据时,需传入消息长度,而不是依赖程序自行寻找 ” 结束符。
4.5 避免无限重试导致雪崩
如下图所示代码所示,失败之后立马重试。当出现问题时,不断立即重试,会导致雪崩。给重试加上 sleep,减缓下游雪崩的速度,留下缓冲的时间。
4.6 真实初始化
如果每次服务启动都存在一定的成功率抖动,需要检查服务注册前的初始化流程,看看是不是存在异步初始化,导致未完成初始化即提供对外服务。如下图 48 行所示注释,原代码中初始化代码为异步函数,服务对外提供服务时可能还没有完成初始化。
4.7 资源隔离
时延/成功率要求不同的服务接口,建议使用不同的处理线程。如下图中几个 service,之前均共用同一个处理线程池。其中 secure_review_service 处理耗时长,流量不稳定,其他接口流量大且时延敏感,线上出现过 secure_review_service 瞬时流量波峰,将处理线程全部占住且队列积压,导致其他 service 超时失败。
4.8 数据库压力优化
4.8.1 分批拉取
当某个表数据很多或单次传输量很大,拉取节点也很多的时候,数据库的压力就会很大。这个时候,可以使用分批读取。下图 308-343 行代码,修改了 sql 语句,从一批拉取改为分批拉取。
4.8.2 读备机
如果业务场景为只读不写数据,且对一致性要求不高,可以将读配置修改为从备机读取。mysql 集群一般只会有单个主机、多个备机,备机压力较小。如下图 44 行代码所示,使用readuser,主机压力得到改善。
4.8.3 控制长链接个数
需要使用 mysql 长链接的业务,需要合理配置长链接个数,尤其是服务节点数很多的情况下。连接数过多会导致 mysql 实例内存使用量大,甚至 OOM;此外 mysql 的连接数是刚性限制,超过阈值后,客户端无法正常建立 mysql 连接,程序逻辑可能无法正常运转。
4.8.4 建好索引
使用 mysql 做大表检索时,应该建立与查询条件对应的索引。本次优化中,我们根据 DB 慢查询统计,找到有大表未建查询适用的索引,导致 db 负载高,查询速度慢。
4.8.5 实例拆分
非分布式数据库 (如 mariaDB) 存储空间是有物理上限的,需要预估好数据量,数据量过多及时进行合理的拆库。
4.8.6 分布式数据库负载均衡
分布式数据库一般应用在海量数据场景,使用中需要留意节点间负载均衡,否则可能出现单机瓶颈,拖垮整个集群性能。如下图是内容架构使用到的 hbase,图中倒数两列分别为请求数和region 数,从截图可看出集群的 region 分布较均衡,但部分节点请求量是其他节点几倍。造成图中请求不均衡的原因是集群中有一张表,有废弃数据占用大量 region,导致使用中的 region 在节点间分布不均,由此导致请求不均。解决方法是清理废弃数据,合并空数据 region。
4.9 互斥资源管理
4.9.1 避免连接占用
接入系统服务的 mysql 连接全部使用了连接池管理。每一个连接在使用完之后,需要及时释放,否则该连接就会被占住,最终连接池无资源可用。下图所示的 117 行连接释放为无效代码,因为提前 return。有趣的是,这个 bug 和另外一个 bug 组合起来,解决了没有连接可用的问题:当没有连接可用时,获取的连接则会为空。该服务不会对连接判空,导致服务 core 重启,连接池重新初始化,又有可用的连接了。针对互斥的资源,要进行及时释放。
4.9.2 使用 RAII 释放资源
下图所示的 225 行代码,该任务为互斥资源,只能由一个节点获得该任务并执行该任务。GetAllValueText 执行完该任务之后,应该释放该任务。然而在 240 行提前 return 时,没有及时释放该资源。
优化后,我们使用 ScopedDeferred 确保函数执行完成,退出之前一定会执行资源释放。
05、告警治理
告警轰炸是接手服务初期常见的问题。除了前述的代码质量优化,我们还解决下述几类告警:
- 全链路超时配置不合理: 下游服务的超时时间,大于上游调用它的超时时间,导致多个服务超时告警轰炸、中间链路服务无效等待等。
- 业务未隔离: 某个业务流量突增引起全链路队列阻塞,影响到其他业务。
- 请求阻塞: 请求线程中的处理耗时过长,导致请求队列拥堵,请求消息得不到及时处理。
5.1 全链路超时时间合理设置
未经治理的长链路服务,因为超时设置不合理导致的异常现象:
- 超时告警轰炸: A 调用 B,B 调用 C,当 C 异常时,C 有超时告警,B 也有超时告警。在稳定性治理分析过程中,C 是错误根源,因而 B 的超时告警没有价值,当链路较长时,会因某一个底层服务的错误,导致海量的告警轰炸。
- 服务无效等待: A 调用 B,B 调用 C,当 A->B 超时的时候,B 还在等 C 的回包,此时 B 的等待是无价值的。
这两种现象是因为超时时间配置不合理导致的,对此我们制定了“超时不扩散原则”,某个服务的超时不应该通过框架扩散传递到它的间接调用者,此原则要求某个服务调用下游的超时必须小于上游调用它的超时时间。
5.2 基于业务分 set 进行资源隔离
针对某个业务的流量突增影响其他业务的问题,我们可将重点业务基于 set 做隔离部署。确保特殊业务只运行于独立节点组上,当他流量暴涨时,不干扰其他业务平稳运行,降低损失范围。
5.3 高耗时计算使用线程池
如下图红色部分 372 行所示,在请求响应线程中进行长耗时的处理,占住请求响应线程,导致请求队列阻塞,后续请求得不到及时处理。如下图绿色部分 368 行所示,我们将耗时处理逻辑转到线程池中异步处理,从而不占住请求响应线程。
06、研发流程
在研发流程上,我们沿用司内其他技术产品积累的 CICD 建设经验,包括以下措施:
07、优化效果总结
7.1 健全的CICD
7.1.1 代码合入
在稳定性专项优化前,内容架构的服务没有合理的代码合入机制,导致主干代码出现违背编码规范、安全漏洞、圈复杂度高、bug等代码问题。
优化后,我们使用统一的蓝盾流水线模板给所有服务加上流水线,以保证上述问题能被自动化工具、人工代码评审拦截。
7.1.2 服务发布
在稳定性优化前,内容架构服务发布均为人工操作,没有 checklist 机制、审批机制、自动回滚机制,有很大安全隐患。
优化后,我们使用统一的蓝盾流水线模板给所有服务加上 XAC 流水线,实现了提示发布人在发布前自检查、double_check 后审批通过、线上出问题时一键回滚。
7.2 更完备的可观测性
7.2.1 多维度监控
在稳定性优化前,内容架构服务的监控覆盖不全,自定义监控均使用一维的属性监控,这导致多维拼接的监控项泛滥、无法自由组合多个维度,给监控查看带来不便。
优化后,我们用更少的监控项覆盖更多的监控场景。
7.2.2 业务监控和负责人制度
在稳定性优化前,内容架构服务几乎没有业务维度监控。优化后,我们加上了重要模块的多个业务维度监控,譬如:数据断流、消息组件消息挤压等,同时还建立值班 owner、服务 owner 制度,确保有告警、有跟进。
7.2.3 trace 完善与断流排查文档建设
在稳定性优化前,虽然已有上报鹰眼 trace 日志,但上报不完整、上报有误、缺乏排查手册等问题,导致对数据处理全流程的跟踪调查非常困难。
优化后,修正并补全了 trace 日志,建设配套排查文档,case 处理从不可调查变成可高效率调查。
7.3代码bug修复
7.3.1 内存泄露修复
在稳定性优化前,我们观察到有3个服务存在内存泄露,例如代码质量章节中描述内存泄露问题。
7.3.2 coredump 修复 & 功能 bug 修复
在稳定性优化前,历史代码中存在诸多 bug 与可能导致 coredump 的隐患代码。
我们在稳定性优化时解决了如下 coredump 与 bug:
- JSON 解析前未严格检查,导致 coredump 。
- 服务还未初始化完成即接流,导致服务重启时被调成功率猛跌。
- 服务初始化时没有同步加载配置,导致服务启动后缺失配置而调用失败。
- Kafka 消费完立刻 Commit,导致服务重启时,消息未实际处理完,消息可能丢失/
- 日志参数类型错误,导致启动日志疯狂报错写满磁盘。
7.4 服务被调成功率优化
在稳定性优化前,部分内容架构服务的被调成功率不及 99.5% ,且个别服务存在严重的毛刺问题。优化后,我们确保了服务运行稳定,调用成功率保持在 99.9%以上。
7.5 外部存储使用优化
7.5.1 MDB 性能优化
在稳定性优化前,内容架构各服务对MDB的使用存在以下问题:低效/全表SQL查询、所有服务都读主库、数据库连接未释放等问题。造成MDB主库的CPU负载过高、慢查询过多等问题。优化后,主库CPU使用率、慢查询数都有大幅下降。
7.5.2 HBase 性能优化
在稳定性优化前,内容架构服务使用的 HBase 存在单节点拖垮集群性能的问题。
优化后,对废弃数据进行了清理,合并了空数据 region,使得 HBase 调用的 P99 耗时有所下降。
7.6 CPU 用量下降
在稳定性优化前,内容架构服务使用的线程模型是老旧的 spp 协程。spp 协程在在高吞吐低延迟场景下性能有所不足,且未来 trpc 会废弃 spp。
我们将重要服务的线程模型升级成了 Fiber 协程,带来更高性能和稳定性的同时,服务CPU利用率下降了 15%。
7.7 代码质量提升
在稳定性优化前,内容架构服务存在很多不规范代码。例如不规范命名、魔术数字和常量、超长函数等。每个服务都存在几十个代码扫描问题,最大圈复杂度逼近 100。
优化后,我们对不规范代码进行了清扫,修复规范问题,优化代码命名,拆分超长函数。并在统一清理后,通过 MR 流水线拦截,保护后续合入不会引入新的规范类问题。
7.8 其他优化效果
经过本轮治理,我们的服务告警量大幅度下降。以系统中的核心处理服务为例,告警数量从159条/天降低到了0条/天。业务 case 数从 22 年 12 月接手以来的 18 个/月下降到 4 个/月。值班投入从最初的 4+ 人力,降低到 0.8 人力。
我们项目组在完成稳定性接手之后,下一步将对全系统做理想化重构,进一步提升迭代效率、运维效率。希望这些经验也对你接管/优化旧系统有帮助。如果觉得内容有用,欢迎分享。
各位开发者接手过什么样的老项目或者老代码,遇到了什么难题和心得? 可以在公众号(点👉这里👈进入开发者社区,右边扫码即可进入公众号)评论区讨论分享。我们将选取1则最有创意的分享,送出腾讯云开发者-文化衫1个(见下图)。5月22日中午12点开奖。