从千万级数据查询优化说起:红黑树调整和位图索引的实战应用

举报
8181暴风雪 发表于 2025/07/26 18:55:37 2025/07/26
【摘要】 去年我们接手了一个数据分析系统,日增数据量500万条,总数据量已经突破10亿。最要命的是,运营部门的查询需求五花八门:“查询所有上海地区、年龄25-35岁、最近7天活跃的VIP用户”,这种查询每天要跑上千次。原系统用的是普通B+树索引,复杂查询经常要扫描上千万行数据,一个查询跑十几分钟是家常便饭。经过3个月的优化,我们用红黑树优化了内存索引结构,用位图索引处理低基数字段,查询性能提升了50...

去年我们接手了一个数据分析系统,日增数据量500万条,总数据量已经突破10亿。最要命的是,运营部门的查询需求五花八门:“查询所有上海地区、年龄25-35岁、最近7天活跃的VIP用户”,这种查询每天要跑上千次。

原系统用的是普通B+树索引,复杂查询经常要扫描上千万行数据,一个查询跑十几分钟是家常便饭。经过3个月的优化,我们用红黑树优化了内存索引结构,用位图索引处理低基数字段,查询性能提升了50倍。今天就来聊聊这段"与索引死磕"的经历。

问题暴露:B+树索引的局限性

先看看原系统的索引情况:

字段名 数据类型 基数 原索引类型 查询频率 平均查询时间
user_id BIGINT 2亿 B+树(主键) 极高 0.01ms
city VARCHAR 380 B+树 230ms
age_group TINYINT 8 B+树 580ms
is_vip BOOLEAN 2 B+树 1200ms
gender CHAR(1) 2 B+树 890ms
status TINYINT 5 B+树 450ms

问题很明显:

  1. 低基数字段(如is_vip、gender)用B+树效率极低
  2. 组合查询需要多个索引merge,性能差
  3. 内存中的热点数据访问存在性能瓶颈

红黑树:内存索引的不二选择

为什么选择红黑树?

我们对比了几种平衡树:

数据结构 查找复杂度 插入复杂度 删除复杂度 平衡要求 旋转次数 实现难度
AVL树 O(logn) O(logn) O(logn) 严格平衡 最多2次 ★★★★☆
红黑树 O(logn) O(logn) O(logn) 近似平衡 最多3次 ★★★☆☆
B树 O(logn) O(logn) O(logn) 多路平衡 无旋转 ★★★★☆
跳表 O(logn) O(logn) O(logn) 概率平衡 无旋转 ★★☆☆☆

红黑树在插入删除频繁的场景下,调整次数少,综合性能最好。

红黑树的五个性质

实现前先复习下红黑树的性质:

  1. 每个节点要么是红色,要么是黑色
  2. 根节点是黑色
  3. 所有叶子节点(NIL)是黑色
  4. 红色节点的两个子节点都是黑色
  5. 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点

插入调整的血泪史

红黑树插入后的调整是最复杂的部分。我们碰到的情况:

enum class InsertCase {
    ROOT,           // 情况1:新节点是根
    BLACK_PARENT,   // 情况2:父节点是黑色
    RED_UNCLE,      // 情况3:叔叔是红色
    RED_PARENT_LEFT_CHILD,   // 情况4:父红叔黑,当前是左孩子
    RED_PARENT_RIGHT_CHILD   // 情况5:父红叔黑,当前是右孩子
};

void fixInsert(Node* node) {
    while (node != root && node->parent->color == RED) {
        if (node->parent == node->parent->parent->left) {
            Node* uncle = node->parent->parent->right;
            
            if (uncle && uncle->color == RED) {
                // 情况3:叔叔是红色,变色即可
                node->parent->color = BLACK;
                uncle->color = BLACK;
                node->parent->parent->color = RED;
                node = node->parent->parent;
            } else {
                if (node == node->parent->right) {
                    // 情况5:先左旋转换成情况4
                    node = node->parent;
                    leftRotate(node);
                }
                // 情况4:变色+右旋
                node->parent->color = BLACK;
                node->parent->parent->color = RED;
                rightRotate(node->parent->parent);
            }
        } else {
            // 镜像情况,左右互换
            // ...
        }
    }
    root->color = BLACK;
}

实际调整的统计数据:

插入情况 出现频率 需要旋转 需要变色 平均调整次数
情况1-2 45% 0
情况3 30% 1.5
情况4 15% 1次 2
情况5 10% 2次 3

删除调整:比插入还要复杂

