笔记
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 的加速实践
前言:一个让我半夜惊醒的线上问题
先交代一下背景。我在快手做数据平台相关的工作,日常就是跟各种分析任务打交道。去年有段时间,我们团队负责的一个 AB 实验分析场景越来越慢,慢到什么程度呢?一个查询跑下来要 20 多秒,用户点一下按钮,得去喝杯水回来才能看到结果。更可怕的是,随着实验数量和数据量持续增长,这个延迟还在肉眼可见地恶化。
那段时间我几乎每天都要被业务同学追着问:“这个报表为什么又卡了?”“能不能优化一下?”说实话,最开始我们试过各种常规手段:加机器、调参数、改 SQL……效果都有限。后来实在没办法了,我们决定动真格的——把整个技术栈从 Spark 换成了 Apache Doris。结果你猜怎么着?同样的查询,从 20 多秒降到了 0.15 秒,提速 145 倍。
这不是标题党,是实打实的生产数据。下面我把整个优化思路、技术细节、踩过的坑,还有最终的效果,掰开了揉碎了跟大家聊聊。
背景:为什么 AB 实验分析这么难?
先说说 AB 实验分析这个场景到底特殊在哪。
快手这样的产品,每天有成千上万个实验在跑。每个实验都会把用户分成几组(对照组、实验组),然后对比不同组在核心指标(比如点击率、停留时长、转化率)上的差异。分析师需要快速查看每个实验的指标变化,判断实验效果是否显著。
这个场景对查询的要求非常苛刻:
- 高基数维度:实验 ID、用户 ID、指标名称都是高基数维度。一个实验可能有几百万用户,指标可能有几百个。
- 多维聚合:需要按实验、版本、日期、指标等维度做 group by,还要算均值、方差、置信区间。
- 实时性要求:分析师希望秒级出结果,不然思路会被打断。
- 数据量大:快手日活用户数亿,实验数据量级轻松到 PB 级别。
用一句话总结:这是一个典型的高基数、高并发、多维聚合分析场景,传统的 OLAP 引擎很难同时满足所有需求。
原方案:Spark + 预计算,为什么行不通?
我们最早用的是 Spark SQL 方案。数据从 Hive 落地后,用 Spark 做预聚合,生成宽表,然后供分析师查询。流程大概是这样的:
- 原始数据(用户行为日志)写入 Hive 分区表
- 每天凌晨跑 Spark 任务,按实验 ID、版本、日期、指标等维度做预聚合
- 聚合结果写入到一张大宽表(Parquet 格式)
- 分析师通过 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 的几个特性正好切中我们的痛点:
- 支持高并发点查询:Doris 的 MPP 架构 + 向量化执行引擎,单机就能支撑几百 QPS 的简单查询。
- 物化视图 + 自动路由:Doris 支持创建物化视图,查询时自动路由到最优的物化视图,不需要手动改写 SQL。这意味着我们不用再手动维护多份预聚合表。
- 精确去重:Doris 内置 Bitmap 和 HLL 类型,支持精确去重和近似去重,满足 AB 实验的精确统计需求。
- 在线 Schema Change:Doris 支持在线修改表结构,加列、改类型都不影响查询。业务方要加新维度,我们随时可以加。
- 多表 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 上有两个问题:
COUNT(DISTINCT user_id)在高基数场景下性能差,Doris 对精确去重的优化不如 Bitmap。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 是字符串怎么办?我们做了两步:
- 用一个全局字典表,将字符串用户 ID 映射为整数 ID。
- 在数据摄入时,先查字典表,再把整数 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 倍。
除了性能提升,还有几个意外收获:
- 运维成本降低:之前维护 Spark 任务 + Presto 集群,需要 2 个人专职负责。现在 Doris 集群几乎不需要日常维护,一个人兼职就够了。
- 业务迭代加速:业务方想加新指标或新维度,直接在 Doris 上加列,几分钟就能生效。之前要等 Spark 任务重跑,至少半天。
- 查询更灵活:分析师可以直接写 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 群,响应速度挺快的。我们团队也在持续贡献代码,欢迎一起交流。