聊聊项目架构调整

过度设计的反面是务实

去年接手了一套系统,花了近三个月做了一些调整才正式外发。

当时这套系统是为了满足一个时间点快速迭代发布的,接手后觉得其中有一些问题,聊聊其中做的一些决策。

模块存在的合理性

case1

A 项目和 B 项目都是 Django+DRF 提供的服务。

仔细阅读代码后发现

  1. B 项目逻辑并不复杂,但是麻雀虽小五脏俱全,DB、缓存、配置、helm-charts,出包流水线等都得来一套
  2. 存在一些共同的逻辑,抽象出一个 SDK 来实现逻辑复用
  3. A 项目需要调用 B 项目,此时封装一层独立的调用逻辑(B 项目加一个接口,需要同步改这里)
  4. 为同一套前端提供的服务(通过k8s svc将请求转发到不同项目)

同构的技术栈,强耦合的逻辑,却是两个独立的项目。

能否合并成一个项目?答案是可以。

花了几个小时,将 B 项目的差异化代码直接拷贝到 A 项目,去掉 SDK,独立调用层改成直接函数调用,去掉对应的helm-charts,出包流水线等等。

结果:

  • 项目 -1
  • SDK -1
  • 流水线 -1
  • helm-chart -1
  • A 项目中调用逻辑 -1

整体的维护成本,复杂度等都降低了。

一个服务是否拆分成多个,以及多个服务是否应该合并成一个,取决于很多因素,不一定说拆分一定不好,但是拆分带来的维护成本往往是大于单体的。

case2

A 项目启动时,会从一个静态文件服务器 B 拉取一些运行时文件,B 是可以动态更新这些文件的。这样做的好处,是为了方便用户侧动态更新一些文件,直接 rolling update A 就能使得线上接口刷新成新的。

当然,这样做时候代价的,需要维护服务 B,同时 A 需要嵌入拉取逻辑。

问,这个机制目前有使用场景吗?没有,但是可能有。

这个就有点臆测未来用户侧的使用场景了,这种更应该有真实用户反馈过来再去加上,而不是一开始就加上。

所以,选择了去掉 静态文件服务器 B, 直接在 A 项目打镜像时将运行时依赖文件打进去,相当于强耦合。我们甚至留了口子,如果用户侧需要更新,基于 A 镜像再打一个镜像,问题是上线一年多一次也没遇到。

结果:

  • 项目 -1
  • A 项目中的拉取更新逻辑 -1

case3

重构时,A 项目使用了最新的实现方案,去替代 B 项目,二者语言和技术栈完全不一样。

B 项目中有个频率控制逻辑,使用的漏桶算法,而 A 的生态中频率控制算法只有fixed window,由于觉得漏桶算法效果更好,并且时间很赶并没有多余的精力在 A 中实现漏桶算法。所以将 B 中的漏桶算法直接挪出来独立成一个模块 C,然后 A 项目实现对应逻辑依赖模块 C。

相当于异构的实现,通过 RPC 协议交互。

但是这样引入了额外的模块和复杂度。

那么, fixed window频率控制算法在实际用户使用中真的会有问题吗?是的,会有问题,但是最终的频率控制效果用户能接受吗?

捞了线上正在运行的 B 项目的频率控制触发的日志,发现实际上用户侧配置的值一般都比较高,并且很少有触发的频率控制的调用方。

最终,我们决定去掉这个模块 C,直接使用 A 生态中基于fixed window的频率控制。

上线后只出现了一两例触发极端情况的场景,但是这个也给我们在 A 项目中实现漏桶算法提供了充足的缓冲时间。

如果效果差不多,或者稍微差一点但是能接受,那么不一定需要引入一个新模块来解决问题。

模块实现的合理性

case1

A 项目需要用到外部鉴权,依赖于外部的权限数据。此时引入一个模块 B,B 使用 A 生态的多语言框架实现(不同的语言),内部通过 RPC 交互。B 通过 HTTP 调用Web做全量以及增量的权限数据同步。

相当于: A => RPC => B => HTTP => Web Server

由于这个全量、增量同步做得很复杂,并且做了一层cache,导致线上出现权限问题时并不好排查。并且 Web Server 是一个产品项目,发布频率高并且稳定性低于 A。

那么为什么使用多语言框架呢?可能刚好官方支持吧(坑比较多),这时候有点像是拿着锤子满世界都是钉子,往里面放也行。

