🐬

GoでMySQLに接続するときに起きていること

に公開

はじめに: database/sql と driverの関係

Goアプリケーションからリレーショナルデータベースにアクセスする時はGORMのようなORMを使うことがほとんどだと思いますが、場合によっては database/sql パッケージを直接使うこともあると思います。例えば、こんなコードです。

import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql"
    "log"
)

func main() {
    db, err := sql.Open("mysql", "user:pass@tcp(localhost:3306)/dbname")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    row := db.QueryRow("SELECT name FROM users WHERE id = ?", 1)

    var name string
    if err := row.Scan(&name); err != nil {
        log.Fatal(err)
    }

    log.Println("User name:", name)
}

このコードでは、パッと見は "database/sql" しか使っていないようにも思えますが、実は 「ドライバ」 と呼ばれるものがMySQLサーバーに接続してデータ取得を行なってくれています。

ソースコードで言うと、

_ "github.com/go-sql-driver/mysql"

の部分でドライバをimportしていて、

sql.Open("mysql", "user:pass@tcp(localhost:3306)/dbname")

の第一引数 "mysql" の部分でMySQL用のドライバを指定しています。

ちょっと分かりにくいと思うので "database/sql" とドライバの関係を図にしてみました。

ソースコードだけを見ると "database/sql" だけを使っているように思えますが、実はその先にはドライバがいて、そこからデータベースサーバーに接続してくれています。

また "database/sql/driver" というインターフェースがあり、各種ドライバ( go-sql-driver/mysqlgithub.com/lib/pq など)はそれを実装していて、このドライバを使うことによりデータベースサーバーに接続できます。

概要: データベースに接続するまでの流れ

ここからはMySQLサーバーに的を絞って説明を進めます。
MySQLサーバーに接続するまでに起きている事と、それぞれの役割を図にしてみました。

この後の章でそれぞれについて詳しく説明をしていこうと思います。

  • ①Goアプリケーション
  • ②database/sql
  • ③ドライバとデータベース

詳細: ① Goアプリケーション

まずは「①Goアプリケーション」から説明していきます。

GoアプリケーションからMySQLサーバーに接続しデータを取得する際は、前述の通り database/sql を使ってデータを取得します。

それにはまず次のようにデータベースへの接続情報を設定します。実はこの時点ではまだDB接続は行なっていません。

db, _ := sql.Open("mysql", "user:pass@tcp(localhost:3306)/dbname")
defer db.Close()

そして次のようにSQLを発行します。この時点でDB接続が確立され、SQLが実行されます。

row := db.QueryRow("SELECT name FROM users WHERE id = ?", 1)

その後、結果セットを取り出します。

var name string
row.Scan(&name)

たったこれだけでデータを取得できるのですが、その裏側ではドライバとMySQLサーバーがやり取りをしていて、このおかげでデータが取得できています。

詳細: ② database/sql

続いて一つ下のレイヤーの「②database/sql」の説明になります。

database/sql は、Go言語標準ライブラリに含まれるデータベース操作用のパッケージです。このパッケージは直接データベースと通信するわけではなく、 database/sql/driver インターフェースを介して、実装されたドライバに処理を委譲します。

また、 database/sql はドライバとの接続を最適化するために コネクションプーリング を行っており、都度接続を開いたり閉じたりせず、既存の接続を再利用するよう設計されています。さらに、TCP接続が切れていた場合の 再接続処理 なども実装されています。

詳細: ③ ドライバとデータベース

最後に「③ドライバとデータベース」の説明になります。

ここではドライバとMySQLサーバーがどのようなやり取りをしているか説明していこうと思いますが、少し難しい話になってくるので、ここからはもう少し細かく丁寧に説明していこうと思います。

ドライバ目線で考えると、最終的な目的は「MySQLサーバーにSQLを渡して結果を受け取る」ことですが、まず一番最初に ハンドシェイク をする必要があります。ハンドシェイクが終われば、ようやくSQLの実行が可能になります。そしてこの時、MySQLドライバとMySQLサーバーがやり取りを行う際は 「MySQL クライアント・サーバープロトコル」 というプロトコルで通信を行います。

