TiDB の retry ポリシー

## 概要

システムを構築する際には、retry が必要な状況に頻繁に遭遇します。問題を簡単に retry して解決できる場合もありますが、そうでない場合もあります。retry ポリシーの研究は、エラーの発生を可能な限り少なくし、サービスの品質を向上させるために行われます。

私自身も TiDB の開発中に多くの retry が必要な場面に遭遇しました。そのため、この記事ではデータベースシステムで retry をどのように処理するかについてまとめてみました。読者の皆さんに少しのヒントを与えることができればと思います。

## エラーが発生した場合

エラーが発生した場合、私たちは実行した操作とエラーの種類を考慮する必要があります。2つの次元で分類することができます。まず、retry が可能かどうかです。

- retry 可能
- ステータスの変更後に retry が必要
- 別の操作に切り替えが必要
- retry 不可能

2番目の次元は、次の操作を行う前に待つ必要があるかどうかです。

- 即座に実行
- 待つ必要があるが、待ち時間は不明
- 待つ必要があるが、待ち時間は既知

これら2つの次元の組み合わせに基づいて、TiDB の例を以下に示します。

|  | 即座に実行 | 待つ必要があるが、待ち時間は不明 | 待つ必要があるが、待ち時間は既知 |
| --- | --- | --- | --- |
| retry可能 | 特定の条件でSQLをretry | TiKVが "server is busy" を返す場合 | "check txn status" が有効期限切れのロックに遭遇した場合 |
| ステータスの変更後に retry が必要 | stale read が失敗した場合 | - | - |
| 別の操作に切り替えが必要 | Raft の選挙の状態が変化した場合 | - | - |
| retry 不可能 | 不明なエラー | - | - |

## 例

### retry 可能 + 即座に実行

エラーをシステムエラーと論理エラーに分けると、システムエラーは偶発的なエラーであり、ディスクへの書き込みエラーや RPC エラーなどが該当します。一方、論理エラーは確定的なエラーであり、制約に違反した場合やデッドロックが発生した場合などが該当します。

デッドロックは、2つの並行タスクがお互いの排他リソースの解放を待っている状態を指します。この場合、相互待ち状態を解除するためには、1つのタスクがロールバックされる必要があります。TiDB では、デッドロックエラーはロールバックされたトランザクションが受け取るエラーです。

