本文作者:PingCAP 唐刘
在很早之前,我就注意到了 TiDB 代码里面的一个风险,就是我们很多的单元测试看起来是 UT 测试,实际上算是 IT 测试,也就是我们非常多的接口是使用 SQL 来进行测试的。关于这一点,在 PingCAP 内部进行了大量的讨论,支持 SQL 测试的研发理由非常充分,因为 SQLite 就是使用这种方式来的,而且这样的测试写起来非常的简单。而反对这种方式的研发也有充足的理由,我们很难提升 branch 的测试覆盖率,或者一些简单的功能都还需要通过 SQL 来覆盖,测试并不简单。
讨论并没有对错,对我来说,无论是 UT 还是 IT,两者都是不可或缺的,都必须要做,只不过对于 TiDB 来说,我们需要让单元测试真的就是单元测试,所以在 2023 年年中,我做了决定,将大部分的 SQL 测试代码从当前 UT 代码里面迁移出去,放到独立的 IT 的目录(相关的工作可以看 Split integration tests(IT) and unit tests(UT) in TiDB repo)。同时开始重构代码,让大家更好的写 UT 测试。这里简单的记录下为啥我要这么决策。
在开始之前,仍然有如下的几个声明:
- 我说的不一定是对的。我也会定期刷新我自己的认知。
- 这仅仅只是我自己关于质量的思考,是我自己在 PingCAP 的经验总结,也并不一定适用于其他公司。
质量的基本保证
我非常喜欢玩乐高,也知道,当我拿到一个新的乐高的玩具,譬如火车,航天飞机等,如果不看说明书,完全一股脑的胡乱拼,大概率是得不到最后的成品的。所以乐高的说明书以及零件的包装袋都几乎是按照一个一个模块来组织的,也就是需要我拆开一袋零件,拼一个模块,然后依次迭代,最终组装成一个成品。如果中途任何一个模块拼错了,我就会功亏一篑,所以在拼完一个模块之后,我都会仔细的检查一下这个拼出来的模块跟说明书上面的是不是一致,然后在设想下这个在完整成品的样子下面是不是也是这个样子,如果发现拼出来是正确的,就继续拼下一个模块。
这个就是模块化的力量。开发 TiDB 也是一样的道理。TiDB 是由一个一个模块来构建起来的,如果其中任何的一个模块出现了问题,TiDB 的质量就会出现风险,就会有 bug。所以要减少 bug,一个好的方式就是对模块进行单独的测试,这个也就是我们俗称的 UT 测试了。
所以,这里就有了我的第一个理由 - 『UT 是软件质量的基本保证』。我们在设计模块的时候,需要清晰的定义这个模块的各个接口定义,各个接口的输入、输出是什么,预期的行为是什么,而要确保我们的定义实现的准确,我认为最好的方式就是通过 UT 来去验证。当然这里还有一些事情需要注意:
- UT 并不能保证发现所有的 bug,甚至一个模块通过 UT 保证了 100% 的代码覆盖率也不能确保这个模块没有 bug。因为 UT 我们只是确保了模块的接口行为符合了我们的预期,但是各个模块之间的接口交互协同,是不是更符合更上层模块的预期,我们是覆盖不到的。
- 越到上层的模块,UT 的收益会比底层的模块小。因为上层的模块会更加的复杂,有时候为了方便测试,我们会将很多依赖的模块变成 Mock 的方式来插入。而 Mock 则是一种抽象了,既然是抽象,我们就不可能完全的覆盖实际模块的所有行为,就容易漏出 bug。不过即使这样,也不是不写 UT 的理由。因为 UT 能让我们更加清晰的去了解模块的接口定义。
- UT 并不是万能的,还是需要结合 IT 以及其他的测试方法来一起来确保质量。
效率提升的前置条件
前面提到了我从玩乐高的旅程中,确信模块化的重要性,以及 UT 对于模块化的重要性。从另一方面来说,好的模块化,好的 UT 是能提升我们的开发效率的。
这里举一个显示的例子,也就是为啥我要启动 TiDB 里面 UT 和 IT 的拆分的工作,一个重要的原因就是我们本地跑 UT 的测试,大概会耗时 50 分钟,然后大概率不能完整的将所有测试跑通过。
首先 50 分钟,对于整个研发的效率就是一个极大的浪费,设想一下,我做了一次改动,需要等待 50 分钟才能确保本地所有的 UT 测试通过之后,才提交代码,大家效率怎么可能上来。所以之前研发同学巧妙了回避了这个问题,大家直接提交 PR 到 GitHub,然后触发我们自己内部的 CI 系统,帮助去跑这些测试,而自己则是在本地继续开发下一个功能。这个看起来很美好,只不过大家都把压力转到了内部的 CI 系统上面,所以我们就需要来解决 CI 系统的瓶颈问题了,包括但不限于购买更多的 CI 机器,引入分布式 cache 来缓存没有变化的测试结果等等,不过随着更多的研发提交 PR,增加更多的测试,CI 跑得越来越慢。
另外一点就是,我做了一次改动,想在本地跑 UT 测试,结果一跑测试就挂掉。主要就在于我们当前不看的模块是通过 SQL 进行测试的。要进行 SQL 测试,我们在测试里面需要创建一个 Mock 的 storage,需要启动 DDL 来进行一些 schema 的初始化,顺带还需要启动不少的额外功能,所以很容易导致的一个结果就是如果本地测试的并发开的够多,因为本地机器配置问题,导致大量的测试抢占机器资源,而我们不少的测试里面是有 timeout 的判断,自然结果就是很多测试会失败了。所以为了解决这个问题,研发又是大量的跟前面类似,直接提交 PR,让内部 CI 帮大家跑测试了。
这个其实就是系统领域俗称的公地悲剧了。要解决这个问题,一个办法就是真正的拆分 UT 和 IT,让 UT 跑得足够的简单和高效,而 UT 里面其实是 IT 的 SQL 测试,则放到真正的 TiDB 去单独的跑。当然实际并没有说的这么简单,不过基于这个大的方向,我们取得了非常显著的结果,当前 UT 的整体测试已经下降到 10 多分钟,而 UT 也能正常的在本地全部跑过了。这个对于研发整个的效率是质的提升的。
代码复杂性的间接体现
前面我提到,UT 是一个很好的能去验证一个模块的接口的实现是否符合接口定义的方式。所以,我其实有一个很极端的观点,就是『如果一个模块没办法很好的写 UT,那么这个模块的复杂度就需要引起重视了。』
如果一个模块足够的复杂,那么对应的风险就是模块的可维护性会很差,任何对于这个模块的改动都可能引入更大的风险,譬如更多的 bug(当然,即使一个足够简单的模块,引入变更也是会可能引入 bug 的,这个就是软件工程好玩的一个地方)。不过在我看来,能改动一个模块都还算好的,一个模块如果够复杂了,研发也很难在里面改代码,甚至都不敢改代码了,慢慢的整个模块就变成了传说中的『屎山』了,这个是会严重影响后面的研发效率的。
悲催的是,当前 TiDB 的代码就有了这样的问题,无论是 session,还是优化器,都很难通过简单的 UT 来进行测试,这也是为啥我们需要在更外层用 SQL 来进行测试的一个原因。所以从根本上面来说,即使我们进行了 UT 和 IT 的拆分,我们仍然还有很长的路要走。幸运的是,大家已经注意到了这个问题,在开始改进,譬如对于 session 的拆分和重构,让多个模块减少对 session 的依赖,方便后面的 UT 测试等。这些工作就跟我们上面的 UT 和 IT 的拆分一样,会以年记来进行投入,而且我相信对于 TiDB 的未来是有明显的好处的。
写在最后
当前的 TiDB 已经是一个非常复杂的系统,而对付复杂度一个好的办法就是『模块化』,而让一个模块符合设计的预期,UT 在我看来是必不可少的。这也是为啥我坚持在 TiDB 里面需要写真正的 UT 的一个原因。另外一个方面,就是我们当前的代码复杂度,已经让非常多的非 PingCAP 的社区开发者望而却步,大家很难直接在 TiDB 里面贡献代码了,这也是我不希望看到的。
所以才有了最近我们一系列的重构和拆分工作,也希望通过这些工作,逐渐的降低 TiDB 的复杂度,让 TiDB 变得更加易于维护和迭代。