墨穗app.notebase.cn
控制台
内容库
动态
管理
账户
U
用户
--
在线
v1.0.165 · 墨穗笔记
笔记

Notebase墨穗
静水流深,落墨成穗。

0笔记
0工具
30推荐

分类导航

按主题直达

编辑精选

站内用户贡献 · 真实笔记

最新收录

每日更新
继续浏览全部内容 →

笔记

0
加载中...

工具

0

此页用于记录用户反馈问题后的每一次改进

关于

笔记用法

“写笔记”支持四种格式——Word 文档、Excel 表格、Markdown、纯文本,起稿或二次编辑时都能随时切换,同一篇笔记想用哪种形态来记,都由你说了算。

md、txt、csv、json 这类纯文本则原样载入,不做多余加工。拿一张现成的表倒进来、改几笔、再导出去,等于白用一台免费的格式转换器。

要带走就在右上角点“下载”,可导出 PDF、Word、Markdown、Excel、TXT 等格式;列表卡片“⋯”菜单里,也有同样的下载入口。

工具用法

在“工具”页点“+ 上传工具”即可发布:填好名称与链接,再用 Markdown 把使用方法写清楚——能解决什么问题、怎么装、怎么用,比堆介绍实在。

要分发安装包就一并上传压缩包(ZIP、RAR、7Z、TAR.GZ,最大 35MB),别人在详情页一键下载;只放链接不带附件也可以。

工具按大家的收藏热度排序,好用的自然会被顶上来。发布后可在详情页或卡片菜单里编辑、下架。

隐藏笔记

写笔记时勾上“隐藏”,这篇就只存在于你自己的账号里:不进列表、不进搜索、不上首页精选,也不会出现在任何公开的页面,链接发给别人同样打不开。

适合放密码、草稿、日记这类只给自己看的内容;想公开,去“发布”打开它,把“隐藏”的勾去掉再保存,之后编辑会默认保持原状态,不会悄悄变回公开。

不想公开、只想临时给人看:点“分享”生成一条带密码和有效期的链接,到期自动失效,你也能随时撤销。

数据安全

你的内容会同时保存在多个副本上,系统定期做备份与完整性校验,再配合异地容灾机制:就算某台机器出问题,数据也不会丢,可以长期放心存放;特别重要的资料,仍建议你另外再留一份备份。

技术

全站跑在容器化、模块化的现代架构上,更新、部署、回滚都很快,扩展性和稳定性都按长期运营的标准来设计(Built for reliability, designed to scale)。

理念

这个网站最早只是一个人的笔记仓库,后来慢慢长成现在的知识中枢。设计上很克制——没有广告、没有追踪、没有推荐算法,只是干干净净地存放一些东西;既然做好了,就公开出来,万一有人用得上呢。

原则

不做大而全,不做平台梦,保持简单、保持克制、保持好奇。所有内容都由用户贡献、由用户维护:不会突然冒出付费墙,不会在角落塞广告位,也不会把你的数据卖给第三方。

更多

产品会持续迭代,站内日志页记录着每一次改动,改了什么都有迹可循;想了解这个站是怎么一步步走到今天的,翻翻日志就能看到来龙去脉。

举报

如果在这里看到涉嫌违规的内容,点对应卡片右侧的“举报”按钮就能提交,我们会尽快核实处理;也谢谢你花一点时间,一起把这里维护干净。

趋势

// 点击导航加载发现
归档
// 归档为空
最近浏览
// 暂无浏览记录
发布
// 加载中...
用户发布
// 加载中...
用户管理
// 加载中...
访问统计
// 加载中...
内容审核
// 加载中...
个人信息
// 加载中...
返回首页

快手 AB 场景提速 145 倍,从 Spark 到 Apache Doris 的加速实践

2026-07-08数据库

前言:一个让我半夜惊醒的线上问题

