案例研究 · 运营商 OSS 数据湖

让人、服务和 AI 智能体共用所有通信终端数据的 OSS 数据湖

从一块很慢的 NOC 屏幕开始,最终把数据汇集到一处,由多个团队和 AI 智能体共同使用。

这是一个合成场景:以虚构的运营商为对象,按常见的电信 OSS 做法(3GPP PM/CM/trace、奖章分层)构建;名称为虚构,数字为本实验实测。

1背景

NOC · 过去 1 小时

用户最多的 10 个小区

信号最弱的 10 个小区

一家运营商的 OSS 团队在 NOC 屏幕上显示“过去 1 小时用户最多的小区和信号最弱的小区”。终端(UE:手机、IoT 模块、车辆、路由器等)的测量每次都从源头重新聚合,屏幕因此很慢。

  • 基于文件的数据湖难以达到交互式响应时间
  • 每个阶段各自定义 schema,名称和含义逐渐走样
  • 修改管道和 schema 耗时很长
  • 无法预测的即席问题和 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架构

典型电信 OSS 的整个数据湖。实线部分在本实验中实际运行,虚线部分遵循同样的模式,只在图中展示。

  • 本实验中运行
  • 同样的模式(仅示意)

Schema 契约(Git): 列名、说明和类型在 Git 的一份契约文件中定义,各阶段的 schema 和 DDL 都由它自动生成。

控制平面 API: 主题、流作业、连接器、schema 和表都通过 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 broker)。

-- 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 市哪些区域信号下降了?”,智能体查看目录和 schema 说明,生成 SQL 去问数据湖。schema 契约里的说明直接成为智能体的上下文。

做法:智能体通过 MCP 或 HTTP 连接 ClickHouse,以只读用户查询。即使问题无法预测,汇总表也能让回答保持快速。

数据平台团队 构成示例

在一处修改 schema 并立即生效

详情

每个阶段各自定义 schema 时,名称和含义会逐渐走样,每次修改都要经过多个团队。常见的做法是以 Git 中的一份数据契约文件为准,在 CI 中自动生成消息 schema、数据湖表定义和 ClickHouse DDL。

做法: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 策略比较一条测量的前后
  • ✓
    用资源数据补充通过字典(dictGet)为每个小区补上区域名称每一行都有区域名称(05 检查)
  • ✓
    写入时预聚合AggregatingMergeTree,uniq 状态 + 合计,小区 × 1 分钟一个小区一分钟的多行 → 1 行
  • ✓
    黄金层之上的黄金层10 分钟汇总由 1 分钟汇总级联 MV 生成与从原始数据计算的 10 分钟聚合一致,对照组被检出
  • ✓
    地理维度汇总由同一批白银层行生成区域 × 1 分钟合计 = 白银层行数(05 检查)
  • ✓
    每分钟刷新的答案可刷新物化视图一次性替换 20 行刷新期间始终能读到 20 行
  • ✓
    问哪个阶段都是同样的答案对四个阶段执行同一个 NOC 查询20 行一致(03 检查)
  • ✓
    骤降告警比自身过去 1 小时下降 12 dB 以上的小区,按区域合并每分钟都检出生成器制造故障的区域(04 检查)
  • ✓
    HTTP 服务与只读用户结果按每行一条 JSON 流式返回用只读用户查询,写入被拒绝
  • ✓
    H3 地理函数在 SQL 中得到小区边界和更粗分辨率的汇总用 h3ToGeoBoundary 绘制的 NOC 地图

已验证的流向:一条测量在各阶段的样子

  1. 原样保存

    不做修改直接存储;规则变化时从这里重建。

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

  2. 整理

    统一类型,把用户号码换成哈希;不能用的行丢弃。

    h3 617853093946327039 · imsi → 624750…

  3. 1 分钟汇总

    每个小区每分钟折成一行;任何时间段都能再合并。

    一个小区一分钟 441 行 → 1 行

  4. 现成的答案

    只把屏幕要读的答案每分钟重新算好。

    NOC 地图需要的 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 中的视图。

一条测量的前后对比

原样整理变化
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 分钟汇总行

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

NOC 地图读取的 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;

问哪个阶段都得到同样的答案

把同一个 NOC 问题分别发给四个阶段,返回的 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),容器 12 个 CPU · 内存 7.75 GiB。预热 1 次后 5 次运行的中位数,服务器端耗时,查询缓存关闭。仅供参考。

构建时发现的一个陷阱。 聚合状态的类型必须与源列是否 Nullable 一致。不一致的 MV 在 CREATE 时就被拒绝;但如果之后用 ALTER 把源列改成 Nullable,此后的 INSERT 全部失败,而源表照样增长(README 中有复现记录)。

6一线经验

aNullable 的代价主要在查询复杂度

同一个 NOC 查询分别写在青铜层和白银层上。标出的 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 填充,原始数据只聚合一次。用带 APPEND 的可刷新 MV 时,若窗口比刷新间隔宽,数据会被插入两次。

类型 · 清洗 · 哈希→小区 × 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。

亲手把数据送过去

在实时演示中每点一次“发送下一分钟”,一分钟的测量就会穿过四个阶段,NOC 地图和告警随之刷新。

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

打开实时演示

在本地运行只需要 Docker 和 Python 3。本站的演示回放在本地录制的几分钟。