乐趣区

关于go:Go-语言使用-MySQL-的常见故障分析和应对方法

导读:很多同学在应用 Go 和数据库打交道的过程中,常常会遇到一些异样不晓得为什么,本文从 SQL 连接池的原理进行剖析,模仿了一些例子对异样的景象进行解读剖析,并给出一些常见的应答伎俩,冀望能帮忙到大家。

全文 12795 字,预计浏览工夫 32 分钟

有很多同学遇到了 MySQL 查问迟缓的问题,其可能体现为 SQL 语句很简略,然而查问耗时很长。可能是因为这样一些起因所致。

1、资源未及时开释

Go 的 sql 包应用的是长连贯形式让 Client 和 SQL Server 交互,为了防止 SQL Server 链接过多,个别会在 Client 端限定最大连接数。

上面是 sql 的连接池的状态图(设置了最大关上连接数的状况):

SQL Client 和 Server 交互后,有些后果返回的是一个流 (Stream),此时的网络连接(Conn) 是被 Stream 对象持续应用的,Client 须要迭代读取后果,读取实现后应立即敞开流以回收资源(开释 conn)。

比方最长用的 DB.QueryContext 办法即是如此:

// QueryContext 查问一些后果
// query:select * from test limit 10
func (db *DB) QueryContext(ctx context.Context, query string, args ...any) (*Rows, error)
type Rows struct{Close() error 
    ColumnTypes() ( []*ColumnType, error) 
    Columns() ( []string, error) 
    Err( ) error 
    Next( ) bool 
    NextResultSet( ) bool 
    Scan(dest ...any) error
}

当还有后果的时候(即 Rows.Next()==true 时),阐明还有后果未读取进去,此时必须调用 Rows.Close() 办法来对流进行敞开以开释连贯(让以后连贯变为闲暇状态以 让其余逻辑能够应用该连贯)。

1.1 试验 1 - 不调用 Rows.Close()

若不调用 Close 又会怎么呢?上面做一个试验来察看一下:

select * from user;
+----+-------+---------------------+----------+--------+
| id | email | register_time       | password | status |
+----+-------+---------------------+----------+--------+
|  2 | dw    | 2011-11-11 11:01:00 | d        |      0 |
+----+-------+---------------------+----------+--------+
1 row in set (0.03 sec)
package main
import (
   "context"
   "database/sql"
   "encoding/json"
   "fmt"
   "sync"
   "time"
   _ "github.com/go-sql-driver/mysql"
)
func main() {db, err := sql.Open("mysql", "root:@tcp(127.0.0.1:3306)/test")
   if err != nil {panic(err)
   }
   db.SetMaxOpenConns(1)
   // 启动一个独自的协程,用于输入 DB 的状态信息
   go func() {tk := time.NewTicker(3 * time.Second)
      defer tk.Stop()
      for range tk.C {bf, _ := json.Marshal(db.Stats())
         fmt.Println("db.Stats=", string(bf))
      }
   }()
   // 启动 10 个协程,同时查问数据
   var wg sync.WaitGroup
   for i := 0; i < 10; i++ {wg.Add(1)
      go func(id int) {defer wg.Done()
         queryOne(id, db)
      }(i)
   }
   wg.Wait()
   fmt.Println("finish")
}
func queryOne(id int, db *sql.DB) {start := time.Now()
   rows, err := db.QueryContext(context.Background(), "select * from user limit 1")
   if err != nil {panic(err)
   }
   // defer rows.Close() 
   // 没有从 Rows 里读取后果,也没有调用 rows.Close
   fmt.Println("id=", id, "hasNext=", rows.Next(), "cost=", time.Since(start))
}

执行后将输出如下内容:

