踩坑记之 Xorm 升级

问题描述

去年年底,在业务后台系统稳定运行了一段时间后,我们对其进行了一次较大版本的优化迭代,在上线一个集群后,查看 Sentry 后台,发现大量报错如下图所示:

image2020-12-11_17-26-58.png

主要报错内容:Table xxxxxx.data' doesn't exist,显示在查询数据库时,没有找到 data 表,在我们的实际业务中,从未创建过名为 data 的表,但在代码中使用了名为 Data 的结构体,难道是在使用 Xorm 时没有成功地指定表名?。先通过紧急回滚的方式解决问题,然后再进一步排查原因。

问题分析及排查

通过分析日志,发现所有报错的地方都与 sourceMatch 这个函数有关(如上图中红色框所示),查看其具体实现(左边为优化前,右边为优化后):

image2020-12-11_17-29-17.png

及调用 sourceMatch 函数的地方(此处仅举一例):

image2020-12-11_17-37-10.png

除了 sourceMatch 实现中 clone session 的方式不同外,使用 sourceMatch 的代码逻辑并未做任何调整,同时对比发现上图中 467 行的 yyyyMatch 函数同样替换了 clone session 的方法,但没有出现类似 Table xxxxxxxxx.data' doesn't exist 的报错,yyyyMatch 函数的实现如下:

image2020-12-11_17-34-2.png

在这次迭代中,我们把底层的 MySQL 操作库由原来的 github.com/go-xorm/xorm@v0.7.9 升级为 xorm.io/xorm@v1.0.3

旧版本的 github.com/go-xorm/xorm.Session 中提供了 Clone 方法,而 xorm.io/xorm.Session 未提供 Clone 方法,于是相关的开发人员直接基于旧版本的 Xorm 实现封装了 cloneSession() 方法,代码修改如下:

image2020-12-11_17-21-57.png

image2020-12-11_17-21-33.png

github.com/go-xorm/xorm 提供的 Session.Clone() 方法实现如下:

0

到目前为止,基本可以断定我们自己封装的 cloneSession 方法存在的问题,session 没有 clone 成功。但在调用 yyyyMatch 时,表名及查询条件都是正常的,而调用 sourceMatch 时,表名就不再是通过 Engine.Table(table) 设置的表名了,而变成了由 Data 结构体反射出来的 data 作为表名。

image2020-12-11_17-51-17.png

解决方案

方案一:显示设置表名

一开始简单地认为只是表名没有正确设置,给 Data 结构体增加 TableName() string 方法,显示设置表名,同时使用 NewData(table string) 方法创建 Data 实例,这样能保证查询时,表名正确设置:

image2020-12-11_18-8-29.png

并修改 match、sourceMatch 和 yyyyMatch 方法如下:

image2020-12-11_18-12-10.png

调用上述三个函数的地方做出相应修改:

image2020-12-11_18-13-14.png

完成上述修改后,虽然不会再报 Table xxxxxxxxx.data' doesn't exist 的错误,但并没有解决问题,因为 session 仍然没有 clone 成功:不仅仅是表名,还有查询条件(通过 Debug 模式观察日志可以看到 SQL 查询语句,使用上述修改方法后,虽然不再报错,但查询条件不见了),进一步深入查看代码发现,问题的根本原因是 Session 结构体中的 statement 字段没有被成功复制。那如何让 session clone 成功呢?

方案二: DeepCopy (以及从源码中得到的线索)

一种常见的 Clone 结构体的方法是采用所谓的深拷贝技术(DeepCopy),Go 中常用的 DeepCopy 实现有两种:使用 gob Marshal/Unmarshal 和 reflect 反射(可参考:几种深度拷贝(deepcopy)方法的性能对比Go 语言对象深拷贝方式性能分析)。但这两种技术都存在一个缺陷:结构体不可导出字段在拷贝时会被丢弃掉,也就是说,深拷贝只能拷贝结构体的可导出字段。在使用 DeepCopy 解决 session clone 问题的尝试中,DeepCopy 的两种实现都没有效果,因为 xorm.io/xorm.Session 的字段定义如下:

1

github.com/go-xorm/xorm.Session 的字段定义如下:

2

上述两者包含了大量非可导出字段,不能使用 DeepCopy 来实现 session 的克隆操作。

无法使用 DeepCopy 方式解决问题,需要进一步阅读 Xorm 相关源码。再进一步阅读对比两个包的 Session 定义差异时,作者突然意识到一个问题:为什么 github.com/go-xorm/xorm.Session 提供了 Clone 方法,并能够正常工作,而新版本的 xorm.io/xorm.Session 不再提供 Clone 方法?关键就在上述两张图中标红的 statement 字段,在 github.com/go-xorm/xorm.Session 中,它是 Statement 类型,而在 xorm.io/xorm.Session 中,作者将其重构成 *statments.Statement 类型了。

回顾 github.com/go-xorm/xorm.Session.Clone 方法:

3

这个方法非常简单,就是重新生成了一个 Session 引用实例,但因为 statement 字段结构体(而非结构体指针),生成的 Statement 实例是全新的,而其它指针类型的字段在两个实例中是共享的,即 session1.Clone() 出来了 session2,session1 和 session2 共享了诸如 db,engine,tx 这些字段,但会独享自己那一份 Statement;而 db,engine 等字段可以在 session1 调用 Close 方法之后会继续存在。

