数据库基础体系 · 第 82/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

OLTP、OLAP 与 Lakehouse:工作负载、存储布局和数据链路选型

在数据库选型中,OLTP、OLAP 和 Lakehouse 经常被并列讨论,但它们并不处于同一个抽象层次:

  • **OLTP(Online Transaction Processing,在线事务处理)**描述的是一类以短事务、并发写入和单行或少量数据访问为主的工作负载。
  • **OLAP(Online Analytical Processing,在线分析处理)**描述的是一类以大范围扫描、聚合、连接和趋势分析为主的工作负载。
  • **Lakehouse(湖仓)**描述的是一种数据组织与数据平台架构:在对象存储上的文件数据之上,增加表格式、元数据、事务语义和计算引擎,使数据湖具备一部分数据仓库能力。

因此,“OLTP 还是 OLAP”主要是在选择工作负载对应的执行与存储方式;“是否采用 Lakehouse”主要是在选择分析数据的组织、治理和链路形态。一个系统可以同时包含三者:业务数据库承担 OLTP,Lakehouse 保存历史明细,OLAP 引擎服务报表和交互式查询。


一、先从工作负载定义,而不是从产品名称开始

1. OLTP 的核心:许多并发的小状态变更

OLTP 系统维护的是业务状态。例如,电商订单从“待支付”变为“已支付”,本质上是一个状态转换:

St+1=T(St,e)S_{t+1}=T(S_t, e)

其中:

  • StS_t 是某一时刻数据库中的业务状态;
  • ee 是一次业务事件,例如支付成功;
  • TT 是事务执行的状态转换;
  • St+1S_{t+1} 是事务提交后的新状态。

一次典型订单支付事务可能同时完成:

  1. 检查订单当前状态;
  2. 扣减库存;
  3. 写入支付记录;
  4. 更新订单状态;
  5. 提交这些变化。

如果第 3 步失败,第 1、2、4 步通常也不能单独生效。这就是事务原子性在业务上的含义。

OLTP 常见特征是:

  • 单次事务访问的行数较少;
  • 读写都要求较低延迟;
  • 并发连接数较高;
  • 更新集中在当前状态数据;
  • 需要唯一性、外键、检查约束等一致性规则;
  • 事务之间可能访问相同的行,因此需要并发控制。

这里的“单次访问行数较少”是工作负载特征,不是数据库语法限制。OLTP 数据库也可以执行全表扫描和复杂查询,只是这些查询往往不应与高峰期的在线事务争夺同一组资源。

2. OLAP 的核心:对大量数据执行低频或中频计算

OLAP 关注的不是某一行当前状态,而是数据集合的性质。例如:

SELECT
    date_trunc('month', paid_at) AS month,
    region,
    sum(amount) AS revenue,
    count(*) AS order_count
FROM orders
WHERE paid_at >= DATE '2025-01-01'
  AND paid_at <  DATE '2026-01-01'
GROUP BY 1, 2
ORDER BY 1, 2;

该查询需要:

  1. 找到时间范围内的大量订单;
  2. 读取 paid_atregionamount 等列;
  3. 按月份和地区分组;
  4. 对金额求和、对行计数;
  5. 排序并返回结果。

OLAP 的典型特征是:

  • 扫描行数多;
  • 访问列数可能少于表的总列数;
  • 聚合、排序、连接和窗口函数较多;
  • 单条查询可能消耗较多 CPU、内存和 I/O;
  • 查询并发数不一定高,但资源峰值可能很大;
  • 更关心吞吐量、扫描效率和结果延迟,而不是每个事务几毫秒完成。

一个常见误区是把 OLAP 简化成“只读”。分析系统也可能写入:

  • 批量导入事实表;
  • 生成汇总表;
  • 刷新物化视图;
  • 执行分区重写或数据整理。

区别在于,这些写入通常是批量、追加或后台维护,而不是每秒大量修改随机单行状态。

3. 负载不能只按“读多写少”分类

以下几个维度比“读多还是写多”更有区分度:

维度 OLTP 常见形态 OLAP 常见形态
事务规模 少量行、短事务 批量作业或长查询
访问模式 点查、范围查、单行更新 大范围扫描、聚合、连接
延迟目标 稳定的低延迟 查询吞吐和可接受的分析延迟
并发特征 高并发、事务彼此竞争 中低并发、单查询资源消耗大
数据变化 当前状态频繁变化 追加明细、批量修正、重算
一致性重点 事务原子性、约束、隔离 快照一致性、批处理结果一致
数据生命周期 热数据和当前状态 长历史、明细与汇总并存

