在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`包和`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`包即可。
接下来最关键的一环——如何丢弃连接?
`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_Packet
func (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连接池中一个非常核心的错误,跟连接池机制息息相关,我们应该重点掌握。
扫码关注 了解更多