而在对 xorm.io/xorm.Session 进行类似的封装之后:

4

但在 xorm.io/xorm.Session 中的 statement 字段时结构体指针,调用 cloneSession 函数,session1 和克隆出来的 session2 的 statments.Statement 是同一个实例的不同引用,当调用完 session1 的执行方法后(此处为 Get 方法),session1 会调用 resetStatement() 方法,而 resetSatement 方法最终将 Satement 实例清空(清空了包括表名、查询条件这些业务信息):

5

6

7

综合以上可以发现,不管是用简单的实例引用方式,还是深拷贝的方式都不能完整的将 xorm.io/xorm.Session 中的statement 字段完完全全地复制出来,clone 之后 Session 中的 statement 字段是同一个 statments.Statment 实例的不同引用。那么另一个受欢迎 Go ORM 库 Gorm 又是如何实现 Session Clone 的呢?

github.com/go-gorm/gorm 没有提供名叫 Session.Clone 的方法,但是提供了一个签名为 DB.Session(config *Session) *DB 的方法,此方法实现了 session clone 功能.

8

其中,Statement 的 clone Statement.clone() *Statement 方法如下:

9

Gorm 的 clone 实现方式是通过递归对每个字段进行赋值实现的(这也是一种深拷贝技术)。

方案三 避免 Clone 操作

通过上述分析,使用简单的引用拷贝和深拷贝的方式,都不能完整拷贝出 xorm.io/xorm.Session(其中最重要的是 Statement 结构,在 Session 中,最终是这个结构构造了 SQL 查询的上下文信息);而类似 gorm.Session() 的方式过于繁琐,且需要库作者提供。在业务代码中,我们无法避免对查询条件的复用,那么如果复用下面红框中的这段查询条件代码呢?

image2020-12-14_14-34-7.png

可以使用闭包来避免 clone 操作,实际代码如下图所示:

image2020-12-14_14-35-5.png

在之前需要 clone session 的地方,都改为调用 newSession 方法:

image2020-12-14_14-36-22.png

这样,每次调用 newSession 方法,返回的 Session 实例都是包含了同样查询条件和表名,每个 Session 实例相互之间互不影响。这样既避免了 clone Session 带了的种种问题,又解决了业务查询条件复用的问题。

最终,我们采用本方案解决这个 Bug。

经验教训

本次问题较为严重但非常不应该,从以下几个方面来回顾复盘。

一、测试不够严谨充分。第一是自测。虽然我们使用了集成测试和单元测试,每次增加业务逻辑或者优化代码逻辑都会跑单元测试和集成测试,但本次刚好由于相关开发人员在本机测试时,由于网络问题,跑了几次之后,都提示数据库超时后,放弃了本地单元测试。而测试环境目前在使用 Drone 无人机发布时,没有增加单元测试环节,后来经过测试同事简单回归之后就直接上线了。第二时测试同事测试时,用例不够细化充分,刚好能使上述 Bug 出现的逻辑没有在测试时覆盖到。总之,就是测试不够充分严谨。

二、对第三方包的了解程度需要提高。大部分情况下,我们不需要重现造轮子,基本都是使用社区里面比较常用的基础包或库,这不仅能提高我们自己的开发效率,也可以从一定程度上了保证我们的开发质量(毕竟常用的开源包都经受了社区的检验)。但是,在使用第三方包时,一定要对使用的包有一个全面的了解和把控,知道其中重要部分的实现机制,以免踩到不必要的坑。

三、监控报警不够及时。本次出现问题,发现不够及时,大概是在上线之后 15 分钟才了解到,因为我们只把错误信息上报到了 Sentry,但是没有监控报警,是通过人为巡检的方式发现的,这里面存在较大的改进空间。

四、回滚机制不够完善。目前我们业务项目没有使用版本发布,每次发布都是最新的(提交 tag ,k8s tag 等都没有)。这样导致回滚比较困难,导致本次回滚操作用时较长。这一点在迁移到云效之后会有较大的改进,每次发布新版本,都会进行版本记录,以方便追踪项目迭代记录和回滚。

五、最后想谈一谈项目优化的问题。大部分情况下,项目是由业务驱动的,在开发过程中,由于业务较多,开发任务繁重,会代码质量下降。而此后由于顾及项目的稳定性,我们往往倾向于保持旧的代码不动(哪怕知道很多地方可以优化的情况下)。通过引入完整的单元测试和集成测试,加上 Goland 强大的 Refactor 功能,是可以在业务开发任务较轻的时候进行项目代码优化的。目前在其他项目上,我们已经经过几轮较频繁的优化迭代了,没有出现过像此次这样的故事。不过,在优化的过程中,我们要保证对业务逻辑不影响,就得保证进行充分的回归测试。我们应该多做小范围重构,而不是等到项目无法维护时再进行项目重写。

经过这次线上事故,希望自己能牢记一点:对自己写下的每一行代码负责。要负责好自己写下的每一行代码,不仅是自己一个字符一个字符敲下的代码,还包括自己引进的第三方包、自己使用 Copy-Paste 大法从网络上面“借鉴”到的代码。还有就是:一定要跑测试。不仅要跑测试,还要不断积累测试用例,完善测试用例。

展示评论