还有一类常见的混合负载,例如实时风控、运营看板和 HTAP。它们可能同时需要在线更新和聚合查询,但“混合”不等于单一存储布局可以无代价地同时优化两者。


二、OLTP 为什么通常采用行式存储

1. 行式布局适合重建一条业务记录

假设订单表有:

(order_id, user_id, status, amount, created_at, shipping_address, ...)

一次订单详情查询往往需要同一个订单的多个字段:

SELECT order_id, user_id, status, amount, shipping_address
FROM orders
WHERE order_id = 1001;

行式存储会把同一行的字段组织得相对接近。通过主键索引找到目标行后,数据库可以读取这一行及其相关页,而不必为每个字段分别扫描整个列区域。

这并不表示物理上所有行一定连续排列,也不表示一次查询只读取一个磁盘块。具体页结构、溢出列、索引组织和缓存行为取决于数据库实现。但“同一行字段共同服务于记录访问”是行式布局的基本优势。

2. 随机更新需要处理行版本和索引维护

以 PostgreSQL 为例,普通表使用 MVCC(多版本并发控制)保存事务可见性信息。更新一行时,数据库通常不是简单地在原位置覆盖所有内容,而是创建新的行版本,并让并发事务根据快照判断哪个版本可见。旧版本最终需要由清理过程回收。

一次更新的成本不仅是写入新行,还可能包括:

  • 修改受影响的索引;
  • 写入 WAL(Write-Ahead Log,预写式日志);
  • 维护页面和可见性信息;
  • 处理并发事务;
  • 后台回收失效版本。

因此,OLTP 的性能不应只看“每次更新写了多少业务字段”,还要看索引数量、行宽、冲突程度和日志量。

一个简化的写入成本模型可以写成:

CupdateCpage+niCindex+CWAL+Clock+CvacuumC_{\text{update}} \approx C_{\text{page}} + n_i C_{\text{index}} + C_{\text{WAL}} + C_{\text{lock}} + C_{\text{vacuum}}

其中:

  • CpageC_{\text{page}}:读取和写入数据页的成本;
  • nin_i:需要维护的索引数量;
  • CindexC_{\text{index}}:每个索引更新的平均成本;
  • CWALC_{\text{WAL}}:日志写入和刷盘成本;
  • ClockC_{\text{lock}}:等待并发事务的成本;
  • CvacuumC_{\text{vacuum}}:旧版本后续清理的成本。

这不是某个数据库的精确性能公式,而是帮助分析瓶颈来源的模型。

3. 一个 PostgreSQL 事务示例

下面的例子使用 PostgreSQL 语义。它要求两个表已存在,并且 orders.status、库存数量等约束根据业务补充。

BEGIN;

SELECT status
FROM orders
WHERE order_id = 1001
FOR UPDATE;

UPDATE inventory
SET available = available - 1
WHERE product_id = 42
  AND available > 0;

-- 应用程序应检查上一条 UPDATE 的影响行数是否为 1。
-- 为避免库存不足时仍然创建订单,实际程序需要在这里判断并回滚。

INSERT INTO payments(order_id, paid_at, amount)
VALUES (1001, clock_timestamp(), 99.00);

UPDATE orders
SET status = 'paid'
WHERE order_id = 1001
  AND status = 'pending';

COMMIT;

这个事务包含几个重要边界:

  • FOR UPDATE 表示当前事务要锁定目标订单行,避免两个支付流程同时修改同一订单;
  • 库存扣减使用条件 available > 0,但应用仍需检查影响行数;
  • 只有 COMMIT 成功后,其他事务才应观察到完整结果;
  • 如果支付插入失败,应执行 ROLLBACK,不能只回滚某一条语句后继续提交。

在 PostgreSQL 中,事务块、行锁、MVCC 和约束共同决定了并发语义。MySQL 的具体行为还取决于存储引擎,尤其是表是否使用支持事务的 InnoDB;不能把某一产品的事务行为无条件推广到所有 MySQL 表或所有数据库。

