Timee Product Team Blog

タイミー開発者ブログ

日中でも安心して ALTER TABLE を流したい ─ Datadog + Devin によるロングトランザクション削減

こんにちは、タイミーでバックエンドエンジニアをしている 福井 (bary822) です。

タイミーのバックエンドは巨大な Rails のモノリスアプリケーションです。以前から「アクセスが集中する特定のテーブル(以下、人気テーブル)への DB マイグレーションが日中に通らない」という問題を抱えており、看過できないレベルになってきたため、本格的に対処に乗り出しました。

この記事では、原因となっていたロングトランザクションに対し、Datadog と Devin を組み合わせた自動修正フローで対処した話と、その設計の裏側を紹介します。

DBマイグレーション失敗のメカニズム

日常的に発生していたのは、人気テーブルへの ALTER TABLE が日中はほぼ通らない、という状況でした。原因は メタデータロック (MDL) です。

  • Aurora MySQL(8.0) では、SELECT / INSERT / UPDATE / DELETE などの DML が対象テーブルの MDL(共有ロック)を取得する
  • MDL はテーブルなどのメタデータに対して取得されるロックであり、共有 MDL が保持されている間は ALTER TABLE に必要な排他 MDL を取得できずロック待ちになる
  • MDL が解放されるまで ALTER TABLE はブロックされるため、1 本でも長い時間走るトランザクション(以下、ロングトランザクション)があると、その裏で ALTER TABLE がタイムアウトしてしまう

つまり、クエリ実行頻度の高い人気テーブルほど日中は触れなくなり、「カラムを別テーブルに切り出す」「カラム、インデックスの削除を諦める」といった、技術的制約が設計を歪める方向に力学が働き始めていました。

このマイグレーション失敗そのものに対しては、これまで strong_migrations gem のロック取得リトライ機能(lock_timeout_retries など)で何とか対策してきました。しかし、これらはあくまで成功確率を上げる ための投機的なアプローチにとどまり、根本原因であるロングトランザクションそのものには手を入れられていませんでした。

ロングトランザクション修正の方針

これまで見てきた通り、根本原因はロングトランザクションそのものです。そこで、リトライで凌ぐ運用から一歩踏み込んで、いよいよロングトランザクション自体を減らしていく方向に舵を切ることにしました。

とはいえ、現時点において目立ったロングトランザクションを頑張って解消したとしても、今後開発者が意図せず新たなロングトランザクションを生み出してしまう可能性は大いにあります。

かといってマージ前にロングトランザクションを検出するのも現実的ではありませんでした。トランザクションの長さは、多くの場合そのレコード(スキャン)量に依存しており、本番で実行してみるまで検知しにくいからです。

そこで本番リリース前の検知は諦めて、リリース後にできるだけ早く検知する方針にしました。また、検知から修正、レビューまでをできるだけ自動化し、人間は最終判断要員として介入するだけで済む状態にすることで持続可能な運用を目指すことにしました。

仕組みの全体像

上記方針をもとにいくつかのプランを検討した結果、タイミーで既に導入されていた Datadog、Devin などを組み合わせ、以下の 5 フェーズからなる自動化フローを構築しました。

  1. 準備: ActiveRecord Query Logs を有効化し、クエリの発行元がSQLコメントとして埋め込まれるようにしておく
  2. 観測: Datadog Agent から本番 DB に対して定期クエリを実行し、performance_schemainformation_schema の情報をもとに、テーブルごとにMDLを取得するロングトランザクション時間をカスタムメトリクスとして Datadog に送信する
    1. テーブルごとにMDLを保持しているトランザクションのうち、計測時点で最も時間が長い秒数を記録する
  3. 検知: Datadog Monitor にてテーブルごとに一定のしきい値を超えるロングトランザクションを検知する
  4. 修正: Datadog Monitor で発火されたアラートをトリガーとして、Datadog Workflow Automation を起動。コンテキストを整理して GitHub Actions 経由で Devin Session を起動し、修正 PR を作成
  5. レビュー: 「修正対象のコードに詳しい人」を自動的に判定してアサイン + AI による事前レビュー

