こんにちは、おはようございます、こんばんは、スマートバンクで顧客体験チームのエンジニアリングマネージャーをしている佐藤(@tmnbst)です。
Rails 7.1 以降 では、SELECTクエリが内部的に自動でリトライされる仕組みが導入されています。 このリトライ処理は、allow_retry という内部フラグによって制御されており、Railsが「これは安全(冪等)なクエリだ」と判断した場合に場合にのみ有効になります。 普段Railsを使っているだけではなかなか気づけないこの仕様ですが、ネットワーク切断やDBのフェイルオーバー時などの場面で効果を発揮します。
この記事では、Railsのコードを読みながら allow_retry の仕組みを紐解き、実際にどんな条件でリトライされるのかを検証してみます。
1. allow_retry とは何か
まず allow_retry とは、Railsが内部的にSQLクエリを「再実行してもよい」と判断したときに true になるActiveRecord::ConnectionAdapters::DatabaseStatements#execute
の引数オプションです。
https://github.com/rails/rails/pull/46273 で導入されて、直近では https://github.com/rails/rails/pull/51336 でRails側で構築する安全と思われるクエリ(findで実行するselectクエリなど)はデフォルトでallow_retryがtrueとなるようになりました。
SELECT クエリにおいて、ネットワーク越しに一時的なエラー(例: MySQL server has gone away や Connection reset by peer)が起きたとき、もう一度だけリトライして正常に結果を得られるのであれば、それが望ましいでしょう。 しかし、すべてのクエリを闇雲に再送すると危険です。UPDATE や DELETE が2回実行されたら、状態が壊れてしまうため、Railsは「安全(冪等)とみなせるクエリに限定してリトライを許す」という設計をしています。
2. 内部処理解説
to_sql_and_binds
メソッドの役割
to_sql_and_bindsメソッドは、SQLクエリの生成とリトライ可否の判定を同時に行う、ActiveRecordの中核的なメソッドです。このメソッドでallow_retryがtrueになる条件を詳しく解説します。
def to_sql_and_binds(arel_or_sql_string, binds = [], preparable = nil, allow_retry = false) # Arel::TreeManager -> Arel::Node if arel_or_sql_string.respond_to?(:ast) arel_or_sql_string = arel_or_sql_string.ast end if Arel.arel_node?(arel_or_sql_string) && !(String === arel_or_sql_string) # Arelオブジェクトの場合の処理 collector = collector() collector.retryable = true # ← ここが重要! sql = visitor.compile(arel_or_sql_string, collector) allow_retry = collector.retryable # ← retryableがallow_retryに [sql.freeze, binds, preparable, allow_retry] else # 文字列SQLの場合 [arel_or_sql_string, binds, preparable, allow_retry] # ← デフォルトはfalse end end
allow_retry = true
になる条件
✅ 条件1: Arelオブジェクト(構造化クエリ)であること
Arelオブジェクトとは、ActiveRecordのクエリビルダーが生成する構造化されたクエリオブジェクトです。
Arelオブジェクトの例
# ✅ allow_retry = true になるパターン # 1. ActiveRecordクエリメソッド User.where(name: "Onebank-kun") User.joins(:posts) User.select(:id, :name) # 2. 手動Arel構築 users = User.arel_table query = users.where(users[:created_at].gt(1.week.ago)) # 3. 複雑なクエリビルダー User.includes(:posts) .where(posts: { published: true }) .order(:created_at)
❌ 条件2: 文字列SQL(生SQL)でないこと
文字列として渡されるSQLは、構造化されていないためallow_retry = false
になります。
文字列SQLの例
# ❌ allow_retry = false になるパターン # 1. 直接SQL文字列 ActiveRecord::Base.connection.execute("SELECT * FROM users") # 2. find_by_sqlでの生SQL User.find_by_sql("SELECT * FROM users WHERE created_at > NOW() - INTERVAL 1 DAY") # 3. Arel.sqlを使った場合(注意!) User.where(Arel.sql("created_at > NOW()")) # 結果的に文字列扱い # 4. where句での生SQL User.where("name LIKE ?", "%Alice%")
collector.retryable
の詳細メカニズム
先ほどの to_sql_and_binds メソッド内で登場した collector.retryable = trueという処理があります。これがどのように設定され、最終的に allow_retry に反映されるのかを、もう少し掘り下げて見ていきます。
# Arelオブジェクト処理時の内部動作 collector = collector() collector.retryable = true # 初期値をtrueに設定 # visitor.compileの過程で、特定の条件でfalseに変更される場合がある sql = visitor.compile(arel_or_sql_string, collector) # 最終的なretryable値がallow_retryになる allow_retry = collector.retryable
collector.retryable
がfalseになるケース
collector.retryableがfalseになるのは、主にArel.sqlを含むクエリの場合です。Arel.sqlはデフォルトでretryable: falseとなっており、NOW()やRAND()などの時間依存・非決定的な関数や、副作用のある処理を含む可能性があるためです。 Visitorがクエリをコンパイルする際にSqlLiteralノードを検出するとcollector.retryable = falseに設定され、データ整合性保護のため一度falseになったクエリ全体がリトライ不可になります。これにより、重複実行による予期しない副作用を防いでいます。
# 例:Arel.sqlを含むクエリ User.where(User.arel_table[:created_at].gt(Arel.sql("NOW()"))) # → Arel.sqlの部分でcollector.retryableがfalseになる
確認方法
ここまで解説しましたが、アプリケーションのコードが allow_retry: true になるか中々分かりづらいと思います。そんな時は Rails console で allow_retry になるかを確認する方法がおすすめです。
def check_allow_retry(query_or_sql) conn = ActiveRecord::Base.connection case query_or_sql when String # 文字列SQLの場合 sql, binds, preparable, allow_retry = conn.send(:to_sql_and_binds, query_or_sql) else # Arelクエリの場合 arel = query_or_sql.respond_to?(:arel) ? query_or_sql.arel : query_or_sql sql, binds, preparable, allow_retry = conn.send(:to_sql_and_binds, arel) end status = allow_retry ? "✅ true " : "❌ false" puts "#{status}" puts "SQL: #{sql}" puts end check_allow_retry(User.where(name: "Oneban-kun")) => ✅ true => SQL: SELECT `users`.* FROM `users` WHERE `users`.`name` = 'Oneban-kun' check_allow_retry(User.where(User.arel_table[:name].eq('Oneban-kun'))) => ✅ true => SQL: SELECT `users`.* FROM `users` WHERE `users`.`id` = 'Oneban-kun' check_allow_retry("select * from users where name = 'Oneban-kun'") => ❌ false => SQL: select * from users where id = 'Oneban-kun' b43-core-api(dev)> check_allow_retry(User.where("name = ?", "Oneban-kun)) => ❌ false => SQL: SELECT `users`.* FROM `users` WHERE (id = 'Oneban-kun') check_allow_retry(User.where(Arel.sql("name = 'Oneban-kun"))) => ❌ false => SQL: SELECT `users`.* FROM `users` WHERE (name = 'Oneban-kun')
📊 allow_retry動作パターン一覧表
パターン | allow_retry |
---|---|
ActiveRecordクエリ | ✅ true |
純粋なArelクエリ構築 | ✅ true |
文字列SQL | ❌ false |
文字列SQLリテラル | ❌ false |
Arel.sql | ❌ false |
リトライされる仕組みについて
with_raw_connection
メソッドの実際のリトライ仕組みについて詳しく解説いたします。
リトライ機能の全体アーキテクチャ
1. 設定フェーズ
まずSQLを実行する前に、リトライ可能な状態かどうかを判定し、再試行回数(retries_available)やリトライのタイムリミット(retry_deadline)が初期化されます。
def with_raw_connection(allow_retry: false, materialize_transactions: true) @lock.synchronize do # リトライ設定の初期化 retries_available = allow_retry ? connection_retries : 0 deadline = retry_deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) + retry_deadline reconnectable = reconnect_can_restore_state?
パラメータ | 説明 | デフォルト値 |
---|---|---|
allow_retry |
リトライを許可するか | false |
connection_retries |
最大リトライ回数 | 1 |
retry_deadline |
リトライ制限時間 | nil (無制限: connection_retries の回数分だけリトライする) |
2. 実行フェーズ
実際のデータベース操作が yield @raw_connection
によって実行されます。
この時点ではまだ例外処理もリトライも発生しておらず、通常のクエリ発行として動作します。
begin yield @raw_connection # 実際のDB操作を実行 rescue => original_exception # ここからリトライロジックが始まる end
3. 例外発生時
ActiveRecordは発生した例外を2つのカテゴリに分類します:
A. クエリレベルのエラー(retryable_query_error?
)
def retryable_query_error?(exception) return false if current_transaction.invalidated? exception.is_a?(Deadlocked) || exception.is_a?(LockWaitTimeout) end
- 対象: デッドロック、ロック競合
- 戦略: 同じ接続でそのままリトライ
- 例: 他のトランザクションとの競合
B. 接続レベルのエラー(retryable_connection_error?
)
def retryable_connection_error?(exception) (exception.is_a?(ConnectionNotEstablished) && !exception.is_a?(ConnectionNotDefined)) || exception.is_a?(ConnectionFailed) end
- 対象: 接続断、ネットワークエラー
- 戦略: 再接続してからリトライ
- 例: ネットワーク瞬断、サーバー再起動
4. リトライ実行
エラーの種類に応じて、同一接続または再接続後のリトライが行われます。 retries_available や retry_deadline を超えない範囲で、内部的に retry が呼ばれクエリが再実行されます。
if !retry_deadline_exceeded && retries_available > 0 retries_available -= 1 if retryable_query_error?(translated_exception) # パターンA: クエリエラー backoff(connection_retries - retries_available) retry # リトライ! elsif reconnectable && retryable_connection_error?(translated_exception) # パターンB: 接続エラー reconnect!(restore_transactions: true) reconnectable = false # 再接続は1回限り retry #リトライ! end end
5. バックオフの実装
リトライ時には backoff メソッドによって0.1秒単位の線形待機が挿入されます。 無制限なリトライによる負荷集中を避け、短時間での再実行を少しずつ間引く設計になっています。
def backoff(counter) sleep 0.1 * counter end
リトライ回数 | 待機時間 | 累積時間 |
---|---|---|
1回目 | 0.1秒 | 0.1秒 |
2回目 | 0.2秒 | 0.3秒 |
3回目 | 0.3秒 | 0.6秒 |
6. リトライ回数やリトライ制限時間の設定変更
database.yml でこれらの値の設定変更が可能です
# config/database.yml production: # ... connection_retries: 3 # 最大3回リトライ retry_deadline: 10.0 # 10秒以内にリトライ完了 verify_timeout: 2 # 2秒以内に接続確認
3. 全体像
図にすると以下のようなフローになります。
※ 人間が頑張って書きました
flowchart TD A[クエリ実行開始] --> B{クエリの種類は?} B -->|Arelオブジェクト| C[to_sql_and_binds メソッド] B -->|文字列SQL| D[allow_retry = false<br/>(リトライ不可)] C --> E[collector.retryable = true<br/>(初期値)] E --> F[visitor.compile でAST解析] F --> G{Arel.sql含む?} G -->|Yes| H[collector.retryable = false] G -->|No| I[collector.retryable = true維持] H --> J[allow_retry = false] I --> K[allow_retry = true] J --> L[with_raw_connection<br/>allow_retry: false] K --> M[with_raw_connection<br/>allow_retry: true] D --> L L --> N[クエリ実行<br/>(リトライなし)] M --> O[クエリ実行<br/>(リトライあり)] N --> P{エラー発生?} O --> Q{エラー発生?} P -->|Yes| R[例外発生<br/>処理終了] P -->|No| S[正常終了] Q -->|No| S Q -->|Yes| T{リトライ可能エラー?<br/>deadlock, timeout等} T -->|No| R T -->|Yes| U{リトライ回数<br/>上限内?} U -->|No| R U -->|Yes| V[バックオフ待機<br/>0.1 * counter秒] V --> W{接続エラー?} W -->|Yes| X[reconnect!実行] W -->|No| Y[そのままリトライ] X --> O Y --> O style C fill:#e1f5fe style M fill:#c8e6c9 style L fill:#ffcdd2 style K fill:#c8e6c9 style J fill:#ffcdd2
まとめ
ふだん意識せず使っている ActiveRecordクエリメソッド が、実は堅牢性を高める設計になっていることがよくわかります。 障害時の回復性やアプリケーションの信頼性を考えるうえで、Railsが提供するこの自動リトライの仕組みを正しく理解しておく価値は高いでしょう。 フェイルオーバーや一時的な接続断といった運用上のトラブルに備え、開発者が意識せずとも安全性を担保できる設計は、まさにRailsらしさの好例ではないでしょうか。 今後は、どのようなケースでリトライが効かないのか、またリトライの挙動をどう観測・制御できるのかといった視点も深掘りしてみたいと思います。
スマートバンクでは、Railsの仕組みを深く理解しながらプロダクトを育てていく仲間を募集しています。 もし少しでも興味を持っていただけたら、採用特設サイトもぜひのぞいてみてください! smartbank.co.jp