本記事は サムザップ Advent Calendar 2025 の8日目の記事です。
はじめに
はじめまして!
本記事では Go の MySQL クライアントの実装を見ていきつつ、どのように MySQL サーバーと通信しているのか、そのプロトコルも含めて掘り下げていきます。
そうすることで、MySQL も Go も理解を深めていこうというのが目的です。
各種技術とそのバージョン
-
MySQL
- v9.5.0
-
database/sql
- v1.25.5
- Go 標準の SQL データベースに関する汎用インターフェース
-
go-sql-driver/mysql
- v1.9.3
- MySQL 用のデータベースドライバー
MySQL プロトコルの概要
MySQL の通信プロトコルは、以下の 2 つのフェーズから成り立つステートフルプロトコルです。
- 接続フェーズ
- サーバーとクライアントの接続を確立するフェーズ
- コマンドフェーズ
- SQL などのコマンドを実行するフェーズ
- コマンドフェーズが終わるとコネクションが閉じる
MySQL プロトコルパケット
サーバーとクライアント間では、最大 16MB の以下のような パケットをやりとりします。
| 型 | 名前 | 説明 |
|---|---|---|
| int<3> | payload_length | ペイロード長 |
| int<1> | sequence_id | シーケンス ID |
| string | payload | パケットのペイロード |
MySQL サーバーと Go のクライアントとの通信の流れ
MySQL サーバーと Go のクライアントとの主な通信の流れは以下のとおりです。
基本的には接続フェーズとコマンドフェーズを通して任意のコマンドを実行します。ただ、database/sql にはコネクションプールの機構があるため、コネクションを閉じずに別のコマンドを受け付けることで、効率的にコネクションを利用するようにしています。
- クライアントは MySQL サーバーに接続してコネクションを確立する(= 接続フェーズ)
- クライアントはコネクションを介してコマンドを 1 つ送信する(= コマンドフェーズ)
- クライアントはサーバーから返されたレスポンスを読み取る
- 読み取り完了後、コネクションをアイドル状態(Sleep)にする
- アイドル状態のコネクションは別のコマンドを受け付ける → 2,3 を繰り返す
- database/sql はコネクションプールの機構があるため可能
- 必要に応じてコネクションをクローズ
接続フェーズ
接続フェーズでは主に以下を行います。
- Capability Negotiation
- クライアント/サーバーの互いがサポートしている機能やオプションを互いに伝え合い、相互に利用可能な範囲で接続を確立する
- 必要なら SSL 通信チャネルの設定
- クライアント認証
シーケンス
SSL ハンドシェイクがある場合のシーケンスが以下のような形です。