删除操作的调整更加复杂,我们遇到了6种不同的情况:

删除情况 描述 处理方式 复杂度 出现频率
删除红色节点 直接删除 无需调整 O(1) 40%
兄弟是红色 情况1 旋转+变色 O(1) 10%
兄弟黑且子节点全黑 情况2 变色+递归 O(logn) 20%
兄弟黑远侄红 情况3 旋转+变色 O(1) 15%
兄弟黑近侄红远侄黑 情况4 双旋转+变色 O(1) 10%
其他复杂情况 情况5-6 组合处理 O(1) 5%

性能测试对比

在内存索引中使用红黑树后的效果:

操作类型 数据量 B+树(ms) AVL树(ms) 红黑树(ms) 性能提升
随机插入 100万 850 920 680 20%
顺序插入 100万 450 2100 520 AVL退化严重
随机查找 100万 0.012 0.010 0.011 相当
随机删除 100万 780 890 620 21%
混合操作 100万 1820 2450 1350 26%

位图索引:低基数字段的救星

什么时候用位图索引?

通过分析查询日志,我们发现很多查询都是针对低基数字段:

字段 基数 查询示例 传统索引效果 适合位图
is_vip 2 WHERE is_vip=1 扫描50%数据
gender 2 WHERE gender=‘M’ 扫描50%数据
age_group 8 WHERE age_group IN (3,4,5) 扫描37.5%数据
city 380 WHERE city=‘上海’ 扫描0.26%数据
user_id 2亿 WHERE user_id=12345 直接定位

结论:基数小于1000的字段都可以考虑位图索引。

位图索引的实现

核心思想是用bit位表示某行是否满足条件:

class BitmapIndex {
private:
    struct Bitmap {
        vector<uint64_t> bits;  // 每个uint64_t存储64位
        size_t size;
        
        void set(size_t pos) {
            size_t idx = pos / 64;
            size_t offset = pos % 64;
            bits[idx] |= (1ULL << offset);
        }
        
        bool test(size_t pos) const {
            size_t idx = pos / 64;
            size_t offset = pos % 64;
            return bits[idx] & (1ULL << offset);
        }
        
        // 位运算实现集合操作
        Bitmap operator&(const Bitmap& other) const {
            Bitmap result;
            for (size_t i = 0; i < bits.size(); i++) {
                result.bits[i] = bits[i] & other.bits[i];
            }
            return result;
        }
    };
    
    // 每个取值对应一个位图
    unordered_map<string, Bitmap> value_bitmaps;
};

位图索引的空间占用

实际空间占用对比:

字段 基数 行数 B+树索引大小 位图索引大小 压缩后大小 空间节省
is_vip 2 1亿 1.2GB 25MB 3MB 99.75%
gender 2 1亿 1.2GB 25MB 2.8MB 99.77%
age_group 8 1亿 1.2GB 100MB 15MB 98.75%
status 5 1亿 1.2GB 62.5MB 10MB 99.17%
province 34 1亿 1.2GB 425MB 80MB 93.33%

位图的压缩算法

原始位图太占空间,我们实现了WAH(Word Aligned Hybrid)压缩:

class CompressedBitmap {
    struct Word {
        uint64_t data;
        bool is_literal;  // true:原始数据 false:行程编码
        
        // 行程编码格式:1位标志 + 1位填充值 + 62位长度
        uint64_t getRunLength() const {
            return data & 0x3FFFFFFFFFFFFFFF;
        }
        
        bool getRunValue() const {
            return (data >> 62) & 1;
        }
    };
    
    vector<Word> words;
};

压缩效果统计:

数据分布 压缩前 压缩后 压缩率 解压速度
均匀随机 12.5MB 11.8MB 5.6% 8GB/s
聚集分布 12.5MB 0.8MB 93.6% 12GB/s
稀疏数据 12.5MB 0.1MB 99.2% 15GB/s
真实数据 12.5MB 1.5MB 88% 10GB/s

位图索引的查询优化

位图最强大的是组合查询:

-- 查询:上海地区的VIP男性用户
SELECT * FROM users 
WHERE city = '上海' 
  AND is_vip = 1 
  AND gender = 'M'

执行过程:

  1. 获取city='上海’的位图 B1
  2. 获取is_vip=1的位图 B2
  3. 获取gender='M’的位图 B3
  4. 计算 B1 & B2 & B3
  5. 根据结果位图获取数据

性能对比:

