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