ロングトランザクション修正フローの構成図

以下、それぞれのフェーズで工夫したポイントを紹介します。

準備: クエリの発行元を明らかにする

Rails 7 から標準提供されている ActiveRecord Query Logs には豊富なオプションが用意されており、クエリの発行元をコメントとして付与する対象を限定することができます。

https://railsguides.jp/v8.1/configuring.html#config-active-record-query-log-tags

タイミーでは次の設定を入れています。

config.active_record.query_log_tags_enabled = true
config.active_record.query_log_tags = %i[namespaced_controller action sidekiq_worker rake_task]

観測: ロングトランザクション発生状況を可視化する

MySQL では performance_schemainformation_schema の情報を組み合わせることで「テーブルごとのその時点で実行されている最も長いMDLを取得するトランザクション」を特定することができます。

さらにクエリコメントとして付与された発行元の情報を組み合わせることで「どこから実行されたトランザクションが何秒実行されているか」が特定可能になります。

次の例では、テーブル名を table_name 、クエリの発行元を query_source として取得しています(query_source は、実際の出力を見ながら扱いやすいように加工している)。

計測クエリ例

    SELECT
      table_name,
      CASE
        WHEN raw_sql LIKE '%namespaced_controller:%' THEN
          CONCAT(
            TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(raw_sql, 'namespaced_controller:', -1), '*/', 1), ',', 1)),
            '#',
            TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(raw_sql, 'action:', -1), '*/', 1), ',', 1))
          )
        WHEN raw_sql LIKE '%sidekiq_worker:%' THEN
          TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(raw_sql, 'sidekiq_worker:', -1), '*/', 1), ',', 1))
        WHEN raw_sql LIKE '%rake_task:%' THEN
          CONCAT('rake:', TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(SUBSTRING_INDEX(raw_sql, 'rake_task:', -1), '*/', 1), ',', 1)))
        ELSE 'unknown'
      END AS query_source,
      tx_duration_seconds AS max_tx_duration_seconds
    FROM (
      SELECT
        CASE
          WHEN ml.OBJECT_NAME LIKE '#sql-%' THEN 'DDL_IN_PROGRESS'
          ELSE ml.OBJECT_NAME
        END AS table_name,
        COALESCE(esc.SQL_TEXT, it.trx_query, '') AS raw_sql,
        TIMESTAMPDIFF(SECOND, it.trx_started, NOW()) AS tx_duration_seconds,
        ROW_NUMBER() OVER (
          PARTITION BY CASE WHEN ml.OBJECT_NAME LIKE '#sql-%' THEN 'DDL_IN_PROGRESS' ELSE ml.OBJECT_NAME END
          ORDER BY TIMESTAMPDIFF(SECOND, it.trx_started, NOW()) DESC
        ) AS rn
      FROM performance_schema.metadata_locks ml
      JOIN performance_schema.threads th
        ON ml.OWNER_THREAD_ID = th.THREAD_ID
      JOIN information_schema.innodb_trx it
        ON th.PROCESSLIST_ID = it.trx_mysql_thread_id
      LEFT JOIN performance_schema.events_statements_current esc
        ON th.THREAD_ID = esc.THREAD_ID
      WHERE ml.OBJECT_TYPE = 'TABLE'
        AND ml.OBJECT_SCHEMA NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
        AND TIMESTAMPDIFF(SECOND, it.trx_started, NOW()) >= 5
    ) ranked
    WHERE rn = 1

このクエリを何かしらの方法で本番DBに対して定期的に実行し、その結果をどこかに貯めておけばロングトランザクション発生状況を可視化できます。

Datadog ではこれを簡単に行うことができました。アプリケーションが実行されているものとは別のサービスとして ECS 上で常時稼働している Datadog Agent にて定期的にクエリを実行し、その結果をカスタムメトリクスとして Datadog に送信しています。

