微服务的灾难-依赖地狱

微服务模式下,我们的系统中往往需要集成进各种各样的 SDK,这些 SDK 部分来自于非功能性的业务需求,例如 bool 表达式解析,http router,日期时间解析;一部分来自于对公司内基础设施的绑定,如 MQ Client,配置下发 Client,其它服务的调用 SDK 等等。

一般的观点会认为公司内的 SDK 是较为可靠的,而开源库的稳定性不可控,所以人们在升级公司内部库时往往较为激进,开源库版本升级较为保守。具体到 Go 语言,公司内的库,我们可能会直接指定依赖的版本为 master(glide 中每次构建会使用 master 分支的代码)。

实际上真的如此么?业界有个名词叫 dependency hell,指的是软件系统因依赖过多,或依赖无法满足时会导致软件无法运行。导致依赖地狱的问题有:

  1. 依赖过多
    一个软件包可能依赖于众多的库,因此安装一个软件包的同时要安装几个甚至几十个库包。
  2. 多重依赖
    指从所需软件包到最底层软件包之间的层级数过多。这会导致依赖性解析过于复杂,并且容易产生依赖冲突和环形依赖。
  3. 依赖冲突
    即两个软件包无法共存的情况。除两个软件包包含内容直接冲突外,也可能因为其依赖的低层软件包互相冲突。因此,两个看似毫无关联的软件包也可能因为依赖性冲突而无法安装。
  4. 依赖循环
    即依赖性关系形成一个闭合环路,最终导致:在安装A软件包之前,必须要安装A、B、C、D软件包,然而这是不可能的。

我们编写的服务也属于软件系统的范畴,所以也难以摆脱依赖地狱的问题。在微服务场景下,因为本文开头所述的原因,我们必然会依赖一大堆外部 SDK。对于开发者来说,实际上真正有选择权力的就只有我可以使用什么样的开源库。公司内的 SDK 是没有自己造轮子的价值的。毕竟自己造的司内 SDK 也没有人会帮你修 bug,原生 SDK 至少有单独的团队维护。

在开发 lib 时,比较好的做法是尽量引入少的依赖,以避免上面提到的问题 1。实际上没有几个提供 SDK 的团队能做得到,想想当初 javascript 圈子的 leftpad 事件吧,即使是一行代码的库,被人删除就引起了无数大公司系统无法 build 的悲剧。对于目前 Go 编写的系统,实际上存在同样的风险,我们的依赖大多来自于 github,如果你们没有使用 vendor 方案把依赖缓存到自己的系统中,别人删库了你有辙么?

一般 star 数比较高的开源库作者还是比较有节操的,删库的人毕竟是少,所以我们先假装这个问题不存在。公司内的实际开发过程中,我们遇到的依赖地狱大多体现在依赖冲突上,这个比较好理解,比如:

A --> B --> D.v1
A --> C --> D.v2

A 模块依赖 B 和 C,而 B 和 C 分别依赖 D 的不同版本,如果 D.v1 和 D.v2 恰好进行了 API 不兼容的更新,且都是在 github.com/xxx/D 路径下,通过 tag 来区分版本。那么就会对我们造成很大的麻烦,B 和 C 如果又恰好是公司内的不同部门的不同团队,要求他们因为这种原因进行兼容性更新就像是去求大爷一样难。

印象中之前 Go 社区是没有办法解决这种问题的,为了解决这个问题,我司还专门对 glide 进行了定制,以在依赖冲突的时候提醒用户,阻止构建,防止之后出现问题。Go 的社区在这方面也做得确实不太好,gomod 我还没有试用,不清楚是否对依赖冲突有优雅的解决方案。之前社区里大多数人对 Go 的依赖管理也一直颇有微词,希望 gomod 能彻底解决这些问题吧。

除了语言本身的问题,我发现公司内的 library 研发们,根本没有任何开源界的节操,版本升级时根本不考虑向前兼容或者向后兼容的问题,并且出现问题的时候也不会做任何提示,连日志都基本不打印。经常会有配置管理 v1 和配置管理 v2 的 sdk 同时存在模块中时,发生同一个全局变量初始化两次,发生冲突,逻辑不能正常运行,结果启动阶段没有任何 warning,直到执行阶段才出现诡异错误,导致用户在线上埋下定时炸弹的问题。

实在是不知道该说什么好。

多模块之间的循环依赖就更不用说了,如果循环依赖出现在单机系统中,至少在 Go 语言中是没法编译通过的,而因为微服务的关系,循环依赖往往会存在那些没有合理划分业务边界的系统当中。据我观察,出现得还不少。

这时候你能重新划分职责,让循环依赖消失么?

显然是不行的。程序员在当前的微服务架构下,将持续地被外部的垃圾 SDK 和各种莫名其妙的依赖问题所困。

Xargin

Xargin

If you don't keep moving, you'll quickly fall behind
Beijing