id= 0 hasNext= true cost= 9.607371ms
db.Stats= {"MaxOpenConnections":1,"OpenConnections":1,"InUse":1,"Idle":0,"WaitCount":9,"WaitDuration":0,"MaxIdleClosed":0,"MaxIdleTimeClosed":0,"MaxLifetimeClosed":0}
db.Stats= {"MaxOpenConnections":1,"OpenConnections":1,"InUse":1,"Idle":0,"WaitCount":9,"WaitDuration":0,"MaxIdleClosed":0,"MaxIdleTimeClosed":0,"MaxLifetimeClosed":0}
db.Stats= {"MaxOpenConnections":1,"OpenConnections":1,"InUse":1,"Idle":0,"WaitCount":9,"WaitDuration":0,"MaxIdleClosed":0,"MaxIdleTimeClosed":0,"MaxLifetimeClosed":0}
db.Stats= {"MaxOpenConnections":1,"OpenConnections":1,"InUse":1,"Idle":0,"WaitCount":9,"WaitDuration":0,"MaxIdleClosed":0,"MaxIdleTimeClosed":0,"MaxLifetimeClosed":0}
db.Stats= {"MaxOpenConnections":1,"OpenConnections":1,"InUse":1,"Idle":0,"WaitCount":9,"WaitDuration":0,"MaxIdleClosed":0,"MaxIdleTimeClosed":0,"MaxLifetimeClosed":0}
db.Stats= {"MaxOpenConnections":1,"OpenConnections":1,"InUse":1,"Idle":0,"WaitCount":9,"WaitDuration":0,"MaxIdleClosed":0,"MaxIdleTimeClosed":0,"MaxLifetimeClosed":0}
db.Stats= {"MaxOpenConnections":1,"OpenConnections":1,"InUse":1,"Idle":0,"WaitCount":9,"WaitDuration":0,"MaxIdleClosed":0,"MaxIdleTimeClosed":0,"MaxLifetimeClosed":0}
db.Stats= {"MaxOpenConnections":1,"OpenConnections":1,"InUse":1,"Idle":0,"WaitCount":9,"WaitDuration":0,"MaxIdleClosed":0,"MaxIdleTimeClosed":0,"MaxLifetimeClosed":0}
db.Stats= {"MaxOpenConnections":1,"OpenConnections":1,"InUse":1,"Idle":0,"WaitCount":9,"WaitDuration":0,"MaxIdleClosed":0,"MaxIdleTimeClosed":0,"MaxLifetimeClosed":0}

解读一下状态数据:

{
    "MaxOpenConnections": 1,  // 最大关上连接数,和代码设置的统一,是 1
    "OpenConnections": 1,     // 已关上的连接数    
    "InUse": 1,               // 正在应用的连接数
    "Idle": 0,                // 闲暇连接数
    "WaitCount": 9,           // 期待连接数
    "WaitDuration": 0,        // 期待总耗时(在期待退出时才计数)"MaxIdleClosed": 0,       // 超过最大 idle 数所敞开的连贯总数 
    "MaxIdleTimeClosed": 0,   // 超过追到 idle 工夫所敞开的连贯总数
    "MaxLifetimeClosed": 0    // 超过最大生命周期所敞开的连贯总数
}

从下面的输入能够看出,总共启动了 10 个协程,只有一个协程的 queryOne 办法胜利执行了,其余 9 个协程的都是处于期待状态。

1.2 试验 2 - 调用 Rows.Close()

若将 queryOne 办法的,“// defer rows.Close()”的正文去掉,即变为:

func queryOne(id int, db *sql.DB) {start := time.Now()    
    rows, err := db.QueryContext(context.
Background(), "select * from user limit 1")    
    if err != nil {panic(err)    
    }    
    defer rows.Close() // 关上了此处的正文,Close 办法会开释资源    
    fmt.Println("id=", id, "hasNext=", rows.Next(), "cost=", time.Since(start)) 
}

执行后,会输入如下内容:

# go run main.go
id= 9 hasNext= true cost= 4.082448ms
id= 3 hasNext= true cost= 5.670052ms
id= 8 hasNext= true cost= 5.745443ms
id= 5 hasNext= true cost= 6.238615ms
id= 6 hasNext= true cost= 6.520818ms
id= 7 hasNext= true cost= 6.697782ms
id= 4 hasNext= true cost= 6.953454ms
id= 1 hasNext= true cost= 7.1079ms
id= 0 hasNext= true cost= 7.3036ms
id= 2 hasNext= true cost= 7.464726ms
finish

上述输入后果阐明所有的 10 个协程都胜利执行实现。

1.3 试验 3 - 应用带超时的 Context

补充,上述调用 QueryContext 办法的时候,应用的是 context.Background(),所以是统一阻塞的成果。理论在应用的时候,传入的 context 个别是有超时工夫或者反对勾销的,相似这样:

func  queryOne(id int, db *sql.DB) {start := time.Now() 
    ctx,cancel:=context.WithTimeout(context.Background(),time.Second) // 要害     
    defer cancel()  // 要害。若将此行替换为 _=cancel, 又是另外一种后果了
    rows, err := db.QueryContext(ctx , "select * fro m user  limit 1") 
     if err != nil {// panic (err) 
       fmt.Println("BeginTx failed:",err)        
       return 
    } 
    // defer rows.Close () // 关上了此处的注 释,Close  办法会开释资源     
    fmt.Println("id=" , id, "hasNext=", rows.Next(), "cost=", time.Since (start)) 
}

运行后能够察看到,所有的 10 个协程也都执行胜利了:

id= 9 hasNext= true cost= 1.483715ms
id= 3 hasNext= true cost= 175.675µs
id= 6 hasNext= true cost= 1.277596ms
id= 1 hasNext= true cost= 174.307µs
id= 7 hasNext= true cost= 108.061µs
id= 4 hasNext= true cost= 115.072µs
id= 2 hasNext= true cost= 104.046µs
id= 0 hasNext= true cost= 96.833µs
id= 8 hasNext= true cost= 123.758µs
id= 5 hasNext= true cost= 92.791µs
finish

因为 context 是带超时的,而且执行实现后会调用 defer cancel() 将 ctx 勾销,所以即便没有应用 rows.Close 开释资源,ctx 在被 cancel 后也会立刻开释资源。

若是将 defer cancel() 换为 \_=cancel , 又是另外一种后果了,咱们将看到的是:

d= 9 hasNext= true cost= 2.581813ms
BeginTx failed: context deadline exceeded
BeginTx failed: context deadline exceeded
BeginTx failed: context deadline exceeded
BeginTx failed: context deadline exceeded
BeginTx failed: context deadline exceeded
BeginTx failed: context deadline exceeded
BeginTx failed: context deadline exceeded
BeginTx failed: context deadline exceeded
BeginTx failed: context deadline exceeded

1.4 解决方案

小结:

  • 咱们应该应用 QueryContext 这类反对传入 context 的函数,并且传入带超时管制的 context,并且在逻辑执行实现后,应应用 defer 办法将 context 勾销。
  • 对于返回一个流类型的后果,应用实现后肯定须要调用 Close 办法以开释资源。
  • 所有 *sql.DB、*sql.Tx、*sql.Stmt 的返回 *Conn、*Stmt、*Rows 这几种类型的都须要 Close:
type DB/Tx/Stmt struct{Conn(ctx context.Context) (*Conn, error)
   Prepare(query string) (*Stmt, error)
   PrepareContext(ctx context.Context, query string) (*Stmt, error)
   Query(query string, args ...any) (*Rows, error)
   QueryContext(ctx context.Context, query string, args ...any) (*Rows, error)
}

要防止该问题呈现,个别只须要如上例,增加上 defer rows.Close() 即可。

若是应用的 GDP 框架,读取 Rows 后果,能够应用 mysql.ReadRowsClose 办法,在读取实现后,会主动的 Close。比方:

type user struct {     
   ID           int64     `ddb:"id"`     
   Status       uint8     `ddb:"status"` 
}
func readUsers(ctx context.Context)([]*user,error)
    rows, err := cli.QueryContext(ctx, "select * from user where status=1 limit 5")     
    if err != nil {return nil,err}     
    var userList []*user     
    err=mysql.ReadRowsClose(rows, &userList)     
    return   userList,err
}

