リアルタイムな双方向通信を実現するWebSocketは、チャットやダッシュボード、共同編集ツールなどで広く利用されています。しかし、モバイル回線の切り替えや一時的なネットワークの瞬断などにより、接続は頻繁に切断されます。
本記事では、WebSocket接続が切断された際の「指数バックオフを用いた再接続制御」と、切断中に発生した「メッセージの欠損を防ぐバッファリング・再送制御」の実装方法と設計パターンを解説します。
1. 読者が抱える課題とこの記事で分かること
抱える課題
- 単純な再接続(一定間隔でのリトライ)を行うと、サーバー障害時にクライアントからの接続要求が集中し、サーバーが過負荷(DoS状態)に陥る。
- 接続が切れている間に送信しようとしたメッセージが消失してしまう。
- 再接続後に、切断期間中にサーバー側で発生したイベント(メッセージ)をクライアントが受信できない。
この記事で分かること
- サーバー負荷を抑える「指数バックオフ(Exponential Backoff)とジッター(Jitter)」を取り入れた再接続アルゴリズム
- 送信未完了メッセージを一時保存し、再接続後に順序を保証して再送するクライアント側バッファリング
- 切断期間中のサーバー側メッセージを同期するためのシーケンス番号を用いた再同期アプローチ
2. 再接続制御:指数バックオフとジッターの実装
接続が切断された際、即座に、または一定間隔(例: 1秒ごと)で再接続を繰り返すと、サーバーが一時的にダウンしている場合に復旧を妨げる原因になります。
これを防ぐために、再接続の試行間隔を段階的に延ばす「指数バックオフ」と、複数のクライアントが同時にアクセスするのを防ぐためにランダムな揺らぎを加える「ジッター」を組み合わせます。
実装コード例(TypeScript)
以下は、ブラウザ標準の WebSocket API をラップし、指数バックオフとジッターを備えた再接続クラスの実装例です。
interface ReconnectConfig {
initialDelayMs: number; // 初回リトライまでの待ち時間 (例: 1000ms)
maxDelayMs: number; // 最大待ち時間 (例: 30000ms)
factor: number; // 増加倍率 (例: 2)
jitter: boolean; // ジッター(揺らぎ)を適用するかどうか
}
class ResilientWebSocket {
private ws: WebSocket | null = null;
private url: string;
private config: ReconnectConfig;
private attemptCount = 0;
private isExplicitClose = false;
constructor(url: string, config: ReconnectConfig) {
this.url = url;
this.config = config;
}
public connect(): void {
this.isExplicitClose = false;
this.ws = new WebSocket(this.url);
this.ws.onopen = (event) => {
console.log("WebSocket connected");
this.attemptCount = 0; // 接続成功時にリトライカウントをリセット
this.onOpen(event);
};
this.ws.onclose = (event) => {
this.onClose(event);
if (!this.isExplicitClose) {
this.scheduleReconnect();
}
};
this.ws.onerror = (event) => {
console.error("WebSocket error:", event);
};
this.ws.onmessage = (event) => {
this.onMessage(event);
};
}
public close(): void {
this.isExplicitClose = true;
if (this.ws) {
this.ws.close();
}
}
private scheduleReconnect(): void {
const delay = this.calculateDelay(this.attemptCount);
console.log(`Reconnecting in ${delay}ms (attempt: ${this.attemptCount + 1})`);
setTimeout(() => {
this.attemptCount++;
this.connect();
}, delay);
}
private calculateDelay(attempt: number): number {
// 指数バックオフの計算: initialDelay * (factor ^ attempt)
let delay = this.config.initialDelayMs * Math.pow(this.config.factor, attempt);
// 最大値でキャップ
if (delay > this.config.maxDelayMs) {
delay = this.config.maxDelayMs;
}
// ジッター(ランダムな揺らぎ)の追加
if (this.config.jitter) {
// 0 から delay までのランダムな値を採用(Full Jitterアルゴリズムの一例)
delay = Math.random() * delay;
}
return Math.round(delay);
}
// 外部からオーバーライドして利用するコールバック群
protected onOpen(event: Event): void {}
protected onClose(event: CloseEvent): void {}
protected onMessage(event: MessageEvent): void {}
}
※本コードは実装パターンを示すためのサンプルです。実際のプロダクション環境に導入する際は、利用するフレームワークやランタイムの仕様に合わせて動作検証を行ってください。
3. メッセージ欠損対策:2つのアプローチ
接続が切断されている間、メッセージの欠損を防ぐには「クライアントからサーバーへの送信」と「サーバーからクライアントへの送信」の双方で対策が必要です。
アプローチA:クライアント送信メッセージのバッファリング
接続が切れている間にクライアント側で発生した送信要求をキュー(Queue)に溜め、再接続が完了した直後に順次送信します。
良い例と悪い例
-
悪い例(エラーを無視して送信)
// 接続状態を確認せずに送信すると、例外が発生するか、メッセージが虚空に消える function sendMessage(data) { socket.send(JSON.stringify(data)); } -
良い例(バッファリングの実装)
class BufferedWebSocket extends ResilientWebSocket { private sendQueue: string[] = []; public sendBuffered(data: any): void { const payload = JSON.stringify(data); if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(payload); } else { // 接続が確立されていない場合はキューに退避 this.sendQueue.push(payload); console.warn("Socket not open. Message buffered."); } } protected onOpen(event: Event): void { // 再接続成功時にキューに溜まったメッセージを順次送信 while (this.sendQueue.length > 0 && this.ws && this.ws.readyState === WebSocket.OPEN) { const payload = this.sendQueue.shift(); if (payload) { this.ws.send(payload); } } } }
アプローチB:サーバー側メッセージの再同期(シーケンス番号方式)
クライアントの切断中にサーバー側で発生したイベントを漏れなく受信するためには、アプリケーションレイヤーでの「シーケンス番号(Ack/Nack)」の管理が必要です。
処理フロー
-
シーケンス番号の付与: サーバーは送信するすべてのメッセージに、インクリメントされる一意のシーケンス番号(例:
seq: 101)を付与します。 - 受信確認(Ack): クライアントは正常に受信した最新のシーケンス番号を記録します。
-
再接続時の要求: クライアントは再接続時に、最後に受信したシーケンス番号(例:
last_seq: 100)をサーバーに通知します。 -
未送信分の再送: サーバーは通知されたシーケンス番号以降のメッセージ(
101以降)を、サーバー側のキャッシュやデータベースから取得してクライアントに再送します。
4. 設計・導入時のチェックリスト
WebSocketの再接続とメッセージ保証を実務で導入する際は、以下の項目を設計・確認してください。
| チェック項目 | 確認内容 | 対策・判断基準 |
|---|---|---|
| バッファの上限設定 | クライアント側のメモリを圧迫しないか? | キューの最大件数を設定し、溢れた場合は古いメッセージを破棄するかエラーを返す。 |
| メッセージの重複排除 | 再送により同じメッセージが複数回到達しないか? | メッセージごとに一意のID(UUIDなど)を付与し、受信側で重複排除を行う。 |
| 切断検知の仕組み | TCPのハーフオープン状態を検知できるか? | アプリケーションレイヤーで定期的なPing/Pong(ハートビート)を実装する。 |
| サーバー側キャッシュの寿命 | サーバーはどのくらいの期間メッセージを保持するか? | メッセージの保持期間(TTL)や最大件数を定め、メモリやDBの圧迫を防ぐ。 |
| 認証情報の再検証 | 再接続時にトークンが有効期限切れになっていないか? | 再接続のハンドシェイク時に、最新のアクセストークンを再送・検証する。 |
5. 注意点とよくある失敗
ハートビート(Ping/Pong)の欠如による「幽霊接続」
ネットワーク回線が物理的に切断された場合でも、OSやブラウザが即座に切断を検知できず、接続が維持されているように見える現象(ハーフオープン)が発生します。これを防ぐため、数秒〜数十秒間隔でクライアント・サーバー間でPing/Pongを往復させ、応答がない場合はクライアント側から明示的に close() を呼び出して再接続処理をトリガーしてください。
メッセージの順序保証
バッファから再送する際、非同期処理のタイミングによってはメッセージの順序が前後する可能性があります。順序が重要なアプリケーション(チャットや金融取引など)では、クライアント側で送信キューの処理を厳密に直列化(シリアライズ)してください。
6. まとめ
WebSocketを用いたリアルタイム通信において、ネットワークの切断は「例外」ではなく「日常的に発生するイベント」として設計する必要があります。
- 接続維持: 指数バックオフとジッターを用いた再接続アルゴリズムでサーバー負荷を抑えつつ再接続を試みる。
- データ保護: クライアント側での送信バッファリングと、シーケンス番号を用いたサーバー側からの再同期を組み合わせることで、メッセージの欠損を防ぐ。
実装するシステムの要件(許容される遅延、メッセージの重要度、想定接続数)に合わせて、適切なパラメータとバッファサイズを設計してください。