Day 13|Doris 高并发 Serving:Prepared Statement、Row Store、Short-Circuit、Row Cache 与查询缓存
30 天学习路径 · 第 13 天
前置知识:Day 7 Unique Key、Day 8 分桶与 Tablet、Day 11 索引、Day 12 物化视图
建议版本基线:Apache Doris 4.x;实验前请在目标版本再次核对参数和语法。

1. 今天要解决的核心问题
前十二天,我们已经把 Doris 从“实时分析数据库”拆到了表模型、物理组织、导入、索引和物化视图。到这里,很容易形成一个印象:Doris 的优势主要来自大规模扫描和并行计算。这个印象只覆盖了一部分能力。
真实企业系统里还有一类非常常见的请求:
SELECT *
FROM user_profile
WHERE user_id = ?;
或者:
SELECT order_id, user_id, status, amount, pay_time, delivery_time
FROM order_current
WHERE order_id = ?;
它们没有复杂 Join,也没有大规模聚合。一次请求通常只需要一行,业务却可能在短时间内发起成千上万次。用户中心、订单详情、设备状态、风控特征读取、广告用户画像、配置查询,都可能出现这种模式。
如果让这类请求完整走一遍传统 OLAP 查询流程,很多固定成本会被放大:SQL 解析、语义分析、优化器规划、Fragment 构造、调度、RPC、多个列文件随机读取、列值重组。单次成本看起来不大,高 QPS 下会持续消耗 FE CPU、BE CPU、网络和 IOPS。
Doris 为这类请求提供了一条专门的点查优化路径。官方 4.x 文档把它归纳为几层协同能力:Unique Key Merge-on-Write、Row Store、Short-Circuit、PreparedStatement 和 Row Cache。它们共同目标很清晰:让一个完整主键等值查询尽量接近一次 KV Lookup。
今天还会讲另一条性能路径——缓存。点查优化解决“每一次主键读取如何更轻”,SQL Cache、Query Cache、Condition Cache 等能力解决“已经计算过的东西能否直接复用”。两类技术面向的请求不同,混在一起理解容易产生错误设计。
学完今天,你应该能够回答六个问题:
- 什么样的 SQL 才能触发 Doris 的 Short-Circuit 点查路径?
- Row Store 为什么能降低宽表整行读取的 IOPS?
- PreparedStatement 到底省掉了哪些工作?
- Row Cache 与 Page Cache 有什么粒度差异?
- SQL Cache、Query Cache、Condition Cache 分别适合什么场景?
- 如何设计一套能进入生产评审的高并发点查 POC?
2. 从 OLAP 查询成本理解高并发点查
一个典型分析 SQL 会经过 FE 的解析、绑定、优化,再形成分布式计划;BE 扫描 Tablet 中的 Segment,做过滤、Join、聚合、Exchange,最终返回结果。这条通用路径的价值在于处理复杂查询,它能够利用 MPP、向量化、Pipeline 和大量并行任务。
主键点查的计算结构非常简单。假设 order_id=1008611 唯一确定一行数据,系统实际需要完成的事情只有几件:
- 找到这个 Key 会落在哪个分区和 Bucket;
- 找到对应 Tablet;
- 在 Tablet 中定位最新可见版本;
- 读取需要返回的字段;
- 把一行结果交给客户端。
当请求规模达到高并发之后,通用执行框架中的固定步骤会成为主要成本。点查优化的思路因此可以概括为:已知答案在哪,就减少通用规划和大范围扫描。

