Apache Doris 查询指南:从写对到写快

Doris 兼容 MySQL 协议,大多数 MySQL SQL 语法可以直接用。但 Doris 是 OLAP 数据库,和 MySQL 的执行模型完全不同——同一个查询,按 OLTP 的思路写和按 OLAP 的思路写,性能可能差 100 倍以上。

这篇文章从连接方式开始,逐步讲清楚 Doris 查询里最重要的几个概念:分区裁剪、聚合模型、去重函数选择、窗口函数、Join 优化,以及如何用 EXPLAIN 读懂查询计划。

一、连接方式

Doris 的 FE(Frontend)节点对外提供 MySQL 协议,默认端口 9030:

-- MySQL 客户端直接连
mysql -h fe_host -P 9030 -u root -p

-- 查看所有数据库
SHOW DATABASES;

-- 切换数据库
USE your_database;

-- 查看表结构
DESCRIBE table_name;
-- 或
SHOW CREATE TABLE table_name;

JDBC 连接串(Java 程序里):

jdbc:mysql://fe_host:9030/database?useUnicode=true&characterEncoding=utf8

Doris 还提供 HTTP API,用于流式导入或查询元数据,但日常 SQL 查询还是走 MySQL 协议更方便。

二、基础查询语法

基本 SELECT 语法和 MySQL 一致:

-- 基础查询
SELECT user_id, order_amount, create_time
FROM orders
WHERE dt = '2024-01-15'
  AND order_status = 1
ORDER BY create_time DESC
LIMIT 100;

-- 别名
SELECT
    user_id                          AS uid,
    sum(order_amount)                AS total_amount,
    count(*)                         AS order_cnt,
    count(distinct user_id)          AS uv
FROM orders
WHERE dt = '2024-01-15'
GROUP BY user_id
HAVING total_amount > 1000;

Doris 支持 CTE(WITH 子句),复杂查询里推荐用 CTE 提升可读性:

WITH daily_stats AS (
    SELECT
        dt,
        sum(gmv)            AS total_gmv,
        count(*)            AS order_cnt
    FROM dws_order
    WHERE dt BETWEEN '2024-01-01' AND '2024-01-31'
    GROUP BY dt
),
ranked AS (
    SELECT
        dt,
        total_gmv,
        order_cnt,
        rank() OVER (ORDER BY total_gmv DESC) AS gmv_rank
    FROM daily_stats
)
SELECT * FROM ranked WHERE gmv_rank <= 10;

三、分区裁剪:查询快慢的核心

Doris 中大多数表按时间分区(PARTITION BY RANGE),分区裁剪是性能差距最大的地方。查询时必须带上分区列的过滤条件,否则 Doris 会扫描所有分区,性能直接崩。

-- 好:明确指定分区列范围,只扫描 1 月的数据
SELECT sum(gmv)
FROM dws_order
WHERE dt BETWEEN '2024-01-01' AND '2024-01-31';

-- 坏:没有分区过滤,全表扫描
SELECT sum(gmv)
FROM dws_order
WHERE order_status = 1;

-- 查看表的分区信息
SHOW PARTITIONS FROM dws_order;

分区裁剪是否生效,可以用 EXPLAIN 确认(后面会详细讲)。裁剪生效时,EXPLAIN 里会显示 partitions=1/365 这类信息,说明只扫描了 1 个分区而不是全部 365 个。

分区列过滤的几个坑:

-- 坏:对分区列做函数运算,裁剪失效
SELECT * FROM orders WHERE date_format(dt, '%Y-%m') = '2024-01';

-- 好:改写为范围条件
SELECT * FROM orders WHERE dt >= '2024-01-01' AND dt < '2024-02-01';

-- 坏:子查询里的分区条件不一定能下推
SELECT * FROM orders
WHERE dt IN (SELECT max(dt) FROM orders);

-- 好:先算出具体值再过滤
SELECT * FROM orders WHERE dt = '2024-01-31';

除了分区裁剪,Doris 还支持按分桶列(BUCKET)的过滤,能进一步减少扫描的 Tablet 数量:

-- 如果表按 user_id 分桶,这个查询只扫描一个 Bucket
SELECT * FROM user_orders
WHERE dt = '2024-01-15' AND user_id = 12345;

四、聚合查询与聚合模型

Doris 有三种数据模型:Duplicate(明细)、Aggregate(聚合)、Unique(唯一)。Aggregate 模型的表在写入时就会做预聚合,查询时无需全量扫描。

标准聚合函数

