1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GoでMySQLプロトコルを学んでみる

1
Last updated at Posted at 2025-12-08

本記事は サムザップ 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 にはコネクションプールの機構があるため、コネクションを閉じずに別のコマンドを受け付けることで、効率的にコネクションを利用するようにしています。

  1. クライアントは MySQL サーバーに接続してコネクションを確立する(= 接続フェーズ
  2. クライアントはコネクションを介してコマンドを 1 つ送信する(= コマンドフェーズ
  3. クライアントはサーバーから返されたレスポンスを読み取る
  4. 読み取り完了後、コネクションをアイドル状態(Sleep)にする
  5. アイドル状態のコネクションは別のコマンドを受け付ける → 2,3 を繰り返す
    • database/sql はコネクションプールの機構があるため可能
  6. 必要に応じてコネクションをクローズ

接続フェーズ

接続フェーズでは主に以下を行います。

  • Capability Negotiation
    • クライアント/サーバーの互いがサポートしている機能やオプションを互いに伝え合い、相互に利用可能な範囲で接続を確立する
  • 必要なら SSL 通信チャネルの設定
  • クライアント認証

シーケンス

SSL ハンドシェイクがある場合のシーケンスが以下のような形です。
image.png

はじめにクライアントがサーバーに接続すると、サーバーはクライアントに Protocol::Handshake パケットを送信します。
その後必要であれば SSL 接続を確立し、最後にクライアントは Protocol::HandshakeResponse を送信します。

Protocol::Handshake パケットには、MySQL サーバーのバージョンやサーバーがサポートしている機能を表す Capabilities Flags などが含まれます。そうすることで、クライアントは相互利用可能な機能を判断することができ、接続を確立することができます。

database/sql の処理

DB 設定の初めに行われる sql.Open で通信が行われていると勘違いされがちですが、実際は以下のような処理の流れになっています。

  1. Open(driverName, dataSourceName string) - database/sql
  2. (db *DB) connectionOpener(ctx context.Context) - database/sql
    • 以下を実行する goroutine を作成
      1. (db *DB) openNewConnection(ctx context.Context) - database/sql
      2. (c *connector) Connect(ctx context.Context) - go-sql-driver/mysql

作成された goroutine は db.openerCh から値を受信すると最終的に mysql.Connect が実行されます。そこで実際の接続処理が行われ、これは各種 Query や Exec が実行されたタイミングで処理されます。

ではその mysql.Connect の処理を追っていきます。

go-sql-driver/mysql - connector.go
// 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
          • 実際の行の値
          • 行数分のパケットが送られる
          • 各パケットはカラム数を含む

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の実装から追ってみましょう

go-sql-driver/mysql - connection.go
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 になります。

go-sql-driver/mysql - connection.go
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 さんの記事になります。お楽しみに!!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?