Aurora MySQL での設定方法: https://docs.datadoghq.com/ja/database_monitoring/setup_mysql/aurora

検知: 修正対象のロングトランザクションを絞り込む

カスタムメトリクスとして1度 Datadog に取り込んでしまえば、それを使ってアラート(Datadog Monitor)を仕込むことは簡単です。

メトリクスはクエリ発行元( query_source )でグルーピングして監視するようにしました。こうすることで後続のフローに「どのクラス(ファイル)でロングトランザクションが発生したか」を渡せるようになります。

また、発行元が特定できなかったものや定期実行しないバッチなどは対象外としました。

以下が Datadog Monitor のクエリです。(!query_source:rake:tmp:* は定期実行しないバッチを取り除くためのものです)

default_zero(avg:custom.mysql.mdl_holder.max_tx_duration_by_table{account:timee-jp-prod,replication_role:writer, !query_source:unknown, !query_source:rake:tmp:*} by {query_source})

しきい値はまずはアラートがノイズにならない程度(後続の修正フローによって作成されるPRのレビューが負担にならない程度)から始めることをおすすめします。

タイミーの場合は当初数百 sec を超えるロングトランザクションが発生していたため、まずは 100 秒をしきい値として設定しました。

この時点でロングトランザクションの発生元が限られている場合は、後続の自動修正フローを構築する前に、まずはそれらだけを対象にいったん修正してみるのも効果的かもしれません。

修正: パターン集で修正アプローチを制御する

Datadog Monitor のしきい値超過をトリガーに、Datadog Workflow Automation を起動します。ここでは、Monitor から渡されたロングトランザクションに関する情報(クエリ実行元、発生時間など)を取りまとめ、GitHub Action 経由で Devin Session を起動して、詳細な原因調査と修正PRの作成を行います。

また、数百秒にわたるロングトランザクションでは、Monitor が重複してトリガーされる可能性があります。そのため、同一クエリ発行元に対して Devin Session が重複実行されないようにする必要がありました。具体的には、Session 起動時のタグに query_source を設定し、新しい Session を起動する前に既存の起動有無をチェックして、利用料金の無駄を防いでいます(初期段階ではこのチェックがなく、一夜にして数百ドルかかったことがありました)。

Devin による修正では Datadog MCP 経由で APM などの情報を分析させることで詳細な原因調査を行っていますが、しばらく運用しているうちにロングトランザクションの発生とその修正方法には一定のパターンがあることを発見しました。そこであらかじめ修正パターンをドキュメント化してレポジトリに置いておき、それを Devin に参照させるようにしました。こうすることで調査のアタリをつけやすくなりコンテキストの節約に寄与したり、実行時間を短縮することができました。

