笔者从前分享过两篇文章,分别是根据 GORM V2 和 XORM 在分布式链路追寻上的建设,此后偶然有网友联系笔者进行交流,首要关于项目运用 GORM V1 或者原生 SQL情况下,尽可能少侵入事务代码去做数据库操作的日志输出、错误监控和链路追寻。

本系列文章通过四点内容为一切 Go 事务上的 SQL 操作日志输出、监控和链路追寻问题供给处理思路。

  1. 运用 SQLHooks 在 sql.Driver 上挂载钩子函数。
  2. ORM 、SQL 以及 SQLX 的实践。
  3. Prometheus 收集 DB 操作指标。
  4. Opentracing 链路追寻。

本文内容首要为第一步和第二步,后续 Prometheus 和 Opentracing 相关内容日后有机会更新,文章所用到的代码均已开源,有问题能够自行查阅,Github – sqlhooks-example 。

自界说驱动

众所周知 database/sql 原生库供给的是 interface{} 接口界说,在进行数据库操作时一般都是凭借 driver.Driverdriver.Conn进行的,关于这部分内容能够阅览 Go 言语设计与完成 – 数据库 内容进行了解。

既然如此,咱们只需求在 Diver 和 Conn 上面封装一层就能完成全量 SQL 日志打印和监控。

在尽量避免造轮子的前提下,笔者凭借 Github 开源项目 SQLHooks 进行实践。值得留意的是,如果您需求应用到出产环境,可参考开源项目自行封装

SQLHooks 的原理非常简略,封装了一个 Driver 完成原生库 driver.Driver,在调用 Exec、Query 以及 Prepare 等操作函数时调用开发者传入的钩子函数。

// Driver implements a database/sql/driver.Driver
type Driver struct {
	driver.Driver
	hooks Hooks
}
// Hooks instances may be passed to Wrap() to define an instrumented driver
type Hooks interface {
	Before(ctx context.Context, query string, args ...interface{}) (context.Context, error)
	After(ctx context.Context, query string, args ...interface{}) (context.Context, error)
}

此时,有开发经历的 Gopher 现已留意到 Hooks 接口的办法都带有 context.Context 参数,大概率现已猜到下文的操作。

以打印完整 SQL 和 Args 参数为例,咱们能够界说一个包含日志打印目标的结构体完成 Hooks 接口。

为了能更好呈现作用,本文的实践中加入了 SQL 耗时,一般该功能是在数据库中完成并呈现给 DBA 人员查询,但咱们开发人员一般也需求该指标用于确定 SQL 质量。

首先咱们界说 zapHook 结构体,该结构体包含一个 zap logger 目标和用于启用 SQL 耗时核算的布尔值。

// make sure zapHook implement all sqlhooks interface.
var _ interface {
	sqlhooks.Hooks
	sqlhooks.OnErrorer
} = (*zapHook)(nil)
// zapHook using zap log sql query and args.
type zapHook struct {
	*zap.Logger
	// 是否打印 SQL 耗时
	IsPrintSQLDuration bool
}
// sqlDurationKey is context.valueCtx Key.
type sqlDurationKey struct{}

接下来咱们需求界说 Before 函数需求做的两件事。

  1. 输出实践履行 SQL 的 Query 指令和参数日志。
  2. 将履行 SQL 的开端时刻目标注入到上下文。
func buildQueryArgsFields(query string, args ...interface{}) []zap.Field {
	if len(args) == 0 {
		return []zap.Field{zap.String("query", query)}
	}
	return []zap.Field{zap.String("query", query), zap.Any("args", args)}
}
func (z *zapHook) Before(ctx context.Context, query string, args ...interface{}) (context.Context, error) {
	if z == nil || z.Logger == nil {
		return ctx, nil
	}
	z.Info("log before sql exec", buildQueryArgsFields(query, args...)...)
	if z.IsPrintSQLDuration {
		ctx = context.WithValue(ctx, (*sqlDurationKey)(nil), time.Now())
	}
	return ctx, nil
}

