TVerとテレビと私2025(今回は車載多め) 

1、はじめに

この記事は、TVerアドベントカレンダー20日目の記事です。

qiita.com

19日目の記事は@datahaikuninjaさんの「Pub/Sub で Worker Pool パターンを実装し、BigQuery リバース ETL ジョブの同時実行数を制御する」でした。

皆さん、こんにちは、 コネクテッドTV(以下 CTV)領域を中心に担当してきましたが、現在はアライアンス領域にも守備範囲を広げております、 井出と申します。 今年は、KDDIさんとのpovoのキャンペーンなども担当しておりました。

絶賛、「じゃあ、あんたが作ってみろよ」ロス(特に南川さんロス)でふらふらとキーボードを叩いています。

さ、気を取り直して書いていきます。

去年書いた記事はこちらです。

techblog.tver.co.jp

そもそも、CTVってなんやねんという方向けの補足は上にも記載ございますので、是非みてください。 あ、でも1つアップデートがありまして、PlayStation5®︎に対応しました!

TVer、PlayStation®5に対応開始 TVer初、ゲーム機にアプリが登場 | TVer INC.

入社当初の 2023 年から準備してきた案件で、個人的にも念願のリリースです。 ぜひ実際に触っていただき、フィードバックをいただけると嬉しいです。

2、今日は何の話を?

今日は少し視点を変えて、 PC・スマホ・テレビに続く “第4のスクリーン” とも言われる車載におけるエンタメ体験 について触れてみたいと思います。 後半では、TVer の CTV 戦略の現在地についてもご紹介します。 なお、車載領域の話は一般的な業界情報が中心で、TVer に特化した内容ではありませんのでご了承ください。

3、自動車に乗りながら、エンタメを楽しむには

① インフォテインメントシステムとは

インフォテインメントシステムは「Information(情報)」と「Entertainment(娯楽)」を統合した車載向けシステムで、 地図・車両状況の確認から、動画・音楽再生までを一体的に担います。 近年、自動車が 5G や車載 Wi-Fi などの高速・安定通信を備えるようになったことで、 “移動中の時間をどれだけ豊かにできるか” が車内UXの重要テーマ になっています。

② インフォテインメントシステムに関わるプレイヤー

車載エンタメは、以下のような多層構造で成り立っています。

  • 自動車メーカー
  • 車載システムを開発する企業(従来の Tier1 に相当)
  • OS を提供する企業(Google Automotive OS / QNX / 独自 OS など)
  • 動画ブラウザや再生エンジンを提供する企業
  • 動画配信サービス
  • 車載向けのアプリ配信・UX レイヤーを横断提供する企業(例:Xperi)

スマートフォンやテレビに比べ、関係者が多く、エコシステムが複雑なのが特徴です。

③-1 自動車メーカー

特に海外の自動車メーカー(例:BMW など)では、テレビチューナーの搭載が少ないこともあり、 YouTubeNetflix の視聴需要が早期から高かった と言われています。 また、自動車は「映像体験の場」である前に「安全に移動するための機械」であり、 動画視聴には家庭用デバイスにはない 厳格な安全要件 が存在します。

車載に特有の安全要件(一般論)

  • 運転中の動画視聴制限(前席は停車中のみ許可、など)
  • 走行中の操作制限(タッチ操作やメニュー階層の制約)
  • 地域ごとの法規制(欧州 UN-R、北米 FMVSS など)
  • UI の文字サイズや視認性要件
  • クラッシュ時のフェイルセーフ(アプリが固まらないこと)

これらは、自動車メーカーが最も慎重に対応する領域です。

③-2 車載システム開発会社

Bosch、Continental、Harman、Panasonic Automotive、Desay SV などが代表的です。 メーカー単位、時には車種単位で契約し、車載システムの開発を担っています。

③-3 OSベンダー

車載OSには多様な選択肢があります。

  • Google Automotive OS(GAOS)
  • BlackBerry QNX
  • 自動車メーカーが独自に構築した OS

興味深いのは、 「OSはGAOSだが、アプリストアは自動車メーカーの独自運営」 といったケースが多い点です。 また、LG が WebOS ベースの車載プラットフォームを開始するなど、多極化が進んでいます。

LG Vehicle Solution | LG Mobility

③-4 動画ブラウザ

車載で多く使われる再生エンジンは以下です。

しかし、家庭用ブラウザと比べて制約が大きいのが特徴です。 車載ブラウザの主な制約

  • DRM が L3 制限になりがち(高画質出力に影響)
  • メモリ・CPU の制限が厳しい
  • Cookie / localStorage に制約がある場合がある
  • バックグラウンド制御がOSに強く依存する

動画サービス側は、これらを踏まえて再生システムを最適化する必要があります。

③-5 その他横断提供ベンダー

例として Xperi のように、 「アプリ配信管理」「DRM」「レコメンド」「番組メタデータ」など、 エンタメ領域をまとめて自動車メーカーに提供する企業も存在します。

4、動画配信サービスは車載に展開するには?

インフォテインメントシステム上にサービス展開するためには、大きく2つのやり方があります。

①ネイティブアプリでの展開

Google Automotive OS が広がり、 YouTube や Prime Video などがネイティブアプリを提供し始めています。 ただし、車載アプリは 審査が非常に重く、期間も長い のが特徴です。

車載アプリ審査で見られるポイント(一般論)

  • 運転中の操作・視聴の制御が正しいか
  • UI の視認性・フォーカス遷移
  • メモリ使用量・クラッシュ耐性
  • 起動時間・復帰時間
  • 映像/音声が走行中に不具合を起こさないか

家庭用CTVの審査とはレベルが異なります。

②ブラウザベースでの展開

多くのサービスは、車載ブラウザでの展開が主流です。

  • 車載向けに最適化した CTV アプリを流用 するパターン
  • PC/タブレット向け UI を調整 するパターン

など様々な形があります。 ブラウザ対応は柔軟ですが、DRM/帯域/制約に応じたチューニングが必要です。

mobi-times.com

prtimes.jp

5、車載特有のネットワーク事情

車載ネットワークは、家庭用環境より 圧倒的に不安定 です。

ネットワークの特徴としては以下があげられます。

  • トンネル・山間部・高架などで通信が途切れやすい
  • 車載アンテナはスマホより干渉を受けやすい場合がある
  • 車内 Wi-Fi は複数人数で帯域が競合
  • 常に基地局間のハンドオーバーが発生

OTT 側で必要な工夫としては以下が考えられます。

車載では、安定して“止まらず見られる”ことが最重要 になります。

6、車載エンタメの代表的なユースケース

現時点で特に多い利用シーンは、以下の3つです。

  • 後席キッズ視聴
    アニメや子ども向け番組の需要が高く、車載エンタメの王道ともいえる利用ケース。

  • 長距離移動・渋滞時の視聴(一時停車中)

 家族利用が中心で、テレビ的な「ながら視聴」に近い使われ方。

  • 停車中のパーキングエンタメ

 北米・中国を中心に伸びている領域で、休憩中に動画や音楽を楽しむ利用が増加。

日本では 後席の子ども向け利用(1) と 長距離移動中の利用(2) が特に強く、 海外は 停車中のエンタメ(3) の伸びが顕著と言われています。

7、車載エンタメの今後

自動運転時代の車内UXは、未来像として、 以下のような構想が議論されています。

  • 車内全体がディスプレイ化
  • AR HUD に映像や案内を重ねる
  • 視聴しているコンテンツに応じた移動体験の連動
  • 座席ポジションに合わせた画面補正
  • “移動×メディア視聴” を前提とした広告 UX

車内が「第3の居住空間」へ変わっていく中で、 動画サービスの役割はますます重要になっていく領域です。

8、TVerのCTV状況

TVerのCTVデバイス対応状況

スマートテレビで11社、ストリーミングメディアプレイヤーで2社、プロジェクターで3社、セットトップボックスで3社 が昨年時点での状況でしたが、PS5®についに対応し、ゲーム機領域にも参入できました。

この記事をご覧になっている方々のお持ちのデバイスも、もしかしたら、対応しているかもしれません。 ぜひ、TVerをCTVデバイスでお楽しみください!

TVerにおけるCTVの立ち位置のその後

リリース当初 1.9% ほどだった CTV のデバイス別再生割合は、 2023年1月には 31%、現在は 約38% にまで伸長しています。

【TVer】2024年度の動向をまとめた「数字で見るTVer広告」発表 | 株式会社TVerのプレスリリース

TVerを使う3人に1人以上は、CTVを利用して、視聴しているということになります。

また、再生数も、

prtimes.jp

11月に過去最高の1.9億回を突破しました。 再生数も増加しており、2億回に迫る勢いです。

9、おわりに

私自身、仕事を通じてエンドユーザーにより良い体験を届けることを大切にしています。 CTV に加えて幅広い領域に携われるようになり、新たな挑戦に日々やりがいを感じています。 現在、TVerでは一緒に働いてくださる仲間を募集しています。 ご興味をお持ちいただけましたら、ぜひお気軽にお問い合わせください。

herp.careers

最後までお読みいただきありがとうございました。 また別のテーマで記事を書ければと思います。 今後とも TVer をよろしくお願いいたします!

次回は @togoeさんの「デザインシステムを「1から作り直したけど撤退した話」〜TVerデザインシステムV2お蔵入りから学んだこと〜」です。お楽しみに!!!

