《Go语言高级编程》截选:Database 和数据库打交道

断了今生、忘了曾经 提交于 2019-12-27 07:48:52

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

5.5.1 从database/sql讲起

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

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


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

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


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

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


 
  1. type Driver interface {
  2. Open(name string) (Conn, error)
  3. }

调用sql.Open()返回的db对象实际上就是这里的Conn,也是一个接口:


 
  1. type Conn interface {
  2. Prepare(query string) (Stmt, error)
  3. Close() error
  4. Begin() (Tx, error)
  5. }

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

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


 
  1. package main
  2.  
  3. import (
  4. "database/sql"
  5. _ "github.com/go-sql-driver/mysql"
  6. )
  7.  
  8. func main() {
  9. // db是一个sql.DB类型的对象
  10. // 该对象线程安全,且内部已包含了一个连接池
  11. // 连接池的选项可以在sql.DB的方法中设置,这里为了简单省略了
  12. db, err := sql.Open("mysql",
  13. "user:password@tcp(127.0.0.1:3306)/hello")
  14. if err != nil {
  15. log.Fatal(err)
  16. }
  17. defer db.Close()
  18.  
  19. var (
  20. id int
  21. name string
  22. )
  23. rows, err := db.Query("select id, name from users where id = ?", 1)
  24. if err != nil {
  25. log.Fatal(err)
  26. }
  27.  
  28. defer rows.Close()
  29.  
  30. // 必须要把 rows 里的内容读完,或者显式调用 Close() 方法,
  31. // 否则在 defer 的 rows.Close() 执行之前,连接永远不会释放
  32. for rows.Next() {
  33. err := rows.Scan(&id, &name)
  34. if err != nil {
  35. log.Fatal(err)
  36. }
  37. log.Println(id, name)
  38. }
  39.  
  40. err = rows.Err()
  41. if err != nil {
  42. log.Fatal(err)
  43. }
  44. }

如果想了解官方database/sql库更加详细的用法,读者可以参考Go语言相关文档(http://go-database-sql.org/)。

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

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

是的,所以社区才会有各种各样的SQL Builder和ORM。

5.5.2 提高生产效率的ORM和SQL Builder

在Web开发领域常常提到的ORM是什么?我们先看看维基百科:对象关系映射(Object Relational Mapping,简称ORM、O/RM或O/R映射)是一种程序设计技术,用于实现面向对象编程语言里不同类型系统的数据之间的转换。从效果上说,它其实是创建了一个可在编程语言里使用的“虚拟对象数据库”。

最为常见的ORM实际上做的是从数据库数据到程序的类或结构体这样的映射。所以你手边的程序可能是从MySQL的表映射到你的程序内的类。我们可以先来看看其他编程语言里的ORM使用起来是什么样的感觉:


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

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


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

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

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

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


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

很多ORM都提供了这种Filter类型的查询方式,不过实际上在某些ORM背后甚至隐藏了非常难以察觉的细节,比如生成的SQL语句会自动limit 1000

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

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


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

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

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

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


 
  1. where := map[string]interface{} {
  2. "order_id > ?" : 0,
  3. "customer_id != ?" : 0,
  4. }
  5. limit := []int{0,100}
  6. orderBy := []string{"id asc", "create_time desc"}
  7.  
  8. 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就不合适了。

5.5.3 脆弱的数据库

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


 
  1. where := map[string]interface{} {
  2. "product_id = ?" : 10,
  3. "user_id = ?" : 1232 ,
  4. }
  5.  
  6. if order_id != 0 {
  7. where["order_id = ?"] = order_id
  8. }
  9.  
  10. res, err := historyModel.GetList(where, limit, orderBy)

如果你的系统里有类似上述示例的大量if的话,就难以通过测试用例来覆盖所有可能的SQL组合了。

这样的系统只要发布,就已经蕴含了巨大的风险。

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

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

所以现如今,大型的互联网公司核心线上业务都会在代码中把SQL放在显眼的位置提供给DBA评审,举一个例子:


 
  1. const (
  2. getAllByProductIDAndCustomerID = `select * from p_orders where product_id in (:product_id) and customer_id=:customer_id`
  3. )
  4.  
  5. // GetAllByProductIDAndCustomerID
  6. // @param driver_id
  7. // @param rate_date
  8. // @return []Order, error
  9. func GetAllByProductIDAndCustomerID(ctx context.Context, productIDs []uint64, customerID uint64) ([]Order, error) {
  10. var orderList []Order
  11.  
  12. params := map[string]interface{}{
  13. "product_id" : productIDs,
  14. "customer_id": customerID,
  15. }
  16.  
  17. // getAllByProductIDAndCustomerID 是const类型的sql字符串
  18. sql, args, err := sqlutil.Named(getAllByProductIDAndCustomerID, params)
  19. if err != nil {
  20. return nil, err
  21. }
  22.  
  23. err = dao.QueryList(ctx, sqldbInstance, sql, args, &orderList)
  24. if err != nil {
  25. return nil, err
  26. }
  27.  
  28. return orderList, err
  29. }

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

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

本文截选自《Go语言高级编程》,柴树杉,曹春晖 著.

本书从实践出发讲解Go语言的进阶知识。本书共6章,第1章简单回顾Go语言的发展历史;第2章和第3章系统地介绍CGO编程和Go汇编语言的用法;第4章对RPC和Protobuf技术进行深入介绍,并讲述如何打造一个自己的RPC系统;第5章介绍工业级环境的Web系统的设计和相关技术;第6章介绍Go语言在分布式领域的一些编程技术。书中还涉及CGO和汇编方面的知识,其中CGO能够帮助读者继承的软件遗产,而在深入学习Go运行时,汇编对于理解各种语法设计的底层实现是必不可少的知识。此外,本书还包含一些紧跟潮流的内容,介绍开源界流行的gRPC及其相关应用,讲述Go Web框架中的基本实现原理和大型Web项目中的技术要点,引导读者对Go语言进行更深入的应用。

本书适合对Go语言的应用已经有一些心得,并希望能够深入理解底层实现原理或者是希望能够在Web开发方面结合Go语言来实现进阶学习的技术人员学习和参考。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!