一、引言

CMDB 对象是否被某个指标覆盖,表面上是一个按天查询的问题,实际需要同时读取三类数据:series 保存指标与对象的对应关系,value 判断指定日期是否存在值,CMDB 补充对象名称、类型、标签和属性。早期由页面实时关联计算;当跑批数据达到数十亿行、现场 ClickHouse 内存约束为 40 GiB 时,这条路径已经难以承载覆盖判定、对象关联、去重和缓存等职责。本文复盘已有实现如何将覆盖判定按日落表、如何明确分布式关联语义,以及如何在读取侧处理未完成合并的数据。文中只保留已经确认的现场事实与代码行为:没有异常堆栈、耗时日志或压测数据支撑的性能收益不作推断;写入重复的具体根因也不从表引擎或查询补丁反推。

二、正文

(一)先划清 series、value 与 CMDB 的职责

覆盖关系由三份数据共同表达:series 是覆盖生成的主表,负责描述指标和对象的对应;value 用于判断指定日期是否有值;dwd_cmdb_object_all 提供对象的名称、类型、标签和属性等详情。将这些职责区分开,才能避免把展示字段、覆盖判定和缓存策略混成一条无法维护的查询链路。

项目新增 oms_object_metric_date_cover 及定时任务,将页面实时推导改为按天写入覆盖结果。页面优先读取日粒度结果,而非每次从原始关系重新计算。这是读写职责的转移,不代表计算成本消失:生成任务仍然需要处理大范围关联,详情字段的放置也会影响写入与读取的边界。

下面仅说明表结构意图,不是生产可执行 SQL:

-- 结构示意:非生产可执行 SQL,字段与来源均有省略
CREATE TABLE oms_object_metric_date_cover
(
    date Date,
    metric_id String,
    object_id String,
    version UInt64,
    ...
)
ENGINE = ReplicatedReplacingMergeTree(version)
PARTITION BY date
ORDER BY (metric_id, object_id, date);

(二)以 series 驱动并明确分布式关联语义

覆盖生成以 series 明细为主表:先通过嵌套 GLOBAL IN 判断指定日期的 value 中是否存在值,再使用 GLOBAL INNER JOIN dwd_cmdb_object_all 补齐对象信息。这里必须准确描述实现:关联使用的是 GLOBAL INNER JOIN,不是 LEFT JOIN,因此不应把它包装成“左表优化”。

GLOBAL IN 用于明确分布式查询中的集合语义,但它不消除集合物化或广播的成本;GLOBAL INNER JOIN 同样只是明确关联方式,不会自动降低大范围关联、聚合和写入的压力。对于 IN 与 GLOBAL IN 的语义差异,应以 【ClickHouse】IN Operators 为准。

下面仅展示关联骨架,不是生产可执行 SQL:

-- 结构示意:非生产可执行 SQL,不代表完整过滤条件
SELECT
    s.metric_id,
    s.object_id,
    c.object_name,
    c.object_type,
    ...
FROM <series_detail> AS s
GLOBAL INNER JOIN dwd_cmdb_object_all AS c
    ON s.object_id = c.object_id
WHERE s.object_id GLOBAL IN
(
    SELECT <object_id>
    FROM <value_for_specified_date>
    WHERE ...
);

在数十亿行和约 40 GiB 内存的现场约束下,嵌套 GLOBAL IN、集合物化或广播、GLOBAL INNER JOIN、聚合以及 INSERT ... SELECT 的中间结果都需要谨慎控制。现有资料无法证明其中任何一项是唯一或主要瓶颈,因此这里只把它们列为需要验证的边界。

(三)将单次写入与分批写入放在同一组边界下对照

单条 INSERT ... SELECT 可以直接完成集合筛选、关联、聚合和写入,但当处理范围扩大时,多个阶段叠加在同一执行链路中,边界不易收敛。项目曾对单条写入和分批写入做过对照,最终采用先 COUNT、再以 LIMIT/OFFSET 循环写入的方式,默认每批 5000 行;同时减少部分 GROUP BY 与冗余对象列,将对象详情调整为读取时按需 GLOBAL INNER JOIN。

下面仅表达分批处理意图,不是生产可执行 SQL:

-- 结构示意:非生产可执行 SQL
SELECT count()
FROM <cover_generation_source>
WHERE ...;

INSERT INTO oms_object_metric_date_cover
SELECT <cover_columns>
FROM <cover_generation_source>
WHERE ...
LIMIT 5000 OFFSET <batch_offset>;

这是一项写入方案对照,而不是已量化的性能结论。分批能够限定单批范围,但仍需定义稳定的分页边界、失败续跑、重试幂等和补写后的缓存更新;不能据此断言它比单条写入更快,或已经解决内存问题。

方案已确认的作用仍需关注的边界
单条 INSERT ... SELECT一次完成生成与写入,实现直接大范围集合筛选、关联、聚合与写入叠加时的执行边界
COUNT 后按 LIMIT/OFFSET 分批写入默认每批 5000 行,限制单批处理范围分页稳定性、失败恢复、重试语义与实际负载表现

(四)让存储去重与查询去重各自承担责任

覆盖表使用 ReplicatedReplacingMergeTree(version),按 date 分区,排序键为 (metric_id, object_id, date)。ReplacingMergeTree 的去重依赖后台合并;在合并尚未完成时,查询是否立即看到去重后的结果还取决于查询方式和分区范围,不能仅凭引擎名称作出保证。相关行为应参考 【ClickHouse】ReplacingMergeTree 与 【ClickHouse】ReplacingMergeTree 使用指南。

集群侧通过拆分 Local 表与 Distributed 表,并以 cityHash64(metric_id, object_id) 分片,目标是让同一逻辑键稳定路由,避免分布不一致放大分布式读取中的重复。至于写入侧为何产生重复,现有记录没有可验证结论:任务重试、批处理分页、写入边界和分片路由都只能作为排查方向,不能被写成既定根因。

分页读取时,查询范围收窄到最近两日分区,并使用 FINAL 与 LIMIT 1 BY metric_id, object_id:前者在查询侧按最终状态处理,后者显式保留每个指标对象组合的一条记录。下面仅展示查询意图,不是生产可执行 SQL:

-- 结构示意:非生产可执行 SQL,排序与过滤条件需要按业务补全
SELECT ...
FROM oms_object_metric_date_cover FINAL
WHERE date >= <recent_two_days>
ORDER BY <version_or_time> DESC
LIMIT 1 BY metric_id, object_id;

FINAL 是查询侧兜底,不是免费的开关;其成本与分区大小、数据分布和并发有关。LIMIT 1 BY 的排序字段也必须确实表达“最新”的业务语义。FINAL 和 LIMIT BY 的具体行为可分别参考 【ClickHouse】SELECT 的 FROM 与 FINAL 修饰符 与 【ClickHouse】LIMIT BY 子句。

(五)缓存路径按日回退,但不替代数据正确性规则

读取路径调整为先查询 Redis 当日缓存,再通过 depth=2 按天回退;仍未命中时回源 ClickHouse,并将结果回填 Redis,TTL 为 90 天。这是一条有时间边界的读取策略,适合按日理解覆盖关系、允许读取历史数据的场景。

缓存命中、按日回退、回源和回填应分别观察。depth=2 表示回退范围,90 天 TTL 是保留策略,而不是数据一定正确或实时刷新的承诺。覆盖数据重算或补写后,缓存键设计、失效时机和刷新规则仍需单独定义;将 CMDB 详情延后到读时 GLOBAL INNER JOIN,也只是把一部分关联成本从写入侧移到读取侧。

三、总结

这次调整的核心不是宣称某项优化带来了确定收益,而是让每一层职责可被单独验证:按日覆盖表承担覆盖判定,series、value 与 CMDB 的关系在生成任务中明确表达,ReplacingMergeTree 与 FINAL、LIMIT 1 BY 共同处理读取侧的重复可见性,Redis 负责当日缓存、历史回退与回填。后续应基于查询记录、内存采样和真实负载验证成本,并补齐写入重试、分页边界和缓存失效规则,而不是把未知问题归因到某一个算子或引擎行为。

四、文献引用

最后修改:2026 年 09 月 14 日
如果觉得我的文章对你有用,请随意赞赏