4. 行存储并不意味着“不能分析”

对 PostgreSQL 的订单表执行聚合是合法的:

EXPLAIN (ANALYZE, BUFFERS)
SELECT region, sum(amount)
FROM orders
WHERE created_at >= TIMESTAMP '2025-01-01'
  AND created_at <  TIMESTAMP '2025-02-01'
GROUP BY region;

但当 orders 很大时,数据库可能需要访问大量数据页。即使查询只需要三列,行式页面往往仍包含同一行的其他字段。索引可以帮助缩小范围,但如果时间条件命中大部分表,顺序扫描可能反而更合理。

EXPLAIN (ANALYZE, BUFFERS) 的价值在于验证实际路径:

  • Seq Scan 表示顺序扫描;
  • Index ScanBitmap Heap Scan 表示使用索引定位数据;
  • Buffers 可帮助判断缓存命中与实际读盘;
  • 计划估算行数与实际行数差异很大时,可能需要检查统计信息、数据倾斜或谓词选择性。

索引不是“所有查询的加速器”。它对高选择性的点查和范围查更有帮助,但会增加写入成本,并可能在低选择性查询中不如顺序扫描。


三、OLAP 为什么通常采用列式存储和向量化执行

1. 列式布局减少无关数据读取

设事实表有 100 个字段,而一个报表只使用:

sale_date, product_id, quantity, amount

列式存储可以主要读取这 4 列,而不是读取每条记录的全部 100 个字段。一个简单的扫描成本近似为:

CscancCneeded(Cread(c)+Cdecode(c))C_{\text{scan}} \approx \sum_{c \in C_{\text{needed}}} \left( C_{\text{read}}(c) + C_{\text{decode}}(c) \right)

其中 CneededC_{\text{needed}} 是查询实际需要的列集合。

如果查询只访问少数列,列裁剪(column pruning)就能减少 I/O 和解压成本。相同列通常还具有更强的值相似性,例如状态列、地区列和日期列更容易压缩。

但列式存储不是在所有场景都占优。若应用经常按主键取出一整行,列式引擎可能需要读取多个列段并重建结果;大量单行更新也往往需要重写更大的列块或数据片段。

2. 向量化执行降低逐行解释开销

OLAP 引擎通常不会为每一行都调用一次完整的解释器逻辑,而是以一批行组成的向量或批次执行:

  1. 从存储层读取一批列数据;
  2. 对批次执行谓词过滤;
  3. 对剩余值进行表达式计算;
  4. 将结果送入聚合或连接算子;
  5. 重复处理下一批。

例如条件:

WHERE amount > 100

可以对一批 amount 值生成布尔选择向量,再把选择结果传给后续算子。这样可以减少函数调用、改善 CPU 缓存利用,并更容易使用 SIMD 等底层优化。

3. 分区、排序和数据跳过不是同一件事

这三个概念经常被混淆:

  • 分区(partitioning):把数据按一个或多个分区键拆成更大的目录、表分区或数据单元;
  • 排序键(sort key / ordering key):控制同一数据单元内部记录的排列顺序;
  • 数据跳过索引或统计信息(data skipping):根据最小值、最大值、布隆过滤器等元数据判断某个数据块是否不可能满足谓词。

例如按月份分区、按 user_id 排序:

2025-01/
  按 user_id 排序的数据块
2025-02/
  按 user_id 排序的数据块

查询:

WHERE created_at >= '2025-01-01'
  AND created_at <  '2025-02-01'
  AND user_id = 42

可能先跳过其他月份,再在一月份数据内利用排序和块级统计缩小读取范围。

但是:

  • 分区不是索引;
  • 排序键不保证所有查询都快;
  • 数据跳过依赖数据分布、块大小、统计信息和谓词形态;
  • 分区过多会增加元数据和调度开销;
  • 如果分区键基数极高,例如按用户 ID 建立数百万分区,通常会造成管理和查询问题。

4. ClickHouse MergeTree 中的具体表现

在 ClickHouse 中,MergeTree 系列表使用数据 part 保存数据。写入通常先形成新的 part,后台再进行合并。其常见建模元素包括:

  • PARTITION BY:决定 part 的分区归属;
  • ORDER BY:决定 part 内部的排序键和主索引组织;
  • 数据跳过索引:在适合的列和谓词上减少需要读取的 granule;
  • 后台 merge:将多个 part 合并为更大的 part。

