事例研究 · 通信事業者 OSS データレイク

通信端末のデータを人、サービス、AI エージェントが一緒に使う OSS レイク

遅い監視画面一つから始まり、データを一か所に集めて多くのチームと AI エージェントが分け合う構成に変えた話です。

匿名化した合成事例です。実際の事例の構成を一般化しており、事業者、都市、データは架空です。

1状況

NOC · 直近 1 時間

ユーザーが多いセル 10

電波が弱いセル 10

通信事業者の OSS チームの監視画面は「直近 1 時間、ユーザーが多いセルと電波が弱いセル」を表示します。端末(UE: スマートフォン、IoT モジュール、車両、ルーターなど)の測定値を毎回元データから集計し直していたため、画面が遅くなっていました。

  • ファイルベースのレイクでは対話的な応答時間を満たしにくい
  • 段階ごとにスキーマが別々に定義され、名前と意味がずれる
  • パイプラインとスキーマの変更に時間がかかる
  • 予測できないアドホックな質問と AI エージェントのクエリが増える

通信事業者のシステムは大きく二つに分かれます。BSS は顧客とお金(契約、料金、請求)を、OSS はネットワークそのもの(設備、障害、品質)を扱います。このレイクは OSS 側です。

2業界の流れ: OSS データレイヤーの仮想化

システムごとにデータを複製する代わりに、一つのデータ層の上に意味とアクセス制御を載せ、人と AI エージェントが同じデータを使う方向に進んでいます。公開資料からまとめた層です。

  1. 利用者自律運用エージェント、ネットワークのデジタルツイン、NOC · BIManaged Postgres
  2. アクセスと統制標準 Open API、MCP ツールカタログ、ポリシー · 監査、書き込み前の人による承認Managed Postgres
  3. 意味の層オントロジー · ナレッジグラフ、トポロジー · インベントリのモデル、標準情報モデル
  4. データプロダクトドメインが所有し契約を付けた整形済みデータ(ゴールド)ClickHouse
  5. 保存と照会オープンテーブルのレイクにある履歴を、移さずにその場で照会ClickHouse + Iceberg
  6. 収集RAN · コア · 伝送 · プローブからのストリーミング、標準化された網データの収集
  7. 源泉網機能、装置管理システム、OSS/BSS の記録システム

Managed Postgres 利用者側の利用パターンと作業記録(購読、API キー、ウォーターマーク、チケット、エージェントの作業履歴と承認)はトランザクションデータなので ClickHouse の Managed Postgres に置き、レイクとは pg_clickhouse でつなぎます。 デモにはなし

3アーキテクチャ

実際の事例から一般化したレイク全体です。実線はこのラボで実際に動く部分、点線は同じパターンで図にのみ示します。

  • このラボで動作
  • 同じパターン(図のみ)

スキーマ契約(Git): 列名、説明、型を Git の契約ファイル一式で定義し、各段階のスキーマと DDL をそこから自動生成します。

コントロールプレーン API: トピック、ストリームジョブ、コネクター、スキーマ、テーブルを手作業ではなく API で宣言、配備、観測します。人と AI エージェントが同じ API を使います。

4誰が何に使うのか

同じデータを多くのチームがそれぞれのやり方で使います。見る、受け取る、運用する、AI の四つに分けて示します。

  • ラボで実行 このラボのデータで実際に実行した結果
  • 構成例 実際の事例の構成。このラボでは実行していない

ネットワーク監視センター(NOC) ラボで実行

今どこが混み、どこが弱く、どこで障害が起きたか

図は JavaScript で描きます。

  • ユーザーが多いセル
  • 電波が弱いセル
  • 直前に電波が落ちた地域
  • 測定件数
詳しく

直近 1 時間でユーザーが最も多いセル(青)、電波が最も弱いセル(赤)、直前の 1 分で急に電波が落ちた地域(黄)を一画面に描きます。

どうやって: 画面に必要な答えだけを毎分あらかじめ計算しておくので、画面はすぐ開きます。セルの境界は ClickHouse の H3 関数が返します。

運用ダッシュボード · BI ラボで実行