依照相同的流程,咱们需求界说 After 函数需求做的流程。

  1. 测验从上下文获取履行 SQL 的开端时刻目标。
  2. 输出履行 SQL 完毕的 Query 和参数日志(一般仅在 Before 函数输出一次,但本文实践为了作用进行了二次输出)。
func (z *zapHook) After(ctx context.Context, query string, args ...interface{}) (context.Context, error) {
	if z == nil || z.Logger == nil {
		return ctx, nil
	}
	var durationField = zap.Skip()
	if v, ok := ctx.Value((*sqlDurationKey)(nil)).(time.Time); ok {
		durationField = zap.Duration("duration", time.Now().Sub(v))
	}
	z.With(durationField).Info("log after sql exec", buildQueryArgsFields(query, args...)...)
	return ctx, nil
}

咱们还需求完善 OnError 产生错误时的钩子函数,这个函数一般是必要的,咱们希望在 SQL 履行失利后进行日志输出或上报指标和原因。

func (z *zapHook) OnError(_ context.Context, err error, query string, args ...interface{}) error {
	if z == nil || z.Logger == nil {
		return nil
	}
	z.With(zap.Error(err)).Error("log after err happened", buildQueryArgsFields(query, args...)...)
	return nil
}

至此咱们现已完成了 SQLHooks 一切接口的完成,最终一步是运用 SQLHooks 库供给的 Wrap 办法创立新的 Driver 注册到大局驱动上。

// 大部分 MySQL 操作都运用 go-sql-driver 作为驱动.
import (
	"database/sql"
	"github.com/go-sql-driver/mysql"
)
// 覆盖驱动名 mysql 会导致 panic, 因此需求创立新的驱动.
//
// database/sql/sql.go:51
const driverName = "mysql-zap"
func initZapHook(log *zap.Logger) {
	if log == nil {
		log = zap.L()
	}
	hook := &zapHook{Logger: log, IsPrintSQLDuration: true}
	sql.Register(zapDriverName, sqlhooks.Wrap(new(mysql.MySQLDriver), hook))
}

接下来咱们就能够凭借该钩子函数输出悉数 DB 结构生成的 SQL 的日志。

事务实践

在进行本文实践进程前,您需求了解本文根据以下环境进行。

  • Go 1.17
  • go-sql-driver v1.6.0
  • SQLX 1.3.4
  • GORM 1.22.3
  • TiDB 5.2.2

接下来将会分成四个环节进行实践。

  1. 界说测试表结构和 Go 结构体。
  2. 查看 Go 原生 SQL 履行和查询作用。
  3. 查看 SQLX 结构履行和查询作用。
  4. 查看 GORM 结构履行和查询作用。

界说测试表结构和 Go 结构体

首先笔者手动界说了一个简略的数据库表结构体并创立。