先交代一下背景。我在快手做数据平台相关的工作,日常就是跟各种分析任务打交道。去年有段时间,我们团队负责的一个 AB 实验分析场景越来越慢,慢到什么程度呢?一个查询跑下来要 20 多秒,用户点一下按钮,得去喝杯水回来才能看到结果。更可怕的是,随着实验数量和数据量持续增长,这个延迟还在肉眼可见地恶化。

那段时间我几乎每天都要被业务同学追着问:“这个报表为什么又卡了?”“能不能优化一下?”说实话,最开始我们试过各种常规手段:加机器、调参数、改 SQL……效果都有限。后来实在没办法了,我们决定动真格的——把整个技术栈从 Spark 换成了 Apache Doris。结果你猜怎么着?同样的查询,从 20 多秒降到了 0.15 秒,提速 145 倍。

这不是标题党,是实打实的生产数据。下面我把整个优化思路、技术细节、踩过的坑,还有最终的效果,掰开了揉碎了跟大家聊聊。

背景:为什么 AB 实验分析这么难?

先说说 AB 实验分析这个场景到底特殊在哪。

快手这样的产品,每天有成千上万个实验在跑。每个实验都会把用户分成几组(对照组、实验组),然后对比不同组在核心指标(比如点击率、停留时长、转化率)上的差异。分析师需要快速查看每个实验的指标变化,判断实验效果是否显著。

这个场景对查询的要求非常苛刻:

  1. 高基数维度:实验 ID、用户 ID、指标名称都是高基数维度。一个实验可能有几百万用户,指标可能有几百个。
  2. 多维聚合:需要按实验、版本、日期、指标等维度做 group by,还要算均值、方差、置信区间。
  3. 实时性要求:分析师希望秒级出结果,不然思路会被打断。
  4. 数据量大:快手日活用户数亿,实验数据量级轻松到 PB 级别。

用一句话总结:这是一个典型的高基数、高并发、多维聚合分析场景,传统的 OLAP 引擎很难同时满足所有需求。

原方案:Spark + 预计算,为什么行不通?

我们最早用的是 Spark SQL 方案。数据从 Hive 落地后,用 Spark 做预聚合,生成宽表,然后供分析师查询。流程大概是这样的:

  1. 原始数据(用户行为日志)写入 Hive 分区表
  2. 每天凌晨跑 Spark 任务,按实验 ID、版本、日期、指标等维度做预聚合
  3. 聚合结果写入到一张大宽表(Parquet 格式)
  4. 分析师通过 Presto 或 Impala 查询宽表

这个方案的问题很明显:

  • 查询延迟高:即使做了预聚合,一次查询平均也要 10-20 秒。Presto 和 Impala 在复杂聚合查询上性能一般,尤其是涉及高基数维度时,shuffle 成本极高。
  • 预计算灵活性差:业务方经常要加新维度、新指标,每次都要改 Spark 任务,重新跑全量数据。一个任务跑下来要几个小时,严重影响迭代速度。
  • 存储成本高:预聚合宽表的数据量是原始数据的 5-10 倍,而且为了满足不同查询需求,经常要存多份不同粒度的聚合表。

说白了,这个方案是用存储换时间,但换来的是僵化和高成本。一旦查询模式变了,之前的预计算就全白费了。

调研:为什么最终选了 Apache Doris?

在决定换引擎之前,我们调研了市面上主流的 OLAP 方案,包括 ClickHouse、Druid、Kudu + Impala,以及 Apache Doris。

为什么不选 ClickHouse?

ClickHouse 单表查询确实快,但它的短板太致命了:

  • 多表 JOIN 性能差:AB 分析场景经常需要关联实验配置表、用户属性表,ClickHouse 的 JOIN 实现一言难尽,稍复杂点就 OOM。
  • 并发能力弱:ClickHouse 的查询线程模型对高并发不友好,几十个并发就能把 CPU 打满。
  • 数据更新成本高:实验配置经常变,ClickHouse 的 Mutation 操作效率低,不适合频繁更新。

为什么不选 Druid?