SELECT
    dt,
    city,
    channel,
    sum(gmv)                        AS total_gmv,
    sum(order_cnt)                  AS total_orders,
    avg(order_amount)               AS avg_amount,
    max(order_amount)               AS max_amount,
    min(order_amount)               AS min_amount,
    count(*)                        AS row_cnt,
    count(DISTINCT user_id)         AS uv     -- 精确去重,数据量大时慢
FROM dws_order_detail
WHERE dt = '2024-01-15'
GROUP BY dt, city, channel
ORDER BY total_gmv DESC;

GROUPING SETS / ROLLUP / CUBE

需要同时计算多个维度组合的汇总时,GROUPING SETS 比写多个 UNION 效率高(数据只扫一遍):

-- 同时计算 (city, channel)、(city) 和总计 三个粒度的聚合
SELECT
    city,
    channel,
    sum(gmv) AS total_gmv
FROM dws_order
WHERE dt = '2024-01-15'
GROUP BY GROUPING SETS (
    (city, channel),
    (city),
    ()
);

-- ROLLUP:自动生成层级聚合(等价于上面从细到粗的维度组合)
SELECT city, channel, sum(gmv)
FROM dws_order
WHERE dt = '2024-01-15'
GROUP BY ROLLUP(city, channel);

五、去重查询:精确 vs 近似

UV(独立访客)计算是 OLAP 系统里最消耗资源的操作之一。Doris 提供了三种方案,性能和精度各有取舍。

方案一:COUNT DISTINCT(精确,慢)

-- 精确,但数据量大时很慢(需要全量去重)
SELECT dt, count(DISTINCT user_id) AS uv
FROM dws_page_view
WHERE dt = '2024-01-15'
GROUP BY dt;

方案二:Bitmap(精确,快)

适合 user_id 是整数类型的场景。表中提前存储 Bitmap 类型的列,查询时用 bitmap_union_count

-- 前提:表里有 user_id_bitmap 列,类型为 BITMAP
-- 建表示例(Aggregate 模型):
-- user_id_bitmap BITMAP BITMAP_UNION

-- 查询:速度比 count distinct 快几倍到几十倍
SELECT
    dt,
    bitmap_union_count(user_id_bitmap) AS uv
FROM ads_uv_daily
WHERE dt = '2024-01-15'
GROUP BY dt;

-- 计算留存:昨天和今天都访问过的用户数
SELECT
    bitmap_and_count(
        today.user_id_bitmap,
        yesterday.user_id_bitmap
    ) AS retain_uv
FROM ads_uv_daily today
JOIN ads_uv_daily yesterday
    ON today.dt = '2024-01-15'
   AND yesterday.dt = '2024-01-14';

-- 从明细数据实时聚合成 Bitmap(用于 DWS 层查询)
SELECT
    dt,
    bitmap_union(to_bitmap(user_id)) AS user_bitmap
FROM dws_page_view
WHERE dt = '2024-01-15'
GROUP BY dt;

方案三:HLL(近似,最快)

误差约 1%,数据量极大时(十亿级别)用这个:

-- 前提:表里有 user_id_hll 列,类型为 HLL
-- 建表示例(Aggregate 模型):
-- user_id_hll HLL HLL_UNION

SELECT
    dt,
    hll_union_agg(user_id_hll) AS approx_uv
FROM ads_uv_hll_daily
WHERE dt = '2024-01-15'
GROUP BY dt;

-- 从明细构建 HLL(动态计算,不需要预建列)
SELECT
    dt,
    hll_union_agg(hll_hash(user_id)) AS approx_uv
FROM dws_page_view
WHERE dt = '2024-01-15'
GROUP BY dt;

-- 或者用内置函数(更简洁)
SELECT
    dt,
    approx_count_distinct(user_id) AS approx_uv
FROM dws_page_view
WHERE dt = '2024-01-15'
GROUP BY dt;

选择策略:

  • UV 需要精确 + 数据量 < 亿级 → count(distinct)
  • UV 需要精确 + 数据量大 + user_id 是整数 → Bitmap
  • UV 允许约 1% 误差 + 数据量极大 → HLL / approx_count_distinct

六、窗口函数

窗口函数是 Doris 查询里非常常用的特性,适合计算排名、累计值、环比/同比等。

排名函数