示例:

CREATE TABLE events
(
    event_time DateTime,
    tenant_id  UInt64,
    event_type LowCardinality(String),
    value      Float64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, event_time);

这个设计表达的是:

  1. 按月形成分区,便于按时间范围管理和删除;
  2. 每个分区内按 (tenant_id, event_time) 排序;
  3. 适合包含租户和时间条件的查询,例如:
SELECT
    toDate(event_time) AS day,
    sum(value)
FROM events
WHERE tenant_id = 10
  AND event_time >= '2025-01-01 00:00:00'
  AND event_time <  '2025-02-01 00:00:00'
GROUP BY day
ORDER BY day;

ORDER BY 不是传统 OLTP 数据库意义上的唯一主键约束。它主要影响物理组织和查询剪枝,不能仅凭设置 ORDER BY (tenant_id, event_time) 就推断 tenant_id, event_time 不会重复。

MergeTree 的后台合并也带来一个重要事实:插入成功与数据已经合并完成是两个不同状态。查询通常可以读取多个 part,后台再逐步整理它们。过度频繁的小批量插入会产生大量小 part,增加合并压力;这属于写入链路和后台状态共同造成的问题,而不是单纯的 SQL 问题。


四、OLTP 与 OLAP 的查询语义也不同

1. 当前状态查询与历史事实查询

OLTP 中的订单表通常表达“订单当前是什么状态”:

order_id = 1001, status = paid

OLAP 中的事实表更关心“发生过什么”:

order_id = 1001, event = payment_succeeded, event_time = ...
order_id = 1001, event = shipped, event_time = ...

如果只把 OLTP 当前表定期复制到分析库,历史状态变化可能已经丢失。此时分析系统能够回答“现在有多少已支付订单”,却未必能回答“上个月每天有多少订单从待支付转为已支付”。

这就是数据链路设计中“快照同步”和“变更捕获”的区别:

  • 快照同步:周期性复制源表当前内容;
  • CDC(Change Data Capture):捕获插入、更新、删除及其顺序或事务位置。

2. 事实表、维度表和去规范化

OLTP 通常倾向于规范化,以减少更新异常。例如用户信息独立保存,订单通过 user_id 引用用户。

OLAP 常见星型模型:

  • 事实表:订单、支付、曝光等可度量事件;
  • 维度表:用户、商品、地区、日期等描述性实体;
  • 度量:金额、数量、时长等可聚合值。

是否去规范化取决于访问模式。分析查询频繁连接小维度表时,保留维度表可以减少重复;对固定报表而言,适度宽表可能减少连接成本。但去规范化会引入维度变更处理问题,例如商品所属类目改变后,历史订单应使用当时类目还是当前类目。


五、Lakehouse 到底增加了什么

1. 数据湖、数据仓库和 Lakehouse 的边界

可以先区分三个概念:

  • 数据湖:通常以对象存储上的文件保存原始或加工数据,格式可以是 Parquet、JSON、CSV 等,治理和事务能力取决于外围系统;
  • 数据仓库:通常由统一系统管理表、存储、执行、权限和事务,面向结构化分析;
  • Lakehouse:以低成本、开放格式的文件存储为基础,再增加表级元数据、快照、提交、模式管理和多个计算引擎可访问的语义。

Lakehouse 不是“把数据库文件放进对象存储”这么简单。一个可用的 Lakehouse 表至少要解决:

  1. 文件属于哪张表;
  2. 哪些文件是当前快照的一部分;
  3. 某次提交新增或删除了哪些文件;
  4. 并发写入如何提交;
  5. 读者如何获得一致快照;
  6. 模式如何演进;
  7. 旧文件何时可以清理;
  8. 多个引擎如何解释同一批数据。

具体表格式的事务、删除、更新和并发提交语义由相应规范和实现决定,不能把某个表格式的能力泛化为所有 Parquet 文件都具备。

2. 文件格式、表格式、目录和计算引擎

Lakehouse 中常见的组件可以分为四层:

文件格式

例如 Parquet 负责:

  • 按列组织数据;
  • 保存列类型和统计信息;
  • 支持压缩和编码;
  • 让多个引擎读取同一文件。

