go-mssqldbによるパフォーマンスチューニング

本記事では、go-mssqldbドライバーをSQL Serverで使用するGoアプリケーションの性能最適化に関する指針を提供します。

最も影響の大きい変更から始めましょう

ほとんどのアプリケーションは初日からドライバーごとのチューニングを必要としません。 パケットサイズを変更したり、準備済みの声明をあちこちに追加したり、一括オプションを調整したりする前に、以下のステップから始めてください。

  1. サーバーの制限に合った制限付き接続プールサイズを設定しましょう。
  2. コンテキストのタイムアウトを追加して、ブロックされた SQL 呼び出しが接続を占有し、高負荷時にアプリがフリーズしたように見えるのを防ぎましょう。
  3. SQL Serverでの遅いクエリ、欠落したインデックス、不要な往復を修正しましょう。
  4. 変更の前後に作業量をベンチマークしましょう。

多くのサービスでは、接続プールの設定やクエリ形状がパケットサイズや文の準備よりも重要です。

接続プールの調整

database/sql接続プールは最も影響力のあるパフォーマンスレバーです。 プロビジョニング不足のプールはゴルーチンが接続待ちをブロックし、過剰プロビジョニングのプールはサーバーリソースを浪費します。

db.SetMaxOpenConns(25)     // Match your workload concurrency
db.SetMaxIdleConns(10)     // Keep warm connections ready
db.SetConnMaxLifetime(5 * time.Minute)  // Recycle connections periodically
db.SetConnMaxIdleTime(1 * time.Minute)  // Close stale idle connections

プールの競合を検出するために db.Stats().WaitCountdb.Stats().WaitDuration を監視しましょう。 詳細については、 コネクションプーリングをご覧ください。

パケットサイズを増やす

デフォルトのTDSパケットサイズは4,096バイトです。 大規模な結果セットや大量データを転送するワークロードでは、パケットサイズを増やすことでネットワークの往復回数が減ります。

sqlserver://<user>:<password>@<server>?database=AdventureWorks2025&packet+size=16384

有効範囲:512から32,767。 8,192または16,384の値は高スループットのシナリオで一般的です。

ベンチマークデータでネットワーク転送が負荷を支配していることが示されない限り、デフォルトのパケットサイズを維持してください。 小規模なクエリと行を持つOLTPスタイルのトラフィックでは、大きなパケットは意味のある利点を得ずに複雑さを増すことが多いです。

用意された声明を活用してください

準備済み文は、繰り返しのクエリ解析やサーバー上でのコンパイル計画を回避できます。 同じクエリを異なるパラメータで何度も実行する場合に db.PrepareContext を使います:

stmt, err := db.PrepareContext(ctx,
    "SELECT TOP (1) FirstName + ' ' + LastName AS Name FROM Sales.vSalesPerson WHERE CountryRegionName = @p1")
if err != nil {
    log.Fatal(err)
}
defer stmt.Close()

for _, loc := range locations {
    var name string
    stmt.QueryRowContext(ctx, loc).Scan(&name)
}

Tip

サーバー側の準備済みステートメントハンドルを消費しないために、 defer stmt.Close() を使って文を閉じましょう。

すべての声明をデフォルトで準備しないでください。 同じステートメントがホットパスで繰り返し実行される場合に、最も効果を発揮します。 一度きりのクエリの場合、 QueryContextExecContext の方が通常はよりシンプルで速いです。

大きな挿入物には一括コピーを使いましょう

個々の INSERT 文は大量のデータ負荷に対して遅いです。 バルクコピーはクエリプロセッサをバイパスし、データを直接サーバーにストリーミングします。

stmt, err := txn.Prepare(mssql.CopyIn("MyTable", mssql.BulkOptions{
    Tablock:      true,
    RowsPerBatch: 5000,
}, "Col1", "Col2"))

詳細は 「バルクオペレーション」をご覧ください。

適切な場合はヴァルチャールを使いましょう

