wth2jhl.online

Back

“Go-MySQL-Driver is a MySQL-Driver for Go’s database/sql package.” —— go-sql-driver/mysql README 第一行


一、为什么我会去翻这个仓库#

起因是上周拆 go-redis v9 源码 的时候,发现它在 internal/proto 里自己实现 RESP 协议解析。Redis 协议简单(5 种类型)都值得自己造轮子,MySQL 协议复杂得多——它一定有更精妙的设计。

我去翻 go-sql-driver/mysql(也常叫 mysql driver):2.4 MB、35 个 .go 文件、14 年历史(2012-05-04 first commit)、1033 commits、最新 v1.10.0(2026-04-28)、Go 1.24+ 要求。

CHANGELOG 第一段就让我意识到这是真的”活着”的仓库:

“v1.9.0 (2025-02-18) — Implement zlib compression. Add support for VECTOR type introduced in MySQL 9.0. Supported Go version is updated to Go 1.21+.”

MySQL 9.0 出了 VECTOR 类型(向量数据库原生字段),driver 两个月内就跟上了。这种节奏说明维护质量。

我给的判断:这是一个”如何用 Go 实现一个生产级 database/sql driver”的教科书级样本。它不试图做大(不像 go-redis 那样覆盖 Redis 全家桶),只做一件事:把 MySQL 协议讲清楚。但它把这件事做到14 年没崩过 API,是 Go 生态最稳定的几个依赖之一。


二、目录长得很稀疏,但每个文件都是一个完整主题#

注意几点:

  1. driver.go 只有 3.6KB——MySQLDriver{} 是空结构体,几乎所有逻辑都在 connector.go
  2. packets.go 最大(37KB)——MySQL 协议本身复杂
  3. 每个 .go 文件对应一个明确主题——没有混在一起的”main logic”

三、driver.go + connector.go:空 driver + 重 connector 的模式#

3.1 driver.go 极简#

MySQLDriver 是空结构体,只有 Open() 一个方法。它把全部重活交给 connector。

3.2 connector.go:连接生命周期都在这#

connector.Connect(ctx) 是一段精心编排的 11 步握手:

func (c *connector) Connect(ctx context.Context) (driver.Conn, error) {
    // 1. beforeConnect 钩子(cfg 改写机会)
    // 2. 创建 mysqlConn{} 结构
    // 3. 用 DialFunc 或默认 net.Dialer 拨号
    // 4. TCP keepalive
    // 5. startWatcher() 启动 context cancel watcher
    // 6. mc.buf = newBuffer()
    // 7. readHandshakePacket() —— 读服务端握手包
    // 8. auth() + writeHandshakeResponsePacket() —— 客户端认证
    // 9. handleAuthResult() —— 处理认证切换
    // 10. 启用压缩(如果 server 通告)
    // 11. 处理 DSN params(SET k = v, ...)
    return mc, nil
}
go

为什么这样拆?

driver.Driver 接口在 Go 1.10 之前是唯一入口,Go 1.10 加了 driver.DriverContext 和 driver.Connector——后者允许复用 Config 对象(不必每次从 DSN 字符串重新解析)。这个 driver 同时实现两套:

func (d MySQLDriver) Open(dsn string) (driver.Conn, error)                  // 老接口
func (d MySQLDriver) OpenConnector(dsn string) (driver.Connector, error)    // 新接口
go

并且 NewConnector(cfg *Config) 让你绕过 DSN 解析直接传 Config 对象。这是给高级用户的入口(不要在 hot path 里重复解析 DSN)。

这是一个标准的”渐进式 API 演化”——旧 API 不删,新 API 渐入。14 年没破 API 兼容性,根因在这里。


四、packets.go:MySQL 协议编解码器#

MySQL 客户端/服务端协议是二进制 TCP 协议,每个 packet 结构:

┌──────────┬──────────┬──────────────────┐
│ pkt len  │ sequence │ payload (n bytes)│
│ [24 bit] │ [8 bit]  │                  │
└──────────┴──────────┴──────────────────┘
text

readPacket() 是个无限循环,处理大 packet 多帧拼接:

几个亮点:

1. Sequence number 校验有 fallback:MySQL 和 MariaDB 不校验压缩包的 sequence,所以代码里 if !mc.compress 才报 ErrPktSyncMul。这是对协议差异的精细处理。

