ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

散列表、红黑树、B+树、跳表、布隆过滤器:查询结构选型与实战

散列表、红黑树、B+树、跳表、布隆过滤器:查询结构选型与实战 如果你经常刷技术面试题或者做系统设计评审会发现一个很有意思的现象散列表、红黑树、B树、跳表、布隆过滤器这五个词常常被放在同一份大纲里反复拷问。一开始我也以为这只是面试官凑知识点后来自己真正去搞缓存穿透、做数据库索引优化、写排行榜服务之后才反应过来——这五个结构根本就是同一道大题的五个分支它们各自守着“查询”这条主线上的一类特定需求。把这五者连起来理解比单独背任何一方的定义都有用得多。这篇文章我不想从教科书定义开始铺陈而是想用实际选型和踩坑的视角把这几个结构“为什么长这样”“什么时候用它”“哪里容易翻车”讲清楚。适合的人群是准备面试但被概念绕晕的同学、工作中要选型但不知道从哪下手的小伙伴以及纯粹想把数据结构的底层逻辑吃透的读者。读完你会得到一套自己的判断框架而不是零散的知识点。1. 这五张牌放在一起不是凑数是围绕“查询”的取舍展开先说一个最容易忽略的视角散列表、红黑树、B树、跳表、布隆过滤器虽然名字差别很大但它们服务的都是同一件事——你给一个数据集合我要快速回答“某个东西在不在”“它排第几”“我要一段区间”。区别只在于你愿意为这个回答付出多少时间、多少内存以及能不能忍受“偶尔说错”。散列表解决的是精确命中问题。它用哈希函数把任意 key 映射到数组下标平均 O(1) 查到结果。这是查询性能的天花板几乎无法被超越。但它的短板也非常明显无序没法做范围查询扩容时会瞬间卡顿哈希碰撞多了以后性能会劣化。红黑树和 B树、跳表解决的是有序查询问题。红黑树适合在内存里维护一个动态有序集合插入删除不破坏平衡B树把“有序”和“磁盘 IO 友好”结合到一起是数据库索引的默认答案跳表则用概率代替旋转实现在内存里好写、好调、并发友好的有序结构。布隆过滤器更特殊它放弃了精确性。它回答“可能存在或一定不存在”用几个比特位就能描述海量集合。它的存在意义是挡在更贵的查询前面比如缓存穿透场景里先用布隆过滤器拦掉大部分不存在的 key避免请求打到数据库上。这五者放在一张表里看会更直白结构查询类型平均复杂度有序性核心代价经典场景散列表精确点查O(1)无序空间换时间扩容抖动缓存、字典、去重红黑树点查 范围O(log n)有序旋转变色实现复杂TreeMap、std::mapB树点查 范围O(log n)有序节点越大越是磁盘友好MySQL InnoDB 索引跳表点查 范围O(log n)有序随机层高期望空间 O(n)Redis Sorted Set布隆过滤器存在性判断O(k)无序有误判率通常不可删缓存穿透、URL 去重所以我的建议是别把这五个结构当成五个孤立考点而是当成一条选型链。你手里有一份数据先问“要不要有序”再问“要不要精确”再问“数据量多大、落在内存还是磁盘”答案自然会指向其中某个结构。后面每一节我都会按这个逻辑拆开讲。2. 散列表负载因子、冲突策略与扩容才是哈希表性能的分水岭2.1 哈希函数解决的是“把无限映射到有限”冲突不是极端是必然很多人有个错觉以为好的哈希函数应当完全避免冲突。这不可能。散列表的底层是有限长度的数组而 key 的空间几乎是无限的把无限映射到有限必然存在多个 key 落到同一个下标的情况。真正的哈希设计不是消灭冲突而是让冲突尽量均匀、可控。这里就要引入负载因子的概念。负载因子 已存储元素个数 / 桶数组长度。负载因子越低冲突概率越低但内存浪费越大越高内存越省但冲突会迅速拖慢查询。JDK 的 HashMap 默认负载因子是 0.75这个值不是数学上算出来的唯一最优解而是空间和时间之间一个被长期工程验证过的折中。实际项目里如果你能精确预估 key 数量完全可以自己调。我做过一个登录态存储服务key 量固定 100 万左右直接把初始容量设为 200 万负载因子保持 0.5 以下写入和查询延迟都很稳几乎不触发扩容。还有一点容易被忽略哈希函数的质量比哈希表本身的结构更影响性能。取模时如果能保证哈希值均匀分布链表的长度会比较平均如果哈希函数有严重的规律性比如低 10 位总是相同那么无论底层是链表还是树都会有一批节点堆积在同一位置。这也是为什么很多竞赛模板里模拟散列表时会强调取一个较大的质数prime number——质数能降低哈希值公因数对桶下标分布的影响。2.2 冲突处理的三条路线链表法、开放寻址、树化冲突解决最常见的是拉链法chaining也就是每个桶后面挂一个链表。查找时先定位到桶再沿着链表线性比较 key。这个方法简单直观删除也容易缺点是链表过长时性能退化到 O(n)。所以 JDK 8 以后的 HashMap 做了一个重要优化当链表长度超过阈值 8 时把链表转换成红黑树。注意这个阈值不是随意定的。在理想随机哈希下负载因子 0.75 时桶内链表长度达到 8 的概率约为千万分之六也就是说正常情况下根本不该触发树化。如果你的程序里频繁出现链表转树大概率不是运气不好而是哈希函数存在缺陷或者 key 分布严重不均匀。第二条路线是开放寻址open addressing冲突发生时顺着数组往后探测空闲位置。经典的是线性探测也就是“位置被占了我就往后走一格”。它的优势是不需要额外指针缓存局部性好内存紧凑劣势是负载因子一大就容易出现聚集clustering导致探测链越来越长。因此开放寻址表的负载因子通常控制在 0.5 到 0.6 以下否则性能会雪崩。删除时也不能直接置空得用“墓碑标记”否则会切断后续 key 的探测链。比如 acwing 算法模板里的模拟散列表就是用一个较大质数作为数组长度配合开放寻址或拉链法来演示最朴素的实现。工程级代码当然不会这么简单但它把核心原理暴露得很干净。2.3 为什么负载因子 0.75 这么流行扩容为什么不是灾难再说说扩容。散列表的容量不能无限增长负载因子到了阈值就要 resize。扩容的第一步是申请更大的数组第二步是把旧桶里的每一个元素重新计算哈希并放入新桶。这一步的时间复杂度是 O(n)所以哈希表单次插入的最坏代价其实很吓人。但“均摊”策略救了它。如果我们维持负载因子不超过一个常数那么扩容次数大约只有 O(log n) 次每次扩容的代价又被接下来一大轮插入摊薄。因此即使单次扩容很慢平均到每一次插入上依然是 O(1)。实际工程里还是要小心很多线上故障不是平均性能不够而是“峰值抖动”。HashMap 达到扩容阈值那一刻大量请求恰好涌进来可能带来几百毫秒的卡顿。我处理过的一个业务就踩过这个坑内存缓存里的字典表每天重建一次但构建过程中不加初始容量导致最后扩容一次把本来 30 毫秒的构建拖到 200 多毫秒。后来改成预估容量直接初始化问题消失。3. 红黑树它不是平衡树里的优等生而是“够用平衡”与“可控代价”的妥协3.1 五条性质到底在说什么红黑树为什么能在那么多有序容器里胜出核心原因是它的“平衡”是一种弱平衡它不要求左右子树高度差不超过 1而是通过颜色约束保证从根到叶子的任意路径中最长路径不超过最短路径的两倍。这个约束来自它的五条性质节点非红即黑根节点是黑色叶子节点视为黑色 NIL红色节点的子节点必须是黑色从任一节点到其每个叶子的路径上黑色节点数量相同。这五条性质组合起来的效果是最短路径是全黑路径最长路径是红黑交替路径而每条路径黑色节点数量一样多所以最长路径至多是最短路径的两倍。两倍的高度差听起来不如 AVL 树“左右高差不超过 1”那么优美但它把树高限制在了 O(log n) 的常数倍数里对复杂度分析来说完全够用。这种“够用平衡”换来的是维护成本的下降。AVL 树对高度差极其敏感删除时可能一路回溯旋转到根红黑树则可以通过局部变色和少量旋转把调整控制在常数级别。说白了红黑树是典型的“用稍高的树高换更低的插入删除代价”而大多数业务场景里读多写多都有这种折中很划算。3.2 为什么 Java、C 的 map 最终都选了红黑树而不是 AVLJava 的 TreeMap、TreeSetC 的 std::map、std::multimap底层默认实现都是红黑树。这几乎是整个工业界的共识。为什么会这样看两个指标就明白了。第一个指标是插入删除的旋转次数。AVL 树插入时最多需要两次旋转可以恢复平衡但删除时最坏可能要沿着路径不断旋转代价是 O(log n)。红黑树插入时最多两次旋转删除时最多三次旋转其余靠变色搞定。对于真实业务负载——大量随机写入、持续的内存操作——这个差异会被放大。第二个指标是实现复杂度。AVL 树的“高度差不超过 1”这一判断条件在删除时很烦因为删完之后不仅要重新算高度还要判断四种旋转时机红黑树的变色规则虽然也多但一旦形成了固定的修复模板反而更机械、更容易写对。我自己用 C 写平衡树的经历也印证了这一点。早年为了让排行榜更“平衡”选过 AVL结果调试删除逻辑的时候差点崩溃。后来换成红黑树规则虽然看着多但插入删除的修复流程非常固定直接把经典的“叔红变色、叔黑旋转”套路背下来就能写稳。工程上选型别跟“更平衡”较劲要跟“维护成本”和解。3.3 插入删除的核心修复逻辑变色、旋转与“双黑”红黑树的插入相对好理解。新插入的节点默认染红因为红色不会破坏“每条路径黑节点数相同”的性质唯一可能触犯的是“红节点不能有红孩子”。修复时看叔叔节点的颜色如果叔叔是红色就把父节点和叔叔一起变黑、祖父变红然后把问题向上推一层如果叔叔是黑色就围绕祖父做一次左旋或右旋再配合变色。整个过程最多向上递归两层旋转次数有限。删除才是真正劝退新手的地方。如果删掉的是一个红色节点直接删不影响黑高如果删掉的是黑色节点就会导致某条路径黑色节点数少一出现所谓的“双黑”double black状态。修复的核心思路是想办法从兄弟子树借一个黑色节点过来。兄弟是红色就先旋转把兄弟变成黑色然后看兄弟的侄子们侄子们有红色就旋转并变黑侄子们全黑就把兄弟变红把双黑问题向上传递。这听起来复杂但它在工程上的实际效果是删除操作最多三次旋转就能结束剩下的都是变色。如果你只是使用者而不是实现者其实不需要手写这些逻辑。但理解了“变色承担大量调整工作、旋转只负责拓扑结构改变”这一点你就明白红黑树为什么能在动态插入删除非常频繁的系统里站住脚。3.4 红黑树不是万能的有序容器什么时候别用它很多同学学完红黑树容易进入“什么有序需求都用 TreeMap”的误区。红黑树确实强大但它有几个天然短板。第一它是一棵二叉树节点里要存颜色、左右孩子指针、父指针内存开销比紧凑的数组结构大不少。第二它在磁盘场景下并不友好——二叉树的每一个节点可能散落在不同的页里随机 I/O 太频繁。第三并发场景下红黑树的旋转和变色涉及多个节点的指针修改锁粒度很难细化不像跳表那样容易做无锁化。所以现在你能理解为什么数据库索引不直接用红黑树。红黑树在内存里维护动态有序集合非常优秀一旦数据量大到要落盘主角就得换成 B树。下一节我们就聊这个话题。4. B树三层高度就能压住千万行这是数据库用它的真正原因4.1 磁盘最贵的是随机 IO树的高度因此成了关键为什么数据库索引不用红黑树也不用哈希表先看红黑树的问题。一棵红黑树如果要存一千万个 key树高大约是 24 层。每个节点的子节点在磁盘上极可能不连续查询时每深入一层就是一次磁盘随机 IO。24 次随机 IO每次按 10 毫秒算那就是 240 毫秒——这还只是一个查询。要知道内存访问是按纳秒计的磁盘随机访问是按毫秒计的差了整整几个数量级。任何索引结构只要让“树高 × 单节点 IO 代价”变大就不适合数据库。哈希表的问题更直接。它虽然 O(1)但无序做不了范围查询而且扩容时要把所有数据重新散列到新数组对数据库这种动辄几百 GB 的体量来说完全不可接受。于是数据库的诉求变成了把树高度压缩尽量低同时让每个节点尽量装满数据一次 IO 拉回一整个页典型 16KB在这个页内做快速二分查找。这就是 B树登场的理由。4.2 B树和 B 树差在“叶子才是真正的数据”范围扫描让 B树胜出B树的核心理念是“矮胖”。每个内部节点不存数据只存 key 和指向子节点的指针因此一个节点能容纳的子节点数量也就是扇出fan-out非常大。假设主键是 8 字节指针是 6 字节一个 16KB 的页大约能存上千个这样的 KV 对而 B 树不是 B树因为内部节点也要存数据扇出会小得多相同数据量下树会变得更高。真正的胜负手在叶子节点。B树的所有数据都存放在叶子节点并且叶子节点之间会用链表串起来。范围查询比如 “SELECT * FROM t WHERE id BETWEEN 100 AND 200”B树只要找到 100 所在的叶子然后沿着叶子链表一路往后扫就行。B 树则不同内部节点也可能直接命中数据范围查询时往往要在多个层级的节点之间来回跳实现复杂且 IO 更多。这还没算上 B树因为内部节点更小缓存命中率更高这个隐性优势。所以主流关系型数据库的索引几乎都选择了 B树。4.3 算一笔账一个三层 B树到底能存多少行这是最直观的数学证明。假设 MySQL InnoDB 的页大小是 16KB一个主键是 BIGINT8 字节页内指针约 6 字节为了简化先忽略每条记录的额外开销。一个叶子页能存多少行取决于行大小我们先看内部节点。一个内部节点能存的索引条目数约为 16KB / (8 6) ≈ 1170。如果树高是 3也就是根节点 中间层 叶子层那么这个三层树最多可以有 1170 × 1170 ≈ 137 万个叶子页。每个叶子页如果按 16KB 算行大小为 1KB大约能放 16 行。总行数就是 137 万 × 16 ≈ 2192 万行。如果一行只有 500 字节这个数字能到 4000 万量级。换句话说一个两千万行级别的表走主键索引查询通常只需要三次磁盘 IO读根节点页、读中间层页、读叶子页。三次 IO 对任何数据库来说都是可以接受的。这里要记住的结论是B树的树高不会随着数据量线性增长而是按“底数 扇出”的对数增长。扇出一旦上千树高超过三层都少见四层已经是海量数据了。4.4 回到 MySQL 的 InnoDB聚簇索引与顺序主键以及 B树的坑InnoDB 里主键索引本身就是聚簇索引叶子节点直接存整行数据。二级索引的叶子节点存的是主键值所以“回表”就是拿着主键再去聚簇索引里查一次。理解了这个结构你就能解释很多数据库设计规则为什么建议用自增主键因为 B树的叶子节点按主键顺序排列自增主键的插入总是追加在最后不容易触发页分裂如果主键是 UUID 这类随机值插入时可能要频繁分裂叶子页、调整索引写性能明显劣化。为什么不要在大字段上建太多索引因为二级索引的叶子也占页索引越多B树占的空间越大写入时的维护成本也越高。为什么范围查询走索引比较高效因为叶子链表天然有序连续扫描不需要回根节点重新走。B树也并不是完美无缺。它的写入在高并发下会成为明显的瓶颈点特别是页分裂时需要对页加锁。这也是为什么很多 NoSQL 系统会选择 LSM-Tree 而不是 B树的原因之一。但如果你选型的是传统关系型数据库B树目前依然是索引方案里最稳的选择。5. 跳表把平衡树的旋转换成抛硬币Redis 偏爱它的理由不止“简单”5.1 跳表如何用概率保证查找复杂度跳表的结构第一次见到时会觉得有点“不正经”它是一个普通有序链表但每个节点有概率被提升到更高层高层链表相当于底层链表的“快速通道”。查找时从最高层的头节点出发每一步都往右看如果下一个节点的 key 仍然小于目标就继续向右如果大于目标就下降一层。反复横跳直到底层找到目标或者判定不存在。它如何保证复杂度关键在“概率提升”的设计。每个节点独立地以概率 p通常取 1/2 或 1/4决定是否复制到上一层所以一个节点的期望层数是 1 p p² ... 1/(1-p)常见取 p1/2 时期望层数是 2也就是所有节点的指针总数期望为 2n空间是 O(n)。而从最高层降到合适的层每降一层大约能筛掉一批元素目标节点的查找路径期望长度是 O(log n)。简单说跳表是用“额外的指针层”换来了“更短的查找路径”并且整个过程不需要任何旋转、变色、平衡因子维护。我第一次从红黑树切到跳表时最大的感受是终于不用跟一堆旋转分支斗智斗勇了。插入一个节点先用随机数算出它的层高然后从顶层开始找插入位置逐层修改前驱和后继的指针即可。删除也一样只要把各层的指针摘掉。这种“每一层都是普通链表操作”的感觉非常舒服调试成本低到令人感动。5.2 Redis 有序集合的典型选型逻辑Redis 的 ZSET有序集合底层是跳表 散列表的组合。跳表负责按分数排序和范围查找散列表负责按 member 快速定位分数。为什么 Redis 的作者不直接用红黑树官方给过一些原因跳表实现简单、容易调试在并发环境下更容易通过锁分段来做并发控制而且性能方面内存里的跳表查找常数并不比红黑树差多少。还有一点经常被忽略跳表对范围查询非常友好。ZRANGEBYSCORE 这样的操作只需要先定位到区间的左端点然后沿底层链表一路向后取元素就行了。红黑树虽然也能做范围查询但需要中序遍历并且维护后继指针工程实现复杂。Redis 是个追求简洁的系统跳表这种“把有序性摊在链表上”的设计特别契合它的气质。Redis 在实际实现里还做了两个调整。一是把概率 p 从常见的 0.5 调成了 0.25这样高层节点更稀疏内存占用更低虽然单次查找会多比较几次但 Redis 的场景里这个取舍划算。二是限制了最大层数为 32防止极端随机把层高推得过分夸张。这些细节都不复杂但它们直接决定了跳表在真实负载下的表现。5.3 跳表和红黑树在实际工程里的替换关系与注意点如果让我给一个选型结论我会说在纯内存、写多、并发要求高的场景跳表优先在内存极其紧张、读多写少、需要严格最坏情况保证的场景红黑树优先。跳表的最坏情况并不是严格的对数时间虽然概率上极难发生但如果你在做的是金融交易这种不允许任何极端慢查询的系统红黑树或者 AVL 更稳。跳表还有一个容易被忽略的坑随机种子和层高分布。如果你自己实现跳表随机数生成器质量差层高可能出现偏斜导致实际退化。我见过一个同学用rand() % 10模拟概率结果因为随机数周期太短在数据量上来时高层节点分布明显不均查询性能出现了肉眼可见的掉速。解决方法是使用质量更高的伪随机或者直接用上一层节点数的对数关系推导层高。另外删除节点时不要忘了自顶向下维护每一层的前驱指针很多实现 Bug 都出在“只删了底层节点高层还留着残留指针”上。6. 布隆过滤器位数组加多个哈希的“宁错勿漏”参数算不好就是白用6.1 为什么只用位数组和哈希函数就能判断存在性布隆过滤器的结构简单到让人难以置信一个 m 位的位数组初始全部为 0k 个相互独立的哈希函数。插入一个元素时用 k 个哈希函数分别算出 k 个位下标全部置 1。查询一个元素时同样算出 k 个下标检查这些位是否都为 1。只要出现任何一个位是 0元素一定不存在如果全部是 1只能说“可能存在”。漏判不可能。误判一定有。因为不同元素的哈希结果可能在同一组位上重叠。你可以把布隆过滤器理解成一个“非常节省内存的集合摘要”它从不撒谎说不存在却可能在“存在”上误报。这种性质恰好命中了一类关键场景在代价昂贵的真正查询之前先排除掉绝大多数不存在的可能。举个我实际做过的例子。我们有一个短链接服务用户访问时先查缓存再查数据库然后发现大量不存在的短码请求在打数据库。这些非法请求根本没有对应的行却让数据库白忙一场。引入布隆过滤器后服务启动时把所有合法短码灌进去每次请求先问过滤器如果返回“一定不存在”直接 404连缓存都不查。数据库压力立刻降了一个数量级。这就是布隆过滤器最典型的用法——“挡掉那些注定查不到的东西”。6.2 误判率公式与参数选择别拍脑袋布隆过滤器的误判率并不是一个固定值它跟三个参数有关元素数量 n、位数组长度 m、哈希函数个数 k。如果位数组太短位会被很快占满误判率飙升如果哈希函数太多每个元素要置很多位位数组很快变满如果哈希函数太少每个元素的“指纹”太弱碰撞概率上升。存在一个最优的 kk_opt (m / n) * ln 2 ≈ 0.7 * (m / n)给定期望的误判率 p 和元素量 n需要的最低位数组长度约为m ≈ - n * ln(p) / (ln 2)²这两个公式非常实用。比如我想支持 1000 万个元素接受 1% 的误判率那么 n10^7p0.01ln(p)≈-4.605算下来 m ≈ 10^7 * 4.605 / 0.4805 ≈ 9580 万 bit也就是约 11.4 MB。很多人第一次算出来会震惊1000 万个元素只用了 11 MB换成 HashMap 存 String 至少要几百 MB。而哈希函数个数 k ≈ 0.7 * (9580万 / 1000万) ≈ 6.7取 7。这些数字背后是数学不是拍脑袋。我见过线上布隆过滤器误判率高达 20% 以上的案例一查原因就是位数组长度按“感觉”设的根本没算过。正确的做法是先按公式算再用真实数据集压测最后根据线上误判率微调参数。要特别注意的是误判率会随着位数组被写满而动态变化所以在容量估算时最好留 30% 的余量否则数据量超过预期后误判曲线会突然变陡。6.3 不能删除怎么办Counting Bloom Filter 与衍生方案普通布隆过滤器最大的缺陷是不能删除元素。原因很简单把某个位置 0可能同时破坏了其他元素的位。实际业务里如果想删除有几种路线。一种是重建定期重新灌入当前合法集合这在数据变化不频繁、量不太大时最省事。另一种是 Counting Bloom Filter把每一位扩展成一个计数器删除时对计数器减一减到 0 才真正清掉。听起来美好但计数器本身要占用几倍内存而且多个元素共享同一计数器时减到 0 依然会导致误删判断。还有更新的布谷鸟过滤器、SBFScalable Bloom Filter用更复杂的结构来支持删除和动态扩容。我的建议是能不用删除就别用。如果必须删除优先考虑“定期重建”而不是 Counting Bloom Filter。因为计数器方案在分布式场景下维护成本太高重建方案反而简单可控。我做过的一个用户标识过滤服务就是每天凌晨重建一次布隆过滤器重建期间用旧的过滤器继续服务等新的构建完成再原子切换。整个过程对调用方完全透明线上没有任何一段代码要处理“删除”。6.4 缓存穿透实战布隆过滤器放在哪一层误判如何监控回到缓存穿透这个经典场景。正常流程是请求 → 查缓存 → 未命中 → 查数据库。布隆过滤器介入后变成请求 → 查布隆过滤器一定不存在则直接拒绝→ 查缓存 → 未命中 → 查数据库。注意布隆过滤器要放在缓存之前否则它失去“挡住大量无效请求”的意义。另外合法的 key 要预先一次性灌入而不能在每次请求命中时动态添加否则第一次访问某个合法 key 时可能被过滤器误判为不存在造成“合法请求被拒”的假阴性雪崩。线上还要监控两个指标布隆过滤器的实际误判率以及被它拦掉的请求占比。前者可以用“过滤器判定存在但实际不存在”的样本数除以“过滤器判定存在”的总样本数来估算偶尔抽检即可。后者则直接反映过滤器到底帮你挡掉了多少无效流量。如果拦掉的比例很低说明非法请求本来就少也许这个过滤器根本没必要引入如果拦掉比例很高就要确认自己加的 key 集合是否完整别把合法数据漏在过滤器外面。7. 综合选型三种典型场景下这些结构怎么打配合7.1 场景一订单流水范围查询与唯一性判断假设一个订单系统的数据库里有一张订单表主键是自增 id查询需求包括“查某个 id 的订单详情”和“查某段时间内的订单列表”。数据库层面直接用主键聚簇索引——B树这个设计已经被证明非常高效。但如果你在应用层还要做一个内存缓存来扛峰值流量可以用散列表按订单 id 缓存详情再配合一个 Timer 定时清理过期缓存。这个场景里B树负责持久化和范围扫描散列表负责热点点查两个结构天然分工。如果你还要快速判断某个订单号是否在“最近 7 天”内存在不想每次都回数据库可以给订单号建一个布隆过滤器用定时任务每 5 分钟刷新一次。过滤器说“不存在”就直接返回说“可能存在”再走缓存或者数据库。这样点查、范围查、存在性判断三类需求都有了对应结构。7.2 场景二实时排行榜与动态榜单排行榜的真实需求一般是按分数排序、按用户查排名、取前 N 名、分数变更后快速更新。这种场景用 Redis ZSET 是最省事的而 ZSET 底层正是“散列表 跳表”。散列表负责按 user_id 直接 O(1) 拿到当前分数跳表负责维护按分数排序的整体顺序范围取 TopN 就走跳表的高层快速通道。假设你非要用自研结构跳表也是最合适的选择分数变更时删除再插入跳表的层高结构不要求全局重平衡比红黑树的旋转要直观很多。这里要提醒一点排行榜数据的读量远大于写量所以很多人都觉得用红黑树也行。确实可以但你要承担的是更复杂的实现和更细的锁粒度。如果团队里没有对红黑树烂熟于心的人我强烈建议用跳表因为后续维护、扩展、排查问题都会省力不少。7.3 场景三高并发缓存背后的存在性拦截这是把「散列表 布隆过滤器 B树」组合得最经典的地方。流量入口先过布隆过滤器滤掉绝大概率不存在的 key再把通过检查的请求打到一个散列表实现的本地缓存没有命中缓存的再去查数据库数据库走 B树主键索引。每一层都在用自己最强的点布隆过滤器用极小内存挡住绝大多数无效请求散列表用 O(1) 扛住热点读取B树在磁盘上做最终裁决。这个架构里最容易出的问题就是布隆过滤器的参数没算好或者合法 key 集合没有定期更新导致误判率上升、开始挡住合法请求。我习惯的做法是给不同维度的 key 建多个小型过滤器而不是一个超大的过滤器。比如按用户 id 和按商品 id 分开建各自调各自的参数一旦某一个出了问题影响面可控不会互相污染。7.4 你的选型心智模型这五者在我心里的排序逻辑是这样的先问数据量和存储介质。数据落磁盘直接选 B树别考虑别的数据在内存进入第二步。再问是否需要有序。不需要有序优先散列表需要有序进入第三步。再问并发和实现复杂度。团队能啃红黑树读多写少选红黑树追求易维护、范围查询多选跳表。最后如果内存非常紧张、只关心“在不在”、能容忍误判就用布隆过滤器。这四层筛选走下来绝大多数业务场景都能立刻确定主结构。至于散列表和红黑树的组合也没什么稀罕JDK 8 的 HashMap 在链表过长时会转红黑树Redis 的 ZSET 同时用散列表和跳表。现实世界里的系统从来不会只用一种结构而是让每种结构负责它最擅长的那个环节再层层叠加成一条完整的查询链路。理解到这一层再看“散列表、红黑树、B树、跳表、布隆过滤器”这五个词它们就不再是名词解释题而是一整套可以用来解决实际问题的工具箱。我在实际项目里反复体会最深的一点是初学者往往纠结“背下复杂度结论”老手则更关注“这个结构在什么条件下会失效”。散列表在负载因子失控会退化红黑树在磁盘上会绝望B树在乱序主键前也会分裂成灾跳表会受到随机数质量的影响布隆过滤器参数拍脑袋就会全线误判。先把每个结构的边界摸清楚再谈组合作战比单纯刷十道面试题有用得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进