デフォルトでは、コードはstringとしてnvarcharパラメータを送信します(Unicode)。 もしカラムが varcharを使うと、サーバーは暗黙の変換を行い、インデックスをスキップすることがあります。 mssql.VarCharを使ってパラメータvarchar送信してください。

db.QueryContext(ctx, "SELECT * FROM Production.Product WHERE ProductNumber = @p1",
    mssql.VarChar("FR-R92B-58"))

コンテキストタイムアウトを活用してください

個々のクエリにコンテキストの締め切りを設定し、ブロックされたSQL呼び出しが接続をピン留めしたり呼び出し元を停止させたりするのを防ぎます。

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

rows, err := db.QueryContext(ctx, "SELECT * FROM LargeTable")

リソースは速やかに閉鎖してください

オープン *sql.Rows*sql.Tx*sql.Conn オブジェクトがプールからの接続をピンで固定します。 できるだけ早く閉めてください。

rows, err := db.QueryContext(ctx, query)
if err != nil {
    return err
}
defer rows.Close()

for rows.Next() {
    // process
}
return rows.Err()

往復の回数を減らす

  • 可能であれば、クエリをまとめて1回の呼び出しにする: "SELECT ...; SELECT ...;"
  • 個別のSELECT SCOPE_IDENTITY()呼び出しではなく、OUTPUTクローズを使用してください。
  • テーブル値パラメータを使って、個々の挿入をループする代わりに、1回の呼び出しで複数行を送信します。

パフォーマンス チェックリスト

Area レコメンデーション
プール サーバーの制限とワークロードの同時進行に基づいて MaxOpenConns を設定してください。
プール MaxIdleConns MaxOpenConnsの半分以上に設定してください。
Network packet sizeは大容量の転送や大量の読み込みをベンチマークした後に限り変更してください。
Queries 繰り返し使われる文には、準備された文だけを使いましょう。
Queries 暗黙の変換を避けるためにmssql.VarChar列にはvarcharを使いましょう。
大きな荷重 バッチ挿入には一括コピー(mssql.CopyIn)を使いましょう。
Resources RowsTxConnオブジェクトを速やかに閉じてください。
タイムアウト すべてのクエリや文にコンテキストの締め切りを設定しましょう。
読み込みが多い 読み取りレプリカには ApplicationIntent=ReadOnly を使用します。
Benchmarks testing.Bを使って最適化前後を測定しましょう。
Monitoring クエリ ストアを有効にし、SSMSレポートやQuery Performance Insightを活用してください。
Monitoring db.Stats()をPrometheusまたはOpenTelemetryにエクスポートしてください。

ベンチマークデータベースの運用

Goの testing.B を使ってデータベース操作のパフォーマンスを測定し、最適化変更を検証します:

func BenchmarkInsertSingle(b *testing.B) {
    db := setupDB(b)
    ctx := context.Background()

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _, err := db.ExecContext(ctx,
            "INSERT INTO BenchTable (Name) VALUES (@p1)",
            sql.Named("p1", fmt.Sprintf("bench-%d", i)))
        if err != nil {
            b.Fatal(err)
        }
    }
}

func BenchmarkInsertBulk(b *testing.B) {
    db := setupDB(b)
    ctx := context.Background()

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        txn, err := db.BeginTx(ctx, nil)
        if err != nil {
            b.Fatal(err)
        }
        stmt, err := txn.Prepare(mssql.CopyIn("BenchTable",
            mssql.BulkOptions{}, "Name"))
        if err != nil {
            b.Fatal(err)
        }
        for j := 0; j < 1000; j++ {
            if _, err := stmt.Exec(fmt.Sprintf("bench-%d-%d", i, j)); err != nil {
                b.Fatal(err)
            }
        }
        if _, err := stmt.Exec(); err != nil {
            b.Fatal(err)
        }
        stmt.Close()
        if err := txn.Commit(); err != nil {
            b.Fatal(err)
        }
    }
}

