6.4. Database 和数据库打交道

本节将对 db/sql 官方标准库作一些简单分析,并介绍一些应用比较广泛的开源 orm 和 sql builder。并从企业级应用开发和公司架构的角度来分析哪种技术栈对于现代的企业级应用更为合适。

从 database/sql 讲起

golang 官方提供了 database/sql 包来给用户进行和数据库打交道的工作,实际上 database/sql 库就只是提供了一套操作 db 的接口和规范,例如抽象好的 sql 预处理(prepare),连接池管理,数据绑定,事务,错误处理等等。官方并没有提供具体某种数据库实现的协议支持。

和具体的数据库,例如 MySQL 打交道,还需要再引入 MySQL 的驱动,像下面这样:

import "database/sql"
import _ "github.com/go-sql-driver/mysql"

db, err := sql.Open("mysql", "user:password@/dbname")
import _ "github.com/go-sql-driver/mysql"

这一句 import,实际上是调用了 mysql 包的 init 函数,做的事情也很简单:

func init() {
    sql.Register("mysql", &MySQLDriver{})
}

在 sql 包的 全局 map 里把 mysql 这个名字的 driver 注册上。实际上 Driver 在 sql 包中是一个 interface:

type Driver interface {
    Open(name string) (Conn, error)
}

调用 sql.Open() 返回的 db 对象实际上就是这里的 Conn。

type Conn interface {
    Prepare(query string) (Stmt, error)
    Close() error
    Begin() (Tx, error)
}

也是一个接口。实际上如果你仔细地查看 database/sql/driver/driver.go 的代码会发现,这个文件里所有的成员全都是 interface,对这些类型进行操作,实际上还是会调用具体的 driver 里的方法。

从用户的角度来讲,在使用 database/sql 包的过程中,你能够使用的也就是这些 interface 里提供的函数。来看一个使用 database/sql 和 go-sql-driver/mysql 的完整的例子:

package main

import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql"
)

func main() {
    // db 是一个 sql.DB 类型的对象
    // 该对象线程安全,且内部已包含了一个连接池
    // 连接池的选项可以在 sql.Open 中设置,这里为了简单省略了
    db, err := sql.Open("mysql",
        "user:password@tcp(127.0.0.1:3306)/hello")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    var (
        id int
        name string
    )
    rows, err := db.Query("select id, name from users where id = ?", 1)
    if err != nil {
        log.Fatal(err)
    }

    defer rows.Close()

    // 必须要把 rows 里的内容读完,否则连接永远不会释放
    for rows.Next() {
        err := rows.Scan(&id, &name)
        if err != nil {
            log.Fatal(err)
        }
        log.Println(id, name)
    }

    err = rows.Err()
    if err != nil {
        log.Fatal(err)
    }
}

如果读者想了解官方这个 database/sql 库更加详细的用法的话,可以参考:

http://go-database-sql.org/

包括该库的功能介绍、用法、注意事项和反直觉的一些实现方式(例如同一个 goroutine 内对 sql.DB 的查询,可能在多个连接上)都有涉及,本章中不再赘述。

聪明如你的话,在上面这段简短的程序中可能已经嗅出了一些不好的味道。官方的 db 库提供的功能这么简单,我们每次去数据库里读取内容岂不是都要去写这么一套差不多的代码?或者如果我们的对象是 struct,把 sql.Rows 绑定到对象的工作就会变得更加得重复而无聊。

是的,所以社区才会有各种各样的 sql builder 和 orm 百花齐放。

提高生产效率的 orm 和 sql builder

在 web 开发领域常常提到的 orm 是什么?我们先看看万能的维基百科:

对象关系映射(英语:Object Relational Mapping,简称ORM,或O/RM,或O/R mapping),
是一种程序设计技术,用于实现面向对象编程语言里不同类型系统的数据之间的转换。
从效果上说,它其实是创建了一个可在编程语言里使用的“虚拟对象数据库”。

最为常见的 orm 实际上做的是从 db -> 程序的 class / struct 这样的映射。所以你手边的程序可能是从 mysql 的表 -> 你的程序内 class。我们可以先来看看其它的程序语言里的 orm 写起来是怎么样的感觉:

>>> from blog.models import Blog
>>> b = Blog(name='Beatles Blog', tagline='All the latest Beatles news.')
>>> b.save()

