TiDB の Graceful Shutdown とアプリケーションの最適化アドバイス



## 概要
TiDBは、MySQLプロトコルと互換性のある分散型データベースです。分散型データベースと言えば、災害への対応能力が思い浮かびますが、実際に災害が発生する頻度はそれほど高くありません。日常的には、バックアップ、ソフトウェアのアップグレード、パラメータの変更など、「運用ための操作」の機会の方が多いです。本記事では、サーバー再起動が必要な「運用ための操作」シナリオで、アプリケーションへの影響を最小限に抑える方法について詳しく解説します。

## TiDBクラスターの主要コンポーネントの Graceful Shutdown とその影響
tikv-serverは、multi-raftのリーダーを移動後に再起動します。この間、 tidb-server はtikvに対してリトライを行い、アプリケーションのレイテンシーが若干増加することがあります。pd-server（リーダー）も同様に、pdのリーダーをフォロワーに移譲します。これにより、 tidb-server がpdへのリトライを行い、アプリケーションのレイテンシーが若干増加します。 tidb-server 自体には Graceful Shutdown 機能がありますが、TCPサービスを提供するため、実際には状態を持つサービスとなります。本記事では、 tidb-server の Graceful Shutdown について詳しく探求します。

## MySQLプロトコルサーバーの特徴
一部のアプリケーションは、短い接続でMySQLサーバーを使用することがあります。例えばPHPでは、データベースを利用する際に都度TCP接続を開始し、いくつかのクエリを送信した後にTCP接続を閉じることが一般的です。一方で、JavaやGolangのような多くのアプリケーションは、コネクションプールを使用してTCP接続のコストを削減し、パフォーマンスを向上させます。これにより、一定時間内にTCP接続を維持し、アプリケーションに割り当て、使用後に回収し、一定期間後に接続を破棄します。

アプリケーション側のMySQLライブラリは、MySQLプロトコルサーバーが分散型データベースであるとは想定しておらず、サーバー側からTCP接続を切断することも想定していません。ただし、ほとんどの場合、コネクションプール内の接続が生存しているかを確認する手段を提供しています。リモートサーバーがTiDBサーバーの場合、主動的な運用による Graceful Shutdown の際に、サーバー側からTCP接続を切断する必要があります。そのため、クライアント側で自動的にこの状況に対応する処理が必要です。

## TiDBの Graceful Shutdown 
 tidb-server が Graceful Shutdown する際、すぐに全てのトランザクションを中断したり、TCP接続を切断することはありません。 tidb-server のシャットダウンプロセスは以下のように進行します：