Pub/Sub で Worker Pool パターンを実装し、BigQuery リバース ETL ジョブの同時実行数を制御する

この記事は TVer Advent Calendar 2025 19 日目の記事です。昨日、18 日目の記事は@k0bya4 さんの、「30分で Spanner の検索とグラフクエリを試す」でした。

はじめに

TVer 広告プロダクト本部 SRE の髙品(@datahaikuninja)です。広告プロダクトを支えるシステム全体の信頼性向上と、広告配信システムのバックエンド開発を担当しています。

私たちは、ユーザーのセグメント(属性)データベースとして Google Cloud の Bigtable を採用しています。広告配信時に Bigtable にアクセスして広告リクエストを送信したユーザーのセグメントを取得し、セグメントターゲティング配信を実現しています。

Bigtable にセグメントデータを書き込む ETL で、BigQuery のリバース ETL という機能を使っているのですが、リバース ETL ジョブの同時実行数が増加して以下の事象が発生することがありました。

  1. BigQuery の計算資源であるスロットの取り合い(スロット競合)
  2. Bigtable への書き込みスロットリング

(1) はセグメントデータ書き込み処理にかかる時間増加につながります。BigQuery は実行中のクエリジョブが均等にスロットを使用できるように調整するフェアスケジューリング1を行いますが、同時に多数のジョブが実行されるとスロットを要求するジョブが増えて、結果として各ジョブの実行完了に時間がかかるようになります。 (2) はリバース ETL ジョブ自体の失敗、という問題を引き起こします。多数のリバース ETL ジョブが大量の書き込みリクエストを Bigtable に送信すると、Bigtable サービスの書き込みキューが溢れてスロットリングを起こし2、BigQuery のリバース ETL ジョブはエラーで終了します。

これらの問題を解決するために ETL をリアーキテクチャして、リバース ETL ジョブの同時実行数を制御できるようにしました。この記事では、ETL のリアーキテクチャと、同時実行数制御のコアである Google Cloud の Pub/Sub を使った Worker Pool パターンの実装を紹介します。

ETL アーキテクチャ

Bigtable にセグメントデータを書き込む ETL は、Cloud Storage のファイル作成イベントにより開始します。リアーキテクチャ前は以下の構成でした。

graph LR
    A[Cloud Storage] -->|ファイル作成イベント| B[Eventarc]
    B -->|ワークフロー起動| C[Workflows]
    C -->|ジョブ作成| D[Cloud Run Jobs]
    D -->|クエリジョブ実行| E[BigQuery]
    E -->|リバースETL実行| F[Bigtable]
  1. Cloud Storage にセグメントデータ (CSV ファイル) がアップロードされると、Eventarc にファイル作成イベント(google.cloud.storage.object.v1.finalized)が送信されます。
  2. Eventarc が Cloud Workflows を起動します。
  3. Workflows が Cloud Run Jobs にジョブを作成します。Workflows ではファイルパスを検証してジョブを作成するかどうか判断し、ファイルパスに応じて適切な環境変数をセットして Cloud Run Jobs の構成をオーバーライドしつつ呼び出します。
  4. Cloud Run Jobs で実行されるアプリケーションが BigQuery にクエリジョブ(リバース ETL ジョブ)を作成します。
  5. BigQuery が 1 でアップロードされた CSV ファイルを外部テーブルとして参照してデータを検証、変換して、Bigtable へリバース ETL を実行します。

BigQuery のリバース ETL を使うと Bigtable への ETL のコア処理をユーザーが自前実装する必要がなく便利です。Cloud Run Jobs で実行されるアプリケーションで同じことをやるとしたら、単にクエリジョブを発行するだけでなく以下の機能を自分で実装しなければいけません。

BigQuery のリバース ETL を使えば上記の処理は GoogleSQL を書くだけで実装できます。処理のほとんどを BigQuery にオフロードしており、Cloud Run インスタンスの CPU とメモリはほとんど使用しないのでリソース管理から解放される点もメリットです。

基本的にはよく動く設計なのですが、Cloud Storage に同時に多数のセグメントデータがアップロードされるとリバース ETL ジョブも同時に多数実行されてしまうので、冒頭で紹介した BigQuery のスロット競合と Bigtable の書き込みスロットリングが発生することがあります。注意深く書いておきますが、これらの問題は常に発生するわけではありません。例えば、CSV ファイルのレコード数が少ない場合は問題が発生する可能性は低いです。1つのリバース ETL ジョブが更新する Bigtable の行数が少ないので、ジョブがすぐに終了して同時実行数が増えないからです。実際、運用初期は問題は発生していませんでした。しかし、現在の私たちのシステムでは数百万から数千万レコードの CSV ファイルが Cloud Storage に多数同時にアップロードされます。経験上、このような場合に BigQuery のスロット競合と Bigtable の書き込みスロットリングに遭遇します。Bigtable のテーブルを数百万行以上更新する重いジョブの処理には比較的時間がかかるので同時実行数が増えてしまっています。

問題を解決するために、以下の構成にリアーキテクチャしました。

graph LR
    %% Nodes
    A["Cloud Storage"]
    B["Eventarc"]
    C["Workflows"]
    D["Pub/Sub"]
    E["GKE Deployment<br>(Worker)"]
    F["BigQuery"]
    G["Bigtable"]

    %% Edges
    A -->|"ファイル作成イベント"| B
    B -->|"ワークフロー起動"| C
    C -->|"メッセージ(ジョブ)をプッシュ"| D
    E -->|"メッセージ(ジョブ)をプル"| D
    E -->|"クエリジョブ実行"| F
    F -->|"リバースETL<br>実行"| G

Workflows は Cloud Run Jobs を呼び出してジョブを作成するのではなく、Pub/Sub にメッセージ(ジョブ)を push します。そして、GKE Deployment として常時起動しているワーカーが Pub/Sub に push されたメッセージを pull し、BigQuery にリバース ETL ジョブを作成します。ワーカーは同時に処理するメッセージ数を制限することで、BigQuery に作成されるリバース ETL ジョブの同時実行数を制御することが可能です。リアーキテクチャにより、BigQuery と Bigtable のリソースを使いすぎないようにジョブを処理できるようになったので、問題が発生しなくなりました。

ちなみに、Bigtable へのリバース ETL を実行するには QUERY の割当があるスロット予約が必要なので、スロット予約を使用するジョブの同時実行数制限によってリバース ETL ジョブの同時実行数がコントロールできないか試したのですが、うまくいきませんでした3。できないのなら、アプリケーションで制御すればよいと考えていたため、サポートへの問い合わせはしていません。

Pub/Sub は「メッセージを生成するサービスを、それらのメッセージを処理するサービスと切り離す、非同期のスケーラブルなメッセージングサービス」と紹介されています4。リアーキテクチャ前から、Google Cloud のリソースレベルではジョブの作成 (Workflows) とジョブの処理 (Cloud Run Jobs) は切り離されていましたが、ジョブの生成と処理はタイミングとしては切り離されておらず、ジョブが生成されると直ちに処理されていました。タイミングが分離されていないことが、ジョブの同時実行数が増えてしまう主要因だったと言ってもよいと思います。リアーキテクチャ後は Pub/Sub が Workflows と GKE の間に挟まり、ジョブの生成と処理のタイミングを切り離すジョブキューの役割を担います。

Pub/Sub をジョブキューとして利用するときに注意したことがあります。ワーカーが Pub/Sub に push されたメッセージ(ジョブ)を直ちに pull して処理してしまうと、BigQuery のリバース ETL ジョブの同時実行数を制限することができません。この記事で扱っている問題を解決するワーカーを開発するときは、Pub/Sub からジョブを pull するタイミングを制御すること、つまり同時に処理するジョブ数を制限することが重要です。

Pub/Sub を使った Worker Pool パターン実装

同時に処理するジョブ数を制限するというテーマは Go の並行処理パターンの議論で扱われます。Go の channel と goroutine を組み合わせて goroutine (ワーカー) 数を制御するやり方は、Worker Pool という名前で知られるパターンです。以下では、 Pub/Sub を利用した Worker Pool 実装を紹介します。Go のサンプルコードと並行処理パターンですが他の言語にも応用できる内容だと思います。

まず、Pub/Sub を使った Worker Pool 開発において知っておく必要がある Pub/Sub サービスの用語5を導入します。

  • パブリッシャー (Publisher)
    • メッセージ送信者
      • この記事では Workflows が Publisher です。ジョブを Pub/Sub メッセージで表現します。
  • トピック (Topic)
    • Publisher がメッセージを送信する宛先
  • サブスクリプション (Subscription)
    • トピックに対して1つ以上存在するメッセージを受信するための登録
  • サブスクライバー (Subscriber)
  • Pub/Sub クライアントライブラリ

つまり、Pub/Sub はトピックを通じてパブリッシャーとサブスクライバーがメッセージをやりとりするサービスです。開発者は公式のクライアントライブラリを使用してサブスクライバーアプリケーションを開発することができます。

次に、サブスクリプションの種類6を導入します。

この記事で紹介する Worker Pool で使用するのは pull サブスクリプションです。以下は、pull サブスクリプションにおけるメッセージの送受信のモデルです。