或者是 QueryWithBuilderScan:

b := &SimpleBuilder{SQL: "SELECT id,name from user where id=1",}
 type user struct{
  Name string `ddb:"name"`
  ID int `ddb:"id"`
 }
 var us []*user
 err = mysql.QueryWithBuilderScan(ctx, client, b, &us)

2、事务不残缺

关上一个事务 (Tx) 后,必须提交 (Commit) 或者回滚 (Rollback),否则会事务不残缺,也会导致 Client 端资源(连贯) 不开释。

func (db *DB) BeginTx(ctx context.Context, opts *TxOptions) (*Tx, error)
type Tx 
func (tx *Tx) Commit() error    // 提交事务
func (tx *Tx) Rollback ( ) error  // 回滚事务
func (tx *Tx) Exec(query string, args ...any) (Result, error) 
func (tx *Tx) ExecContext(ctx context.Context, query string, args ...any) (Result, error) 
func (tx *Tx) Prepare(query string) (*Stmt, error) 
func (tx *Tx) PrepareContext(ctx context.Context, query string) (*Stmt, error) 
func (tx *Tx) Query(query string, args ...any) (*Rows, error) 
func (tx *Tx) QueryContext(ctx context.Context, query string, args ...any) (*Rows, error) 
func (tx *Tx) QueryRow(query string, args ...any) *Row 
func (tx *Tx) QueryRowContext(ctx context.Context, query string, args ...any) *Row 
func (tx *Tx) Stmt(stmt *Stmt) *Stmt 
func (tx *Tx) StmtContext(ctx context.Context, stmt *Stmt) *Stmt

2.1 和 PHP 的区别

另外须要留神的是,应用 Go 规范库的 DB.BeginTx 办法开启一个事务后,会失去一个事务对象 Tx,要让一批 SQL 在一个事务里执行须要让这些 SQL 在此 Tx 对象上执行。这点和 PHP 的是不一样的,比方在 PHP 中是这样应用事务:

  <?php
/* 开始一个事务,敞开主动提交 */
$dbh->beginTransaction(); 
 /* 在全有或全无的根底上插入多行记录(要么全副插入,要么全副不插入)*/
$sql = 'INSERT INTO fruit(name, colour, calories) VALUES (?, ?, ?)';
$sth = $dbh->prepare($sql);
foreach ($fruits as $fruit) {
    $sth->execute(array(
        $fruit->name,
        $fruit->colour,
        $fruit->calories,
    ));
}
/* 提交更改 */
$dbh->commit();
// 此代码来自 https://www.php.net/manual/zh/pdo.commit.php

而应用 Go 的事务是这样的:

import (
  "context"
  "database/sql"
  "log"
)
var (
  ctx context.Context
  db  *sql.DB
)
func main() {tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
  if err != nil {log.Fatal(err)
  }
  id := 37
    // 应用 Tx 执行 Update 语句,而不是持续应用 db.Exec
  _, execErr := tx.Exec(`UPDATE users SET status = ? WHERE id = ?`, "paid", id)
  if execErr != nil {_ = tx.Rollback()
    log.Fatal(execErr)
  }
  if err := tx.Commit(); err != nil {log.Fatal(err)
  }
}
// 此代码来自于:https://pkg.go.dev/database/sql@go1.18.3#example-DB.BeginTx

2.2 试验

上面持续试验事务不残缺的影响,主体局部和上述一样,queryOne 办法变成如下这样:

func queryOne(id int, db *sql.DB) {tx,err:=db.BeginTx(context.Background(),nil)
   if err!=nil{panic(err)
   }
   // defer tx.Rollback()
   start := time.Now()
   rows, err := tx.QueryContext(context.Background(), "select * from user limit 1")
   if err != nil {panic(err)
   }
   defer rows.Close()
   // 事务没有回滚、提交
   fmt.Println("id=", id, "hasNext=", rows.Next(), "cost=", time.Since(start))
}

