
本文字数:11994;估计阅读时间:30 分钟
作者:Fiveonefour & ClickHouse Team
本文在公众号【ClickHouseInc】首发

摘要
· ORM 已在 Postgres、MySQL 等 OLTP(事务型)数据库中被广泛采用,对许多开发者来说非常有用。
· ClickHouse 等 OLAP(分析型)数据库也可能从 ORM 抽象中获益。
· 鉴于 OLTP 与 OLAP 在语义上的根本差异,现有的事务型 ORM 并不适合直接用于 OLAP。
· Moose OLAP(https://docs.fiveonefour.com/moose/getting-started/from-clickhouse)(隶属于 MooseStack(https://github.com/514-labs/moose))是一个开源、基于 MIT 协议的类 ORM 接口,专为 ClickHouse 构建,借鉴了事务型 ORM 的设计理念,但已针对 OLAP 场景进行了优化。
本文希望引发 社区(https://join.slack.com/t/moose-community/shared_invite/zt-2fjh5n3wz-cnOmM9Xe9DYAgQrNu8xKxg) 讨论,探讨一个问题:一个真正高效的 OLAP ORM 应该具备哪些特性?
现代应用越来越多地引入面向用户的分析功能和 AI 能力——这些特性依赖于对大规模数据集的聚合分析。这也促使开发团队逐步走出传统的 OLTP(事务型)数据库(如 Postgres(https://www.postgresql.org/)、MySQL(https://www.mysql.com/)),转向 ClickHouse 这类 OLAP(分析型)数据库的世界——也就是说,超出了现有对象关系映射器(ORM)通常的能力范围。
尽管争议不断,像 Prisma(https://www.prisma.io/)、Drizzle(https://orm.drizzle.team/)、SQLAlchemy(https://www.sqlalchemy.org/) 这样的 ORM 仍然流行,因为它们允许你在代码中通过特定语言的对象来定义和管理数据库结构。这样不仅可以获得自动补全、类型检查、跳转定义和安全重构等 IDE 原生体验,还能让数据库操作代码更简洁、贴近业务逻辑。然而,在 SQL 之上引入抽象层也可能带来一些弊端,例如可观察性变差、灵活性受限,甚至存在抽象失效的风险。
尤其在分析型工作负载中,这些 ORM 的短板被进一步放大。OLAP 查询往往比 CRUD 操作更复杂,也更难优化。同时,分析型数据库通常还包含很多特有的功能——比如窗口函数、增量聚合、表引擎等——而这些功能往往无法通过现有 OLTP ORM 来使用。
那我们是否可以将 ORM 的部分优势也带入 OLAP 世界呢?或者说,我们为什么不直接拿已有的 ORM 来满足分析型场景的需求?
面向 OLTP 的 ORM 不一定适用于 OLAP
一开始看,扩展现有的 OLTP ORM 看起来是条高效路径。毕竟,它们已经具备基础能力:模式定义 API、查询构建器、迁移工具,还有庞大的用户社区。但我们尝试过这种做法(埋下伏笔!),最终发现并不可取。OLTP 和 OLAP 数据库的设计逻辑存在根本差异,贸然复用会导致大量混淆甚至反模式的用法。
OLTP 的模式定义 API 是基于“按行存储、写入时强制约束”的逻辑设计的,而像 ClickHouse 这样的 OLAP 数据库则是“按列存储、只追加写入,并依赖后台任务去重”。这些核心架构差异最终会影响 ORM 模型设计的基本含义。
如果你直接将一个为 OLTP 场景设计的 ORM(比如 Drizzle)接入 ClickHouse 这样的 OLAP 数据库,很容易产生一种“看起来兼容”的错觉。虽然两者的 API 形式可能类似,但它们的默认行为、数据保障机制,甚至是相同方法名称的语义,都有很大不同。
我们可以通过两个常见的例子来说明这种差异:nullable() 和 unique()。
示例一:可空性(Nullability)的逻辑是反过来的
在 OLTP 系统中,字段通常默认是可以为 NULL 的。如果你希望某个字段必须有值,需要显式添加约束来声明该字段是 必填的:
import { integer, pgTable } from "drizzle-orm/pg-core";
const table = pgTable('table', {
integer: integer('integer').notNull(), // in OLTP, you override nullable assumption
});
而在 OLAP 系统中,由于数据按列存储,在列中出现 NULL 值会带来较高的存储和处理开销。因此,像 ClickHouse 这样的数据库通常默认所有字段都是必填的。要允许某字段为空,必须明确 将该列声明为 NULLABLE(https://clickhouse.com/docs/sql-reference/data-types/nullable)。
CREATE TABLE t_null(
x Int8,
y Nullable(Int8) -- in OLAP, you override required assumption
) ENGINE TinyLog
如果不加区分地在 OLTP 和 OLAP 中使用 .nullable() 或 .notNull() 这样的统一 API,会造成默认语义的混乱,同时掩盖对性能的影响。开发者可能定义出一个表结构在语义上无误,但实际查询性能却非常糟糕。
示例二:“唯一性”在 OLAP 中的含义完全不同
再来看唯一性约束。在 OLTP 数据库(如 Postgres、MySQL 等)中,唯一性是通过写入时校验实现的。
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT UNIQUE -- in OLTP, you enforce uniqueness positively
);
在这个例子中,一旦你尝试插入重复的 `id` 或 `email`,数据库就会立即拒绝写入。之所以可以这样处理,是因为 OLTP 系统的写入操作通常是逐行的小规模事务,数据库可以高效地在写入过程中执行唯一性校验。
但 OLAP 的处理方式完全不同。分析型系统通常以批量方式摄入数据,在写入过程中执行逐行去重的代价极高,甚至可能严重拖慢整个数据导入过程。因此:
-
所有数据行都会直接写入,不做即时校验;
-
数据去重会在后台的合并过程中执行;
-
合并引擎是否能正确处理重复数据,取决于你是否提供了明确的去重规则。
也就是说,在 OLAP 中,仅使用 .unique() 并不能真正实现唯一性保障。你必须同时定义:
-
如何识别重复记录(例如通过排序键)
-
当发现重复时保留哪一条记录(使用版本字段、时间戳或序列号)
-
何时执行唯一性逻辑(是在后台合并阶段还是在查询时)
下面是一个在 ClickHouse 中定义唯一事件表的示例:
CREATE TABLE events
(
event_id UUID,
user_id UInt64,
event_time DateTime,
payload String,
version UInt64 -- The "version" used to decide which duplicate wins
)
ENGINE = ReplacingMergeTree(version) -- The engine that handles deduplication
ORDER BY (event_id); -- The "key" that defines what a duplicate is
这个表的行为可以总结为
-
按 event_id 对数据行进行分组
-
保留 version 值最大的那一行
-
其他重复记录在后台合并后被丢弃
如果你修改了排序键或去掉了 version 字段,那么去重的逻辑将会完全改变。
如果我们将 OLTP ORM 中的 .unique() 方法直接套用到 OLAP 中,并默默将其映射为这种去重行为,那么就会导致语义混淆。在 OLTP 场景中,.unique() 意味着“数据中不能存在重复”;而在 OLAP 中,它的含义变成了“数据摄入时允许存在重复,最终保留哪条由你定义的逻辑来决定,且只有在后台合并之后才生效”。
换句话说,如果你在 OLTP 和 OLAP 中使用相同的建模 API,开发者可能会误以为两者的行为一致——但事实并非如此,也不应该如此。更稳妥的做法是专门为 OLAP 设计一套建模 API,使其能清晰地表达 OLAP 系统的默认行为和语义,而不是在事务型 ORM 的基础上强行附加对 OLAP 的支持。
那么,一个适用于 OLAP 的 ORM 应该是什么样子呢?
简单来说,我们认为,一个优秀的 OLAP 开发体验(DX)应该有选择地借鉴事务型 ORM 的优点,同时摒弃那些与分析型数据库不兼容的设计:
借鉴事务型 ORM 的核心理念:
-
使用代码来定义数据库 schema,可实现版本控制、PR 审核、类型安全等优势
-
提供接近 SQL 风格的查询构建器,支持编写完整的 ClickHouse 查询,拥有 IDE 自动补全与编译期检查能力
-
本地开发与持续集成(CI)应尽可能贴近生产环境,以便在上线前准确预览 schema 变更
引导符合 OLAP 特性的行为设计:
-
建立符合 OLAP 语义的默认行为(如字段可为空、唯一性和去重逻辑等)
-
假设部分 schema 变更可能源自系统外部(如 CDC 数据流或下游分析人员操作)
-
支持原生的 OLAP 迁移策略(例如使用版本迭代替代直接 ALTER)
-
提供兼容整个数据基础设施链路的数据类型(如支持 Kafka(https://kafka.apache.org/) 与 ClickHouse 间的数据流通)
保留 OLAP 的强大能力:
-
ClickHouse 等 OLAP 数据库具备强大的计算引擎和丰富的分析功能。良好的开发体验应该在简洁性和高级能力之间取得平衡:既能覆盖常用场景,又为复杂需求提供灵活的扩展能力
-
分析型负载以聚合、窗口函数和统计分析为核心,并伴随功能强大的函数库。DX 设计应让这些能力更加易用,而不是将其隐藏或简化到无法使用
用 MooseStack 在 OLAP 中实践 ORM 的设计理念
对我们来说,这些理念早已从理论变成了实践,在我们构建 MooseStack(https://docs.fiveonefour.com/moose) 的过程中不断验证。MooseStack 是一套面向分析型基础设施的开源开发工具集,其中的核心模块之一 Moose OLAP(https://docs.fiveonefour.com/moose/olap),为 ClickHouse 提供了基于 Typescript 和 Python 的抽象接口。虽然 Moose OLAP 严格意义上不是一个传统 ORM,但我们确实从事务型 ORM 的设计理念中受益良多。
将 Schema 作为可组合、可跨系统复用的代码
一切的起点,就是“以代码管理 schema”——这是 ORM 最具价值的部分之一。我们最初的 Moose OLAP 原型就是通过复用 Prisma schemas(https://www.prisma.io/) 来建模 ClickHouse 的表结构。
model ClickHouseTable {
id Int
createdAt DateTime
name String?
}
虽然最初实现的原型带来了熟悉的开发体验,但在实际使用过程中,我们很快遇到了几项现实难题:
-
模型不能直接在代码中引用。 我们最终不得不从 Prisma 的模型中额外生成 TypeScript 类型,才能在业务逻辑中使用这些定义。
-
组合和继承能力严重不足。 在 OLAP 场景中,我们更倾向于构建去范式的派生表,希望在类型层面灵活复用字段,组装成一个宽表结构,而无需依赖 join。但 Prisma 并不支持真正的 schema 继承,它主要通过关系型设计(即范式表 + 查询时组合)来实现字段复用。这显然与 OLAP 的建模方式不兼容:OLAP 系统通常不使用外键、join 成本高昂、预先计算好的去范式结构反而更具优势。我们真正需要的是表内原生支持字段扩展和混入(mixins)的能力。
-
缺乏 OLAP 原生语义支持。 Prisma 中的数据类型无法很好地映射 ClickHouse 的核心特性,例如 Nullable、LowCardinality、特定引擎标签等。
由于我们本就需要手动生成 TypeScript 类型来完成业务开发,与其绕远路,不如直接选择使用原生语言类型(类似 Drizzle 或 SQLModel(https://sqlmodel.tiangolo.com/) 的做法),并通过注解来补充 ClickHouse 所需的语义。这就是我们现在在 Moose OLAP 中采用的数据建模方式:
interface DataModel {
columnName: Key<string>;
secondColumnName: Date;
}
export const table = new OlapTable<DataModel>("table_name")
下面是一些对于 OLAP 场景下实现“以代码管理 schema”来说非常关键的设计理念:
1)面向 OLAP 的配置机制。 类似“order by” 字段选择、ClickHouse 引擎设置、字段是否 Nullable 等关键配置项,均通过接口形式暴露,并默认采用适用于 OLAP 负载的合理参数。
export const table = new OlapTable<DataModel>("table_name", {
orderByFields: ["columnName"]
engine: ClickHouseEngines.MergeTree
})
2)schema 与表结构的解耦和灵活组合能力。 由于 schema 是一个 TypeScript 接口(或 [Pydantic](https://docs.pydantic.dev/latest/) 模型),你可以像写代码一样自由扩展和组合这些类型:
interface DataModel {
columnName: Key<string>;
secondColumnName: Date;
}
interface ExtendedModel extends DataModel {
payload: Record<string, Any>; // JSON column
version: number;
}
const extendedTable = new OlapTable<ExtendedModel>("another_table_name")
这种方式非常契合 OLAP 数据流程:你常常需要从上游表生成派生表,新表结构大体一致,只是增加了几个计算字段。支持复用和扩展 schema 不仅可以减少冗余,还能清晰地展现数据的来源与演变路径。
这也意味着,如果你的事务型 ORM 目前已经支持(或可以生成)原生语言类型,那么这些类型也能作为基础模型,在分析系统中继续使用和扩展。
3)通过联合类型实现系统间兼容性。 一个字段的基础类型(例如 string)可以通过类型标签扩展出特定的 ClickHouse 类型(例如 LowCardinality 或 ClickHouseInt<"int8">),使模型既具备通用性,又能准确表达 OLAP 的存储语义。
interface DataModel {
columnName: string & LowCardinality; //ClickHouse specific typing
secondColumnName: number & ClickHouseInt<'int8'>; //ClickHouse specific typing
}
这样一来,基础类型可以被序列化用于 Kafka 的 schema registry,而类型标签则告诉 Moose 如何将其正确映射到 ClickHouse 中。通过这种方式,你可以在 Kafka 主题和 ClickHouse 表之间共用相同的类型定义,从而在数据从流系统流入 OLAP 表的过程中,有效避免 schema 漂移问题。
这一能力的实现依赖于 Typia(https://typia.io/)(用于 TypeScript)和 Pydantic(https://docs.pydantic.dev/latest/)(用于 Python)等 schema 校验库。这样不仅可以将自定义类型传递到 ClickHouse schema 中,还能基于这些类型约束,在 Moose APIs(https://docs.fiveonefour.com/moose/apis) 中实现数据摄入和查询时的运行时校验。
在复杂查询中引入类似 ORM 的 SQL 类型机制
当 OLAP schema 已经作为代码中的对象定义存在时,顺理成章的下一步,就是利用这些类型来构建查询,以获得类型安全、自动补全等开发体验优势。
传统 ORM 通常会提供高级的“关系型 API”(如 User.findMany()),用于简化 SQL 查询。这种设计对简单的按行增删改查操作非常友好,但对于复杂分析场景就显得力不从心。在 OLAP 查询中,你很少只操作一行数据,更多是对数百万行数据进行聚合和计算。通用的 CRUD 辅助方法无法胜任诸如“计算每位用户在过去一小时内的近似 top-k 项”或“执行多个组之间的方差分析(ANOVA)”等操作。
因此,Moose OLAP 借鉴了 Drizzle 所倡导的“你会 SQL,就会用它(https://orm.drizzle.team/docs/data-querying)”的理念。当前,你可以通过带标签的 sql 模板字符串来编写查询。在模板中引用你在代码中定义好的表结构和字段名称,可以比传统的原始 SQL 字符串获得更好的类型安全性,同时又不限制 SQL 本身的表达能力。
sql`SELECT
${events.columns.user_id},
approxTopK(5, ${events.columns.event_name}
FROM ${events}
GROUP BY ${events.columns.user_id}`
ClickHouse 的 SQL 方言功能丰富,语法灵活。初学者在刚接触时可能会感到复杂难懂。而自动补全与类型校验机制可以有效帮助开发者规避一些常见的语法错误。
这就是我们目前的实现现状。不过,我们也在探索进一步引入类似 Drizzle 的链式查询构建器的可能性。这类 API 与原生 SQL 十分接近,如果你在 `GROUP BY` 中使用了未出现在 `SELECT` 中的字段,IDE 会立即提示语法错误。如果 Moose OLAP 采用类似机制,最终的使用方式可能会像这样:
db.select({
user_id: events.user_id,
top_events: approxTopK(5, events.event_name)
})
.from(events)
.groupBy(events.user_id);
问题的核心其实并不在于“做一个更好用的 API”,而在于如何兼顾两个关键目标:一方面,开发者仍然需要完整访问 ClickHouse 提供的所有能力(如函数、修饰符、查询提示等),以便理解和优化查询计划与成本;另一方面,又不能因为封装过度而造成代码难以维护,特别是在 ClickHouse SQL 方言不断演进的情况下。
想请读者思考: 在“类型安全的查询构建器”和“直接编写 SQL”之间,你认为应该如何划分边界?你希望哪些部分具备类型系统的支持(比如函数调用、组合器使用、窗口帧定义等),又希望哪些部分保留原始 SQL,以确保语义清晰、性能可控?
面向 OLAP 的 Schema 管理与迁移机制
如果你用过 OLTP ORM 管理迁移流程,你应该对这套操作非常熟悉:修改代码中的模型,生成迁移文件,应用它,然后数据库结构就和代码保持一致。这就是一种典型的 “代码优先”(code-first)方式(https://orm.drizzle.team/docs/drizzle-kit-generate),ORM 控制一切,代码成为事实来源,所有变更都通过它驱动。在一个服务独立拥有数据库 schema 和写入权限的环境中,这种模式运作非常顺畅。
但 OLAP 的现实往往更加复杂。在实际部署中,OLAP 数据库通常位于数据生产者和消费者之间的核心位置。上游可能包括 CDC 流、ETL 管道或外部 API,下游可能是可视化报表、机器学习模型、内部服务,甚至人类或 AI 智能体等分析用户。你无法完全控制整个数据链路,schema 可能会在系统之外发生变更,并影响你不了解的部分。
一些 ORM 提供了 “数据库优先”(database-first)模式(https://orm.drizzle.team/docs/drizzle-kit-pull),即从现有数据库生成代码结构。但这类方式通常无法提供自动化迁移支持,因为它假定数据库由其他工具负责管理。
在 Moose OLAP 中,我们希望找到一个兼顾开发体验与现实需求的平衡方案:
-
默认仍然采用代码为主的“事实来源”模型
-
提供稳定的迁移工具与自动化能力
-
同时能够识别并优雅处理代码与数据库结构出现偏差的情况
MooseStack 中的迁移计划设计
前文中我们已经看到,你可以用 Moose OLAP 将 OLAP 表结构用代码定义下来。在本地开发过程中,借助 Moose 的本地开发环境(https://docs.fiveonefour.com/moose/getting-started/local-dev)(通过 moose dev 启动),你可以安全地进行迭代。比如说,如果你在代码中定义了一个 events 表,并向其中新增了一个名为 status 的字段:
interface EventSchema {
id: Key<string>
number: number & ClickHouseInt<"int64"> status: string // <--- new field
}
const events = new OlapTable<EventSchema>("events")
当你保存代码更改时,终端会立即显示基础设施的更新日志。
$ moose dev
⠋ Processing Infrastructure changes from file watcher
~ Table: events
Column changes:
+ status: String
但到了生产环境,你可能会更关注迁移的可控性与稳定性。Moose Migrate(https://docs.fiveonefour.com/moose/migrate#how-it-works) 会自动生成部署过程中实际应用到数据库的 SQL 迁移语句。在上线前,你可以完整查看这份迁移计划,了解代码改动所引入的全部变更内容。这项规划能力不仅可以集成到本地开发流程中,还能融入 CI/CD 自动化与 PR 审查流程。等你真正部署到生产时,就能做到“心中有数”。
如何应对并处理 Schema 漂移
许多 ORM 及其配套的迁移工具,都是通过“代码对比代码”的方式生成迁移计划,也就是比对当前模型与上一次迁移记录之间的差异。这种方式在 OLTP 场景下通常没有问题,因为数据库结构基本只由代码控制,代码快照也能准确反映实际状态。
但 OLAP 并不具备这种封闭性。由于它往往处在多个上游生产者和下游消费者之间,schema 很可能在你不知情的情况下被其他系统修改。此时如果仍采用静态、代码驱动的迁移逻辑,就有可能生成错误或过时的 SQL。更关键的是,OLAP 数据库通常不支持事务,这意味着一旦迁移失败,回滚也会变得更加困难。
为降低风险,Moose Plan 引入了一种“代码 ↔ 实时数据库”的差异分析机制。这个做法并不新鲜,类似的机制也出现在某些 OLTP 迁移工具中(如 Alembic(https://alembic.sqlalchemy.org/en/latest/autogenerate.html))。moose plan 会将代码中的 schema 与当前数据库的实际结构进行比对。如果存在 schema 漂移,Moose 会检测出来,并将差异纳入迁移计划中,确保变更是有据可依的。
另外,在 Moose OLAP 中,你还可以为每张表显式声明“所有权”。如果某张表是由外部 CDC 管道(如 ClickHouse 的 ClickPipes(https://clickhouse.com/cloud/clickpipes))或其他 ETL 工具所管理,你可以 将其标记为(https://www.fiveonefour.com/blog/Introducing-Lifecycle-Management) EXTERNALLY_MANAGED。Moose 将观察它的状态,但不会尝试对其进行任何变更:
import { OlapTable, LifeCycle } from "@514labs/moose-lib";
interface ClickPipesUserEvent {
user_id: string;
event_type: string;
event_data: Record<string, any>;
captured_at: Date;
source_table: string;
}
const clickPipesEvents = new OlapTable<ClickPipesUserEvent>("clickpipes_events", {
lifeCycle: LifeCycle.EXTERNALLY_MANAGED
});
Moose Migrate 是 MooseStack 中我们重点投入的领域之一。接下来我们将支持更明确地表达开发者的意图,特别是在系统难以自动判断操作目的时(比如“重命名列”与“删除再创建”看起来结果一样,但语义完全不同)。同时我们也在研究 OLAP 常用的迁移策略:带版本的分阶段发布。在 OLAP 环境中,由于上下游系统复杂且耦合度高,最佳实践通常是采用类似 API 版本控制的方式,让同一个表或视图可以同时存在多个版本,并逐步淘汰旧版本。这样一来,那些依赖该表的生产者或消费者——即便它们并不在你直接控制之下——也可以根据自身节奏平滑迁移到新版数据结构。

Moose OLAP 目前已支持版本化表结构,而 ClickHouse 的 物化视图(https://clickhouse.com/docs/materialized-views)(Moose 也 已支持(https://docs.fiveonefour.com/moose/olap/model-materialized-view))为不同版本表之间的数据迁移提供了一种优雅、灵活的方案(我们觉得这个话题甚至值得单独写一篇博客——如果你感兴趣,欢迎告诉我们!)。我们也在探索是否能在 Moose Migrate 中引入自动迁移辅助逻辑,进一步简化这种版本发布模式下的设计与部署工作。
结语:构建超越传统 ORM 的 OLAP 解决方案
正如我们在文中不止一次提到的,MooseStack(https://docs.fiveonefour.com/moose) 并不仅仅止步于 Moose OLAP(https://docs.fiveonefour.com/moose/olap) 这类受 ORM 启发的功能模块。我们在 Fiveonefour(https://www.fiveonefour.com/) 的使命,是让整个分析型技术栈具备现代化的开发者体验。这其中包括流数据主题管理、编排工作流、本地开发服务器、GitHub 集成、预览分支支持,甚至 AI 辅助开发等。未来我们还将推出更多内容,敬请期待!
现在就可以动手体验 Moose OLAP,只需不到 5 分钟(https://docs.fiveonefour.com/moose/getting-started/from-clickhouse),你就能将 Moose 接入现有的 ClickHouse 部署,或者连接到 ClickHouse playground(https://clickhouse.com/docs/getting-started/playground) 环境,快速开始尝试。
我们的项目完全开源(https://github.com/514-labs/moose),同时也非常欢迎来自社区的反馈和建议。我们知道自己在很多方面还在摸索中:如果你正在大规模使用 ClickHouse,如果你已经有更优雅的 schema 注解方案,如果你探索出了低维护成本的类型化 SQL 实践,或者成功实现了 topic/table 的版本切换机制——我们非常愿意向你学习。欢迎随时加入我们的 Slack 社区(https://join.slack.com/t/moose-community/shared_invite/zt-2fjh5n3wz-cnOmM9Xe9DYAgQrNu8xKxg),和我们聊聊。
我们的目标并不是将 ORM 强套在 OLAP 上,而是希望让分析型开发具备现代 Web 技术栈那样优秀的开发体验——同时保留 OLAP 本身强大的功能和价值。
征稿启示
面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com

2万+

被折叠的 条评论
为什么被折叠?



