概要

RateHub は Twelvedata、Finnhub、Binance、Whitezzzs などの上流に接続し、業務システムへリアルタイム価格と複数周期のローソク足を提供する共通サービスです。

初版では Connect と Share を分離し、MongoDB を共有の正しい状態として扱いました。第 2 版では JDK 21、Netty、メモリ上の状態、MySQL を用いる単一のリアルタイムサービスへ集約しました。

これは高度な構成から単純な構成への後退ではなく、データの寿命、リアルタイム経路、実際の事業規模に設計を合わせ直す判断でした。

1. 背景と制約

子会社は 2023 年 6 月に設立され、私が 8 月に入社した当時、ほぼ各案件が個別のコードを保守していました。相場接続も複製され、各システムが Twelvedata や Finnhub に別々に接続していました。

制約は三つありました。

  1. 接続コスト:一部プロバイダーの API キーは WebSocket 接続が通常 1〜3 本に制限され、重複接続による費用が月約 2,000 米ドルに達していました。
  2. 最新価格の鮮度:価格は高頻度で変化する短命な状態です。利用側が重視するのは現在値と遅延であり、すべての変化の永続化ではありません。
  3. 履歴の検索:ローソク足には過去の照会、欠損補完、周期別検索が必要で、安定した永続化モデルが求められます。

目的は新たな取引システムを作ることではなく、接続、最新状態、履歴保存を共通の機能にすることでした。

2. 初版:Connect と Share の分離

2.1 構成と責務

JDK 17、Spring Boot、MyBatis Plus、MongoDB、Kafka、XXL-Job、Redis、Redisson、Vue 3 を使用しました。

要素 主な責務 実行形態
Connect WebSocket クライアントとして受信し MongoDB に保存 クラスター
Share WebSocket による価格配信と HTTP によるローソク足照会 クラスター
XXL-Job 分・時・日・週・月・年のローソク足タスク 定期処理
Redis / Redisson 分散ロック、調整、短期の共有状態 共通基盤

主な経路は次のとおりです。

上流 WebSocket → Connect クラスター
    → MongoDB(共有の正しい状態)
        ├── Share が最新価格を取得 → 下流 WebSocket
        └── ローソク足集計 → HTTP 照会・定期補完

最新価格には WIF-USDC のような取引ペアをキーとして使い、ローソク足は各周期で集計し、時間を一意な次元として始値・高値・安値・終値を記録しました。

2.2 解決できたこと

  • Connect と Share を個別に拡張できる。
  • MongoDB の共有状態により、各インスタンスがローカルメモリだけに依存する差異を避けられる。
  • Redisson のロックでタスクの重複実行を防げる。
  • 上流接続、下流配信、履歴照会の責務が明確になる。

問題は、最新状態と履歴として残す事実を経路上で区別していなかったことです。

2.3 Kafka は補助的な経路

初版の Kafka は、リアルタイム中核経路以外のイベントを非同期で伝えるために使い、価格の全件配信バスにはしていませんでした。

代表例は接続監視です。接続サービスが接続状態、切断、監視イベントを送り、管理サービスが MongoDB に保存して管理画面へ配信しました。

接続サービス → Kafka → 管理側コンシューマー
                          ├── MongoDB
                          └── 管理画面 WebSocket

管理側が一時停止しても、上流の受信を同期呼び出しで待たせない構成です。各接続モジュールにはイベント伝達、監視状態同期、一部タスクの非同期化のためのコンシューマー設定もありました。

しかし、価格は依然として次の経路でした。

上流 WebSocket → 接続サービス → MongoDB
    → 管理サービスの定期照会 → 下流 WebSocket

Kafka があっても、最新相場の共有状態を MongoDB に置く構造は変わりません。

3. 運用で見えた問題:永続化とブロッキング呼び出し

3.1 更新による読み書きの増幅

相場は小さな単位で絶えず更新されます。初版では各変化を保存し、Share が読み直して配信しました。

上流の変化 1 件 → DB 書き込み 1 回
    → DB 読み取り 1 回以上 → 下流への配信 1 件以上

DB がリアルタイムの主要経路に入りました。購読のない銘柄でも、ポーリング、集計、定期走査が I/O を生みます。接続数や銘柄数が増えるほど、保存層が本来不要な処理まで引き受けました。

重要な判断は、最新相場は上流から復元できる実行時の状態であり、全更新を残すべき業務上の事実ではないという点でした。

3.2 HTTP 待機によるリソース競合

ローソク足の照会と補完には多数の HTTP 呼び出しが必要です。上流が遅いと、従来のモデルではプラットフォームスレッドが待機で占有されます。

上流の遅延 → スレッドの占有 → プール内で待機
    → タスクの長期化 → 接続・キューの滞留 → 全体の応答悪化

