cover_image

Golang DB连接池ErrBadConn的应用

梓荣 三七互娱技术团队
2024年03月18日 10:00
01

问题背景

图片
图片

在MySQL主从切换期间,程序往往有小一段时间出现写入报错——`read-only`。

究其原因,为了保证数据一致性,切换时是先“把旧实例设read-only"再”修改域名解析“,这么做必然导致先前创建连接“残留”在旧实例。

以前PHP程序使用短连接,因而`read-only`报错的持续时间较短,就DNS生效的几秒。但Golang程序普遍使用DB连接池,`read-only`报错时间理论上会比PHP更长,毕竟旧的连接被复用了。

可以用一段简单代码模拟DB切换过程:

package db_connect import (    "bytes"    "database/sql"    "encoding/json"    "fmt"    "log"    "net/http"    "testing"    "time"    _ "github.com/go-sql-driver/mysql"    "github.com/txn2/txeh") const (    dbDomain     = "test_db"    masterDBHost = "192.168.200.10"    slaveDBHost  = "192.168.200.11") var (    masterDB *sql.DB    hosts    *txeh.Hosts)  func TestSwitchDB(t *testing.T) {    db, err := sql.Open("mysql", "test_user:123456@tcp("+dbDomain+":3306)/test")    if err != nil {       log.Fatalln(err)    }    defer db.Close()    db.SetConnMaxLifetime(time.Second * 30)     go func() {       <-time.After(5 * time.Second)     //5s后开始进行切换       if err := SwitchDB(); err != nil {          log.Fatalln(err)       }    }()     tic := time.NewTicker(time.Second)    defer tic.Stop()    for {       select {       case <-tic.C:          r, err := db.Exec("insert into user(name) values ('abc')")          if err != nil {             log.Println("insert fail:", err)             break          }          rows, err := r.RowsAffected()          if err != nil {             log.Println(err)             break          }          log.Println("insert successfully, affect rows:", rows)       }    }}  func SwitchDB() error {    //设置只读    _, err := masterDB.Exec("SET GLOBAL read_only = ON")    if err != nil {       return err    }    log.Println("[Switch DB] Set read-only=ON successfully")     //模拟3s DNS生效时间    <-time.After(3 * time.Second)     //切换Host,模拟DNS解析生效    hosts.AddHost(slaveDBHost, dbDomain)    if err := hosts.Save(); err != nil {       return err    }    log.Println("[Switch DB] Modify Host successfully")    return nil}

程序说明如下:

1. 起两个DB实例,分别是`192.168.200.10`和`192.168.200.11`,在`/etc/hosts`创建"192.168.200.10 test_db"记录,模拟DNS解析到主库。

2.程序主协程每隔1s往`test_db`解析的实例写入数据;

3.起一个异步协程,程序启动后5s开始模拟DB切换过程:

a).`192.168.200.10`设置只读,

b).等待3s,

c).修改`/etc/hosts`记录为"192.168.200.11 test_db",即域名指向新实例;

运行程序,结果如下:

图片

在`48:49`设置`read-only`,`48:50`开始报错,`48:52`域名解析生效,但写入报错仍在持续,一直到`49:15`,刚好达到连接的最大生命周期——30s。

大家无法接受这么长时间的写入报错,所以往往需要DBA在完成切换完成后检查"残留"连接,手动KILL掉,以减少影响时间。

再深入分析,造成这个问题的原因是连接复用,如果不复用就没问题了。怎么才不复用?不放回连接池。试想想,如果能对SQL执行结果进行判断,一旦报`read-only`错误就丢弃连接,下次请求用的都是新连接,这样是不是能把切换影响减小?

OK,Show Me Your Code。

02

问题解决

图片
图片

sql包结构分析

在写代码之前先分析`sql`包和`github.com/go-sql-driver/mysql`包,画出UML图:

图片

