大家好,我是蓝胖子,关于sql监控的需求有很多,比如我们常常需要在sql执行前后加上埋点来对sql执行时长,执行语句进行记录,今天我们就来看看在golang中如何实现sql的埋点记录。
首先,我们来看下,在golang中如何实现数据库查询。
golang 实现数据库查询
golang的database/sql包下封装了对数据库查询的接口方法,真正实现数据库连接以及查询的逻辑是由第三方库实现的,拿mysql举例,这个库是github.com/go-sql-driver/mysql。
通常我们执行一个sql语句会先创建一个sql.DB对象,代码如下,
db, err = sql.Open(driverName, dataSourceName)
sql.Open方法要求传入驱动名称和数据库连接字符串,数据库驱动是由第三方库注册到全局变量里的,如下:
// /usr/local/go/src/database/sql/sql.go:35
var (
driversMu sync.RWMutex
drivers = make(map[string]driver.Driver)
)
/// goproject/pkg/mod/github.com/go-sql-driver/mysql@v1.7.1/driver.go:83
func init() {
sql.Register("mysql", &MySQLDriver{})
}
在执行sql时,一般是通过调用sql.DB对象的Query或者Exec方法,
func (db *DB) Query(query string, args ...any) (*Rows, error)
func (db *DB) Exec(query string, args ...any) (Result, error)
下面我们就来着重的分析下这两个方法内部是如何执行sql的。
需要注意的是,在分析过程中一定要分清楚哪些是go官方包database/sql 下的逻辑,哪些是第三方库的逻辑,这样可以很好的理解go database/sql包是如何规范sql查询的。
接口分析
db.Query 方法最终会走到db.queryDc 方法,db.Exec最终会走到db.execDc方法,两个方法执行逻辑都是比较类似的,如下所示:
它们首先都会判断能不能直接执行sql语句,如果不能的话就要使用占位符的方式,prepare和statement的方式来执行sql,返回dirver.ErrSkip错误则代表前者的执行逻辑走不通,需要跳过,接着执行下面的逻辑。
无论是直接执行sql还是使用prepare和statement的方式执行sql,其内部最终都会调用到第三方库封装好的操作数据库的方法。
拿query逻辑,github.com/go-sql-driver/mysql 库举例,database/sql包下用到的连接接口类型实际上是github.com/go-sql-driver/mysql 第三方库返回的连接结构体,所以如果第三方库实现的连接实现类型实现了相应的driver.Queryer或者driver.QueryerContext接口,那么查询时则会走到直接执行sql的方法ctxDriverQuery里。
所以对于第三方库的连接类型实现而言,driver.Queryer接口并不是必需的,database/sql对于连接类型的定义接口只要包含如下几个方法就可以了:
type Conn interface {
Prepare(query string) (Stmt, error)
Close() error
Begin() (Tx, error)
}
不过github.com/go-sql-driver/mysql 依然为连接类型实现了driver.Queryer接口方法,在这个方法里,由它自己去判断是否能够采取直接执行sql的方式。
直接执行sql相比prepare statement 方式少一次网络传输,效率更高,但会带来sql注入问题。
所以github.com/go-sql-driver/mysql 实现driver.Queryer接口时,判断了 如果写的sql有传参数,比如像这样db.Query("select * from t_user where id = ?;",1),需要将连接配置参数InterpolateParams设置为true才能采取直接执行sql的方式,否则会返回dirver.ErrSkip ,让databse/sql包去执行prepare statement的逻辑。
连接配置参数InterpolateParams设置为true ,github.com/go-sql-driver/mysql 会对sql语句执行转义,避免sql注入。
看到这里,应该明白了golang中执行sql的逻辑,我们的最终目的是对sql执行前后能够加上一些自己的埋点日志。所以接下来,还要看看这部分应该如何来做。
自定义驱动实现sql埋点统计
由于database/sql包下仅仅是定义了一个连接类型的接口,连接类型的真正实现是由第三方库实现的,所以我们可以采取装饰器模式,对github.com/go-sql-driver/mysql 库产生的连接类型进行重新包装,覆盖原有的查询方法,就可以在查询前后加上自己的埋点逻辑了。
连接的创建是由驱动产生的,database/sql包下同样也只定义了一个驱动接口,实现是第三方库实现的。接口如下:
type Driver interface {
Open(name string) (Conn, error)
}
并且database/sql包下还有一个全局变量drivers和一个注册方法,第三方库可以通过这个注册方法把自己实现的驱动注册进去,后续database/sql包下创建连接时,就是通过驱动名创建一个sql.DB 对象,sql.DB对象内部会用对应的驱动实现创建连接。
// /usr/local/go/src/database/sql/sql.go:35
var (
driversMu sync.RWMutex
drivers = make(map[string]driver.Driver)
)
/// goproject/pkg/mod/github.com/go-sql-driver/mysql@v1.7.1/driver.go:83
func init() {
sql.Register("mysql", &MySQLDriver{})
}
// 用户代码
db, err = sql.Open(driverName, dataSourceName)
所以我们要实现自定义的连接类型来包装github.com/go-sql-driver/mysql 库下的连接类型,首先是包装它的驱动类型。如下,我们可以对原有连接和驱动加上钩子函数的属性。
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)
}
type Driver struct {
driver.Driver
hooks Hooks
}
func (drv *Driver) Open(name string) (driver.Conn, error) {
conn, err := drv.Driver.Open(name)
if err != nil {
return conn, err
}
wrapped := &Conn{conn, drv.hooks}
return wrapped, nil
}
Conn struct {
driver.Conn
hooks Hooks
}
对于新的链接类型只要对它的查询方法进行覆盖就能埋点了,拿query接口举例,在原有的queryContext方法前后加上自己的逻辑。
func (conn *Conn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
var err error
list := namedToInterface(args)
// Query `Before` Hooks
if ctx, err = conn.hooks.Before(ctx, query, list...); err != nil {
return nil, err
}
results, err := conn.queryContext(ctx, query, args)
if err != nil {
return results, handlerErr(ctx, conn.hooks, err, query, list...)
}
if _, err := conn.hooks.After(ctx, query, list...); err != nil {
return nil, err
}
return results, err
}
注意,因为用连接查询的地方涉及到好几个接口方法,我们都需要覆盖到这些方法,才能让埋点不会被漏掉。
这些地方分别是queryDC中执行执行sql时的QueryContext 接口,execDc中执行sql时的ExecContext接口,连接的PrepareContext接口,以及driver.Stmt的ExecContext和QueryContext接口。
具体的实现钩子函数的代码已经上传到github
https://github.com/HobbyBear/easymonitor/blob/main/infra/sqlhooks.go