毎分変わる KPI の推移

図は JavaScript で描きます。

詳しく

ダッシュボードツールが毎分同じクエリを投げて線を描きます。下は都市別の弱い電波の割合の直近 1 時間で、跳ね上がった点はその分にどこかの地域で障害があったことを示します。

どうやって: ダッシュボードツールが ClickHouse に直接つながります。ユーザー数のように重複を除くべき値も、1 分要約に持つ状態を合わせて正確に数えます。

-- ---------------------------------------------------------------- 2 · dashboard
-- One point per city per minute for a time-series panel (Grafana, Superset, ...):
-- distinct users (uniq states merged across the city's cells) and weak-signal share.
CREATE OR REPLACE VIEW oss_lake.uc_dashboard AS
WITH (SELECT max(anchor_ts) FROM oss_lake.params) AS t
SELECT
    k.bucket                                                 AS minute,
    g.city                                                   AS city,
    uniqMerge(k.users_st)                                    AS users,
    sum(k.samples_n)                                         AS samples,
    round(100 * sum(k.poor_n) / sum(k.rsrp_n), 2)            AS poor_pct
FROM oss_lake.cell_kpi_1m AS k
INNER JOIN oss_lake.grid AS g ON g.h3 = k.cell
WHERE k.bucket >= t - INTERVAL 1 HOUR AND k.bucket < t
GROUP BY minute, city
ORDER BY minute, city;

品質アナリスト ラボで実行

準備していない質問にすぐ答える

都市区域弱い電波の割合
A 市都心0%
A 市中間2.6%
A 市郊外23.1%
B 市都心0%
B 市中間2.5%
B 市郊外23.1%
C 市都心0.1%
C 市中間2.6%
C 市郊外23.1%
詳しく

弱い電波は都心に多いのか、郊外に多いのか。誰もレポートを作っていない質問を、その場で SQL で聞きます。

どうやって: 1 分要約に直接聞き、地域情報と結合します。ファイルベースのレイクでは対話的な応答時間を満たしにくかった種類の質問です。

-- ---------------------------------------------------------------- 1 · ad-hoc analysis
-- A quality analyst's question: where is weak signal concentrated, downtown or at the edge?
CREATE OR REPLACE VIEW oss_lake.uc_analysis AS
WITH (SELECT max(anchor_ts) FROM oss_lake.params) AS t
SELECT
    g.city                                                   AS city,
    multiIf(g.ring <= 10, 'downtown', g.ring <= 30, 'middle', 'edge') AS zone,
    uniqExact(k.cell)                                        AS cells,
    sum(k.samples_n)                                         AS samples,
    round(sum(k.rsrp_sum) / sum(k.rsrp_n), 1)                AS avg_rsrp,
    round(100 * sum(k.poor_n) / sum(k.rsrp_n), 1)            AS poor_pct
FROM oss_lake.cell_kpi_1m AS k
INNER JOIN oss_lake.grid AS g ON g.h3 = k.cell
WHERE k.bucket >= t - INTERVAL 1 HOUR AND k.bucket < t
GROUP BY city, zone
ORDER BY city, indexOf(['downtown', 'middle', 'edge'], zone);

オンコール担当 · 下流システム ラボで実行

購読: 何か起きたら知らせる

  • 10:20 · C 市 地域 8730c19a5ffffff: セル 48 個で電波が 21.2 dB 低下
詳しく

セルの直近 1 分の電波が自分の直近 1 時間より大きく落ちたらアラートになります。セルを地域単位にまとめ、人が読める一行にします。下はこのラボで実際に検出されたアラートです。

どうやって: 実際の事例では、新しいデータが用意できるとメッセージキューで「今取りに来て」というイベントを送り、購読者がそのとき受け取ります。結果を Kafka トピックに直接送ることもできます。

運用では同じ行を URL テーブルエンジンで Webhook に送ります。ラボでは使いません(届かない宛先が元の INSERT を失敗させるため)。

例であり、このラボでは実行しません(Kafka ブローカーが必要)。

-- Example, not run in this lab (it needs a Kafka broker): publish each minute's alerts to a
-- topic that an on-call app or chat bot subscribes to. Inserting into a Kafka-engine table
-- produces messages; a refreshable MV with APPEND does that once a minute.
CREATE TABLE oss_lake.alerts_topic
(
    area String, city UInt8, minute DateTime, cells UInt64, drop_db Float64, rsrp_now Float64
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'broker:9092',
         kafka_topic_list  = 'noc-alerts',
         kafka_group_name  = 'oss-lake',
         kafka_format      = 'JSONEachRow';

CREATE MATERIALIZED VIEW oss_lake.alerts_publish
REFRESH EVERY 1 MINUTE APPEND TO oss_lake.alerts_topic AS
SELECT area, city, minute, cells, drop_db, rsrp_now FROM oss_lake.uc_alert_areas;

業務アプリ · サービス · AI エージェント ラボで実行

即時照会: HTTP 一回で受け取る

curl 'http://localhost:18123/?user=oss_reader' \
  --data-binary 'SELECT area, city, minute, cells, drop_db, rsrp_now FROM oss_lake.uc_alert_areas FORMAT JSONEachRow'
{"area":"8730c19a5ffffff","city":2,"minute":"2026-10-18 10:20:00","cells":48,"drop_db":21.2,"rsrp_now":-114.1}
詳しく

利用者は HTTP リクエスト一つで必要なデータを受け取ります。実際の事例では ClickHouse の前の API サービスが認証とカタログを担い、元の形式と業界標準モデルの形式から選べるようにしています。下は ClickHouse の HTTP インターフェースに直接送ったリクエストと応答の冒頭です。

どうやって: ClickHouse は HTTP でクエリを受け、結果をできた順に一行ずつ流します。ドライバーは要りません。

サービング層 · 業務アプリ(今後の拡張) 構成例

Managed Postgres: 購読、キー、業務データは Postgres に

詳しく

サービング層には、購読の一覧、API キー、どこまで送ったか(ウォーターマーク)といった小さな業務データが伴います。実際の事例でもこれを Postgres に置いています。ClickHouse の Managed Postgres を使えばこの業務データとレイクが一つのクラウドにあり、pg_clickhouse 拡張で Postgres の SQL のままレイクのアラートを担当チームや未解決チケットと結合できます。

根拠: 同じ方法を clickhouse-managed-postgres-hols リポジトリの pg_clickhouse ラボで検証しました(2026-10-01)。このラボには含めていません。

拡張の例であり、このラボでは実行しません。ホストとパスワードは仮の値です。

-- Expansion example, not run in this lab. Runs on (Managed) Postgres, where an operations app
-- keeps tickets and area owners; pg_clickhouse reads the lake's alert view in place.
-- Same statements as clickhouse-managed-postgres-hols extensions/pg-clickhouse-lab
-- (verified there 2026-10-01 on pg_clickhouse 0.11.0, PostgreSQL 18.6); host and password are placeholders.
CREATE EXTENSION IF NOT EXISTS pg_clickhouse;

CREATE SERVER lake FOREIGN DATA WRAPPER clickhouse_fdw
    OPTIONS (driver 'binary', host '<clickhouse host>', port '9000', dbname 'oss_lake');
CREATE USER MAPPING FOR CURRENT_USER SERVER lake
    OPTIONS (user 'oss_reader', password '<password>');

CREATE SCHEMA lake;
IMPORT FOREIGN SCHEMA oss_lake LIMIT TO (uc_alert_areas) FROM SERVER lake INTO lake;

-- who to call: this minute's alert areas with their owner team and open tickets
SELECT a.area, a.cells, a.drop_db, o.team, count(t.id) AS open_tickets
FROM lake.uc_alert_areas AS a
JOIN ops.area_owner AS o ON o.area = a.area
LEFT JOIN ops.ticket AS t ON t.area = a.area AND t.status = 'open'
GROUP BY a.area, a.cells, a.drop_db, o.team
ORDER BY a.cells DESC;

AI エージェント 構成例

自然言語の質問を SQL にして答える

詳しく

運用者が「直近 30 分で B 市の電波が落ちた地域は?」のように聞くと、エージェントがカタログとスキーマの説明を読んで SQL を作り、レイクに聞きます。スキーマ契約の説明がそのままエージェントの文脈になります。

どうやって: エージェントは MCP や HTTP で ClickHouse につながり、読み取り専用ユーザーで照会します。予測できない質問が多くても、要約テーブルのおかげで速く答えます。

データプラットフォームチーム 構成例

スキーマを一か所で変えてすぐ反映する

詳しく

スキーマが段階ごとに別々に定義されると、名前と意味が少しずつずれ、変更のたびに複数のチームを経由します。実際の事例では Git のデータ契約ファイル一式を基準に、メッセージのスキーマ、レイクのテーブル定義、ClickHouse の DDL を CI で自動生成します。

どうやって: ClickHouse は生成された DDL をそのまま受け取ります。テーブルと MV は SQL で定義されるので、レビューと配備をコードと同じように行います。

データ品質の担当者 ラボで実行

入ってくるデータの品質を段階ごとに確かめる

詳しく

セルや加入者のない測定は整える段階で捨て、電波の値がない測定は 0 ではなく「なし」として残します。各段階で何を捨て何を残したかを行数で確かめられます。

どうやって: 整形ルールはマテリアライズドビューの SQL 一か所にあるので、ルールと結果を一緒にレビューします。元データは受け取ったまま残るので、ルールが変われば作り直せます。

プラットフォーム運用(SRE) 構成例

パイプラインそのものを観測する

詳しく

収集器、トピック、ストリームジョブ、コネクターが増えると、どこで詰まりどこで失敗しているかを見ること自体が仕事になります。これらのログ、メトリクス、トレースも同じ ClickHouse に集められます。

どうやって: ClickHouse ベースの観測スタック ClickStack で OpenTelemetry のデータを受け、検索とダッシュボードに使います。

アナリスト · データサイエンティスト 構成例

古い履歴と最近のデータを一緒に見る

詳しく

長い履歴はオープンテーブル形式のレイク(Iceberg など)に置き、最近のデータは ClickHouse で速く使います。二つを一つのクエリでつなげば、再処理や長期の傾向分析が楽になります。

どうやって: ClickHouse はオブジェクトストレージ上の Iceberg テーブルを直接読めるので、レイクを移さずにゴールドのテーブルと結合します。

データエンジニア · 他の分析基盤 構成例

一括抽出: まとめて持っていく

詳しく

モデルの学習や他システムへの取り込みのように大きな塊が必要なときは、期間単位でまとめて出します。

どうやって: ClickHouse はクエリ結果をオブジェクトストレージにファイルとして直接書いたり、HTTP で流したりできます。実際の事例では即時照会の次の段階として検討している方法です。

AI 開発チーム 構成例

エージェントが正しく答えているかを観測する

詳しく

エージェントが作った SQL と答えが正しいか、コストと遅延はどうかを記録して評価してこそ、信頼して使えます。

どうやって: ClickHouse を保存先に使う Langfuse で、エージェントのトレース、評価、コストを同じプラットフォームで見ます。

5PoC で検証した機能

この PoC では、実際の構成のメッセージバスとストリーム処理の段階を ClickHouse 内のマテリアライズドビューで代替し、以下の機能を実データで一つずつ確認しました。確認はラボの SQL に検査として入っており、tools/hol run が毎回確認し直します。 ただし「更新中も 20 行」と「書き込みは拒否」の二つは手作業で確認しました(2026-10-18)。

  • ✓
    受け取ったまま取り込む形式変換なしで保存、日単位のパーティション、3 日の TTL元データの行数 = 生成した行数
  • ✓
    取り込み時の整形MV で H3 の文字列 → 数値、IMSI の暗号文 → ハッシュ、NULL の方針測定 1 件の前後を比較
  • ✓
    インベントリ参照による補強Dictionary(dictGet)でセルごとに地域名すべての行に地域名(05 の検査)
  • ✓
    取り込み時の事前集計AggregatingMergeTree、uniq 状態 + 合計、セル × 1 分1 セルの 1 分ぶんの複数行 → 1 行
  • ✓
    ゴールドの上のゴールド10 分要約を 1 分要約から連鎖 MV で元データから計算した 10 分集計と一致、対照を検出
  • ✓
    地理軸の要約同じシルバーの行から地域 × 1 分合計 = シルバーの行数(05 の検査)
  • ✓
    毎分更新される答えリフレッシュ可能な MV が 20 行を丸ごと入れ替え更新中も常に 20 行が読める
  • ✓
    どの段階に聞いても同じ答え四つの段階に同じ監視クエリ20 行が一致(03 の検査)
  • ✓
    急な低下のアラート直近 1 時間より 12 dB 以上落ちたセルを地域単位で生成器が落とした地域を毎分検出(04 の検査)
  • ✓
    HTTP サービングと読み取り専用ユーザー結果を 1 行ずつ JSON で流す読み取り専用ユーザーで照会、書き込みは拒否
  • ✓
    H3 の地理関数セルの境界と粗い解像度の集計を SQL でh3ToGeoBoundary で描いた監視マップ

検証した流れ: 測定 1 件が各段階でどう見えるか

  1. 受け取ったまま保管

    変えずに保存します。ルールが変わればここから作り直します。

    h3 '8930e1d8c0bffff' · imsi '/ZM4BSO2…'

  2. 整える

    型を合わせ、加入者番号をハッシュに替えます。使えない行は捨てます。

    h3 617853093946327039 · imsi → 624750…

  3. 1 分要約

    セルごとに 1 分 1 行に畳みます。どの期間でも合算し直せます。

    1 セルの 1 分 441 行 → 1 行

  4. すぐ使える答え

    画面が読む答えだけを毎分計算し直しておきます。

    監視マップに必要な 20 行

整える

セル × 1 分

地域 × 1 分

セル × 10 分1 分要約を引き継ぐ

地域要約: 直近 1 時間に弱い電波の割合が高い地域

地域弱い電波の割合
C North-West13.3%
B South-West13.3%
A North-East13.1%
B South-East12.5%
A West12.5%
エンジニア向け: テーブル、変換、SQL、測定値

四つの段階はデータベース oss_lake の ue_trace_raw、ue_trace、cell_kpi_1m、cell_rank_now テーブルです。最初の二つはマテリアライズドビューで、最後は毎分動くリフレッシュ可能なマテリアライズドビューでつながります。ユースケースは 04-usecases.sql のビューです。

測定 1 件の前と後

受け取ったまま整える変わった点
event_ts
2026-10-18 10:20:00.011
event_ts
2026-10-18 10:20:00.011
そのまま
h3_res09
8930e1d8c0bffff
h3
617853093946327039
文字列 → 数値(stringToH3)
imsi
/ZM4BSO2N5g8Ovcpun3PtidkglHPB22vffMxteYPT5w=
imsi_hash
6247503676043346188
暗号文 → ハッシュ(cityHash64)。元の番号はレイクに入りません
rsrp
-69
rsrp
-69
Float32。値がなければ NULL のまま
rsrq
-4.5
rsrq
-4.5
Float32
sinr
20
sinr
20
Float32
cqi
13
cqi
13
文字列 → 数値
pci
290
—使わないため捨てる
probe
probe-a
—使わないため捨てる
—
region1
City A
インベントリから付けた地域名
—
region2
A Central
インベントリから付けた地域名

441 行が畳まれた 1 分要約の 1 行

bucket2026-10-18 10:20:00
cell617853093946327039
users_stuniqState(…) → 133
samples_n441
rsrp_n399
rsrp_sum-31229
poor_n0

監視マップが読む 20 行

ユーザーが多いセル 10

#cellusers
18930e1d8c0bffff5,475
28930c1903cbffff5,449
38930c1619d7ffff5,385
48930e1d8c03ffff2,596
58930c190227ffff2,565
68930c16198bffff2,545
78930e1d8c0fffff2,016
88930c16199bffff1,969
98930c190237ffff1,910
108930c190353ffff1,777

電波が弱いセル 10

#cellavg_rsrp
18930c16ad73ffff-117.9 dBm
28930e1c302fffff-117.5 dBm
38930c16ad3bffff-117.5 dBm
48930c19221bffff-117.4 dBm
58930c16ad03ffff-117.4 dBm
68930c16ad07ffff-117.4 dBm
78930e1c3397ffff-117.3 dBm
88930c1922cfffff-117.2 dBm
98930e1c339bffff-117.2 dBm
108930c16ad0fffff-117.2 dBm

定義

-- ---------------------------------------------------------------- the inventory (CM)
-- Every cell gets a site and a fictional district: 'Central' within 10 rings, otherwise one
-- of six 60-degree sectors around the city centre. Coverage holes are known weak spots.
INSERT INTO oss_lake.cell_inventory
WITH
    [(37.5665, 126.9780), (35.1796, 129.0756), (35.8714, 128.6014)] AS centres,
    ['North-East', 'East', 'South-East', 'South-West', 'West', 'North-West'] AS sectors,
    centres[city + 1] AS c,
    h3ToGeo(h3) AS p,
    (degrees(atan2((p.2 - c.2) * cos(radians(c.1)), p.1 - c.1)) + 360) % 360 AS bearing
SELECT
    h3                                                            AS cell,
    concat(['A', 'B', 'C'][city + 1], '-', leftPad(toString(intDiv(idx, 7)), 4, '0')) AS site_id,
    ['City A', 'City B', 'City C'][city + 1]                      AS region1,
    concat(['A', 'B', 'C'][city + 1], ' ',
           if(ring <= 10, 'Central', sectors[1 + toUInt8(floor(bearing / 60)) % 6])) AS region2,
    is_hole                                                       AS known_weak
FROM oss_lake.grid;
-- Rows without a cell or a subscriber cannot be placed or counted: silver drops them.
CREATE MATERIALIZED VIEW oss_lake.ue_trace_mv TO oss_lake.ue_trace AS
SELECT
    event_ts,
    stringToH3(assumeNotNull(h3_res09))       AS h3,
    cityHash64(assumeNotNull(imsi))           AS imsi_hash,
    CAST(rsrp AS Nullable(Float32))           AS rsrp,
    toFloat32(ifNull(rsrq, 0))                AS rsrq,
    toFloat32(ifNull(sinr, 0))                AS sinr,
    toUInt8OrZero(ifNull(cqi, ''))            AS cqi,
    -- a measurement gets a place name from the inventory
    dictGet('oss_lake.cell_dict', 'region1', h3) AS region1,
    dictGet('oss_lake.cell_dict', 'region2', h3) AS region2
FROM oss_lake.ue_trace_raw
WHERE h3_res09 IS NOT NULL AND imsi IS NOT NULL;
CREATE MATERIALIZED VIEW oss_lake.cell_kpi_1m_mv TO oss_lake.cell_kpi_1m AS
SELECT
    toStartOfMinute(event_ts)  AS bucket,
    h3                         AS cell,
    uniqState(imsi_hash)       AS users_st,
    count()                    AS samples_n,
    count(rsrp)                AS rsrp_n,
    sum(ifNull(rsrp, 0))       AS rsrp_sum,
    countIf(rsrp < -100)       AS poor_n
FROM oss_lake.ue_trace
GROUP BY bucket, cell;
CREATE MATERIALIZED VIEW oss_lake.region_kpi_1m_mv TO oss_lake.region_kpi_1m AS
SELECT
    toStartOfMinute(event_ts)  AS bucket,
    region1, region2,
    uniqState(imsi_hash)       AS users_st,
    count()                    AS samples_n,
    count(rsrp)                AS rsrp_n,
    sum(ifNull(rsrp, 0))       AS rsrp_sum,
    countIf(rsrp < -100)       AS poor_n
FROM oss_lake.ue_trace
GROUP BY bucket, region1, region2;
[[sql:gold10]]
-- No APPEND: every refresh replaces the 20 rows.
CREATE MATERIALIZED VIEW oss_lake.cell_rank_rmv
REFRESH EVERY 1 MINUTE TO oss_lake.cell_rank_now AS
WITH (SELECT max(anchor_ts) FROM oss_lake.params) AS t
SELECT
    lr.1 AS list, lr.2 AS rnk, cell, users, samples, avg_rsrp, poor_pct, t AS window_end
FROM
(
    SELECT
        cell,
        uniqMerge(users_st)                              AS users,
        sum(samples_n)                                   AS samples,
        round(sum(rsrp_sum) / sum(rsrp_n), 1)            AS avg_rsrp,
        round(100 * sum(poor_n) / sum(rsrp_n), 1)        AS poor_pct,
        sum(rsrp_n) >= 30                                AS eligible,
        row_number() OVER (ORDER BY users DESC, cell)    AS r_users,
        row_number() OVER (PARTITION BY eligible ORDER BY sum(rsrp_sum) / sum(rsrp_n), cell) AS r_signal
    FROM oss_lake.cell_kpi_1m
    WHERE bucket >= t - INTERVAL 1 HOUR AND bucket < t
    GROUP BY cell
)
ARRAY JOIN [('users', r_users), ('signal', if(eligible, r_signal, 999))] AS lr
WHERE lr.2 <= 10;
-- ---------------------------------------------------------------- 3 · alerts
-- Cells whose signal in the latest minute is at least 12 dB below their own last hour.
-- Subscribers (an on-call app, a chat bot) pick these up every minute; see 4 and
-- subscribe-kafka.sql for how they are delivered.
CREATE OR REPLACE VIEW oss_lake.uc_alert_cells AS
WITH (SELECT max(anchor_ts) FROM oss_lake.params) AS t
SELECT
    cell, city, area, minute, rsrp_now, rsrp_hour,
    round(rsrp_hour - rsrp_now, 1)                           AS drop_db,
    samples_now
FROM
(
    SELECT
        k.cell                                               AS cell,
        any(g.city)                                          AS city,
        h3ToParent(k.cell, 7)                                AS area,
        t - INTERVAL 1 MINUTE                                AS minute,
        sumIf(k.rsrp_sum, k.bucket = minute) / sumIf(k.rsrp_n, k.bucket = minute) AS rsrp_now,
        sum(k.rsrp_sum) / sum(k.rsrp_n)                      AS rsrp_hour,
        sumIf(k.rsrp_n, k.bucket = minute)                   AS samples_now
    FROM oss_lake.cell_kpi_1m AS k
    INNER JOIN oss_lake.grid AS g ON g.h3 = k.cell
    WHERE k.bucket >= t - INTERVAL 1 HOUR AND k.bucket < t
    GROUP BY cell
)
WHERE samples_now >= 3 AND rsrp_hour - rsrp_now >= 12;

どの段階に聞いても同じ答え

同じ監視の質問を四つの段階それぞれに投げると、同じ 20 行が返ります(03-queries.sql が検査)。段階ごとに行数と読む量が減ります。

  1. 受け取ったまま ブロンズ21,600,000
  2. 整える シルバー21,470,680
  3. 1 分要約 ゴールド2,431,524
  4. すぐ使える答え サービング20
  • 受け取ったまま 710.6 ms · 10,807,707 行を読み取り
  • 整える 229.7 ms · 10,742,997 行を読み取り
  • 1 分要約 185 ms · 1,219,109 行を読み取り
  • すぐ使える答え 1.2 ms · 20 行を読み取り

測定 2026-10-18 10:22 UTC、ClickHouse 26.9.13.15、Apple M4 Pro (macOS arm64)、コンテナの CPU 12 個 · メモリ 7.75 GiB。ウォームアップ 1 回の後 5 回実行の中央値、サーバー側の経過時間、クエリキャッシュ無効。参考値です。

作りながら分かった落とし穴。 集計状態の型は、元の列が Nullable かどうかと一致している必要があります。一致しない MV は CREATE で拒否されますが、後から ALTER で元の列を Nullable にすると、その後の INSERT はすべて失敗し、元のテーブルだけが増え続けます(README に再現記録)。

6現場で学んだこと

aNullable のコストは主にクエリの複雑さ

同じ監視クエリをブロンズとシルバーで書いたものです。印を付けた NULL の確認、型変換、ハッシュがシルバーでは消えます。

受け取ったまま
SELECT
    stringToH3(assumeNotNull(h3_res09))            AS cell,
    uniq(cityHash64(assumeNotNull(imsi)))          AS users,
    count()                                        AS samples,
    round(avg(rsrp), 1)                            AS avg_rsrp,
    round(100 * countIf(rsrp < -100) / count(rsrp), 1) AS poor_pct,
    count(rsrp) >= 30                              AS eligible,
    row_number() OVER (ORDER BY users DESC, cell)  AS r_users,
    row_number() OVER (PARTITION BY eligible ORDER BY avg(rsrp), cell) AS r_signal
FROM oss_lake.ue_trace_raw
WHERE event_ts >= t - INTERVAL 1 HOUR AND event_ts < t
  AND h3_res09 IS NOT NULL AND imsi IS NOT NULL
GROUP BY cell
整える
SELECT
    h3                                             AS cell,
    uniq(imsi_hash)                                AS users,
    count()                                        AS samples,
    round(avg(rsrp), 1)                            AS avg_rsrp,
    round(100 * countIf(rsrp < -100) / count(rsrp), 1) AS poor_pct,
    count(rsrp) >= 30                              AS eligible,
    row_number() OVER (ORDER BY users DESC, cell)  AS r_users,
    row_number() OVER (PARTITION BY eligible ORDER BY avg(rsrp), cell) AS r_signal
FROM oss_lake.ue_trace
WHERE event_ts >= t - INTERVAL 1 HOUR AND event_ts < t
GROUP BY cell

buniq 状態の大きさは型ではなくグループごとの異なる値の数で決まる

型を変えて小さくなるのを期待するより、ユーザー数をどの単位(1 分、10 分)に置くかを選んでください。

状態一つの大きさ: 入力の型別、異なる値の数別

  • uniq · String · 異なる値 300 個1,203 B
  • uniq · UInt64 · 異なる値 300 個1,203 B
  • uniqCombined(12) · UInt64 · 異なる値 300 個2,651 B
  • uniq · UInt64 · 異なる値 1,000 個4,003 B
  • uniqCombined(12) · UInt64 · 異なる値 1,000 個2,651 B

このラボのユーザー数の状態全体: 1 分単位と 10 分単位

  • 1 分: 2,431,524 行 × 平均 34 B79.3 MiB
  • 10 分: 263,946 行 × 平均 193 B48.6 MiB

c粗い要約は細かい要約から作る

10 分要約は、1 分要約に入ってくる行を引き継ぐ MV で埋め、元データは一度だけ集計します。リフレッシュ可能な MV を APPEND で作ると、窓がリフレッシュ間隔より広いときに二重に入ります。

型 · 整形 · ハッシュ→セル × 1 分→セル × 10 分✓ 元データから直接計算した 10 分集計と一致(わざと違えた対照は検出)

d名前は _kst、型は UTC の時刻列

バケットと TTL が 9 時間ずれます。時刻はタイムゾーンを明示した型で保存してください。

event_kst 09:00≠UTC 00:00同じ瞬間、ラベルだけが違う

e利用者が増えたらユーザーと上限を分ける

ダッシュボード、MCP の AI エージェント、データカタログ、Kafka Connect のバッチは、それぞれ異なる形で負荷をかけます。

  • 読み取り専用ユーザーに結果行数と実行時間の上限
  • エージェントとカタログは別のユーザーに分ける
  • 取り込み側は大きなバッチか async insert

測定: 2026-10-18、ClickHouse 26.9.13.15、このラボのデータと 05-lessons.sql。

自分で流してみる

ライブデモで「次の 1 分を送る」を押すたびに 1 分ぶんの測定値が四つの段階を通り、監視マップとアラートが更新されます。

cd usecase/oss-network-quality-lake
ui/up.sh

ライブデモを開く

ローカルで動かすには Docker と Python 3 だけで足ります。このサイトのデモはローカルで録画した数分を再生します。