系统重构实战

时至今日,我已是重构“专业户”,同一个业务域已经落地两次重构,而且新的微重构也在进行中。两次重构,前一次算惨胜,即使熬过来了,一大堆bug导致多次回切不说,由于代码结构不合理,重构周期拖得很长很长,后续需求迭代在这上面也是苦不堪言;第二次却是比较成功的,同个地方没有跌倒两次,算是有所成长。回顾两次历程,有这么几个点必须拿出来分享下。

一,首先,要确立好重构目标,控制好复杂度。

目标是什么,永远是一个项目的难题,比如这里罗列了场景的一些目标,以及对应的关键风险点。

目标 \ 复杂度 数据表重新设计 业务逻辑代码重组 数据迁移 国标问题或框架约束
替换开发语言,比如php换java 不需要 不需要 不需要 细节多
替换技术栈,比如替换rpc框架 不需要 不需要 不需要 细节多
业务域拆分,比如拆分商品中心 微调 少量调整 需要
系统分层拆分,比如拆分业务聚合层与领域能力层 不需要 需要 不需要
多业务线整合,比如既支持同城司机又支持跨城司机 必要 用设计模式支持 需要
改善系统的可维护性,比如复杂的计价逻辑 不需要(除非原设计很糟糕) 需要 不需要

这里所谓的风险点,是指会明显增加复杂度,更容易产生bug,明显增加开发、测试、灰度放量的工作量。

如果数据表需要重新设计,那工作量和复杂度无疑是最大的。比如以前的设计是按身份证可以查到唯一司机,重新设计后可能变成需要按身份证+软删状态,甚至加业务线标识才能查到唯一司机,不仅仅是代码改动大,还包括接口边界变化,还包括链路上下游要跟着一起对齐。

如果业务逻辑代码需要重组,也是风险巨大的。比如业务逻辑中经常会伴随一些“负负得正”的错乱代码,看起来不对跑起来却是正确的,改成“正确”后反而就不对了。。。又比如,一些过于复杂超前的设计,宣讲传递不到位,导致开发的同学理解偏差了写错了。。。

相比之下,数据迁移反而还算“简单”的了。只要方案本身做好双写和幂等,有什么坑都是前期设计可预见的,而不是落地过程中隐藏的。

至于国标问题,或者说是编码、规范之类的问题,问题虽小却非常缠人,比方说java的web强制要求urlencode、弱类型语言搬到强类型遭遇各种格式转换与空指针等等。

可见,重构圈定的目标越多,复杂度和风险是会越高的,而且设计是完全不同的;而圈定的目标少了,则可能错过宝贵的重构窗口,把技术债务留到了“下一次”。

二,业务梳理很重要!!!

除非开发同学对代码和业务场景如数家珍倒背如流,否则还是老老实实做业务梳理以免翻车。但我们做业务梳理的时候经常很迷茫,尤其是新接手系统的同学,不知道如何下手,这里有些思路总结可以参考。

1.重要业务场景的端到端用例图(或流程图或时序图)

开发问:传参是啥?哪些必填?找谁联调?

测试问:我测试用例怎么写?

举例,目标接口是个用户注册,那么整个端到端用例是怎样的。比如用户填手机->发验证码->调用本域的注册接口->防刷验证->插入数据库->异步通知新客系统->同步返回成功等等。

此番梳理下来就清楚了业务场景、接口边界与交互等等。当然了,再补个应用拓扑图或分层架构图就更加完整了。例如业内最流行的分层架构:

界面层的:用户端、CRM、运营管理端 X 业务线1、2、3

应用层的:列表页、商详页、下单流程、履约流程、采购流程、运营活动流程等等

领域层的:商品域、订单域、TMS、WMS、优惠券等等

然后这块一定要好好花时间评审或宣讲,这块的评审对后续的设计、开发编码的效率、测试的效率都是磨刀不误砍柴工的。这块占到工期的三分之一都不过分,绝对值得的。

2.接口列表(或功能点清单)

接口列表梳理出来基本可以出开发排期了。但接口列表除了接口本身,还需要关注接口背后的业务重要性、qps访问量等等。

