写点什么

Golang DB 连接池 ErrBadConn 的应用

  • 2024-03-27
    广东
  • 本文字数:4934 字

    阅读完需:约 16 分钟

Golang DB连接池ErrBadConn的应用

问题背景

在 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.10192.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-only48:50开始报错,48:52域名解析生效,但写入报错仍在持续,一直到49:15,刚好达到连接的最大生命周期——30s。

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

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

OK,Show Me Your Code。

问题解决

sql 包结构分析

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

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

现在需求是"对 SQL 执行结果进行判断,一旦报read-only错误就丢弃连接",很自然,扩展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:2931:30完成 DB 域名切换,接着立刻写入成功,并未出现之前的一直报错。

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

官方实现

可能有同学会问,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 连接池中一个非常核心的错误,跟连接池机制息息相关,我们应该重点掌握。


发布于: 刚刚阅读数: 5
用户头像

一群聪明的人做好玩的事 2018-11-16 加入

连接技术力量,赋能业务发展,引领创新未来

评论

发布
暂无评论
Golang DB连接池ErrBadConn的应用_golang_三七互娱后端技术团队_InfoQ写作社区