話が難しくなってきましたが、次の順番でゆっくりと説明していきます。

  1. MySQL クライアント・サーバープロトコルの概要
  2. ハンドシェイクの流れ
  3. プリペアドステートメントの実行

③-1. MySQL クライアント・サーバープロトコルの概要

MySQLドライバがサーバーと通信する際に使っているのが「MySQL クライアント・サーバープロトコル」です。このプロトコルは、SQLの文字列を送るのではなく、バイナリ形式のパケットをやり取りすることで、高速かつ効率的に通信を行っています。

パケットは 「ヘッダー + ペイロード」 で構成されます。

  • ヘッダー: 基本的に4バイト。最初の3バイトがペイロードの長さ、残りの1バイトがシーケンス番号。
  • ペイロード: コマンドやデータが詰まっています。例えば、クエリを送る場合には COM_QUERY というコマンドを使ってSQL文を送信します。

以下は、プロトコルの構造をざっくりイメージした図です。

ちょっと話が難しくなってきましたが、要するに、こうした決められた仕様にのっとって ドライバMySQLサーバー はやり取りを行なっている、とイメージできれば良いと思います。

③-2. ハンドシェイクの流れ

MySQLに接続する際、クライアント(アプリ)とサーバーの間では、最初にハンドシェイクが行われます。これは接続前の“自己紹介”のようなもので、使用するプロトコルや認証方式をすり合わせた上で、認証処理を行います。

MySQL 8.0以降では、デフォルトで caching_sha2_password 認証方式が使われます。この方式では、まず簡略なキャッシュ認証が試され、必要に応じて公開鍵を使った本格的な認証へと切り替わります。

次の図はキャッシュがヒットした場合のシーケンス図で、少ないやり取りで認証が済んでいることが分かると思います。

もしもキャッシュがヒットしなかった場合、次の図のようにより多くやり取りが必要となります。

ハンドシェイクが無事完了すると、ようやくクエリが実行可能となります。

③-3. プリペアドステートメントの実行

さて、アプリ側で Prepare, Execute, Next などを呼び出したとき、裏ではどんなプロトコルでサーバーと会話しているのでしょうか。

たとえば、以下のようなコードを実行したとします。

stmt, _ := db.Prepare("SELECT name FROM users WHERE id = ?")
rows, _ := stmt.Query(1)
for rows.Next() {
    var name string
    rows.Scan(&name)
}

この裏では、以下のようなやり取りが発生します。

まず db.Prepare("SELECT name FROM users WHERE id = ?") が呼び出されると、最終的にドライバはMySQLサーバーにこのSQLを渡します。

そして stmt.Query(1) を実行すると、先ほど渡しておいたSQLの引数として 1 をMySQLサーバーに渡します。それによりMySQLサーバー側でSQLが実行され、その結果を返してくれます。

その後 rows.Next() を呼び出すたびに、ドライバは1行分の結果セットを読み出し、1行分の情報をアプリケーション側に返す流れとなります。

結果セットが大量の行数だった場合についての補足

rows.Next() を呼び出すたびに、ドライバは net.Conn.Read() を呼び出してMySQLサーバーから次の1行分のデータを受け取ります。

つまりMySQLサーバー側から結果が大量に送信されてきたとしても、結果セットを最初にすべてメモリにためるわけではないということです。

MySQLサーバーは、結果が多くても一気に送らず、ドライバ側が読み進めるのを待つようになっています。こうしたTCPレベルでフロー制御があり、ドライバ側の受信が追いつかないと、MySQLサーバー側はそれ以上送ってきません。

まとめ

普段、ORMや sql.DB を使っているだけでは意識しない部分ですが、実際には

  • database/sql はインターフェースだけ提供している
  • 実際の通信・プロトコル処理はMySQLドライバが担当
  • Next() のたびに1行ずつ net.Conn から読み込まれている
  • サーバー側はTCPの仕組みを使って流量を制御している

といった動作が裏で行われています。

以上です。

Discussion