2. close + return ErrInvalidConn:读失败时主动 close 连接并返回 ErrInvalidConn——这是 database/sql 的约定,让连接池知道这条 conn 死了,不要复用。Go database/sql 文档显式说:

“If a connection is found to be in a bad state when used, it should return ErrInvalidConn from one of the methods.”

3. compressed read 走另一条路:如果有 mc.compress,readNext 被替换成 mc.compIO.readNext——用同一个 packet header 接口切换实现,上层代码完全无感。

我截的这段代码不到 50 行,但藏着 3 个生产级细节。这就是 14 年迭代出来的代码质感。


五、buffer.go:自己造 zero-copy-ish buffer,不复用 bufio#

// buffer.go
const defaultBufSize = 4096
const maxCachedBufSize = 256 * 1024

type buffer struct {
    buf       []byte // read buffer.
    cachedBuf []byte // buffer that will be reused. len(cachedBuf) <= maxCachedBufSize.
}
go

为什么不用 bufio.Reader?

“The buffer is similar to bufio.Reader / Writer but zero-copy-ish. Also highly optimized for this particular use case.”

MySQL 协议是同步的——一个连接同时只能读或写(同一时刻),所以读 buffer 和写 buffer 可以复用同一个。bufio.Reader/Writer 假设双向流,必须分两个 buffer——浪费。

fill() 段有个细节:

func (b *buffer) fill(need int, r readerFunc) error {
    dest := b.cachedBuf

    // grow buffer if necessary to fit the whole packet.
    if need > len(dest) {
        dest = make([]byte, ((need/defaultBufSize)+1)*defaultBufSize)
        if len(dest) <= maxCachedBufSize {
            b.cachedBuf = dest
        }
    }

    n := len(b.buf)
    copy(dest[:n], b.buf)
    // ...
}
go

maxCachedBufSize = 256KB——超过这个阈值的 buffer 不缓存(防止长 packet 撑爆 GC)。这是个显式的内存预算:小 buffer 进 cache,大 buffer 临时分配。

这种”自己造轮子但每个字节都优化”的代码风格是 C 时代系统程序员的味道,Go 生态里也只有这种底层驱动代码才会出现。


六、auth.go:4 种 auth 插件 + RSA 公钥注册#

6.1 4 种 auth 插件#

MySQL 8.0 把默认 auth plugin 从 mysql_native_password(SHA1-based)换成了 caching_sha2_password(SHA256 + caching)。MariaDB 又搞了 client_ed25519。driver 全部支持:

// auth.go 中
case "mysql_native_password":     // SHA1, 老 MySQL
case "caching_sha2_password":     // SHA256 + caching, MySQL 8.0 默认
case "sha256_password":           // SHA256 不缓存, MySQL 5.7+
case "client_ed25519":            // Ed25519, MariaDB
go

fallback 机制在 connector.go:

// Send Client Authentication Packet
authResp, err := mc.auth(authData, plugin)
if err != nil {
    c.cfg.Logger.Print("could not use requested auth plugin '"+plugin+"': ", err.Error())
    plugin = defaultAuthPlugin
    authResp, err = mc.auth(authData, plugin)
    if err != nil {
        mc.cleanup()
        return nil, err
    }
}
go

——如果服务端要求的插件失败,降级到默认插件重试一次。这是给跨版本 MySQL/MariaDB 部署的实战兼容性。

6.2 RSA 公钥注册#

auth.go 提供 RegisterServerPubKey(name, pubKey) API:用户预先注册服务器的 RSA 公钥,driver 在加密密码传输时用本地公钥,避免从服务器(可能被 MITM 篡改)拿公钥。

var (
    serverPubKeyLock     sync.RWMutex
    serverPubKeyRegistry map[string]*rsa.PublicKey
)
go

global registry + RWMutex——跟 RegisterDialContext、RegisterTLSConfig、RegisterReader、RegisterLocalFile 一个模式。全部是 global 注册表 + 全局读写锁。这种设计在 14 年前是惯例,今天用 context-based DI 看着有点老,但用 sync.RWMutex 包好并发就够了,简单可靠。

6.3 Ed25519 是唯一外部依赖#

// go.mod
require filippo.io/edwards25519 v1.2.0
go

——整个 driver 只依赖一个 crypto 库(Ed25519 签名)。CHANGELOG 提到:

“Bump filippo.io/edwards25519 from 1.1.1 to 1.2.0. While older versions have reported CVEs, they do not affect go-mysql.”

