KEIS BLOGは株式会社ケイズ・ソフトウェアが運営しています。

KEIS BLOG

MySQLでGoroutineを使って並列クエリを投げたくなるパターンとその回避方法

Go言語の強力な機能の一つに、軽量なスレッドがあります。Goroutineを活用して、MySQLに対して複数のクエリを並列に実行することで、パフォーマンス向上を図りたいと考えたりもするかもしれません。

でも、並列クエリには注意が必要です。

ちゃなことで。この記事では、Goroutineを使って並列クエリを投げたくなるようなパターンと、その回避方法について詳しく解説します。特に、SQLのUNIONなどの機能を活用するメリットについても触れます。

目次


並列クエリに魅力を感じるパターン

Goroutineを使ってMySQLに対して並列にクエリを実行することは、以下のようなシナリオで魅力的に感じられるでしょう:

  • 複数の独立したデータ取得: 複数のテーブルや異なるクエリで独立したデータを同時に取得したい場合。
  • 高スループットが要求されるAPI: 多数のリクエストを高速に処理する必要があるWeb APIなど。
  • 非同期処理: クエリの結果を待たずに他の処理を進めたい場合。

これらのケースでは、並列処理によって全体の処理時間を短縮し、アプリケーションの応答性を向上させることが期待されます。しかし、実際には並列クエリの実行にはいくつかの課題が存在します。

並列クエリの問題点

1. データベースの負荷増大

並列にクエリを実行すると、データベースサーバーへの同時接続数が増加します。これにより、CPUやメモリ、ディスクI/Oなどのリソースが競合し、逆にパフォーマンスが低下する可能性があります。「並列にすればするほどいいんじゃないの?」という考え、意外と一般的な認識のようです。そんなことはありません。適切な並列数が存在することは、公式のblogでもはっきりと書かれています。

https://dev.mysql.com/blog-archive/mysql-connection-handling-and-scaling

なんで「かえってパフォーマンスが下がっちゃうのか」はちょっと長くなるので、ここでは割愛しますが。

2. クエリの依存関係とトランザクション管理

複数のクエリが互いに依存している場合、並列実行は問題を引き起こすことがあります。また、トランザクションの一貫性を保つための管理が複雑になって、かえって実装を難しくするだけになってしまいます。

3. オーバーヘッドの増加

Goroutine自体は軽量ですが、並列クエリを管理するためのオーバーヘッドが発生します。特に、クエリ数が増えると、アプリ側もコンテキストスイッチングや同期処理のコストが無視できなくなります。

4. エラー処理の複雑化

並列実行では、各クエリのエラー処理が独立して行われるため、全体のエラーハンドリングが複雑になります。どのクエリが失敗したかを追跡し、適切な対応を取る必要があります。

回避方法:SQLのUNIONを活用する

並列クエリの問題点を回避するためにはいろいろ方法はありますが、ここでは一つの方法として、SQLのUNIONUNION ALLを活用する手法を紹介します。 これにより、複数のクエリを一つのクエリに統合し、データベースへの負荷を軽減しつつ、効率的なデータ取得が可能となります。

UNIONUNION ALL の違い

UNION は複数のSELECT文の結果を結合し、重複する行を排除します。一方、UNION ALL は重複を排除せずにすべての結果を結合します。パフォーマンスの観点からは、重複排除が不要な場合はUNION ALLを使用する方が高速です。

例:複数のクエリをUNIONで統合

以下に、Goroutineを使わずにUNIONを用いて複数のクエリを統合する例を示します。

SELECT column1, column2 FROM table1 WHERE condition1
UNION ALL
SELECT column1, column2 FROM table2 WHERE condition2
UNION ALL
SELECT column1, column2 FROM table3 WHERE condition3;

このように、UNION ALLを使うことで、複数のSELECT文の結果を一度に取得できます。これにより、データベースへの接続回数が減少し、リソースの競合を緩和することが可能です。

メリットとデメリット

メリット:

  • データベースへの接続回数が減少するため、リソースの使用効率が向上します。
  • クエリの管理が一元化され、エラーハンドリングが容易になります。
  • オーバーヘッドが削減され、全体のパフォーマンスが向上します。

デメリット:

  • 複雑なクエリになると、デバッグや保守が難しくなる場合があります。
  • 各SELECT文が同じ形式の結果を返す必要があるため、柔軟性が制限されます。

その他の回避策

1. コネクションプールの活用

Goroutineを使ってクエリを並列に実行する際は、コネクションプールを活用することで、データベースへの接続数を制限し、リソースの競合を防ぐことができます。Goの標準ライブラリでは、database/sqlパッケージがコネクションプールを提供しています。

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

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

    // コネクションプールの設定
    db.SetMaxOpenConns(10) // 最大接続数
    db.SetMaxIdleConns(5)  // 最大アイドル接続数

    queries := []string{
        "SELECT * FROM table1",
        "SELECT * FROM table2",
        "SELECT * FROM table3",
    }

    var wg sync.WaitGroup
    for _, query := range queries {
        wg.Add(1)
        go func(q string) {
            defer wg.Done()
            rows, err := db.Query(q)
            if err != nil {
                log.Println(err)
                return
            }
            defer rows.Close()
            // データ処理
        }(query)
    }
    wg.Wait()
}

コネクションプールを適切に設定することで、並列クエリ数を制御し、データベースへの負荷を軽減できます。

2. クエリの最適化

クエリ自体を最適化することで、並列実行の必要性を減らすことができます。例えば、必要なデータのみを取得する、インデックスを適切に設定する、複雑なジョインを避けるなどの手法があります。直列で処理速度が間に合ってる、要件的にも問題無いなら、並列化は百害あって一利なし、です。

3. キャッシュの活用

クエリキャッシュやアプリケーションレベルのキャッシュを利用することで、同じクエリの実行回数を減らし、データベースへの負荷を軽減できます。これにより、並列クエリの必要性を低減できます。

まとめ

MySQLでGoroutineを使って並列にクエリを実行することは、一見パフォーマンス向上につながるように思えますが、実際にはいくつかの課題が存在します。データベースへの負荷増大やオーバーヘッドの増加、エラーハンドリングの複雑化など、並列クエリの実行には慎重な設計と管理が求められます。

そのため、可能な限りSQLのUNIONUNION ALLを活用し、クエリを統合することで、データベースへの接続回数を減らし、リソースの競合を緩和する戦略をとった方がいいことが多いです。

また、コネクションプールの活用やクエリの最適化、キャッシュの利用など、他の回避策も併せて検討することで、より効率的なデータベース運用が可能になります。

最適なアプローチを選択するためには、システムの特性やリソースの状況を十分に理解し、適切なバランスを取ることが重要です。バチッとこの辺がハマると、MySQL のパフォーマンスを最大限に引き出す事ができますし、ひいては安定したシステム運用にもつながります。 「なんでもかんでもGoroutine使わない」ということで・・・。