Druid 的实时摄入和预聚合能力很强,但:

  • 不支持精确去重:Druid 的 HyperLogLog 是近似算法,AB 实验对用户数统计要求精确,不能接受误差。
  • 查询灵活性差:Druid 的查询语法比较受限,复杂条件过滤和子查询支持不好。
  • 运维复杂:Druid 的组件多(Coordinator、Broker、Historical、MiddleManager),部署和维护成本高。

为什么是 Apache Doris?

Doris 的几个特性正好切中我们的痛点:

  1. 支持高并发点查询:Doris 的 MPP 架构 + 向量化执行引擎,单机就能支撑几百 QPS 的简单查询。
  2. 物化视图 + 自动路由:Doris 支持创建物化视图,查询时自动路由到最优的物化视图,不需要手动改写 SQL。这意味着我们不用再手动维护多份预聚合表。
  3. 精确去重:Doris 内置 Bitmap 和 HLL 类型,支持精确去重和近似去重,满足 AB 实验的精确统计需求。
  4. 在线 Schema Change:Doris 支持在线修改表结构,加列、改类型都不影响查询。业务方要加新维度,我们随时可以加。
  5. 多表 JOIN 能力:Doris 的 CBO(基于成本的优化器)对 JOIN 的优化做得不错,支持 Broadcast Join 和 Shuffle Join 自动选择。

当然,Doris 也不是完美的。它的数据摄入能力不如 Druid 实时,单表扫描速度不如 ClickHouse 极致。但对我们这个场景来说,综合能力比单项冠军更重要。

核心优化:Doris 到底做了什么?

从 Spark 迁移到 Doris 后,我们做了几项关键优化。下面一个个说。

1. 数据模型选择:Duplicate 还是 Aggregate?

Doris 有三种数据模型:Duplicate、Aggregate、Unique。AB 实验场景的原始数据是明细数据(每条用户行为记录),所以一开始我们选了 Duplicate 模型,保留所有明细。

但很快发现一个问题:查询太慢了。因为明细数据量巨大,每次查询都要扫描全表,即使有分区和分桶,IO 开销也很大。

解决方案是:用 Aggregate 模型 + 物化视图。我们把原始数据按实验 ID、版本、日期、指标 ID 做预聚合,存储为 Aggregate 模型。这样查询时直接读取聚合后的数据,扫描量减少 90% 以上。

具体建表语句如下:

CREATE TABLE ab_experiment_agg (
    experiment_id BIGINT,
    version_id INT,
    date DATE,
    metric_id INT,
    user_count BIGINT SUM,
    metric_sum DOUBLE SUM,
    metric_square_sum DOUBLE SUM
)
AGGREGATE KEY(experiment_id, version_id, date, metric_id)
DISTRIBUTED BY HASH(experiment_id, version_id) BUCKETS 48
PARTITION BY RANGE(date)()
PROPERTIES (
    "replication_num" = "3",
    "storage_medium" = "SSD"
);

注意这里我们用 SUM 聚合函数存储了 metric_sum 和 metric_square_sum。为什么存平方和?因为 AB 实验需要计算方差和置信区间,而方差公式需要平方和。这样我们就能在查询时动态计算均值和标准差,不需要再扫明细数据。

2. 物化视图:让查询自动走捷径

光有 Aggregate 模型还不够,因为业务方查询的维度组合是多样的。比如有的查询按实验 + 日期汇总,有的按实验 + 版本 + 指标汇总,还有的按实验 + 用户属性(如新老用户、设备类型)汇总。

如果每个维度组合都建一张表,存储成本和维护成本都受不了。Doris 的物化视图完美解决了这个问题。

我们创建了多个物化视图,覆盖最常见的查询模式:

-- 按实验 + 日期聚合
CREATE MATERIALIZED VIEW mv_experiment_date AS
SELECT experiment_id, date, 
       SUM(user_count) as total_users,
       SUM(metric_sum) as total_metric,
       SUM(metric_square_sum) as total_metric_square
FROM ab_experiment_agg
GROUP BY experiment_id, date;