“依赖少 = 攻击面小”——这道理大家都懂,但能做到 1 个依赖的 Go 项目极少。


七、infile.go:LOAD DATA LOCAL INFILE 的 3 层安全#

LOAD DATA LOCAL INFILE 是 MySQL 的”让客户端上传文件”功能。历史上有 CVE-2019-12042 / CVE-2021-2186 等多个任意文件读取漏洞。driver 给了 3 层防御:

Layer 1: DSN allowAllFiles=true (默认 false)
         ↓ 同意任意本地文件
Layer 2: RegisterLocalFile(path) (白名单)
         ↓ 只允许预先注册的文件
Layer 3: RegisterReader(name, func() io.Reader) (函数白名单)
         ↓ 不读文件,读自定义 reader
text

每层从宽到严,默认只开 Layer 2/3(要明确注册才能用):

// infile.go
var (
    fileRegister       map[string]struct{}
    fileRegisterLock   sync.RWMutex
    readerRegister     map[string]func() io.Reader
    readerRegisterLock sync.RWMutex
)
go

Layer 3 最安全——你可以注册一个”读 S3 流的 reader”,根本不碰本地文件系统。这种设计是给云原生场景的。

注意注册表都是 nil-initialized + lazy init:

fileRegisterLock.Lock()
if fileRegister == nil {
    fileRegister = make(map[string]struct{})
}
// ...
go

——没人注册就不分配 map,避免零开销污染。


八、connector.go 的隐藏礼物:连接属性#

每次建连,driver 自动给服务端发一组客户端连接属性(MySQL 5.6+ CLIENT_CONNECT_ATTRS):

// connector.go
connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrClientName)
connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrClientNameValue) // "_client_name": "go-mysql"
connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrOS)
connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrOSValue)         // "_os": runtime.GOOS
connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrPlatform)
connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrPlatformValue)   // "_platform": runtime.GOARCH
connAttrsBuf = appendLengthEncodedString(connAttrsBuf, connAttrPid)
connAttrsBuf = appendLengthEncodedString(connAttrsBuf, strconv.Itoa(os.Getpid()))
// _server_host(如果能从 Addr 解析出来)
// + 用户自定义的 ConnectionAttributes
go

这是给 DBA 的礼物——他们能在 MySQL 端查询:

SELECT * FROM performance_schema.session_connect_attrs;
sql

看到当前所有连接来自哪个 Go 程序、哪个 OS、哪个 PID。排查”哪个服务连了数据库”超好用。

CHANGELOG 提到 v1.9.0:

“Check if MySQL supports CLIENT_CONNECT_ATTRS before sending client attributes.”

——这个 fix 重要:MySQL 5.6 之前的版本不认这个 capability,发了会被服务端忽略。driver 主动探测能力再发送。


九、handleParams:DSN 参数下推为 SET k = v#

DSN 里的 params 不会在握手包发——连接建好后用一条 SET k = v, ... 批量下发。

注意 cmdSet.Grow(4 + ... + 30*(len-1))——这是预分配 builder 容量的小优化:4 是 “SET “,30 估算每个额外 param 加 ”, ” 的开销。一次 grow 不再 realloc。

DSN params 可以做这些事(README 有完整列表):

  • time_zone='+08:00' — 设时区
  • charset=utf8mb4 — 设字符集
  • parseTime=true — 把 DATETIME 字段解析成 time.Time
  • loc=Local — time.Time 的时区
  • maxAllowedPacket=... — 覆盖服务端默认值

全部下推成 SET,每次新连接都重新设置——这就是为什么 go-mysql 是连接级配置,而不是进程级配置。


十、conncheck.go:build tag 探测 Go 版本#

// conncheck.go
//go:build !go1.24
package mysql

func connCheck(tlsConfig string) error {
    // Go < 1.24: 没有 Conn.RotateKey, 不做检查
    return nil
}
go
// conncheck_dummy.go
//go:build go1.24
package mysql

import "crypto/tls"

func connCheck(tlsConfig string) error {
    // Go 1.24+ tls.Config.RotateKey, 检查用户配的 tls.Config 有没有设
    // ...
}
go

——用 build tag 区分 Go 版本,决定要不要启用 TLS rotation 检查。这是给 Go 1.24+ 的 TLS connection key rotation 加 hook。

