URL短縮サービスのリダイレクト・パスには、たった一つの仕事しかありません。それは、スラッグを宛先URLに解決し、1桁ミリ秒で301を返すことです。それ以外はすべて記帳作業に過ぎません。クリック・アナリティクス、アトリビューション、地理情報のエンリッチメント、不正スコアリング、Webhookのファンアウト――これらはいずれもリクエスト・パスに乗せることはできません。レイテンシの予算がそれを許さないからです。
これが、アナリティクス・パイプラインをリダイレクトのp95 < 15msという目標と共存させるためのエンジニアリング上の工夫です。エッジはクリック・イベントをRedpandaに送信して、あとは忘れます(Fire-and-forget)。別のワーカーである click-ingester が後でそれを拾い、エンリッチメントを行い、バッチでClickHouseに書き込みます。リダイレクト・プロセスがブロックされることは決してありません。アナリティクス・パイプラインがホット・パスに触れることもありません。トレードオフとなるのは耐久性ですが、それは一見するよりも小さなトレードオフです。
ここでの「Fire-and-forget」の本当の意味#
edge-redirectハンドラーは、2層キャッシュから宛先URLを取得した後、Location ヘッダーを送信する前に次の3つのことを行います。
- リクエスト(スラッグ、ワークスペースID、ユーザーエージェント、リファラ、IP、ローカルのGeoLite2-City mmdbからの地理情報、デバイス/ブラウザのパース結果、疑わしいフラグ)から、インメモリの
click.Event構造体を構築する。 - franz-go Kafkaプロデューサーの
producer.Emit(ctx, event)を呼び出す。 HTTP/1.1 301とLocationヘッダーをレスポンス・バッファに書き込む。
プロデューサーの呼び出しは即座に返ります。Redpandaブローカーからのackを待つことはありません。franz-goライブラリはプロセス内でレコードをバッファリングし、バックグラウンドのゴルーチンでディスパッチします。プロダクション・コールバックは後で、リクエスト・ゴルーチンを所有していないワーカープール上で呼び出されます。送信に失敗した場合、コールバックはエラーをログに記録し、イベントは破棄されます。リダイレクトはすでに提供済みです。
func (p *Producer) Emit(ctx context.Context, e Event) {
if p == nil {
return
}
b, err := json.Marshal(e)
if err != nil {
p.log.Warn("click marshal", zap.Error(err))
return
}
rec := &kgo.Record{Topic: p.topic, Value: b}
p.client.Produce(ctx, rec, func(_ *kgo.Record, err error) {
if err != nil && p.log != nil {
p.log.Warn("click produce", zap.Error(err))
}
})
}
これがインターフェースのすべてです。エッジ・プロセス内のリトライ・キューも、同期的なack待ちも、ディスクへのスプールもありません。システムの他の部分との契約はシンプルです。ベストエフォートで送信し、失敗をログに記録し、決してブロックしないことです。
nilレシーバー・ガードにより、Kafkaブローカーなしでローカル開発が可能です。これがないと、すべてのコントリビューターが fasthttp ハンドラーに対してリダイレクト・パスをテストするためだけにRedpandaコンテナを実行する必要があります。
なぜ同期書き込みを選ばなかったのか#
明らかな代替案は、エッジからClickHouseに各クリックを直接書き込むことです。検討はしましたが、以下の3つの複合的な理由により却下しました。
レイテンシ。 フランクフルトのPOPから同じリージョンのClickHouseクラスターへのClickHouse INSERTの往復時間は、ネットワークが空いている時でp50が3〜6ms、負荷がかかっている時でp95が12〜20msです。これはリダイレクト予算のすべてに相当します。これをレスポンス・パスに追加すると、他の問題が発生する前にp95が15msのSLOを超えてしまいます。キャッシュ戦略の投稿で、実際には予算がいかにタイトであるかを説明しています。
バックプレッシャー。 ClickHouseは、1回のINSERTにつき1000〜10000行のバッチ処理を好みます。タイトなループでの単一行のインサートは好みません。MergeTreeエンジンはインサートごとにパート・ファイルを書き込み、バックグラウンド・プロセスがパートをマージします。マルチリージョンのエッジ・フリートから直接書き込むパターンでは、数百万の小さなパートが作成され、マージ・キューが追いつかなくなります。ClickHouseのドキュメントには、「少なくとも1000行のバッチで、1秒に1回以下の頻度でインサートすること」と明記されています。
障害の分離。 ClickHouseクラスターの再起動、ネットワークの瞬断、あるいはレプリカをロックするスロークエリが、リダイレクトの失敗に直結してしまいます。エッジ・プロセスがタイムアウトし始める(p95の悪化)か、クリックをドロップし始める(データ品質の悪化)かのどちらかになります。両者の間にメッセージバスを挟むことで、それぞれが独立して失敗できるようになります。ClickHouseが劣化していてもエッジはリダイレクトを続けられ、1つのPOPがオフラインでもClickHouseはインジェスチョンを続けられます。
Redpandaはこれら3つのプレッシャーをすべて吸収します。Kafkaプロトコル互換なので、franz-go経由で透過的に通信できます。JVM不要のシングルバイナリ構成です。ディスク上にバッファリングするため、トピックの保持期間内であれば、数時間のClickHouseの停止でもイベントを失うことはありません。
click-ingesterワーカー#
click-ingester は、クリック・イベント・トピックのコンシューマー・グループとして動作するGoサービスです。リージョンごとに1つのレプリカ、計3つのリージョンで、スラッグやワークスペースによるシャーディングは行いません。コンシューマー・グループはレプリカが再起動すればリバランスされ、パーティションはRedpandaによって割り当てられます。コンシューマーの仕事はわずかです。
- トピックからフェッチをポーリングする。
- 各レコードのJSONを型定義された
Eventにデコードする。 - ライターのインメモリ・バッファにイベントをプッシュする。
- 時には:Webhookの発火、Klaviyo / Mixpanel / GA4 MPへの転送、アプリ内のライブ・クリック・ストリームへの公開。
ライターは、件数または時間のいずれか早い方でバッチ処理を行います。デフォルトは、1バッチあたり1000イベント、または5秒のフラッシュ間隔です。バッチはClickHouseへの INSERT INTO click_events PrepareBatch呼び出しとして構築され、1つのサーバーサイド・アペンドとしてコミットされます。成功すると、ライターは基盤となるKafkaレコードのオフセットをコミット済みとしてマークします。失敗した場合は何もコミットされず、コンシューマーは次回のポーリング時に最後に成功したオフセットから再度フェッチします。
「フラッシュ後のオフセット」という契約が、耐久性の保証となります。レコードが正常なバッチの一部としてClickHouseに着信するまで、コンシューマーがRedpandaに「このレコードを処理した」と伝えることはありません。コンシュームとフラッシュの間にクラッシュが発生した場合、コンシューマー・グループがリバランスされ、新しい所有者が最後にコミットされたオフセットから再ポーリングし、イベントが再処理されます。再処理が安全なのは、click_events テーブルが合成イベントIDをキーとした ReplacingMergeTree であり、重複したインサートはマージ時に集約されるためです。
不正なメッセージはリトライされません。JSONのデコードに失敗した場合は即座にコミット済みとしてマークされ、コンシューマーがポイズン・レコードでスタックしないようにします。これは小さいながらも現実的なデータ損失の原因となります。その割合はフリート全体で1日あたり数イベント程度であり、影響を受けたイベントはコンシューマーの decode_error_total Prometheusカウンターに表示されます。
耐久性のトレードオフを数字で見る#
Fire-and-forget方式では、一部のイベントを諦めることになります。問題は、それがどれくらいの数であり、ユースケースにおいて重要かどうかです。
90日間の期間でプロダクション環境の損失率を測定しました。その数値は送信イベントの約0.04%、つまり1万クリックにつき約4クリックの損失です。内訳は以下の通りです。
- インフライト・バッファがある状態でのエッジ・プロセスの再起動。 franz-goは、ブローカーにフラッシュする前に数百ミリ秒分のレコードをバッファリングします。デプロイ中のSIGTERMにより、バッファ内にあるものがドロップされる可能性があります。デプロイ・スクリプトは2秒のタイムアウトでバッファをドレインするクリーン・シャットダウンを実行し、ほとんどのケースをカバーしますが、すべてではありません。
- プロデューサーのリトライ・ウィンドウを超えたRedpandaブローカーの利用不能。 franz-goはプロデューサーの失敗をリトライしますが、リトライの予算には上限があります。あるリージョンのRedpandaクラスターが30秒以上異常な状態になると、バッファが溢れ、新しいレコードはプロデューサーのエッジでドロップされます。
- エッジPOPとリージョンのRedpandaクラスター間のネットワーク・パーティション。 上記と同じ効果です。プロデューサーは警告をログに記録し、接続が回復するまでイベントをドロップします。
URL短縮サービスのワークロードにおいて、0.04%の損失は許容範囲内です。クリックは統計的なシグナルであり、金融取引ではありません。コホート分析、コンバージョン・アトリビューション、地理的分布はすべて、その程度の損失率であればサンプル全体で十分に集計可能です。監査要件のある規制業界や、クリック数に連動した課金など、これを許容できないユースケースは、リダイレクト層が直接提供するものではありません。
より高い耐久性を必要とするワークスペース向けに、Fire-and-forgetパスに加えて、すべてのクリックをPostgresに同期的に書き込む個別の監査ログ・モードを提供しています。同期書き込みは、リダイレクトのp95に3〜5msを追加します(オプトイン、デフォルトはオフ)。ClickHouseエクスポート・ガイドには、カウントの照合が必要なコンプライアンス・チーム向けの監査ログの形式が記載されています。
ClickHouse停止時のリプレイ戦略#
プロデューサー側はFire-and-forgetですが、コンシューマー側にはしっかりとしたリプレイ・ストーリーがあります。
ClickHouseが利用できない場合、ライターのフラッシュ呼び出しは失敗します。コンシューマーはポーリングを続けます(franz-goのポーリング・ループはライターのフラッシュ・ループとは独立しています)が、フラッシュが成功しなかったためオフセットはコミットされません。Redpandaの保持期間は72時間に設定されており、これはイベントが期限切れになり始める前に許容できる最大停止時間です。
実際に(18ヶ月の間に有意義な長さのものが3回)停止が発生した際の回復シーケンスは以下の通りです。
- ClickHouseがオンラインに復帰する。
- 次のフラッシュ試行が成功し、オフセットをコミットする。
- コンシューマーが設定されたバッチレートでバックログをドレインして追いつく。1000イベントのバッチと5秒のフラッシュ設定では、コンシューマーはレプリカあたり毎秒約200イベントをドレインでき、3つのレプリカがあれば1分間に約3万6000イベントを処理できます。
click_eventsテーブルのGrafanaダッシュボードにキャッチアップ曲線が表示されます。バックログが解消されるまで、行のインサートレートは高い状態を維持します。
72時間の保持期間は、データ損失なしに数日間のClickHouseの再構築を吸収できるようにサイズ設定されています。プロダクション環境で4時間以上使用したことは一度もありません。コストはRedpandaブローカーのディスク容量ですが、アナリティクス・データを失うことに比べれば微々たるものです。
アーカイブからのリプレイも可能です。Redpandaには、クローズされたセグメントをS3互換のオブジェクト・ストレージに転送する階層化ストレージがあります。設定はしていますが、必要になったことはありません。ホット・リプレイですべてのインシデントをカバーできています。
コンシューマーが他に行っていること#
クリック・インジェスチョンは、単にClickHouseへ書き込むだけではありません。コンシューマーは、クリックを必要とするあらゆるダウンストリーム・システムへの中央ファンアウト・ポイントでもあります。
- Webhookディスパッチャー。 顧客が設定したWebhookは、エッジからではなくコンシューマーから発火します。コンシューマーは、設定されたフィルターに一致するクリックごとにWebhookジョブをキューに入れます。リトライ、署名、配信は
webhook-dispatcherで行われます。 - サーバーサイド・イベント転送。 Klaviyo、Mixpanel、GA4 Measurement Protocol、Meta CAPI。コンシューマーはワークスペースごとの設定キャッシュを保持し、各ワークスペースが連携させた各クリックに対して適切なPOSTを実行します。フォワーダーはベストエフォートであり、小規模なインメモリ・リトライを備えています。永続的な失敗はデッドレター・テーブルに記録されます。
- ライブ・クリック・ストリーム。 アプリ内の「キャンペーンのドロップをライブで見る」ビューは、Redisのpub/subチャネルをサブスクライブしています。コンシューマーは、アクティブなライブ・セッションに一致するクリックごとに、最小限の形状のイベントを発行します。これはパイプラインの中で唯一、同期的(Synchronous-feeling)に感じられる部分ですが、ベストエフォートです。チャネルが混雑している場合はイベントをドロップします。
- ピクセルの発火。 コンバージョン・ピクセル(リターゲティングおよびオフライン・コンバージョン)は、リンクごとの設定に基づいてコンシューマーから発火します。ピクセルの発火は独自の障害ドメインであり、失敗はログに記録されますが、ClickHouseライターにバックプレッシャーをかけることはありません。
これらはすべて、オフセット・コミットの後、次のポーリングの前に実行されます。ピクセルのエンドポイントが遅いと、実効的なコンシューマー・スループットが低下する可能性があります。フォワーダーごとのタイムアウト(1秒のハードキャップ)と、バッチごとの並行数制限(実行中16まで)により、スロー・パスが支配的になるのを防いでいます。
なぜKinesisやキューではなくこの形なのか#
イベント・バスの代替案をいくつか評価しましたが、採用しませんでした。
キューとしてのSQSまたはRabbitMQ。 いずれも、クリック・イベントのボリュームにおいてRedpandaが提供するブローカーあたりのスループットを持っていません。SQSはリクエストごとに課金されるため、高ボリュームのストリームではコストが高くなります。RabbitMQは高密度のトピックにおいてプッシュバックが発生します。
AWS Kinesis。 AWSに常駐しているなら妥当な選択です。しかし、私たちはそうではありません(Hetzner FRA、Hetzner ASH、OVH SGP)。EUファーストのデプロイメントには、セルフホストのKafkaまたはRedpandaが適しています。
プレーンなKafka。 機能します。私たちは、シングルバイナリ、Zookeeper不要、JVMチューニング不要といった運用プロファイルを理由にRedpandaを選びました。ワイヤ・プロトコルは同一であり、franz-goからは区別がつきません。Elidoのセルフホスト環境では、コードを変更せずにApache Kafkaに入れ替えることも可能です。
Confluent Cloudなどのマネージド・サービス。 私たちが望む形でのEU常駐ではありません。リダイレクト層には、同じリージョン内でのメッセージバスのレイテンシが必要です。
この決定の詳細は、リダイレクト層の設定選択の信頼できる情報源であるedge-redirectアーキテクチャ・ページに詳しく記載されています。
次回、別のアプローチをとるとしたら#
Fire-and-forgetパターンは正解です。しかし、実装には設計をコピーする人にとって注意すべき粗削りな部分があります。
シャットダウン時のドレイン。 franz-goの2秒のドレイン・タイムアウトでは、バッファがビジーな状態でのデプロイ中にイベントを失ったことがあります。解決策は、プロセスが終了する前に同期的にフラッシュするSIGTERMフックを導入し、タイムアウトを長く設定し、ブローカーが到達不能な場合はハードキルすることです。
デコード失敗のデッドレター・パス。 ポイズン・レコードをコミット済みとしてマークして進むのはスループットの面では問題ありませんが、可観測性を損ないます。将来の反復では、チームが何が起きているか監査できるように、生のバイトデータとデコードエラーを click_events_decode_failures テーブルに書き込むようにします。
ワークスペースごとのフォワーダー並行数。 現在、すべてのワークスペースのフォワーダーはコンシューマーのグローバル・プールを共有しています。Mixpanelエンドポイントが遅いノイズの多いワークスペースがあると、他のワークスペースが圧迫される可能性があります。ワークスペースごとのキャップを設けるのが明らかな修正案ですが、まだ構築していません。
これらはいずれもプロダクション環境のインシデントを引き起こしてはいません。ADRのバックログに記録し、少しずつ改善していく性質のものです。
関連記事#
- FRA、ASH、SGPからのリダイレクトでp95 < 15msを達成する — この記事と対になるレイテンシ予算の基礎。
- URLリダイレクトのキャッシュ戦略:L1 LRUとL2 Redis — ホット・パス・ストーリーのもう半分。
- クリック・アナリティクスに(Postgresではなく)ClickHouseを使う理由 — このパイプラインの下流での決定。
- スマート・リンクの解説 — クリック・イベントが送信される前に、宛先URLフィールドが実際にどのように解決されるか。
- Terraformとしての短縮リンク — リダイレクト層の設定に関する運用のウォークスルー。
- 12のGoサービスにSentryを配線する — コンシューマーと並行して動作するパニックおよび5xxのキャプチャ・パス。
- アーキテクチャ:
/docs/architecture/edge-redirect。 - 運用ガイド:
/docs/guides/clickhouse-export— クリックごとの耐久性を必要とするワークスペース向けの監査ログ・モード。 - 外部サイト:Redpanda階層化ストレージ、ClickHouse一括インサート、fasthttp。