sequenceDiagram
    participant P as Pub/Sub<br>Pull Subscription
    participant S as Subscriber

    %% 右から左へのリクエスト
    S->>P: StreamingPullRequest
    S->>P: StreamingPullRequest

    %% 左から右へのレスポンス
    P->>S: StreamingPullResponse
    P->>S: StreamingPullResponse

    %% (N)Ack
    S->>P: (N)AckRequest
    P->>S: Empty

Pub/Sub サーバーとサブスクライバー間の通信が、(1) PullRequest (2) PullResponse (3) (N)AckRequest (4) Empty ではないことが分かりづらいかもしれません。Pub/Sub クライアントライブラリにはメッセージを1つずつ受信して確認する、同期 pull モードのための単項 Pull (unary pull) API も存在しますが、 代わりに、以下の理由で図中に示した Streaming Pull API の使用が推奨されています7

  • 単項 Pull API を使うためには低レベルのクライアントライブラリを使用することになり、高度なメッセージ配信制御機能を自分でコーディングしなければならない
  • Streaming Pull API の方がメッセージの配信レイテンシーを最小化し、スループットを最大化できる

今回のリアーキテクチャで実装したワーカーアプリケーションにおいては、低レイテンシーと高スループットは必須ではありませんが、「1 回限りの配信」や「フロー制御」、「リース管理」のような高度な配信制御機能は必要でした。そのため、 Streaming Pull API に対応した高レベルクライアントライブラリを使用していますが、メッセージの受信設定を調整すれば単項 Pull API を使わなくても同時処理メッセージ数制限は可能なので問題ありません。

ようやくですが、サンプルコードの紹介です。Pub/Sub エミュレーターを利用して、同時処理メッセージ数を制限する Pub/Sub サブスクライバーの動作をローカルで試せるサンプルプロジェクトを作成しました。以下に置いてあります。

https://github.com/datahaikuninja/google-cloud-pubsub-worker-pool-sample

Pub/Sub サブスクライバーが同時に処理するメッセージ数を制限するには、Pub/Sub クライアントライブラリを使用するサブスクライバーアプリケーションのメッセージの受信設定を調整します。Go では pubsub.Subscriber 型の構造体が公開する ReceiveSettings.MaxOutstandingMessages フィールドの値になります。以下が構造体の定義です。

https://github.com/googleapis/google-cloud-go/blob/ce28733097806539575dafcd019920f340c83304/pubsub/v2/subscriber.go#L124-L129

サンプルコードでは以下の箇所です。

func NewWorker(ctx context.Context, pubsub *pubsub.Client, subscription string) *Worker {
    subscriber := pubsub.Subscriber(subscription)
    subscriber.ReceiveSettings.MaxOutstandingMessages = maxOutstanding // this line

    // enable lines below if you needed
    // subscriber.ReceiveSettings.MinDurationPerAckExtension = 600 * time.Second
    // subscriber.ReceiveSettings.MaxExtension = 3600 * time.Second

    return &Worker{
        subscriber: subscriber,
    }
}

MaxOutstandingMessages は最大未処理メッセージ数という意味ですから、Pub/Sub サブスクライバーが Ack (確認・処理)していないメッセージ数が MaxOutstandingMessages に達すると Pub/Sub サブスクライバーは Pub/Sub サーバーからのメッセージ取得を一時停止します。

Pub/Sub サブスクライバーSubscriber.Receive() で Pub/Sub サーバーからメッセージを取得し、このメソッドの第 2 引数で渡されたコールバック関数を実行します。以下がクライアントライブラリ側の実装です。

https://github.com/googleapis/google-cloud-go/blob/main/pubsub/v2/subscriber.go#L197

Subscriber.Receive() はメッセージを受信するたびにコールバック関数を新しい goroutine で実行するので、受信して処理中のメッセージ数 = コールバック関数を実行する goroutine 数の関係です8MaxOutstandingMessages に達してメッセージ受信を一時停止すると、コールバック関数を実行する新しい goroutine は起動しません。これが、Pub/Sub クライアントの同時処理メッセージ数制限の仕組みであり、Pub/Sub を使った Worker Pool のコアです。

サンプルコードでは Worker.Run()Subscriber.Receive() を包んでワーカーを表現しています。Subscriber.Receive() の第 2 引数に渡したコールバック関数を実行する goroutine こそワーカーの実体と言えるものであり、コールバック関数がワーカーに処理させたいビジネスロジックです。

func (w *Worker) Run(ctx context.Context) error {
    var err error
    var wg sync.WaitGroup
    var workerCnt atomic.Uint64 // a number of workers

    err = w.subscriber.Receive(ctx, func(ctx context.Context, m *pubsub.Message) {
        wg.Add(1)
        workerCnt.Add(1)
        log.Printf("W: subscriber.Receive spawn worker. worker count: %v", workerCnt.Load())
        log.Printf("W: worker pulled messageID: %s", m.ID)

        defer func() {
            // decrement counter
            // ref: https://pkg.go.dev/sync/atomic#AddUint64
            workerCnt.Add(^uint64(0))
            log.Printf("W: worker exiting. worker count: %v", workerCnt.Load())
            wg.Done()
        }()

        err := w.processJob(m)
        if err != nil {
            log.Printf("W: worker failed to process messageID: %s, jobID: %s, error: %v", m.ID, m.Attributes["jobID"], err)
            m.Nack()
            return

        }
        m.Ack()
        log.Printf("W: worker acked messageID: %s, jobID: %s", m.ID, m.Attributes["jobID"])
    })
    if err != nil && !errors.Is(err, context.Canceled) {
        return fmt.Errorf("W: unexpected error in subscriber.Receive: %v", err)
    } else {
        log.Println("W: subscriber.Receive exited by context.Canceled")
    }

    wg.Wait()
    log.Printf("W: worker count: %v", workerCnt.Load())
    log.Println("W: all worker finished, exiting")
    return nil
}

サンプルプロジェクトを動かしてみると、W: subscriber.Receive spawn worker. worker count: %v のログが、コールバック関数を実行する goroutine の数(ワーカーの数)が MaxOutstandingMessages を超えないことを示しています。

2025/12/15 10:31:15 W: starting subscriber.Receive
2025/12/15 10:31:16 P: pushed message
2025/12/15 10:31:16 W: subscriber.Receive spawn worker. worker count: 1
2025/12/15 10:31:16 W: worker pulled messageID: 1
2025/12/15 10:31:16 W: processing messageID: 1, jobID: 31bd5a71-7c17-46a4-bf81-8d6dc0983fae
2025/12/15 10:31:16 W: read message data: heavy job!
2025/12/15 10:31:17 P: pushed message
2025/12/15 10:31:17 W: subscriber.Receive spawn worker. worker count: 2
2025/12/15 10:31:17 W: worker pulled messageID: 2
2025/12/15 10:31:17 W: processing messageID: 2, jobID: 180ceff8-5fcd-455b-a4c5-e787380d6b95
2025/12/15 10:31:17 W: read message data: heavy job!
2025/12/15 10:31:18 P: pushed message
2025/12/15 10:31:18 W: subscriber.Receive spawn worker. worker count: 3
2025/12/15 10:31:18 W: worker pulled messageID: 3
2025/12/15 10:31:18 W: processing messageID: 3, jobID: 9739e63b-d0e2-4eca-8407-427c0507cbe3
2025/12/15 10:31:18 W: read message data: heavy job!
2025/12/15 10:31:19 P: pushed message
2025/12/15 10:31:20 P: pushed message
2025/12/15 10:31:21 P: pushed message
2025/12/15 10:31:21 W: worker acked messageID: 1, jobID: 31bd5a71-7c17-46a4-bf81-8d6dc0983fae
2025/12/15 10:31:21 W: worker exiting. worker count: 2
2025/12/15 10:31:21 W: subscriber.Receive spawn worker. worker count: 3
2025/12/15 10:31:21 W: worker pulled messageID: 4
2025/12/15 10:31:21 W: processing messageID: 4, jobID: d9441820-cd5b-45ac-b103-a513d41269b0
2025/12/15 10:31:21 W: read message data: heavy job!
2025/12/15 10:31:22 P: pushed message
2025/12/15 10:31:22 W: worker acked messageID: 2, jobID: 180ceff8-5fcd-455b-a4c5-e787380d6b95
2025/12/15 10:31:22 W: worker exiting. worker count: 2
2025/12/15 10:31:22 W: subscriber.Receive spawn worker. worker count: 3
2025/12/15 10:31:22 W: worker pulled messageID: 5
2025/12/15 10:31:22 W: processing messageID: 5, jobID: 5142303e-5c84-40e0-8f82-660f2695fcea
2025/12/15 10:31:22 W: read message data: heavy job!
2025/12/15 10:31:23 P: pushed message
2025/12/15 10:31:23 W: worker acked messageID: 3, jobID: 9739e63b-d0e2-4eca-8407-427c0507cbe3
2025/12/15 10:31:23 W: worker exiting. worker count: 2

以上が、Pub/Sub を使った Worker Pool の実装でした。

Pub/Sub を使わない他の Worker Pool 実装も紹介して、Pub/Sub を使う Worker Pool のメリットを考えてみます。

Go の Worker Pool パターンのコンセプトは、固定数の goroutine が channel から job や task を取得して処理する、という構造です。

Worker Pool の基本形は Go By Examples: Worker Pools で紹介されています。このサンプルコードでは、3 つの Worker が jobs channel からジョブを取り出して処理します。Worker の数を超える job が channel に投入されますが、総 job 数と jobs channel のバッファサイズが一致するように書いているのでブロッキングは起こりません。バッファサイズを超える job を送信すると送信側がブロックされます。

