東京ガス内製開発チーム Tech Blog

東京ガス内製開発チームのTech Blogです!

TiDBで外部キー制約を諦めずに性能を改善した話

みなさん、こんにちは!ソフトウェアエンジニアリングチームの夏です! 10月に入社して5ヶ月が経ちました!私は入社してからバックエンドのマイクロサービス化に取り組んでいます!

マイクロサービス化の取り組みの詳細については、以下の記事や杉山がアーキテクチャカンファレンス2024に登壇したときの資料がありますので、ご興味のある方はぜひチェックしてみてください!

tech-blog.tokyo-gas.co.jp

speakerdeck.com

直近リリースしたマイクロサービスでは、データベースとして TiDB を採用しています! TiDB は、MySQLとの高い互換性を持つデータベースですが、100%の互換性があるわけではないため、導入にあたりいくつか検討が必要な事項があります。

今回は、私たちがリリースしたマイクロサービスの負荷試験で直面した「外部キー制約による性能劣化」について紹介したいと思います!

TiDB の採用

TiDBとは、MySQL互換の分散データベースです。TiDBの分散型アーキテクチャによりスケーラビリティ・高可用性を提供しているのが特徴です。 スケーリングに関しては、ReadだけではなくWriteのスケールアウトも可能です。また、TiDBはMySQLとの高い互換性を持つため、MySQLのクライアントやORMが使えるという利点もあります。

今回開発したマイクロサービスは、現時点で、年間1000万以上のレコードが増える見込みでした。また、このマイクロサービスを利用するプロダクトが増えた場合、レコードの増加はさらに加速します。 これらのデータを維持しながらパフォーマンスを維持するための運用コストを最小限にするためにTiDBを採用しました。

負荷テストで直面した課題

今回リリースしたマイクロサービスは、myTOKYOGASからのリクエストを受け付けます。myTOKYOGASは大変ありがたいことに、多くの方にご利用いただいております。 また、myTOKYOGAS は特定の時間にアクセスがスパイクするため、その負荷に耐えられる必要がありました。そこで、リリースに向けて、入念な負荷テストを実施しました。

負荷テストでは段階的に負荷を上げていったのですが、ある一定の負荷を超えたタイミングで、データを登録するAPIタイムアウトエラーが発生するようになりました。

原因の分析

負荷テストの実施前より、「外部キーチェックを有効にした場合に性能が劣化するケースが存在する」という話はチームとして認識していたため、そこにあたりをつけました。

TiDBは v8.1 時点で、外部キー機能を experimental な機能としています。 TiDBのドキュメントにおける外部キー制約のページには、以下の注意事項が記載されています。

TiDB は現在LOCK IN SHARE MODEサポートしていないため、子テーブルが大量の同時書き込みを受け入れ、参照される外部キー値のほとんどが同じである場合、深刻なロック競合が発生する可能性があります。大量の子テーブルデータを書き込む場合はforeign_key_checks無効にすることをお勧めします。 https://docs.pingcap.com/ja/tidb/stable/foreign-key#locking

今回のテスト対象のマイクロサービスが操作するテーブルの構造を簡略化した図を以下に示します。データを登録する API が実行されると、Transaction Table にレコードが追加されます。 また、Transaction Table の item_id は、Master Table への外部キーとなっています。

加えて、今回開発したマイクロサービスが提供するAPIユースケースを考慮すると、同じ時期に挿入される Transaction Table のレコードの多くが、同じ item_id を持つという特徴があり、負荷テストもそれに沿ったリクエストで負荷をかけていました。つまり、高負荷の状況下で作成されるレコードの多くが、同じ外部キー値を参照していたことになります。これはまさに、TiDBの外部キー制約に関する注意事項に記載のある「子テーブルが大量の同時書き込みを受け入れ、参照される外部キー値がほとんど同じである場合」に該当していました。

APIが追加するテーブルの構造の簡略図

問題発生時の状況を図にまとめると以下のようになります。 ほとんどのAPIitem_id=1 のレコードを追加しており、その度に、Master Table に排他ロックがかかります。 排他ロックがかかっている時は、外部キーチェックができないため、排他ロックの解除を待機します。 高負荷な状況で、排他ロックによる待機が多発したことによって処理が詰まった結果、タイムアウトエラーとなったと考えられます。

性能劣化が起きたときの状況

そこで、外部キーチェックを常に無効にした状態で再度負荷テストを実施すると、高い負荷をかけてもデータを登録するAPIでエラーが発生しなくなりました。