感觉掉坑里了,能否去掉 B 模块呢?

最终经过评审,我们的实现非常简单A => HTTP => Core Server => DB,不存在全量、增量数据同步,直接依赖 DB,加了一层缓存,整体复杂度非常低。

有时候生态里面一些亮点、强力的 feature,并不一定是好事,平平无奇的机制能实现的话,不一定要用。

case2

A 项目生态中有一个白名单, 历史 B 项目中也有个白名单逻辑,然后在从 B 项目升级到 A 项目的过程中, 竟然在 A 项目也实现了 B 项目的白名单逻辑,即同时存在两个白名单逻辑。

两个白名单逻辑本质上是一致的。

决策:升级过程中做简单的数据迁移,只留一个白名单逻辑。

不合理的依赖

case1 反向依赖

上面模块合理性的case, 底层对可用性要求更高的模块,依赖了上层可用性不那么高的Web Server模块,直接拉低了整体的可用性。

通过新起一个更底层的服务,只依赖于 DB,独立于Web Server,将之前的依赖链路抹掉,变成可靠性更高的链路。

case2 循环依赖

A 项目是一个业务网关,依赖了一个认证项目 B,实施的时候,发现这个 B 的地址竟然是 A 项目的子路径。

原来是因为这个认证逻辑本身没有流水日志用于问题排查,所以将 B 项目接入了 A 项目网管。

此时即循环依赖 A 的认证逻辑依赖了 A 自己的网关地址;

后来决策,依赖地址直接改成B 服务,流水日志 B 项目自行解决。

其他相关的

过于灵活的配置

项目的配置文件生成:原始helm chart中的template有一个configmap, 并且有一批环境变量,在启动的shell脚本中,读取环境变量,并且用 envset、sed 等命令,将配置文件和镜像中的配置文件合并生成一个新的文件。 相当于,配置有 3 个来源,configmap/镜像中的yaml/环境变量中的配置。

这样看起来非常灵活,一系列判断逻辑可以根据一些开关、环境变量,生成差异化的配置,然后启动;

但是带来的问题是,在 shell 脚本中维护这个逻辑可维护性无疑是非常差的,并且认知负担非常重。

后来做的决策是,去掉启动脚本中的逻辑,在configmap中由 helm 的语法、模板负责生成一个完备的配置,即所见即所得

这样所有的差异化在模板阶段就处理掉了,而不是在运行时处理。

有时候,灵活的配置,强大的脚本,看起来很厉害,但是并不一定是好事,特别是来源多,条件复杂这种场景。这时候,唯一来源,并且看起来有些的方式,反而是最好维护的。

过于冗长的流水线

存在不一定合理

由于之前处于快速迭代期,交付期间出现过各个环节的问题,导致出包的流水线非常长,几乎要二三十分钟才能出一个包,一旦有问题重出又是二三十分钟。

流水线中很多环节,是为了 cover 掉之前快速迭代容易遗漏的一些点,例如依赖包遗漏不熟检查,例如打包后执行一次helm install部署测试。 这些环节耗时都比较长,并且是同步的。

但是,当过来快速迭代期之后,依赖变更已经不频繁,并且出现了其他环节能够cover掉出包过程中的早期测试,那么原先有效的环节可能变得非常鸡肋

不删只是慢了点,存在即合理,会导致很多时候不敢去改流水线,去掉这些鸡肋。

后来复盘,统一删除,将时间压缩到 5 分钟以内。

过多的测试用例、测试环境及流程

使用 A 项目替换掉之前的 B 项目,但是,没有重新去梳理 case,而是在 B 项目的 case 基础上又加了一批 case;并且也没有重新做差异化的梳理,导致出现了 4 套测试环境+300多个case;

每个环境的 case 都是提前造好的,接手后,正式发布前需要把 4 个环境升级上来,并且执行完所有 case,有失败的需要逐一去排查原因。

这样带来的问题是,出包后的测试成本也非常高,并且 4 套环境的维护,case 的更新代价也很高。

你不跑,怕有问题没覆盖到,去跑,又得更新四套环境逐一验证,没过的 case 还得逐一确认修复。

后来,我们决定进行测试左移,到开发阶段而不是出包后,重新梳理 case,变成原先三分之一,并且能够在任意环境,自动构建环境,执行覆盖所有case。


summary

3040 Words

2024-06-16 00:00 +0000