より高度な Worker Pool が Dispatcher-Worker です。Handling 1 Million Requests per Minute with Golang という Blog で紹介された実装が有名だと思います。 Dispatcher-Worker では、Worker は job を直接取得はせず、Dispatcher が空いている Worker に job を割り振ります。特徴的なのは、各ワーカーが専用のジョブ受信 channel を持ち、ジョブを処理し終えると自身のジョブ受信 channel をワーカープールの channel に向けて送信し、Dispatcher は ワーカープールの channel が受信した各ワーカーのジョブ受信 channel に job を送信する、という点です。ジョブ受信 channel をやりとりするワーカープール channel という発想が Dispatcher-Worker のコアだと思います。Dispatcher-Worker のサンプルコードを説明すると記事が長くなるので引用は控えます。私が、コアであると述べた特徴を意識して WorkerPool chan chan Job の初期化と利用方法に注目して参照元 Blog を読んでもらえたらと思います。翻訳記事もあります。

シンプルな Worker Pool, Dispatcher-Worker と Pub/Sub を使う Worker Pools の違いは主に2つあると思います。

  • Pub/Sub トピックを job queue として利用することによるメリットがある
    • キューサイズを事前に考える必要がない
    • ジョブ消失リスクが低い
  • Pub/Sub クライアントライブラリの Subscriber が Dispatcher 相当の機能を備えているので、開発者はジョブを処理するビジネスロジックの実装に集中できる

Go の channel で job queue をつくるときは、channel のバッファサイズと、アプリケーションシャットダウンによるジョブ消失について考慮しておくべきだと思います。まず、バッファサイズは十分な大きさにしておかないとバッファが溢れて channel への送信がブロックされます。Dispatcher-Worker のように外部からジョブを受け取る窓口 (job collector) を作るなら channel の長さを調べてバッファが埋まっていたら呼び出し元にリトライを促したりすることはできますが、妥当なバッファサイズはいくつなのか決めるのは個人的には悩ましいです。事実上溢れないバッファサイズにすることは可能ですが、無駄に多くのメモリを確保することになるので非効率です。そして、オンメモリの job queue の中身は想定外のアプリケーションシャットダウンによって消失するリスクがあります。SIGTERM シグナルをフックしてジョブをどこかに退避したり、job の新規受付を停止して job queue が空になるまでシャットダウンを遅らせるといった Graceful Shutdown のアイディアは浮かびますが、OOM による SIGKILL や基盤の障害でいきなりプロセスが死んでしまう場合には無力ですし、Graceful Shutdown するためにプロセスの終了を遅延させるにも限度があります。

Pub/Sub を job queue として利用するときは事前にバッファサイズを設計する必要はありません。その代わりに Pub/Sub の割り当て (quota)9 を超えないか事前に確認します。相当大規模なシステムでない限り Pub/Sub の push/pull スループットの割り当てを超えてしまうことはないので、設計段階で割り当てを心配しすぎる必要はないです。私たちは、Worker Pool の他に、広告配信サーバーが記録する広告ログの送信先としても Pub/Sub を利用しており、BigQuery サブスクリプションでログを非同期で BigQuery に取り込んでいます。広告配信サーバーは 1 日に億単位の広告リクエストを処理して配信ログを Pub/Sub へ push していますが、Pub/Sub の割当を超えてエラーが発生したことはありません。

ジョブ消失については、バッファ溢れから割り当て超過のように考慮点が変わるのではなく、Pub/Sub を使うことでリスクを下げることができます。Pub/Sub に push された ジョブは、基本的にはサブスクライバーが確認応答期限までに Ack 応答をサーバーに返さない場合に再配信されます。この仕組みを利用すれば、想定外のシャットダウンが起きてもアプリケーションが再起動したときに Pub/Sub から未処理ジョブの再配信を受けることができます。

Pub/Sub クライアントライブラリに Dispatcher 相当のワーカー管理を任せられることも、目立たないですが確かなメリットだと思います。サンプルプロジェクトのコードを見てもらえれば分かるように、Subscriber.Receive() に渡すコールバック関数を書くだけで堅牢な Worker Pool を実装できるので、本質的なビジネスロジックの実装に集中することができます。

なお、私はこの記事において、どんな場合でも Pub/Sub を使った Worker Pool 実装が他に優っていると言いたいのではありません。個人的には、できる限りマネージドサービスを組み合わせてアーキテクチャとアプリケーションを設計するのが好みであり、Pub/Sub を使うと比較的楽にプロダクションで使える Worker Pool を実装できた、という事例を紹介したいと思ってこの記事を書いています。goroutine と channel を使ったシンプルな実装で十分な場合もあるでしょうし、制約条件で Pub/Sub が合わない場合はマネージドサービスを諦めて自前実装する場合もあると思います。個人の価値判断は含まれていますが、記事の目的は設計パターンの紹介であり、異なるパターンの優劣を付けることではありません。

おわりに

この記事では、Bigtable にデータを書き込む ETL に Pub/Sub を使った Worker Pool を組み込むことで、BigQuery のリバース ETL ジョブの同時実行数を制限し、ETL の信頼性を向上させる方法を紹介しました。記事を書いてみて気付いたのは、Pub/Sub を使った非同期処理パターンの汎用性です。BigQuery のリバース ETL は、Bigtable だけでなく Spanner にも対応しているので、この記事で紹介したアーキテクチャは Spanner にも応用できそうですし、BigQuery のリバース ETL に限らず、データベースの更新等の重いジョブの同時実行数を制御したいという場面では役立ちそうです。低レイテンシー・高スループットが求められる大量の非同期ジョブの処理においても、もちろん有効なアーキテクチャだと思います。

記事で扱う範囲が広がってしまうので Worker Pool の実行環境については敢えて触れませんでした。一言二言だけ書いておくと、コンテナアプリケーションとして Worker Pool をデプロイするなら 2025 年 12 月時点では GKE と Cloud Run Worker Pools の2つの選択肢があります。従来、GKE を使っていないユーザーは常時起動ワーカーを実行するためのベストな選択肢がありませんでした。GKE を使っていないユーザーにとっては、常時起動ワーカーを実行するためだけに GKE を採用するのはオーバーエンジニアリングだと思いますし、Cloud Run Services では制約が多く、Cloud Run Jobs ではそもそも不適切な使用方法になってしまうという悩みがありました10。しかし、Cloud Run Worker Pools の登場により Cloud Run 中心のアーキテクチャを変更することなく、常時起動ワーカーを実行することが可能になっています。2025 年 12 月時点では、Cloud Run Worker Pools はまだプレビューですが、GA が楽しみですね。

長くなってしまいましたが、ここまで読んでいただきありがとうございます。この記事が、読者の方の良き設計の参考になると嬉しいです。明日は @iideshoさんの「TVerとテレビと私(今回は車載多め)2025」です。


  1. https://docs.cloud.google.com/bigquery/docs/slots?hl=ja#fair_scheduling_in_bigquery
  2. 書き込みキューのスロットリングは 2025 年 12 月時点では undocumented なリソース制限です。https://docs.cloud.google.com/bigtable/quotas には記載されていません。サポートに問い合わせて制限が存在することを確認しました。Apache Beam の GitHub リポジトリにも同様の問題が報告されていたため、スロットリングは他の Google Cloud プロジェクトでも発生が確認されているようです。https://github.com/apache/beam/issues/34760
  3. Bigtable へのリバース ETL の制約については、https://docs.cloud.google.com/bigquery/docs/export-to-bigtable?hl=ja#limitations を参照。スロット予約を使用するジョブの同時実行数制限については、 https://docs.cloud.google.com/bigquery/docs/query-queues?hl=ja#set_the_maximum_concurrency_target_for_a_reservation を参照。
  4. https://docs.cloud.google.com/pubsub/docs/overview?hl=ja
  5. https://docs.cloud.google.com/pubsub/architecture?hl=ja#glossary
  6. https://docs.cloud.google.com/pubsub/docs/subscription-overview?hl=ja#push_pull
  7. https://docs.cloud.google.com/pubsub/docs/pull?hl=ja#high_client_library
  8. "Receive calls f concurrently from multiple goroutines." ref: https://pkg.go.dev/cloud.google.com/go/pubsub#Subscription.Receive
  9. https://docs.cloud.google.com/pubsub/quotas?hl=ja#quotas
  10. こちらの発表資料を参考にさせていただきました。https://speakerdeck.com/iselegant/deep-dive-cloud-run-worker-pools

30分で Spanner の検索とグラフクエリを試す

この記事は TVer Advent Calendar 2025 18日目の記事です。

はじめに

Backend Enabling Team の小林 (@k0bya4) です。

TVer のサービスユーザー向けメインバックエンドシステムは AWS で構築していますが、サブシステムとして全文検索・ベクトル検索・グラフクエリなどの機能を、バックエンドエンジニアが RDB の知識を活かしながら運用できないか検証しています。Spanner はこれらの機能を 1 つのデータベースで提供しており、今回は Google Cloud が提供するサンプルデータセットを使って、30 分ほどでクイックにその使用感を確認してみました。