Parquet 本身不是完整的表事务系统。直接把多个 Parquet 文件放在一个目录中,并不能自动获得原子提交、并发写入和可靠删除语义。

表格式

表格式在文件之上维护表快照和提交元数据。它通常需要记录:

快照 N:
  file-001.parquet
  file-002.parquet

快照 N+1:
  保留 file-001.parquet
  删除 file-002.parquet
  新增 file-003.parquet

读者读取快照 N+1 时,不应看到“只完成了一半”的文件替换。

目录与权限

目录服务负责发现表、解析名称、授权访问,并可能保存 schema、分区信息和表属性。目录不一定等价于事务提交日志;两者的职责要区分。

计算引擎

Spark、Trino、Flink、DuckDB 以及其他引擎可能读取相同的开放文件或表格式,但 SQL 方言、事务支持、谓词下推和写入语义并不完全相同。能读同一个文件,不代表所有引擎都支持同样的更新、删除和并发提交能力。

3. Lakehouse 的快照与失败路径

设一个批处理任务要新增两个文件:

file-101.parquet
file-102.parquet

较安全的流程是:

  1. 任务写入临时路径;
  2. 对文件执行校验,确认行数、schema 和文件完整性;
  3. 生成提交元数据;
  4. 以条件提交方式发布新快照;
  5. 读者从新快照中同时看到两个文件;
  6. 旧快照仍保留到满足保留策略后再清理。

如果任务在第 1 步崩溃,通常只留下临时文件,不影响当前快照。

如果两个任务都基于快照 10 产生新版本:

任务 A: 快照 10 -> 快照 11A
任务 B: 快照 10 -> 快照 11B

提交时需要检测当前版本仍然是 10。先成功的任务发布新快照,后提交的任务必须重试、合并或失败;否则两个任务可能互相覆盖。

这说明 Lakehouse 的“事务”至少涉及:

  • 数据文件;
  • 提交元数据;
  • 当前版本指针;
  • 对象存储的一致性和并发条件;
  • 失败后的孤儿文件清理。

不能因为文件已经上传成功,就认为数据已经对查询可见。上传完成、提交完成、查询可见、旧文件可回收是不同状态。


六、从 OLTP 到 OLAP 或 Lakehouse 的数据链路

一个常见链路如下:

业务请求
  -> OLTP 数据库
  -> WAL / binlog / CDC 采集
  -> 消息队列或日志缓冲
  -> 清洗、去重、补充维度
  -> Lakehouse 明细表
  -> OLAP 引擎或查询引擎
  -> 报表、指标、机器学习特征

每一段都有独立的正确性问题。

1. 采集位置:事务日志优于重复查询

如果通过不断执行:

SELECT *
FROM orders
WHERE updated_at > :last_time;

来增量同步,会遇到几个问题:

  • 两条记录可能具有相同时间戳;
  • 时钟精度不足导致遗漏;
  • 更新后时间变化与事务提交顺序不一致;
  • 删除记录可能无法通过普通查询发现;
  • 长事务提交顺序与行更新时间可能不同。

CDC 通常使用数据库的事务日志,并携带日志位置、事务 ID 或类似游标。消费者应保存一个可恢复的进度点:

已处理到日志位置 L

处理失败时,从 L 或安全重放位置继续读取。若消息至少一次投递,同一事件可能被处理多次,因此下游需要幂等设计。

2. 幂等不是“重复执行不会报错”

一个操作幂等,意味着执行一次和执行多次的最终业务结果相同:

f(f(x))=f(x)f(f(x))=f(x)

例如,使用事件唯一 ID 建立去重表:

event_id = abc123 已处理

再次收到 abc123 时跳过应用,可以实现事件级幂等。

但以下操作通常不是天然幂等的:

UPDATE account
SET balance = balance - 100;

同一事件执行两次会扣款两次。更安全的方式是让事件 ID 参与约束,或把“账户变更”和“已处理事件记录”放在同一个事务中。

3. 乱序、删除和迟到数据

CDC 或消息系统可能出现:

  • 同一主键的更新事件乱序;
  • 删除事件晚于新增事件到达;
  • 网络重试造成重复事件;
  • 迟到数据属于已经关闭的分区;
  • 维度表更新早于或晚于事实事件。