如果条件允许,最好把上游调用方和传参格式都梳理清楚。有时候,有的接口表面看是一个接口,可能背后是七八个接口的工作量。。。而且借着这传参格式的梳理,可以适当做些职责边界的拆分,可能把这个接口拆成三个则工作量反而简化了五倍。例如:

  • 接口既可以按id查也可以按手机号查?按手机号要去查另一个表?
  • 传code则字段a,b,c必填?不传code则删除并插入?排查问题时如何判断到底是插入还是更新???
  • 批量接口,却用于C端请求量极大的查询,跟管理端事务还混用?不加缓存则扛不住,加缓存则管理端事务不一致?

你需要给场景打标和拆分。。。高qps、核心链路、批量查询、复杂事务写入等等。

3.模型设计

也不一定非要说领域模型什么的,很多时候数据库模型本身就是领域模型。当然了,除非是针对性的业务线整合重构,或数据库瓶颈突破的重构,否则数据库模型不会动也不敢动。

模型设计(或者是梳理),核心的关注点首先是表维度

  • 比如,一行用户记录究竟是手机唯一、还是身份证唯一、还是微信号唯一?
  • 又比如,拿着用户id查信息,会不会查出同id的另一个业务线的用户,因为这id不是全业务线维度的uuid?
  • 又比如,下单创建的订单,跟发货时是不是同一种单?如果是,那么不同商家发货呢?从两个不同仓发货呢?

然后则是关键状态字段,比如商品审核状态、上下架状态,售罄是判断库存数还是改为下架状态等等。

再然后则是表规模的策略,是分库分表设计,还是按时间归档等等。

三,关键架构选型要确定好

1.应用架构设计

一说起架构,不同人会在不同层面提很多不同的架构名词。最顶层的可能会提企业架构、战略架构,这个层面不展开。重构通常也不会涉及到这个层面的。应用的分层架构图也在上一步业务梳理中已经产出(如果是做拆分,则还会重新设计)。除了升级技术框架外,重构最常见的目标是提升性能、提升系统可维护性、拓宽多业务线维度支持。

针对提升性能这个目标,第一反应当然是加缓存、建视图。而这背后便是有名的CQRS命令与查询职责隔离模式。比如对应用做简单的CQRS拆分,可能性能和系统稳定性就有了质的飞跃。但是不要片面理解为读写分离哦,事务性质的查询还是应该属于“命令”的。

针对系统可维护性,则可能需要通过设计模式来优化。最最常见就是把“规则”设计成责任链模式,比如用户匹配最近门店需要四层if-else,可以改成四个filter;比如计价系统需要匹配距离、套餐、附加费、优惠减免等等,也可以设计成filter。否则,四层16个分支,还有些跨维度交叉,需求变更的时候怎么改得动。。。yrd博客园原版

针对拓宽多业务线支持的目标,这种硬核的目标,就是得动到数据库表设计,而且这跟具体公司具体业务有关,没有参考答案,表设计明确了其他东西也就逐渐清晰。最典型的例子便是商品模型。可能设计的结果是加了个business_type字段,但即便只是这么“简单”,也是伤筋动骨的,更何况业务线的差异并不是这么简单的。

  • 同一商品,华南区的货不能卖到华北区。。。
  • b2c业务线按spu售卖,o2o业务线按sku售卖,都有组合商品。。。
  • 特卖业务线的商品是按每天迭代一批,每天slogan、折扣价都不一样,甚至连图片都不一样。。。
  • BI要拿你的商品数据给不同业务线做报表。。。

完成表设计后,有可能还伴随着服务的拆分。服务拆分经常会陷入一种疑惑,明明我一个DAO就能搞定的事情,为什么要拆成一个远程调用,有时还会变成个分布式事务问题。所以服务拆分的收益是啥呢?是能力的复用,比如把商品服务拆出来,是不是新一条业务线比如o2o比如微商什么的,直接接入商品中心就能用,后续对接CRM对接订单系统,因为是同套商品服务,对接很容易就对齐。

2.服务治理架构

这是个大话题,通常不用选是最好的选择,公司原有体系如果成熟的话就不需要费心思去选了。但如果要选的话。。。微服务、mesh、slb。。。

  • 如果原语言体系是php,那么大概率是slb挂载服务域名,然后每台机还会有个nginx/apache。
  • 如果是老一代微服务,则通常是服务的具体节点间长连接通信,借助注册中心做路由规则。
  • 至于mesh,较常见是k8s架构才比较多采用mesh架构。