Spanner Graph が 2025年1月に GA となりました。Spanner は従来からリレーショナルデータベースとしての機能を持っていますが、グラフクエリ(GQL)、全文検索、ベクトル検索といった機能が追加され、1つのデータベースで多様なワークロードに対応できるようになっています。

本記事では、Retail サンプルデータセットを使って、全文検索、ベクトル検索、グラフクエリによるレコメンドを実際に動かしてみます。サンプルデータセットを使えば、スキーマ設計やデータ投入なしですぐに試すことができます。

環境構築

本記事では Google Cloud が提供する Retail サンプルデータセットを使用します。これは EC サイトを模したデータで、ユーザー、商品、注文などのテーブルがあらかじめ用意されています。

Retail サンプルには Spanner Graph、全文検索、ベクトル検索を実行するための構文とサンプルクエリが含まれているため、Enterprise エディション以上のインスタンスが必要です。

インスタンスの作成からサンプルデータセットの投入まで、こちらの記事が参考になります。構築を試す際には参照してください。

本記事では retail データベースが作成済みの前提で進めます。

Retail データセットの概要

Retail サンプルは EC サイトを模したデータセットで、以下のテーブルで構成されています。

テーブル名 役割 グラフでの扱い
Users ユーザー情報 Node
Products 商品情報 Node
Orders 注文情報 Node
Payments 支払い情報 Node
Addresses 住所情報 Node
OrderItems 注文と商品の関連 Edge(Orders → Products)
ShoppingCarts カートと商品の関連 Edge(Users → Products)

全文検索の設定

Spanner の全文検索には複数の種類があります。Retail サンプルでは TOKENIZE_FULLTEXT を使った基本的な全文検索が設定されています。TOKENIZE_FULLTEXT自然言語テキストを単語単位でトークン化し、キーワード検索に適しています。

Products テーブルには商品名をトークン化した列が定義されています。

Name_Tokens TOKENLIST AS (TOKENIZE_FULLTEXT(Name)) HIDDEN

この列に対して Search Index が作成されており、SEARCH() 関数でキーワード検索ができます。全文検索の詳細は公式ドキュメントを参照してください。

ベクトル類似度検索の設定

Products テーブルには商品の埋め込み表現のベクトルが格納されています。埋め込み表現は Vertex AI の Embedding API などで生成でき、テキストや画像の意味的な特徴を数値ベクトルとして表現したものです。

ProductEmbedding ARRAY<FLOAT64>(vector_length=>768)

大量のベクトル同士の類似度計算を高速化するため、Vector Index が設定されています。

CREATE VECTOR INDEX ProductVectorIndex ON Products(ProductEmbedding)
  WHERE ProductEmbedding IS NOT NULL
  OPTIONS (distance_type = 'COSINE');

Property Graph の定義

グラフクエリを実行するには、テーブルを Node や Edge として定義した Property Graph が必要です。Retail サンプルでは以下のように定義されています。

CREATE PROPERTY GRAPH ECommerceGraph
  NODE TABLES (
    Users,
    Products,
    Orders,
    Payments,
    Addresses
  )
  EDGE TABLES (
    OrderItems
      SOURCE KEY (OrderID) REFERENCES Orders
      DESTINATION KEY (ProductID) REFERENCES Products,
    ShoppingCarts
      SOURCE KEY (UserID) REFERENCES Users
      DESTINATION KEY (ProductID) REFERENCES Products
  );

Node は既存のテーブルをそのまま指定します。Edge では SOURCE KEYDESTINATION KEY で接続元・接続先の Node を指定します。

OrderItems Edge は「どの注文にどの商品が含まれるか」、ShoppingCarts Edge は「どのユーザーがどの商品をカートに入れているか」という関係を表現しています。

Property Graph の詳細は公式ドキュメントを参照してください。

全文検索を試す

全文検索を使って商品名からキーワード検索してみましょう。Retail サンプルには以下のようなクエリが用意されています。

SELECT ProductID, Name, PriceUSD
FROM Products
WHERE SEARCH(Name_Tokens, 'phone')
ORDER BY PriceUSD DESC
LIMIT 5;

SEARCH() 関数の第一引数にはトークン化された列(Name_Tokens)、第二引数には検索キーワードを指定します。このクエリは「phone」を含む商品を価格の高い順に 5 件取得します。

実行結果:

ProductID Name PriceUSD
201414 Smith-Allen Mobile Phone 1086.15
197251 Lutz-Howard Phone 974.94
301629 Garza-Rogers Cell Phone Plus 69 908.03
581741 Brock, Phone 830.75
377370 Adkins, Cell Phone Plus 58 734.93

AND / OR 検索

複数キーワードでの検索も可能です。スペース区切りで AND 検索、OR 演算子で OR 検索になります。

AND 検索(両方を含む):

SELECT ProductID, Name, PriceUSD
FROM Products
WHERE SEARCH(Name_Tokens, 'cell phone')
ORDER BY PriceUSD DESC
LIMIT 5;
ProductID Name PriceUSD
301629 Garza-Rogers Cell Phone Plus 69 908.03
377370 Adkins, Cell Phone Plus 58 734.93
865179 Jones, Cell Phone Pro 43 709.21
407419 Farley, Cell Phone 625.71
988662 Johnson Cell Phone Plus 28 395.94

OR 検索(いずれかを含む):

SELECT ProductID, Name, PriceUSD
FROM Products
WHERE SEARCH(Name_Tokens, 'cell OR phone')
ORDER BY PriceUSD DESC
LIMIT 5;
ProductID Name PriceUSD
201414 Smith-Allen Mobile Phone 1086.15
197251 Lutz-Howard Phone 974.94
301629 Garza-Rogers Cell Phone Plus 69 908.03
581741 Brock, Phone 830.75
377370 Adkins, Cell Phone Plus 58 734.93

AND 検索では「Cell Phone」を両方含む商品のみ、OR 検索では「Mobile Phone」のように片方のみ含む商品も結果に含まれています。

日本語テキストの検索

全文検索は日本語にも対応しています。テストデータで確認してみましょう。

INSERT INTO Products (ProductID, Name, Description, PriceUSD)
VALUES (99999901, 'スマートフォン Pro Max', 'Latest smartphone', 999.99);

SELECT ProductID, Name FROM Products
WHERE SEARCH(Name_Tokens, 'スマートフォン');
ProductID Name
99999901 スマートフォン Pro Max

スマートフォン」ではヒットしますが、「スマート」や「フォン」ではヒットしませんでした。公式ドキュメントによると、日本語は自動でセグメンテーションされます。分割の粒度は内部の辞書に依存するため、検索対象のテキストに応じてどのようなキーワードでヒットするかを事前に確認しておくと良いでしょう。

部分文字列での検索が必要な場合は、TOKENIZE_NGRAMSTOKENIZE_SUBSTRING の使用を検討してください。TOKENIZE_NGRAMS は文字列を N 文字単位で分割してトークン化するため、「スマート」のような部分文字列でもヒットするようになります。

トークンの分割結果は DEBUG_TOKENLIST() 関数で確認できます。

SELECT Name, DEBUG_TOKENLIST(Name_Tokens) AS tokens
FROM Products
WHERE ProductID = 99999901;
Name tokens
スマートフォン Pro Max スマートフォン(boundary), pro, max(end_boundary)

スマートフォン」が1つのトークンとして扱われているため、部分文字列の「スマート」や「フォン」ではヒットしません。

ベクトル類似度検索を試す

ベクトル類似度検索を使って、ある商品に似た商品を検索してみましょう。

まず、埋め込み表現のベクトルを持つ商品を確認します。

SELECT ProductID, Name, ARRAY_LENGTH(ProductEmbedding) AS embedding_dim
FROM Products
WHERE ProductID = 123;
ProductID Name embedding_dim
123 High-end Smartphone Model X 768

ProductID 123 は「High-end Smartphone Model X」で、768次元の埋め込みベクトルを持っています。この商品を基準に、他の商品とのコサイン距離を計算します。COSINE_DISTANCE() はコサイン距離を返し、値が小さいほど類似度が高いことを示します。

WITH ReferenceProduct AS (
  SELECT ProductEmbedding FROM Products WHERE ProductID = 123
)
SELECT p.ProductID, p.Name, COSINE_DISTANCE(rp.ProductEmbedding, p.ProductEmbedding) AS distance
FROM Products p, ReferenceProduct rp
WHERE p.ProductID != 123 AND p.ProductEmbedding IS NOT NULL
ORDER BY distance
LIMIT 5;

実行結果:

ProductID Name distance
748564 Gonzales, Phone 0.160337
733052 Mathews Smartphone Pro 16 0.160598
789 Premium Wireless Headphones 0.167190
476417 Dixon, Mobile Phone Pro 37 0.179702
865179 Jones, Cell Phone Pro 43 0.185011

基準の商品「High-end Smartphone Model X」に対して、Phone や Smartphone といった類似カテゴリの商品が上位に並んでいます。

グラフクエリでレコメンドを試す

グラフクエリを使って、よく一緒に購入される商品を見つけてみましょう。「この商品を買った人はこんな商品も買っています」のようなレコメンド機能の基礎となる分析です。

SQL での分析

まず、公式サンプルに含まれる SQL 版のクエリを実行します。

SELECT
  p1.ProductID AS product1_id,
  p1.Name AS product1_name,
  p2.ProductID AS product2_id,
  p2.Name AS product2_name,
  COUNT(*) AS frequency