この結果から、高い負荷をかけた時に期待したパフォーマンスが得られなかったのは、外部キー制約が原因であることがわかりました。

対策

外部キーチェックを無効にすることで、パフォーマンスが改善することが確認できました。しかし、外部キーはデータベースの参照整合性を維持する上で重要です。 バグや運用中のミスによって整合性のないデータを混入させないためにも、外部キー制約を入れるべきという話になりました。 そこで、当チームでは外部キー制約を極力維持しながら性能を改善する方法を模索しました。

MySQLおよびTiDBにおける外部キーチェックは、システム変数 foreign_key_checks によって動的に制御されます。このシステム変数は、グローバルスコープおよびセッションスコープの両方をサポートします。 そこで、外部キー制約によって性能の劣化が発生した「レコードの作成処理」の間だけ、外部キーチェックを無効にする実装を行いました。

システム変数の設定は SET [GLOBAL|SESSION] の Statement により行うことができます。 この Statement によりトランザクション内で foreign_key_checks = 0 とすることで、外部キーチェックを無効化しました。

Golang の ORM である gorm を利用して実装すると、以下のようになります。 SET SESSION Statement を実行するために、Execを使って Raw SQL を実行しています。 また、defer によりトランザクションの最後に外部キーチェックを再度有効にしています。 これにより、今回性能劣化が発生したレコード作成処理以外の箇所での外部キー制約による参照整合性を担保することができました。

   db.Transaction(func(tx *gorm.DB) (err error) {
        // foreign_key_checks を無効化
        if fkcOffErr := tx.WithContext(ctx).Exec("SET SESSION foreign_key_checks = ?", 0).Error; err != nil {
            return fkcOffErr
        }

        // 関数終了時に foreign_key_checks を有効化
        defer func() {
            if fkcOnErr := tx.WithContext(ctx).Exec("SET SESSION foreign_key_checks = ?", 1).Error; fkcOnErr != nil {
                err = fkcOnErr
            }
        }()

        // 性能劣化の原因と思われるレコード作成処理
        createModel := &Model{
            UserID: userID,
            ItemID: itemID,
        }
        if createErr := tx.WithContext(ctx).Create(&createModel); createErr != nil {
            return createErr
        }
        return nil
    })

結果

SET SESSIONforeign_key_checks の有効/無効を切り替えることで、外部キー制約を維持しながらパフォーマンスの問題をクリアすることができました!

負荷試験 結果サマリー(アプリケーションのレスポンス)

TiDBの導入にあたって

TiDB はMySQLとの高い互換性を持つNewSQLですが、一部未サポートの機能や experimental な機能が存在します。 負荷テストによって期待する性能が出ることを確認するのはもちろん大事ですが、未サポートや experimental な機能などは目を通した方が良いでしょう。

TiDBのサポート機能

MySQL互換性

また、外部キー制約の件以外にも、TiDB特有の振る舞いがいくつかあったため、別の記事で紹介できれば思います!

まとめ

外部キー制約チェックの有効/無効をセッションごとに切り替えることで、排他ロック多発による性能劣化を抑えることができました!

この対策により、無事マイクロサービスのリリースを迎えることができました。 入社してすぐリリースを経験し、提供できる価値を体感することで、私自身のモチベーションアップにつながりました!

今後も、提供できる価値という視点を念頭に置きながら、プロダクトの開発に取り組みたいと思います!

おまけ

外部キー制約の有効/無効の切り替えが効果があることをローカル環境で検証しました。 検証は、tiup playground でローカルに立ち上げたTiDBクラスターおよびローカルで起動したAPIサーバーで行いました。

以下の2つの設定でAPIリクエストを大量に流したときのレコード作成処理に要した時間を比較しました。

  1. foreign_key_checks の有効/無効を切り替えた場合(青)
  2. foreign_key_checks を有効にしたままの場合(オレンジ)

青の分布が今回の対策後の実行時間ですが、外部キーチェック対策を入れることで、実行時間が短くなっていることが確認できました!

対策前後のレコード作成処理の実行時間の比較 (ローカル実行)

最後まで、お読みいただきありがとうございました!


当チームは積極的な採用を行っています!もしこうした環境やチームに魅力を感じる方がいらっしゃいましたら、ぜひお気軽にお話をしましょう!

アプリケーションエンジニアはこちらから! www.wantedly.com

モバイルアプリエンジニアはこちらから! www.wantedly.com

SRE はこちらから! www.wantedly.com