inSmartBank

ワンバンクを運営する株式会社スマートバンクのメンバーによるブログです

Rails の隠れた堅牢性:SELECTクエリが自動リトライされる仕組み

こんにちは、おはようございます、こんばんは、スマートバンクで顧客体験チームのエンジニアリングマネージャーをしている佐藤(@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

github.com

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メソッドの実際のリトライ仕組みについて詳しく解説いたします。

github.com

リトライ機能の全体アーキテクチャ

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

We create the new normal of easy budgeting, easy banking, and easy living.
In this blog, engineers, product managers, designers, business development, legal, CS, and other members will share their insights.