从千万级数据查询优化说起:红黑树调整和位图索引的实战应用
去年我们接手了一个数据分析系统,日增数据量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 |
问题很明显:
- 低基数字段(如is_vip、gender)用B+树效率极低
- 组合查询需要多个索引merge,性能差
- 内存中的热点数据访问存在性能瓶颈
红黑树:内存索引的不二选择
为什么选择红黑树?
我们对比了几种平衡树:
数据结构 | 查找复杂度 | 插入复杂度 | 删除复杂度 | 平衡要求 | 旋转次数 | 实现难度 |
---|---|---|---|---|---|---|
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) | 概率平衡 | 无旋转 | ★★☆☆☆ |
红黑树在插入删除频繁的场景下,调整次数少,综合性能最好。
红黑树的五个性质
实现前先复习下红黑树的性质:
- 每个节点要么是红色,要么是黑色
- 根节点是黑色
- 所有叶子节点(NIL)是黑色
- 红色节点的两个子节点都是黑色
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点
插入调整的血泪史
红黑树插入后的调整是最复杂的部分。我们碰到的情况:
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'
执行过程:
- 获取city='上海’的位图 B1
- 获取is_vip=1的位图 B2
- 获取gender='M’的位图 B3
- 计算 B1 & B2 & B3
- 根据结果位图获取数据
性能对比:
查询条件数 | 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%)
踩坑经验总结
红黑树的坑
-
NIL节点的处理
// 错误:忘记处理NIL if (node->left->color == RED) { } // 可能崩溃 // 正确:先判断是否为NIL if (node->left != NIL && node->left->color == RED) { }
-
旋转后的父指针更新
- 左旋右旋都要更新父指针
- 根节点变化要特殊处理
-
并发访问问题
- 红黑树的调整操作不是原子的
- 需要细粒度锁或无锁设计
位图索引的坑
-
更新开销大
操作 B+树索引 位图索引 说明 插入一行 O(logn) O(k) k是基数 更新一行 O(logn) O(2k) 需更新新旧值 删除一行 O(logn) O(k) 所有位图都要改 -
基数变化问题
- 字段基数增长会导致性能下降
- 需要监控并及时调整策略
-
内存占用
- 未压缩的位图非常占内存
- 压缩解压需要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不断。最后还是选择了成熟稳定的方案。
记住:过早优化是万恶之源,但完全不优化是慢性自杀。在数据量达到一定规模时,选择正确的数据结构和算法,往往能带来数量级的性能提升。
如果你也在做大数据查询优化,欢迎交流!索引优化这条路很深,一起探索总比单打独斗强。
- 点赞
- 收藏
- 关注作者
评论(0)