修正パターンドキュメント例

    # トランザクション内の外部APIコールを排除する
    
    ## 概要
    
    トランザクション(`with_lock` / `transaction do`)の内側で外部APIコール(HTTP リクエスト、LLM API、外部 SDK 呼び出しなど)を実行している場合、通信時間の間ずっとMDL(Metadata Lock)が保持され続けます。外部呼び出しの所要時間は秒〜分単位に及ぶことがあり、これがロングトランザクションの**最も典型的な原因**です。
    
    改善の基本方針は、外部呼び出しをトランザクション外に出して **MDL保持時間を最小化** することです。完全な除去ではなく **トランザクションスコープの最小化** を第一選択とし、ロックが守ろうとしていたデータ整合性は別の手段(ステータス管理・楽観的整合性チェックなど)で維持します。
    
    ## 問題のシグネチャ
    
    - **コード上の特徴**:
      - `with_lock do ... end` または `transaction do ... end` の内部に、HTTP クライアント呼び出し(Net::HTTP, Faraday, RestClient など)、AWS SDK 呼び出し、LLM API 呼び出し、メール送信、Slack 通知などが含まれている
      - 外部呼び出しが完了してから `save!` / `update!` が呼ばれる流れになっている
    - **APMトレース上の特徴**:
      - トランザクション開始から終了までのスパン内に、`http.client` / `aws.s3` / `openai.api` 等の子スパンがある
      - DB クエリの所要時間より外部呼び出しスパンの所要時間のほうが長い
      - 「DB時間 << 全体時間」のトレースが頻発している
    
    ## Before / After
    
    ```ruby
    # Before(外部APIコールがトランザクション内 → MDLを長時間保持)
    def process
      with_lock do
        reload
        return false unless entered?
        result = call_external_api!  # 外部APIコール → 最大120秒のMDL保持
        save_result!(result)
      end
    end
    
    # After(トランザクションを分離してMDL保持時間を最小化)
    def process
      # 短いトランザクション: ステータス確認のみ
      with_lock do
        reload
        return false unless entered?
      end
    
      # 外部APIコールはトランザクション外で実行(MDLを保持しない)
      result = call_external_api!
      save_result!(result)
    end
    ```
    
    ### 楽観的整合性チェックの追加(再enqueueパターンがある場合)
    
    対象の処理が「データ変更時に再enqueueされる」設計の場合、以下のリスクが生まれます:
    
    - Worker A がデータ読み込み後にトランザクションを終了
    - レコードが更新され Worker B が enqueue
    - Worker A が古いデータで重い処理を続行
    - Worker B が新しいデータで上書き(結果整合性は保たれるが Worker A の処理は無駄になる)
    
    このリスクを緩和するため、トランザクション終了後に再enqueueトリガーと同じ変化検知ロジックでデータの鮮度を確認し、変化があれば中断する楽観的チェックを追加します。
    
    ```ruby
    # トランザクション内でスナップショット取得
    before_checker = SomeChecker.new(record)
    data = load_data_in_transaction
    
    # トランザクション外で鮮度確認(重い処理の前)
    current_record = Record.includes(...).find(id)
    return if before_checker.changed?(current_record)  # Worker Bに任せる
    
    # 重い処理を実行
    process(data)
    ```
    
    ## 効果
    
    - MDL保持時間が **秒〜分単位** で短縮される(外部呼び出しの所要時間ぶん)
    - ロングトランザクション(長時間 MDL 保持)アラートの発火回数が大幅に減少することが期待される
    - 同テーブルへの他アクセス(マイグレーション・更新クエリ)の待ち時間も短縮される
    
    ## 注意点・トレードオフ
    
    - **排他制御が弱まる可能性**: トランザクション外に出すことで排他制御が弱まる場合があります。`retry: false` の Sidekiq Worker など、同一レコードが同時処理されるリスクが低い場合は許容できます
    - **堅牢化の選択肢**: より堅牢にするには、トランザクション内でステータスを `processing` に変更してから外部呼び出しを行うパターンが有効です(スキーマ変更が必要な場合は別PRで対応)
    - **楽観的整合性チェックの適用条件**: 対象レコードの更新が同一Workerの再enqueueをトリガーする設計になっている場合のみ必要。再enqueueしない設計では不要です
    - **完全除去は最終手段**: ロックの完全除去は、保護が不要であることを論理的に説明できる場合にのみ行ってください。経緯(`git log` / `git blame`)を確認せずに削除すると、過去に修正済みのバグを再発させるリスクがあります
   ```
    
    # Before(外部APIコールがトランザクション内 → MDLを長時間保持)
    
    def process
    
    with_lock do
    
    reload
    
    return false unless entered?
    
    result = call_external_api!  # 外部APIコール → 最大120秒のMDL保持
    
    save_result!(result)
    
    end
    
    end
    
    # After(トランザクションを分離してMDL保持時間を最小化)
    
    def process
    
    # 短いトランザクション: ステータス確認のみ
    
    with_lock do
    
    reload
    
    return false unless entered?
    
    end
    
    # 外部APIコールはトランザクション外で実行(MDLを保持しない)
    
    result = call_external_api!
    
    save_result!(result)
    
    end
    
    ```
    
    ### 楽観的整合性チェックの追加(再enqueueパターンがある場合)
    
    対象の処理が「データ変更時に再enqueueされる」設計の場合、以下のリスクが生まれます:
    
    - Worker A がデータ読み込み後にトランザクションを終了
    - レコードが更新され Worker B が enqueue
    - Worker A が古いデータで重い処理を続行
    - Worker B が新しいデータで上書き(結果整合性は保たれるが Worker A の処理は無駄になる)
    
    このリスクを緩和するため、トランザクション終了後に再enqueueトリガーと同じ変化検知ロジックでデータの鮮度を確認し、変化があれば中断する楽観的チェックを追加します。
    
    ```
    
    # トランザクション内でスナップショット取得
    
    before_checker = SomeChecker.new(record)
    
    data = load_data_in_transaction
    
    # トランザクション外で鮮度確認(重い処理の前)
    
    current_record = Record.includes(...).find(id)
    
    return if before_checker.changed?(current_record)  # Worker Bに任せる
    
    # 重い処理を実行
    
    process(data)
    
    ```
    
    ## 効果
    
    - MDL保持時間が **秒〜分単位** で短縮される(外部呼び出しの所要時間ぶん)
    - ロングトランザクション(長時間 MDL 保持)アラートの発火回数が大幅に減少することが期待される
    - 同テーブルへの他アクセス(マイグレーション・更新クエリ)の待ち時間も短縮される
    
    ## 注意点・トレードオフ
    
    - **排他制御が弱まる可能性**: トランザクション外に出すことで排他制御が弱まる場合があります。`retry: false` の Sidekiq Worker など、同一レコードが同時処理されるリスクが低い場合は許容できます
    - **堅牢化の選択肢**: より堅牢にするには、トランザクション内でステータスを `processing` に変更してから外部呼び出しを行うパターンが有効です(スキーマ変更が必要な場合は別PRで対応)
    - **楽観的整合性チェックの適用条件**: 対象レコードの更新が同一Workerの再enqueueをトリガーする設計になっている場合のみ必要。再enqueueしない設計では不要です
    - **完全除去は最終手段**: ロックの完全除去は、保護が不要であることを論理的に説明できる場合にのみ行ってください。経緯(`git log` / `git blame`)を確認せずに削除すると、過去に修正済みのバグを再発させるリスクがあります