FROM OrderItems oi1
JOIN OrderItems oi2
  ON oi1.OrderID = oi2.OrderID AND oi1.ProductID < oi2.ProductID
JOIN Products p1 ON oi1.ProductID = p1.ProductID
JOIN Products p2 ON oi2.ProductID = p2.ProductID
GROUP BY p1.ProductID, p1.Name, p2.ProductID, p2.Name
ORDER BY frequency DESC
LIMIT 5;
product1_id product1_name product2_id product2_name frequency
270555 Roy, Camping Chair 733052 Mathews Smartphone Pro 16 6
205907 Silva-Navarro Smart Yoga Mat 974628 Quinn-Burton Deluxe Sleeping Bag 5
182627 Peterson, mobile device 838797 Conley Smartphone 5
662275 Wood-Oneal Smartphone Max 79 759176 Moreno mobile device Max 10 5
789 Premium Wireless Headphones 733052 Mathews Smartphone Pro 16 5

同じ注文に含まれる商品ペアを集計しています。自己結合と複数の JOIN が必要で、クエリがやや複雑です。

GQL での分析

同じ分析を GQL で書くと、関係をパターンとして直感的に表現できます。

GRAPH ECommerceGraph
MATCH (p1:Products)<-[:OrderItems]-(o:Orders)-[:OrderItems]->(p2:Products)
WHERE p1.ProductID < p2.ProductID
RETURN p1.ProductID AS ProductID1, p1.Name AS Name1, p2.ProductID AS ProductID2, p2.Name AS Name2, COUNT(*) AS frequency
GROUP BY ProductID1, Name1, ProductID2, Name2
ORDER BY frequency DESC
LIMIT 5;
ProductID1 Name1 ProductID2 Name2 frequency
270555 Roy, Camping Chair 733052 Mathews Smartphone Pro 16 6
205907 Silva-Navarro Smart Yoga Mat 974628 Quinn-Burton Deluxe Sleeping Bag 5
182627 Peterson, mobile device 838797 Conley Smartphone 5
662275 Wood-Oneal Smartphone Max 79 759176 Moreno mobile device Max 10 5
789 Premium Wireless Headphones 733052 Mathews Smartphone Pro 16 5

MATCH 句の (p1:Products)<-[:OrderItems]-(o:Orders)-[:OrderItems]->(p2:Products) で「注文を介して繋がる2つの商品」という関係を1行で表現しています。SQL 版で必要だった自己結合や複数の JOIN が不要になり、データの関係性が読み取りやすくなります。

まとめ

Retail サンプルデータセットを使って、Spanner の 3 つの機能を試しました。

機能 用途 使用例
全文検索 キーワードによる検索 商品名から「phone」を含む商品を検索
ベクトル類似度検索 意味的に似たアイテムの検索 ある商品に似た商品を検索
グラフクエリ 関係性をたどる分析 よく一緒に購入される商品を検索

これらの機能は 1 つのデータベースに統合されており、ユースケースに応じて使い分けたり組み合わせたりできます。

Retail サンプルには本記事で紹介した以外にもクエリが用意されています。ぜひ他のクエリも試してみてください。

TVer では今後、実際のサービスデータを想定した検証を進めていく予定です。

JSConf JP 2025のスポンサーとして登壇しました!

こんにちは!TVerのWebフロントエンドエンジニアJeun Yun (Paul) Tsangです。

2025年11月16日に開催されたJSConf JP 2025に、TVerはプレミアムスポンサーとして参加しました。当日のブース出展およびスポンサーセッション登壇の様子を共有します。

JSConf JPとは?

JSConf JPは、Japan Node.js Associationが主催する、JavaScriptをテーマとした日本最大級のカンファレンスです。グラントウキョウサウスタワーを会場とし、国内外のエンジニアが交流し、最新の技術動向を共有する場として開催されました。

カンファレンスでは、JavaScriptの進化、アーキテクチャ、導入事例、技術選定といった幅広いテーマのセッションが、日本語と英語の両方で行われました。スポンサー企業は、自社のブランディング、採用活動、そしてブースやスポンサーセッションを通じて開発組織のアピールと技術的な貢献を行いました。

休憩スペースに会社ブースが設置されています!

TVerのブースでガチャガチャ!

プレミアムスポンサーとしてブースを出展し、TVer Webフロントエンドチームにとって記念すべき初のカンファレンス参加ができました!

ブースでは、目玉企画としてオリジナルグッズが当たるガチャガチャを実施し、多くの方々にお立ち寄りいただきました。景品にはタンブラー、今治タオル、コーヒーなど実用性の高いものを用意し、来場者の皆様からご好評をいただきました。また、タブレットを設置したTVerアプリ体験コーナーも設け、技術的な質問だけでなく、ユーザー視点でのフィードバックを得る機会となりました。

TVerのブースでガチャガチャ!

来場者は主にWebフロントエンドエンジニアであり、TVerのエンジニア組織や具体的な技術課題について直接ご説明することで、採用活動における効果的なアピールの場となりました。海外からの参加者も多く、英語での対応を通じてTVerの技術と魅力を直接伝えることができました。一方で、TVerが現状日本語のみのサービスであるため「サービスを知っても利用できない」という外国人のユーザーの声を直接聞くこととなり、今後のサービス拡大における多言語サポートの重要性を再認識する貴重な機会にもなりました。

アンケートの回答依頼とその結果

ブースでは、ご来場者の皆様にTVerの開発組織への認知度を測り、さらに関心を持っていただけるよう、アンケートを実施しました。

まず、「TVerの社内に開発組織があることをご存知ですか?」という質問には、72.2%の方が「知っていた」と回答。2024年からの内製化の成果が着実に現れ、開発組織の認知度が向上していると思っています。

一方で、「TVerのTech Blogを読んだことはありますか?」という質問に対しては、残念ながら72.2%が「読んだことはない」と回答されました。今後はTech Blogを通じた情報発信活動をさらに強化して行きたいと思います!

弊社Webフロントチームの登壇について

私たちWebフロントチームから、30分間のスポンサーセッションで発表させていただきました。

私にとってJSConf JPでの登壇は初めての経験であり、多少の緊張はありましたが、登壇前にチームから温かい応援を頂いたおかげで無事に発表することができました。セッションは「TVerのWeb内製化 - 開発スピードと品質を両立させるまでの道のり」をテーマとし、Webフロントエンドの内製化からチームが取り組んできた課題とその解決策を紹介しました。

発表は二部構成で、前半は永井よりTVerの開発組織と内製化の活動について説明があり、後半は私から、開発スピードと品質を向上させるための技術的な解決策を紹介しました。技術的な観点だけでなく、方針を決定する際の考え方や、TVerが積極的にAIツールを活用している事例なども共有いたしました。

この発表を通じて、他の企業の皆様がTVer Webフロントエンドチームの取り組みから何かを学び、ご自身の開発組織の改善に繋げていただければ大変嬉しく思います。是非興味あればご覧ください。

気になったセッションについて

Cross-Platform Television Application Development: JavaScript Frameworks for Smart TV Ecosystems

AmazonのGiovanni Laquidaraさんから多様なテレビプラットフォームのためにアプリをどう開発した方が良いか、課題やツールを共有いただきました。技術的な話だけではなくユーザー体験やアクセシビリティの議題もあって面白かったです。特にテレビアプリはマウスや指での操作ではなく遠くからリモコンでの操作が基本なので違う考え方が必要です。

フレームワークに関しては主にLightningjsReact Nativeの選択肢があります。Lightningjsはテレビアプリを中心としたフレームワークになるのでテレビのパフォーマンスの考慮が特長です。一方React Nativeは一般的なクロスプラットフォームフレームワークになります。そのためテレビアプリのために特化した機能がないかもしれないですが、フロントエンドエンジニアは大体Reactの知識を持っているので開発がしやすくなります。今回のセッションでReact Nativeが推奨されましたが、TVerは既にReact Nativeを採用しているので選択はよかったと思います。

TVerは動画配信サービスとしてテレビアプリも対応する必要がありますので興味深いセッションでした。今後テレビアプリの開発に携わる機会があれば学んだ情報を活かしていきたいと思います!

最後に

JSConf JPを開催してもらった方、TVerのブースにきてもらった方や登壇を聞いてもらった方、ありがとうございました!初めてのブース対応と登壇でしたが良い経験になりました。これからもさらに良い内容で登壇させていただきたいと思います。

また、TVerはエンジニアを積極的に募集していますので少しでも興味あればカジュアル面談を受け付けています。是非TVerの求人一覧をご覧ください。

AIでデバッグ機能を爆速生成し、開発・検証の「面倒」を根こそぎ削る

本記事は TVer Advent Calendar 2025 16日目の記事です。
15日目の記事は @entaku0818 さんによる「iOS 26のAlarmKit APIでアプリからアラームを鳴らす」でした。

qiita.com

はじめに

TVerAndroidエンジニアをしている石井です。

開発・検証プロセスにおいて、特定のデータや環境を操作・確認できるデバッグ機能が不可欠となります。実装自体は容易にできますが、簡易的なUIでも0から作るのは面倒で、日々の業務の中で後回しになりがちです。なのでAIに作らせましょう。

AIに作らせるメリット

デバッグ機能は通常のユーザー向け機能と異なり、品質要件やデザイン要件が緩和されるため、AIによる実装と非常に相性が良いという特性があります。

