4、广告检索模块实现:倒排索引与多条件组合检索

广告检索,说白了就是用户发起一次请求,系统要在几百毫秒内从几百万条广告里找出最匹配的那几个。这个模块我做了好几年,踩过不少坑。今天咱们就聊聊核心的数据结构——倒排索引,以及怎么把多条件检索跑得又快又稳。

4.1 倒排索引的数据结构设计:HashMap + 跳表

先说说倒排索引。传统数据库是按行存的,你要查“性别=男且年龄=25”,得全表扫描。广告系统不行,延迟要求太高。所以我们用倒排索引——说白了就是反过来存:每个词(比如“男”、“25岁”)后面挂一个列表,记录哪些广告包含这个词。

那这个列表用什么结构?我早期用过ArrayList,后来发现不行。为什么?因为广告的定向条件经常要取交集、并集,ArrayList做归并排序太慢了。后来我换成了跳表(Skip List)

跳表是个好东西。它本质是有序链表,但加了几层索引。查找时从顶层往下跳,时间复杂度O(log n)。而且它支持范围查询,比如“年龄在18-30岁之间”,跳表能快速定位到起点。

我个人的习惯是:HashMap + 跳表组合。HashMap的key是定向条件的值(比如“城市=北京”),value是一个跳表,里面存的是广告ID列表。这样查询时先通过HashMap定位到具体的倒排链,再用跳表做快速合并。

核心数据结构示意:

// 伪代码:倒排索引结构
class InvertedIndex {
    // key: 定向条件值,如 "gender:male"
    // value: 跳表,存储广告ID(有序)
    Map<String, SkipList<Integer>> index;
    
    // 添加广告到索引
    void addAd(Ad ad) {
        for (String condition : ad.getConditions()) {
            SkipList<Integer> list = index.get(condition);
            if (list == null) {
                list = new SkipList<>();
                index.put(condition, list);
            }
            list.insert(ad.getId());
        }
    }
}

这里有个细节:跳表里的广告ID必须是有序的。为什么?因为后面做多条件组合检索时,我们要做归并排序,有序列表才能用双指针快速取交集。嗯,这个后面会细说。

4.2 多条件组合检索的代码实现

假设用户请求是:“性别=男,年龄=25-35,城市=北京”。我们需要从三个倒排链里取交集。最直接的做法是:取最短的链,然后遍历它,去其他链里二分查找。但这样效率不高。

我推荐用多路归并。把所有倒排链按长度排序,从最短的开始,用双指针依次取交集。代码大概长这样:

// Java伪代码:多条件组合检索
public List<Integer> search(List<String> conditions) {
    // 1. 获取所有倒排链,按长度排序
    List<SkipList<Integer>> lists = new ArrayList<>();
    for (String cond : conditions) {
        SkipList<Integer> list = index.get(cond);
        if (list == null) return Collections.emptyList(); // 没有匹配
        lists.add(list);
    }
    lists.sort(Comparator.comparingInt(SkipList::size));
    
    // 2. 从最短链开始,逐步取交集
    List<Integer> result = lists.get(0).toList();
    for (int i = 1; i < lists.size(); i++) {
        result = intersect(result, lists.get(i));
        if (result.isEmpty()) break;
    }
    return result;
}

// 双指针取交集
private List<Integer> intersect(List<Integer> a, SkipList<Integer> b) {
    List<Integer> res = new ArrayList<>();
    int i = 0, j = 0;
    while (i < a.size() && j < b.size()) {
        int va = a.get(i), vb = b.get(j);
        if (va == vb) {
            res.add(va);
            i++; j++;
        } else if (va < vb) {
            i++;
        } else {
            j++;
        }
    }
    return res;
}

这段代码我实际用过,效果不错。但要注意:如果条件特别多(比如10个以上),每次取交集都要遍历,性能会下降。这时候可以加个提前终止:一旦结果集为空,直接返回,不用继续了。

避坑指南:我曾经遇到过一个问题——倒排链里的广告ID重复了。比如一个广告同时满足“性别=男”和“年龄=25”,但索引里只存一次。如果重复存了,取交集时会出现重复ID,导致最终结果不准。所以插入时一定要去重。

4.3 检索性能优化:位图索引与Roaring Bitmaps

跳表虽然好,但有个问题:内存占用。每个广告ID是一个int(4字节),加上跳表的指针开销,几百万条广告下来,内存轻松上GB。而且取交集时,如果两个链都很大(比如几十万),双指针遍历也很慢。

这时候就该位图索引(Bitmap Index)出场了。位图的思想很简单:每个定向条件对应一个位图,位图的第i位表示第i个广告是否满足这个条件。取交集就是做位运算AND,CPU一条指令就搞定了,快得飞起。

但位图有个致命问题:稀疏性。如果广告总量是1亿,但满足某个条件的只有1000个,那位图里99.99%都是0,浪费内存。怎么办?

我推荐用Roaring Bitmaps。这是目前工业界最流行的压缩位图方案。它的核心思路是:把广告ID按高16位分桶,每个桶内用不同的编码方式。如果桶内数据密集(超过4096个),就用位图;如果稀疏,就用有序数组。这样既节省内存,又保持位运算的高效。

Roaring Bitmaps 性能对比(我实测的数据):

数据结构 内存占用(1000万广告) 交集耗时(两个100万级链)
ArrayList ~80 MB ~50 ms
跳表 ~120 MB ~20 ms
普通位图 ~1.25 MB(但稀疏时浪费) ~1 ms
Roaring Bitmaps ~5 MB(稀疏场景更省) ~2 ms

你看,Roaring Bitmaps在内存和速度上取得了很好的平衡。我现在的项目里,核心的定向条件(性别、年龄、城市)都用Roaring Bitmaps,而一些长尾条件(比如兴趣标签)用跳表。这样既保证了高频查询的速度,又控制了内存。

注意:Roaring Bitmaps不是银弹。如果你的广告ID不是连续递增的(比如有删除操作导致ID空洞),位图的压缩率会下降。我建议定期重建索引,或者用单调递增的ID生成策略。

4.4 总结与个人经验

最后说几句掏心窝的话。倒排索引的设计,没有标准答案。你得根据业务场景来选:

  • 查询量小、条件少:HashMap + 跳表就够了,实现简单,维护成本低。
  • 查询量大、条件多:上Roaring Bitmaps,性能提升明显。
  • 内存敏感:可以考虑用布隆过滤器做预过滤,但会有误判率。

我记得有一次线上事故,就是因为倒排链太长,取交集时内存溢出。后来加了Roaring Bitmaps,问题解决了。所以啊,架构设计一定要考虑极端情况,别等到线上出问题才后悔。

好了,这一章就到这里。下一章我们聊聊广告排序模块,看看怎么在几十毫秒内给广告打分排序。到时候见。