go-zero-如何封装数据库使用?

go-zero 框架基于原生*sql.DB做了一整套封装,通过四层递进流程完成数据库连接的创建、缓存、增强包装与接口抽象,内置懒加载、单飞请求合并、熔断降级、监控采集能力。下面结合框架源码分步拆解整个连接构建流程。

1:创建sql.DB对象

core/stores/sqlx/sqlmanager.go

func newDBConnection(driverName, datasource string) (*sql.DB, error) {
	conn, err := sql.Open(driverName, datasource)
	if err != nil {
		return nil, err
	}
........
	if err := conn.Ping(); err != nil {
		_ = conn.Close()
		return nil, err
	}
	return conn, nil
}

创建并初始化一个标准库 *sql.DB 连接池,然后用conn.Ping()测试数据连接有效性,因为sql.Open 只管创建内存对象,不建真实连接。

2:sql.DB对象哈希缓存

然后把 *sql.DB对象存进resources map[string]io.Closer,并暴露外部调用方法getSqlConn

func getCachedSqlConn(driverName, server string) (*sql.DB, error) {
	val, err := connManager.GetResource(server, func() (io.Closer, error) {
		conn, err := newDBConnection(driverName, server)
		if err != nil {
			return nil, err
		}

		if driverName == mysqlDriverName {
			if cfg, e := mysql.ParseDSN(server); e != nil {
				// if cannot parse, don't collect the metrics
				logx.Error(e)
			} else {
				checksum := sha256.Sum256([]byte(server))
				connCollector.registerClient(&statGetter{
					host:   cfg.Addr,
					dbName: cfg.DBName,
					hash:   hex.EncodeToString(checksum[:]),
					poolStats: func() sql.DBStats {
						return conn.Stats()
					},
				})
			}
		}

		return conn, nil
	})
	if err != nil {
		return nil, err
	}

	return val.(*sql.DB), nil
}

func getSqlConn(driverName, server string) (*sql.DB, error) {
	conn, err := getCachedSqlConn(driverName, server)
	if err != nil {
		return nil, err
	}

	return conn, nil
}

这里多说一下,因为go-zero数据库sql.DB对象是赖加载,即应用程序启动时,不一定要同时激活数据库连接。而是等到有请求业务涉及查询数据库时,才会创建slq连接对象,并缓存至哈希。这意味这,可能同时存在高并发场景,即同一时刻,大批量多个相同请求,都会去创建sql对象的创建、缓存,这无疑是灾难的。所以,go-zero的方案是:同一时刻相同请求合并,【具体请见:go-zero-同一时刻相同请求合并】。

缓存连接对象同时,connCollector.registerClient:注册监控采集器,数据库库地址、库名,定时采集池子运行指标,运维可通过监控查看连接池是否打满、是否大量等待连接。

注意,此时return conn是对象*sql.DB。

3:从哈希缓存中取出sql.DB,包装至结构体commonSqlConn。

\core\stores\sqlx\sqlconn.go

// NewSqlConn returns a SqlConn with given driver name and datasource.
func NewSqlConn(driverName, datasource string, opts ...SqlOption) SqlConn {
	conn := &commonSqlConn{
		connProv: func(context.Context) (*sql.DB, error) {
			return getSqlConn(driverName, datasource)
		},
		onError: func(ctx context.Context, err error) {
			logInstanceError(ctx, datasource, err)
		},
		beginTx: begin,
		brk:     breaker.NewBreaker(),
	}
	for _, opt := range opts {
		opt(conn)
	}

	return conn
}

从getSqlConn取出sql.DB,赋值connProv,并包装至结构体commonSqlConn。

注意,此时这个conn是commonSqlConn结构体,里面的connProv就是sql.DB对象。

同时,该结构为还有一个熔断器,当sql执行之前,先通过熔断校验判断是否放行,sql执行完毕,熔断器也会更新统计状态等。

注册begin函数于beginTx字段,用途于事务场景。

4:实现 SqlConn 方法接口,以供业务层调用

最后返回的conn,即commonSqlConn结构体,return conn 后,就实现了SqlConn 接口方法,对外只暴露SqlConn统一方法(Query、Exec、Begin、Ping 等),隐藏commonSqlConn结构体字段,目的是隐藏内部缓存、熔断、获取连接的细节。

Session interface {
	Exec(query string, args ...any) (sql.Result, error)
	ExecCtx(ctx context.Context, query string, args ...any) (sql.Result, error)
	Prepare(query string) (StmtSession, error)
..........
	QueryRowsPartialCtx(ctx context.Context, v any, query string, args ...any) error
}

// SqlConn only stands for raw connections, so Transact method can be called.
SqlConn interface {
	Session
...........
	TransactCtx(ctx context.Context, fn func(context.Context, Session) error) error
}

以下代码段实现了SqlConn 接口所以方法,commonSqlConn结构体字段,只能在内部方法中访问,业务层完全调用不了,方便底层实现可以随意重构、修改字段、更换熔断算法等,而不影响上层业务。

func (db *commonSqlConn) Exec(q string, args ...any) (result sql.Result, err error) {
	return db.ExecCtx(context.Background(), q, args...)
}

func (db *commonSqlConn) ExecCtx(ctx context.Context, q string, args ...any) (
	result sql.Result, err error) {
	ctx, span := startSpan(ctx, "Exec")
	defer func() {
		endSpan(span, err)
	}()

	err = db.brk.DoWithAcceptableCtx(ctx, func() error {
		var conn *sql.DB
		conn, err = db.connProv(ctx)
		if err != nil {
			db.onError(ctx, err)
			return err
		}

		result, err = exec(ctx, conn, q, args...)
		return err
	}, db.acceptable)
	if errors.Is(err, breaker.ErrServiceUnavailable) {
		metricReqErr.Inc("Exec", "breaker")
	}

	return
}