第四章 广告检索系统设计

广告检索,说白了就是在一堆广告里快速找到匹配的那几个。我刚开始做广告系统时,觉得这不就是个查数据库嘛,后来才发现——嗯,事情没那么简单。

一个成熟的广告系统,每秒要处理几万甚至几十万次请求。每次请求来了,你得在几十毫秒内从几百万条广告里找出符合条件的。这活儿,常规的数据库查询根本扛不住。

倒排索引原理

倒排索引,我习惯叫它「反向查字典」。你想想看,普通索引是「文档→关键词」,倒排索引反过来——「关键词→文档列表」。

举个例子。假设有三条广告:

  • 广告A:标签「运动、篮球、夏季」
  • 广告B:标签「运动、足球、冬季」
  • 广告C:标签「科技、手机、夏季」

倒排索引长这样:

运动 → [A, B]
篮球 → [A]
夏季 → [A, C]
足球 → [B]
冬季 → [B]
科技 → [C]
手机 → [C]

用户搜「运动+夏季」,直接取交集——A和C。不用全表扫描,速度飞快。

核心要点:倒排索引的本质是用空间换时间。把关键词到文档的映射提前算好存起来,查询时直接定位。

我在项目中遇到过一个问题:广告标签可能有几百种,但每个广告的标签数量很少(一般不超过20个)。这时候倒排索引的稀疏性就很重要。我建议用位图(Bitmap)来存储文档ID列表,压缩率高,求交集也快。

广告检索的布尔表达式

广告检索的条件,本质上就是个布尔表达式。比如:

(性别=男 AND 年龄∈[18,30]) OR (兴趣=运动 AND 地域=北京)

怎么高效计算?我常用的方法是转成析取范式(DNF)。说白了,就是把表达式拆成「或」连接的多个「与」子句。

上面那个例子转成DNF:

(性别=男 AND 年龄∈[18,30])  OR  (兴趣=运动 AND 地域=北京)

每个子句内部是AND关系,子句之间是OR关系。计算时,先算每个子句的匹配结果,再取并集。

避坑指南:我曾经把表达式嵌套太深,结果性能惨不忍睹。后来发现,把表达式转成DNF后,虽然存储空间多了点,但计算速度提升了3倍以上。记住:检索系统里,计算复杂度比存储成本更值钱。

具体实现时,我习惯用「跳表」来加速AND操作。两个有序列表求交集,跳表可以跳过大量不匹配的元素。你想想看,如果列表A有100万条,列表B有50万条,用跳表可能只比较几万次就出结果了。

检索性能优化

性能优化,我一般从三个维度下手:缓存、分片、索引结构。

缓存策略

缓存是性价比最高的优化手段。我建议分两层:

  • 结果缓存:相同的查询条件,直接返回上次结果。适合热门广告位。
  • 索引缓存:把倒排索引的热门关键词缓存到内存。适合高频标签。

但要注意,广告系统的数据更新很快。缓存时间不能太长,我一般设5-10秒过期。太短了缓存没意义,太长了数据不一致。

警告:千万别把所有索引都塞进缓存。我见过有人把10GB的索引全放内存,结果GC把服务搞挂了。只缓存Top 20%的热门关键词,能覆盖80%的请求,这才是合理的二八原则。

分片策略

当广告量超过千万级,单机就扛不住了。分片是必须的。

我常用的分片方式:

分片方式 原理 适用场景
哈希分片 按广告ID哈希取模 均匀分布,适合通用场景
范围分片 按地域/行业等维度分 查询条件有明确维度
一致性哈希 环形哈希,减少扩容影响 需要动态扩缩容

我个人习惯用一致性哈希。为什么呢?因为广告系统经常要扩容,一致性哈希只需要迁移少量数据,不像普通哈希那样要全量重排。

分片后还有个问题:查询时怎么聚合?我建议用「Scatter-Gather」模式——把查询请求广播到所有分片,各分片并行计算,最后汇总结果。注意要设置超时,避免某个慢分片拖垮整个请求。

实时索引更新

广告系统最头疼的问题之一:索引更新。广告主随时可能修改出价、暂停广告、新增创意。你不能每次修改都重建索引吧?

我常用的方案是「双缓冲」:

// 伪代码示例
class IndexManager {
    Index currentIndex;  // 当前服务使用的索引
    Index buildingIndex; // 后台构建中的索引
    
    void updateIndex() {
        buildingIndex = buildNewIndex(); // 后台构建
        // 原子切换
        currentIndex = buildingIndex;
    }
    
    List<Ad> search(Query q) {
        return currentIndex.search(q); // 无锁读取
    }
}

说白了,就是准备两份索引。一份给线上查询用,另一份在后台慢慢构建。构建完了,指针一换,新索引就上线了。整个过程对查询线程无锁,性能影响极小。

经验之谈:我曾经用「增量更新」的方式,每次只更新变化的部分。但后来发现,增量更新容易产生碎片,索引膨胀得厉害。双缓冲虽然内存多了一倍,但稳定性和性能都好得多。对于广告系统这种对延迟敏感的场景,我建议优先考虑双缓冲。

更新频率怎么控制?我建议:

  • 高优先级(如暂停广告):秒级更新
  • 中优先级(如修改出价):分钟级更新
  • 低优先级(如新增创意):小时级批量更新

这样既保证了实时性,又不会让索引构建太频繁。

小结

广告检索系统,核心就三件事:倒排索引让你找得快,布尔表达式让你算得准,缓存和分片让你扛得住。实时索引更新,则是保证数据新鲜度的关键。

嗯,这些内容看起来多,但真正落地时,你会发现每个点都有很多坑。我建议先从最简单的倒排索引+单机缓存开始,等量上来了再考虑分片和双缓冲。别一开始就搞得太复杂,容易把自己绕进去。