执行后输出和上述没有 rows.Close 相似:

id= 9 hasNext= true cost= 11.670369ms
db.Stats= {"MaxOpenConnections":1,"OpenConnections":1,"InUse":1,"Idle":0,"WaitCount":9,"WaitDuration":0,"MaxIdleClosed":0,"MaxIdleTimeClosed":0,"MaxLifetimeClosed":0}
db.Stats= {"MaxOpenConnections":1,"OpenConnections":1,"InUse":1,"Idle":0,"WaitCount":9,"WaitDuration":0,"MaxIdleClosed":0,"MaxIdleTimeClosed":0,"MaxLifetimeClosed":0}
db.Stats= {"MaxOpenConnections":1,"OpenConnections":1,"InUse":1,"Idle":0,"WaitCount":9,"WaitDuration":0,"MaxIdleClosed":0,"MaxIdleTimeClosed":0,"MaxLifetimeClosed":0}
db.Stats= {"MaxOpenConnections":1,"OpenConnections":1,"InUse":1,"Idle":0,"WaitCount":9,"WaitDuration":0,"MaxIdleClosed":0,"MaxIdleTimeClosed":0,"MaxLifetimeClosed":0}

同样,总共启动了 10 个协程,只有一个协程的 queryOne 办法胜利执行了,其余 9 个协程的都是处于期待状态。

若将上述 queryOne 办法中的 // defer tx.Rollback() 的正文关上,则所有 10 个协程都能够胜利执行实现。

2.3 解决方案

要防止事务不残缺,要保障事务要么被 Commit,要么被 Rollback。

若是应用的 GDP 框架,能够应用 mysql.BeginTx 办法来应用事务。该计划能够更平安的应用事务,会主动的根据 函数返回值来决定是 Commit 还是 Rollback,若业务函数呈现了 panic 也会主动的 Rollback。

// 业务逻辑函数的定义,在此函数内实现事务内的增删改查
// 返回 error==nil 则 tx.Commit(), 否则 tx.Rollback()
type doFunc func(ctx context.Context, qe QueryExecuto r) error 
func BeginTx(ctx context.Context, cli CanBeginTx, opts *sql.TxOptions, do doFunc) error
var cli mysql.Client
updateUserNameByID := func(ctx context.Context, id uint64, name string) error {
   //  应用 BeginTx 办法,能更省心的处理事务
   err := mysql.BeginTx(ctx, cli, nil, func(ctx context.Context, qe mysq.QueryExecutor) error {
      // 其余的数据库更新逻辑略
      b1 := &mysql.SimpleBuilder{}
      b1.Append("select name from user where uid=?", id)
      var oldName string
      if err := mysql.QueryRowWithBuilderScan(ctx, qe, b1, &oldName); err != nil {return err}
      if oldName == "诸葛亮" || oldName == name {
         // 返回 err,mysql.BeginTx 办法将会回滚事务
         return fmt.Errorf("不须要更新,事务整体回滚")
      }
      b2 := &mysql.SimpleBuilder{}
      b2.Append("update user set name=? where id=?", name, id)
      _, err := mysql.ExecWithBuilder(ctx, qe, b2)
      if err != nil {return err}
      // 返回 nil,mysql.BeginTx 办法将会提交事务
      return nil
   })
   return err
}

3、其余起因

3.1 不反对预处理

默认个别会应用预处理的形式来晋升 SQL 的安全性,防止产生 SQL 注入的问题。

若是在厂内应用集群版 MySQL:DDBS(DRDS),其对 prepare 反对的并不好,应用后会导致性能特地差。可能体现为,本应该几毫秒返回的查问,实际上要数百毫秒甚至数秒能力返回。此时须要在参数中增加上配置项 interpolateParams=true,敞开 prepare 性能来解决。