查询条件数 B+树索引(ms) 位图索引(ms) 性能提升 扫描行数比
1个条件 230 45 5.1x 50% vs 0%
2个条件 580 52 11.2x 25% vs 0%
3个条件 1200 58 20.7x 12.5% vs 0%
4个条件 2100 65 32.3x 6.25% vs 0%

红黑树 + 位图索引的混合方案

整体架构设计

我们最终设计了一个混合索引系统:

索引层级 使用技术 负责内容 数据规模 访问延迟
L1-热点缓存 红黑树 最近1小时热点 10万 <0.01ms
L2-内存索引 红黑树 最近24小时 500万 <0.1ms
L3-位图索引 压缩位图 低基数字段 全量 <100ms
L4-B+树索引 InnoDB 高基数字段 全量 <1000ms

查询路由策略

class QueryRouter {
    QueryPlan route(const Query& query) {
        QueryPlan plan;
        
        // 分析查询条件
        for (const auto& condition : query.conditions) {
            if (isHotData(condition)) {
                plan.addStep(L1_CACHE, condition);
            } else if (isLowCardinality(condition.field)) {
                plan.addStep(L3_BITMAP, condition);
            } else if (isHighSelectivity(condition)) {
                plan.addStep(L4_BTREE, condition);
            } else {
                plan.addStep(L2_MEMORY, condition);
            }
        }
        
        // 优化执行顺序
        plan.optimize();
        return plan;
    }
};

实际查询优化案例

最典型的一个查询优化:

原查询

SELECT user_id, user_name, last_login 
FROM users 
WHERE city = '上海' 
  AND age_group IN (3,4,5)
  AND is_vip = 1
  AND last_login > DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY last_login DESC
LIMIT 1000

执行计划对比

执行步骤 原方案 优化后方案 数据量
1. 过滤city B+树索引扫描 位图索引 1亿→260万
2. 过滤age_group B+树索引合并 位图AND操作 260万→97万
3. 过滤is_vip B+树索引合并 位图AND操作 97万→4.8万
4. 过滤last_login 全表扫描 红黑树范围查询 4.8万→1.2万
5. 排序 文件排序 内存排序 1.2万
6. 限制结果 - - 1000条

性能提升

  • 查询时间:15.3秒 → 0.28秒(提升54倍)
  • 扫描行数:4800万 → 1.2万(减少99.97%)
  • 内存使用:2.3GB → 150MB(减少93%)

踩坑经验总结

红黑树的坑

  1. NIL节点的处理

    // 错误:忘记处理NIL
    if (node->left->color == RED) { } // 可能崩溃
    
    // 正确:先判断是否为NIL
    if (node->left != NIL && node->left->color == RED) { }
    
  2. 旋转后的父指针更新

    • 左旋右旋都要更新父指针
    • 根节点变化要特殊处理
  3. 并发访问问题

    • 红黑树的调整操作不是原子的
    • 需要细粒度锁或无锁设计

位图索引的坑

  1. 更新开销大

    操作 B+树索引 位图索引 说明
    插入一行 O(logn) O(k) k是基数
    更新一行 O(logn) O(2k) 需更新新旧值
    删除一行 O(logn) O(k) 所有位图都要改
  2. 基数变化问题

    • 字段基数增长会导致性能下降
    • 需要监控并及时调整策略
  3. 内存占用

    • 未压缩的位图非常占内存
    • 压缩解压需要CPU开销

优化效果总结

经过3个月的优化,系统性能有了质的飞跃:

指标 优化前 优化后 提升
简单查询响应时间 0.5秒 0.01秒 50x
复杂查询响应时间 15秒 0.3秒 50x
日查询量支持 10万 500万 50x
内存使用 32GB 48GB +50%
CPU使用率 85% 35% -59%
运维成本 3人 1人 -67%

写在最后

这个项目让我深刻体会到,没有万能的索引结构。红黑树适合内存中的动态数据,位图索引适合低基数的分析查询,B+树适合磁盘存储。关键是理解每种数据结构的特点,在合适的场景使用合适的技术。

还有一点感悟:优化永无止境,但要适可而止。我们曾经尝试过更激进的优化,比如自己实现无锁红黑树,结果bug不断。最后还是选择了成熟稳定的方案。

记住:过早优化是万恶之源,但完全不优化是慢性自杀。在数据量达到一定规模时,选择正确的数据结构和算法,往往能带来数量级的性能提升。

如果你也在做大数据查询优化,欢迎交流!索引优化这条路很深,一起探索总比单打独斗强。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。