はじめにクライアントがサーバーに接続すると、サーバーはクライアントに Protocol::Handshake パケットを送信します。
その後必要であれば SSL 接続を確立し、最後にクライアントは Protocol::HandshakeResponse を送信します。
Protocol::Handshake パケットには、MySQL サーバーのバージョンやサーバーがサポートしている機能を表す Capabilities Flags などが含まれます。そうすることで、クライアントは相互利用可能な機能を判断することができ、接続を確立することができます。
database/sql の処理
DB 設定の初めに行われる sql.Open で通信が行われていると勘違いされがちですが、実際は以下のような処理の流れになっています。
-
Open(driverName, dataSourceName string)- database/sql -
(db *DB) connectionOpener(ctx context.Context)- database/sql- 以下を実行する goroutine を作成
-
(db *DB) openNewConnection(ctx context.Context)- database/sql -
(c *connector) Connect(ctx context.Context)- go-sql-driver/mysql
-
- 以下を実行する goroutine を作成
作成された goroutine は db.openerCh から値を受信すると最終的に mysql.Connect が実行されます。そこで実際の接続処理が行われ、これは各種 Query や Exec が実行されたタイミングで処理されます。
ではその mysql.Connect の処理を追っていきます。
// Connect implements driver.Connector interface.
// Connect returns a connection to the database.
func (c *connector) Connect(ctx context.Context) (driver.Conn, error) {
var err error
// ...省略(処理準備)...
/*
1. コネクション作成
*/
dctx := ctx
if mc.cfg.Timeout > 0 {
var cancel context.CancelFunc
dctx, cancel = context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
}
if c.cfg.DialFunc != nil {
mc.netConn, err = c.cfg.DialFunc(dctx, mc.cfg.Net, mc.cfg.Addr)
} else {
dialsLock.RLock()
dial, ok := dials[mc.cfg.Net]
dialsLock.RUnlock()
if ok {
mc.netConn, err = dial(dctx, mc.cfg.Addr)
} else {
nd := net.Dialer{}
// netパッケージのDialContext -> TCP/IPのソケット接続を確立
mc.netConn, err = nd.DialContext(dctx, mc.cfg.Net, mc.cfg.Addr)
}
}
if err != nil {
return nil, err
}
mc.rawConn = mc.netConn
/*
2. KeepAlive設定をする
*/
// MySQLドライバーでは常にTCP KeepAliveをONになるようにしている
if tc, ok := mc.netConn.(*net.TCPConn); ok {
if err := tc.SetKeepAlive(true); err != nil {
c.cfg.Logger.Print(err)
}
}
/*
3. startWatcherを呼び出す
*/
// contextを監視するgoroutineを呼びだす
// ↓ 詳しくはこちら ↓
// https://shogo82148.github.io/blog/2017/06/16/mysql-driver-and-context/
mc.startWatcher()
if err := mc.watchCancel(ctx); err != nil {
mc.cleanup()
return nil, err
}
defer mc.finish()
mc.buf = newBuffer()
/*
4. Initial Handshake Packetを受け取る
*/
// mc.readHandshakePacket
// プロトコルバージョンやCapabilityなどの確認を行っている
authData, serverCapabilities, serverExtCapabilities, plugin, err := mc.readHandshakePacket()
if err != nil {
mc.cleanup()
return nil, err
}
if plugin == "" {
plugin = defaultAuthPlugin
}
/*
5. 認証パケットの作成
*/
// Initial Handshake 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
}
}
// サーバー対応かつクライアントが希望している機能を読み込み
mc.initCapabilities(serverCapabilities, serverExtCapabilities, mc.cfg)
// HandshakeResponsePacketを作成して送信
if err = mc.writeHandshakeResponsePacket(authResp, plugin); err != nil {
mc.cleanup()
return nil, err
}
/*
6. 認証結果の受け取り
*/
// レスポンスの受け取りとその結果によって処理分岐
// ・OK - 正常終了
// ・AuthMoreData - 追加の認証のリクエスト
// ・AuthSwitchRequest - 別の認証に切り替えるリクエスト
if err = mc.handleAuthResult(authData, plugin); err != nil {
mc.cleanup()
return nil, err
}
// ...省略(接続設定の最終処理)...
return mc, nil
}
ここから、実際に Initial Handshake Packet による接続設定処理が行われているのがわかると思います。
興味がある方は、各種メソッドをさらに深掘ってみてください。
コマンドフェーズ
コマンドフェーズでは、クライアントから以下のサブプロトコルに属すコマンドパケットを送信されます。
ペイロードの最初のバイトでコマンドの種類を表します。
-
Text Protocol
- SQL を文字列で送り結果を受け取る
-
Utility Commands
- 接続管理やメタ情報取得など補助的操作
-
Prepared Statement
- プリペアドステートメント
-
Stored Programs
- マルチリザルトセット・マルチステートメント処理など
本記事では、Text Protocolを中心に深掘っていきます。
COM_QUERY
Text Protocol では COM_QUERY と呼ばれるコマンドパケットがクライアントから送られてきます。
基本的なペイロードの内容は以下となっています。
| 型 | 名前 | 説明 |
|---|---|---|
| int<1> | command | 0x03: COM_QUERY |
| string | query | 実行するSQLクエリのテキスト |
またレスポンスにもいくつか種類があります。
- ERR_Packet
- OK_Packet
- LOCAL INFILE Request
- ファイルを送信するように要求するリクエスト
- CSVなどのクライアントのファイルから指定のテーブルにインサートする場合などで使う
-
LOAD DATA LOCAL INFILE ‘ファイル名’ INTO TABLE ‘テーブル’;など
- Text ResultSet
- クエリの結果をまとめたレスポンス
- metadata
- Column Count
-
Column Definition
- カラム数を含んだパケットから開始
- カラム数分のcolumn definitionパケットが送られる
- row data
-
Text Resultset Row
- 実際の行の値
- 行数分のパケットが送られる
- 各パケットはカラム数を含む
-
Text Resultset Row
- metadata
- クエリの結果をまとめたレスポンス
database/sqlの処理
database/sql でクエリを発行する代表的メソッドである以下の実装を見ていきます。
-
Queryer.Query(query string, args []Value))(Rows, error)- database/sql- 検索結果を取得するSELECTなどで利用
-
Execer.Exec(query string, args []Value)(Result, error)- database/sql- 検索結果を取得しないINSERT, UPDATE, DELETEなどで利用
上記はそれぞれ MySQL ドライバーの以下を実行します。
(mc *mysqlConn) Query(query string, args []driver.Value)(driver.Rows, error)(mc *mysqlConn) Exec(query string, args []driver.Value)(driver.Result, error)
ではまずQueryの実装から追ってみましょう
func (mc *mysqlConn) Query(query string, args []driver.Value) (driver.Rows, error) {
return mc.query(query, args)
}
func (mc *mysqlConn) query(query string, args []driver.Value) (*textRows, error) {
/*
1. SQLの作成
*/
handleOk := mc.clearResult()
if mc.closed.Load() {
return nil, driver.ErrBadConn
}
if len(args) != 0 {
if !mc.cfg.InterpolateParams {
return nil, driver.ErrSkip
}
// パラメータ付きクエリのSQL文生成
// 失敗すればdatabase/sql側でプリペアドステートメントが実行される
prepared, err := mc.interpolateParams(query, args)
if err != nil {
return nil, err
}
query = prepared
}
/*
2. リクエストの送信
*/
err := mc.writeCommandPacketStr(comQuery, query)
if err != nil {
return nil, mc.markBadConn(err)
}
/*
3. レスポンスの受信
*/
var resLen int
// (mc *okHandler) readResultSetHeaderPacket
// ・column countを返す
// ・OK, ERR, LocalInFileの場合は 0
// ・それ以外はResultSetとみなし、最初のパケットがcolumn countを示すのでそれを返す
resLen, _, err = handleOk.readResultSetHeaderPacket()
if err != nil {
return nil, err
}
rows := new(textRows)
rows.mc = mc
// ResultSet以外がきた場合
// 後続パケットの掃き出しを行う
// 複数ステートメントなどで、ResultSetが後続に来る可能性を考慮
if resLen == 0 {
rows.rs.done = true
switch err := rows.NextResultSet(); err {
case nil, io.EOF:
return rows, nil
default:
return nil, err
}
}
// (mc *mysqlConn) readColumns
// ・column countの数だけColumn Definitionパケットを受信する
// ・tableName, columnName, fieldTypeなどをmysqlFieldに変換
// ・[]mysqlFieldを返却
rows.rs.columns, err = mc.readColumns(resLen, nil)
return rows, err
}
Query メソッドでは driver.Rows という構造体で返却していますが、 Rows.Next というメソッドで TextResultSetRow パケットを順々に取得して型変換していくようになっています。
次は Exec になります。
func (mc *mysqlConn) Exec(query string, args []driver.Value) (driver.Result, error) {
/*
1. SQLの作成
*/
if mc.closed.Load() {
return nil, driver.ErrBadConn
}
if len(args) != 0 {
if !mc.cfg.InterpolateParams {
return nil, driver.ErrSkip
}
prepared, err := mc.interpolateParams(query, args)
if err != nil {
return nil, err
}
query = prepared
}
err := mc.exec(query)
if err == nil {
copied := mc.result
return &copied, err
}
return nil, mc.markBadConn(err)
}
func (mc *mysqlConn) exec(query string) error {
handleOk := mc.clearResult()
/*
2. リクエストの送信
*/
if err := mc.writeCommandPacketStr(comQuery, query); err != nil {
return mc.markBadConn(err)
}
/*
3. レスポンスの受信
*/
resLen, _, err := handleOk.readResultSetHeaderPacket()
if err != nil {
return err
}
// ResultSetがきた場合
// ・後にくるカラム定義や行データのパケットをresLen分読み飛ばす
if resLen > 0 {
// columns
if err := mc.skipColumns(resLen); err != nil {
return err
}
if err := mc.skipRows(); err != nil {
return err
}
}
// ソケットにまだ残っているResultSetパケットがあれば全て捨てる汎用ループ
return handleOk.discardResults()
}
Query と Exec、それぞれの実装から分かるとおり、前半のリクエストの送信までの処理は同じです。
つまりこの関数自体は、どちらも受け付ける SQL はなんでもよくなっていて、レスポンスの処理で分かれる形になります。
終わりに
本記事での深掘りは以上となります。
他のコマンドフェーズのサブプロトコルや、パケット受信後の型変換など、まだまだ深掘りできる要素はたくさんありますので、ぜひご興味ありましたらご自身で調べてみてください。
少しでも MySQL プロトコルと、Go の MySQL クライアントの理解が進んでいたら嬉しいです。
明日は、 @kazun9_naka さんの記事になります。お楽しみに!!