ベンチマークを以下で実行してください:

go test -bench=BenchmarkInsert -benchmem -count=5

Tip

統計的に意味のある結果を得るには -count=5 以上の数値を使いましょう。 Benchstatを使って、変更前後のベンチマーク結果を比較してください。

読み取り専用ルーティングを使用

SQL Server環境に読み取れるセカンダリを持つ可用性グループがある場合は、接続文字列でApplicationIntent=ReadOnlyを設定して読み取り専用クエリをセカンダリプレプリカにルーティングします。

sqlserver://<user>:<password>@mylistener?database=AdventureWorks2025&ApplicationIntent=ReadOnly

読み書きワークロードごとに別々の *sql.DB インスタンスを作成します:

writeDB, err := sql.Open("sqlserver",
    "sqlserver://<user>:<password>@mylistener?database=AdventureWorks2025")
if err != nil {
    log.Fatal(err)
}

readDB, err := sql.Open("sqlserver",
    "sqlserver://<user>:<password>@mylistener?database=AdventureWorks2025&ApplicationIntent=ReadOnly")
if err != nil {
    log.Fatal(err)
}

// Use readDB for reports, dashboards, and analytics.
// Use writeDB for inserts, updates, and deletes.

クエリプラン解析

SET SHOWPLAN_XMLを使ってクエリを実行しずに実行計画を取得します。 この方法は、テーブルスキャン、欠落インデックス、高コストな操作を特定するのに役立ちます:

func getQueryPlan(ctx context.Context, db *sql.DB, query string) (string, error) {
    // Use a dedicated connection so SHOWPLAN mode doesn't affect other queries.
    conn, err := db.Conn(ctx)
    if err != nil {
        return "", err
    }
    defer conn.Close()

    // Enable SHOWPLAN_XML mode.
    _, err = conn.ExecContext(ctx, "SET SHOWPLAN_XML ON")
    if err != nil {
        return "", err
    }

    var planXML string
    err = conn.QueryRowContext(ctx, query).Scan(&planXML)
    if err != nil {
        return "", err
    }

    // Disable SHOWPLAN_XML mode.
    _, _ = conn.ExecContext(ctx, "SET SHOWPLAN_XML OFF")

    return planXML, nil
}

Warning

SET SHOWPLAN_XML ON 接続全体に影響を及ぼします。 SHOWPLANモードを専用接続に隔離するには必ず db.Conn(ctx) を使いましょう。

遅いクエリを見つけるにはクエリ ストアを使います

Goのベンチマークやクライアントサイドのタイミングは、アプリケーションの視点からクエリにかかる時間を示しますが、その時間はネットワークの遅延、サーバー実行時間、クライアント処理を組み合わせたものです。 クエリ ストアはサーバー上の実行計画やランタイム統計をキャプチャし、SQL Serverがどのようにクエリを実行し、どのくらいの頻度で実行され、パフォーマンスが時間とともにどのように変化したかを正確に把握できます。

クエリ ストアは、パラメータスニッフィング、プラン回帰、サーバーリソースを最も多く消費するクエリを特定するのに特に有用です。 もしまだ有効になっていなければ、データベースで有効にしてください:

ALTER DATABASE [AdventureWorks2025] SET QUERY_STORE = ON;

有効化後は、いくつかの方法でパフォーマンスデータを確認できます:

  • SQL Server Management Studio(SSMS):オブジェクト エクスプローラーでデータベースを展開し、クエリ ストアフォルダを開き、「Top Resource Consuming Queries」「Repressed Queries」「Overall Resource Consumption」などの組み込みレポートを使用します。
  • Azureポータル:Azure SQL Databaseでは、Query Performance Insightのブレードを開くと、ツールをインストールしずにリソースを大量に消費するクエリが表示されます。
  • Transact-SQL(T-SQL):プログラムアクセスが必要な場合は、Goアプリケーションから sys.query_store_runtime_statssys.query_store_plan カタログビューを直接照会してください。