SELECT
    dt,
    city,
    gmv,
    -- rank: 并列名次会跳号(1,1,3)
    rank()       OVER (PARTITION BY dt ORDER BY gmv DESC) AS rnk,
    -- dense_rank: 并列名次不跳号(1,1,2)
    dense_rank() OVER (PARTITION BY dt ORDER BY gmv DESC) AS dense_rnk,
    -- row_number: 唯一行号,并列时按内部顺序打号
    row_number() OVER (PARTITION BY dt ORDER BY gmv DESC) AS row_num
FROM dws_city_gmv
WHERE dt = '2024-01-15';

-- 取每天 GMV 最高的前 3 个城市
SELECT * FROM (
    SELECT
        dt, city, gmv,
        row_number() OVER (PARTITION BY dt ORDER BY gmv DESC) AS rn
    FROM dws_city_gmv
    WHERE dt BETWEEN '2024-01-01' AND '2024-01-31'
) t
WHERE rn <= 3;

聚合窗口(累计、移动平均)

SELECT
    dt,
    daily_gmv,
    -- 累计求和(从第一行到当前行)
    sum(daily_gmv) OVER (ORDER BY dt
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_gmv,
    -- 近 7 天移动平均
    avg(daily_gmv) OVER (ORDER BY dt
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS ma7_gmv
FROM dws_daily_gmv
WHERE dt BETWEEN '2024-01-01' AND '2024-01-31'
ORDER BY dt;

LAG / LEAD(取前后行的值,计算环比)

SELECT
    dt,
    daily_gmv,
    -- 取前一天的 GMV
    lag(daily_gmv, 1, 0) OVER (ORDER BY dt) AS prev_day_gmv,
    -- 取前 7 天的 GMV(计算周同比)
    lag(daily_gmv, 7, 0) OVER (ORDER BY dt) AS prev_week_gmv,
    -- 环比增长率
    round(
        (daily_gmv - lag(daily_gmv, 1, 0) OVER (ORDER BY dt))
        / nullif(lag(daily_gmv, 1, 0) OVER (ORDER BY dt), 0) * 100,
        2
    ) AS wow_rate
FROM dws_daily_gmv
WHERE dt BETWEEN '2024-01-01' AND '2024-01-31'
ORDER BY dt;

七、Join 查询与性能优化

Doris 支持多种 Join 类型,但 Join 在分布式系统里是代价最高的操作,写法影响很大。

基本 Join 语法

-- INNER JOIN
SELECT o.order_id, o.gmv, u.city
FROM dws_order o
INNER JOIN dim_user u ON o.user_id = u.user_id
WHERE o.dt = '2024-01-15';

-- LEFT JOIN(保留左表所有行)
SELECT o.order_id, o.gmv, coalesce(u.city, '未知') AS city
FROM dws_order o
LEFT JOIN dim_user u ON o.user_id = u.user_id
WHERE o.dt = '2024-01-15';

Broadcast Join vs Shuffle Join

Doris 会自动选择 Join 策略,但了解这两种策略有助于分析慢查询:

  • Broadcast Join:小表广播到所有节点,大表不移动。适合一大一小的 Join(小表 < 几百 MB)
  • Shuffle Join(Hash Join):两张表都按 Join 键哈希重新分布,适合两张大表的 Join,但网络开销大
-- 强制指定 Broadcast Join(当 Doris 选错时手动干预)
SELECT /*+ SET_VAR(broadcast_row_count_limit = 100000000) */
    o.order_id, d.product_name
FROM dws_order o
JOIN [broadcast] dim_product d ON o.product_id = d.product_id
WHERE o.dt = '2024-01-15';

Join 的几个注意事项

-- 避免:在 Join 条件里对列做函数运算(会退化为笛卡尔积或全表扫描)
-- 坏
SELECT * FROM a JOIN b ON cast(a.user_id AS varchar) = b.user_id;

-- 好:提前做类型转换,或建表时统一类型
SELECT * FROM a JOIN b ON a.user_id = b.user_id;

-- 避免:没有 Join 条件的隐式笛卡尔积(会产生巨大的中间结果集)
-- 坏
SELECT * FROM a, b WHERE a.dt = b.dt;  -- 如果 dt 不是 Join 键,会爆

-- 好:明确写 JOIN ... ON
SELECT * FROM a JOIN b ON a.user_id = b.user_id WHERE a.dt = '2024-01-15';

八、EXPLAIN:读懂查询计划

遇到慢查询,第一步是用 EXPLAIN 看查询计划:

EXPLAIN SELECT sum(gmv) FROM dws_order WHERE dt = '2024-01-15';

重点看这几项:

  • partitions=1/365:分区裁剪是否生效,这里表示只扫了 365 个分区中的 1 个
  • cardinality:预估的行数,用于优化器决策
  • HASH JOIN / BROADCAST:使用了哪种 Join 策略
  • PREAGGREGATION: ON/OFF:预聚合是否命中(Aggregate 模型表)
-- 更详细的执行计划(包含物理执行信息)
EXPLAIN VERBOSE SELECT sum(gmv) FROM dws_order WHERE dt = '2024-01-15';

-- 查询实际执行统计(需要先执行查询)
-- 在 Doris 管理页面的 Query Profile 里查看,或通过 SQL 查询 FE 日志

如果 PREAGGREGATION: OFF,说明查询没有命中预聚合,性能会下降。常见原因是 SELECT 列表里包含了非 Key 列的非聚合函数,或者 WHERE 条件对 Value 列进行了过滤。

九、物化视图加速查询

Doris 支持物化视图(Materialized View),把常用的聚合结果预先计算好存储起来,查询时自动命中,无需修改 SQL:

-- 创建物化视图(按 dt + city 预聚合)
CREATE MATERIALIZED VIEW mv_order_city_daily AS
SELECT
    dt,
    city,
    sum(gmv)         AS total_gmv,
    count(*)         AS order_cnt,
    bitmap_union(to_bitmap(user_id)) AS user_bitmap
FROM dws_order_detail
GROUP BY dt, city;

-- 查询自动命中物化视图(不需要修改 SQL)
SELECT dt, city, sum(gmv)
FROM dws_order_detail
WHERE dt = '2024-01-15'
GROUP BY dt, city;

-- 确认是否命中物化视图(EXPLAIN 里看 TABLE 是否变成了 mv_order_city_daily)
EXPLAIN SELECT dt, city, sum(gmv)
FROM dws_order_detail
WHERE dt = '2024-01-15'
GROUP BY dt, city;

物化视图命中需要满足几个条件:查询的聚合函数和物化视图里定义的一致、GROUP BY 的维度是物化视图维度的子集、没有对 Value 列的过滤条件。

十、常用函数速查

日期函数

-- 当前日期/时间
SELECT curdate(), now(), current_timestamp();

-- 日期格式化
SELECT date_format(create_time, '%Y-%m-%d') AS dt;

-- 日期加减
SELECT date_add(curdate(), INTERVAL -7 DAY);   -- 7天前
SELECT date_sub(curdate(), INTERVAL 1 MONTH);  -- 1个月前

-- 提取日期部分
SELECT year(dt), month(dt), day(dt), weekday(dt);

-- 日期差(天数)
SELECT datediff('2024-01-31', '2024-01-01');   -- 返回 30

字符串函数

SELECT
    concat('hello', ' ', 'world'),       -- 字符串拼接
    concat_ws('-', '2024', '01', '15'),   -- 用分隔符拼接
    substring(phone, 1, 3),              -- 子字符串
    left(phone, 3),                      -- 左取
    length(name),                        -- 字节长度
    char_length(name),                   -- 字符长度(中文友好)
    upper(name), lower(name),            -- 大小写
    trim(name), ltrim(name), rtrim(name),-- 去空格
    replace(str, 'old', 'new'),          -- 替换
    like '%关键词%',                     -- 模糊匹配
    regexp_extract(str, '\\d+', 0);      -- 正则提取

条件函数

SELECT
    if(score >= 60, '及格', '不及格'),
    ifnull(city, '未知'),
    nullif(amount, 0),   -- 如果 amount=0 返回 NULL(防止除零)
    coalesce(city, province, country, '未知'),  -- 返回第一个非 NULL 值
    case status
        when 1 then '待支付'
        when 2 then '已支付'
        when 3 then '已取消'
        else '未知'
    end AS status_name,
    case
        when amount >= 1000 then '大额'
        when amount >= 100  then '中额'
        else '小额'
    end AS amount_level;

十一、总结

Doris 查询性能的关键优先级:

  1. 分区裁剪:每个查询必须带分区列过滤,是最重要的优化,不容妥协
  2. 去重函数选择:大数据量的 UV 统计用 Bitmap 或 HLL,不要无脑 count(distinct)
  3. 物化视图:高频查询预先建物化视图,查询自动命中,改造成本为零
  4. EXPLAIN 先行:遇到慢查询先看执行计划,确认分区裁剪和预聚合是否生效
  5. JOIN 小心:大表 Join 大表前先过滤,尽量让一侧数据量小到可以 Broadcast