品質を大きく気にしない、実装の容易さ

デバッグ機能は、一般のユーザーではなく、開発者やテスターが使用する前提で作られます。 そのためレイアウトや操作感に洗練されたUI/UXは求められません。 加えてAndroidではビルドタイプを設定することができ、デバッグ機能をデバッグビルドに限定することによってユーザー影響を一切考えずに実装することが可能になります。
AIがコードを生成した場合、通常は細かい調整が必要になりますが、デバッグ機能においてはそこまでの品質を求めていないため、生成コードをほぼそのまま利用でき、実装工数を大幅に削減できます。

仕様がシンプルで伝達漏れが少ない

デバッグ機能は、1機能が1つの目的に特化して作られ、開発者やテスターが利用するシンプルな構成になります。このシンプルさにより、仕様が複雑になりにくく、伝達漏れや考慮漏れ等の不具合が大幅に減ります。そのため、複雑な設計や詳細な仕様詰めに工数を掛けることなく、検証に必要な機能を即座に開発環境に組み込むことが可能です。

機能の洗い出し

機能の洗い出しは、エンジニア組織内にとどめず、サービス開発に携わる非エンジニア(ディレクター、デザイナー、QAなど)を巻き込むことが重要です。他メンバーの視点を取り込むことで、調査に必要な機能を網羅できます。開発組織全体のSlackチャンネルなどで意見を募るといった方法で、必要なデバッグ機能を効率よく収集しています。

デバッグ機能の一例

実際に実装したデバッグ機能の一例です。

機能名 概要 活用シーン
コンテンツ詳細画面への遷移 コンテンツIDを入力して、その詳細画面へ直接遷移する 特定コンテンツでのみ発生する不具合などの調査・検証に活用
利用者の環境表示 APIの疎通先やアプリバイナリのバージョンなど、利用者がどの環境でアプリを使用しているかなどを表示する 不具合報告に合わせて環境情報をいただくことで原因調査に役立てる
A/Bテストなどのローカル強制切り替え 本来、サーバ側で割り振られるA/Bテストのパターンをフロント側で強制上書きして動作確認できるようにする 特定パターンでの動作検証やテストをする際に活用
導線がない画面への遷移 新規で開発していて導線がまだない画面や特定条件下でのみ遷移できる画面などの遷移をデバッグ画面に用意しておく ディレクターやデザイナーなどの非エンジニアが確認する際に活用

実際のデバッグメニュー

まとめ

デバッグ機能は開発・検証プロセスにおいて重要な役割を担っていますが、UIを0から作る必要があり手間がかかります。 そのためAIに作らせることで迅速に実装してくれます。実際これらの機能を実装するのに数分で完了しています。

今は非エンジニアの方に欲しい機能をヒアリングして追加実装を少しずつ行っていたりしますが、エンジニアが間に入らない自動化を目指しています。 今後の展望として、Issue起票を軸にActionsを発火させ、生成AIがPRを作成し、エンジニアがレビューするフローを作れないかなど、検討中です。

dbt Platform による TVer 広告データの分析基盤構築

はじめに

こちらは TVer Advent Calendar 2025 16日目の記事です。15日目は @ko-ya346 さんの「TVer の分析業務について」でした。

こんにちは、TVer の広告事業領域でデータサイエンティストをしている川井です。普段は TVer 広告の配信システムの開発や、広告効果分析、データ基盤構築などを担当しています。

今回は、TVer 広告のデータサイエンティストが直面する集計業務におけるつらみを解消すべく、dbt Platform を用いてデータ分析基盤を構築している最前線をご紹介します。

広告領域におけるデータ集計依頼

TVer 広告のデータサイエンティストは少数精鋭で日々の業務を遂行しています。分析業務と並行して、営業チームからのデータ集計依頼にも対応しています。例えば以下のような依頼です。

  • 都道府県別・デバイス別の配信実績を出してほしい」
  • 「このキャンペーンのユーザー属性別ユニークユーザー数を教えてほしい」
  • 「クリエイティブ別の視聴完了率は?」

このような多岐にわたる依頼に対して、色々な配信ログを直接参照するような集計 SQL を都度書いて対応していました(現在も一部対応中ですが...)。

サービスのグロースに伴う課題

TVer の広告事業が急成長する中で、以下のような問題が顕在化しました。

依頼の増加と多様化

  • キャンペーン数の増加に伴い、配信実績確認の依頼が激増
  • 集計粒度の多様化
    • 「日次」「週次」といった、様々な時間粒度での集計依頼
    • 広告代理店・広告主・キャンペーン・クリエイティブといった、様々な粒度での集計依頼

再現性と効率の問題

  • 似たような集計を何度も書き直している
  • 人によってデータに対する理解度が異なり、集計作業ですら属人化してしまう

結果として、本来注力すべき分析業務ではなく、集計作業に時間を取られる状況に陥っていました(今も改善中ですが...)。

dbt Platform でデータ分析基盤を整備

このような課題を解決するため、事前に最適な粒度で集計したデータマートを構築し、迅速かつ正確に集計結果を提供する必要があると考えました。 データ分析基盤の構築には dbt Platform を採用し、ベストプラクティスに倣って 3 層構造のデータモデルを構築しています。

  1. Staging 層
    • 生ログからの軽い加工を担当。NULL の COALESCE 処理、エイリアス名の統一など後続処理で扱いやすい形への整形を行う
  2. Intermediate 層
  3. Mart 層
    • 分析・可視化用の最終テーブル。営業チームが直接参照するキャンペーン別配信実績サマリや、BI ツール接続用の集計済みテーブルを配置

結果として、136 個の SQL モデル 57 個の YAML 定義ファイルで構成される基盤が稼働しており、現在も拡張中です。

dbt Platform を選定した理由

スクラップ&ビルドのしやすさ

TVer の広告事業は急成長中のため、レポート要件の追加や集計粒度の多様化、季節性イベントに伴う分析ニーズの変化など、変化が激しい状況です。dbt は SQL ベースでモデルを定義するため、不要になったモデルの削除や新規モデルの追加が容易なことから、このような変化にも柔軟に対応できます。また、YAMLスキーマを定義し、モデル間の依存関係を ref() 関数で明示することで、変更の影響範囲も把握しやすくなっています。

データエンジニアが不在でもメンテナンス可能

専任データエンジニアがいない中で少数のデータサイエンティストが様々な依頼に対応しているため、以下の点でもメリットがあります。

  • ワークフローツールの管理運用が不要なため、SQL によるロジック記述に集中できる
  • スケジューリングが標準機能として搭載されているため、簡単にジョブ実行頻度を制御できる
  • ドキュメントが自動生成されるため、新規にジョインしたメンバーのモデル理解が容易になる

BigQuery との親和性

TVer ではデータ基盤に BigQuery を採用しています。dbt は BigQuery 固有の機能との親和性が高く、日々の運用で恩恵を受けています。

例えば広告配信では、毎日大量のレコードが発生するため、スキャン量の最適化が不可欠です。dbt では SQL モデル内の config() ブロックで partition_bycluster_by を指定するだけで、パーティションクラスタリングを適用したテーブルを作成できます。

また、BigQuery 向けのインクリメンタルマテリアライゼーションでは、merge 戦略(MERGE 文による upsert)と insert_overwrite 戦略(パーティション単位の置き換え)を選択できます。配信ログの特性に応じてこれらを使い分けることで、フルスキャンを回避しビルド時間とコストを削減しています。

エコシステムとコミュニティの充実

BigQuery をデータ基盤として採用している以上、Google Cloud ネイティブの Dataform も選択肢でした。しかし dbt はユーザー数が圧倒的に多く、実務での Tips やトラブルシューティング事例が豊富に蓄積されています。少人数チームで運用する我々にとって、「困ったときに検索すれば先人の知見が見つかる」安心感は大きな決め手でした。

また、dbt_utilselementary といったパッケージのエコシステムが充実しており、汎用的なユーティリティや異常検知の仕組みをすぐに導入できる点も魅力です。

データ品質の担保

dbt の標準テスト機能とカスタムテストを組み合わせ、データ品質を多層的にチェックしています。

標準テストでは not_nulluniqueaccepted_valuesrelationships などを活用し、主キーの一意性制約や外部キーの参照整合性、カラム値の妥当性を担保しています。

加えて、データパイプライン固有の問題を検知するためカスタムテストを実装しています。具体的には、直近 N 時間分のレコードが想定どおり存在するかを検証するテストを作成し、上流のデータ取込遅延やジョブ失敗を早期に検知できる仕組みを整えました。

テスト失敗時は Slack 通知が飛ぶようにしており、多方面へ影響が出る前に対処できる体制を構築しています。

現状の運用

現在はチームメンバーが dbt に慣れることを優先し、依頼ベースで必要なデータマートを都度構築しています。「完璧なデータモデルを最初から設計する」のではなく、まずは動くものを作り、運用しながら改善するアプローチを取っています。活用が進み、データモデルが増えてきた現在、次のフェーズとして以下の整備を進めています。

  • 類似モデルの統合、不要になったモデルの廃止
  • ディメンションテーブルの共通化促進
  • モデル命名規則やドキュメント記載ルールの標準化

今後の展望

セマンティックレイヤーの活用拡大