在选型上,mesh是非常亲和slb的,本身就是个支持跨语言的架构;微服务,则多少得看运气了,比如遇上dubbo2.5搭配k8s,则需要做网络上做很多很多功夫。

总体上讲就是考量:是否跨语言、是否容器k8s、是否有原框架的约束等等。每家公司的约束条件差异非常大,很难标准地做方案,要具体问题具体分析。(如果没有历史包袱的,直接挑个成熟的全家桶吧。)

3.切流网关

罕见哪家公司有魄力到全链路完全升级重构的吧,总会有个分批分步。上线也总得有个灰度切流的。那么此时就需要个切流网关。这里也有几种范式可以“抄作业”。

  运维复杂度 开发复杂度 跨语言/特定微服务体系兼容 复杂规则定制 urlencode等协议转换
在旧应用做转发 极简单 简单 较差 易定制 易定制
nginx体系网关(例如kong) 较复杂 有成熟插件 很兼容 有一定门槛 兼容性强
java体系网关(zuul或webflux自建) 较简单 较复杂 易定制 易定制 兼容较差

4.避免过度设计

一个应用拆分多少个模块?要不照着DDD或kola做个四五六七层的模块拆分吧?明明一句sql按id查询已经搞定的事情,写完mapper写repository再写service再写controller,上游还得给你套个防腐层adapter。。。如果你的应用是个复杂的大单体,其实是需要分模块的没毛病。

问题是你已经在应用层面做了拆分啊!mq交互已经拆出去了,还哪来的integration?缓存已经顶上去到视图层了,还哪来的repository?况且你不是用Cachable注解实现吗?你真的会哪天把mybatis替换成hibernate却不新起应用灰度放量上线?你写的service接口,在你的整个职业生涯中,真的见过有第二个实现类?(如果有,你早就换个别的名字了吧比如SPI比如manage)

怎么说呢,方法论设计模式本身是好的是,是没问题的,但需要具体应用具体分析。单个应用的复杂度多高颗粒度多大,到底需不需要这么多分层,又或者其实在拆服务拆应用的时候这些复杂性早就在服务层面拆出去了呢。

四,系统的非功能性指标是不能掉以轻心的

1.容量评估

通常办法就是压测评估机器数。要做细致当然也很多活,比如基准qps设定、用例的选取、配套的监控和问题定位等等。这块难不倒专业的测试同学和开发同学。

2.中间件参数

一般来说,重点关注池子大小、自动重连机制、超时配置。池子小了得堆机器,没有重连的话公有云抖一抖就瘫了,超时太长也是抖一抖就瘫了。

2.1.web容器参数(举例tomcat)

代码:org.springframework.boot.autoconfigure.web.ServerProperties.Tomcat

配置:server.tomcat.

maxThreads  最大worker数  默认200是相对合理的,极致情况才会去优化

maxConnections  最大连接数  默认8192

acceptCount  连接的等待队列  默认100

mbeanregistry.enabled  打开mbean和监控  慎重开启,有少量性能损耗,可能存在安全隐患

2.2.db参数(举例hikari)

hikari配置:

connectionTimeout  获取连接的等待时间  默认30s,可以改短

maximumPoolSize  最大连接数  一般建议比web工作数一样比如200,异步操作多的则改大些

maxLifetime  连接最大存活时间  默认30min太长,改短些有利于抖动时自愈

jdbc连接串配置:

connectTimeout  连接超时  默认0不超时,建议设置个5s之类的

socketTimeout  连接后读取超时  默认0不超时,建议设置个12s之类的

2.3.java线程池

maximumPoolSize  最大线程数  不要设无限大,会内存泄漏

BlockingQueue<Runnable> workQueue  等待队列  视场景,不推荐用阻塞型

RejectedExecutionHandler handler  拒绝策略  视场景,一般是自旋重新入队,不推荐阻塞,不推荐占用主线程处理

3.限流熔断降级

通常重构很难有精力覆盖到这块,但这块确实又很重要。通常由rpc框架来承担,也有系统会自己接入单独的框架实现。例如hystrix、sentinel、resilience4j等等。

对内需要关注的是,要有限流保护自己应用;对外需要关注的是,降级不要引发重试洪峰(不要引发用户自动重登重试,引导用户等待而非频繁刷新)。

