问题背景
在 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。
问题解决
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:29,31: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 连接池中一个非常核心的错误,跟连接池机制息息相关,我们应该重点掌握。
评论