Name = "demo"
# 其余配置项略
[MySQL] 
Username     = "example"
# 其余参数略
DSNParams ="charset=utf8&timeout=90s&collation=utf8mb4_unicode_ci&parseTime=true&interpolateParams=true"

4、如何排查

咱们能够利用 DB 的 Stats() 接口返回的数据来剖析是否存在上述问题。在上述章节中,咱们就是打印此数据来察看 Client 的状态信息。

{    
"MaxOpenConnections" : 1 ,  // 最大关上连接数,和代码设置的统一,是 1    
"OpenConnections" : 1 ,     // 已关上的连接数        
"InUse" : 1 ,               // 正在应用的连接数    
"Idle" : 0 ,                // 闲暇连接数    
"WaitCount" : 9 ,           // 期待连接数    
"WaitDuration" : 0 ,        // 期待总耗时(在期待退出时才计数)"MaxIdleClosed" : 0 ,       // 超过最大 idle 数所敞开的连贯总数     
"MaxIdleTimeClosed" : 0 ,   // 超过追到 idle 工夫所敞开的连贯总数    
"MaxLifetimeClosed" : 0    // 超过最大生命周期所敞开的连贯总数
}

若应用的是 GDP 框架,咱们能够通过如下几种伎俩来察看此数据。

4.1 集成 GDP 利用面板

在百度厂内,GDP 框架(百度外部的  Go Develop Platform,具备易用性好、易扩大、易察看、稳固牢靠的特点,被数千模块应用)提供了一个叫做 ”GDP 利用面板 ” 的功能模块,该模块提供了可视化的 UI 让咱们能够十分不便的查看、察看利用的各种状态信息。比方能够查看零碎信息、文件系统信息、网络状态信息、编译信息、go runtime 信息、框架里各种组件的状态信息(如服务发现的运行状态、MySQL、Redis 等 各种 Client 的连接池信息等)。

集成该性能非常简单,只须要增加 2 行配置性代码。

实现集成后,能够通过 http://ip:port/debug/panel/?t… 来拜访此面板,找到对应的 servicer 后(页面的地址是 /debug/panel/?tab=servicer&key={servicer\_name}),页面上的“MySQL ClientStats”段落即为以后 MySQL Client 的 Stats 信息。比方:


4.2 集成监控

GDP 框架的标准化指标监控能力曾经将所有 MySQL Client 的 Stats 信息进行了采集输入。能够以 prometheus 或者 bvar 格局输入。

实现集成后,拜访 http://ip:port/metrics/service 即可查看到对应的指标项,大抵是这样的:

client_connpool{servicer="demo_mysql",stats="ConnType"} 1
client_connpool{servicer="demo_mysql",stats="IPTotal"} 1
client_connpool{servicer="demo_mysql",stats="InUseAvg"} 0
client_connpool{servicer="demo_mysql",stats="InUseMax"} 0
client_connpool{servicer="demo_mysql",stats="InUseTotal"} 0
client_connpool{servicer="demo_mysql",stats="NumOpenAvg"} 0
client_connpool{servicer="demo_mysql",stats="NumOpenCfg"} 100
client_connpool{servicer="demo_mysql",stats="NumOpenMax"} 0
client_connpool{servicer="demo_mysql",stats="NumOpenTotal"} 0

能够对上述指标增加报警,以帮咱们更快发现并定位到问题。

4.3 输入到日志

若不采纳上述 2 种计划,还能够采纳启动一个异步协程,定期将 Stats 信息输入到日志的计划,以不便咱们剖析定位问题。

————————END————————

举荐浏览

百度交易中台之钱包零碎架构浅析

基于宽表的数据建模利用

百度评论中台的设计与摸索

基于模板配置的数据可视化平台

如何正确的评测视频画质

小程序启动性能优化实际

咱们是如何穿过低代码“⽆⼈区”的:amis 与爱速搭中的要害设计

挪动端异构运算技术 -GPU OpenCL 编程(根底篇)

退出移动版