Devin は与えられたコンテキストとパターン集を照らし合わせ、当てはまるパターンがあればこれを参考に修正。なければ新規パターンとしてドキュメントを追加します。

つまり、Devin が直せば直すほど、次の Devin が使えるドキュメントが増えていくループを、リポジトリ内で完結する形で作っています。プロンプトの調整も普通の PR ベースで行えるので、レビュアーからのフィードバックが自然と AI 側の挙動改善に還元されていきます。

レビュー: 「そのコードに詳しい人」を特定する

ロングトランザクション修正は、コードの表面的な変更だけでは判断できないケースが多く、実装の意図やドメイン背景を知っている人のレビューが不可欠です。

そこで、次の手順でレビュアーを決めています。

  1. コードオーナーが設定されていれば、その人(チーム)をレビュアーとする
  2. なければ、直近 1 年間で最も多くそのファイルに commit したユーザーとその時点での所属チーム
  3. 1 年以内に commit がなければ、特定チーム(私が所属するチーム)

これはプロンプトベースだと間違ったアサインを行うことがあったため、スクリプト化しました。

さらに、作成された PR に対して AI レビューを実行しています。Devin はレビューに対して自動で対応を行うため、人間レビュアーの目に届く時点で、AI 同士の一次すり合わせは終わっている状態になっています。

運用上のポイント

昨今、コーディングエージェントの性能向上やその周辺ツールの充実により、このような自動修正フローを簡単に構築することができるようになりました。