代码结构比较简单,`sql`包主要提供连接池管理,`sql/driver`包定义接口,最下层的`github.com/go-sql-driver/mysql`包则根据MySQL协议实现接口。

现在需求是"对SQL执行结果进行判断,一旦报`read-only`错误就丢弃连接",很自然,扩展`github.com/go-sql-driver/mysql`包即可。

关键——ErrBadConn

接下来最关键的一环——如何丢弃连接?

`sql`包提供了连接池的管理,在`sql.go`可以找到这么一段代码:

参考源码:https://github.com/golang/go/blob/go1.17.6/src/database/sql/sql.go#L1414

// putConn adds a connection to the db's free pool.// err is optionally the last error that occurred on this connection.func (db *DB) putConn(dc *driverConn, err error, resetSession bool) {    ...    if err == driver.ErrBadConn {        // Don't reuse bad connections.        // Since the conn is considered bad and is being discarded, treat it        // as closed. Don't decrement the open count here, finalClose will        // take care of that.        db.maybeOpenNewConnections()        db.mu.Unlock()        dc.Close()        return    }    if putConnHook != nil {        putConnHook(db, dc)    }    added := db.putConnDBLocked(dc, nil)    db.mu.Unlock()    ...}

从注释可以看到,放回连接池前会判断当前执行的`err`是否为`driver.ErrBadConn`,若为之则丢弃,否则放回连接池等待复用。

因此控制是否丢弃连接的关键是`driver.ErrBadConn`,那就好办,返回此错误即可。

它的定义十分简单:

参考源码:https://github.com/golang/go/blob/go1.17.6/src/database/sql/driver/driver.go#L159

/// ErrBadConn should be returned by a driver to signal to the sql// package that a driver.Conn is in a bad state (such as the server// having earlier closed the connection) and the sql package should// retry on a new connection.//// To prevent duplicate operations, ErrBadConn should NOT be returned// if there's a possibility that the database server might have// performed the operation. Even if the server sends back an error,// you shouldn't return ErrBadConn.var ErrBadConn = errors.New("driver: bad connection")

统计`sql`包里对`driver.ErrBadConn`的引用,高达36次,是一个非常重要的错误:

图片

再翻源码,发现有趣的地方。如果上一次执行返回`driver.ErrBadConn`,重试2次(hardcode)。

参考源码:https://github.com/golang/go/blob/go1.17.6/src/database/sql/sql.go#L1670

// QueryContext executes a query that returns rows, typically a SELECT.// The args are for any placeholder parameters in the query.func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error) {    var rows *Rows    var err error    for i := 0; i < maxBadConnRetries; i++ {     // maxBadConnRetries = 2       rows, err = db.query(ctx, query, args, cachedOrNewConn)       if err != driver.ErrBadConn {          break       }    }    if err == driver.ErrBadConn {       return db.query(ctx, query, args, alwaysNewConn)    }    return rows, err}


扩展驱动

现在思路有了,开始扩展mysql驱动。

需求一:执行SQL后,判断是否存在错误,若为`read-only`则返回`driver.ErrBadConn`。具体扩展`mysql.mysqlConn`即可:

