ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

快手千亿级多模态检索实践:Apache Doris 宽表建模与毫秒级查询优化

快手千亿级多模态检索实践:Apache Doris 宽表建模与毫秒级查询优化 接手这类项目前我其实犹豫了很久。“千亿级”和“多模态”这两个词摆在一起听起来像年终总结里的形容词而不是一个能落地的工程问题。但真正深入进去以后你会发现它其实是一个非常具体、非常吃建模功底和链路设计能力的事。这篇就好好聊聊快手在 Apache Doris 上做千亿级别多模态检索时整个方案是怎么一步一步长出来的以及我们在线上踩过的那些真正值得说的坑。1. 先把“多模态检索”翻译成人话它到底在解决什么问题快手每天新增的短视频内容是多模态信息最典型的载体。一条视频摆在系统面前不仅仅是一个文件它同时包含标题文本、自动语音识别出的台词文字、封面和视频帧里的光学字符识别结果、画面语义分析打出的标签比如“猫”“美食”“户外”、作者填写的分类、位置信息、背景音乐属性甚至还有评论区用户行为映射出的隐性标签。这些不同来源的信息就是“多模态”的原始形态。传统做法是每个业务线各维护一套自己的索引。搜索侧建一套关键词倒排推荐侧建一套标签倒排审核侧可能又单独拉一套状态位筛选平台。这种架构的坏处是每一次跨模态查询都要先拿到多个系统的结果再做交集和并集。比如我想查“最近三天发布、画面里含有‘口红’语音里提到‘测评教程’作者是头部美妆博主且热度大于某个阈值”的视频旧体系里至少要串联五六个接口把候选集来回拉取合并。数据量小时还能凑合到了每天的活跃内容叠加历史累计内容达到几十亿甚至千亿这个量级时这种“多系统拼凑”的方式在延迟和成本上都是灾难。我们当时定下的核心诉求其实很简单把一条视频身上所有模态信息抽象成一张统一的、可以任意组合过滤的宽表让任何一条业务线都能通过一次查询就把多个模态条件一起命中。这个诉求落到技术上就是一个“实体中心 列式属性 位图过滤”的检索模型。实体是视频属性是不同模态解析出的结构化字段而真正支持任意组合条件快速命中的是底层数据库的位图索引和分区分桶能力。2. 为什么选 Apache Doris 而不是继续用 Elasticsearch 或者自研 KV选型这件事当时也经历了几轮 PK。不是 Apache Doris 一上来就赢而是它恰好把我们的核心矛盾都解决了。2.1 和 Elasticsearch 比聚合能力和写入吞吐是关键差异Elasticsearch 做多条件过滤其实非常成熟尤其擅长文本检索这也是很多团队的第一直觉。但我们在真正评估时发现了几个硬伤。第一个是存储放大和成本。短视频内容的模态标签基数非常大一个标签可能打在上亿条视频上倒排链非常长ES 对这类高基数标签的压缩率并不理想集群规模会被撑得很大。第二个是聚合能力。我们的业务场景不只是“过滤出来就行”还需要在过滤后的集合上做热度排序、人群分布统计、作者画像切割ES 在这类分析型操作上的灵活性和执行效率明显不如一个真正的 MPP 分析数据库。第三个是更新模式。内容状态时刻在变审核通过、下架、作者删除、标签修正这些高频小批量更新在 ES 里会不断产生 segment merge 压力线上抖动很难控制。2.2 和 HBase 类 KV 比扫描能力和 Bitmap 加速是核心HBase 这类系统做“实体画像型”存储很合适按 content_id 随机读单行非常快但多模态检索最频繁的操作并不是“按 ID 找一行”而是“按一组标签条件扫出符合条件的成千万上亿行”。KV 系统对这类按属性过滤的大范围扫描并不友好必须自己维护二级索引索引一致性还得另做一套方案。而 Doris 天然是列式存储配合位图索引之后多条件过滤基本可以做到准点查效果。2.3 还有一个很容易被忽视的点以 SQL 为中心的开发范式做数据的人都知道SQL 提效有多重要。上 Doris 之后上游数据团队不需要学一套私有 API只要会写 SQL就能完成特征接入、数据校验、性能查看。而对我们这种用户量级的场景能降低协作门槛就意味着能少修很多故障。最终选型结论Doris 让我们在同一个系统里同时拿到了“数据湖时代的高吞吐写入”“离线分析能力”和“在线高并发点查能力”相当于把原来三个系统的活合并成了一个系统来干运维复杂度和机器成本都降了一个量级。3. 千亿数据的建模一张宽表如何承载所有模态建模是整个项目里最磨人的环节。一开始也有过激进的“多表关联派”和保守的“全部打平派”最后我们是选了“实体中心宽表 位图列”的折中方案。3.1 主表的整体结构表设计核心原则是以内容实体content_id为粒度一行代表一条视频每条视频的所有模态特征全部放进这一行的不同列。CREATE TABLE video_modality_index ( content_id BIGINT NOT NULL COMMENT 内容唯一ID, publish_day DATE NOT NULL COMMENT 发布日期用于分区, publish_ts DATETIME NOT NULL COMMENT 精确发布时间, status TINYINT NOT NULL COMMENT 内容状态0审核中 1可用 2下架, author_uid BIGINT NOT NULL COMMENT 作者ID, author_level SMALLINT COMMENT 作者等级用于作者分层筛选, first_category INT COMMENT 一级分类, second_category INT COMMENT 二级分类, title_text VARCHAR(1024) COMMENT 标题原文, asr_text VARCHAR(2048) COMMENT 语音识别文本, ocr_text VARCHAR(2048) COMMENT 视频帧OCR文本, tag_ids BITMAP COMMENT 全模态标签ID集合, semantic_ids BITMAP COMMENT 语义分析标签ID集合如图像识别/音频识别的结果, heat_score DOUBLE COMMENT 热度分用于排序 ) DUPLICATE KEY(content_id) PARTITION BY RANGE(publish_day) (...) DISTRIBUTED BY HASH(content_id) BUCKETS 96 PROPERTIES ( replication_num 3, dynamic_partition.enable true );用 DUPLICATE 模型而不是 UNIQUE 模型是因为内容主键本身不重复而且我们需要保留不同批次导入特征的可能性。像tag_ids和semantic_ids这样的 BITMAP 列专门用来承载高频枚举类标签比如“包含美妆品类的标签编号 1024”转换成 SQL 条件就是bitmap_contains(tag_ids, 1024)。3.2 为什么把文本也直接放进宽表你可能会有疑问既然标签已经位图化了为什么还要把标题、语音识别文本、OCR 文本原文也存进来这里有一个实战经验纯标签检索能覆盖 70% 的需求但剩余 30% 的检索条件往往是模糊表达比如“标题里含‘口红’但不含‘试色’”。这种语义精细判断标签无法完全覆盖必须回到原文做低延迟匹配。所以我们把文本也放进宽表既能支持LIKE类条件又避免了跨库 JOIN。代价就是存储变大但 Doris 的列式压缩对中文文本压缩比很高实际增量并不可怕。3.3 分区和分桶的颗粒度怎么定分区我们选了按天。原因很直观内容时效性是检索的核心约束用户搜索“今天的热点视频”不会想看三个月前的推荐召回也天然优先近期内容。按天分区后查询可以直接裁剪掉大部分数据文件只扫最近几天甚至当天的分区。分桶则按content_id哈希分 96 桶。为什么不是 48 或 128这是根据集群规模和数据量试出来的桶数太少导致单个 Tablet 数据量过大导入和查询的并发能力受限桶数太多则小文件变多FE 元数据压力增大BE 上的 compaction 也会变频繁。96 桶在当时的节点规模下是一个扫描并发和元数据开销折中后的结果。现在节点变多了临时表也会把它调成 128 或者 192但这是后话。4. 数据链路离线全量重建与在线实时增量并存模型定了接下来真正决定成败的是数据链路设计。千亿级数据体量下任何“先全量再更新”的朴素思路都行不通。4.1 两条链路并行链路总体分成两条一条是离线批量链路用来做周期性全量数据重建和冷数据修正另一条是实时增量链路负责承接秒级产生的新内容和特征变更。链路类型数据来源导入方式数据时效用途离线批量Hive / Iceberg 特征表Broker LoadT1全量重建、历史数据修正、冷数据补齐实时增量Kafka上游 Flink 清洗后Routine Load秒级到分钟级新内容入库、标签变更、状态流转离线批量这边Hive 里的特征宽表经过 Spark 任务统一清洗对齐后按天分区导出到 Doris 对应的临时分区校验通过后再替换正式分区。这么做的好处是出问题可以快速回滚到前一天版本对线上查询几乎没有影响。实时增量走的是 Routine Load直接消费 Kafka 里的特征消息。消息体里带着 content_id 和全量模态特征Doris 根据主键做 UPSERT。这里有个关键问题一天里同一 content_id 可能被更新多次比如先进入“审核中”审核通过后变成“可用”紧接着又被加上新的语义标签。如果每次更新都写入一个版本宽表里同一主键会有大量行堆积。我们最终是通过“版本号 时间戳”做幂等让同一主键只保留最新状态老版本由 Doris 的合并机制处理掉。4.2 状态口径统一别让下架内容继续被检索短视频内容最怕一个问题某条视频因为违规被下架结果搜索引擎里还能看到摘要。数据链路里必须有一个非常明确的状态位贯穿始终。我们最终把内容状态收敛成单一字段status所有上游不再各写各的任何变更都必须带上最新的status值。下架操作不是物理删除而是把状态位置为“下架”然后在查询侧统一过滤status 1。物理删除的成本太高而且内容平台经常要回溯历史状态保留状态位比物理删除更符合业务审计需求。4.3 临时分区解决“大版本更新”对线上查询的冲击全量重建最怕影响在线查询。我们的做法是“先导临时分区再原子替换”在目标表上新建一个临时分区指定要覆盖的日期范围。把全量数据导入临时分区。数据校验无误后通过ALTER TABLE把临时分区替换为正式分区。整个过程对查询侧是透明的。替换之前查询仍然读旧分区替换完成后立刻切换为新数据。这里比较容易踩的坑是分区替换期间正好有长时间运行的查询会拿到旧分区的文件句柄导致结果短暂不一致。我们的应对是控制替换窗口一般选在凌晨低峰期执行同时让查询超时设置短于替换窗口。5. 在线服务如何做到毫秒级查询引擎侧的优化数据灌进来了模型也建好了最后一个硬骨头是查询延迟。我们的线上 SLO 是P99 单次跨模态检索延迟不超过 150ms常规缓存命中场景控制在 20ms 以内。5.1 查询计划要“用尽一切手段裁剪数据”先说一个最典型的查询用户搜索“最近一天发布、美妆类目、标题匹配‘口红’、热度排序前 100”。Doris 的 FE 拿到 SQL 后实际上做了以下几步裁剪分区裁剪按publish_day直接定位到前一天的分区只扫一个分区文件。分桶裁剪如果查询里不带content_id条件分桶裁剪就失效但枚举值条件的高选择性主要靠位图索引兜底。位图索引加速first_category这类枚举列上建了普通位图索引tag_ids列本身是 BITMAP 类型查询时可以直接用bitmap_contains做快速判定。短路返回如果ORDER BY heat_score LIMIT 100能在扫描过程中提前收集到足够的 top N 结果Doris 会提前终止扫描不需要等全分区扫完。这是 Doris 查询引擎里非常值钱的一个特性也是我们在压测时最满意的地方。对比之前 ES 的实现同样的条件命中ES 倒排链拉取加合并的耗时是 Doris 的 3 到 5 倍。5.2 物化视图解决高频统计查询实时场景里有一类查询非常烦运营后台要实时看“过去一小时头部作者维度下各类目视频的分发量”这种聚合实时跑在千亿级宽表上成本极高。我们用了 Doris 的物化视图把这类聚合透明化改写。物化视图不是全量预聚合它支持增量刷新基表导入新数据后物化视图会自动同步更新对应分区。这样运营后台的查询直接命中物化视图延迟从秒级降到了几十毫秒而且完全不用改业务代码。5.3 查询层的“业务级缓存”与热点保护数据库再怎么优化也不能让所有请求都打到 Doris 上。我们在查询网关层做了两层处理第一层是高频查询结果缓存同一 query 条件在秒级窗口内的结果复用减少 Doris 重复计算。第二层是热点标签降级当某个标签比如某个头部热点事件的关联标签被大量并发查询同时命中时网关会把这个标签单独提取出来走一个独立的短链缓存不让热点请求把所有 BE 节点打满。这套组合拳打下来线上高峰期 Doris 集群的 CPU 使用率一直控制在一个非常健康的水平没有出现过热点拖垮全集群的问题。6. 线上踩过的几个硬坑数据倾斜、统计信息失效、MOW 写放大任何系统上线后都会遇到问题Doris 也不例外。这里挑几个后期排查比较典型的坑写出来希望对你有帮助。6.1 标签分布倾斜导致“明明走位图还是慢”有段时间线上出现一种情况单个标签的条件查询命中数据量明明不大但延迟却跑到几百毫秒。排查后发现问题出在标签分布极不均匀。举个例子“搞笑”类标签关联的视频数量可能是“珠宝鉴定”类标签的上千倍。按content_id做哈希分桶时这些热门标签的内容虽然被散到了不同节点但单个节点上仍然堆积着海量数据。位图索引能精确定位但最终取数据行的时候还是要把所有命中的行读出来。对这个问题的处理我们分了两步第一把超高热度的标签单独拆到独立表里专门做热点聚合第二调整分桶策略让高热度内容分布更均匀同时查询侧加了一层“高频标签预聚合缓存”让这类查询的绝大多数请求直接命中缓存不再打底层。6.2 统计信息过期导致优化器抽风这是所有分布式数据库都会遇到的事Doris 的查询优化器高度依赖统计信息一旦表的数据量因为大批量导入发生剧烈变化统计信息没有及时更新优化器就会做出错误的执行计划。我们线上出现过一次某天全量数据重建后某张表的行数翻了一倍但统计信息还是旧值。结果优化器认为某个条件非常稀疏没有走位图索引而是选择全分区扫描导致 P99 延迟从 80ms 暴涨到 3 秒。这个问题定位后我们做了两件事对所有核心检索表开启统计信息的自动收集并设置窗口期避开查询高峰。对重要的例行批量任务在导入完成后主动执行一次 ANALYZE确保统计信息跟得上数据变化。这是一个非常容易被忽略的坑尤其在数据量快速增长的业务里强烈建议把统计信息刷新变成导入链路中的固定环节。6.3 MOW 模式的小批量高频率更新带来写放大我们为了兼顾实时更新和查询性能核心表开了 Doris 的 Merge-on-Write 模式。使用后发现如果频繁提交很小的更新批次会产生大量的小版本底层文件数和合并开销都会明显上升甚至挤压查询资源。这个问题的本质是写入模型的选择代价MOW 模式优化了读取时合并的损耗但写路径上需要标记旧版本频繁小事务会放大这些额外开销。我们把多次小更新合并成一批Routine Load 的消费批次从原来的单条提交调整为攒批提交上游 Kafka 里按 content_id 做一次聚合去重确保同一内容在一个批次内只保留最新的完整特征。改完之后合并压力小了非常多查询稳定性也上来了。7. 下一步向量检索怎么和多模态宽表架构共存多模态检索做到现在纯标签和文本匹配已经覆盖了大部分业务诉求。但内容平台的检索一定会走向更深的语义理解用户表达的未必是某个关键词而是“那种氛围的视频”。这类诉求靠离散标签是永远无法完整表达的必须引入向量化语义检索。我们的规划是Doris 继续扮演“多模态属性过滤 候选集聚合”的核心角色而向量相似度计算比 es 放在专门的高性能向量检索平S里面。简单来说查询请求会先在向量检索引擎里跑出语义上最相近的候选集比如 top 5000然后把这些候选 content_id 列表传给 Doris再叠加时间、作者、类目、状态等多模态条件做精筛和排序。有的读者可能会问为什么不让 Doris 直接存向量做原生的向量索引这是一个值得关心的方向。但对现有的线上体量来说最可靠、最稳妥的架构永远是“把每个系统用在它最擅长的地方”。向量引擎负责高效召回Doris 负责夯实多模态精细过滤两个系统之间用一个轻量级的候选集协议来衔接。这样一来Doris 表里的semantic_ids列还能继续保留作为向量召回之后的一个可解释的校验层。等到 Doris 的向量能力在亿级规模上足够成熟而且运维成本可控我们可能会尝试把一部分向量计算收回到 Doris 内部减少跨引擎的调度开销。但这是后话当前阶段稳定性和可排查性最重要。做这套系统最有成就感的时候不是压测数据达到目标的那一天而是业务方在排期不太紧张的时候跟我说“现在跨模态检索需求基本上提一个条件就能出一个结果不用再等各团队协调了。”对做基础架构的人来说这句话比任何性能指标都值钱。如果你也在设计类似的多模态内容检索体系我个人的体会是不要急着去追新概念先把“实体中心、模态打平、位图索引、分区裁剪”这几板斧用扎实你会发现大部分以前要拆三四个系统才能解决的问题现在一张宽表加一个 Doris 就能说清楚。
返回列表