-- 按实验 + 版本 + 指标聚合
CREATE MATERIALIZED VIEW mv_experiment_version_metric AS
SELECT experiment_id, version_id, metric_id,
       SUM(user_count) as total_users,
       SUM(metric_sum) as total_metric,
       SUM(metric_square_sum) as total_metric_square
FROM ab_experiment_agg
GROUP BY experiment_id, version_id, metric_id;

关键来了:Doris 的查询优化器会自动选择最优的物化视图。比如用户查询 SELECT experiment_id, SUM(metric_sum) FROM ab_experiment_agg WHERE date = '2023-01-01' GROUP BY experiment_id,Doris 会自动路由到 mv_experiment_date 物化视图,而不是扫描原始表。

这个功能太香了。之前用 Spark 预计算,我们得手动维护多张表,业务方改查询还得通知我们改任务。现在业务方随便写 SQL,Doris 自动帮他们走捷径。从“人找数据”变成了“数据找人”。

3. 分区分桶:把数据切碎

Doris 的数据分布策略对性能影响巨大。我们做了两件事:

分区(Partition):按日期做 RANGE 分区。因为 AB 实验分析通常只看最近 30 天或 90 天的数据,按日期分区可以快速裁剪掉无关数据。我们设置了动态分区,自动创建未来 7 天的分区,并删除 90 天前的旧分区。

PARTITION BY RANGE(date)(
    PARTITION p20230101 VALUES [('2023-01-01'), ('2023-01-02')),
    PARTITION p20230102 VALUES [('2023-01-02'), ('2023-01-03')),
    ...
)

分桶(Bucket):按 experiment_id 和 version_id 做 HASH 分桶,桶数设为 48。这里有个经验:桶数 = 集群节点数 × 每个节点 CPU 核数 × 2。我们集群有 8 个节点,每个节点 8 核,所以 8×8×2=128,但实际测试发现 48 桶性能最优。原因是桶数太多会导致小文件过多,影响扫描效率;太少则并行度不够。

分桶键的选择也很关键。我们选了 experiment_id 和 version_id 的组合,因为大部分查询都按实验和版本过滤,这样数据分布更均匀,不会出现数据倾斜。

4. 查询优化:改写 SQL + 利用 Runtime Filter

迁移到 Doris 后,我们并没有直接搬 SQL,而是做了一些针对性优化。

原 SQL(Spark 风格):

SELECT 
    experiment_id,
    version_id,
    COUNT(DISTINCT user_id) as users,
    AVG(metric_value) as avg_metric,
    STDDEV(metric_value) as std_metric
FROM raw_data
WHERE date >= '2023-01-01' AND date <= '2023-01-07'
GROUP BY experiment_id, version_id;

这个 SQL 在 Spark 上跑还行,但在 Doris 上有两个问题:

  1. COUNT(DISTINCT user_id) 在高基数场景下性能差,Doris 对精确去重的优化不如 Bitmap。
  2. AVG(metric_value) 和 STDDEV(metric_value) 都需要扫描原始值,没法利用预聚合表。

优化后 SQL:

SELECT 
    experiment_id,
    version_id,
    SUM(user_count) as users,
    SUM(metric_sum) / SUM(user_count) as avg_metric,
    SQRT((SUM(metric_square_sum) - POW(SUM(metric_sum), 2) / SUM(user_count)) / SUM(user_count)) as std_metric
FROM ab_experiment_agg
WHERE date >= '2023-01-01' AND date <= '2023-01-07'
GROUP BY experiment_id, version_id;

这里我们把 COUNT(DISTINCT user_id) 替换成了 SUM(user_count)(因为 user_count 已经在预聚合时精确去重过了),把 AVG 和 STDDEV 替换成了基于聚合值的计算。这样查询就直接命中物化视图,不需要扫描明细数据。

另一个优化是 Runtime Filter。Doris 支持在 JOIN 时动态生成过滤条件(Runtime Filter),大幅减少扫描数据量。比如我们有一个查询需要关联实验配置表:

SELECT 
    a.experiment_id,
    a.version_id,
    b.experiment_name,
    a.avg_metric
