T18 · リアルタイムシステム
チェーンの変化を、どうリアルタイムでユーザーへ届けるか?
- 練習する能力
- Builder
- 実践
- 購読 API を実装し、30 秒切断してから再接続して、間のイベントをすべて補完できるか確認する。
- AI Lab
- AI に再接続プロトコルを設計させ、弱いネットワークでのパケット損失を自分で測る。
現実の問題
T17 で信頼できる索引器ができました。長時間接続を追加し、ブロック処理ごとにイベントを送り出すと、ローカルでは数秒で届き、快適です。
公開 2 週間後、再現しにくい似た苦情が出ます。
- 「活動一覧に数件足りないが、更新すると現れる。」
- 「処理中のままだが、実際には成功済み。」
- 「画面の残高がエクスプローラーと違う。」
サーバーログは正常です。すべて送信しました。 送信関数は成功し、例外も滞留通知もありません。
共通点は、モバイル回線で短時間切断したことでした。
設計は送ったら忘れる方式でした。その瞬間接続中なら届き、不在なら永久に消えます。ネットワークでなく、設計で失っています。不在への準備が何もないからです。
問いは、30 秒切断して戻ったユーザーへ、その間の出来事をどこから補うかです。
思考実験
3 つの配送方法を考えます。
第一はラジオ放送です。 聞いている人には届き、不在なら番組は消え、何を逃したか誰も知りません。
サーバーが聴衆について何も記憶せず、低費用です。到達は運に依存します。
第二は、番号付き掲示板です。 連番を付けて壁へ残し、戻った人が「812 まで見た」と言えば、813 以降を読めます。
切断は復旧不能の事故から、補完できる追い付きへ変わります。ただし壁を保存し、有限の容量なので古いメッセージはいずれ消します。
第三は、変化したから見に行くよう知らせるだけです。 内容でなく信号を送り、受信者が最新状態を照会します。
一信号の喪失にはほぼ影響がないという利点があります。次の信号でまとめて更新し、冪等で喪失に強い方式です。ただし途中の出来事ではなく、現在の状態しか答えられません。
共通する基準は次です。
リアルタイム配信は、再生可能か、正しい状態へ収束可能にします。どちらもなければネットワークへの賭けです。
第一はどちらもなく、必ず失います。冒頭の苦情は、その三つの表れです。
あなたが決める
チェーンの変化で画面を更新する方法を選びます。
結果を観察する
異なる指標で比較します。
| 遅延 | 切断時の喪失 | サーバー状態費用 | 途中の出来事を答えるか | |
|---|---|---|---|---|
| 定期取得 | 高い | 状態は回復、過程は失う | なし | いいえ |
| 送って忘れる | 最小 | 永久に失う | なし | 可能だが不確実 |
| 番号と補完 | 最小 | 失わない | 高い、窓を保存 | はい |
| 変化信号のみ | 低い | 状態は回復、過程は失う | 非常に低い | いいえ |
遅延と喪失は無関係です。本章の中心は、
リアルタイムは遅延の指標、失わないことは正確性の指標で、別の機構が保証します。
多くは前者だけ実装し、安定回線の開発ではよく見えます。問題はユーザー側で、まれに数件欠ける、調査困難な形で現れます。
前章とつながる第二の結論です。
先端のイベントは取り消され得ますが、配信済みイベントは自分では消えません。
取り消しメッセージを用意するか、T17 の outbox 条件で十分深いものだけ送ります。失わないことと、偽でないことは別の要件で、両方が必要です。
モデルを作る
三要素
イベントを失わないシステムの要素です。
- 単調カーソル
- 再生可能な窓
- 再接続時の補完プロトコル
カーソルの候補:三つ中、二つは誤り
タイムスタンプは誤りです。 同ミリ秒に複数あり、時計変更や複数インスタンスのずれで単調にならず、重複か欠落が出ます。
DB の自動採番も、多くの場合誤りです。 採番とコミットの順序は違います。A が 102、B が 103 を取り、B が先にコミットすると、読み手が 103 へ進んだ後で A がコミットします。102 は永久に読まれません。
エラーのない穴です。使うなら、十分古い行だけ読むか、単一書き手が直列割当する必要があります。
チェーン座標が適切です。 (chain_id, block_number, log_index) は単調で一意、インスタンス間で一致し、再起動後も一致します。ユーザーもチェーンで照合できます。
比較可能な文字列か大整数へ符号化します。
cursor = chainId : blockNumber : logIndex
例: 1 : 00000000020134 : 0007メッセージプロトコル:必須の種類
ライブラリから独立して記述します。
クライアント → サーバー subscribe { topics, cursor } 既読位置付き購読
サーバー → クライアント snapshot { cursor, state } 任意の現在状態
サーバー → クライアント event { cursor, type, payload }
サーバー → クライアント revoke { fromCursor } この位置以降を無効化
サーバー → クライアント reset { reason } 古いカーソル。再購読を要求
双方向 ping / pong 生存確認最後の三種類が漏れやすいものです。
reset は必須です。 有限窓の外まで離れたクライアントに、reset なしで黙って何も補わないと最新だと誤認します。正常に見えるため、切断より悪い状態です。
revoke は再編成の出口です。 未確認を送るなら、直前のイベントの無効化を通知します。実装しないなら、十分深いものだけ送り、深度へ任せます。
ハートビートは双方向です。 一方だけ送ると、片側しか相手の停止を判定できません。両側にタイムアウト判定が必要です。なければ半開接続が残ります。
スナップショットと増分の境界
現在を取得してから購読する順序は誤りです。
誤った順序:
t0 スナップショット取得(cursor=100 まで)
t1 ← cursor=101 が発生するが、誰も監視していない
t2 購読を開始し、102 以降を受け取る
結果:101 が兆候もなく永久に欠ける購読、バッファ、スナップショット、解放の順です。
1. 接続して購読し、サーバーが接続のキューへイベントを入れ始める
2. まだ配信せず、クライアントかサーバーでバッファする
3. スナップショットと、その cursor を取得する
4. バッファの cursor がスナップショットより大きいものだけ順に送り、古いものを捨てる
5. 通常配信へ移る逆の順序が、再接続後の欠落の頻出原因です。ちょうど境界の瞬間にイベントが必要なので、ローカルでは再現しにくいものです。
バックプレッシャー:待つのでなく縮退する
弱い回線、背景タブ、遅い実装で、読み手が遅い場合です。
| 方針 | 結果 |
|---|---|
| 読み終わるまでブロック | 一クライアントが全体を止める |
| 無限バッファ | ピーク時にメモリが増え、プロセスが終了 |
| 有界キュー、満杯で縮退 | 対象へ reset して再取得させ、他へ影響しない |
第三が正解です。
バックプレッシャーの出口は、待機ではなく縮退です。
接続ごとに例えば 1000 件の有界キューを持ち、満杯なら、その接続の全待機を消して reset します。購読、スナップショット、補完を再実行させます。
古い数件だけ消してはいけません。見えない穴を作ります。すべて捨て、やり直しが必要と明示します。
再接続:バックオフにジッターを付ける
即時再試行は、障害サーバーをさらに圧迫します。指数バックオフを使います。
n 回目の待機 = min(基本間隔 × 2 の n 乗, 上限) × 乱数係数
係数は 0.5〜1.5乱数は必須です。なければ再起動後に全クライアントが同秒に戻り、次の再起動でも同じことを繰り返す、周期的な自己攻撃になります。
配送保証:サーバーの正確に一度を追わない
ネットワークで正確に一度は実現できず、次を組み合わせます。
サーバー:少なくとも一度の配送。補完で数件再送することがある
クライアント:cursor で除去。既読以下なら捨てる
組み合わせ:効果として正確に一度T15 の冪等性、T16 の主キー、T17 の再開と、同じ手法の四度目の登場です。一度だけ行うのでなく、複数回でも同じ結果にします。
ファンアウトと複数インスタンス
接続別キューと、トピックから接続への索引を用意し、イベントで検索、配送します。単一なら単純です。
複数なら中間のメッセージ基盤を使えますが、T16 の事実は一つという規則を守ります。基盤は伝送で、事実源ではありません。補完は DB をカーソルで照会します。
基盤の保存方針、分割順序、消費位置は別の故障可能な状態です。速さは基盤へ、正確性は DB へ任せます。
何と呼ぶか
ブラウザーとサーバーが両方自発的に送信できる接続です。
解決するのは通路だけです。切断中の喪失はプロトコルの仕事で、長時間接続だけで信頼できる配信にはなりません。
クライアントが関心のあるトピックを宣言し、サーバーが対象を選びます。
カーソルを含められる必要があります。トピックだけなら、再接続ごとに今から始め、切断中を失います。
受信位置を示す単調増加の位置です。
時計は厳密に単調でなく、自動採番はコミット順で穴ができます。チェーン座標は自然な最適解です。単調、一意、再起動後も有効で、照合可能です。
カーソル付きで戻ったクライアントへ、後続イベントを順に補います。
再生窓の保存が前提です。有限なので、古すぎるという通知が必要です。なければ正常に見える、永続的な欠落状態になります。
消費が生産より遅く、圧力が上流へ伝わることです。
待たず縮退します。 有界キューを消して再取得させます。ブロックは全体を止め、無限バッファはピーク時にプロセスを止めます。
相手の不在を検出する、定期的な相互メッセージです。
双方向が必須です。一方だけでは、消えた相手へ書き続ける半開接続が静かに蓄積し、資源を使い切ります。
実践
すべてローカルとテストネットで読み取りだけです。 署名、送信、支払いはしません。
自動化できる条件は次です。
30 秒切断して再接続した受信列が、
常時接続の対照クライアントと、数量、順序、内容すべてで一致する。T17 の outbox に接続し、チェーン座標を使います。
新しい事実源を作らず、outbox の行を、十分深いという条件で送ります。
(chain_id, block_number, log_index) を比較可能な文字列へ符号化します。正しく選べば、補完は普通の範囲照会です。
メッセージを実装し、reset を省きません。
subscribe、snapshot、event、reset、双方向ハートビートを作ります。深いイベントのみなので、revoke は後でも構いません。
補完照会です。
select * from outbox
where chain_id = $1
and (block_number, log_index) > ($2, $3) -- クライアントのカーソル
and block_number <= $safeHeight
order by block_number, log_index
limit 1000;limit 超過は一気に送らず、区切り、各回で接続の生存を確認します。
境界の順序を正しくします。
購読、バッファ、スナップショット、カーソル別解放の順です。
スナップショット行の前後へ各 1 秒の遅延を足し、その間にイベントを発生させます。クライアントで見える必要があります。 なければ順序が逆で、本番の偶発欠落を生みます。
30 秒切断を試します。
同じトピックの二クライアントを開き、一方を常時接続、他方を 30 秒切ってカーソル付きで戻します。
受信列を比較します。
- 数量は同じか?
- 順序は同じか?
- 各内容は同じか?
- 重複が届いた場合、除去が機能したか?スクリプトへまとめると、システムの回帰テストになります。
遅いクライアントで圧力を作ります。
一件ごとに 1 秒 sleep する読み手へ、1 万件送ります。
先に無限バッファでメモリを見ます。増え続ける様子は、Lab で最も学びのある 3 分です。
次に 1000 件などの有界キューへ変え、満杯なら消して reset します。メモリが安定し、通常のクライアントへ影響しないか確認します。
弱い回線で試します。
OS のネットワーク模擬機能を使い、ヘルプで引数を確認し、遅延、損失、帯域制限を加えます。
各条件で合格スクリプトを実行します。
- 500 ミリ秒の遅延
- 10% のパケット損失
- 非常に低い帯域でバックプレッシャーを発生損失では短すぎる生存期限が現れやすいものです。実際は生きた接続が、何度も停止扱いされます。
再編成を発生させ、偽イベントが漏れないか試します。
T17 の模擬で巻き戻します。
対象高さのイベントは一件も受け取っていないべきです。 安全高に達する前に outbox から消えるためです。
逆も見るなら、安全深度を 0 にして、不存在の送金が配信される様子を見ます。T1 の問題そのものです。
200 接続を同時に開き、サーバーを再起動します。
同秒に戻るなら、バックオフにジッターがありません。
加えて再試行し、時間が分散するか見ます。200 でも差は見え、本番では数万接続です。
AI Lab
3 段階で、第二、第三が本当の試験です。
第 1 段階:
切断再接続でイベントを失わない、チェーンの購読プロトコルを設計してください。
全メッセージ型、フィールド、両側の状態機械を示してください。
第 2 段階:
各場面で、どの部分が対応するか説明してください。
1. 30 秒切断し、12 イベントが発生。
2. 3 時間切断し、再生窓は 1 時間。
3. 読み取りが配信の十分の一の速度で、10 分継続。
4. 再起動で 5 万クライアントが同時切断。
5. スナップショット取得と購読の間にイベントが発生。
6. 配信済み 3 件が、再編成で無効になる。
第 3 段階:
未対応の場面はどれですか?最小変更と代償を説明してください。第一はよく答えます。カーソル購読が頻出題だからです。ただし時計を提案しがちで、複数台、時計変更、同ミリ秒で誤ります。
第二では、2、3、5、6 が頻出の失敗です。reset 漏れ、無限バッファ、逆の順序、再編成の省略です。4 はジッターの有無を明らかにします。
第三は知識でなく、正直さを問います。良い答えは未対応を認め修正し、悪い答えはすべて対応済みと主張します。案を出させるより、自分の案の穴を指摘させる方が有用です。T1 の AI Lab と同じ規則です。
最後は弱い回線で自分が実測します。失敗は現実の条件で現れ、10% 損失での挙動をモデルはもっともらしく推測するだけです。
AI の回答後、自分で必ず検証すること
- 時計や自動採番でなくチェーン座標を使う理由を追問した
- 窓の期限切れを知らせるメッセージがある。なければ静かに欠落が続く
- 生存確認が双方向で、両側がタイムアウトを判定する
- 再接続にジッターがあり、再起動後に全員が同秒へ集中しない
- 購読がスナップショットより先で、境界にイベントを作って検証した
- 有界破棄と reset を使い、ブロックや無限バッファは修正した
- 再編成の取り消しか、深いイベントのみという方針が明確である
- ライブラリ API、フレーム型、終了コードを文書で照合した
- 遅延、損失、帯域制限の三条件で、常時接続群と受信列を比較した
実例
スナップショットの後に購読し、その間を誰も受け取りませんでした。
低確率でもなくならないのが特徴です。活発なユーザーには毎日一、二度、開発では数十ミリ秒の偶然が必要で再現できません。
購読とバッファを先にし、取得後にカーソルで解放します。
弱い回線の送信バッファが満杯で、ブロック書き込みが返りません。
共有の配送ループなら、全員への配信が止まります。
無限バッファなら、ピークでメモリを使い切り、最も止まれない時に停止します。
有界キューと縮退が正解です。
サーバーだけがハートビートを送り、応答を確認しませんでした。
モバイル切り替えや NAT の期限切れでクライアントが消えても、サーバーは生きていると思って書き続け、メモリとトピック索引を消費します。
双方向と、両側のタイムアウトが必要です。 5 分で書けても、省けばいずれ事故になります。
ブロック生成直後に送り、再編成で取引が消えても表示が残りました。
T17 と同じ問題の、通知と画面という二つの出口です。
十分深いものだけ送るか、取り消しを実装します。 何もしなければ不存在の履歴を表示します。
変数を一つ変える
接続が静かに切れ、復帰後も接続済みと思い、死んだ接続へ書き続けます。
前景へ戻ることを再接続のきっかけにし、能動的に再接続してカーソルで補います。TCP の期限を待ちません。
停止は数時間続き得るため、窓切れの reset と再取得は、例外でなく主経路です。
5 接続、5 購読、5 倍の配信があり、カーソルも別々です。
共有背景コンテキストで一接続へまとめるか、費用を受け入れ、ユーザー別接続数を制限します。数十タブで不釣り合いな資源を使わせません。
T15 の二系統の制限の具体例です。
一万人なら毎秒一万照会で、大半は変化なしです。
キャッシュと、変化まで要求を待たせる仕組みを作れば、別形式で長時間接続を再実装したことになります。
ポーリング費用は低遅延要求で急増し、長時間接続費用は接続数に比例します。 新しさでなく遅延と規模で選びます。
接続メモリ、トピック索引、イベントごとの配信が同時に重くなります。
内容を軽くし、信号と取得でキャッシュ可能にし、トピック別に分割し、最後に複雑な配送層を考えます。
配送層は速さ、DB は正確性です。 補完は常に DB のカーソル照会にします。それが百万接続でも保つ経路です。
持ち帰る問い
何を解決しますか?不安定な回線でも、画面をチェーンと一致させます。速さと、失わない正確性は別の目標です。前者だけなら、公開時によく見えてもユーザーが問題を発見します。
誰が支払いますか?接続数と配信量に応じた費用で、メモリ、書き込み、再生窓は制御しにくい項目です。信号のみと深いイベントのみという設計は、正確性に加えて費用のためでもあります。
誰がリスクを負いますか?ユーザーは不完全な画面を見て、それを知れません。代償は数件でなく、画面への信頼を失うことです。毎回エクスプローラーで確認するなら、製品は不要な中間層になります。
章末チェック
通路だけ解決し、不在を扱っていないからです。
送って忘れる方式は状態を持たず、未接続なら永久に逃し、送信関数の成功でログも正常です。
単調カーソル、再生窓、カーソル付き再接続補完の三つが必要です。欠ければ静かに失います。
時計は同時刻、調整、複数台のずれで単調になりません。
自動採番は、割当順とコミット順が違います。103 を読んで進んだ後に 102 がコミットすると、エラーなく永久に逃します。
チェーン座標なら単調、一意、再起動後も有効で、照合もでき、ほぼ無料の最適解です。
購読してバッファし、スナップショットを取得し、それより大きいカーソルだけ解放します。
逆なら数十ミリ秒の間を失い、ローカルでは再現困難で、本番では偶発欠落になります。
取得の前後へ遅延を加え、その間にイベントを作り、届くか試します。
待たずに縮退します。
ブロックは全体を止め、無限バッファはピークでプロセスを止めます。一件も失わないという試みの代償を全員に負わせます。
接続別の有界キューを、満杯ならすべて消して reset を送り、再取得させます。古い数件だけでは、見えない穴を作ります。
出口は待機でなく縮退です。
唯一の正解はありません。次を確認します。
- カーソルは厳密に単調で、再起動後も有効か?
- 窓の長さと、超過時の明確な
resetはあるか? - 境界の順序は正しく、そこでイベントを作って試したか?
- 有界キューと縮退を使い、遅い人が他へ影響しないか?
- 生存確認と期限判定が両側にあるか?
- 再接続にジッターがあるか?
- 確認深度に達したものだけか?未確認なら取り消しはどうするか?
- 30 秒切断比較と、弱い回線の三条件を試したか?
最後が重要です。半分の問いは、実際の回線条件で初めて正直に答えられます。
要点
リアルタイムシステムは再実行できなければならない。ユーザーが切断後に戻っても、その間のイベントを失ってはいけない。