ただし、ここでの retry はSQLクライアントに混乱をもたらすことはありません。クライアントにとっては、retry の有無にかかわらず、オートコミットの悲観的トランザクション（[`pessimistic-txn.pessimistic-auto-commit`](https://docs.pingcap.com/tidb/stable/tidb-configuration-file#pessimistic-auto-commit-new-in-v600)で有効にする必要があります）は、自動的にデッドロックエラーを処理します。なぜなら、retry の有無に関係なく、この SQL の効果はクライアントの視点では結果が返されるまで待機するだけであり、待機中にこの SQL と他のSQLの順序関係をクライアントは知りません。

即座に retry できるということは、この操作の失敗の原因がほぼなくなったことを意味します。したがって、デッドロックを引き起こす SQL は、自身のアボートによって必要な排他リソース（TiDB では悲観的ロック）を取得していますので、即座に retry できます。

- https://github.com/pingcap/tidb/blob/899dfe8a7417a545a0c049c7d77876c8eaee5667/pkg/sessiontxn/isolation/repeatable_read.go#L241-L258

### retry 可能 + 待つ必要があるが、待ち時間は不明

エラーメッセージ "server is busy" は、TiKV がリクエストを処理できない場合にTiDBが返すエラーです。ただし、これは一時的なものであり、しばらくすると処理できるようになる可能性があります。ただし、処理できるかどうかはわからないため、一定の時間を待ってからリトライする必要があります。

このような待機してリトライするが、待機時間がわからない場合には、統一されたリトライ戦略があります。タスクの待機時間は、リトライ回数に関連しており、リトライ回数が多いほど待機時間も長くなります。

Backoff などの関数を呼び出すと、一定の時間待機し、最長時間を超えない場合はエラーを返さず、待機のタイミングもこのような関数の呼び出し回数（リトライ回数）に応じて増加します。

- https://github.com/tikv/client-go/blob/e80e9ca1fe66c8cc251491d880f2d2645293c1a5/config/retry/backoff.go#L58-L75

### retry 可能 + 待つ必要があるが、待ち時間は既知

時には、待機する必要がある時間がわかっている場合、意味のないリトライを待機時間が終了する前に避けることができます。

Percolator プロトコルは、2つのフェーズの書き込みを使用して原子性を保証します。TiDBでは、最初のフェーズではロックとデフォルトレコードを書き込み、最初のフェーズが終了した後にプライマリキーのトランザクションをコミットすることが成功と見なされます。その後、第二フェーズでは、すべてのセカンダリを非同期でコミット状態に設定するために、ロックレコードを削除し、書き込みレコードを書き込みます。

書き込み時にロックに遭遇する場合、そのロックが属するトランザクションが成功したか失敗したかを判断する必要があります。成功した場合、そのロックはコミット状態に設定され、失敗した場合はロールバックされます。この場合、トランザクションの結果を確定するために、プライマリキーの状態をクエリする必要があります。このとき、単純にプライマリキーがコミットされたかどうかで状態を判断すると、正常にコミットできるはずのトランザクションがロールバックされ、トランザクションの成功率が影響を受ける可能性があるため、すべてのロックにはTTL（Time To Live）という属性があります。以下の3つの結果があります：

- ロックのTTLが期限切れで、プライマリキーがコミットされている。
- ロックのTTLが期限切れで、プライマリキーが未コミット。
- ロックのTTLが期限切れでなく、プライマリキーがコミットされている。
- ロックのTTLが期限切れでなく、プライマリキーが未コミット。

上記の TTL は、トランザクションのサイズに基づいて推定される、トランザクションの開始時に設定される時間です。TTL の期限切れまでにロックをロールバックしないようにすることで、トランザクションの成功率を確保することができます。もしコーディネータの TiDB に障害が発生した場合、TTL の期限切れ後に他の TiDB がトランザクションの状態を確定します（プライマリキーの状態を確認することで）。

TTL の期限切れ前にまだ有効なロックと未コミットのプライマリキーがある場合、トランザクションの状態を確定するためには、TTL の期限切れまで待機する必要があります。したがって、この場合には最大待機時間が存在します。

- https://github.com/tikv/client-go/blob/e80e9ca1fe66c8cc251491d880f2d2645293c1a5/txnkv/transaction/prewrite.go#L486

### 状態変更後の retry + 即座に実行

Stale read は、データの新鮮度を制限して、トラフィックとデータセンター間のネットワーク遅延を節約するために使用されます。データセンター間で展開される場合によく使用されます。Stale read クエリは、ローカルの TiKV に送信された後、TiKV は自身の安全なタイムスタンプ（safe ts）に基づいて、このクエリリクエストを処理できるかどうかを判断します。処理できない場合は、データが準備されていないことを示すエラーを返します（リーダーノード以外のノードのみがこのエラーを返します）。TiDB はこのエラーを受け取ると、使用中のローカルレプリカのデータが十分に新しくないと判断し、リトライのためにクエリをリーダーに送信します。

- https://github.com/tikv/client-go/blob/e80e9ca1fe66c8cc251491d880f2d2645293c1a5/internal/locate/region_request.go#L2243-L2250

### 別の操作に切り替えが必要 + 即座に実行

任意の候補ピアは Raft 選挙を開始し、自分自身をリーダーにしようとしますが、現在のタームまたはそれ以上のタームのリーダーハートビートを受信すると、選挙を放棄してフォロワーになります。これは、状態変更後のリトライとは異なり、選挙がまだ成功していなくても（タイムアウトしていなくても）、状態の変化により選挙の必要性がなくなったため、この場合はリトライする必要がありません。

- https://github.com/tikv/raft-rs/blob/65a00620d80a89603c3358fd4b90265a692ae767/src/raft.rs#L1374

### retry 不可能 + 即座に実行

もう一つのエラータイプは、retry できないものです。retry するとユーザーに混乱を引き起こすため、このエラーを適切に表現するために適切な手段を使用する必要があります。先述したように、プライマリキーのコミットが成功するかどうかはトランザクションの成功または失敗の原子性の指標ですが、プライマリキーのコミットの成功が確認できない場合、例えば、プライマリキーのコミットのリクエストが RPC エラーを返した場合、その後のいかなるリトライも成功しなくなります。この場合、TiDB は未確定のエラーを上位に投げます。データベース接続層はこのエラーを受け取った後、このトランザクションの接続を切断します。したがって、クライアントにとって、切断された接続のトランザクションの状態は確定できず、人手で介入する必要があります（MySQL には未確定のエラーを表現する他の方法がありません）。しかし、このような状況はほとんど発生しないため、理論上の動作と見なすことができます。

- https://github.com/tikv/client-go/blob/e80e9ca1fe66c8cc251491d880f2d2645293c1a5/txnkv/transaction/2pc.go#L1716-L1722
- https://github.com/pingcap/tidb/blob/899dfe8a7417a545a0c049c7d77876c8eaee5667/pkg/server/conn.go#L1110-L1113

## 結論

開発者として、これらの retry 戦略は非常に複雑だと感じますが、あらゆる状況に完璧に対応するためには、これらの詳細を処理する必要があります。TiDB や他の分散システムはいくつかの類似点を持っているため、読者にとって参考になるかもしれません。