type mysqlConn struct {    driver.Conn} func (mc *mysqlConn) Exec(query string, args []driver.Value) (driver.Result, error) {    ec, ok := mc.Conn.(driver.Execer)    if !ok {       return nil, errors.New("unexpect Error")    }    r, err := ec.Exec(query, args)    if err != nil && strings.Contains(err.Error(), "Error 1290") { //粗糙实现,1290就是read-only报错       mc.Conn.Close()       return nil, driver.ErrBadConn    }    return r, err}

上面的代码就是在`Exec`执行后判断错误,如果是`1290`就返回`driver.ErrBadConn`,不放入连接池,实现丢弃连接效果。

需求二:注册新驱动,使用扩展后的`mysql.mysqlConn`:

func init() {    sql.Register("mysqlv2", &MySQLDriver{&mysql.MySQLDriver{}})} type MySQLDriver struct {    *mysql.MySQLDriver} func (d MySQLDriver) OpenConnector(dsn string) (driver.Connector, error) {    rawConnector, err := d.MySQLDriver.OpenConnector(dsn)    if err != nil {       return nil, err    }    return &connector{       rawConnector,    }, nil} type connector struct {    driver.Connector} func (c *connector) Driver() driver.Driver {    return &MySQLDriver{&mysql.MySQLDriver{}}} func (c *connector) Connect(ctx context.Context) (driver.Conn, error) {    dc, err := c.Connector.Connect(ctx)    if err != nil {       return nil, err    }    return &mysqlConn{dc}, nil}

为了跟原版驱动区分,注册了一个新的mysql驱动——`mysqlv2`。

OK,代码写完,测试新驱动,修改代码如下:

func TestSwitchDB(t *testing.T) {    db, err := sql.Open("mysqlv2", "test_user:123456@tcp("+dbDomain+":3306)/test")  //使用新驱动    if err != nil {       log.Fatalln(err)    }    defer db.Close()    db.SetConnMaxLifetime(time.Second * 30)    ...}

执行结果如下:

图片

改善是很明显的。

报错时间只维持在`31:28`~`31:29`,"31:30"完成DB域名切换,接着立刻写入成功,并未出现之前的一直报错。

大家可以算一下,从开始到切换完成,一共创建了多少个连接 : ) 

03

官方实现

图片
图片

可能有同学会问,DB切换的场景很常见,不至于解决这么简单的问题就要扩展mysql驱动代码吧?

没错,上面的内容主要是为了阐述连接池机制和问题解决思路。在实际应用中,我们是使用mysql驱动的一个dsn参数——`rejectreadOnly`达到上述目的。

用回原版mysql驱动,dsn增加`rejectreadOnly`参数,如下:

func TestSwitchDB(t *testing.T) {    db, err := sql.Open("mysql", "test_user:123456@tcp("+dbDomain+":3306)/test?rejectreadOnly=true")    if err != nil {       log.Fatalln(err)    }    defer db.Close()    db.SetConnMaxLifetime(time.Second * 30)    ...}

执行程序:

图片

可见,效果跟上面扩展版的"mysqlv2"驱动一样的。

看看官方驱动里`rejectreadOnly`参数做了哪些事情:

参考源码:https://github.com/go-sql-driver/mysql/blob/0004702b931d3429afb3e16df444ed80be24d1f4/packets.go#L555

// Error Packet// http://dev.mysql.com/doc/internals/en/generic-response-packets.html#packet-ERR_Packetfunc (mc *mysqlConn) handleErrorPacket(data []byte) error {    if data[0] != iERR {       return ErrMalformPkt    }     // 0xff [1 byte]     // Error Number [16 bit uint]    errno := binary.LittleEndian.Uint16(data[1:3])     // 1792: ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTION    // 1290: ER_OPTION_PREVENTS_STATEMENT (returned by Aurora during failover)    if (errno == 1792 || errno == 1290) && mc.cfg.RejectreadOnly {       // Oops; we are connected to a read-only connection, and won't be able       // to issue any write statements. Since Rejectread-only is configured,       // we throw away this connection hoping this one would have write       // permission. This is specifically for a possible race condition       // during failover (e.g. on AWS Aurora). See README.md for more.       //       // We explicitly close the connection before returning       // driver.ErrBadConn to ensure that `database/sql` purges this       // connection and initiates a new one for next statement next time.       mc.Close()       return driver.ErrBadConn    }    ...}

思路如出一辙。

因此在实际生产中,使用`rejectreadOnly`参数即可。

小结

图片

ErrBadConn是Golang DB连接池中一个非常核心的错误,跟连接池机制息息相关,我们应该重点掌握。


END


三七互娱技术团队

扫码关注 了解更多

图片


继续滑动看下一个
三七互娱技术团队
向上滑动看下一个