一方で「作った仕組みを普段の開発フローの中で無理なく運用する方法」をセットで実装することは以前に増して重要になってきたように思います。

今回のケースでは下記3点を特に意識して実装に落とし込みました。

  • 人間の目に触れる前までに無駄を削ること
  • 人間が対応する場合の工数を可能な限り小さくすること
  • 無理なく運用できるペースで継続できること

AI による相互レビューで無駄を削る

前述の AI 相互レビューでは次の観点でPRの妥当性を判断しています。

  • この変更は本当に長時間MDLを生み出すボトルネックにアプローチしているか?
  • この変更が長時間MDLを解消するための必要最小限の変更か?
  • 長時間MDLを解消しつつ、元の振る舞いを極力維持できているか?

たとえ修正によってあるトランザクションがMDLを取得する時間が短くなったとしても、それが検出されたロングトランザクションを十分に解消する(アラートが鳴らなくなるレベル)でなければ修正する価値はありません。

また、修正できたとしてもその変更範囲が膨大になってしまえばレビュアーの負荷が高くなり、いつまでもマージできないことで運用が回らなくなってしまいます。

AI レビューでこれらの観点を満たさない場合は PR を クローズする運用を行っています。

「対応しない」ことも選択肢におく

継続的な運用で意外と重要なのが、「対応しない」判断を尊重することです。

Devin が作った PR が、レビュアーの目から見て対応しないと判断されることは普通にあります。多くの場合トランザクションの範囲を小さくしたりトランザクション自体を無くすことはデータの整合性とトレードオフの関係にあるからです。

このとき単にクローズして終わりだと、次に同じクエリ発行元( query_source )でトリガーされたときにまた同じ PR が生成されてしまいます。

これを避けるために、「対応しない」ことがあるという前提で運用を考えました。また、対応しない場合の工数もできる限り小さくなるようにしています。

  • 対応しないものは query_source 単位で Ignore List として管理し、リポジトリに含めておく
    • Ignore List の実体はただの query_source のリスト(フォーマットは JSON、YAML など何でもいい)
  • レビュアーが PR に long-transaction-wontfix ラベルを付けるとGitHub Actions が起動し、それまでの commit を破棄して Ignore List に追加する

⚠️ Ignore List は query_source 単位なので、同じ query_source の別箇所で新たにロングトランザクションが発生しても検知されなくなります。厳密な検知性より運用のシンプルさを優先した割り切りで、必要があれば粒度を後から変えられるようにしています。

しきい値を下げて対象を広げていく

ここまでの仕組みは、Datadog Monitor のしきい値(初期構築時は 100 s)を超えたロングトランザクションを対象にしています。運用初期はやや保守的な値に置き、専用のダッシュボードにまとめたロングトランザクション発生状況や作成された修正 PR 数やマージ数、レビュアーの偏りを見ながら、段階的に下げていく運用を行っています。

現在では無理なく運用しながらしきい値を 50s まで引き下げられており、人気テーブルによっては MDL 保持時間が以前の半分以下になりました。

定期観測しているダッシュボード。画面上部のメトリクス(MDL保持時間)が時間が進むにつれて改善されている(短くなっている)ことがわかる

おわりに

以前投稿した Flaky Test 自動修正の取り組みとテーマは違いますが、同じようなパターンでロングトランザクションを改善する仕組みの実装と運用ノウハウを紹介しました。

tech.timee.co.jp

今回のケースでは変更によるトレードオフが発生する特性があるため、「対応しない」という選択も同じように尊重する必要がありました。そこでロングトランザクションを駆逐するのではなく、あくまでも現状を緩和することをターゲットに置いたことで現実的に持続可能な運用に落とし込むことができました。

問題の発生を検知し、自動で原因分析から修正 PR の作成まで行うパターンは、他の問題にも適用できる汎用性があります。そのため、ついつい多用したくなってしまいます。しかし、開発サイクルのどこかに人間が介在する限り持続可能な運用に落とし込むことが重要になっていることをあらためて実感しています。

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