基于Apache Doris的RTB实时竞价分析系统:建模与调优实践
RTB实时竞价广告的分析系统不是简单“拉个报表”的事。一次广告展示从发起竞价到竞胜出价中间会产生至少四五类事件日志每一类的时间口径、字段语义、更新逻辑都不一样而业务方关注的核心问题却高度集中钱花到了哪里花得值不值。数据量按天是几十亿条级别同时还对实时性极其敏感——广告主盯着的投放计划超过五分钟消耗没更新电话就来了。我之前在一家广告平台这边基于 Apache Doris 搭建了实时的竞价分析系统。Kafka 里的竞价日志进库后媒体、投放计划、创意、地域、设备、时段这些维度随时可以切换核心看板查询响应控制在秒级。上线之后原先依赖离线报表的链路被压缩了一大半运营和算法团队也第一次能做到按小时观察竞胜率变化。这篇文章把整个设计到落地的过程拆开讲数据链路怎么搭、Doris 表结构怎么建、实时导入参数怎么配、查询调优踩过哪些坑。适合正在调研实时分析数据库、或者在广告/流量分析场景里打算引入 Doris 的团队参考。1. 实时竞价分析到底在分析什么1.1 一条竞价日志的完整旅程想搞清楚 Doris 在这里面扮演什么角色先得把 RTB 的数据链路理顺。用户在 App 或网页里触发一次广告请求媒体端的广告交易平台会发出竞价请求把这个流量包分发给多个需求方。需求方拿到请求后基于自己的数据和策略决定是否出价、出多少价把出价结果返回。全部需求方返回之后交易平台选出最高价者竞胜的一方拿到这次展示机会随后会产生展示记录如果用户对广告产生点击又会多一条点击事件。整个决策过程要在几十毫秒内完成但决策结束后这些事件日志会源源不断地写入 Kafka进入后续的分析系统。这里先纠正一个常见误区实时竞价分析的“实时”不等于要介入毫秒级决策。竞价决策由线上服务自己做分析系统处理的是竞价发生后的事后数据延迟通常以“秒级可见”为目标。我当时的设定是日志产生到可查询控制在 30 秒内核心业务希望更快就调到 10 秒。这个口径直接决定了下游是用流式计算做窗口聚合还是像 Doris 这样导入加预聚合链路选型完全不同。量级评估也决定了技术选型。按典型投放规模估算假设一天产生 30 亿次竞价请求每次请求产生的日志经过整理后约 300 字节再叠加展示、点击、竞胜这些事件一天新增原始数据大约 1.5TB 到 2TB。高峰时段集中到了晚间几个流量小时写入速率能冲得很高。这么大的增量再加上业务要随时按多维切片和钻取传统 MySQL 这类 OLTP 库完全扛不住列式 OLAP 是必然选择。1.2 三类分析需求为什么绕不开从运营、投放策略和算法团队的视角看实时竞价分析系统的需求可以收敛成三类。第一类是实时监控。媒体侧、平台侧都要盯着消耗、竞胜率、CPM、点击率这些核心指标并且要能按分钟或小时观察趋势出现异常波动要第一时间发现。比如某个头部媒体在晚上八点的竞胜率突然掉了一半运营需要能立刻定位到是出价策略问题还是媒体流量变化。第二类是多维下钻。看板上的异常往往只是入口接下来要在媒体、广告位、投放计划、广告主、地域、设备系统、运营商、时段之间任意组合分析。比如“某广告主在北京地区、晚间时段、iOS 设备上的竞胜率为什么明显低于其他组合”这种查询天然是多维交叉过滤的。第三类是明细回溯。策略同学调优时需要翻单条记录样本验证某些结论比如一条点击是否真的对应当前这条计划的竞胜记录。这类需求对系统的高基数明细查询能力有要求。三类需求放在一起技术选型上有一个根本矛盾既要做超大明细的实时入库又要有多维聚合秒级返回。传统做法往往是明细库和聚合库分开一套扛明细、一套扛聚合再中间搭同步任务复杂度高不说两边的口径还不一定对得上。Doris 能把这些需求一块儿承接住这也是它在广告分析场景里被广泛选择的核心原因。2. 为什么最终选了 Apache Doris2.1 过去那套技术栈的痛在没有引入 Doris 之前我也经历过几种方案的混搭各有的短板在广告场景里特别明显。用 ClickHouse 做竞价分析单表聚合确实快但最难受的是关联能力弱。竞价分析里“广告主-投放计划-创意-媒体”之间免不了要做带维度的补全和 joinCK 的多表 join 优化一直不省心左连接遇到大表时内存压力也大。还有一点ClickHouse 对数据更新的支持相对生硬RTB 链路偶尔需要回补、重放历史数据这套机制对我们来说维护成本偏高。用 Druid 也试过。它做时间序列聚合很顺手但口径偏粗SQL 能力偏弱灵活下钻和高基数明细查询都很别扭。运营同事要想查一个具体请求样本在 Druid 里得绕一大圈。用 Elasticsearch 做日志检索是强项但拿它做高频聚合就不太划算。量上来之后 ES 的内存压力非常大竞价分析这种多维聚合查询不仅慢还要担心集群稳定性。更痛苦的是这些方案的组合还会带着一套离线数仓报表当天才能出跟“实时竞价”这个名字完全不匹配。数据从线上链路到 Hive 再到预计算层环节多延迟大口径在每一层都可能被改一遍。这些问题不是单个工具不行而是整体技术栈和 RTB 场景的延迟要求严重不匹配。2.2 Doris 恰好命中了场景的哪些点最后换到 Doris回顾下来主要是几个点正好命中。标准 MySQL 协议这点很实用。Doris 对外用 MySQL 协议公司内 BI 工具、运营自取数工具全都直接连不需要额外适配层。团队里懂 MySQL 的同事几乎零成本上手这是选择它非常现实的理由。明细和聚合共存。Doris 支持 Duplicate、Aggregate、Unique 多种数据模型同一个集群里既能放原始事件明细也能建聚合表、物化视图加速。分析时透明路由数据写在明细里查询命中预聚合就不需要额外建任务这是最打动我的点。实时导入链路完善。Routine Load 可以直接消费 Kafka不用自己维护 Flink 作业Stream Load 则适合做批量微批。广告业务日志本身就在 Kafka从源头到 Doris 不需要引入额外的中间存储。再就是生态和部署相对轻量。FE 和 BE 两个角色整个集群结构清楚扩容就是加 BE 节点数据自动均衡。对比之前同时维护 ES、CK、消息通道三套系统运维压力下降非常明显。这里也要客观说一句Doris 不是万能它有自己适合的场景边界比如对超高并发点查的能力预期不要太高语法上更偏分析型需求。但在 RTB 这种写多、分析多的场景里它的匹配度确实高。3. 核心表结构设计与建模细节3.1 先想清楚上游数据事件流还是事实流很多团队在 Doris 建模前先犯一个方向性错误把原始日志原封不动导进去四条事件日志靠 request_id 在查询时关联。因为每条原始日志字段很稀疏比如 request 日志里没有最终竞胜价win 日志里没有出价过程查询时要么多表 join要么用子查询性能非常难看。我在搭建时的做法是在上游 Kafka 之前的流处理环节做一次事件关联把同一次竞价请求的关键事件聚合到一行形成“竞价事实流”。这一行包含了请求时间、媒体、广告位、投放计划、出价、底价、竞胜价、是否竞胜、是否有展示、是否有点击这些核心字段。Doris 里实际分析的就是这张“竞价事实宽表”而不是分散的原始事件日志。这样做的好处非常直接。第一Doris 内不需要做昂贵的自关联所有核心指标在一行里就能算出来。第二聚合模型和物化视图都建立在“一行一次竞价”的基础上统计口径自然统一。第三原始事件日志还在另一个存储里保留少量采样用于问题排查与 Doris 分析链路解耦。对于初期接入、数据量还没到极端规模的情况你也可以直接把各事件日志分开入库但随着量上来事实宽表的优势会越来越明显。3.2 明细表排序键求快还是要均衡事实流的表结构我用的是 Duplicate 模型排序键设计为 (event_time, request_id)。当时这么选核心原因是业务查询几乎都带时间范围event_time 放第一列让前缀索引有效裁剪request_id 放第二列则方便按请求 ID 定位单条明细。这里有个容易被忽略的细节Doris 的前缀索引是稀疏索引最多覆盖前 36 字节的排序列而且要求范围列放在最后才能命中。如果你排序键把 event_time 放第一列那么查询条件里只有“时间范围 广告位等值”时广告位的等值条件是无法利用前缀索引的还是得靠 ZoneMap 做列级过滤。反过来如果你把广告位放第一列、时间放第二列单广告位的时间范围查询会很快但全局任意时间段的热门媒体查询就会退化。做不到用一套前缀索引满足所有组合查询这是建模时必须接受的现实。我的策略是明细表满足“时间范围 单条定位”的检索需求把频繁的多维聚合需求交给物化视图和聚合表而不是试图把几十个维度都塞进排序键。实际建表语句如下CREATE TABLE rtb_bid_fact ( event_time DATETIME NOT NULL COMMENT 事件发生时间, request_id VARCHAR(128) NOT NULL COMMENT 竞价请求ID, media_id BIGINT NOT NULL COMMENT 媒体ID, placement_id BIGINT NOT NULL COMMENT 广告位ID, advertiser_id BIGINT NOT NULL COMMENT 广告主ID, campaign_id BIGINT NOT NULL COMMENT 投放计划ID, creative_id BIGINT NOT NULL COMMENT 创意ID, city_id INT NOT NULL COMMENT 城市ID, os_type TINYINT NOT NULL COMMENT 操作系统枚举, carrier_id SMALLINT NOT NULL COMMENT 运营商枚举, price_bid INT NOT NULL COMMENT 出价(分), price_floor INT NOT NULL COMMENT 底价(分), price_win INT NOT NULL COMMENT 竞胜价格(分), is_win TINYINT NOT NULL COMMENT 是否竞胜, has_impression TINYINT NOT NULL COMMENT 是否有展示, has_click TINYINT NOT NULL COMMENT 是否点击 ) DUPLICATE KEY(event_time, request_id) PARTITION BY RANGE(event_time) () DISTRIBUTED BY HASH(request_id) BUCKETS 48 PROPERTIES ( replication_num 2, storage_medium SSD );分区我按天建一来符合日志按天滚动淘汰的管理习惯二来绝大部分查询都会带上 event_time 范围分区裁剪能把扫描量限制在很小的范围内。分桶键选择 request_id只看重一点哈希足够离散。竞价请求 ID 是天然均匀的随机值用它分桶能保证导入和查询时数据不会集中到某个桶。3.3 用聚合模型和物化视图兜住看板查询明细表满足了下钻查单但看板聚合是另一回事。如果每次实时看板都是全表扫一天数据再做 group by再好的列存也顶不住高频请求。我的做法是针对固定粒度的看板查询单独建 Aggregate 模型的结果表。比如按“天 小时 媒体”统计请求量、竞胜量、出价总和、竞胜价格总和数据由流处理环节或 Doris 自己的 INSERT INTO SELECT 定期汇总写入。Aggregate 模型在导入时按 Key 自动聚合查询时对每个 key 的 REPLACE/SUM 字段直接取结果耗时非常稳定。CREATE TABLE rtb_media_hourly_agg ( stat_hour DATETIME NOT NULL COMMENT 小时时间点, media_id BIGINT NOT NULL COMMENT 媒体ID, request_count BIGINT REPLACE DEFAULT 0 COMMENT 请求数, win_count BIGINT REPLACE DEFAULT 0 COMMENT 竞胜数, sum_bid BIGINT REPLACE DEFAULT 0 COMMENT 出价总和, sum_win BIGINT REPLACE DEFAULT 0 COMMENT 竞胜金额总和 ) AGGREGATE KEY(stat_hour, media_id) DISTRIBUTED BY HASH(media_id) BUCKETS 16 PROPERTIES (replication_num 2);它和明细表之间的数据流动可以用 Doris 的 INSERT INTO SELECT 定时任务也可以用物化视图自动维护。物化视图的优势是省去手动调度Doris 会在基表数据变化时自动增量更新并且查询优化器会把匹配的查询自动路由到视图上。但同步物化视图的排序键受限于聚合列的组合分桶策略也不能自由定制灵活度不如结果表。所以我的原则是固定监控粒度的看板用聚合表灵活组合的下钻场景靠明细表实时聚合两者都能满足时优先用物化视图减少维护成本。3.4 维度编码与索引怎么配维度字段在 Doris 里的数据类型选择直接影响存储和扫描效率。媒体、广告位、广告主这些一律用 BIGINT 业务 ID不要用字符串操作系统、运营商、是否竞胜这些枚举字段用 TINYINT 或 SMALLINT 压缩存储。价格字段我统一用“分”存整数避免浮点精度问题也方便后续计算除以 100 得到元。这样一行事实记录的实际存储可以压到远小于原始文本日志。索引方面Doris 默认有列存 ZoneMap在 segment 层面记录每列 min/max很多等值过滤不需要额外索引。真正会用到的是 bitmap 索引我建在 is_win、os_type、carrier_id 这类低基数字段上。多维组合过滤时bitmap 索引可以快速求交集比如查询“iOS 竞胜 某个城市”这样的组合条件。但注意不要对 request_id 这种超高阶字段建 bitmap 索引只会白白增加写入开销。这里还要强调一点建索引之前先看查询计划EXPLAIN确认实际扫描行数。Doris 的优化器有时直接走列存过滤比走 bitmap 还快我在试过相当多组合后才把索引收敛到两个真正高频的过滤列上其他都靠存储层裁剪解决。无脑堆索引不会提升查询速度反而拖慢导入。4. 实时导入与统计查询的完整落地4.1 Routine Load 消费 Kafka 的参数怎么调Doris 导入实时数据有两条路径Routine Load 和 Stream Load。RTB 日志主链路我用的是 Routine Load它让 Doris 作为 Kafka 的消费者持续拉取数据省掉一层 Flink。建一个作业的完整例子CREATE ROUTINE LOAD rtb_bid_load ON rtb_bid_fact COLUMNS(event_time, request_id, media_id, placement_id, advertiser_id, campaign_id, creative_id, city_id, os_type, carrier_id, price_bid, price_floor, price_win, is_win, has_impression, has_click) COLUMNS TERMINATED BY \t PROPERTIES ( desired_concurrent_number 3, max_batch_interval 20, max_batch_rows 500000, max_batch_size 104857600, format csv, strict_mode false, max_error_number 0, timezone Asia/Shanghai ) FROM KAFKA ( kafka_broker_list broker1:9092,broker2:9092, kafka_topic rtb_bid_fact, kafka_partitions 0,1,2,3,4,5, kafka_offsets OFFSET_BEGINNING );几个参数在实际使用中感受很深。desired_concurrent_number 控制消费并发建议和 Kafka partition 数匹配我当时 6 个分区开 3 个并发任务配置太高反而容易因网络带宽打满触发任务卡死。max_batch_interval 直接决定导入可见延迟想做到 10 秒以内就把这个值调到 5 到 10同时留意 BE 的磁盘压力和 compaction 调度否则批处理太频繁小文件变多查询反而变慢。Routine Load 的时区参数建议显式设置。广告日志本身来自全球多个地域的请求源如果你的平台统一用东八区时间统计投放消耗必须保证在写入 Doris 前把时间字段转成一致的时区字符串否则同一个小时里混了两个时区的记录所有聚合口径全错。这个坑排查起来非常隐蔽最好从源头链路就规范。Stream Load 则是留给离线回补和批量清洗场景的。比如某个时段 Kafka 数据出了问题需要重放指定时间段的日志我可以写脚本用 HTTP 请求批量导入按文件大小控制分批避免一次性导入太大导致 BE 内存压力上升。总之日常实时用 Routine Load异常重放和批量数据用 Stream Load两者配合基本能覆盖全部导入需求。4.2 三个核心看板 SQL 实例落地之后的看板查询我拿三个典型 SQL 说明实现方式。第一个是实时竞胜率与消耗排行。这个看板按小时实时刷新SELECT media_id, COUNT(*) AS bid_cnt, SUM(is_win) AS win_cnt, ROUND(SUM(is_win) * 100 / COUNT(*), 2) AS win_rate, ROUND(SUM(price_win) / 100, 2) AS cost_yuan FROM rtb_bid_fact WHERE event_time NOW() - INTERVAL 1 HOUR GROUP BY media_id ORDER BY cost_yuan DESC LIMIT 20;查询同时过滤了最近一小时的事件字段分区裁剪能把扫描限制在一个分区内配合明细表本身按时间排序通常能做到几百毫秒返回。第二个是广告位维度的下钻分析。运营要看某个广告位上的历史竞胜价格分布这里用到百分位计算SELECT placement_id, COUNT(*) AS req_cnt, PERCENTILE_APPROX(price_bid, 0.8) AS p80_bid, PERCENTILE_APPROX(price_bid, 0.9) AS p90_bid, PERCENTILE_APPROX(price_win, 0.9) AS p90_win FROM rtb_bid_fact WHERE event_time 2025-01-01 00:00:00 AND event_time 2025-01-02 00:00:00 AND placement_id 10245 GROUP BY placement_id;单一广告位加一天分区实际上扫描的数据量非常可控。PERCENTILE_APPROX 走的是近似算法内存占用可控在广告竞价这种场景下精度足够。第三个是渠道转化效果对比。把点击率和展示率同时算出来SELECT carrier_id, COUNT(*) AS bid_cnt, SUM(has_impression) / COUNT(*) AS imp_rate, SUM(has_click) / COUNT(*) AS click_rate, SUM(has_click) / NULLIF(SUM(has_impression), 0) AS ctr FROM rtb_bid_fact WHERE event_time NOW() - INTERVAL 24 HOUR GROUP BY carrier_id ORDER BY bid_cnt DESC;这类聚合在数据落在聚合表或物化视图上时非常快如果直接查明细就要特别注意 event_time 范围扫全表的开销大得多。这三条 SQL 几乎覆盖了业务方日常 80% 的即席查询诉求核心思路都是“用时间范围裁剪 用维度 group by 用表达式算指标”没有一条是复杂到需要调优器发挥极限的。想让 Doris 发挥得好与其搬出复杂魔法 SQL不如把表结构和数据分布先做对。4.3 查询性能调优的几个有效动作调优这件事我在这个项目里总结出几条真正有用的动作按收益排序。第一强制查询带时间范围。看似废话但很多 BI 工具生成的 SQL 不带上限把整表几亿行拖出来算聚合再好的存储也救不了。我通过 Doris 的前端对常用查询模板做了包装自动注入默认时间窗口实测对耗时影响巨大。第二排序键设计好之后不要轻易改但分区策略可以随着数据量成长调整。如果单日数据量过大可以按天加二级分区或者对 event_time 做小时分区查询只扫描对应小分区。这里代价是导入时小文件会变多需要平衡。第三内存和资源分组管理。Doris 2.0 之后的 Workload Group 可以限制某个查询分组的内存和 CPU 占比我把实时看板查询分到高优先级组把 BI 一次性大查询分到低优先级组杜绝互相干扰。具体做法是给不同业务账号绑定不同的 workload group大查询不会因为扫描全大表把内存打满。第四打开查询溢写能力。如果单次聚合的中间结果实在太大内存不够用新版本 Doris 可以把排序、聚合的中间数据溢写到磁盘避免 OOM。这个开关默认是关闭的我特意在个别重查询会话里开启同时注意给临时目录留足磁盘空间。调优切忌上来就加索引、加节点。先看查询计划确认扫描行数和内存预估再决定是改查询、加物化视图还是扩资源我大部分所谓的“性能问题”最后都发现是没有走分区裁剪。5. 常见问题与排查实录5.1 数据倾斜分桶键的选择教训系统上线初期BE 节点负载出现过明显不均。几个节点磁盘使用率高得吓人另外几个空闲。一查原因是分桶键用了 campaign_id 而不是 request_id。头部广告计划集中在少数几个大客户身上哈希之后天然落到少数桶数据分布自然失衡。后来的处理方案是重建表把分桶键改成 request_id 哈希。rehash 之后数据分布均匀了但代价是重建期间双写了一段数据所以这个教训挺深刻的分桶键的设计只考虑随机性不要跟业务倾斜维度绑在一起。如果你无法确认维度是否会倾斜优先用请求 ID、设备 ID 这类接近随机分布的字段。还有一类倾斜发生在查询阶段而不是存储阶段。GROUP BY 某个头部媒体时即便底层数据均匀聚合计算本身也会把大量数据集中到单一 task。这种情况单靠分桶解决不了可以适当增加查询并行度或者提前在聚合表里把头部维度单独拆出来处理。RTB 广告这种“二八效应”极强的场景头部几个投放计划往往占了一半量级必须正视。5.2 导入逐渐落后不全是扩容能解决的上线一段时间后发现 Routine Load 的消费进度越来越落后晚高峰时 behind 能到几千万条。第一反应是集群资源不够准备扩容但排查后发现问题不在 BE 的计算能力而是 compaction 和磁盘 IO。Doris 的列式存储在导入时会不断产生版本小文件后台 compaction 任务把它们合并成大文件。广告业务晚高峰写入密集compaction 跟不上版本增长就会拖累导入性能。压垮临界点的往往是磁盘 IO 而不是 CPU。当时检查 BE 的 compaction score发现明显偏高对 compaction 线程数做了调整并保证 SSD 剩余空间至少留出 20%最终才把导入延迟压回正常区间。另一个容易被忽略的点是 Kafka 分区数。Routine Load 并发默认与分区数相关如果 Kafka topic 分区只有 2 个后面的 BE 压力调优都是白费。我先扩了上游 Kafka 分区再重新调整 Routine Load 并发两件事一起做效果才好。对落地实时导入链路的同学可以先判断瓶颈在摄入端还是合并端看 BE 导入任务耗时曲线如果平滑但滞后大概率是上游并发不足如果时快时慢大概率是 compaction 或磁盘瓶颈。5.3 查询超时给不同查询分好资源随着接入的业务变多BI 团队开始把一些超大范围的 ad-hoc 查询直接打在 Doris 上比如查 30 天全量数据做自定义交叉分析。这种查询偶尔会占掉大块内存导致实时看板接口的超时率明显上升。这个问题要靠资源隔离治理而不是限制业务不查。我在 Doris 里创建了两个 Workload Group实时看板组内存限制 50%、CPU 权重拉高ad-hoc 组内存限制 30%、CPU 权重降低。同时给 ad-hoc 账号设置更短的查询超时比如 120 秒超过就主动失败避免长时间占用资源。实践后效果很明显再出现重型查询时看板接口的 P95 延迟几乎没有波动。资源隔离的思路其实就是把不可控的用户行为装进一个笼子里别让它影响核心链路这是任何分析型系统都要尽早做的治理动作。我把这次迭代里踩到的坑整理成一个速查表方便后面接手的人定位现象可能原因排查思路BE 节点负载不均分桶键没有随机性检查各分桶 tablet 大小重建表选随机字段分桶Routine Load behind 增长Kafka 分区不足或 compaction 瓶颈看 BE 版本数和 compaction score扩容并调整配置看板查询偶尔超时重型查询抢占内存配置 Workload Group 隔离限制 ad-hoc 组资源聚合结果与离线报表不一致事件时间时区混用统一导入前时区转换检查字段口径导出文件导入失败时间字段格式不统一使用标准化时间字符串避免中文与零填充混用整体做下来我个人的体会是RTB 分析系统的瓶颈往往不在 Doris 本身而在接入侧的数据质量和表设计。Doris 确实足够快但如果你建模时没有想清楚“这条查询会怎么被执行、要扫多少数据”它一样会被玩坏。另一个很实在的建议是别一开始就追求把所有指标都实时化把核心看板和头部维度先做到实时其余保留离线链路效果是最好的。后续如果想再往前走一步完全可以把 Doris 当成统一分析层把自助 BI、算法特征回溯、投放监控都接进来这套底座是站得住的。