本文以一个Web项目的业务代码重构实践作为依据,来探讨项目业务代码重构过程中遇到的开发问题,以及重构过程中的一些注意点,希望可以给项目开发和服务开发维护重构提供一些通用的参考与思路。
这里不探讨大型项目的重构实践,毕竟一个大型项目的重构,更偏重于架构体系完善更新与业务领域拆分,它所涉及的架构体系、人力资源、部门协调等等其他问题都具有很大的挑战。另外大部分开发所负责的仅是其中的一个服务或者模块,这里探讨的内容可能对拆分后的服务重构更具参考意义。
1.项目代码重构的背景
1.1 背景问题
2022年年初,我们小组接手了一个已经开发五六个月的项目,该项目正处于快速迭代时期。我们本以为迭代时间不长,接手之大概能很快上手轻松切入业务,但往往我们提到这个“但是”的时候,紧接着就是反转,理想和实际还是有差距的。众所周知,一般快速迭代赶进度的项目,都会存在或多或少的问题。我们遇到的这个项目也正好精准的在这个范围之内。我们在接手后第一次版本迭代中,已经提前考虑对项目不熟悉的情况,并做了一定的准备,但依然有不少非预期的情况出现。这一次的情况,让我们不得不再次提前评估以后迭代可能会遇到的问题!
1.2 需要改变现状
项目第一个版本迭代就出现难以预期的问题,这不合理!在解决完当前版本已经发现的问题之后,我们花费一些时间去大概梳理了一下原来的项目代码,看了不少接口的大致实现。本身看看代码这是个小事情,但是这一看不要紧,好多的接口实现的逻辑都存在问题(比如在循环中调用数据库查询,多层次无效缓存实现,缓存淘汰机制复杂性能差等等… ),这些问题涉及性能、稳定性、业务异常等多个方面。如果不去解决,除了影响用户体验,还会给我们的正常项目迭代和维护造成了极大的干扰!这个项目是用Python开发的内部项目,该项目本身是作为toB类型的内部工具来开发的。而目前因为项目业务场景的扩展,要越来越多的承担toC的功能,在得物App中使用场景也同样增加,这对项目性能和稳定性又带来了额外的挑战。我们迫切需要解决这些不稳定因素,快速的切入业务,开展正常的业务迭代,以满足需求的变化。
1.3 重构的初步想法
对于那些已经发现的问题,如果可以快速修复的,我们都进行了修复并验证发布,也明显取得了一些效果。但是还有不少有共性的大类问题充斥在代码中,使我们不能轻易的去对现有的代码动刀,这些问题也是亟待解决的。当时,正好结合公司部门技术栈统一(业务项目转为使用Java/Go语言)的要求,我们决定在切入业务的过程中,逐渐通过重构迭代,来提升性能,减少问题,并接入公司的技术基建体系,降低代码的维护成本。虽然有了初步的重构想法,但是这显然不是一件容易的事情! 重构之前,我们还有很多前置工作要做。
2.重构的前置工作
2.1 熟悉业务流程并分析问题与痛点
在正式重构之前,我们浏览了项目的交接文档,往期的产品文档,以此做到对项目整体产品流程做到心中有数。而后,我们根据现有的产品流程,评估了从头开发该项目应实施的主要架构和大致技术方案,经过评审完善再作为重构参照,这相当于给项目的整体重构优化提供了一个目标样板。用以上的方案作为参考的话,再分析整个产品流程闭环上的接口,我们很快可以定位到项目中存在的一系列问题,当然这些问题大多数都是表面的和宏观的,细节上的很多还需要我们在项目中进一步挖掘。
2.2 评估重构成本与重构推进方式
我们作出重构决定的时候,已经做完了现有项目的基础分析。现在我们将对比项目方案的差异,根据重构的难易程度和紧急程度来确定最终的处理方式。
难易程度 根据 方案差异、修改难度、稳定要求、影响范围来 综合评估紧急程度 根据 迭代复用、接口性能、异常频次、受益范围来 综合评估
处理方式分析如下表:
**难度 **紧急程度 | 紧急 | 中等 | 不急 |
---|---|---|---|
简单 | 重构 | 重构,顺序次后 | 重构,顺序再次 |
中等 | 重构 | 重构,顺序次后 | 重构,顺序再次 |
困难 | 重构( 或迁移观察 ) | 重构( 或迁移观察 ) | 待定 |
由于我们整体项目处于快速迭代当中,并且有限的人力都要去跟版本需求,所以重构迁移的时间是极度受限的,我们虽然定了模块的重构顺序,但是怎么抽调人力去跟进这些事情呢?需求迭代本身就涉及到大量的接口,如果我们本身已经将新的项目构建起来,这里迭代的接口顺势就可以迁移重构到新的项目代码库中去,在迭代的同时推进项目的重构。其他时候再加上一些零散的时间,我们可以逐步的将重构向前推进,但是在这种情况下,我们一定要接受渐进重构的时间跨度会很长这个实际情况。
2.3 完善并确定流量迁移方案
大多数WEB项目基本都会有这些通用架构,如下图所示:
在这样的通用架构里,我们既然切换了底层语言,那公司基建支撑的相应架构,就可以用起来了。
我们最终选择的具体流量迁移方案如下:
- 初始化新的项目仓库并完善发布部署流程
- 打通新老两个项目的用户认证方式并做到双向兼容(如果网关统一承接用户认证,可以省去这一步)
- 利用网关层的能力去配置转发规则,使用特定或者通用规则将接口流量导入新项目中(如果没有网关可以考虑简单接入Nginx配置转发)
- 服务端完成一个批次的接口重构迁移后,在测试环境切换流量到新项目并测试整体流程,通过测试再发布
- 重构中部分接口需要变更的,推进前端将调用切换到新接口
- 上线后跟踪流量与新接口功能状态,如有问题随时回滚
到目前为止,我们已经做好了所有的前置工作,那么现在我们 ready go !
3.重构中发现的典型问题与优化方案
3.1 基本运维监控体系完善
在完善运维体系方面,先统一整合了trace、日志、监控与告警。
优化点 | 问题 | 收益 | 典型思想 |
---|---|---|---|
全面接入trace | 无链路追踪功能 | 做到全链路监控 | 链路追踪 |
完善日志等级 | 日志分级不够完善 | 分级日志便于排查 | 日志系统 |
日志注入trace | 日志无trace不方便关联 | 日志有链路追踪 | 问题日志追踪 |
metric | 无监控信息 | 监控项目程序实时状态 | metric |
目前log、trace、metrics三者的整合打通也显得尤其重要,例如 OpenTelemetry 这样的工具提供成套的规范,可以让开发者快速集成。在企业基建可以支撑的情况下,可以选择这三者,如果不能支持,推荐按照以下顺序完善 日志、trace、日志注入trace信息、metric 整个体系。
看一下下面的实例展示:
trace信息可以帮助我们追踪每一次的调用链路
日志注入trace信息可让我们对独立调用链路日志做快速筛选和时间维度分析, 根据traceId追踪同一条链路数据
{
"level":"error",
"ts":"2022-07-22T21:26:00.073+0800",
"caller":"api/foo_bar.go:38",
"msg":"[foo]bar",
"error":"Post "https://xxx.com/abc/xxx": context deadline exceeded",
"traceId":"0aee15dc63f617e751d17060xcf74b9c",
"content":{
"body":"json str",
"resp":""
}
}
监控体系监测业务项目运行状态
3.2 重整业务逻辑
业务逻辑设计中,有一些问题对开发迭代有很大的阻碍,大概如下:
优化点 | 问题 | 收益 | 典型思想 |
---|---|---|---|
展示接口的数据写入逻辑迁移到数据写入接口非要不可则缓存中转 | 部分展示接口有写数据逻辑导致数据无限增长展示接口性能也差 | 削减无效数据写入提升查询性能 | 大部分系统都是读大于写读接口不做写逻辑避免无效数据写入 |
计数逻辑独立并使用缓存使用异步方式入库 | 计数逻辑依赖明细统计性能差需要独立计数并展示 | 释放数据库统计压力提升数据展示性能 | 预计算代替实时统计缓存代替DB查询 |
迁移信息表的计数字段独立为单表并先缓存 | 部分计数字段在基础表更新TPS高影响表结构变更 | 削减数据库TPS压力隔离冷热数据 | 冷热数据隔离 |
双向反查数据使用独立表替代简单的jsonStr | 双向关联的数据存储格式不好无法满足双向查询和变更更新写入难度过大 | 减少数据更新的交叉关联并提升查询性能 | 合理设计数据表(三大范式)简化双向关联的模型独立管理关联关系 |
改造不合理的缓存体系逐步精简缓存替换缓存结构和数据 | 缓存体系设计不合理相关缓存使用场景多流程长难以一次性迁移 | 清理无效缓存减少缓存回收难度提升缓存使用效果 | 缓存结构选择合理使用淘汰策略 |
业务流程从管理后台的增删改查,到客户端的展示并回收数据,这整个流程中,很容易出现一个问题点就会影响全流程的情况。以上几个问题在变更的过程中,也是会对整体业务流程有贯通影响的,特别需要注意,所以单列了出来!这几个点也是改造起来比较困难的,我们也是根据紧急程度以及整体的梳理进度进行逐步重构。
3.3 代码重构并解决细节问题
在具体的每一个接口或者任务脚本的重构过程中,我们也总结了之前开发遇到的一些典型问题,整理如下:
优化点 | 问题 | 收益 | 典型思想 |
---|---|---|---|
数据库查询网络 I/O 优化 | 列表类接口 循环网络I/O | 减少各类列表接口 RT | 最小化网络 I/O |
削减接口返回字段 | 接口返回无效字段过多 | 减少干扰、削减流量 | 减少网络带宽占用 |
统一返回值字段数据类型 | 弱类型语言类型乱用 | 统一数据类型,便于管理 | 强类型 |
分类树递归生成优化单次查询并重构复用 | 循环多层查询数据库 I/O过多生成算法复杂度略高 | 统一分类树生成集成过滤规则 | O(n)时间复杂度,递归并防止数据异常而死循环 |
非强关联逻辑功能拆分 | 部分接口业务逻辑隔离但是接口强相关 | 剥离不同模块为多个接口 | 分治 |
解决语法类问题 | 循环迭代中改变SET数据类型不匹配数据存储格式 等(Python) | 提升主流程稳定性杜绝答案提交异步流程中断 | 数据与语法兼容 |
抽离相同逻辑达成方法重用 | 相同逻辑方法分散不兼容部分逻辑缺失、维护难度高 | 复用方法与逻辑统一逻辑并降低维护难度 | 复用 |
离线数据同步优化 | 全量同步数据未分批不支持断点补偿 | 限制批次数量分批同步支持中断恢复 | 分批、控制上限中断补偿 |
优化批量导入数据处理 | 业务导入数据处理逻辑有问题关联比对多次查询且不用MAP | 减少算法时间复杂度提升处理速度 | 批量提取MAP对比的O(1)复杂度 |
Redis慢查询优化低效缓存优化清理或拆解大KEY | 未选对正确的数据结构未对数据结构选择正确操作方法例如: 存储set数据取单个元素使用 smembers 命令读取后比对,而不是使用 sismember | 部分smembers读取切换为sismember,RT>50ms查询平均减少5个/秒, 优化完几乎无RT>100ms请求,提升吞吐效率显著**** | 数据结构算法复杂度Redis单线程模型阻塞 |
缓存淘汰机制优化 | 部分缓存无有效淘汰机制代码SCAN淘汰管理难性能差 | 合理设计利用Redis自动过期机制 | 合理利用缓存淘汰机制 |
SQL索引调优 | 低效索引,交叉索引干扰 | 提升查询性能 | 索引调优 索引覆盖查询 |
异步消费脚本优化 | MQ消费任务的幂等、事务性、补偿 逻辑需要确认 | 防丢、防重、补偿处理 | 幂等、事务 等 |
配置迁移到配置中心 | 部分配置硬编码需要迁移 | 统一管理配置代码不包含敏感信息 | 分环境隔离配置隐藏重要信息 |
只有生产者无消费者队列待处理 | 生产向无消费队列无限注入数据导致队列无限膨胀 | 打通生产消费流程取消无用队列减少空间占用 | 队列有生产必有消费 |
以上这些典型问题都是经过提炼总结过后的了,看起来只是有限的几个,但项目代码库中,每一个问题点都可能出现数次甚至数十次,所以才不得不将整体的代码都重构。
下面列举一些实际的例子:
- 列表类接口循环网络I/O的优化
// 这里代码就不贴了,我大概做个说明:
// 数据库ORM使用时候处理粗糙,额外写的GET方法,独立查询关联表
// 在列表接口中循环获取该数据即造成数据库网络I/O的循环调用
// 而通过ORM预加载的方式,则是削减查询并二次组装的数据
// 当然我们也可以自己查询列表数据,然后先遍历提取外键,将关联数据转换为map接口,再次遍历列表来组装结果数据
- 大量数据处理的时候分批操作
// 大量数据处理分批次进行
// 一是考虑内存用量、二是考虑I/O交互流量、三是考虑处理分块时间、四是考虑中断恢复
// 例如: 从数据库批量获取数据,然后批量上传到某处
// 注意: 分页批处理需要保证每一页数据不变化,否则会有错漏或者重复
page := 1
pageSize := 10000
for {
// 判断已经存在的断点数据,将条件恢复成断点条件
// 直接 模拟查询的结构列表
dataList := make([]itemStruct{}, 0)
// 此处处理分批后的业务逻辑
......
// 处理完当前批次可以记录断点
// 断点可以缓存在数据库、Redis等其他媒介中
// 分页查询,查不到数据或者低于pageSize可以当成数据处理完了
if (len(dataList)
- Redis主从版大Key导致的慢查询优化前后对比
- 合理利用Redis的缓存淘汰机制(杜绝掉SCAN扫描的方式来淘汰KEY)
- 敏感数据转移到配置中心(例如这种硬编码的配置,需要迁移走)
4.重构经验总结
4.1 重构的成果收益
经过接近一年的渐进重构,我们大概完成了80%以上的功能模块,解决了以上提到的绝大部分问题,并且取得了很不错的提升。主要表现在以下几个方面:
- 已经全线接入trace、log、metric和告警,并可以根据这几个工具来指导项目维护和优化
- 业务稳定性明显提升,从刚接手时候的经常报错修BUG到现在几乎没有问题
- 不再有循环数据库查询I/O ,除了报表类接口,基本杜绝慢查询
- 部分重点接口的性能提升明显,有一些后台接口极端RT值从10s以上压缩到1s以内,C端主要接口RT的99线在150ms内(非高并发设计场景,勿喷)
- 可复用逻辑已尽量整合,完善并统一了之前不一致的业务逻辑,维护难度明显降低
- 长链路的数据提交整体流程无丢失数据的异常发生
- 业务方使用满意度提升
另外我们因为资源限制,截止目前,并未完成所有的重构工作,后续如果有资源投入,我们会继续推进这些问题的处理。
4.2 人员技能要求
我们也分析了本次重构对于开发人员的一些技能要求。在所有参与的开发人员都经验比较足的情况下,基本上可以通过自我驱动以及经验,来发现上面列举的这些问题,并主动去解决,而不需要额外的培训、规范以及纠错。对于经验不是很丰富的开发者,就需要适当的总结和规范去指导作业。抽取上面的问题来具体分析的话,不外乎如下这些技能:
- 熟练使用当前项目所需要的开发语言,避免产生一些基础的语言问题
- 对数据库有比较深的了解,能根据实际情况设计比较合理的数据结构并做到适当优化
- 对常用的中间件使用比较熟悉,了解一些原理,避免在使用的时候只知其一不知其二从而踩坑又难以排查
- 计算机的基础扎实,操作系统知识,算法与数据结构知识熟悉,可以写高效的代码
- 了解高并发高可用架构,可以根据一些基本思想来指导开发与优化
4.3 规范执行与Review
除了以上的技能,一些规范的制定与执行也十分重要,不过相反的是,这是自上而下的流程,需要一定的管理层面的推进。规范本身指定了行为边界,完善合理的规范制定之后,只要遵照执行,就可以避免绝大部分问题。另外Review制度可以促进规范的落地,避免空有规范而实践偏离的情况。现行的好设计,在经过一定的时间之后,随着业务需求变化以及用户量的提升,或许又需要开启新一轮的优化或者重构,这也是正常的。
5.让重构成为“小”事
5.1 任务阶段化来变“小”当前事项
纷繁复杂的整套重构流程,如果混在一起,可能会让人望而却步。但是通过具体分析,阶段拆解的方式,将任务切割成一件件“小”事情,可以让我们用比较容易的方式去一步步解决问题。另外拆解不是无脑拆分,还是需要有一个整体上的架构设计,否则可能会导致整个重构任务不能达到目标,既延长了时间,又难有成效。重构过程的质量把控,可以通过规范、强制lint检查、Review、单元测试、性能测试 等方式去保障,这在每一阶段都是要贯彻执行的。
5.2 开发的几个主要思路
其实还有很多我们项目中没有出现的问题,恕我们不能一一列举。但是在开发过程中本着几个主要的思路,我们就可以设计并开发出完善且高性能的项目。例如:
- 最小维护难度:系统设计结构完整、逻辑算法简洁高效
- 单个接口最小RT:接口性能要高,RT尽可能的小
- 最小限度的数据交互:接口请求参数以及返回值尽量精简,以节约网络带宽
- 异步削峰限流等:使用异步方式剥离额外逻辑,提升接口性能并提升用户体验
- 最少访问频次:了解计算机各个硬件以及网络I/O的性能层级,尽量减少长耗时的I/O,转换为程序内部处理
- 最少数据写入:数据写入需要尽量削减,减少流量以及无效数据的产生
- 缓存与缓存一致性:多级缓存、缓存一致性、缓存淘汰策略等思路
- 分治与隔离:该思想除了在拆分资源上体现(对象储存CDN剥离图片文件等内容),还可以在业务模块上体现出来(界定业务边界,合理拆分具体的模块与流程)
- 高可用性:注重稳定性,整体项目流程稳定无差错,降级与灾备需要提前考虑
5.3 提升技能与经验积累
当然,并不是掌握上面说的这些思路就高枕无忧了。例如:我们重构的项目之前就有通过异步方式来处理问题,但这个异步是因为本身接口未设计好导致性能受限,所以不得已采取的方案。本身一些长异步流程带来的数据不一致也会产生不少问题,在我们重构接口之后,就着手取消了这些异步任务,将其做成事务流程,显然就比之前的功能要好很多。所以我们还是要加深自己在计算机基础知识层面的认知,以及拓展完善开发知识体系,成体系的掌握高可用高并发系统设计,学习积累和总结开发经验,才能让自己在项目程序设计开发中游刃有余。
本文属得物技术原创,来源于:得物技术官网
得物技术文章可以任意分享和转发,但请务必注明版权和来源:得物技术官网
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net