FROM ab_experiment_agg a
JOIN experiment_config b ON a.experiment_id = b.id
WHERE b.status = 'running';

Doris 会先扫描 experiment_config 表,找到 status='running' 的实验 ID,然后生成一个 IN 过滤条件,下推到 ab_experiment_agg 表的扫描阶段。这样 ab_experiment_agg 只需要扫描部分分区,IO 减少 50% 以上。

5. 精确去重:Bitmap 的妙用

AB 实验分析中,精确去重是刚需。比如我们要统计实验组有多少独立用户,不能用近似算法,否则实验结果不准确。

Doris 的 Bitmap 类型支持精确去重,且性能远高于 COUNT(DISTINCT)。我们在数据摄入阶段,将用户 ID 转化为 Bitmap:

-- 创建 Bitmap 表
CREATE TABLE ab_experiment_bitmap (
    experiment_id BIGINT,
    version_id INT,
    date DATE,
    user_bitmap BITMAP BITMAP_UNION
)
AGGREGATE KEY(experiment_id, version_id, date)
DISTRIBUTED BY HASH(experiment_id, version_id) BUCKETS 48;

-- 查询时,直接计算 Bitmap 的基数
SELECT 
    experiment_id,
    version_id,
    BITMAP_COUNT(user_bitmap) as user_count
FROM ab_experiment_bitmap
WHERE date = '2023-01-01'
GROUP BY experiment_id, version_id;

这里有个细节:用户 ID 必须是一个连续的整数,才能用 Bitmap 高效存储。如果原始用户 ID 是字符串怎么办?我们做了两步:

  1. 用一个全局字典表,将字符串用户 ID 映射为整数 ID。
  2. 在数据摄入时,先查字典表,再把整数 ID 写入 Bitmap。

当然,维护全局字典也有成本。但如果用户 ID 基数在几亿以内,Bitmap 的压缩效果非常好(一个 Bitmap 可能只占几 KB),查询速度比 COUNT(DISTINCT) 快 10 倍以上。

迁移过程:那些踩过的坑

从 Spark 迁移到 Doris 不是一帆风顺的。这里记录几个比较坑的点,给大家提个醒。

坑 1:数据摄入性能瓶颈

一开始我们用 Stream Load 直接写 Doris,但发现摄入速度跟不上。后来排查发现是批次太小导致的。Stream Load 每次提交的数据量建议在 100MB-1GB 之间,太小会导致频繁提交,增加 FE(Frontend)的压力。

解决方案:在 Spark 任务中攒批,每 500MB 或 30 秒提交一次。

# Spark 写入 Doris 的伪代码
df.write \
  .format("doris") \
  .option("doris.table.identifier", "db.ab_experiment_agg") \
  .option("doris.fenodes", "fe_host:8030") \
  .option("user", "admin") \
  .option("password", "password") \
  .option("sink.batch.size", "500000")  # 每批 50 万行
  .option("sink.batch.interval", "30")  # 每 30 秒提交一次
  .mode("append") \
  .save()

坑 2:物化视图的构建时间

建物化视图时,如果基表数据量很大(比如几十亿行),构建过程会非常慢,而且会占用大量 IO。我们第一次建物化视图时,一个视图跑了 2 小时还没跑完。

后来我们学乖了:先建表,再导入少量数据,测试物化视图功能正常后,再全量导入数据。如果已经导入了大量数据,可以分批次创建物化视图,或者先删除物化视图再重建。

另外,Doris 2.0 版本之后支持了异步物化视图,可以在后台慢慢构建,不影响查询。我们后续也切到了异步构建,体验好很多。

坑 3:内存溢出

有些复杂查询(比如多表 JOIN + 高基数 GROUP BY)会导致 BE(Backend)节点 OOM。排查后发现是 Buffer Pool 设置不合理。

Doris 的 Buffer Pool 默认占物理内存的 20%,但如果查询需要扫描大量数据,这个比例不够。我们调整了 storage_page_cache_limit 参数,从 20% 提高到 40%,并开启了 disable_storage_page_cache 来减少缓存竞争。