因此,Lakehouse 明细层常见做法是保留原始事件和来源位置,再在下游构建当前视图或事实表。对于同一业务键,需要依据明确的版本字段、事务序号或事件时间决定哪条记录有效,而不能简单使用“最后到达的数据”。

事件时间与处理时间也不同:

  • 事件时间:业务事件实际发生的时间;
  • 处理时间:数据管道处理它的时间。

按处理时间统计可能及时,但迟到事件会改变历史结果;按事件时间统计更符合业务含义,却需要窗口关闭、迟到修正或重算机制。


七、DuckDB、Parquet 与本地分析链路

DuckDB 是嵌入式分析数据库,适合在单机进程中直接查询本地文件、对象存储映射文件或内存数据。它不是一个负责承载大量在线事务连接的服务端 OLTP 数据库。

一个最小的 Parquet 查询示例:

SELECT
    region,
    sum(amount) AS revenue
FROM read_parquet('data/orders/*.parquet')
WHERE order_date >= DATE '2025-01-01'
  AND order_date <  DATE '2025-02-01'
GROUP BY region
ORDER BY revenue DESC;

其大致执行过程是:

  1. 读取 Parquet 文件的 schema 和行组元数据;
  2. 根据 order_date 谓词尝试跳过不可能匹配的行组;
  3. 只读取 regionamountorder_date 等需要的列;
  4. 使用向量化算子执行过滤和聚合;
  5. 返回聚合结果,而不是把所有明细加载到应用程序内存。

写出分析文件时,可以将查询结果物化为 Parquet:

COPY (
    SELECT
        order_date,
        region,
        sum(amount) AS revenue
    FROM read_parquet('data/orders/*.parquet')
    GROUP BY order_date, region
) TO 'output/daily_revenue.parquet'
(FORMAT PARQUET);

前置条件是进程对输入和输出路径有访问权限,且输入文件 schema 可被统一解析。需要注意:

  • 多个进程同时写同一个输出路径时,不能仅凭 COPY 推断获得跨进程事务;
  • 文件写完不等于已经注册到某个 Lakehouse 表;
  • 如果输入文件正在被其他任务覆盖,查询可能读取到不完整或不一致的数据集合;
  • 大规模并发服务场景需要评估连接管理、资源隔离和部署边界。

DuckDB 很适合:

  • 本地数据分析;
  • CI 中验证 SQL;
  • 处理 Parquet 的批处理脚本;
  • 数据工程中的抽样、转换和质量检查。

它不自动替代分布式表格式、目录服务或在线事务数据库。


八、如何根据访问模式选择存储和链路

1. 选择 OLTP 数据库的条件

优先考虑 OLTP 数据库,当系统需要:

  • 多个请求同时修改相同业务实体;
  • 事务跨多个表原子提交;
  • 强依赖唯一键、外键和检查约束;
  • 按主键或高选择性索引低延迟访问;
  • 明确的当前状态模型;
  • 在线请求失败后可以回滚。

此时应重点设计:

  • 主键和唯一约束;
  • 事务边界;
  • 隔离级别;
  • 锁顺序和死锁重试;
  • 索引数量与写入成本;
  • 长事务和旧版本清理。

2. 选择 OLAP 引擎的条件

优先考虑列式 OLAP 引擎,当系统需要:

  • 高频扫描大量历史数据;
  • 大量聚合、连接和窗口计算;
  • 报表和分析查询只读取部分列;
  • 追加写入多于随机单行更新;
  • 通过分区、排序键和数据跳过降低扫描范围;
  • 将计算吞吐放在单行更新延迟之前。

应根据主要谓词设计排序和分区,而不是按表字段随意选择。可以先列出高频查询:

按 tenant_id + 时间范围查询
按时间范围聚合
按 event_type 过滤

再判断哪些字段适合作为分区键,哪些适合作为排序前缀。排序键应服务于真实查询;否则即使数据“已经排序”,也可能无法有效剪枝。

3. 选择 Lakehouse 的条件

Lakehouse 更适合以下情况:

  • 需要保留长时间历史明细;
  • 数据规模或存储成本使对象存储有吸引力;
  • 希望多个计算引擎访问相同数据;
  • 需要批处理、机器学习、交互式分析共享数据;
  • 数据写入以追加和批量变更为主;
  • 能接受数据从业务库到分析层存在延迟;
  • 团队能够维护表格式、目录、作业和数据质量体系。