1. シグナル受信
2. HTTPポートを非健康状態にする（`/status` [TiDBのヘルスチェックAPI](https://docs.pingcap.com/ja/tidb/stable/tidb-monitoring-api#running-status) が 500 code を返す）
3. [`graceful-wait-before-shutdown`（デフォルトは0）](https://docs.pingcap.com/ja/tidb/stable/tidb-configuration-file#code-graceful-wait-before-shutdown-code-span-class-version-mark-new-in-v5-0-) を待つ
4. TCPポート/HTTPポートの停止
   - この時点から新規接続を拒否
   - 既存の接続はすぐには切断されない
5. [Drain Clients](https://github.com/pingcap/tidb/blob/7c699496219a15474e2fdcf07d0e664540ca8c65/pkg/server/server.go#L942)/待機時間（[`gracefulCloseConnectionsTimeout`は15秒](https://github.com/pingcap/tidb/blob/7c699496219a15474e2fdcf07d0e664540ca8c65/cmd/tidb-server/main.go#L932-L939)）
   - セッションがトランザクションを処理していない場合、サーバー側からTCP接続を切断
   - セッションがトランザクション（暗黙的または明示的）を処理している場合、現在のトランザクションが終了するまで待機後、TCP接続を切断。ただし、待機時間が`gracefulCloseConnectionsTimeout`を超える場合は除く
6. サーバーのクローズ


## 汎用的なアプリケーション最適化原則
分散型データベースのHAの利点を受けるために、アプリケーション開発に対する以下のようなアドバイスがあります：
1. 接続の再接続の実装
2. 冪等性のあるトランザクションのリトライ

第一点に関して、分散型データベースが保証するHAは「新規接続が可能」ということに過ぎず、アプリケーションはサーバー側からのTCP接続の切断を対応する必要があります。これは実現可能で、コストもそれほど高くありません。

第二点に関して、冪等性のあるリトライの実装はコストが高く、アプリケーションは重要なシナリオ（例：決済トランザクション）でのみ冪等性を実装することが一般的です。多くのビジネスシナリオではこれを実現することは難しく、実用的な方法は、問題が発生した際に、アプリケーション開発者がエラーログを確認して、失敗したトランザクションに対する補償を行うことです。

## 実用的なアプリケーション最適化アドバイス
上述の汎用的な提案は、抽象的で理論的なものであり、実際の使用には適していないことが多いです。そこで、TiDBの Graceful Shutdown の特徴に焦点を当て、より実用的で操作可能な提案をいくつか提示します。

## アドバイス1: コネクションプールの最適化

1. コネクションの定期的な解放
   - リモートサーバーがスケールアウトされる可能性があるため、コネクションプール内のコネクションが常に回収されない場合、新たに拡張されたノードが利用されない可能性があります。そのため、コネクションプールのライフタイム属性を数分間に設定し、コネクションに一定の寿命を持たせることをお勧めします。
   - JavaのDBCP2の場合、`MaxConnLifetimeMillis`を設定し、`LogExpiredConnections`を`false`に設定して無用なログの生成を避けることができます。
   - Golangの`database/sql`の場合、`ConnMaxLifetime`を設定しますが、Golangのコネクションリサイクルメカニズムはデフォルト設定ではやや積極的なため、`MaxIdleConns`と`MaxOpenConns`を固定値に設定し、一定数の接続をアイドル状態で維持することが推奨されます。
   - PHP の アプリケーションは PERSISTENT connection を使う事例が少なさそうでこの問題がないですが、もしPERSISTENT connectionを使うなら（例えば `PDO::ATTR_PERSISTENT=>true` ) process の生きる時間を制限した方が良さそうです。（例えば `PHP_FCGI_MAX_REQUESTS` を設定する） 

2. コネクションの有効性の確認
   -  tidb-server の Graceful Shutdown のDrain Clientsフェーズでは、既存のコネクションがサーバー側から切断されます。これは、アプリケーションがコネクションをプールに戻すタイミングと、プールからコネクションを取り出すタイミングで、接続がすでに無効になっている可能性があることを意味します。
   - 一般的なコネクションプールは、validateメソッドまたはMySQLのpingメソッドを提供しています。JavaのDBCP2では、以下の設定が可能です：
     - `ValidationQuery`: "select 1"
     - `ValidationQueryTimeout`: 1 
     - `TestOnBorrow`: true 
     - `TestOnReturn`: true 
   - Golangの`database/sql`はMySQLのpingメソッドを提供していますが、使用方法には注意が必要です。
   `database/sql`の`sql#DB.Ping()`は、コネクションプールである`sql#DB`オブジェクトに属しており、Connection object に属しているわけではありません。そのため、`sql#DB.Ping()`で接続が有効であると確認されても、`sql#DB.Query()`を呼び出す際には異なる接続が使用される可能性があり、Pingの結果が意味をなさない場合があります。接続が安定していることを保証するためには、`sql#Conn`や`sql#Tx`を使用する方法を検討すると良いでしょう。これらは接続オブジェクトの安定性を保証します。
   - PHP に PERSISTENT connection の場合に mysqlnd は適切に mysql ping でコネクションを確認しているので、良さそうです。


## アドバイス2: トランザクションの粒度調整
トランザクションの粒度を下記のように調整した方が良いと思います。

1. トランザクション処理時間を15秒以内に保つ
   - 前述の tidb-server の Graceful Shutdown のDrain Clientsフェーズのタイムアウト時間は15秒です。したがって、トランザクションを15秒以内に制御することで、トランザクションが中断されるリスクを減らすことができます。バッチ処理プログラムの場合は、バッチサイズを調整し、一度のループで15秒以内のトランザクションを処理するようにすると良いでしょう。

2. 適切なシナリオで明示的なトランザクションを使用
   - Webページの処理やAPI内のクエリを`begin`から`commit`までのトランザクションに含めるなど、適切なシナリオで明示的なトランザクションの使用を検討しましょう。これにより、 tidb-server の Graceful Shutdown が発生した場合、そのビジネス処理は1つのトランザクションとして終了を待つため、中断されることがなく、エラーログも減少できます。



## アドバイス3: エラー処理

ほとんどのMySQL開発ライブラリは、シングルノードサーバーを前提に開発されており、接続の開始と終了はクライアントが主導します。サーバーサイドによる接続のクローズは考慮されていないケースが多いです。たとえば、MySQL connector/jでは、`connection.close()`メソッドの呼び出し時に、サーバーとの通信を継続し、`rollback`や`set auto_commit`などのコマンドを送信しようとします。 tidb-server の Graceful Shutdown により、トランザクション終了後にサーバーサイドで接続が閉じられる状況を考慮すると、Javaプログラムでは`connection.close()`の呼び出し時に例外処理を加えることで、プログラムの堅牢性を高めることができます。

また、TiDBは分散型データベースとしてMySQLと異なるいくつかのエラーコードを持っています。これらのエラーコードを適切に処理することも重要です。詳細は以下のリンクで確認できます：
- [TiDB Error Codes](https://docs.pingcap.com/ja/tidb/stable/error-codes)

## アドバイス4: TiDBのヘルスチェックAPIの活用
 tidb-server は、4000ポート（MySQLプロトコル）の他に、10080 HTTPポートも提供しています。そのため、 tidb-server が負荷分散の後に配置されている場合は、 [TiDBのヘルスチェックAPI](https://docs.pingcap.com/ja/tidb/stable/tidb-monitoring-api#running-status) を利用して、より洗練された方法でTiDBの停止を処理することができます。AWS NLBはHTTP APIのヘルスチェックをサポートし、HAProxyもHTTPチェック方法を提供しています。`graceful-wait-before-shutdown`を tidb-server の設定ファイルパラメータに設定することで、AWS NLBやHAProxyがTiDBのバックエンドサービスを Unhealthy 状態に設定するのに十分な時間を確保し、Drain Clientsフェーズ中に新しい接続がその tidb-server に割り当てられるのを避けることができます。

ただし、AWS NLBのデフォルトのヘルスチェックロールの設定は、バックエンドノードが Unhealthy と判断されるまでに比較的長い時間がかかることに注意が必要です。デフォルト設定は以下の通りです：
- `HealthCheckIntervalSeconds`: 30秒
- `HealthCheckTimeoutSeconds`: 10秒
- `UnhealthyThresholdCount`: 2回連続のヘルスチェック失敗
- `HealthyThresholdCount`: 5回連続のヘルスチェック成功
-  Unhealthy と判断されるまでの所要時間：(30秒 + 10秒) x 2 = 80秒
- シャットダウンに必要な時間: 80秒 + `gracefulCloseConnectionsTimeout`(15秒) = 95秒

95秒のシャットダウン時間を実現するためには、以下の制約を突破する必要があります：
- TiUPクラスターでデプロイされたTiDBクラスターの場合、 tidb-server はsystemdにデプロイされており、systemdのデフォルトの最大停止時間は90秒です。
  - `/etc/systemd/system.conf` `DefaultTimeoutStopSec`=90秒
  - 調整が必要な場合は、`tidb-4000.service`のパラメータを変更します。通常は以下の場所にあります：
    - `/etc/systemd/system/tidb-4000.service`
    - 設定例：
      ```
      [Service]
      # for LB Health check timeout
      # graceful-wait-before-shutdown(120) + DrainClient(15) + buffer-time(15)
      TimeoutStopSec=150
      ```
- KubernetesにデプロイされたTiDBクラスターの場合、 tidb-server はpod内にデプロイされています。
  - Kubernetesはpodの最大graceful時間のデフォルト設定があり、デフォルト値は30秒で、オンラインでの変更はできません。
    - `terminationGracePeriodSeconds`のデフォルト値は30秒です。
      - [Kubernetes Pod Lifecycle](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#lifecycle)
    - デプロイ時にこのパラメータを増やす必要があります。

## (未来の)アドバイス5: TiProxyの試用
TiProxy（[GitHub - pingcap/tiproxy](https://github.com/pingcap/tiproxy/)）は、PingCAPがTiDB用に開発したリバースプロキシコンポーネントで、 tidb-server のスケールイン/アウトや Graceful Shutdown のシナリオを最適化するために設計されています。 tidb-server がスケールアウトされた場合、TiProxyは既存の接続を新しいノードにスムーズに移行させ、ノードのバランスを迅速に調整します。 tidb-server がスケールイン、スケールアップダウン、アップグレード、設定変更のために Graceful Shutdown される場合、TiProxyはシャットダウンされるTiDBノード上の接続を他の tidb-server ノードに移行させます。一方、アプリケーションとTiProxy間の接続は安定しており、 tidb-server ノードの変更を認識しません。TiProxyの使用により、本記事で述べた「アドバイス1」「アドバイス2」「アドバイス3」の作業量を大幅に削減することができます。

- ※ 【update】 2024年01月26日現在、TiProxy は tidbcloud.com で Beta 版で試用できます。興味がある方はぜひお問い合わせください。オンプレミス環境の TiProxy(experimental) は、[v7.6.0dmr](https://docs.pingcap.com/tidb/v7.6/tiproxy-overview) からサポートできます。ぜひ試してください。

## まとめ

本文では、TiDB-serverの運用による影響を最小限に抑える方法について主に考察しました。完璧でコストが高い「正しい」方法としては、冪等性のあるリトライがありますが、エラーログの確認コストを減らすためのいくつかの技術を使用することは、実用的かつ現実的なアプローチです。本記事で紹介した方法は、TiDB-serverの運用時におけるアプリケーションの最適化に役立つ参考情報を提供することを目的としています。最終的な適用に際しては、環境に応じた適切な調整と検証が必要です。