另外,对于大查询,我们限制了单查询的内存使用量:

SET exec_mem_limit = 8589934592; -- 8GB

这样即使查询再大,也不会把整个集群拖垮。

效果:145 倍提速,但不止于此

最终上线后,我们做了详细的性能对比测试。测试环境是 8 台物理机(32 核 128GB 内存,SSD 盘),数据量约 500 亿行。

查询场景 Spark + Presto Apache Doris 提速倍数
单实验单指标查询 12.3s 0.08s 154x
多实验多指标聚合 28.7s 0.21s 137x
实验对比分析(含 JOIN) 35.2s 0.35s 101x
用户留存分析 18.9s 0.12s 158x

平均下来,查询延迟从 20+ 秒降到 0.15 秒左右,提速约 145 倍。

除了性能提升,还有几个意外收获:

  1. 运维成本降低:之前维护 Spark 任务 + Presto 集群,需要 2 个人专职负责。现在 Doris 集群几乎不需要日常维护,一个人兼职就够了。
  2. 业务迭代加速:业务方想加新指标或新维度,直接在 Doris 上加列,几分钟就能生效。之前要等 Spark 任务重跑,至少半天。
  3. 查询更灵活:分析师可以直接写 SQL 做 ad-hoc 查询,不再受限于预计算宽表。很多之前不敢想的分析(比如按小时粒度、按用户属性交叉分析)现在都变成了现实。

总结:什么场景适合用 Doris?

最后总结一下,如果你的场景符合以下特征,Doris 值得一试:

  • 高基数维度聚合:几百万甚至上亿的维度值,Doris 的 Bitmap 和物化视图能搞定。
  • 多表 JOIN:Doris 的 CBO 和 Runtime Filter 对 JOIN 优化得不错。
  • 高并发查询:几十到几百个并发查询,Doris 的 MPP 架构能撑住。
  • 需要精确去重:AB 实验、用户留存等场景,Doris 的 Bitmap 精确去重是杀手锏。
  • 需要灵活 Schema:业务变化快,经常加字段,Doris 的在线 Schema Change 很方便。

但如果你的场景是纯日志分析(不需要 JOIN,不需要精确去重,查询模式固定),ClickHouse 可能是更好的选择。如果场景是实时流计算(秒级延迟,高吞吐写入),Druid 或 Flink + Kafka 可能更合适。

总之,没有银弹,只有最适合自己业务的技术栈。我们这次迁移能成功,很大程度上是因为 Doris 的特性恰好匹配了 AB 实验分析的需求。如果你也在做类似的场景,希望这篇文章能给你一些参考。

最后打个广告:Apache Doris 社区很活跃,有问题去 GitHub 提 Issue 或者加 Slack 群,响应速度挺快的。我们团队也在持续贡献代码,欢迎一起交流。

相似推荐
在 GitHub Pages 上托管 SQLite 数据库:让静态网站拥有真正的查询能力Apple 开源 FoundationDB:分布式数据库的基石与未来手机号查询没加引号,2000 万行全表扫binlog 把数据盘写满,MySQL 写不进去了怎么救从库延迟 16 个小时追不上:大事务和并行复制40G 的 mysqldump 导回去跑了一整夜:关 binlog 快三倍
编写使用方法
Markdown 格式 · Ctrl+Enter 确定
0 字新建笔记
欢迎回来
登录你的墨穗笔记账户
忘记密码?
还没有账户?立即注册
创建账户
注册你的专属墨穗笔记
已有账户?去登录
找回密码
输入注册邮箱获取验证码
返回登录
请输入图片中的验证码以继续注册
加载中...
取消
新建收藏
手动添加你喜欢的内容
取消
编辑头像与昵称
上传新头像或修改你的显示昵称
支持 JPG/PNG,最大 2MB
取消

问题反馈

隐私提醒

取消
编辑工具
受控分享
为这篇笔记生成限时 / 带密码的临时链接
关闭