为什么放弃 ClickHouse
在 coomia-dip 的存储选型中,我们最初选择了 ClickHouse 作为 OLAP 引擎,与 PostgreSQL 构成"OLTP + OLAP"双引擎架构。但在 6 个月的实际使用后,我们做出了一个艰难的决定——放弃 ClickHouse,全面迁移到 Apache Doris。这篇 ADR(Architecture Decision Record)详细记录了这个决策的背景、评估过程、迁移方案和事后反思。核心教训:在中小规模场景下,"一个好用的引擎"胜过"两个理论最优的引擎"。
“系列:S14 工程实录 · 第 2 篇 | 难度:中级 | 阅读时间:15 分钟
为什么放弃 ClickHouse
#TL;DR
在 coomia-dip 的存储选型中,我们最初选择了 ClickHouse 作为 OLAP 引擎,与 PostgreSQL 构成"OLTP + OLAP"双引擎架构。但在 6 个月的实际使用后,我们做出了一个艰难的决定——放弃 ClickHouse,全面迁移到 Apache Doris。这篇 ADR(Architecture Decision Record)详细记录了这个决策的背景、评估过程、迁移方案和事后反思。核心教训:在中小规模场景下,"一个好用的引擎"胜过"两个理论最优的引擎"。
#1. 背景:我们为什么需要 OLAP
#1.1 coomia-dip 的查询场景
coomia-dip 作为本体驱动的决策平台,面临两类截然不同的查询需求:
事务性查询(OLTP):
- 按 ID 查找单个 Object
- 创建/更新/删除 Object
- Action 执行时的状态检查
- 权限验证
分析性查询(OLAP):
- 跨大量 Object 的聚合分析(如"过去 30 天各产线的良品率趋势")
- 多维下钻(按时间、地域、产品线分组)
- 复杂 JOIN(Object 之间通过 Link 关联后的聚合)
- 时序分析(设备传感器数据的趋势和异常检测)
在 v1 阶段,我们用 PostgreSQL 处理所有查询。对于小数据量(10 万级),这完全够用。但当测试数据量增长到百万级时,分析性查询开始变得不可接受——一个典型的聚合查询需要 10-30 秒。
#1.2 为什么最初选择 ClickHouse
在 2024 年中的技术选型中,ClickHouse 有几个显著优势:
- 极致的列式存储性能:ClickHouse 在 OLAP 基准测试中几乎稳居第一
- 活跃的社区:GitHub 30k+ stars,中文社区活跃
- MergeTree 引擎:天然支持时序数据和实时聚合
- 成熟的生态:与 Kafka、Spark、Flink 都有成熟的集成方案
- SQL 兼容:支持标准 SQL,学习成本低
基于这些理由,我们在 v2 架构中引入了 ClickHouse,形成了"PostgreSQL(OLTP)+ ClickHouse(OLAP)+ MinIO(对象存储)"的三层存储架构。
#2. ClickHouse 带来的问题
#2.1 数据同步的噩梦
引入 ClickHouse 后,我们面临的第一个问题就是数据同步。当用户通过 Action 修改了一个 Object 时,这个变更需要:
- 写入 PostgreSQL(保证事务性)
- 同步到 ClickHouse(保证分析查询的数据新鲜度)
我们尝试了三种同步方案:
方案 A:双写 在 Action Engine 中同时写入 PostgreSQL 和 ClickHouse。问题:两个数据库没有分布式事务保证,一旦 ClickHouse 写入失败,数据就会不一致。
方案 B:CDC(Change Data Capture) 通过 Debezium 捕获 PostgreSQL 的 WAL 日志,实时同步到 ClickHouse。问题:引入了新的中间件(Debezium + Kafka),增加了运维复杂度;同步延迟在高负载下可达数秒。
方案 C:定时批量同步 每 5 分钟全量或增量同步。问题:数据延迟太大,用户创建 Object 后立即查分析报表看不到。
我们最终选择了方案 B(CDC),但它带来的运维负担远超预期。
#2.2 Schema 变更的痛苦
在 coomia-dip 中,用户可以动态添加 ObjectType 的属性。在 PostgreSQL 中,我们使用 JSONB 列存储动态属性,Schema 变更几乎零成本。
但在 ClickHouse 中,情况完全不同:
- ClickHouse 的 ALTER TABLE ADD COLUMN 在大表上非常耗时
- ClickHouse 不支持 JSONB(虽然有实验性的 JSON 类型,但性能和稳定性都不理想)
- 每次用户添加新属性,都需要在 ClickHouse 中执行 DDL
我们不得不维护一套复杂的"Schema 同步器",它需要:
- 监听 Schema Registry 的变更事件
- 将 coomia-dip 的类型映射到 ClickHouse 的列类型
- 执行 ALTER TABLE
- 处理各种边界情况(类型不兼容、列名冲突等)
这套 Schema 同步器的代码量超过了 3000 行,Bug 频繁,成为整个系统中最不稳定的组件。
#2.3 JOIN 的局限性
ClickHouse 对 JOIN 的支持是有限的。虽然它支持各种 JOIN 语法,但在以下场景中性能急剧下降:
- 多表 JOIN:OQL 中一个典型的查询可能涉及 3-5 个 ObjectType 的关联查询,ClickHouse 在 3 个以上的表 JOIN 时性能显著下降
- 大表 JOIN 大表:当两个参与 JOIN 的表都超过百万行时,ClickHouse 的内存消耗可能导致 OOM
- 频繁的小查询:ClickHouse 针对大批量查询优化,单条查询的延迟(约 50-100ms)比 PostgreSQL(约 1-5ms)高一个数量级
#2.4 Windows 开发环境的兼容性
这个问题看似小,但实际影响很大。我们的开发团队主要使用 Windows + WSL2 环境。ClickHouse 在 WSL2 中运行时存在以下问题:
- 内存分配器(jemalloc)在 WSL2 中偶发 crash
- 文件系统性能在 WSL2 挂载的 Windows 目录下急剧下降
- Docker Desktop 中运行 ClickHouse 的内存消耗是 Linux 原生的 2-3 倍
这意味着开发者在本地跑集成测试时,经常遇到 ClickHouse 相关的随机失败。
#2.5 运维复杂度
引入 ClickHouse 后,我们的运维清单增加了:
- ClickHouse 集群的部署和监控
- Debezium + Kafka 的部署和监控
- Schema 同步器的部署和监控
- 数据一致性检查脚本
- ClickHouse 的备份和恢复
对于一个 4 人团队来说,这个运维负担是不可承受的。
#3. 评估替代方案
#3.1 评估标准
我们制定了 7 个评估标准:
| 标准 | 权重 | 说明 |
|---|---|---|
| OLAP 性能 | 25% | 百万级数据的聚合查询性能 |
| OLTP 兼容性 | 20% | 能否同时处理事务性查询 |
| JOIN 能力 | 15% | 多表 JOIN 的性能和正确性 |
| Schema 灵活性 | 15% | 动态 Schema 变更的支持 |
| 运维简单性 | 10% | 部署、监控、备份的复杂度 |
| 开发者体验 | 10% | 本地开发环境的友好度 |
| 生态成熟度 | 5% | 社区活跃度、文档质量 |
#3.2 候选方案
我们评估了以下方案:
方案 1:继续 PostgreSQL + ClickHouse 保持现状,投入更多精力优化同步和 Schema 管理。
方案 2:PostgreSQL + StarRocks StarRocks 是 ClickHouse 的竞品,号称对 JOIN 更友好。
方案 3:Apache Doris Doris 是一个兼容 MySQL 协议的 MPP 数据库,同时支持 OLTP 和 OLAP。
方案 4:DuckDB(嵌入式) DuckDB 作为嵌入式 OLAP 引擎,无需独立部署。
#3.3 评估结果
| 标准 | PG+CH | PG+StarRocks | Doris | DuckDB |
|---|---|---|---|---|
| OLAP 性能 | 9 | 9 | 8 | 7 |
| OLTP 兼容性 | 5 | 5 | 8 | 3 |
| JOIN 能力 | 5 | 7 | 8 | 9 |
| Schema 灵活性 | 4 | 5 | 7 | 8 |
| 运维简单性 | 3 | 4 | 8 | 10 |
| 开发者体验 | 4 | 5 | 7 | 9 |
| 生态成熟度 | 9 | 7 | 7 | 6 |
| 加权总分 | 5.8 | 6.2 | 7.6 | 7.0 |
Doris 以 7.6 分胜出。它的核心优势是"够用的 OLAP 性能 + 足够的 OLTP 能力 + 简单的运维"。
#4. 为什么 Doris 胜出
#4.1 统一引擎,消灭数据同步
Doris 最大的优势是它同时支持 OLTP 和 OLAP 查询。这意味着我们不再需要维护两个数据库之间的数据同步。数据只写一次,既可以做事务性查询,也可以做分析性查询。
这一个优势就消灭了我们 30% 的运维工作量和 Schema 同步器的全部 3000 行代码。
#4.2 MySQL 协议兼容
Doris 兼容 MySQL 协议,这意味着:
- 大量现成的客户端库可以直接使用
- 开发者已经熟悉的 MySQL SQL 语法可以直接使用
- 迁移成本大大降低
#4.3 优秀的 JOIN 能力
Doris 的 MPP 架构天然适合分布式 JOIN。在我们的基准测试中,3 表 JOIN 查询 Doris 比 ClickHouse 快 2-3 倍,5 表 JOIN 快 5-10 倍。
#4.4 动态 Schema 支持
Doris 2.x 引入了 Variant 类型,类似于 PostgreSQL 的 JSONB,支持动态 Schema。这完美匹配了 coomia-dip 的需求——用户可以动态添加属性,而无需执行 DDL。
#4.5 轻量级部署
在开发环境中,Doris 可以以单节点模式运行,资源消耗远低于 ClickHouse。在生产环境中,Doris 的 FE+BE 架构也比 ClickHouse 集群 + Debezium + Kafka 简单得多。
#5. 迁移过程
#5.1 迁移策略
我们采用了"渐进式迁移"策略:
Phase 1(2 周):搭建 Doris 环境,完成数据迁移脚本 Phase 2(2 周):将读查询逐步切换到 Doris(写仍走 PostgreSQL) Phase 3(1 周):将写操作切换到 Doris Phase 4(1 周):下线 PostgreSQL 和 ClickHouse
#5.2 数据迁移
数据迁移的核心挑战是类型映射。coomia-dip 的 ObjectType 属性有 12 种类型,需要映射到 Doris 的列类型:
| coomia-dip 类型 | PostgreSQL 类型 | Doris 类型 |
|---|---|---|
| STRING | TEXT | VARCHAR(65533) |
| INTEGER | INTEGER | INT |
| LONG | BIGINT | BIGINT |
| DOUBLE | DOUBLE PRECISION | DOUBLE |
| BOOLEAN | BOOLEAN | BOOLEAN |
| TIMESTAMP | TIMESTAMP | DATETIME(6) |
| DATE | DATE | DATE |
| ARRAY | JSONB | ARRAY |
| MAP | JSONB | MAP |
| GEO_POINT | POINT | VARCHAR (GeoJSON) |
| GEO_SHAPE | GEOMETRY | VARCHAR (GeoJSON) |
| DYNAMIC | JSONB | VARIANT |
迁移脚本运行了约 4 小时(总数据量约 500GB),期间未发生数据丢失。
#5.3 查询层适配
OQL 查询引擎需要从"PostgreSQL + ClickHouse 双路由"改为"Doris 单路由"。这实际上是一个简化——删除了约 2000 行路由和适配代码。
#5.4 测试覆盖
迁移期间,我们编写了 200+ 个迁移专用测试用例,覆盖:
- 所有 12 种数据类型的读写
- 边界值(NULL、空字符串、最大值、最小值)
- 并发读写
- 大批量导入
- OQL 查询兼容性
所有 200+ 测试通过后,我们才执行了正式迁移。
#6. 迁移后的效果
#6.1 性能对比
| 场景 | PG+CH | Doris | 变化 |
|---|---|---|---|
| 单 Object 查询 | 2ms | 5ms | -60% |
| 百万级聚合 | 800ms | 1.2s | -33% |
| 3 表 JOIN 聚合 | 3.5s | 1.1s | +218% |
| 5 表 JOIN 聚合 | 12s | 2.3s | +422% |
| 批量导入(10 万行) | 8s | 6s | +33% |
Doris 在单行查询和简单聚合上略逊于 PG+CH 组合,但在 JOIN 场景下有压倒性优势。考虑到 coomia-dip 的查询以 JOIN 为主(Object 之间总是有关联关系),Doris 是更好的选择。
#6.2 运维简化
| 指标 | 迁移前 | 迁移后 |
|---|---|---|
| 需要运维的组件 | 5(PG+CH+Kafka+Debezium+Schema同步器) | 1(Doris) |
| 配置文件数 | 12 | 3 |
| 数据同步延迟 | 1-5s | 0(无需同步) |
| 随机测试失败率 | 5% | 0.2% |
#6.3 代码量减少
| 模块 | 删除行数 |
|---|---|
| Schema 同步器 | -3,200 |
| 查询路由器 | -2,100 |
| CDC 配置 | -800 |
| ClickHouse 适配器 | -1,500 |
| 合计 | -7,600 |
7,600 行代码的删除,意味着 7,600 行代码的潜在 Bug 被消灭了。
#7. 事后反思
#7.1 我们错在哪里
回顾这个决策,我们犯了几个错误:
错误 1:被基准测试迷惑 ClickHouse 在标准 OLAP 基准测试(如 TPC-H、ClickBench)中的表现确实惊人。但基准测试的场景(单表大规模聚合)与我们的实际场景(多表 JOIN + 动态 Schema)差距很大。
错误 2:低估了"额外组件"的成本 引入 ClickHouse 不只是引入一个数据库——它还带来了 Debezium、Kafka、Schema 同步器等一系列"附属组件"。这些组件的运维成本加在一起,远超 ClickHouse 本身。
错误 3:没有做足够的 PoC 我们在选型时做了性能基准测试,但没有做足够的集成测试。如果我们花两周时间做一个完整的 PoC(包括数据同步、Schema 变更、JOIN 查询),可能在第二个月就能发现问题。
#7.2 ClickHouse 适合什么场景
ClickHouse 并不是一个坏产品——它在以下场景中依然是最佳选择:
- 日志分析(固定 Schema,写入量大,单表查询)
- 时序数据分析(IoT 传感器、监控数据)
- 大宽表报表(预聚合的数据仓库)
但它不适合我们的场景——动态 Schema、频繁 JOIN、事务性和分析性查询共存。
#7.3 ADR 的价值
这次经历让我们深刻认识到 ADR(Architecture Decision Record)的价值。我们在做 ClickHouse 决策时没有写 ADR——如果当时写了,就需要显式地列出假设、约束和风险,可能会更早发现问题。
从这次之后,我们规定所有重大技术决策都必须写 ADR,包括:
- 决策背景和问题陈述
- 评估的方案和标准
- 选择的方案和理由
- 已知的风险和缓解措施
- 审查者和批准日期
#8. 给读者的建议
#8.1 存储选型清单
如果你也在做存储选型,以下清单可能有用:
- 明确你的查询模式:OLTP 为主、OLAP 为主,还是混合?
- 评估 JOIN 需求:你的数据模型有多少关联关系?
- 考虑 Schema 变更频率:Schema 是固定的还是动态的?
- 计算总运维成本:不只是数据库本身,还有周边的同步、监控、备份组件
- 在你的真实场景下做 PoC:不要只看基准测试
- 考虑开发者体验:团队在本地环境能否方便地跑起来
- 预留迁移路径:如果选错了,迁移的成本是多少
#8.2 "足够好"胜过"理论最优"
这是这次经历最深刻的教训。在中小规模场景下(数据量 < 10TB),一个"足够好"的统一引擎,几乎总是胜过两个"理论最优"的专用引擎。因为:
- 减少了数据同步的复杂度
- 减少了运维的工作量
- 减少了代码的复杂度
- 减少了团队需要掌握的技术栈
当然,在超大规模场景下(数据量 > 100TB),专用引擎的性能优势可能会压过统一引擎的简单性。但在做出这个判断之前,请先确认你真的有那么大的数据量。
#9. 迁移的技术细节
#9.1 零停机迁移方案
为了实现零停机迁移,我们设计了一个"影子写入"机制:
Phase 1: 影子写入
├── 所有写入 → PostgreSQL(主)
├── 所有写入 → Doris(影子)
└── 所有读取 ← PostgreSQL
Phase 2: 读切换
├── 所有写入 → PostgreSQL(主)
├── 所有写入 → Doris(影子)
└── 所有读取 ← Doris(逐步切换)
Phase 3: 写切换
├── 所有写入 → Doris(主)
└── 所有读取 ← Doris
Phase 4: 下线旧系统
├── 停止 PostgreSQL
├── 停止 ClickHouse
└── 停止 Debezium + Kafka
#9.2 数据一致性验证
在每个 Phase 结束时,我们运行数据一致性验证脚本:
- 比较两个数据库的行数
- 随机抽取 1000 行数据逐字段对比
- 验证所有 ObjectType 的属性是否完整迁移
- 运行全量 OQL 回归测试
#9.3 回滚预案
每个 Phase 都有对应的回滚方案:
- Phase 1 回滚:停止 Doris 影子写入
- Phase 2 回滚:将读切回 PostgreSQL
- Phase 3 回滚:将写切回 PostgreSQL
- Phase 4 回滚:从备份恢复 PostgreSQL
实际迁移中,我们没有使用任何回滚方案——一切顺利。
#10. 总结
放弃 ClickHouse 是 coomia-dip 项目中最痛苦的技术决策之一——不是因为 ClickHouse 不好,而是因为我们为这个错误选型付出了 6 个月的时间成本。但这个经历也教会了我们:技术选型没有银弹,只有"在你的场景下最合适的选择"。
如果可以重来,我们会在 Day 1 就选择 Doris。但没有那 6 个月的 ClickHouse 经验,我们可能也不会真正理解为什么 Doris 是更好的选择。
#Key Takeaways
- 基准测试不等于真实场景:ClickHouse 在标准测试中很快,但不适合我们的多表 JOIN + 动态 Schema 场景
- 总运维成本 > 单组件成本:ClickHouse 本身不贵,但加上 Debezium + Kafka + Schema 同步器,成本翻了 3 倍
- "足够好"的统一引擎 > "理论最优"的专用引擎组合:对于中小规模场景(< 10TB)
- 做 PoC 时要覆盖真实场景:特别是数据同步、Schema 变更、复杂 JOIN
- 重大技术决策要写 ADR:显式记录假设、约束和风险
#Next Article
下一篇:S14-03 8 Layer 合并为 3 进程 — 我们将讲述为什么 8 个 Layer 在开发期太重了,以及如何在保持架构清晰的同时合并为 3 个进程。
Tags: #coomia-dip #ClickHouse #Doris #存储选型 #ADR #架构决策 #数据库迁移