文件命名约定是 <feature>.go + <feature>_dummy.go,两个文件同名不同 build tag。这种模式在 Go 标准库很常见,driver 沿用了。


十一、它示范的 5 条工程纪律#

11.1 渐进式 API 演化#

14 年没破 API——靠同时维护 driver.Driver、driver.DriverContext、driver.Connector 三套接口,老用户用 Open()、新用户用 NewConnector()。CHANGELOG 严格按 SemVer(v1.0.0 → v1.10.0),几乎每个 minor 版本都有 BC。

11.2 全局注册表 + RWMutex#

RegisterTLSConfig、RegisterDialContext、RegisterServerPubKey、RegisterLocalFile、RegisterReader——全部是 global registry pattern。在 2026 年看有点老(context-based DI 是新潮),但用 sync.RWMutex 包好并发就是最简单的可靠方案。

11.3 Zero-copy buffer 优化#

buffer.go 不复用 bufio.Reader/Writer——自己造,因为 MySQL 协议同步读写单 buffer 就够。maxCachedBufSize = 256KB 显式内存预算。

11.4 Lazy init + 显式 nil 检查#

fileRegisterLock.Lock()
if fileRegister == nil {
    fileRegister = make(map[string]struct{})
}
go

没人注册就不分配 map。零开销 + 显式同步。

11.5 Build tag 跨版本兼容#

conncheck.go vs conncheck_dummy.go —— Go 版本差异用 build tag 隔离,不引入运行时 if。编译期解决。


十二、它不适合什么场景#

公平起见,这套 driver 不是万能的:

  • 不适合需要 sharding / 读写分离的场景——它只是 database/sql driver 的实现,sharding 交给 go-mysql-elasticsearch / sharding-proxy 之类中间件
  • 不适合需要 MySQL X Protocol(X DevAPI)的场景——X Protocol 用 Protobuf,这是另一个 driver:mysql/xdriver
  • 不适合不连 MySQL 的场景——TiDB 有自己的兼容性注意事项(README 显式提到)
  • 不适合需要 batched prepared statement 的场景——MySQL 协议本身的限制
  • 不适合想要 ORM 的场景——这是 driver,ORM 用 gorm / sqlx / ent

它的甜蜜区是:所有用 Go 连 MySQL/MariaDB 的应用层代码。覆盖率几乎 100%。


十三、给想深入学习的人#

如果你想 fork 或基于这个写自己的 database/sql driver,建议按这个顺序:

  1. 抄 driver.go 骨架——空 struct + Open() + init() Register
  2. 实现 connector.Connect()——11 步握手流程先写清楚,再填每步
  3. 实现 packets.go——readPacket 循环 + writeCommandPacket,协议常量在 const.go
  4. 加 buffer.go——不要用 bufio,自己造 + 256KB 预算
  5. 加 auth.go——至少支持 default auth plugin(Mysql Native Password),加 fallback
  6. 加 statement.go + rows.go + fields.go——SQL 类型映射是工程量最大的部分
  7. 加 DSN 解析——用 github.com/go-sql-driver/mysql.ParseDSN 参考
  8. 加 README + CHANGELOG——每个 PR 写 changeset,CHANGELOG 严格 SemVer

走完这套,你的 driver 就有生产可用的能力。剩下的就是长时间迭代 + 多 MySQL 版本兼容矩阵。


结语#

go-sql-driver/mysql 证明了一件事:好的 driver 是协议 + 安全 + 工程纪律三位一体。

它不是炫技项目(没有花哨的并发模式、没有泛型魔法),但每个字节都经得起 14 年生产环境考验。当所有 Go 应用都在用它连 MySQL 时,它的稳定性等于 Go 生态的基础设施稳定性。

我读完这套代码最大的收获不是某个具体 API——是它示范了怎么把一个二进制网络协议(MySQL client/server protocol)封装成 Go 风格的 database/sql driver。packets.go 的编解码 + buffer.go 的 zero-copy + connector.go 的生命周期 + auth.go 的多 plugin fallback——每一层都是教科书。

这是 Go 工程化的下一站:不是更大的框架,是更深的协议实现。


引用#

关联阅读#

Go-MySQL-Driver 深度拆解:14 年演化的 Go 官方 MySQL driver
https://wth2jhl.online/blog/go-mysql-driver-deep-dive/
Author 小吴同学
Published at August 20, 2026