为什么要旗帜鲜明地反对 orm 和 sql builder

这个问题在五六年前和前同事讨论过,没想到这么多年过去了,又要跟其他工程师再讨论一遍,有点恍如隔世。

因为我比较懒,所以记录一篇文章,以后碰到类似的问题就不再掺和了。

toC 场景的系统大多要面对较高的 QPS,即使是小型/中型公司使用 MySQL,没有那么高的查询量,单表数据在百万量级也属常见。

无状态的服务当前用 k8s 的 HPA 能力能够做到很好的扩容,结合基本的优化知识,大多数问题能够较好地被初/中级工程师解决。但 DB 一直是非常脆弱的一环,只要一个工程师不慎将不带索引的查询代码带上线,就会导致线上事故。这样的事故在各家公司都不少。哦,当然,公司为了自己的形象考虑,这样的低级事故一般是不对外说的。

政府/企业对事故的管理一般有一套“事前-事中-事后”的分析框架,可以作为我们处理事故的很好的参考。在开始分析之前,要先简单了解一下这个框架:

所谓事前,就是做事情之前应该有统筹的规划,考虑可能出现的问题和困难,最好提前做实验验证下,以保证在真实现场不出问题,或者即使出现问题也有应对方案,不至于手忙脚乱、大动干戈。

所谓事中,就是做事情的过程中对事前规划的事情进行执行,以及记录过程中出现的问题;整个事中起着承前启后的作用,上对事前的规划进行验证,下对事后的总结反思进行铺垫。

所谓事后,就是做事情结束后对整个事情进行总结反思、问题分类和根因分析等。追踪并确认所有的问题有效进行了解决和应对,以确保下次不会再犯之前同样的错误。

我们就用这套框架来看看现在被人们奉为圭臬的 orm 和 sql builder 到底有啥问题。

事前

无论使用 orm 还是 sql builder,研发人员其实都是不太清楚自己的代码会产生哪些 SQL 的,比如很多工程师会参考一些开源的模板代码,将他们的数据查询层定义类似下面的接口:

package iface

type CustomerRepository interface {
    func GetCustomer(ctx context.Context, query QueryOptions) (Customer, error)
    func ListCustomer(ctx context.Context, query QueryOptions) ([]Customer, error)
    func DeleteCustomer(...)...
    func UpdateCustomer(...)...
}

接口定义非常简单,查询条件可以随意传入,在整洁架构的核心圈中依赖这套接口可以灵活地组合出产品的业务逻辑。

而灵活和稳定在这里是矛盾的,比如在 domain 中,可能会有人根据用户传入的参数来组装查询条件:

// 伪代码,别在意语法问题
func GetCustomers(ctx context.Context, userParam map[string]interface{})  ([]Customer, error) {
    var query QueryOptions
    if param["id"] > 0 {
    	query.ID = param["id"]
    }
    if ...
}

用户传入的条件完全不受控,在 MySQL 中,我们不可能为一个笛卡尔积的可能组合去创建索引。所以发生事故就只是运气问题。

若工程师学习过 DDD 的理论,知道基本的贫血模型和充血模型概念,按照充血模型去设计 Repository 的接口,相对来说会稍有改善,但从过往接触过的从业人员来看,懂得这些概念的人很少。

无论使用 orm 还是 sql builder,最终我们都没有办法为可能出现的线上事故做任何充分准备。这本身就是灵活的代价。你又怎么为事故来准备预案呢?

事中

简单来说,就是线上的数据库被打爆了。因为查询没有走索引,MySQL 使用的 CPU 直接飚升。若公司建立了完善的云平台,DBA 或研发可从云平台看到使 DB 出现异常的 slow query。

但问题在于 slow query 中的 SQL 很难与代码直接关联起来,我们还是来看看上面定义的 Repo 接口:


type CustomerRepository interface {
    func GetCustomer(ctx context.Context, query QueryOptions) (Customer, error)
    func ListCustomer(ctx context.Context, query QueryOptions) ([]Customer, error)
    func DeleteCustomer(...)...
    func UpdateCustomer(...)...
}

从 DB 平台中已经可以知道是下面这样的 SQL 有问题:

select * from customer where id != 2

怎么知道是什么样的业务调用链路触发的呢?只能去慢慢读代码了。

在很多公司都见过类似这样的场景:

DBA:"线上 db 快挂了,你看看这条查询,紧急处理一下代码"

RD:“先容我读一下代码,找到了才能改”

DBA:“。。。”

当然,现在的 sql builder 和 orm 框架也可以在 debug 模式打印一些 slow log,但打印行为需要在查询触发时才会有。若未建索引的查询是个万分之一概率进入的分支,碰到事故再开 debug log 大概也来不及了。

事后

别想了,基本也复盘不出个所以然,无非是:

  • 下次 review 一定认真点
  • 我们给上线再加一个流程卡点
  • 下次回归 case 覆盖率要高一点

都解决不了本质问题。

比较好的方案

为了让脆弱的 DB 不要出事,最好的方案还是能够在上线前拦截出所有可能出问题的 SQL。

现在有不少研发会买一些 MySQL 相关的八股课去学习 SQL 知识,基本都是为了应付面试,实际工作中难以良好地运用。真的碰到了 MySQL 的死锁问题,比如 unique key 导致的死锁,还是要去求助 DBA,所以我不是很理解研发去卷 redo log/buffer pool 这些实现原理到底有什么用,有点扯远了。

对于 SQL、索引、死锁,还是 DBA 最为专业,这是他们赖以生存的本事。从工作流程上来讲,我们完全可以让 DBA 参与到研发流程中来,但直接让 DBA 去猜代码中会生成的 SQL 也是不现实的。个人认为 Go 社区中目前比较优秀的解决方案是 sqlc:

从 SQL 文件生成 dao 中的查询代码,将所有查询 SQL 全部显式定义在代码中,在上线流程中可以直接邀请 DBA 参与 CR 中的 SQL Review 环节。

再进一步,可以直接从 information schema 和 sys 库中读取到所有表的索引和访问信息,DB 平台可以自动对代码中的 SQL 进行分析,将不会命中索引的查询进行拦截,从而做到 100% 的事前预防。

由于 sqlc 的作者主要在 pg 环境下工作,所以对于 MySQL 查询的支持并不好,但 sqlc 是当前社区中设计思路最好的数据访问组件,未来我们会参考实现一套对 MySQL 支持更好的工具,希望几个月内能够开源出来。

Xargin

Xargin

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