这五层分别解决不同问题:
| 层次 | 解决的问题 | 核心能力 |
|---|---|---|
| 数据版本 | 同一主键可能存在多个历史版本 | Unique Key Merge-on-Write |
| 物理读取 | 宽表整行读取需要访问许多列页 | Row Store / row_store_columns |
| 执行路径 | 普通查询计划和 Fragment 调度过重 | Short-Circuit |
| FE 固定成本 | 相同 SQL 反复解析、分析、规划 | PreparedStatement |
| 热点读取 | 热点 Key 仍需重复读 Segment | Row Cache |
任何一层都可能带来收益,完整高并发路径需要查询形态和表结构同时满足条件。
3. Unique Key MoW:点查链路的版本基础
Day 7 已经介绍过 Unique Key。这里需要重新强调一个关键点:高并发主键点查和 Merge-on-Write(MoW) 的关系非常紧密。
在 MoW 下,同一主键的新版本写入时,Doris 会在写入阶段完成版本裁决,并通过 Delete Bitmap 等机制让旧版本不可见。对查询端来说,最新状态已经被物化出来,读取时不需要临时把多个 Rowset 中的版本重新合并。
从 Serving 视角看,这意味着:
请求:order_id = 1008611
↓
确定唯一主键
↓
读取当前可见版本
↓
返回当前订单状态
如果查询本身承担大量版本归并工作,单次请求的成本会增加,尾延迟也更容易受到版本数量和 Compaction 状态影响。因此,官方高并发点查条件把 Unique Key MoW 放在最前面。
一个基础表可以写成:
CREATE TABLE order_serving (
order_id BIGINT NOT NULL,
user_id BIGINT,
status VARCHAR(32),
amount DECIMAL(18, 2),
pay_time DATETIME,
delivery_time DATETIME,
province VARCHAR(32),
city VARCHAR(64),
update_time DATETIME
)
UNIQUE KEY(order_id)
DISTRIBUTED BY HASH(order_id) BUCKETS 16
PROPERTIES (
"enable_unique_key_merge_on_write" = "true",
"light_schema_change" = "true",
"store_row_column" = "true",
"replication_num" = "1"
);
实验环境只有一个 BE 时可以使用单副本。生产环境的副本策略需要按照高可用要求重新设计,不能直接复制这段实验配置。
4. Row Store:列存数据库为什么要额外保存一份整行表示
Doris 的主存储仍然是列式存储。分析查询经常只读少量列,列存可以显著减少扫描字节数,并且对压缩、向量化和聚合非常友好。
问题出现在宽表整行点查。
假设用户画像表有 200 列:
SELECT * FROM user_profile WHERE user_id = 123456;
列存布局下,查询要返回完整一行,需要从许多列文件或列 Page 中取得对应值,然后在执行层重新组成一行。数据量只有一行,随机 I/O 次数和列重组成本依然存在。表越宽,这个问题越明显。
Row Store 的做法是在写入时增加一份紧凑的行格式表示。官方当前实现把整行编码后存进额外隐藏列。查询命中点查路径时,BE 可以直接读取这一份行编码数据。

可以把它理解为同一张表同时拥有两种读取友好的物理表示:
- 大范围分析继续读列存;
- 主键整行点查优先读行格式数据。
4.1 store_row_column
全行存储最直接:
PROPERTIES (
"store_row_column" = "true"
)
适合大量请求都需要完整行的场景,例如订单详情页。
4.2 row_store_columns
从 Doris 3.0 起,可以只把一部分热点列放进行存:
PROPERTIES (
"row_store_columns" = "order_id,status,amount,pay_time,delivery_time"
)
如果 API 常用的只有 5~10 个字段,这种方式通常更合理。它可以降低额外存储量和写入编码成本。
当前官方文档还提供 row_store_page_size。针对点查场景,文档示例会把页面设置得更小,例如 4 KB:
"row_store_page_size" = "4096"
参数是否调整需要通过实际行宽、设备 IOPS 和缓存行为测试。直接把某个示例值当作生产默认值,容易把一个优化点变成新的浪费。
4.3 Row Store 的代价
Row Store 会额外保存数据,因此一定存在成本:
- 存储占用增加;
- 写入时多一次行编码;
- Compaction 处理的数据量增加;
- 宽表全行存储可能明显放大磁盘空间;
- 几乎没有点查请求的表,收益可能覆盖不了成本。
所以建表评审时要问一个很实际的问题:这张表是否真的存在稳定且高频的主键详情读取?
5. Short-Circuit:跳过通用分布式执行链路
Row Store 解决的是物理读取,Short-Circuit 解决的是执行路径。
普通查询会经过逻辑计划、物理计划、Fragment、Instance、调度等步骤。复杂分析需要这些能力,单行主键查询没有必要承担完整路径的所有固定成本。
Doris 在查询形态满足条件时,会识别出这是一个点查,并把请求定位到唯一 Tablet,通过更短的 RPC 路径交给持有数据的 BE。

