Apache Doris 深度讲解:从核心概念到实战项目
本文档基于二八法则,聚焦 Doris 最核心的 20% 知识,帮助你解决 80% 的业务场景。 目标:看完之后,你不仅能理解,还能清晰地讲给别人听。
第〇章:一句话理解 Doris 的本质
Apache Doris 是一个 MPP(大规模并行处理)分析型数据库,用一句话概括就是:
"用写 MySQL 的方式,干 Hadoop 的活。"
传统大数据栈需要 Hadoop + Hive + Spark + Presto + HBase + ES 等一堆组件拼在一起,而 Doris 用一个统一的 SQL 引擎就能覆盖绝大部分 OLAP(联机分析处理)场景------实时报表、日志检索、用户画像、数据联邦查询等。
关键定位(给别人讲时先说这句): Doris 不是替代 MySQL 的 OLTP 数据库,而是用来做"查询分析"的。MySQL 擅长的是一条一条地增删改查;Doris 擅长的是一次扫描几千万、几亿行数据,快速聚合出结果。
第一部分:第一周------基础构建与核心存储哲学
第一章:架构------FE 与 BE 的"前台后厨"模型
1.1 类比理解
把 Doris 想象成一家餐厅:
| 角色 | Doris 组件 | 职责 |
|---|---|---|
| 前台服务员 | FE(Frontend) | 接待客人(接收 SQL)、看菜单(管理元数据)、告诉后厨做什么菜(生成查询计划)、算账(返回结果) |
| 后厨大厨 | BE(Backend) | 真正做菜(存储数据 + 执行计算)、切配原料(数据分片存储)、多个厨师同时炒菜(并行计算) |
1.2 FE 的三大核心职责
sql
用户 SQL 请求
│
▼
┌─────────────────────────────┐
│ FE (Frontend) │
│ │
│ 1. 查询解析 & 优化 │ ← 把你的 SQL 翻译成最优执行计划
│ 2. 元数据管理 │ ← 记录所有表的定义、分区、权限等
│ 3. 节点管理 │ ← 知道有多少个 BE,它们是否健康
│ │
│ 内部用 Raft 协议做高可用 │ ← 多个 FE 之间自动选主
└──────────────┬──────────────┘
│ 分发执行计划
▼
┌──────┐ ┌──────┐ ┌──────┐
│ BE-1 │ │ BE-2 │ │ BE-3 │ ← 并行执行,各干各的
└──────┘ └──────┘ └──────┘
关键点(面试/讲解必说):
- FE 是"大脑",BE 是"肌肉"
- FE 之间用 Raft 协议做高可用(一个 Leader + 多个 Follower/Observer)
- 用户只需连接 FE(MySQL 协议,端口 9030),完全兼容 MySQL 客户端
- BE 之间是对等的,没有主从之分,数据通过多副本保证可靠性
1.3 两种部署模式
| 模式 | 特点 | 适用场景 |
|---|---|---|
| 存算耦合(经典模式) | BE 同时负责存储和计算,数据在本地磁盘 | 中小规模集群,对延迟要求高 |
| 存算分离(2.0+ 新架构) | BE 无状态,数据存在对象存储(S3/OSS),本地仅做缓存 | 弹性扩缩容,云原生场景 |
1.4 动手验证
用 Docker 一键部署:
bash
# 拉取 Doris 官方 Docker 镜像(推荐 2.1+ 版本)
docker run -d --name doris-standalone \
-p 9030:9030 -p 8030:8030 -p 8040:8040 \
apache/doris:2.1-standalone
# 用 MySQL 客户端连接 FE
mysql -h 127.0.0.1 -P 9030 -uroot
# 验证 FE 和 BE 状态
SHOW FRONTENDS\G
SHOW BACKENDS\G
连上后如果 SHOW BACKENDS 显示 Alive: true,说明环境就绪。
第二章:[极其重要] 三大数据模型------Doris 的灵魂
这是 Doris 最核心、最有特色的设计。建表选错模型,后面一切努力都白费。
2.1 总览对比
| 模型 | 关键词 | 数据特征 | 典型场景 | 类比 |
|---|---|---|---|---|
| Duplicate(明细模型) | DUPLICATE KEY |
保留所有原始记录,一条不丢 | 日志、埋点、流水 | 记账本------每笔都记 |
| Aggregate(聚合模型) | AGGREGATE KEY |
相同 Key 的行自动按指标列聚合 | 报表统计、PV/UV | 计数器------同类合并 |
| Unique(唯一模型) | UNIQUE KEY |
相同 Key 只保留最新一条 | 订单状态、用户画像 | 通讯录------同名覆盖 |
2.2 明细模型(Duplicate Key)------"有多少存多少"
核心思想: 不做任何去重或聚合,原封不动地保存所有写入的数据。
什么时候用:
- 你不确定未来要做什么分析(先存下来,以后再说)
- 需要保留每一条明细记录(如用户点击流、系统日志)
- 需要做即席查询(Ad-hoc),任意维度都可能作为分析条件
sql
-- 示例:用户行为日志表
CREATE TABLE user_behavior_log (
event_time DATETIME NOT NULL COMMENT '事件时间',
user_id BIGINT NOT NULL COMMENT '用户ID',
event_type VARCHAR(32) NOT NULL COMMENT '事件类型: click/view/cart/buy',
page_url VARCHAR(256) COMMENT '页面URL',
item_id BIGINT COMMENT '商品ID',
device VARCHAR(32) COMMENT '设备类型',
ip VARCHAR(64) COMMENT 'IP地址'
)
DUPLICATE KEY(event_time, user_id, event_type) -- 排序键,决定数据物理排列顺序
PARTITION BY RANGE(event_time) (
PARTITION p20260301 VALUES LESS THAN ('2026-03-02'),
PARTITION p20260302 VALUES LESS THAN ('2026-03-03')
)
DISTRIBUTED BY HASH(user_id) BUCKETS 16;
深入理解 DUPLICATE KEY 的含义:
- 这里的 KEY 不是"唯一约束",而是排序键(Sort Key)
- 数据在磁盘上会按照
event_time, user_id, event_type的顺序物理排列 - 两条完全一样的数据都会被保留,不会去重
给别人讲时的要点: "Duplicate 模型就是最'老实'的模型,你给它什么它就存什么,绝不擅自修改数据。缺点是数据量会很大,优点是信息最完整。"
2.3 聚合模型(Aggregate Key)------"边存边算"
核心思想: 定义好 Key 列(维度)和 Value 列(指标 + 聚合函数),相同 Key 的数据导入时会自动聚合。
什么时候用:
- 你只关心聚合后的结果,不关心明细(如每天的总 PV、总销售额)
- 数据量巨大,需要通过预聚合大幅降低存储和查询成本
- 报表查询模式固定
sql
-- 示例:页面访问统计表
CREATE TABLE page_visit_stats (
visit_date DATE NOT NULL COMMENT '访问日期',
page_url VARCHAR(256) NOT NULL COMMENT '页面URL',
city VARCHAR(64) NOT NULL COMMENT '城市',
-- 以上是 Key 列(维度),以下是 Value 列(指标)
pv BIGINT SUM COMMENT '页面浏览量', -- 自动求和
uv BITMAP BITMAP_UNION COMMENT '独立访客(精确去重)', -- Bitmap 聚合
max_duration INT MAX COMMENT '最大停留时长(秒)', -- 取最大值
min_duration INT MIN COMMENT '最小停留时长(秒)' -- 取最小值
)
AGGREGATE KEY(visit_date, page_url, city)
PARTITION BY RANGE(visit_date) (
PARTITION p202603 VALUES LESS THAN ('2026-04-01')
)
DISTRIBUTED BY HASH(page_url) BUCKETS 16;
聚合函数可选项:
| 聚合函数 | 含义 | 典型用途 |
|---|---|---|
SUM |
求和 | PV、销售额、点击次数 |
MAX |
取最大值 | 最大订单金额、最晚登录时间 |
MIN |
取最小值 | 最小停留时长 |
REPLACE |
用新值覆盖旧值 | 状态字段(但更推荐用 Unique 模型) |
REPLACE_IF_NOT_NULL |
非空时才覆盖 | 部分更新场景 |
HLL_UNION |
HyperLogLog 聚合 | 模糊去重(误差 < 1%,但速度极快) |
BITMAP_UNION |
Bitmap 聚合 | 精确去重(零误差) |
聚合发生在什么时候?
markdown
数据导入 ──→ MemTable(内存中初次聚合) ──→ 写入磁盘(Segment)
│
后台 Compaction 会定期合并多个 Segment ─────────→ 进一步聚合
│
查询时,如果还有未合并的 Segment ──────────────→ 查询时再次聚合
注意事项: 聚合模型下,你查询时如果不按 Key 列 GROUP BY,Doris 仍然会在底层做聚合,但你在 SQL 层面看到的可能是"部分聚合"的中间结果。所以最佳实践是查询时始终带上 GROUP BY。
2.4 唯一模型(Unique Key)------"同 Key 只留最新"
核心思想: 保证 Key 列的唯一性,新数据覆盖旧数据(UPSERT 语义)。
什么时候用:
- 需要数据更新的场景(订单状态变更、用户信息修改)
- 从 MySQL 等 OLTP 数据库同步数据到 Doris(CDC 场景)
- 需要保证主键唯一
sql
-- 示例:订单表(从 MySQL 同步到 Doris)
CREATE TABLE order_detail (
order_id BIGINT NOT NULL COMMENT '订单ID',
user_id BIGINT NOT NULL COMMENT '用户ID',
order_status TINYINT COMMENT '订单状态: 0待付款 1已付款 2已发货 3已完成 4已退款',
total_amount DECIMAL(10,2) COMMENT '订单金额',
pay_time DATETIME COMMENT '支付时间',
ship_time DATETIME COMMENT '发货时间',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_time DATETIME COMMENT '更新时间'
)
UNIQUE KEY(order_id)
PARTITION BY RANGE(create_time) (
PARTITION p202603 VALUES LESS THAN ('2026-04-01')
)
DISTRIBUTED BY HASH(order_id) BUCKETS 16
PROPERTIES (
"enable_unique_key_merge_on_write" = "true" -- 推荐开启 Merge-on-Write
);
2.4.1 Merge-on-Write vs Merge-on-Read(重要面试题)
这是 Unique 模型下的两种实现方式,理解它们的区别至关重要:
vbnet
┌─────────────────────────────────────────────────────────────┐
│ Merge-on-Write(写时合并) │
│ │
│ 写入时:新数据进来 → 立即找到旧数据 → 标记删除旧数据 │
│ → 只保留最新版本 │
│ 读取时:直接读,不需要合并 → 查询快! │
│ │
│ 特点:写入稍慢,查询很快 │
│ 适用:读多写少,或读写均衡 │
│ 推荐:Doris 2.1+ 默认,绝大多数场景使用这个 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Merge-on-Read(读时合并) │
│ │
│ 写入时:新数据直接追加写入,不管旧数据 → 写入快! │
│ 读取时:发现同一个 Key 有多个版本 → 现场合并取最新版本 │
│ │
│ 特点:写入快,查询慢(因为要现场去重) │
│ 适用:写多读少 │
│ 注意:谓词无法下推,性能较差 │
└─────────────────────────────────────────────────────────────┘
给别人讲时的关键句: "Merge-on-Write 是提前做好功课(写入时就合并),查询时就轻松了;Merge-on-Read 是先堆起来,查询时再临时整理,所以查询会慢。现在默认用 Merge-on-Write,因为大部分业务都是读多写少。"
2.5 思考题解答
同样是记录用户的购买行为,什么情况下用 Aggregate?什么情况下用 Unique?
用 Aggregate 的场景:
- 你只关心"每个用户每天买了多少钱的东西"(聚合结果)
- 不需要知道每笔订单的具体详情
- 数据量极大,需要通过预聚合降低存储量
sql
-- Aggregate 模式:只关心汇总数据
CREATE TABLE user_purchase_summary (
purchase_date DATE NOT NULL,
user_id BIGINT NOT NULL,
total_amount DECIMAL(10,2) SUM, -- 自动累加每日购买总额
order_count BIGINT SUM -- 自动累加订单数
)
AGGREGATE KEY(purchase_date, user_id)
DISTRIBUTED BY HASH(user_id) BUCKETS 16;
用 Unique 的场景:
- 每条购买记录都有一个唯一的订单号
- 订单状态会变化(待付款 → 已付款 → 已发货),需要更新
- 需要查看每笔订单的完整信息
sql
-- Unique 模式:每笔订单都要精确追踪
CREATE TABLE user_purchase_detail (
order_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
purchase_date DATE NOT NULL,
status TINYINT,
amount DECIMAL(10,2)
)
UNIQUE KEY(order_id)
DISTRIBUTED BY HASH(order_id) BUCKETS 16;
一句话总结:
- 问"一共多少" → Aggregate
- 问"每一条什么情况" → Unique 或 Duplicate
第三章:数据分布策略------分区与分桶
建表语句中最容易写错、对性能影响最大的就是分区和分桶。
3.1 为什么需要分区和分桶?
想象你有一个图书馆,里面有 1 亿本书:
markdown
不分区不分桶:所有书堆在一个大房间里
→ 找一本书要翻遍整个房间 ❌
分区(Partition):按"出版年份"分成不同楼层
→ 找 2026 年的书,只去 2026 楼层 ✓
分桶(Bucket):每个楼层内部,按"作者姓氏首字母"分成不同书架
→ 找张三的书,只去 Z 书架 ✓✓
3.2 两层数据划分
scss
Table(表)
│
┌────────────┼────────────┐
│ │ │
Partition-1 Partition-2 Partition-3 ← 第一层:分区(通常按时间)
(2026-03-01) (2026-03-02) (2026-03-03)
│
┌─────┼─────┐
│ │ │
Bucket Bucket Bucket ← 第二层:分桶(按 Hash Key)
(Tab-1) (Tab-2) (Tab-3)
│
Tablet ← 最小物理存储单元(含多副本)
Tablet 是 Doris 数据管理的原子单位:
- 每个 Tablet 是一个独立的数据分片
- 多副本(默认 3 副本)分布在不同的 BE 节点上
- 查询时,多个 Tablet 可以被不同的 BE 并行扫描
3.3 分区(Partition)详解
分区的作用:数据裁剪(Partition Pruning)。 查询时,Doris 根据 WHERE 条件自动跳过不相关的分区,大幅减少扫描数据量。
3.3.1 Range 分区(最常用)
按连续范围划分,典型场景是时间:
sql
-- 手动 Range 分区
PARTITION BY RANGE(event_date) (
PARTITION p20260301 VALUES LESS THAN ('2026-03-02'),
PARTITION p20260302 VALUES LESS THAN ('2026-03-03'),
PARTITION p20260303 VALUES LESS THAN ('2026-03-04')
)
动态分区(自动创建、自动清理):
sql
PROPERTIES (
"dynamic_partition.enable" = "true",
"dynamic_partition.time_unit" = "DAY", -- 按天创建分区
"dynamic_partition.start" = "-30", -- 保留最近 30 天
"dynamic_partition.end" = "3", -- 提前创建未来 3 天
"dynamic_partition.prefix" = "p", -- 分区名前缀
"dynamic_partition.buckets" = "16" -- 每个分区的分桶数
)
自动分区(Doris 2.1+,数据驱动):
sql
-- 数据进来时自动按月创建分区
PARTITION BY RANGE(event_date) ()
PROPERTIES (
"auto_partition" = "true",
"auto_partition.time_unit" = "MONTH"
)
3.3.2 List 分区
按离散值划分,适用于地区、类目等:
sql
PARTITION BY LIST(city) (
PARTITION p_bj VALUES IN ('北京'),
PARTITION p_sh VALUES IN ('上海'),
PARTITION p_gz VALUES IN ('广州', '深圳') -- 多个值可以放一个分区
)
3.4 分桶(Bucket)详解
分桶的作用:并行度 + 负载均衡。 分桶数决定了一个分区内数据被切成几块,也就决定了查询时最多能有多少个线程并行扫描。
3.4.1 Hash 分桶(最常用)
sql
-- 按 user_id 做 Hash 分桶,分成 16 个桶
DISTRIBUTED BY HASH(user_id) BUCKETS 16;
分桶列选择原则:
- 高基数列(如 user_id、order_id),确保数据均匀分布
- 常用查询条件列,加速等值查询
- JOIN 关联列,配合 Colocate Join 提升关联性能
3.4.2 Random 分桶
sql
-- 随机分桶,仅 Duplicate 模型支持
DISTRIBUTED BY RANDOM BUCKETS 16;
适用于:数据分布极不均匀、无法找到好的 Hash 列时。
3.5 分桶数量怎么定?
黄金法则:让每个 Tablet 的数据量在 1GB ~ 10GB 之间。
| 分区数据量 | 推荐分桶数 |
|---|---|
| 500MB | 4 ~ 8 |
| 5GB | 8 ~ 16 |
| 50GB | 32 |
| 500GB | 建议先拆更细的分区,每分区 16 ~ 32 |
计算公式: 表总 Tablet 数 = 分区数 × 分桶数 × 副本数
常见错误:
- 分桶数设太小 → 并行度低,查询慢
- 分桶数设太大 → 小文件过多,元数据压力大
- 分桶数一旦创建无法修改(这一点非常重要!)
3.6 完整建表实战
sql
-- 需求:存储 APP 用户访问记录,按天分区,按 user_id 分桶
CREATE DATABASE IF NOT EXISTS app_analytics;
USE app_analytics;
CREATE TABLE user_access_log (
access_date DATE NOT NULL COMMENT '访问日期',
access_time DATETIME NOT NULL COMMENT '精确访问时间',
user_id BIGINT NOT NULL COMMENT '用户ID',
session_id VARCHAR(64) NOT NULL COMMENT '会话ID',
page_url VARCHAR(512) COMMENT '页面URL',
referrer VARCHAR(512) COMMENT '来源URL',
device_type VARCHAR(16) COMMENT '设备类型',
os VARCHAR(32) COMMENT '操作系统',
browser VARCHAR(32) COMMENT '浏览器',
ip VARCHAR(64) COMMENT 'IP地址',
duration_sec INT COMMENT '停留时长(秒)'
)
DUPLICATE KEY(access_date, access_time, user_id)
PARTITION BY RANGE(access_date) ()
DISTRIBUTED BY HASH(user_id) BUCKETS 16
PROPERTIES (
"replication_num" = "1", -- 单机部署设为 1
"auto_partition" = "true",
"auto_partition.time_unit" = "DAY",
"dynamic_partition.enable" = "true",
"dynamic_partition.start" = "-90", -- 保留 90 天数据
"dynamic_partition.end" = "3" -- 提前创建 3 天分区
);
第四章:索引机制------不花钱的性能加速器
4.1 索引全景图
scss
┌────────────────────────────────────────────────┐
│ Doris 索引体系 │
│ │
│ ┌──────────────────┐ ┌────────────────────┐ │
│ │ 自动内建索引 │ │ 手动创建索引 │ │
│ │ (无需操心) │ │ (按需添加) │ │
│ │ │ │ │ │
│ │ • 前缀索引 │ │ • 倒排索引 │ │
│ │ (Prefix Index) │ │ (Inverted) │ │
│ │ • ZoneMap 索引 │ │ • BloomFilter │ │
│ │ │ │ • N-Gram BF │ │
│ └──────────────────┘ └────────────────────┘ │
└────────────────────────────────────────────────┘
4.2 前缀索引(Prefix Index)------"目录页"
原理: Doris 的数据是按排序键排列的(类似 SSTable)。每 1024 行形成一个 Data Block,每个 Block 取第一行的前 36 字节作为索引条目,形成一个稀疏索引。
关键限制:
- 索引长度最多 36 字节
- 遇到 VARCHAR 类型直接截断(即使不到 36 字节)
- 只对排序键的前缀有效
示例分析:
sql
DUPLICATE KEY(user_id, age, message)
-- user_id: BIGINT = 8 字节
-- age: INT = 4 字节
-- message: VARCHAR(100) = 遇到 VARCHAR 截断
-- 实际前缀索引 = user_id(8B) + age(4B) + message前24B = 36B
-- 这个查询走前缀索引 ✓(命中 user_id)
SELECT * FROM t WHERE user_id = 12345;
-- 这个查询走前缀索引 ✓(命中 user_id + age)
SELECT * FROM t WHERE user_id = 12345 AND age = 25;
-- 这个查询不走前缀索引 ✗(age 不是前缀的第一列)
SELECT * FROM t WHERE age = 25;
给别人讲的比喻: "前缀索引就像字典的目录------你只能按拼音首字母查(前缀匹配)。如果你想按笔画查,字典的目录帮不上忙。"
4.3 ZoneMap 索引------"自动的范围标注"
原理: 对每一列的每个 Data Block,自动记录该 Block 内的 最小值(Min)和最大值(Max)。查询时,如果 WHERE 条件的值不在某 Block 的 [Min, Max] 范围内,直接跳过该 Block。
ini
Block-1: age: [18, 35] → WHERE age = 50 → 跳过 ✓
Block-2: age: [30, 65] → WHERE age = 50 → 需要扫描
Block-3: age: [60, 90] → WHERE age = 50 → 跳过 ✓
特点:
- 完全自动,无需手动创建
- 对数值型和日期型效果好
- 对高基数字符串(如 UUID)效果差
4.4 倒排索引(Inverted Index)------"Doris 版 Elasticsearch"
这是 Doris 2.0+ 的杀手级特性,目前业界正在用它替代 ES 处理日志检索场景。
原理: 和搜索引擎一样,把文本拆分成词(分词),建立"词 → 行号"的映射。
arduino
原始数据:
行1: "用户登录失败,密码错误"
行2: "支付超时,请重试"
行3: "用户登录成功"
倒排索引:
"用户" → [行1, 行3]
"登录" → [行1, 行3]
"失败" → [行1]
"密码" → [行1]
"支付" → [行2]
"超时" → [行2]
"成功" → [行3]
创建方式:
sql
CREATE TABLE error_logs (
log_time DATETIME NOT NULL,
level VARCHAR(16) NOT NULL,
service_name VARCHAR(64) NOT NULL,
error_message TEXT,
stack_trace TEXT,
-- 为各字段创建倒排索引
INDEX idx_level (level) USING INVERTED,
INDEX idx_service (service_name) USING INVERTED,
INDEX idx_error_msg (error_message) USING INVERTED
PROPERTIES("parser" = "unicode"), -- 中英文混合分词
INDEX idx_stack (stack_trace) USING INVERTED
PROPERTIES("parser" = "english") -- 英文分词
)
DUPLICATE KEY(log_time, level, service_name)
DISTRIBUTED BY HASH(service_name) BUCKETS 16;
查询语法:
sql
-- 查找包含"timeout"的错误
SELECT * FROM error_logs
WHERE error_message MATCH_ANY 'timeout';
-- 查找同时包含"NullPointer"和"Exception"的堆栈
SELECT * FROM error_logs
WHERE stack_trace MATCH_ALL 'NullPointer Exception';
-- 查找包含"connection refused"这个短语的错误
SELECT * FROM error_logs
WHERE error_message MATCH_PHRASE 'connection refused';
分词器选择:
| 分词器 | 适用场景 | 性能 |
|---|---|---|
| 不设置(默认) | 等值/范围查询(不分词) | 最快 |
english |
纯英文文本 | 快 |
unicode |
中英文混合 | 中等 |
chinese |
纯中文文本 | 较慢 |
对已有表添加倒排索引(不需要重写数据!):
sql
-- 为已存在的表增加倒排索引
ALTER TABLE error_logs ADD INDEX idx_new_col (some_column) USING INVERTED;
-- 构建索引(对已有数据)
BUILD INDEX idx_new_col ON error_logs;
第二部分:第二周------数据流转与查询提速
第五章:数据导入------把数据灌进 Doris 的三条管道
5.1 导入方式全景
scss
数据源
│
┌───────────────┼───────────────┐
│ │ │
本地文件/HTTP Kafka 消息队列 HDFS/S3/OSS
│ │ │
▼ ▼ ▼
Stream Load Routine Load Broker Load
(微批/实时) (持续订阅) (批量历史数据)
│ │ │
└───────────────┼───────────────┘
│
▼
Doris 表
5.2 Stream Load------"HTTP POST 一把梭"
核心思想: 通过 HTTP 协议直接把数据推给 Doris。最简单、最直接的导入方式。
适用场景: 本地文件导入、程序中实时写入(微批)、文件 < 10GB
导入 CSV 文件:
bash
# 假设有一个 user_log.csv 文件
# 格式:2026-03-01,2026-03-01 10:00:00,1001,s001,/home,/search,mobile,iOS,Safari,1.2.3.4,30
curl -u root: \
-H "label:load_20260301_001" \
-H "column_separator:," \
-T user_log.csv \
http://127.0.0.1:8040/api/app_analytics/user_access_log/_stream_load
参数解析:
| 参数 | 含义 |
|---|---|
-u root: |
认证信息(用户名:密码) |
label |
导入标签,全局唯一,用于幂等性保证(同一个 label 不会重复导入) |
column_separator |
列分隔符,默认是 \t |
-T |
要上传的文件 |
导入 JSON 文件:
bash
# json_data.json 内容示例:
# [
# {"access_date":"2026-03-01", "user_id":1001, "page_url":"/home", ...},
# {"access_date":"2026-03-01", "user_id":1002, "page_url":"/cart", ...}
# ]
curl -u root: \
-H "label:load_json_001" \
-H "format:json" \
-H "strip_outer_array:true" \
-T json_data.json \
http://127.0.0.1:8040/api/app_analytics/user_access_log/_stream_load
返回结果解读:
json
{
"TxnId": 1003,
"Label": "load_json_001",
"Status": "Success", // Publish Timeout / Fail 表示失败
"NumberTotalRows": 1000, // 总行数
"NumberLoadedRows": 998, // 成功导入行数
"NumberFilteredRows": 2, // 被过滤的行数(数据质量问题)
"NumberUnselectedRows": 0, // 不满足条件的行数
"LoadBytes": 56789,
"LoadTimeMs": 150
}
Stream Load 的原子性保证: 一次 Stream Load 要么全部成功,要么全部失败。Label 机制保证了幂等性------如果网络超时不确定是否成功,用相同 Label 重试即可。
5.3 Routine Load------"Kafka 消费者机器人"
核心思想: Doris 主动去 Kafka 拉取数据,7×24 小时不间断运行。
适用场景: 实时数据管道、CDC 数据同步、日志持续导入
sql
-- 创建 Routine Load 任务
CREATE ROUTINE LOAD app_analytics.kafka_log_load ON user_access_log
COLUMNS(access_date, access_time, user_id, session_id, page_url,
referrer, device_type, os, browser, ip, duration_sec)
PROPERTIES (
"desired_concurrent_number" = "3", -- 并发消费者数量
"max_batch_interval" = "10", -- 最大攒批间隔(秒)
"max_batch_rows" = "200000", -- 最大攒批行数
"max_batch_size" = "104857600", -- 最大攒批大小(100MB)
"strict_mode" = "true", -- 严格模式:不合格数据直接报错
"format" = "json",
"jsonpaths" = "[\"$.access_date\",\"$.access_time\",\"$.user_id\",
\"$.session_id\",\"$.page_url\",\"$.referrer\",
\"$.device_type\",\"$.os\",\"$.browser\",
\"$.ip\",\"$.duration_sec\"]"
)
FROM KAFKA (
"kafka_broker_list" = "localhost:9092",
"kafka_topic" = "user_access_log",
"property.group.id" = "doris_consumer_group",
"property.kafka_default_offsets" = "OFFSET_END" -- 从最新位置开始消费
);
运维管理命令:
sql
-- 查看任务状态
SHOW ROUTINE LOAD FOR kafka_log_load\G
-- 暂停任务
PAUSE ROUTINE LOAD FOR kafka_log_load;
-- 恢复任务
RESUME ROUTINE LOAD FOR kafka_log_load;
-- 停止任务(不可恢复)
STOP ROUTINE LOAD FOR kafka_log_load;
-- 查看消费进度
SHOW ROUTINE LOAD TASK WHERE JobName = 'kafka_log_load'\G
Routine Load 的两级调度机制:
arduino
Routine Load Job
│
┌────────────┼────────────┐
│ │ │
Task-1 Task-2 Task-3 ← FE 将 Job 拆分为多个 Task
(partition0) (partition1) (partition2)
│ │ │
▼ ▼ ▼
BE-1 BE-2 BE-3 ← 每个 Task 在一个 BE 上执行
(拉 Kafka → (拉 Kafka → (拉 Kafka →
写 Doris) 写 Doris) 写 Doris)
Exactly-Once 语义保证: Doris 在 FE 端记录每个 Kafka Partition 的消费 Offset。每个微批次在事务中完成"拉取数据 + 写入 Doris + 提交 Offset"三个动作,保证不丢不重。
5.4 Broker Load------"搬家公司搬大数据"
核心思想: 异步提交导入任务,后台执行,适合大规模历史数据迁移。
适用场景: 从 HDFS、S3、OSS 等对象存储批量导入 TB 级数据
sql
-- 从 S3 导入
LOAD LABEL app_analytics.s3_load_20260301
(
DATA INFILE("s3://my-bucket/logs/2026-03-01/*.parquet")
INTO TABLE user_access_log
FORMAT AS "parquet"
)
WITH S3 (
"AWS_ENDPOINT" = "s3.cn-north-1.amazonaws.com.cn",
"AWS_ACCESS_KEY" = "your_access_key",
"AWS_SECRET_KEY" = "your_secret_key",
"AWS_REGION" = "cn-north-1"
)
PROPERTIES (
"timeout" = "3600" -- 超时时间 1 小时
);
-- 查看导入进度
SHOW LOAD WHERE LABEL = 's3_load_20260301'\G
5.5 三种导入方式对比
| 特性 | Stream Load | Routine Load | Broker Load |
|---|---|---|---|
| 数据源 | 本地文件/HTTP 请求 | Kafka | HDFS/S3/OSS |
| 同步/异步 | 同步 | 持续异步 | 异步 |
| 适合数据量 | < 10GB(单次) | 持续流式 | TB 级 |
| 实时性 | 秒级 | 秒级 | 分钟~小时级 |
| 使用方式 | curl / HTTP 客户端 | SQL 创建任务 | SQL 提交任务 |
| 原子性 | 单次全成功或全失败 | 微批级别 | 单次任务 |
第六章:Multi-Catalog------"不搬数据,直接查"
6.1 核心价值
传统数据分析流程:MySQL → ETL → Hive → Doris → 查询(链路长、延迟高)
Multi-Catalog 之后:直接在 Doris 里用 SQL 查 MySQL/Hive/S3 的数据(ZeroETL)
scss
┌─────────────────────────────────────────────┐
│ Doris SQL 引擎 │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Internal │ │ MySQL │ │ Hive │ │
│ │ Catalog │ │ Catalog │ │ Catalog │ │
│ │ (本地表) │ │ (外部) │ │ (外部) │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
└───────┼────────────┼────────────┼───────────┘
│ │ │
Doris BE MySQL Server HDFS/S3
(本地存储) (远程读取) (远程读取)
6.2 创建外部 Catalog
sql
-- 连接外部 MySQL
CREATE CATALOG mysql_biz PROPERTIES (
'type' = 'jdbc',
'user' = 'readonly_user',
'password' = 'password123',
'jdbc_url' = 'jdbc:mysql://10.0.0.1:3306',
'driver_url' = 'mysql-connector-j-8.3.0.jar',
'driver_class' = 'com.mysql.cj.jdbc.Driver'
);
-- 连接 Hive(通过 HMS)
CREATE CATALOG hive_dw PROPERTIES (
'type' = 'hms',
'hive.metastore.uris' = 'thrift://10.0.0.2:9083'
);
-- 查看所有 Catalog
SHOW CATALOGS;
-- 切换到 MySQL Catalog
SWITCH mysql_biz;
SHOW DATABASES;
USE order_db;
SELECT * FROM orders LIMIT 10;
-- 跨 Catalog JOIN(核心能力!)
SELECT
d.order_id,
d.total_amount,
m.user_name,
m.vip_level
FROM internal.app_analytics.order_detail d -- Doris 本地表
JOIN mysql_biz.user_db.user_info m ON d.user_id = m.id -- 外部 MySQL 表
WHERE d.order_status = 3;
给别人讲的关键句: "Multi-Catalog 让 Doris 变成了一个'SQL 网关'------不管你的数据在 MySQL、Hive、还是 S3 里,在 Doris 这里都是一张表,写一条 SQL 就能跨源 JOIN。"
第七章:物化视图------"用空间换时间的艺术"
7.1 核心思想
物化视图 = 预计算 + 缓存结果。把经常要跑的复杂查询的结果提前算好、存起来,查询时直接取结果,不再从原始数据算。
类比: 你每天早上都要算"昨天全国各城市的销售额"。与其每次从几亿条订单明细里现场 SUM,不如每天晚上算好存一张汇总表------物化视图就是 Doris 自动帮你做这件事。
7.2 同步物化视图(单表加速)
特点:
- 基于单表创建
- 数据导入时强同步更新
- 查询原表时,优化器自动路由到物化视图(用户无感知!)
sql
-- 原始明细表
CREATE TABLE user_access_log (
access_date DATE,
user_id BIGINT,
page_url VARCHAR(512),
duration_sec INT
)
DUPLICATE KEY(access_date, user_id)
DISTRIBUTED BY HASH(user_id) BUCKETS 16;
-- 创建同步物化视图:按天、按页面统计 PV 和 平均停留时长
CREATE MATERIALIZED VIEW mv_daily_page_stats AS
SELECT
access_date,
page_url,
COUNT(*) AS pv,
SUM(duration_sec) AS total_duration,
COUNT(DISTINCT user_id) AS uv
FROM user_access_log
GROUP BY access_date, page_url;
查询时的智能路由:
sql
-- 用户写的 SQL(查原表)
SELECT access_date, page_url, COUNT(*) AS pv
FROM user_access_log
WHERE access_date = '2026-03-15'
GROUP BY access_date, page_url;
-- Doris 优化器自动识别:这个查询可以命中 mv_daily_page_stats!
-- 实际执行:直接读物化视图,速度提升 10x ~ 100x
验证是否命中物化视图:
sql
EXPLAIN SELECT access_date, page_url, COUNT(*) AS pv
FROM user_access_log
WHERE access_date = '2026-03-15'
GROUP BY access_date, page_url;
-- 在执行计划中看到 "rollup: mv_daily_page_stats" 就说明命中了
7.3 异步物化视图(多表 JOIN 加速)
特点:
- 支持多表 JOIN
- 最终一致性(可定时/手动刷新)
- 可直接查询
sql
-- 场景:经常需要关联订单表和用户表做分析
CREATE MATERIALIZED VIEW mv_order_user_stats
BUILD IMMEDIATE -- 创建后立即构建
REFRESH AUTO -- 自动增量刷新
ON SCHEDULE EVERY 1 HOUR -- 每小时刷新一次
DISTRIBUTED BY HASH(user_id) BUCKETS 8
AS
SELECT
o.user_id,
u.city,
u.vip_level,
DATE_TRUNC(o.create_time, 'day') AS order_date,
COUNT(*) AS order_count,
SUM(o.total_amount) AS total_sales
FROM order_detail o
JOIN user_info u ON o.user_id = u.user_id
GROUP BY o.user_id, u.city, u.vip_level, DATE_TRUNC(o.create_time, 'day');
-- 可以直接查询这个物化视图
SELECT city, SUM(total_sales) FROM mv_order_user_stats
WHERE order_date = '2026-03-15'
GROUP BY city;
7.4 同步 vs 异步物化视图对比
| 特性 | 同步物化视图 | 异步物化视图 |
|---|---|---|
| 表数量 | 仅单表 | 支持多表 JOIN |
| 数据一致性 | 强一致(实时同步) | 最终一致(定时刷新) |
| 能否直接查 | 不能(自动路由) | 可以 |
| 刷新方式 | 数据导入时自动 | 定时/手动/数据变更触发 |
| 适用场景 | 加速单表聚合查询 | 加速跨表 JOIN 分析 |
第八章:性能分析------"医生给 SQL 做体检"
8.1 EXPLAIN------看执行计划
EXPLAIN 就像是 SQL 的"X 光片"------不实际执行查询,只展示 Doris 打算怎么执行。
sql
EXPLAIN VERBOSE
SELECT access_date, COUNT(*) AS pv, COUNT(DISTINCT user_id) AS uv
FROM user_access_log
WHERE access_date BETWEEN '2026-03-01' AND '2026-03-07'
GROUP BY access_date
ORDER BY access_date;
执行计划关键信息解读:
ini
┌───────────────────────────────────────────────┐
│ 重点关注这几个信息: │
│ │
│ 1. partitions=7/90 分区裁剪! │
│ 只扫描 7 个分区(共 90 个)→ 裁剪了 92% │
│ │
│ 2. rollup: mv_daily_page_stats │
│ 命中了物化视图!→ 不扫描原始数据 │
│ │
│ 3. tabletNum=112, tabletList=10001,10002... │
│ 并行扫描 112 个 Tablet │
│ │
│ 4. PREDICATES: access_date >= '2026-03-01' │
│ 谓词下推成功!→ 在存储层就过滤了数据 │
└───────────────────────────────────────────────┘
执行计划中的红色预警信号:
partitions=90/90→ 全分区扫描,没有裁剪,WHERE 条件可能没命中分区列rollup: user_access_log(原表名) → 没命中物化视图- 巨大的
EXCHANGE节点 → 数据在 BE 之间大量 Shuffle
8.2 Query Profile------"手术刀级的性能诊断"
Profile 是查询执行后的"体检报告",告诉你时间到底花在了哪里。
sql
-- 开启 Profile 收集
SET enable_profile = true;
-- 执行你的查询
SELECT ...;
-- 方法1:通过 Web UI 查看(推荐)
-- 浏览器打开 http://FE_IP:8030 → QueryProfile
-- 方法2:通过 API 获取
-- curl http://FE_IP:8030/api/profile?query_id=xxx
Profile 核心指标:
| 指标 | 含义 | 异常信号 |
|---|---|---|
ScanRows |
扫描行数 | 远大于返回行数 → 过滤效率低 |
RowsKeyRangeFiltered |
前缀索引过滤的行数 | 为 0 → 前缀索引没生效 |
BlockConditionsFilteredRows |
ZoneMap 过滤的行数 | 为 0 → 考虑排序优化 |
InvertedIndexFilteredRows |
倒排索引过滤的行数 | 未出现 → 倒排索引没用上 |
PeakMemoryUsage |
内存峰值 | 过大 → 可能导致 OOM |
NetworkTime |
网络传输时间 | 占比高 → 数据 Shuffle 过多 |
IOTime |
磁盘 IO 时间 | 占比高 → 考虑增加索引或物化视图 |
8.3 常用的调优手段速查
sql
性能差?按这个顺序排查:
│
├── 1. WHERE 条件命中分区键了吗?
│ → 没有 → 加上时间条件 / 调整分区策略
│
├── 2. 命中物化视图了吗?
│ → 没有 → 创建匹配的物化视图
│
├── 3. 前缀索引/倒排索引生效了吗?
│ → 没有 → 调整排序键顺序 / 添加倒排索引
│
├── 4. JOIN 时数据 Shuffle 太多?
│ → 是 → 考虑 Colocate Join(两张表用相同的分桶策略)
│ → 或 → 小表用 Broadcast Join
│
└── 5. 以上都没问题?
→ 增加 BE 节点(线性扩展算力)
→ 或增加分桶数(提高并行度)
第三部分:实战项目详解
项目一:电商用户行为日志分析平台
P1.1 项目架构
scss
模拟数据生成器 Doris
(Python 脚本) │
│ ┌────────┼────────┐
│ │ │ │
▼ ▼ ▼ ▼
CSV 文件 ──Stream Load──→ 明细表 聚合表 物化视图
(Duplicate) (Aggregate) (SYNC MV)
│ │ │
└────────┼────────┘
│
SQL 分析
(PV/UV/留存/漏斗)
P1.2 第一步:生成模拟数据
python
# generate_user_logs.py
import csv
import random
from datetime import datetime, timedelta
events = ['click', 'view', 'add_cart', 'purchase', 'search']
pages = ['/home', '/category/phones', '/product/iphone15', '/product/macbook',
'/cart', '/checkout', '/order/success', '/search?q=laptop']
devices = ['mobile', 'desktop', 'tablet']
os_list = ['iOS', 'Android', 'Windows', 'macOS']
with open('user_logs.csv', 'w', newline='') as f:
writer = csv.writer(f)
base_time = datetime(2026, 3, 1)
for i in range(50000): # 生成 5 万条
dt = base_time + timedelta(
days=random.randint(0, 6),
hours=random.randint(0, 23),
minutes=random.randint(0, 59),
seconds=random.randint(0, 59)
)
user_id = random.randint(1, 5000)
writer.writerow([
dt.strftime('%Y-%m-%d'),
dt.strftime('%Y-%m-%d %H:%M:%S'),
user_id,
f'sess_{user_id}_{random.randint(1,100)}',
random.choice(pages),
random.choice(pages),
random.choice(devices),
random.choice(os_list),
'Chrome',
f'192.168.1.{random.randint(1,255)}',
random.randint(1, 300)
])
print("生成完成: user_logs.csv (50000 条)")
P1.3 第二步:建表
sql
CREATE DATABASE IF NOT EXISTS ecommerce_analytics;
USE ecommerce_analytics;
-- 表1:明细模型(存原始日志)
CREATE TABLE user_behavior_detail (
event_date DATE NOT NULL,
event_time DATETIME NOT NULL,
user_id BIGINT NOT NULL,
session_id VARCHAR(64),
event_type VARCHAR(32),
page_url VARCHAR(256),
referrer VARCHAR(256),
device VARCHAR(32),
os VARCHAR(32),
browser VARCHAR(32),
ip VARCHAR(64),
duration_sec INT
)
DUPLICATE KEY(event_date, event_time, user_id)
PARTITION BY RANGE(event_date) (
PARTITION p20260301 VALUES LESS THAN ('2026-03-02'),
PARTITION p20260302 VALUES LESS THAN ('2026-03-03'),
PARTITION p20260303 VALUES LESS THAN ('2026-03-04'),
PARTITION p20260304 VALUES LESS THAN ('2026-03-05'),
PARTITION p20260305 VALUES LESS THAN ('2026-03-06'),
PARTITION p20260306 VALUES LESS THAN ('2026-03-07'),
PARTITION p20260307 VALUES LESS THAN ('2026-03-08')
)
DISTRIBUTED BY HASH(user_id) BUCKETS 8
PROPERTIES ("replication_num" = "1");
-- 表2:聚合模型(自动计算 PV/UV)
CREATE TABLE page_stats_agg (
event_date DATE NOT NULL,
page_url VARCHAR(256) NOT NULL,
device VARCHAR(32) NOT NULL,
pv BIGINT SUM, -- PV 自动累加
uv_hll HLL HLL_UNION, -- UV 模糊去重(HyperLogLog)
uv_bitmap BITMAP BITMAP_UNION, -- UV 精确去重(Bitmap)
max_duration INT MAX,
min_duration INT MIN
)
AGGREGATE KEY(event_date, page_url, device)
PARTITION BY RANGE(event_date) (
PARTITION p20260301 VALUES LESS THAN ('2026-03-02'),
PARTITION p20260302 VALUES LESS THAN ('2026-03-03'),
PARTITION p20260303 VALUES LESS THAN ('2026-03-04'),
PARTITION p20260304 VALUES LESS THAN ('2026-03-05'),
PARTITION p20260305 VALUES LESS THAN ('2026-03-06'),
PARTITION p20260306 VALUES LESS THAN ('2026-03-07'),
PARTITION p20260307 VALUES LESS THAN ('2026-03-08')
)
DISTRIBUTED BY HASH(page_url) BUCKETS 8
PROPERTIES ("replication_num" = "1");
P1.4 第三步:导入数据
bash
# 导入明细表
curl -u root: \
-H "label:detail_load_001" \
-H "column_separator:," \
-T user_logs.csv \
http://127.0.0.1:8040/api/ecommerce_analytics/user_behavior_detail/_stream_load
导入聚合表时需要用到特殊函数:
sql
-- 用 INSERT INTO SELECT 从明细表导入聚合表
INSERT INTO page_stats_agg
SELECT
event_date,
page_url,
device,
1, -- 每行 PV = 1,SUM 后就是总 PV
HLL_HASH(user_id), -- 将 user_id 转为 HLL
TO_BITMAP(user_id), -- 将 user_id 转为 Bitmap
duration_sec,
duration_sec
FROM user_behavior_detail;
P1.5 第四步:分析查询
sql
-- 1. 每日 PV/UV 统计
SELECT
event_date,
SUM(pv) AS total_pv,
HLL_UNION_AGG(uv_hll) AS approx_uv, -- HLL 模糊去重
BITMAP_UNION_COUNT(uv_bitmap) AS exact_uv -- Bitmap 精确去重
FROM page_stats_agg
GROUP BY event_date
ORDER BY event_date;
-- 2. 页面热度排行(基于明细表)
SELECT
page_url,
COUNT(*) AS pv,
COUNT(DISTINCT user_id) AS uv,
AVG(duration_sec) AS avg_duration
FROM user_behavior_detail
WHERE event_date = '2026-03-05'
GROUP BY page_url
ORDER BY pv DESC
LIMIT 10;
-- 3. 简易漏斗分析:浏览 → 加购 → 购买 转化率
WITH funnel AS (
SELECT
COUNT(DISTINCT CASE WHEN event_type = 'view' THEN user_id END) AS view_users,
COUNT(DISTINCT CASE WHEN event_type = 'add_cart' THEN user_id END) AS cart_users,
COUNT(DISTINCT CASE WHEN event_type = 'purchase' THEN user_id END) AS buy_users
FROM user_behavior_detail
WHERE event_date BETWEEN '2026-03-01' AND '2026-03-07'
)
SELECT
view_users,
cart_users,
buy_users,
ROUND(cart_users * 100.0 / view_users, 2) AS view_to_cart_rate,
ROUND(buy_users * 100.0 / cart_users, 2) AS cart_to_buy_rate,
ROUND(buy_users * 100.0 / view_users, 2) AS overall_conversion_rate
FROM funnel;
-- 4. 次日留存率
SELECT
t1.event_date AS day0,
COUNT(DISTINCT t1.user_id) AS day0_users,
COUNT(DISTINCT t2.user_id) AS day1_retained,
ROUND(COUNT(DISTINCT t2.user_id) * 100.0 / COUNT(DISTINCT t1.user_id), 2) AS retention_rate
FROM user_behavior_detail t1
LEFT JOIN user_behavior_detail t2
ON t1.user_id = t2.user_id
AND t2.event_date = DATE_ADD(t1.event_date, INTERVAL 1 DAY)
WHERE t1.event_date BETWEEN '2026-03-01' AND '2026-03-06'
GROUP BY t1.event_date
ORDER BY t1.event_date;
P1.6 HLL vs Bitmap 深入理解
ini
┌─────────────────────────────────────────────────────────────┐
│ 精确去重 vs 模糊去重 │
│ │
│ Bitmap(精确去重) │
│ ─────────────── │
│ 原理:用一个超大的位数组,每个用户 ID 对应一个 bit │
│ user_id=1 → bit[1]=1 │
│ user_id=5 → bit[5]=1 │
│ UV = popcount(bitmap) = 统计有多少个 1 │
│ │
│ 优点:精确,零误差 │
│ 缺点:如果 user_id 值域很大(如 64 位),内存消耗大 │
│ 适用:user_id 是连续的小整数,或总去重数 < 千万级 │
│ │
│ HLL(HyperLogLog,模糊去重) │
│ ────────────────────────── │
│ 原理:概率算法,通过哈希值的"前导零"个数估算基数 │
│ 只用固定的几 KB 内存,就能估算几十亿的去重数 │
│ │
│ 优点:内存极小(固定 ~16KB),速度极快 │
│ 缺点:有 ~1% 的误差 │
│ 适用:去重数 > 千万级,对精确度要求不高(如 UV 统计) │
└─────────────────────────────────────────────────────────────┘
项目二:实时订单业务看板(CDC + 数据更新)
P2.1 项目架构
markdown
MySQL (OLTP) Debezium/Flink CDC Kafka Doris
┌──────────┐ ┌──────────────────┐ ┌──────────┐ ┌──────────────┐
│ 订单表 │───→│ 监听 Binlog │───→│ Topic: │───→│ Routine Load │
│ (增删改) │ │ 生成变更事件 │ │ orders │ │ → Unique 表 │
└──────────┘ └──────────────────┘ └──────────┘ └──────┬───────┘
│
BI 工具
(Superset/
Metabase)
P2.2 Doris 端建表
sql
CREATE DATABASE IF NOT EXISTS realtime_dashboard;
USE realtime_dashboard;
-- 订单实时表(Unique Key + Merge-on-Write)
CREATE TABLE rt_orders (
order_id BIGINT NOT NULL COMMENT '订单ID(主键)',
user_id BIGINT NOT NULL,
product_id BIGINT,
product_name VARCHAR(128),
quantity INT,
unit_price DECIMAL(10,2),
total_amount DECIMAL(10,2),
order_status VARCHAR(32) COMMENT 'CREATED/PAID/SHIPPED/COMPLETED/REFUNDED',
payment_method VARCHAR(32),
create_time DATETIME NOT NULL,
update_time DATETIME
)
UNIQUE KEY(order_id)
PARTITION BY RANGE(create_time) ()
DISTRIBUTED BY HASH(order_id) BUCKETS 16
PROPERTIES (
"replication_num" = "1",
"enable_unique_key_merge_on_write" = "true",
"auto_partition" = "true",
"auto_partition.time_unit" = "DAY"
);
P2.3 Routine Load 消费 Kafka
sql
-- 假设 Kafka 中的 JSON 格式(Debezium 的 after 字段):
-- {
-- "order_id": 10001,
-- "user_id": 2001,
-- "product_id": 3001,
-- "product_name": "iPhone 15",
-- "quantity": 1,
-- "unit_price": 7999.00,
-- "total_amount": 7999.00,
-- "order_status": "PAID",
-- "payment_method": "wechat",
-- "create_time": "2026-03-15 14:30:00",
-- "update_time": "2026-03-15 14:31:00"
-- }
CREATE ROUTINE LOAD realtime_dashboard.orders_from_kafka ON rt_orders
COLUMNS(order_id, user_id, product_id, product_name, quantity,
unit_price, total_amount, order_status, payment_method,
create_time, update_time)
PROPERTIES (
"desired_concurrent_number" = "3",
"max_batch_interval" = "5",
"max_batch_rows" = "100000",
"format" = "json",
"strict_mode" = "true"
)
FROM KAFKA (
"kafka_broker_list" = "localhost:9092",
"kafka_topic" = "mysql_orders_cdc",
"property.group.id" = "doris_orders_consumer"
);
P2.4 实时看板查询
sql
-- 1. 今日实时销售看板
SELECT
order_status,
COUNT(*) AS order_count,
SUM(total_amount) AS total_sales,
AVG(total_amount) AS avg_order_amount
FROM rt_orders
WHERE create_time >= CURDATE()
GROUP BY order_status;
-- 2. 每小时销售趋势
SELECT
DATE_FORMAT(create_time, '%Y-%m-%d %H:00:00') AS hour_slot,
COUNT(*) AS orders,
SUM(total_amount) AS sales
FROM rt_orders
WHERE create_time >= CURDATE()
AND order_status IN ('PAID', 'SHIPPED', 'COMPLETED')
GROUP BY hour_slot
ORDER BY hour_slot;
-- 3. 热销商品 TOP 10
SELECT
product_name,
SUM(quantity) AS total_sold,
SUM(total_amount) AS total_revenue
FROM rt_orders
WHERE create_time >= CURDATE()
AND order_status != 'REFUNDED'
GROUP BY product_name
ORDER BY total_revenue DESC
LIMIT 10;
-- 4. 退款率监控
SELECT
ROUND(
COUNT(CASE WHEN order_status = 'REFUNDED' THEN 1 END) * 100.0
/ COUNT(*), 2
) AS refund_rate
FROM rt_orders
WHERE create_time >= CURDATE();
P2.5 数据更新的核心机制
ini
时间线:
14:30:00 订单 10001 创建 → order_status = 'CREATED'
14:31:00 订单 10001 支付 → order_status = 'PAID' ← Kafka 推送新消息
14:35:00 订单 10001 发货 → order_status = 'SHIPPED' ← Kafka 推送新消息
Unique Key (Merge-on-Write) 行为:
14:30:00 写入 (10001, CREATED)
14:31:00 写入 (10001, PAID) → 发现 order_id=10001 已存在
→ 标记旧记录删除,写入新记录
→ 表中只保留 (10001, PAID)
14:35:00 同理,最终只保留 (10001, SHIPPED)
项目三:全域数据探索与海量日志检索引擎
P3.1 项目架构
scss
┌──────────────────────────────────────────────────────────────┐
│ 统一 SQL 查询入口 │
│ (Doris FE) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Internal │ │ MySQL │ │ Hive/S3 │ │
│ │ Catalog │ │ Catalog │ │ Catalog │ │
│ │ │ │ │ │ │ │
│ │ 日志表 │ │ 用户表 │ │ 历史订单表 │ │
│ │ (倒排索引) │ │ (外部只读) │ │ (外部只读) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └─────── JOIN ────┴──── JOIN ───────┘ │
└──────────────────────────────────────────────────────────────┘
P3.2 日志检索引擎
sql
CREATE DATABASE IF NOT EXISTS log_search;
USE log_search;
-- 海量错误日志表(带倒排索引)
CREATE TABLE system_error_logs (
log_time DATETIME NOT NULL COMMENT '日志时间',
log_level VARCHAR(16) NOT NULL COMMENT 'ERROR/WARN/INFO',
service_name VARCHAR(64) NOT NULL COMMENT '服务名称',
host_ip VARCHAR(32) COMMENT '主机IP',
trace_id VARCHAR(64) COMMENT '链路追踪ID',
error_code VARCHAR(32) COMMENT '错误码',
error_message TEXT COMMENT '错误信息',
stack_trace TEXT COMMENT '堆栈轨迹',
request_url VARCHAR(512) COMMENT '请求URL',
request_params TEXT COMMENT '请求参数',
-- 倒排索引定义
INDEX idx_level (log_level) USING INVERTED,
INDEX idx_service (service_name) USING INVERTED,
INDEX idx_trace (trace_id) USING INVERTED,
INDEX idx_error_code (error_code) USING INVERTED,
INDEX idx_error_msg (error_message) USING INVERTED
PROPERTIES("parser" = "unicode"),
INDEX idx_stack (stack_trace) USING INVERTED
PROPERTIES("parser" = "english"),
INDEX idx_url (request_url) USING INVERTED
PROPERTIES("parser" = "unicode")
)
DUPLICATE KEY(log_time, log_level, service_name)
PARTITION BY RANGE(log_time) ()
DISTRIBUTED BY HASH(service_name) BUCKETS 32
PROPERTIES (
"replication_num" = "1",
"auto_partition" = "true",
"auto_partition.time_unit" = "DAY"
);
日志检索查询示例:
sql
-- 1. 全文检索:查找包含 "timeout" 或 "connection refused" 的错误
SELECT log_time, service_name, error_message
FROM system_error_logs
WHERE error_message MATCH_ANY 'timeout connection refused'
AND log_time >= '2026-03-15'
ORDER BY log_time DESC
LIMIT 50;
-- 2. 精确短语搜索:查找 NullPointerException
SELECT log_time, service_name, stack_trace
FROM system_error_logs
WHERE stack_trace MATCH_PHRASE 'NullPointerException'
AND log_level = 'ERROR'
AND log_time >= '2026-03-15'
LIMIT 20;
-- 3. 错误统计面板:按服务和错误码聚合
SELECT
service_name,
error_code,
COUNT(*) AS error_count,
MIN(log_time) AS first_seen,
MAX(log_time) AS last_seen
FROM system_error_logs
WHERE log_time >= CURDATE()
AND log_level = 'ERROR'
GROUP BY service_name, error_code
ORDER BY error_count DESC
LIMIT 20;
-- 4. 链路追踪:通过 trace_id 串联一次请求的所有日志
SELECT log_time, service_name, log_level, error_message
FROM system_error_logs
WHERE trace_id = 'abc123def456'
ORDER BY log_time;
P3.3 联邦查询:跨源 JOIN
sql
-- 创建外部数据源 Catalog
CREATE CATALOG mysql_prod PROPERTIES (
'type' = 'jdbc',
'user' = 'reader',
'password' = 'readonly_pwd',
'jdbc_url' = 'jdbc:mysql://mysql-prod:3306',
'driver_url' = 'mysql-connector-j-8.3.0.jar',
'driver_class' = 'com.mysql.cj.jdbc.Driver'
);
-- 跨源 JOIN:将本地日志表与外部 MySQL 用户表关联
-- 场景:查找 VIP 用户遇到的错误,优先处理
SELECT
l.log_time,
l.service_name,
l.error_message,
u.user_name,
u.vip_level,
u.phone
FROM log_search.system_error_logs l
JOIN mysql_prod.user_db.users u
ON CAST(
REGEXP_EXTRACT(l.request_params, '"user_id":(\\d+)', 1)
AS BIGINT
) = u.id
WHERE l.log_time >= '2026-03-15'
AND l.log_level = 'ERROR'
AND u.vip_level >= 3
ORDER BY u.vip_level DESC, l.log_time DESC
LIMIT 50;
P3.4 性能调优:JOIN 策略
sql
-- 查看执行计划,关注 JOIN 策略
EXPLAIN VERBOSE
SELECT ... -- 你的跨源 JOIN 查询
-- 常见 JOIN 策略及适用场景:
--
-- 1. Broadcast Join(广播 JOIN)
-- 小表广播到所有 BE 节点,适合大表 JOIN 小表
-- SET broadcast_row_count_limit = 10000000; -- 调整广播阈值
--
-- 2. Shuffle Join(Hash Join)
-- 按 JOIN Key 做 Hash 重新分布,适合两个大表
-- 这是默认策略
--
-- 3. Colocate Join(本地 JOIN)
-- 两张表使用相同的分桶策略,数据天然在同一节点
-- 查询时无需 Shuffle,性能最好
-- 要求:相同 Colocate Group、相同分桶列、相同分桶数
-- 创建 Colocate 表示例:
CREATE TABLE table_a (...)
DISTRIBUTED BY HASH(user_id) BUCKETS 16
PROPERTIES ("colocate_with" = "group_1");
CREATE TABLE table_b (...)
DISTRIBUTED BY HASH(user_id) BUCKETS 16
PROPERTIES ("colocate_with" = "group_1");
-- 此时 table_a JOIN table_b ON user_id 会自动用 Colocate Join
第四部分:知识体系总结
全局数据流图(核心记忆图)
scss
数据源
┌──────────────────┼──────────────────┐
│ │ │
本地文件/API Kafka HDFS/S3/OSS
│ │ │
▼ ▼ ▼
Stream Load Routine Load Broker Load
│ │ │
└──────────────────┼──────────────────┘
│
▼
┌──────────────┐
│ Doris 表 │
│ │
│ • Duplicate │ ← 日志、明细
│ • Aggregate │ ← 报表、聚合
│ • Unique │ ← 更新、去重
│ │
│ 分区 + 分桶 │ ← 数据分布
│ 索引体系 │ ← 加速过滤
└──────┬───────┘
│
┌────────────┼────────────┐
│ │ │
物化视图 EXPLAIN Multi-Catalog
(预计算) (执行计划) (联邦查询)
│ │ │
└────────────┼────────────┘
│
▼
用户 SQL 查询
面试 / 讲解速查卡
| 问题 | 关键回答 |
|---|---|
| Doris 是什么? | MPP 分析型数据库,兼容 MySQL 协议,一个引擎覆盖实时报表+日志检索+数据联邦 |
| 三大模型怎么选? | 要明细用 Duplicate,要聚合用 Aggregate,要更新用 Unique |
| Merge-on-Write vs Read? | Write 写时合并查得快,Read 追加写入但查得慢。默认用 Write |
| 分区 vs 分桶? | 分区做数据裁剪(通常按时间),分桶做并行度和负载均衡(通常按 Hash) |
| 前缀索引的限制? | 最多 36 字节,遇到 VARCHAR 截断,只对排序键前缀有效 |
| 为什么用 Doris 替代 ES? | 倒排索引 + SQL 接口 + 统一存储,运维成本更低,生态更统一 |
| Stream Load vs Routine Load? | Stream 是推(HTTP POST),Routine 是拉(Kafka 消费) |
| 物化视图的价值? | 空间换时间,预计算常用聚合结果,查询可加速 10x~100x |
| Multi-Catalog 的价值? | ZeroETL,不搬数据直接跨源 JOIN |
最后的建议: 理论看三遍不如动手做一遍。按照上面的项目,从项目一开始实操。每个 SQL 都亲自敲一遍,每个 EXPLAIN 都看一遍,很快你就能给别人讲清楚整个 Doris 的世界。