第 19 关 · ★★★

高并发 Serving

Prepared Statement、Point Query 与多级缓存:把点查 QPS 推上数万。

已点亮 · 最佳 分
高并发 Serving 第 1 页

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;实验前请在目标版本再次核对参数和语法。

Day 13 学习总览
Day 13 学习总览

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 等能力解决“已经计算过的东西能否直接复用”。两类技术面向的请求不同,混在一起理解容易产生错误设计。

学完今天,你应该能够回答六个问题:

  1. 什么样的 SQL 才能触发 Doris 的 Short-Circuit 点查路径?
  2. Row Store 为什么能降低宽表整行读取的 IOPS?
  3. PreparedStatement 到底省掉了哪些工作?
  4. Row Cache 与 Page Cache 有什么粒度差异?
  5. SQL Cache、Query Cache、Condition Cache 分别适合什么场景?
  6. 如何设计一套能进入生产评审的高并发点查 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 文档给出的核心触发条件可以整理成五项:

  1. 表为 Unique Key;
  2. Merge-on-Write 开启;
  3. Row Store 已在建表时开启;
  4. SQL 是单表查询;
  5. 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 需要正常优化和执行。

Short-Circuit 触发条件
Short-Circuit 触发条件

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 和参数值。

PreparedStatement 生命周期
PreparedStatement 生命周期

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_PREPARECOM_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 的读取收益。

验证方式建议同时看两项:

  1. EXPLAIN 中出现 SHORT-CIRCUIT
  2. 重复请求的 FE audit log 能观察到 EXECUTE(...) 形式。

7. Row Cache:热点主键怎样继续缩短读取链路

即使已经走 Short-Circuit,BE 仍然可能需要从 Segment 中读取 Row Store 数据。对于高频热点 Key,重复磁盘或文件缓存访问仍有成本。

Doris 因此提供独立 Row Cache,用 LRU 类机制缓存整行。

Row Cache 与 Page Cache
Row Cache 与 Page Cache

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 的官方文档已经进一步明确了几类缓存的定位,课程里需要按新口径重新整理。

Doris 4.x 缓存地图
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 和执行计划摘要复用聚合中间结果。

Query Cache
Query Cache

一个按天分区的报表:

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、整行,还是远端文件块。


登录后可阅读本文完整内容。