MongoDB や同期 HTTP 自体が使えないのではありません。最新価格のアクセス方式とローソク足の並行処理モデルが、その時点のリソース制約に合わず、DB とスレッドがそれぞれボトルネックになったのです。

4. 第 2 版:単一のリアルタイムサービス

JDK 21、Spring Boot、MyBatis Plus、Netty、MySQL、Redis、Kafka、Vue 3 を採用し、Connect と Share を一つのデプロイ単位へまとめました。Kafka は非同期調整と負荷平準化を担い、最新価格の経路には入れません。

4.1 データの寿命による分類

データ 寿命 保存先 理由
最新価格 短命・上流から復元可能 JVM メモリ 配信経路の保存・再読込を減らす
ローソク足履歴 長期・遡及検索が必要 MySQL 周期集計、補完、安定した照会
調整状態 短期・複数インスタンスで共有 Redis ロック、調整、必要な共有状態
上流 WebSocket → 統一接続層
    ├── JVM の最新価格 → ペア別の購読処理 → Netty 配信
    └── 周期集計 → MySQL へ履歴を一括保存

4.2 主な実装判断

  • 接続の統一:接続管理、再接続、購読関係を業務案件ごとに複製しない。
  • 最新価格をメモリに保存:高頻度更新による DB の読み書き増幅を避ける。
  • 履歴を一括保存:周期集計後に MySQL へ書き込み、最新価格の経路から分離する。
  • 長時間接続に Netty を使用:イベント駆動で多数の接続と非同期配信に対応する。
  • 取引ペア別の購読集約:同じ銘柄の複数購読者で上流購読とメモリ状態を共有する。
  • Kafka でタスクを非同期化:取得、キャッシュ、検証、返却をコンシューマー側で行う。
  • 仮想スレッドで HTTP 待機を処理:JDK 21 により I/O 待ち中のプラットフォームスレッド占有を抑える。

仮想スレッドは遅い API を速くしません。タイムアウト、レート制限、サーキットブレーカー、バックオフ、接続プールの分離は別途必要です。改善するのはスレッド利用効率です。

4.3 Kafka の調整とキュー管理

価格の経路は「上流 WebSocket → JVM メモリ → Netty 配信」のままです。Kafka は待機、滞留、並行数制御が必要な処理を扱います。

  1. WebSocket のローソク足要求:socket-candlestick-topic に送り、コンシューマーが上流取得、キャッシュ、連続性検証、返却を実行します。待機を Netty のイベント処理から切り離します。
  2. 定期タスク:分足、ミニチャート、騰落率などを @Scheduled が発行し、@KafkaListener が処理します。Redis ロックで重複を防ぎ、仮想スレッドで複数銘柄を取得し、Redis 保存と配信を行います。
  3. キュー容量の管理:指定トピックの規模を確認し、閾値超過時に対処します。安全に再要求できるローソク足では、必要に応じてトピックを消去・再作成し、クライアントから再要求します。容量保護であり、流量制限やタイムアウト、再試行の代替ではありません。

Kafka は、待機と突発的な並行処理を長時間接続の処理から隔離する役割です。

5. なぜさらに分割しなかったのか

単一化は内部の責務をなくすのではなく、時間的な結びつきの強い処理を同じデプロイ単位にまとめる判断でした。

受信 → メモリ状態の更新 → 購読の照合 → 下流配信

当時は状態更新と配信が頻繁にやり取りし、状態は上流から復元でき、全要素の独立拡張が必要だという実測もありませんでした。ネットワーク経由と共有ストレージ操作を減らすほうが、事前分割より直接的に複雑さを抑えられました。

接続、価格状態、購読ルーティング、集計、履歴の内部境界は維持し、将来の分割経路を残しました。

6. 振り返りと今後の分割条件

分散要素 → 共有 DB が最新価格の経路を担当
    → 短期状態と履歴を区別 → リアルタイム経路を集約
    → 個別に改善できる内部境界を維持

初版は拡張性と共有状態、第 2 版は低遅延、低 I/O、管理可能な複雑さを優先しました。正解は事業規模、データ寿命、可用性の目標に依存します。

版 Kafka の主な役割
初版 サービス間の接続監視イベントと非同期の状態通知
第 2 版 ローソク足要求、定期補完、関連タスク

経路が短くなった理由は Kafka の置き換えではありません。MongoDB の共有状態を JVM メモリに変え、上流コールバックから直接配信したことです。

今後の分割には、単一インスタンスの接続数・メモリ限界、配信の独立拡張、接続管理と業務リリースの分離、状態冗長化の可用性要件、分割が全体の複雑さを減らすという監視上の根拠が必要です。

設計は制約の変化に応じて検証する仮説です。分割でも集約でも、どの制約を解消し、なぜ今その複雑さを引き受けるのかを説明できることが大切です。