CREATE TABLE `test_table`
(
    `id`        bigint(20) NOT NULL AUTO_INCREMENT,
    `score`     bigint(20) NOT NULL,
    `create_at` timestamp  NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `update_at` timestamp  NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_bin
  AUTO_INCREMENT = 30001;

接下来需求在 Go 代码中编写对应的结构体,此进程能够通过各类 SQL 转 struct 工具简化。

import "gorm.io/gorm/schema"
// 完成 GORM 表名接口
var _ schema.Tabler = (*testTable)(nil)
type testTable struct {
	ID       uint      `db:"id"`
	Score    int64     `db:"score"`
	CreateAt time.Time `db:"create_at" gorm:"<-:false"`
	UpdateAt time.Time `db:"update_at" gorm:"<-:false"`
}
func (*testTable) TableName() string { return "test_table" }
type testTableSlice []*testTable
// GoString 输出人类看得懂的日志
func (t testTableSlice) GoString() string {
	var output strings.Builder
	for _, v := range t {
		output.WriteString(fmt.Sprintf("- id: %d, score: %d, createAt: %s, updateAt: %s\n", v.ID, v.Score, v.CreateAt.String(), v.UpdateAt.String()))
	}
	return output.String()
}

在编写事务代码之前,咱们需求进行必要的初始化流程。

var ctx = context.Background()
var dsn = os.Getenv("DSN")
func init() {
	initZapHook(setupZapLogging(zap.InfoLevel))
	rand.Seed(time.Now().UnixNano())
}

至此准备作业现已完成,接下来咱们需求依照对应的结构编写事务代码,统一的流程是刺进后查询。

Go 原生库 SQL

原生库 SQL 是 Go 开发常用的数据库操作库。

因为根据原生库的批量刺进需求凭借字符串拼接,为了简化流程,原生库的实践仅展现单个刺进的进程。

// database/sql
func rawSQLExample() {
	log.Println("going using database/sql exec sql")
	// 创立数据库连接目标
	connRaw, err := sql.Open(zapDriverName, dsn)
	if err != nil {
		log.Fatalln(err)
	}
	// 履行刺进操作前需求预处理再履行
	const rawInsertSQL = `INSERT INTO test_table (score) VALUES (?)`
	stmt, err := connRaw.PrepareContext(ctx, rawInsertSQL)
	if err != nil {
		log.Fatalln(err)
	}
	if _, err = stmt.ExecContext(ctx, rand.Int63()); err != nil {
		log.Fatalln(err)
	}
	// 查询操作
	resQuery, err := connRaw.QueryContext(ctx, "SELECT * FROM test_table")
	if err != nil {
		log.Fatalln(err)
	}
	defer func() { _ = resQuery.Close() }()
	var res testTableSlice
	for resQuery.Next() {
		tmp := new(testTable)
		if err = resQuery.Scan(&tmp.ID, &tmp.Score, &tmp.CreateAt, &tmp.UpdateAt); err != nil {
			log.Fatalln(err)
		}
		res = append(res, tmp)
	}
	log.Printf("%#v", res)
}

编写完事务代码之后咱们能够运转单元测试检验作用。

=== RUN   Test_zapHook
2021-11-14T22:00:07.255+0800	INFO	sqlhooks-exmaple/zap_example_test.go:41	going using database/sql exec sql
2021-11-14T22:00:07.256+0800	INFO	sqlhooks-exmaple/zap.go:54	log before sql exec	{"query": "INSERT INTO test_table (score) VALUES (?)", "args": [6720357018880391204]}
2021-11-14T22:00:07.261+0800	INFO	sqlhooks-exmaple/zap.go:72	log after sql exec	{"duration": "4.728028ms", "query": "INSERT INTO test_table (score) VALUES (?)", "args": [6720357018880391204]}
2021-11-14T22:00:07.261+0800	INFO	sqlhooks-exmaple/zap.go:54	log before sql exec	{"query": "SELECT * FROM test_table"}
2021-11-14T22:00:07.262+0800	INFO	sqlhooks-exmaple/zap.go:72	log after sql exec	{"duration": "1.098115ms", "query": "SELECT * FROM test_table"}
2021-11-14T22:00:07.262+0800	INFO	sqlhooks-exmaple/zap_example_test.go:73	- id: 30095, score: 6720357018880391204, createAt: 2021-11-14 22:00:07 +0800 CST, updateAt: 2021-11-14 22:00:07 +0800 CST
--- PASS: Test_zapHook (0.01s)
PASS

从日志输出来看,确实完成了咱们想要的作用 —— 日志输出和 SQL 履行耗时。

SQLX 结构

SQLX 是一款 Go 事务开发进程中比较常见的数据库操作结构,目前 Github 上有 11.1k 的 star。

得益于 SQLX 支撑命名参数,且命名参数支撑切片,笔者能够演示批量刺进的场景。

// jmoiron/sqlx
func sqlxExample() {
	log.Println("going using jmoiron/sqlx exec sql")
	// 根据 SQLX 建立数据库链接
	connSqlx, err := sqlx.ConnectContext(ctx, zapDriverName, dsn)
	if err != nil {
		log.Fatalln(err)
	}
	// 履行刺进操作前需求进行命名参数替换和预处理
	const sqlxNameInsertSQL = `INSERT INTO test_table (score) VALUES (:score)`
	var valuesToInsert = []*testTable{{Score: rand.Int63()}, {Score: rand.Int63()}, {Score: rand.Int63()}}
	newInsertSQL, args, err := sqlx.Named(sqlxNameInsertSQL, valuesToInsert)
	if err != nil {
		log.Fatalln(err)
	}
	stmt, err := connSqlx.PreparexContext(ctx, newInsertSQL)
	if err != nil {
		log.Fatalln(err)
	}
	if _, err = stmt.ExecContext(ctx, args...); err != nil {
		log.Fatalln(err)
	}
	// 查询操作能够凭借 Select 办法 Scan 到对应的结构体切片
	var res testTableSlice
	if err = connSqlx.Select(&res, "SELECT * FROM test_table"); err != nil {
		log.Fatalln(err)
	}
	log.Printf("%#v", res)
}

运转单元测试咱们获得对应的输出作用。

=== RUN   Test_zapHook
2021-11-14T22:24:50.118+0800	INFO	sqlhooks-exmaple/zap_example_test.go:78	going using jmoiron/sqlx exec sql
2021-11-14T22:24:50.119+0800	INFO	sqlhooks-exmaple/zap.go:54	log before sql exec	{"query": "INSERT INTO test_table (score) VALUES (?),(?),(?)", "args": [415829244009450172,3465963981601780078,2197712931404613967]}
2021-11-14T22:24:50.124+0800	INFO	sqlhooks-exmaple/zap.go:72	log after sql exec	{"duration": "4.85822ms", "query": "INSERT INTO test_table (score) VALUES (?),(?),(?)", "args": [415829244009450172,3465963981601780078,2197712931404613967]}
2021-11-14T22:24:50.124+0800	INFO	sqlhooks-exmaple/zap.go:54	log before sql exec	{"query": "SELECT * FROM test_table"}
2021-11-14T22:24:50.125+0800	INFO	sqlhooks-exmaple/zap.go:72	log after sql exec	{"duration": "1.119153ms", "query": "SELECT * FROM test_table"}
2021-11-14T22:24:50.125+0800	INFO	sqlhooks-exmaple/zap_example_test.go:106	- id: 30096, score: 415829244009450172, createAt: 2021-11-14 22:24:50 +0800 CST, updateAt: 2021-11-14 22:24:50 +0800 CST
- id: 30097, score: 3465963981601780078, createAt: 2021-11-14 22:24:50 +0800 CST, updateAt: 2021-11-14 22:24:50 +0800 CST
- id: 30098, score: 2197712931404613967, createAt: 2021-11-14 22:24:50 +0800 CST, updateAt: 2021-11-14 22:24:50 +0800 CST
--- PASS: Test_zapHook (0.01s)

SQLX 结构和原生库 SQL 的输出作用是一样的,同样能够得到履行的 SQL 和耗时。

GORM 结构

高度封装的 GORM 结构获取对应的 SQL 和履行时刻难度高(特别是 V1 版本),而根据驱动的方式能磨平原生库与结构的差异,在数据库操作入口和出口捕捉咱们所需求的 SQL 信息。

留意观察以下代码,咱们只需求在 DB 目标创立时调整参数即能调用想要的 Hooks 钩子函数。

// GORM V2, need to focus driver name is zapDriverName.
func gormExample() {
	log.Println("going using gorm V2 exec sql")
	// 创立 GORM 链接,需求留意修正 DriverName 参数
	dialector := mysql.New(mysql.Config{DSN: dsn, DriverName: zapDriverName})
	// GORM 需求开启 PrepareStmt,不然会报 driver.ErrSkip 错误
	connGorm, err := gorm.Open(dialector, &gorm.Config{PrepareStmt: true})
	if err != nil {
		log.Fatalln(err)
	}
	// 履行刺进操作只需求调用 API,非常方便,但无法直观查看实践生效的 SQL
	valuesToCreate := []*testTable{{Score: rand.Int63()}, {Score: rand.Int63()}, {Score: rand.Int63()}}
	if err = connGorm.CreateInBatches(valuesToCreate, len(valuesToCreate)).Error; err != nil {
		log.Fatalln(err)
	}
	var res testTableSlice
	// 查询也是相同道理,无法直观查看实践生效的 SQL
	if err = connGorm.Find(&res).Error; err != nil {
		log.Fatalln(err)
	}
	log.Printf("%#v", res)
}

运转单元测试,咱们能够得到输出作用。

=== RUN   Test_zapHook
2021-11-14T22:43:02.364+0800	INFO	sqlhooks-exmaple/zap_example_test.go:111	going using gorm V2 exec sql
2021-11-14T22:43:02.365+0800	INFO	sqlhooks-exmaple/zap.go:54	log before sql exec	{"query": "SELECT VERSION()"}
2021-11-14T22:43:02.366+0800	INFO	sqlhooks-exmaple/zap.go:72	log after sql exec	{"duration": "274.358s", "query": "SELECT VERSION()"}
2021-11-14T22:43:02.367+0800	INFO	sqlhooks-exmaple/zap.go:54	log before sql exec	{"query": "INSERT INTO `test_table` (`score`) VALUES (?),(?),(?)", "args": [6926252270182587172,1587531254996719592,6405207277469356629]}
2021-11-14T22:43:02.370+0800	INFO	sqlhooks-exmaple/zap.go:72	log after sql exec	{"duration": "3.442791ms", "query": "INSERT INTO `test_table` (`score`) VALUES (?),(?),(?)", "args": [6926252270182587172,1587531254996719592,6405207277469356629]}
2021-11-14T22:43:02.437+0800	INFO	sqlhooks-exmaple/zap.go:54	log before sql exec	{"query": "SELECT * FROM `test_table`"}
2021-11-14T22:43:02.438+0800	INFO	sqlhooks-exmaple/zap.go:72	log after sql exec	{"duration": "1.187731ms", "query": "SELECT * FROM `test_table`"}
2021-11-14T22:43:02.438+0800	INFO	sqlhooks-exmaple/zap_example_test.go:133	- id: 30099, score: 6926252270182587172, createAt: 2021-11-14 22:43:02 +0800 CST, updateAt: 2021-11-14 22:43:02 +0800 CST
- id: 30100, score: 1587531254996719592, createAt: 2021-11-14 22:43:02 +0800 CST, updateAt: 2021-11-14 22:43:02 +0800 CST
- id: 30101, score: 6405207277469356629, createAt: 2021-11-14 22:43:02 +0800 CST, updateAt: 2021-11-14 22:43:02 +0800 CST
--- PASS: Test_zapHook (0.07s)
PASS

日志输出的作用和预期相同,都能正常输出实践履行的 SQL 和耗时。

值得留意的是,虽然咱们没有编写额定的内容,但 GORM 结构仍然在初始化进程履行了 SELECT VERSION() 指令。

留意事项

笔者认为本文的内容比较基础,能给部分有需求的同学供给处理问题的思路,但仍有需求留意的事项。

  • 本文根据 SQLHooks 开源库进行编写,值得留意的是,在出产环境下,基建工程尽量自行封装或者供给装备开关。
  • 输出还是收集都是开支较大的作业,可供给参数关闭或者调整收集概率。
  • 在条件允许的情况下,核心事务削减或避免运用 ORM,降低人为误操的危险。

非常感谢您的阅览,如果您有更好的想法或问题,欢迎私信笔者。

参考资料

  • Github – sqlhooks-example
  • Github – SQLHooks
  • Go 言语设计与完成 – 数据库
  • Github – SQLX
  • GORM