如果系统只是一个小型应用,数据量有限,报表也不复杂,直接在 OLTP 库上建立合适索引或只读副本,可能比建设 Lakehouse 更简单可靠。


九、常见错误与诊断方法

错误一:把 OLTP 库直接当报表引擎

表现:

  • 高峰期报表导致业务接口延迟升高;
  • 大查询占用大量缓存和 CPU;
  • 事务锁等待增加;
  • 备份、维护和查询互相影响。

诊断方式:

EXPLAIN (ANALYZE, BUFFERS)
SELECT ...;

同时观察数据库活动、锁等待、I/O、缓存命中和长事务。解决方案可能是优化 SQL,也可能是只读副本、汇总表、数据抽取或独立 OLAP 系统;不能只靠“再加一个索引”解决所有问题。

错误二:把列式数据库当作单行更新存储

表现:

  • 大量小更新形成许多小数据片段;
  • 后台合并持续占用资源;
  • 查询延迟抖动;
  • 更新语义与应用预期不一致。

诊断时应检查写入批次大小、数据片段数量、后台合并积压、更新模式和查询是否依赖最终整理结果。很多列式系统更适合追加事件,再通过版本选择、批量重算或定期整理形成当前视图。

错误三:只按日期分区,却没有考虑排序和数据分布

按日期分区可以帮助时间范围管理,但如果每个分区内部数据完全无序,查询:

WHERE customer_id = 42

仍可能扫描整个时间分区。反过来,按高基数字段建立过细分区,也会带来大量文件、目录和元数据。

应区分:

  • 分区是否能排除大块数据;
  • 排序是否使目标值集中;
  • 文件或数据块统计是否有效;
  • 查询谓词是否与这些元数据可匹配。

错误四:把 Parquet 目录当成事务表

表现:

  • 查询偶尔读到半批文件;
  • 重跑任务产生重复数据;
  • 删除文件后旧查询无法复现;
  • 多个任务互相覆盖输出;
  • 文件已上传但目录查询不到。

诊断时要分别检查:

  1. 文件是否完整;
  2. 文件是否被提交到表快照;
  3. 查询使用的是哪个快照;
  4. 作业是否有唯一批次 ID;
  5. 失败后的临时文件是否清理;
  6. 清理旧文件是否仍被长时间运行的读事务需要。

十、一个可操作的组合架构

一个较常见的分层方案是:

PostgreSQL / MySQL
  保存用户、订单、库存等当前业务状态
        |
        | CDC 或定期快照
        v
对象存储上的原始事件层
  保存不可变原始记录、来源位置和采集时间
        |
        v
Lakehouse 明细层
  统一 schema、去重、处理删除和迟到数据
        |
        +--> DuckDB:本地分析、质量检查、开发验证
        |
        +--> ClickHouse 或其他 OLAP 引擎:交互式报表
        |
        +--> 批处理引擎:宽表、汇总表、特征数据

这个架构的关键不是组件数量,而是边界清晰:

  • OLTP 是业务写入事实的权威来源;
  • CDC 游标是可恢复的处理状态;
  • 原始层保留重放和审计能力;
  • Lakehouse 表快照定义分析数据的可见范围;
  • OLAP 引擎可以是服务层缓存或派生副本,不应被误认为业务交易的唯一事实来源;
  • 汇总结果需要说明刷新时间、迟到数据处理规则和重算方式。

最终选型可以用三个问题收敛:

  1. 一次业务操作是否需要跨多行、跨多表原子提交?
    如果需要,优先从事务数据库建模。

  2. 主要查询是点查当前状态,还是扫描历史集合做聚合?
    前者偏向行式 OLTP,后者偏向列式 OLAP。

  3. 是否需要低成本保存长期明细,并让多个引擎共享同一份分析数据?
    如果需要,评估 Lakehouse;但必须同时评估表格式事务、CDC、幂等、模式演进和数据治理,而不能只选择一个文件格式。

OLTP、OLAP 与 Lakehouse 的关系,归根结底是三种不同问题的组合:业务状态如何可靠变更,历史数据如何高效计算,以及数据如何在不同系统之间可恢复、可验证地流动。只有先明确这三个问题,再决定行式还是列式、数据库还是文件、单体还是分层,选型才不会停留在产品名称的比较上。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。