资讯动态

GoWind UBA 拆解:把 Doris UNIQUE KEY 翻译成 ClickHouse——读数正确性对齐实录

发布时间:2026/10/27 18:49:30 来源:尧图企业网站定制
GoWind UBA 拆解把 Doris UNIQUE KEY 翻译成 ClickHouse——读数正确性对齐实录本文回答一个问题一套围绕 DorisUNIQUE KEY模型建起来的行为分析系统upsert 去重、随时可查的聚合结果要支持 ClickHouse 二选一部署需要翻译的到底是什么——建几张表就够了吗GoWind UBA 的 OLAP 层原本只跑 Doris。为了适配已有 ClickHouse 集群、不想再养一套 Doris的部署环境项目做了第二个引擎实现backend/sql/clickhouse/的全套 DDL、internal/data/clickhouse/的 13 个同名仓储文件、uba-ingest ch-etl回算工具。CHANGELOG 里那句ClickHouse 引擎的读数正确性事件去重 / FINAL 读 / 派生表按天回算已与 Doris 对齐背后是四幕真实的语义翻译——每一段 DDL 注释都是一次数据丢失模式的防御记录。本文是 OLAP 系列的姊妹篇Doris 侧的查询写法与模型设计此前已有专文拆解这篇只讲第二个引擎如何等价替换。一、问题设定UNIQUE KEY 的语义在 ClickHouse 没有对应物Doris 的 UNIQUE KEY 模型给使用者的承诺是同一主键的行后写的覆盖先写的查询永远看到最新版本——写入即生效读端无感。这是整个 UBA 系统的地基Kafka 采集是 at-least-once 的重放/重试必然产生重复事件事件表靠 UNIQUE KEY(event_id, tenant_id, event_time) 天然去重派生表会话事实、用户维度靠定期 upsert 更新。ClickHouse 没有这个承诺。它的 MergeTree 家族里最接近的是ReplacingMergeTree——但它的去重发生在后台合并时而合并是异步的、时机不确定的。这意味着翻译工作的真实难度是三层嵌套的表引擎怎么选——ReplacingMergeTree 的版本列、排序键怎么设计第三幕之前的去重正确性读端怎么办——合并没发生时查询会看到重复行读语义要显式补FINAL派生表怎么办——Doris 侧用 INSERT INTO SELECT 的聚合刷新在 CH 侧物化视图的语义下会算错第四幕的核心。先看引擎怎么接进来。二、引擎路由编译期常量而不是接口注入双引擎的接线方式先交代因为它本身是个设计决策。数据层有一个编译期常量// UseClickHouse 是否使用ClickHouse作为数据存储否则使用Doris。constUseClickHouseboolfalse消费方式是每个服务内部的私有接口 双字段 一个选择方法// analyticsImpl 按 OLAP 引擎选择返回可用的分群查询实现。func(s*SegmentService)analyticsImpl()segmentAnalytics{ifdata.UseClickHouse{returns.chAnalytics}returns.analytics}常见的做法是定义一个AnalyticsRepo接口在装配期注入实现——这里刻意没用。理由写在架构文档里UseClickHouse是编译期写死的部署形态常量选了 Doris 时整个 ClickHouse 分支13 个仓储文件、全部 CH 方言 SQL直接被编译器裁掉不参与构建、不占二进制、不可能在运行时被误触。换来的是两点部署的二选一变成了编译标签而非运行时配置一份错误的环境变量不会让请求打到没有表结构的引擎上方言差异时间函数、去重语义全部各自封死在自己的仓储文件里不渗透进服务层。代价是服务层多了些 if 分支——对一个部署时二选一、运行时永不切换的场景这是便宜的那一侧。三、第一幕去重——ORDER BY 的双重身份事件事实表的 DDL 是整个翻译的核心逐段读注释为原文件所有)ENGINEReplacingMergeTree(updated_at)-- 与 Doris UNIQUE KEY(event_id, tenant_id, event_time) 对齐Kafka at-least-once 重放的同事件只保留一条读取端必须配合 FINALPARTITIONBYtoYYYYMM(event_date)-- 按月分区平衡管理粒度和查询性能ORDERBY(tenant_id,event_category,event_date,event_name,event_ts,event_id)-- 去重键含 event_id对齐 Doris前缀保持原查询剪枝能力TTL event_dateINTERVAL180DAYSETTINGS ttl_only_drop_parts1,-- TTL 只删除完整分区避免部分删除开销optimize_on_insert1-- 入库块先去重再落盘Kafka 批次整体重放时物化视图与 FINAL 读都拿不到重复行三处设计都有明确的对位ReplacingMergeTree(updated_at)的版本列同一去重键的多行合并时保留updated_at最大的——对应 Doris UNIQUE KEY 的后写覆盖。版本列选业务时间戳而非插入时间让乱序到达的旧版本自然落败。ORDER BY 的双重身份。ReplacingMergeTree 的去重键就是 ORDER BY 键但 ORDER BY 同时是主键索引——这两个角色对键序的要求是冲突的去重要求event_id在键内否则去不掉重查询剪枝要求高频过滤列在键前缀。解法是把两种要求排成一列前缀(tenant_id, event_category, event_date, event_name, event_ts)服务查询剪枝event_id垫底保证去重语义——一段键序两种职责注释原话前缀保持原查询剪枝能力。optimize_on_insert 1入库块先去重再落盘。这一条防的是 Kafka 的批次级重放——一批重复事件整体重投时块内去重让物化视图与 FINAL 读都拿不到重复行。注意它防不住跨块的重复那要等后台合并所以它叫缓解而不是解决——真正的兜底在读端。配套的ttl_only_drop_parts 1TTL 到期只删完整分区避免部分删除的开销与合并放大是个顺手但正确的细节。四、第二幕FINAL 读——为异步合并付的税ReplacingMergeTree 的去重发生在后台合并而合并的调度是 ClickHouse 自己的事——任何时刻查询都可能看到尚未合并的重复行。Doris UNIQUE KEY 没有还没合并这个状态CH 有。这就是翻译的第二笔成本读端必须显式声明要去重后的确定结果语法就是表名后跟 FINAL。clickhouse/analytics_repo.go的手写 SQL 里 FINAL 出现了 45 处覆盖所有读事实表的查询JOIN 场景用别名后缀写法users_dim u FINAL。两条例外规则同样明确聚合表物化视图产物读不加密 FINAL——MV 的目标表由回算任务整行写入见下一幕本身没有重复行问题crud 分页列表不能带 FINAL——FINAL 与 LIMIT/OFFSET 组合的执行代价高分页场景的去重由回算任务的 OPTIMIZE 提前兜底下一幕第三节。这一幕的教训可以概括成一句话ReplacingMergeTree 的去重是存储层的懒承诺读语义要靠查询层显式兑现。翻译 UNIQUE KEY 时漏掉 FINAL故障形态不是报错而是偶发的计数翻倍——比崩溃难查得多因为它只在合并滞后的时间窗内出现。五、第三幕物化视图按块聚合是错的——回算取代 MV派生表sessions_fact 会话事实、users_dim 用户维度的刷新是这次翻译里唯一的推倒重来。第一版照搬 Doris 侧的 INSERT INTO SELECT 语义用了 ClickHouse 物化视图——方向就是错的06_etl.sql的头注释把为什么写得很完整这取代了旧的 mv_sessions_fact / mv_users_dim按插入块聚合跨块拆行/覆盖、累计值只有块内局部真值。MV 的触发单位是插入块每落一个块MV 对这一块做聚合写入目标表。对累计指标用户总活跃天数、总会话数这意味着每个块算出的只是块内局部真值多块之间互相覆盖或拆行——Doris 侧同样的 INSERT INTO SELECT 是显式调度、面向全量数据的语义完全不同。这是把 SQL 从一个引擎搬到另一个引擎时最危险的一类错误语法几乎一样语义单位不同。替代方案是放弃 MV、改为每日回算uba-ingest ch-etl执行06_etl.sql口径三段注释原文全历史回算而非块内增量按 RunDate 当天活跃的会话/用户集合触发再对该集合在 events_fact 上无时间限制全量聚合→ “结果与哪天回算无关幂等重跑安全”。回算任务的幂等性不是靠去重保证的是靠口径定义保证的——这一条值得写进任何 ETL 的设计清单CH 无 Doris 的部分列更新users_dim 整行由回算写入活跃/付费/会话等可从事件推断的列register_time、等级、风险等画像列保持默认值等 core 服务通过 users_dim_repo 写入写入带 updated_atReplacingMergeTree 取最新版本整行生效——Doris 的 UPDATE 指定列语义在 CH 侧翻译成了两个写入方各写整行、版本列仲裁职责边界画在列的来源上§3 的 OPTIMIZE FINAL回算结束时把 ReplacingMergeTree 的后台合并提前做完——上一幕留下的洞crud 分页不能带 FINAL由这一步兜住分页列表读到的也是去重后的确定结果。工具链配套uba-ingest ch-etl --dry-run渲染 SQL 预览占位符{{.RunDate}}ch-etl loop模式给没有主机 cron 的部署内建每日调度。回算天然可以随便重跑这个性质让某天回算失败从事故降级为第二天补跑。六、第四幕排序键修复三连——三种真实的数据丢失模式对齐过程还抓出三个排序键错误每一个都对应一种具体的丢数据模式。原文注释比转述有力直接引用-- risk_events风险事件表ORDERBY(tenant_id,event_date,risk_event_id)-- risk_event_id 必须在键内否则同日同级别的同时刻事件会互相去重丢数据-- id_mapping身份映射表ORDERBY(tenant_id,global_user_id,id_type,id_value)-- 缺 global_user_id 时同一设备关联到两个用户会互相覆盖丢关联-- user_tags用户标签表ORDERBY(tenant_id,user_id,tag_id,expire_date)-- 历史版本按过期日期共存随 TTL 自然清理effective_date 在键内会让每次刷新永久堆积新版本三条对应三种经典错误去重键粒度不够——risk_event_id不在键内时ReplacingMergeTree 认为同租户同日同级别是同一行把同一天的多条不同风险事件互相去重了。去重键必须是业务身份的完整表达照抄 Doris UNIQUE KEY 的每一段一个都不能省键选了设备维度而非身份维度——一台测试机登过两个账号键里没有 global_user_id 就会让两个用户的映射互相覆盖。这是唯一性到底定义在谁身上的建模问题引擎翻译逼着你重新回答一遍版本共存键选错——标签表想保留带过期日期的多版本标签TTL 自然清理键里放effective_date生效日意味着每次刷新标签都生成新键值、旧版本永远不被覆盖表会无限膨胀换成expire_date同标签的新版本覆盖旧版本过期版本随 TTL 消亡。一字之差一个是版本化存储一个是无限堆积。这三条的共性在 Doris 上它们全都不会发生UNIQUE KEY 主键写死了身份是翻译动作逼出来的二次建模。翻译的价值恰恰在这里——它强制你把这张表的唯一性到底是什么重新回答一遍。七、结语四幕收拢成一张语义翻译对照表Doris UNIQUE KEY 语义ClickHouse 侧等价物代价主键 upsert写入即去重ReplacingMergeTree(版本列) ORDER BY 含去重键 optimize_on_insert去重键要与查询剪枝前缀共享一段键序查询永远见最新版本读端FINAL45 处手写 SQL合并滞后的时间窗内必须显式声明分页场景另需 OPTIMIZE 兜底INSERT INTO SELECT 显式刷新派生表废弃 MV按块聚合语义错误改每日全历史回算幂等口径引入回算工具与调度派生列的写入方按谁推断得出谁写分工UPDATE 指定列双写入方各写整行版本列仲裁列的归属要重新划界两条可迁移的方法论其一OLAP 引擎迁移的正确单位是语义而不是表——先列出旧引擎给使用者的每一层承诺写入去重、读时可见性、派生刷新再逐层找新引擎的等价物与代价语法相似但语义单位不同的地方MV 按块聚合必然藏着错。其二读正确性要在部署形态上分层设计批量分析可等合并、可带 FINAL交互式分页不行需要OPTIMIZE 提前合并这类运营手段补位——同一张表在不同读路径下的正确性策略可以是不同的。仓库ClickHouse DDL 在backend/sql/clickhouse/引擎路由在backend/app/core/service/internal/data/data.go回算工具在backend/cmd/uba-ingestGitHubgithub.com/tx7do/go-wind-ubaGiteegitee.com/tx7do/go-wind-uba

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