完全没有数据库的痕迹,没错。orm 的目的就是屏蔽掉 db 层,实际上很多语言的 orm 只要把你的 class/struct 定义好,再用特定的语法将结构体之间的一对一或者一对多关系表达出来。那么任务就完成了。然后你就可以对这些映射好了数据库表的对象进行各种操作,例如 save,create,retrieve,delete。至于 orm 背着你背地里做了什么阴险的勾当,你是不一定清楚的。使用 orm 的时候,我们往往比较容易有一种忘记了数据库的直观感受。举个例子,我们有个一需求,是向用户展示最新的商品列表,我们再假设,我们的商品和商家是一对一的关联关系,我们就很容易写出像下面这样的代码:

# 伪代码
shopList := []
for product in productList {
    shopList = append(shopList, product.GetShop)
}

当然了,我们不能批判这样写代码的程序员是偷懒的程序员。因为 orm 一类的工具在出发点上就是屏蔽 sql,让我们对数据库的操作更接近于人类的思维方式。这样很多只接触过 orm 而且又是刚入行的程序员就很容易写出上面这样的代码。

这样的代码将对数据库的读请求放大了 N 倍。也就是说,如果你的商品列表有 15 个 sku,那么每次用户打开这个页面,至少需要执行 1(查询商品列表) + 15(查询相关的商铺信息) 次查询。这里 N 是 16。如果你的列表页很大,比如说有 600 个条目,那么你就至少要执行 1 + 600 次查询。如果说你的数据库能够承受的最大的简单查询是12w qps,而上述这样的查询正好是你最常用的查询的话,实际上你能对外提供的服务能力是多少呢?是 200 qps!互联网系统的忌讳之一,就是这种无端的读放大。

当然,你也可以说这不是 orm 的问题,如果你手写 sql 你还是可能会写出差不多的程序,那么再来看两个 demo:

o := orm.NewOrm()
num, err := o.QueryTable("cardgroup").Filter("Cards__Card__Name", cardName).All(&cardgroups)

很多 orm 都提供了这种 Filter 类型的查询方式,beego 也不例外。不过实际上在这段 orm 背后隐藏了非常难以察觉的细节,那就是生成的 sql 语句会自动 limit 1000。

也许喜欢 beego 的读者读到这里会反驳了,你是没有认真阅读 beego 的文档就瞎写。是的,尽管 beego 在文档里说明了 All 查询在不显式地指定 Limit 的话会自动 limit 1000,但对于很多没有阅读过文档或者看过 beego 源码的人,这依然是一个非常难以察觉的“魔鬼”细节。喜欢强类型语言的人一般都不喜欢语言隐式地去做什么事情,例如各种语言在赋值操作时进行的隐式类型转换然后又在转换中丢失了精度的勾当,一定让你非常的头疼。所以一个程序库背地里做的事情还是越少越好,如果一定要做,那也一定要在显眼的地方做。比如上面的例子,去掉这种默认的自作聪明的行为,或者要求用户强制传入 limit 参数都是更好的选择。

除了 limit 的问题,我们再看一遍这个 beego orm 的查询:

num, err := o.QueryTable("cardgroup").Filter("Cards__Card__Name", cardName).All(&cardgroups) 

你可以看得出来这个 Filter 是有表 join 的操作么?当然了,对 beego orm 有过深入使用经验的用户还是会觉得这是在吹毛求疵。但这样的分析想证明的是,orm 想从设计上隐去太多的细节。而方便的代价是其背后的运行完全失控。这样的项目在经过几任维护人员之后,将变得面目全非,难以维护。

当然,我们不能否认 orm 的进步意义,orm 的设计初衷是为了让数据的操作和存储的具体实现所剥离。但是上了规模的公司的人们渐渐达成了一个共识,由于隐藏重要的细节,orm 可能是失败的设计。其所隐藏的重要细节对于上了规模的系统开发来说至关重要。

相比 orm 来说,sql builer 在 sql 和项目可维护性之间取得了比较好的平衡。首先 sql builer 不像 orm 那样屏蔽了过多的细节,其次从开发的角度来讲,sql builder 简单进行封装后也可以非常高效地完成开发,举个例子:

where := map[string]interface{} {
    "order_id > ?" : 0,
    "customer_id != ?" : 0,
}
limit := []int{0,100}
orderBy := []string{"id asc", "create_time desc"}

orders := orderModel.GetList(where, limit, orderBy)

写 sql builder 的相关代码,或者读懂都不费劲。把这些代码脑内转换为 sql 也不会太费劲。所以通过代码就可以对这个查询是否命中数据库索引,是否走了覆盖索引,是否能够用上联合索引进行分析了。

说白了 sql builder 是 sql 在代码里的一种特殊方言,如果你们没有 dba 并且研发有自己分析和优化 sql 的能力,或者你们公司的 dba 对于学习这样一些 sql 的方言没有异议。那么使用 sql builder 是一个比较好的选择,不会导致什么问题。

另外在一些本来也不需要 dba 介入的场景内,使用 sql builder 也是可以的,例如你要做一套运维系统,且将 mysql 当作了系统中的一个组件,系统的 qps 不高,查询不复杂等等。

一旦你做的是高并发的 OLTP 在线系统,且想在人员充足分工明确的前提下最大程度控制系统的风险,使用 sql builder 就不合适了。

脆弱的 db

无论是 orm 还是 sql builder 都有一个致命的缺点,就是没有办法进行系统上线的事前 sql 审核。虽然很多 orm 和 sql builder 也提供了运行期打印 sql 的功能,但只在查询的时候才能进行输出。而 sql builder 和 orm 本身提供的功能太过灵活。使得你不可能通过测试枚举出所有可能在线上执行的 sql。例如你可能用 sql builder 写出下面这样的代码:

where := map[string]interface{} {
    "product_id = ?" : 10,
    "user_id = ?" : 1232 ,
}

if order_id != 0 {
    where["order_id = ?"] = order_id
}

res, err := historyModel.GetList(where, limit, orderBy)

你的系统里有类似上述样例的大量 if 的话,就难以通过 test case 来覆盖到所有可能的 sql 组合了。

这样的系统只要发布,就已经孕育了初期的巨大风险。

对于现在 7 * 24 服务的互联网公司来说,服务不可用是非常重大的问题。存储层的技术栈虽经历了多年的发展,在整个系统中依然是最为脆弱的一环。系统宕机对于 24 小时对外提供服务的公司来说,意味着直接的经济损失。个中风险不可忽视。

从行业分工的角度来讲,现今的互联网公司都有专职的 dba。大多数 dba 并不一定有写代码的能力,去阅读 sql builder 的相关“拼 sql”代码多多少少还是会有一点障碍。从 dba 角度出发,还是希望能够有专门的事前 sql 审核机制,并能让其低成本地获取到系统的所有 sql 内容,而不是去阅读业务研发编写的 sql builder 的相关代码。

所以现如今,大型的互联网公司核心线上业务都会在代码中把 sql 放在显眼的位置提供给 dba review,以此来控制系统在数据层的风险。结合 golang 举一个例子:

const (
    getAllByProductIDAndCustomerID = `select * from p_orders where product_id in (:product_id) and customer_id=:customer_id`
)

// GetAllByProductIDAndCustomerID
// @param driver_id
// @param rate_date
// @return []Order, error
func GetAllByProductIDsAndCustomerID(ctx context.Context, productIDs []uint64, customerID uint64) ([]Order, error) {
    var orderList []Order

    params := map[string]interface{}{
        "product_id" : productIDs,
        "customer_id": customerID,
    }

    // getAllByProductIdsAndCustomerID 是 const 类型的 sql 字符串
    sql, args, err := sqlutil.Named(getAllByProductIDsAndCustomerID, params)
    if err != nil {
        return nil, err
    }

    err = dao.QueryList(ctx, sqldbInstance, sql, args, &orderList)
    if err != nil {
        return nil, err
    }

    return orderList, err
}

像这样的代码,在上线之前把 dao 层的变更集的 const 部分直接拿给 dba 来进行审核,就比较方便了。代码中的 sqlutil.Named 是类似于 sqlx 中的 Named 函数,同时支持 where 表达式中的比较操作符和 in。

这里为了说明简便,函数写得稍微复杂一些,仔细思考一下的话查询的导出函数还可以进一步进行简化。请读者朋友们自行尝试。


书籍推荐