五,如果需要持久战,与业务需求双线作战的协同

虽说在做重构的时候,需求稍微会拖一拖。但如果重构战线拉长了,免不了要“飞机飞行中换引擎”。一方面,借助切流网关可以自由切换,但另一方面,需求与重构的协同,需要个工作流程来协调。

准入:技术方案评审,识别接口进度

在技术方案评审的时候要卡一道接口列表评审,识别并协调需求改动点与重构的进度冲突。

 

因此可能会有这么个接口进度文档做协同

接口 重构开发进度 放量进度 近期需求变更与时间 需求是否已同步重构 x月x日窗口 y月y日窗口(更早前)
xxx1 新需求 新需求 x月x日将上线需求1 是,直接在新应用做 发新应用
xxx2 10% 0% x月x日将上线需求1 待跟进 (或 已对齐) 发旧应用
xxx3 100% 10% x月x日将上线需求1 是,直接在新应用做 发新应用,放量100%不可回切 放量10%
xxx4 100% 10% x月x日将上线需求1 待跟进 (或 已对齐) 放量回切到0%,发旧应用 放量10%
其他  其他  其他  其他  其他  其他  其他

及时拉齐:定期每周同步代码分支

做重构的同学要及时更新在参照的旧代码仓库;

在新代码仓库上,定期合并后重新拉出开发分支。

准出:测试用例在新旧代码都要通过

如题。如果这点做不到,交付质量是肯定无法保障的。

如果测试同学有困难,开发同学也可以贡献力量的,比如协助搭建自动化用例的环境、协助抓取生产环境的日志。

当然了,流量录制、流量回放对比,值得做,但是否资源条件允许。。。

六,探讨下测试与灰度放量

对测试同学来说,重构最理想的状态是等价替换,也就是相同的用例在新旧应用结果是一致的。但现实很骨感的地方在于,以前的接口未必有用例,即便有,断言细节也不一定很完备。如果有技术手段可以实现“自动”测试,对系统的质量会是很大的保障,并且可以节省大量的测试资源。

流量录制与回放

录制的口子有非常多,域名网关(nginx/kong)、切流网关、在应用上加切面,都可以设置录制。实战中有两种模式是行之有效的。

1.查询接口对比

查询接口是等价且无副作用的,可以在切流网关做录制,异步投递mq,然后在单独的对比系统中执行对比,而且还可以出简单的统计报表。这种模式松耦合,非常灵活,较易实现。

投递mq做成异步的,便不会影响业务处理;对比系统会对新旧系统重新各调用一次,因此需要做成采样,避免对系统造成冲击,录制采样或对比采样都可以(录制侧采样当然更优)。

如果遇到被动式redis缓存的场景怎么办?没特别好的办法,条件允许的话可以新旧应用用两套不同的redis。

2.写类型接口的回放

怎样用例验证一个写入接口的结果正确与完整?断言不仅要包含入db结果一致,还需要调用下游接口一致、调用mq一致等等。

写类型接口不能直接在同个数据库重播。但是从生产录制,在测试环境回放的方法论还是可以的。

这类录制系统复杂很多,需要对所有的外部调用做埋点,通常需要基于jvm-sandbox或sky-walking做整套体系的埋点。

发布后白名单验证

为什么要灰度,是因为即便能力再强的开发与测试,也无法避免未知情况的发生,这个时候就需要个灰度渐进的过程。这也是切流网关最最重要的意义。通常的节奏为:

用于测试或业务方验收,可能按具体手机号、可能按指定前缀、可能按号码区间等等,倒不必太复杂强大,支持具体场景就可以。

颗粒度通常需要到api级别。

渐进放量

通常是个 千分1->百分1->十分1->30%->70%->100% 的过程,当然也会具体情况适当调整。

这个过程要关注报错日志与业务反馈,做好预案可能随时紧急窗口回切。

要避免50%对称的放量比例,可能用户重试1次就成功了于是就没反馈,于是放量50%好像没问题,一切到100%却不可用。。。

如果能按区间放量当然更好,比如按城市、比如按id尾号,会更加有利于快速识别问题,和与业务方沟通通告等等。

结语

衷心祝愿大家读完本文有收获,能少踩坑,又快又稳。

posted @ 2022-06-03 13:46  程序猿攻城狮  阅读(9)  评论(0)    收藏  举报