官方 4.x 文档给出的核心触发条件可以整理成五项:
- 表为 Unique Key;
- Merge-on-Write 开启;
- Row Store 已在建表时开启;
- SQL 是单表查询;
WHERE使用AND连接完整主键列的等值条件。
例如复合主键:
UNIQUE KEY(tenant_id, order_id)
合格查询:
SELECT tenant_id, order_id, status, amount
FROM order_serving
WHERE tenant_id = 10
AND order_id = 1008611;
缺少一列:
WHERE order_id = 1008611
无法完整定位复合主键。
范围查询:
WHERE order_id BETWEEN 1000000 AND 1100000
属于扫描型查询。
加入 Join:
SELECT o.*, u.level
FROM order_serving o
JOIN user_dim u ON o.user_id = u.user_id
WHERE o.order_id = ?;
这类 SQL 需要正常优化和执行。

5.1 如何确认真的走了短路路径
不要凭延迟猜测,直接看执行计划:
EXPLAIN
SELECT *
FROM order_serving
WHERE order_id = 1008611;
命中后,官方文档示例中的 Scan Node 会出现:
SHORT-CIRCUIT
同时应观察:
- partitions 是否被裁剪到极少范围;
- tablets 是否定位到一个目标 Tablet;
- SQL 是否包含完整主键等值条件;
- 是否意外出现 Join、Aggregation、Subquery 或 Range Predicate。
这一条检查应该写进自动化回归测试。表结构或 SQL 模板发生变化后,只要 SHORT-CIRCUIT 消失,高并发链路就可能出现明显退化。
6. PreparedStatement:高 QPS 下 FE CPU 为什么会成为瓶颈
很多应用已经在使用 JDBC PreparedStatement,但客户端参数化 SQL 和服务端 Prepared Statement 需要区分。
如果驱动只在客户端做字符串替换,服务器仍然可能每次收到一条完整 SQL,并重新执行解析和计划流程。高频点查里,SQL 结构几乎完全一样:
SELECT * FROM order_serving WHERE order_id = ?;
变化的只有 order_id。
服务端 PreparedStatement 的目标就是把固定结构缓存下来。第一次执行完成 Prepare,后续请求只传 Statement ID 和参数值。

JDBC 常用配置:
jdbc:mysql://fe1:9030/demo
?useServerPrepStmts=true
&cachePrepStmts=true
&prepStmtCacheSize=500
&prepStmtCacheSqlLimit=2048
Java 示例:
String sql = "SELECT order_id, user_id, status, amount " +
"FROM order_serving WHERE order_id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, 1008611L);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
// consume result
}
}
}
官方 4.x 文档描述了 COM_STMT_PREPARE 与 COM_STMT_EXECUTE 的二进制协议流程。FE 会在连接会话中缓存 Prepared Statement 上下文;如果 SQL 同时符合 Short-Circuit 条件,还会缓存相关短路上下文。
6.1 PreparedStatement 的工程细节
生产环境需要关注几个问题:
第一,连接池决定缓存复用率。 Prepared Statement 上下文是会话级的。连接频繁创建和销毁,会让 Prepare 成本不断重新出现。
第二,Statement 模板数量要受控。 如果应用动态拼接出大量近似 SQL,服务端缓存会被无意义模板占满。字段列表、排序、可选条件需要有明确模板治理。
第三,审计日志可能成为高 QPS 成本。 官方文档说明 Prepared Statement 审计日志默认关闭相关详细重建行为,高并发下应评估日志量和磁盘写入压力。
第四,参数化不能改变点查条件。 SQL 虽然用了 ?,如果它是范围条件、非 Key 条件或复杂表达式,Prepared Statement 只能减少解析规划开销,无法自动获得 Short-Circuit 的读取收益。
验证方式建议同时看两项:
EXPLAIN中出现SHORT-CIRCUIT;- 重复请求的 FE audit log 能观察到
EXECUTE(...)形式。
7. Row Cache:热点主键怎样继续缩短读取链路
即使已经走 Short-Circuit,BE 仍然可能需要从 Segment 中读取 Row Store 数据。对于高频热点 Key,重复磁盘或文件缓存访问仍有成本。
Doris 因此提供独立 Row Cache,用 LRU 类机制缓存整行。