dbt Platform の セマンティックレイヤー(Semantic Layer) 機能を導入し、広告ビジネス指標の定義の一元管理を目指しています。一部データモデルでは既にこれを導入しており、Google スプレッドシートの dbt Semantic Layer アドオンを使って、定義されたメトリクスに従った数値をスプレッドシート上で直接取得できるようにしています(下図)。

Google スプレッドシートから dbt Semantic Layer を使う

セマンティックレイヤーの導入により、「インプレッション数」や「ユニークユーザー数」といった指標の計算ロジックを YAML で一元定義し、どのツールから参照しても同じ結果が得られる Single Source of Truth が実現可能になります。SQL を書くことなく、デバイス別・日付別など任意のディメンションで指標を参照できるよう整備中です。今後は対象モデルを拡大し、より多くの指標をセルフサービスで取得できる環境を目指していきます。

MCP を活用した自然言語でのデータアクセス

さらなるデータの民主化を目指し、Model Context Protocol(MCP)を活用した自然言語によるデータ分析基盤へのアクセスを検討しています。

dbt Labs が公開している dbt MCP Server を利用することで、LLM がセマンティックレイヤーで定義されたメトリクスやディメンションの情報を参照し、ユーザーの自然言語による問い合わせを適切な Semantic Layer API 呼び出しに変換できます。現在 PoC を進めており、「先週のデバイス別視聴完了率を教えて」といった問い合わせに対して正しい結果を返せることを確認しています。

Claude Desktop から MCP で dbt Semantic Layer を使う(勝手に pivot してくれている)

今まで我々のチームに依頼していた内容をそのまま自然言語で問い合わせられる環境を整備し、データリテラシーに依存しない情報アクセスを実現したいと考えています。

終わりに

TVer では、広告事業の成長を支えるデータ基盤の構築・運用に一緒に取り組んでくれる仲間を募集しています。

  • ビジネス価値を引き出すデータモデリングに興味がある方
  • dbt や BigQuery を使ったモダンなデータ基盤に挑戦したい方
  • 「分析のための分析」ではなく、事業成長に直結するデータ活用を実現したい方

ご興味のある方は、ぜひカジュアル面談からお話しましょう!

https://herp.careers/v1/tver/ARm9gwwiv3zS


明日の記事は @NagaiKoki さんの、「Vitestは本当に早いのか? Vitestでテストを高速化するアプローチについて」です。お楽しみに!

TVer の分析業務について

こんにちは、TVer のデータ分析をしている高橋です。
こちらは TVer Advent Calendar 2025 の15日目の記事です。

採用面接やカジュアル面談をしていると、TVer の分析業務についてあまり認知されていないという実感があります。
そこでこの記事では、「よく聞かれる質問」ベースで、実際どのように働いているのかをまとめてみました。

組織について

私は現在、TVer とビデオリサーチによる合弁会社である TVer Data Marketing(以下 TDM) のデータシステム部に所属しています。
元々TVer内にあったデータシステム周りを担当する部署が、今年の4月にまるごとTDM に移った形になります。
データシステム部ですが現在6名(プロパーのみ、マネージャー含む)、データエンジニアなども所属しています。
そのうち分析担当メンバーは、

  • プロパー: 1名
  • 業務委託: 3 名

の少数精鋭チームで動いており、主に TVer の事業部全体の分析支援を依頼ベースで担っています。

データ分析支援の依頼について

どこから依頼が来るか

事業部での施策推進においてデータが必要になる場面は多いため、依頼元は多岐に渡ります。

役割 主な依頼内容
PdM 新機能の評価、仮説検証 UI変更の効果測定
ディレクター ログ設計、基礎分析 新機能の計測用ログ設計、各種機能の利用状況把握
マーケティングCRM キャンペーン施策、Push 通知施策の評価 Push・メルマガ配信セグメントの提案
事業戦略 KPI設計 次期KPI策定に向けた基礎調査、KPIツリー提案
放送局 キャンペーン施策評価 SNS施策のコンバージョン計測

単発依頼のものもあれば、長期的に施策をフォローするものもあります。
Push 配信施策については、ツール導入初期から評価設計やデータ連携の整備に継続的に関わっています。

TVer では分析作業が分析チームに閉じておらず、ビジネス側も SQL や Redash を使って自ら分析するような対応が広まってきています。
なので社内で発生する分析業務を全て担っているわけではなく、

  • 複雑なクエリを書かないと分析出来ないとき
  • 効果検証など専門性が必要なとき
  • そもそも何を分析したらいいか分からないとき

といった場面で分析チームに依頼が寄せられるように感じます。

依頼が来るタイミング

施策は次の図のようなステップに分解できます。
どのステップで相談が入るかは施策や担当者によって大きく異なります。
分析担当者は依頼を受けた段階で コントロールできる範囲 を明確にし、その中で最適なアウトプットを設計するようにしています。

分析チームで定義している施策の流れ

データの面白さ

TVer のデータはとにかくめちゃくちゃ面白いです。

行動ログの粒度が細かい

行動ログは大きく分けると 3種類あります。

ログの種類 発火タイミング
画面遷移ログ HOME 訪問、マイページ訪問など画面が切り替わるタイミング
イベントログ コンテンツのクリック、お気に入り登録など
視聴ログ 再生開始、停止、シーク移動、一時停止・再開、視聴終了など

これらを組み合わせることで例えば、

  • マイページの絞り込み機能の利用状況と、利用時のエピソード到達率
  • HOME から再生までのファネル分析
  • おすすめ欄経由再生のプリロール突破率(広告視聴体験の分析)

などユーザーの行動を非常に高い解像度で追うことができます。

自社開発ログで突合しやすく、分析しやすい

ログは全て自社で設計・開発しています。
そのため、

  • ログ仕様が統制されている
  • 各種テーブルのスキーマ構造が統一され、横断的に扱いやすい
  • 画面遷移ログ × イベントログ × 視聴ログをきれいに突合できる

といった特徴があります。
結果として、前述のような分析をシンプルなクエリで素早く行えることが可能になっています。

自社開発ログのポリシーについてはこちらの記事が詳しいです。

TVer におけるログ収集のポリシー (1/2) - TVer Tech Blog

TVer におけるログ収集のポリシー (2/2) - TVer Tech Blog

社会インフラ級のデータ規模

TVer は社会インフラレベルの事業規模のサービスであり、日々数十億規模のログが収集されています。

speakerdeck.com

膨大なデータと多様な切り口があるため、探索的な分析がいくらでもできます。
まだ活用しきれていない領域も多く、分析者として腕の振りどころが非常に大きい環境です。

放送 × 配信のデータを扱える

TVer では配信の視聴データだけでなく、テレビ放送の視聴データも収集しています。
そのため、

  • 放送番組の見逃し視聴の行動分析
  • 裏被りの時間帯の視聴行動パターン

など、テレビ視聴と配信視聴のクロス分析が可能です。
ここは動画配信サービスの中でもかなりユニークなポイントです。

チーム文化

分析チームは、少人数ながら以下の文化が根付いています。

相互レビューとオンボーディング

クエリ/ロジック/分析の方向性を相互にレビューし合い、どんな依頼でもアウトプットの品質を上げられるようにしています。

新しくジョインした方には、オンボーディングとしてログ仕様や BigQuery、Redash の使い方、過去の分析例などを共有しつつ、最初の分析はレビューを通じて一緒に進めています。
相互レビューを通じて SQL や分析設計のスキルが身に付くので、自然と出来ることの幅が広がっていきます。

分析定例

社内の「データ分析に携わる人」「データ分析に興味ある人」が集まり、

  • 業務で実施した分析内容の共有、相談
  • 参加したイベントの紹介
  • 最近読んでる本の紹介

など、ゆるい勉強会も兼ねて知見を共有し合っています。
現在は隔週で実施しています。

データの流れを理解する

分析チームでは、

「データ分析をするなら、データが"どこで、どう生まれ、どう流れてくるのか" を理解するべき」

という考え方を大切にしています。
ログを扱う時、

  • どの操作/どのタイミングで発火するのか
  • どのように収集されるのか
  • どのような処理が行われてテーブルに入るのか

を理解しているだけで分析の質がまったく変わります。計測ミスや解釈のズレにもすぐ気付けるようになります。
そのため分析官でもデータパイプライン構築などいわゆるデータエンジニアリング領域の業務に挑戦することが可能です。
(自分も昨年データマート基盤を開発しました

このあたりの "領域横断" な雰囲気は小規模チームならではの魅力だと思います。

おわりに

ここまで紹介したように業務の大半はアナリスト的な業務ですが、最近はデータサイエンス寄りのことも少しづつ挑戦しています。

  • Push 向けのレコメンドモデル
  • 再生回数・お気に入り数の予測モデル
  • 分析エージェント構築

これらは、事業部から「これ作って!」という明確な依頼があるわけではなく、なんとなく課題っぽいものを拾って形にしたり、完全に興味ベースで始めたプロジェクトもあります。
まだ検証段階ですが、少しずつ実用化に向けて動いているところです。
TVer は事業規模もデータの厚みも大きく、やろうと思えばもっといろんな領域に挑戦できると思います。

もしこの記事を読んで、
「こういう分析やってみたいかも」
「このチームでデータ活用を進めてみたい」
と思っていただけたら、ぜひ一度カジュアルにお話ししましょう。

まだまだやりたいことがたくさんあるので、仲間が増えると嬉しいです。

herp.careers