Tip

クエリ ストアはサーバー再起動後もデータを永続化するため、日単位や週単位のパフォーマンス傾向を分析できます。 Regressed Queriesレポートを使って、スキーマやコードの変更後に遅くなったクエリを素早く見つけましょう。

パフォーマンスダッシュボードをご利用ください

パフォーマンスダッシュボードは、SQL Serverの健康状態をリアルタイムで把握できる組み込みのSSMSレポートです。 SSMS オブジェクト エクスプローラーでサーバーインスタンスを右クリックし、「レポート>標準レポート>パフォーマンスダッシュボード」を選択します。

ダッシュボードには、次の情報が表示されます。

  • 現在の待ち時間とボトルネック。
  • 最近の高額な問い合わせ。
  • CPU、I/O、メモリ使用の傾向。
  • アクティブなユーザーリクエストとブロックされたセッション。

パフォーマンスダッシュボードは、開発や負荷テスト時に診断クエリを書かずに問題を迅速に発見するのに役立ちます。

Goからサーバー側の指標を監視できます

SQL ServerのパフォーマンスデータをGoアプリケーションからPrometheusやOpenTelemetryのような監視システムに公開する必要がある場合は、動的管理ビュー(DMV)を直接クエリしてください:

最もコストのかかるクエリ

平均経過時間でランク付けされた上位10のクエリを取得してください:

rows, err := db.QueryContext(ctx, `
    SELECT TOP 10
        qs.total_elapsed_time / qs.execution_count AS avg_elapsed_us,
        qs.execution_count,
        qs.total_logical_reads / qs.execution_count AS avg_reads,
        SUBSTRING(st.text, (qs.statement_start_offset/2)+1,
            ((CASE qs.statement_end_offset
                WHEN -1 THEN DATALENGTH(st.text)
                ELSE qs.statement_end_offset
            END - qs.statement_start_offset)/2)+1) AS query_text
    FROM sys.dm_exec_query_stats AS qs
    CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
    ORDER BY avg_elapsed_us DESC`)

現在アクティブなリクエスト

自分のセッションを除く、サーバー上で現在実行中のすべてのリクエストをリストアップします:

rows, err := db.QueryContext(ctx, `
    SELECT
        r.session_id,
        r.status,
        r.wait_type,
        r.cpu_time,
        r.logical_reads,
        t.text AS query_text
    FROM sys.dm_exec_requests AS r
    CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
    WHERE r.session_id != @@SPID`)

大きな結果に対するメモリ効率のパターン

行を蓄積せずにストリーミングする

結果セット全体をスライスに読み込むのではなく、1行ずつ処理します:

rows, err := db.QueryContext(ctx, "SELECT Id, Data FROM BigTable")
if err != nil {
    return err
}
defer rows.Close()

for rows.Next() {
    var id int
    var data string
    if err := rows.Scan(&id, &data); err != nil {
        return err
    }
    // Process immediately, don't append to a slice.
    process(id, data)
}
return rows.Err()

キーセットページネーションを用いたバッチ処理

大きなテーブルスキャンを管理しやすいチャンクに分割することで、メモリ使用を制限し、長時間接続を保持しないようにしましょう:

func processBatched(ctx context.Context, db *sql.DB, batchSize int) error {
    var lastID int
    for {
        rows, err := db.QueryContext(ctx, `
            SELECT TOP(@batch) Id, Data FROM BigTable
            WHERE Id > @lastID ORDER BY Id`,
            sql.Named("batch", batchSize),
            sql.Named("lastID", lastID))
        if err != nil {
            return err
        }

        var count int
        for rows.Next() {
            var id int
            var data string
            if err := rows.Scan(&id, &data); err != nil {
                return err
            }
            process(id, data)
            lastID = id
            count++
        }
        if err := rows.Err(); err != nil {
            return err
        }
        rows.Close()

        if count < batchSize {
            break // No more rows.
        }
    }
    return nil
}