Page Cache 与 Row Cache 的关键区别是缓存对象:
| 能力 | 缓存对象 | 更擅长的请求 |
|---|---|---|
| Page Cache | 列式 Page | 扫描、分析、列读取 |
| Row Cache | 一整行 | 热点主键详情查询 |
大型分析 SQL 会持续访问大量 Page,Page Cache 中的热点内容可能被挤出。独立 Row Cache 可以让点查工作集拥有相对独立的缓存空间。
官方当前高并发点查文档将 Row Cache 标为可选能力,并给出 BE 配置项 disable_storage_row_cache=false。实际部署时应先确认目标版本的默认值,再通过命中率和内存压力决定容量策略。
7.1 缓存命中并不能替代正确的表设计
如果查询没有完整主键,Row Cache 无法把扫描查询变成 KV 查询。
如果热点工作集超过内存,命中率会下降。
如果访问模式接近全随机,Row Cache 的价值也会减弱。
因此,缓存评估需要统计真实请求的 Key 分布:
- Top 1% Key 承担多少流量?
- Top 10% Key 承担多少流量?
- 热点是否随时间快速切换?
- 工作集大小是多少?
- 冷启动持续多长时间?
这些指标比“缓存开多大”更重要。
8. 一次点查请求到底发生了什么
把前面的能力串起来,一次理想点查可以理解成下面这条链:
Application
│ PreparedStatement.execute(order_id)
▼
FE
│ 复用 PreparedStatementContext
│ 识别 Short-Circuit Query
│ 根据完整 Key → Partition / Bucket / Tablet
▼
Target BE
│ Point Query Executor
├─ Row Cache HIT → 直接返回
│
└─ MISS → 读取 Row Store → 写入 Row Cache → 返回
这一链路的设计目标很具体:减少 FE 规划、减少分布式调度、减少网络跳数、减少宽表多列随机读取、减少热点 Key 的重复存储读取。
这也解释了为什么点查优化需要多个组件共同工作。只打开 Row Store,SQL 仍可能走普通计划;只使用 PreparedStatement,BE 仍可能做普通扫描;只看缓存,第一次请求和冷数据依然需要真实读取。
9. 从点查进入缓存体系:需要先分清“缓存什么”
你的原培训材料把 SQL Cache、Partition Cache、Page Cache、File Cache 画成层级结构,这种方式很适合建立直觉。当前 Doris 4.x 的官方文档已经进一步明确了几类缓存的定位,课程里需要按新口径重新整理。

9.1 SQL Cache:缓存完整 SQL 的最终结果
SQL Cache 面向重复执行的相同查询。命中以后,可以跳过完整计算过程。
适合:
- T+1 报表;
- 基础数据更新频率低;
- SQL 文本高度稳定;
- 外部表也希望缓存最终结果。
它对 SQL 文本、表/分区版本、会话环境等较敏感。实时写入特别频繁的表,最终结果很快失效,命中率通常不会理想。
9.2 Query Cache:Tablet 粒度复用聚合结果
当前 4.x Query Cache 是 BE 侧 Pipeline 级能力,主要针对内部 OLAP 表上的 aggregation-scan 模式。它按 Tablet 和执行计划摘要复用聚合中间结果。

一个按天分区的报表:
SELECT region, SUM(amount), COUNT(*)
FROM orders
WHERE dt >= '2026-08-01'
AND dt < '2026-08-18'
AND status = 'PAID'
GROUP BY region;
昨天以前的 Tablet 没有变化,缓存可以被复用;今天的数据持续写入,对应 Tablet Version 变化,需要重新计算。这样可以避免每次都重扫全部历史数据。
当前官方文档的启用方式:
SET enable_query_cache = true;
BE 侧 query_cache_size 默认文档值为 512 MB,单条缓存还有最大字节数和最大行数限制。生产配置应结合 BE 内存预算调整。
9.3 Condition Cache:复用 WHERE 条件的过滤结果
Condition Cache 关注的是谓词计算。
例如一个 Dashboard 有五个图:
WHERE region = 'EAST'
AND order_date >= '2026-08-01'
五个图的 SELECT、GROUP BY、聚合函数不同,但 WHERE 条件相同。Condition Cache 可以在 Segment 粒度缓存这组条件的过滤位图,后续查询直接跳过已经确认不匹配的 Granule。
这类能力对多面板 Dashboard 很有价值,因为不同图表常常共享一套筛选器。
9.4 Page Cache / Row Cache / File Cache
这几种缓存更接近数据读取层:
- Page Cache:内部列式页;
- Row Cache:高并发点查整行;
- File Cache:存算分离或数据湖场景,把对象存储/HDFS 文件块缓存到本地磁盘。
所以“缓存”这个词在 Doris 中可能指完全不同的对象。排障时一定要说清楚:缓存的是 SQL 结果、Tablet 聚合结果、WHERE 位图、列 Page、整行,还是远端文件块。
