拿到“9-Decisions Datastructures数据结构:数据结构与数据库的关联及操作”这个标题时,我第一反应是:这不是一个传统的学习Demo,更像一份“决策清单”。9-Decisions,我理解下来就是9个在设计数据库相关系统时绕不开的关键抉择,每个抉择背后都站着一种或几种核心数据结构。我在某公司的一个跨平台系统里实际研究过类似的事——用数据结构思维重新梳理一套查询存储链路,结果发现很多时候我们以为的“数据库优化问题”,拆到底其实是“选错了数据结构”的问题。这篇文章把我自己的拆解过程、踩过的坑、可以直接抄的代码和排查思路全部整理出来,适合正在学数据结构、想搞清楚数据库底层原理、或者准备面试的开发者,根据你自己的进度挑着看就行。
1. 为什么要做这个项目:数据结构与数据库本来就是一回事
1.1 从一道面试题说起:索引为什么用B+树
我经常被问到一道很经典的题:为什么MySQL这类关系型数据库的索引要用B+树,而不是红黑树或者哈希表?很多人的回答停在“B+树矮,磁盘IO少”,但这只答对了一半。真正的原因是:数据库的数据存在磁盘上,磁盘读写的单位是页(通常4KB或16KB),而内存访问的单位是字节。树的高度决定了一次查询要读多少次磁盘页,B+树的一个节点能装很多个key和指针,同样数据量下树高往往只有3层左右,而红黑树的树高会深得多,极端情况下每次查询要多几次磁盘IO,这在单机数据库里是完全不可接受的。
这个认知差异是我做这个项目的起点。我意识到,如果不把数据结构放在“数据到底存在哪里、怎么被读取”这个真实场景里看,很多结论都只是教科书上的背诵。于是我把数据结构重新对照数据库的存储引擎、索引、缓存、事务、查询优化等模块逐一映射,最后沉淀出了9个关键决策点。
1.2 存储引擎的本质:文件之上堆数据结构
数据库在用户眼里是一个个表、一行行记录,但在物理层面,表数据就是文件里的字节流,索引文件也是字节流。存储引擎的职责说白了就是:在文件之上组织合适的结构,让写入、读取、删除、范围扫描都能高效完成。
我把存储引擎理解成“数据结构的工程化封装”。比如:
- 堆表(Heap Table):本质是数组或链表式的追加写,适合全表扫描和点查,但删除和更新会产生碎片。
- B+树聚簇索引:本质是多路搜索树的叶子节点按序存放数据行,适合范围查询和按主键查找。
- 哈希索引:本质是哈希表,点查O(1),但无法高效做范围查询。
- 日志结构合并树(LSM Tree):本质是“内存跳表 + 磁盘有序文件”的合并结构,写入快、擅长处理写多读少的场景。
- 布隆过滤器:本质是位图加多个哈希函数,用来快速判断“某个key一定不存在”,避免无效磁盘IO。
所以“数据结构与数据库的关联”根本不是两条知识线的交叉,而是同一个问题的两面。数据库的每一个复杂功能,拆到最底层都是几个数据结构在配合工作。下面这9个决策,就是我这次拆解得到的主线。
2. 9个关键决策逐一拆解:从数据结构视角重构数据库
2.1 决策1:存储模型,行存还是列存
第一个决策是记录在磁盘上怎么摆放。行存(Row-based)把一整行的所有字段连续放在一起,列存(Column-based)把每列单独连续存放。
行存的优势在于:一次IO可以拿到一条完整记录,非常适合订单类、事务型的“按主键拿一行”操作。它背后的数据结构类比是链表节点里存了一个完整对象,遍历时局部性很好。列存的优势在于:分析型查询经常只读少数几列,列存可以跳过无关列,配合压缩后IO量大幅下降,背后的数据结构类比是“按列拆分的数组”,每列一个连续数组,聚合计算直接在这个数组上做向量化操作。
我自己的建议是:事务型系统默认行存,分析型系统优先列存。不少系统还做成了行列混合存储,本质就是同一个结构里既有行式组织又有列式组织,查询优化器根据语句特征决定走哪套布局。这个决策没有绝对最优,关键是根据访问模式选结构。
实操心得:判断一个表适合行存还是列存,最简单的方法是把业务SQL列出来,看是否反复出现“SELECT *”和“按主键查”,如果是,选行存;如果SQL里全是“SUM某列、GROUP BY某字段”,选列存。
2.2 决策2:索引用B+树还是哈希
第二个决策也是最容易被拿来讨论的:索引结构选型。B+树和哈希表解决的其实是两类不同的问题。
哈希索引做等值查询极快,时间复杂度O(1),但它有两个致命短板:不支持范围查询、不支持按前缀排序扫描。B+树的等值查询是O(log n),性能已经足够好,同时因为叶子节点有序串联,范围查询按序扫描非常自然,还能支持ORDER BY、GROUP BY等操作。
我在模拟项目X里做过一个对比:一张100万行的表,按主键等值查询,哈希索引和B+树索引差距不大,哈希甚至略快;但一旦出现WHERE create_time BETWEEN ... AND ...这类范围查询,哈希索引直接退化成全表扫描,B+树则稳定在一两次树高级别的磁盘IO内完成。所以现实世界的数据库几乎都默认B+树做主索引,哈希只用于自适应哈希索引这类加速点查的辅助结构。
注意:哈希索引并不是没用,内存数据库里哈希索引很常见,因为数据全在内存,不需要担心范围查询的磁盘开销,但磁盘型数据库的默认选择基本都倾向B+树。
2.3 决策3:缓冲池淘汰策略
第三个决策是当内存不够放所有数据页时,该把哪些页留在缓冲池里。数据库缓冲池(Buffer Pool)本质上是一个大缓存,背后最常见的数据结构是LRU双向链表加哈希表:哈希表负责O(1)查找某个页是否在内存,双向链表负责维护访问顺序。
LRU(Least Recently Used)的工程实现是:每次访问一个页就把它移到链表头部,淘汰时从尾部移除。这个思路很适合读多写少的业务,但我在实际项目中踩过一个坑:一次全表扫描会把缓冲池里所有热数据全部挤出,造成缓存污染。后来我参考常见做法,给链表分成新旧两段(类似MySQL的LRU分代策略),新加入的页先放进旧区,只有被再次访问才能晋升到新区,这样全表扫描的大批量页就不会冲掉真正的热数据。
另一个容易被忽略的细节是:淘汰一个脏页前要先写回磁盘,所以淘汰算法还要考虑页是否被修改过。很多系统会给页加一个dirty标记,优先淘汰干净页,减少写IO。
2.4 决策4:LSM树与跳表,写入密集型场景怎么做
第四个决策针对写多读少的场景。如果直接使用B+树原地更新,每次写入都可能触发随机IO,磁盘随机写和顺序写的差距能达到几十倍,在写入密集的日志、消息、时序场景下性能会很难看。于是有了LSM树(Log-Structured Merge Tree)的思路。
LSM树的简化模型是:写入先进内存里的有序结构——这个有序结构通常用跳表或红黑树实现,达到阈值后刷成不可变的有序文件(SSTable),后台再把小文件不断合并成大文件。整个过程把随机写变成了顺序写,写入路径非常快。
我为什么强调跳表?因为跳表实现简单,支持范围查询,并发控制比红黑树容易,内存分配也更灵活。我在模拟项目X里实现过一版简单的跳表索引,后来又在LSM场景里复用了它。跳表的查找时间复杂度是O(log n),但常数比B+树小,因为它不需要维护树的旋转平衡,节点层数随机生成就行。
代价是读放大和空间放大:一个key可能存在多个历史版本文件里,读的时候要查多个文件,所以通常配合布隆过滤器(决策8)做快速过滤,减少不必要的文件读取。
2.5 决策5:MVCC版本链,一行数据的历史档案
第五个决策是事务并发控制怎么设计。多版本并发控制(MVCC)是现代数据库避免读写互相阻塞的核心手段,它的底层实现非常像“链表的历史版本链”。
在MVCC里,一行记录被修改时不是直接覆盖,而是生成一个新版本,旧版本通过指针串联在一起,形成一个版本链。每条数据上会记录创建该版本的“事务ID”,读事务判断自己能否看到某个版本,取决于版本事务ID和当前事务快照的可见性。
这个设计里最关键的数据结构是:单向链表(版本链)+ 事务快照数组(或时间戳序号)。查询时沿着版本链找第一个对当前事务可见的版本,就能做到不加锁也能读到一致数据。
我在实际调试时被一个问题困扰过:时间长了版本链会拖得很长,导致历史版本的遍历开销越来越大。后来看了很多资料,发现必须依赖后台的purge线程来清理已经没有任何事务会再读取的旧版本,否则单纯靠MVCC的数据结构正确性是不够的,还会引发数据文件和内存的持续膨胀。
2.6 决策6:嵌套循环连接还是哈希连接
第六个决策是SQL里最常见的JOIN操作怎么执行。两层表做连接,最容易理解的是嵌套循环连接(Nested Loop Join),本质就是两层for循环,外层遍历驱动表每一行,内层在被驱动表里找匹配行。如果被驱动表有索引,内层查找走索引,性能还能接受;没有索引,复杂度就是O(N*M),数据量大时直接崩。
哈希连接(Hash Join)的思路则是:先把小表的所有匹配键放进哈希表,再扫描大表,对每一行的键做哈希查找,整体复杂度是O(N+M)。它的核心数据结构就是哈希表,重点是让查询优化器选择把“小表”作为建表侧,哈希表越小、内存越放得下,性能越好。
执行计划上我踩过不少坑:优化器选错驱动表会导致性能差几十倍。所以现在我看执行计划时会先看驱动表的预估行数,以及被驱动表是否走了索引,如果发现全表嵌套,我基本可以判断这条SQL需要优化或加索引。
2.7 决策7:外部排序与堆,ORDER BY和TOP N的底层逻辑
第七个决策是排序聚合类操作怎么落地。数据库做ORDER BY时,如果数据量小于内存,可以直接用快速排序;如果数据量超出内存,就要用外部归并排序。
外部归并排序的思路是:把数据切成多个可以放进内存的块,每块内部排序后写回磁盘,然后把这些有序块做多路归并。多路归并里用到了一个小而重要的数据结构:堆(优先队列)。每次从多个有序块中取出当前最小元素,把堆顶弹出后,再从同一个块补充下一条,继续调整堆。这个“K路归并”就是很多数据库排序算子背后的核心流程。
TOP N(比如取成绩前10)还有一个更巧的优化:维护一个大小为N的最小堆,遍历数据时只保留当前最大的N个,堆顶就是这N个里的最小值,新数据只有比堆顶大才替换。这样既能避免全量排序,又保证最终输出的N个是有序的。我在给某跨平台系统做排行榜模块时就用过这个方式,内存占用恒定,处理百万级数据几乎无压力。
2.8 决策8:布隆过滤器,用极低成本挡掉无效请求
第八个决策是“某个key到底存不存在”。这个问题在很多场景里希望拿到一个快速回答:比如LSM树查一个key之前,先判断它是否在某个SSTable里,如果不在就完全不用读文件。哈希表当然可以给出精确回答,但数据量大的时候哈希表的内存开销很高,于是布隆过滤器出现了。
布隆过滤器是一个位数组加上多个哈希函数。插入key时,用多个哈希函数算出多个位,全部置1。查询时,只要有一个位是0,就说明这个key一定不存在;如果所有位都是1,只能说“可能存在”。它牺牲了少部分精确性,换来了极低的内存和极快的判断速度。
我在测试环境里算过一笔账:1000万个key,如果用精确哈希表可能要几百MB内存,而布隆过滤器只需要几十MB,并且假阳性率可以通过位数组长度和哈希函数个数来控制。实际使用中要注意的是:布隆过滤器不支持删除元素,因为清除某个位可能会误伤其他key。解决思路是使用计数布隆过滤器,或者定期重建过滤器。
2.9 决策9:系统表与数据字典,用结构管理结构自己
第九个决策容易被忽略:数据库本身的元数据(有哪些表、有哪些字段、索引信息、权限信息)也需要用数据结构来管理。
数据字典一般包括:表名到表ID的映射、表ID到列定义的映射、索引名到索引定义的映射、约束信息等。小型系统直接用哈希表存内存字典就够了,但大型数据库会把系统表也做成B+树的普通表,这样元数据操作的接口和普通数据一致,方便备份恢复和事务一致。
我在模拟项目X里最开始把所有元数据放在一个字典里,启动时一次性加载。后来表数量多了、反复创建删除表,发现内存里积压大量无用的历史引用的tag,必须做引用计数和周期性清理。这个教训让我意识到:连“管理结构本身的结构”也需要认真设计,否则最底层的字典表反而可能成为性能瓶颈。
3. 实操:手写一个小型数据库内核,把数据结构串起来
3.1 模块划分
为了验证上面的9个决策,我写了一个叫“某跨平台系统查询内核”的迷你项目。它不追求完整SQL语法,只实现三个核心模块:
- 存储层:按行存储数据,每行有一个自增主键,底层用列表模拟页面文件。
- 索引层:实现跳表索引和哈希索引两种结构,统一接口。
- 缓存层:实现一个LRU缓存,缓存最近访问的记录。
模块之间解耦后,每一层都可以单独替换,比如把索引换成B+树,或者把LRU换成LFU,都不会影响其他模块的接口。调试时很舒服,这也是我建议做类似练习的方式:先定接口,再填实现。
3.2 跳表索引实现
跳表是我做索引的首选,因为代码量小,逻辑直观,适合演示“有序结构如何加速查找”。下面是我在项目里用的简化版本:
import random class SkipListNode: def __init__(self, key, value, level): self.key = key self.value = value self.forward = [None] * (level + 1) class SkipList: def __init__(self, max_level=16): self.max_level = max_level self.header = SkipListNode(None, None, max_level) self.level = 0 self.random = random.Random(42) def _random_level(self): lvl = 0 while self.random.random() < 0.5 and lvl < self.max_level: lvl += 1 return lvl def insert(self, key, value): update = [None] * (self.max_level + 1) cur = self.header for i in range(self.level, -1, -1): while cur.forward[i] and cur.forward[i].key < key: cur = cur.forward[i] update[i] = cur cur = cur.forward[0] if cur and cur.key == key: cur.value = value return lvl = self._random_level() if lvl > self.level: for i in range(self.level + 1, lvl + 1): update[i] = self.header self.level = lvl new_node = SkipListNode(key, value, lvl) for i in range(lvl + 1): new_node.forward[i] = update[i].forward[i] update[i].forward[i] = new_node def search(self, key): cur = self.header for i in range(self.level, -1, -1): while cur.forward[i] and cur.forward[i].key < key: cur = cur.forward[i] cur = cur.forward[0] if cur and cur.key == key: return cur.value return None def range_scan(self, start_key, end_key): res = [] cur = self.header for i in range(self.level, -1, -1): while cur.forward[i] and cur.forward[i].key < start_key: cur = cur.forward[i] cur = cur.forward[0] while cur and cur.key <= end_key: res.append((cur.key, cur.value)) cur = cur.forward[0] return res这段代码的核心思想是:每一层都是一个有序链表,下层是上层的“快速通道”。查找时从最高层开始,能跳多远跳多远,一直降到最底层精确命中。插入时需要先记录每一层的前驱节点,再按随机层数进行链接。随机层数的期望复杂度是O(log n),所以跳表在概率意义上能做到高效的查找和插入。
实操心得:随机种子一定要固定,否则测试结果每次都不一样,不容易复现问题。我在联调时吃过这个亏,调试了半小时才发现是随机数导致的不稳定。
3.3 LRU缓冲池实现
缓存层我用哈希表加双向链表实现LRU。哈希表负责在O(1)时间内找到key对应的节点,双向链表负责维护访问顺序。
class DLinkedNode: def __init__(self, key=None, value=None): self.key = key self.value = value self.prev = None self.next = None class LRUCache: def __init__(self, capacity): self.capacity = capacity self.cache = {} self.head = DLinkedNode() self.tail = DLinkedNode() self.head.next = self.tail self.tail.prev = self.head def _add_node(self, node): node.prev = self.head node.next = self.head.next self.head.next.prev = node self.head.next = node def _remove_node(self, node): node.prev.next = node.next node.next.prev = node.prev def _move_to_head(self, node): self._remove_node(node) self._add_node(node) def _pop_tail(self): node = self.tail.prev self._remove_node(node) return node def get(self, key): node = self.cache.get(key) if not node: return None self._move_to_head(node) return node.value def put(self, key, value): node = self.cache.get(key) if node: node.value = value self._move_to_head(node) return new_node = DLinkedNode(key, value) self.cache[key] = new_node self._add_node(new_node) if len(self.cache) > self.capacity: tail = self._pop_tail() del self.cache[tail.key]为什么用双向链表而不是单向链表?因为需要快速删除某个节点,而删除操作必须知道它的前驱节点。如果只有单向链表,要找到前驱就必须从头遍历,那就白费了O(1)的设计目标。数据库缓冲池里的页淘汰也是同一个道理,所以看起来多了一个指针,实际上换来了删除操作的O(1)复杂度。
3.4 查询路由:走索引还是全表扫描
在存储层之上,我做了一个最简单的查询路由:如果查询条件带了主键,就先查LRU缓存,缓存未命中再走跳表索引定位;如果没有走索引条件,就退化为线性扫描列表。这个路由逻辑很短,但很能反映数据库优化器的基本思路:
class QueryEngine: def __init__(self): self.rows = [] self.index = SkipList() self.cache = LRUCache(1024) def insert(self, key, value): self.rows.append((key, value)) self.index.insert(key, value) def query_by_pk(self, key): val = self.cache.get(key) if val is not None: print("cache hit") return val val = self.index.search(key) if val is not None: self.cache.put(key, val) return val def scan(self): res = [] for key, value in self.rows: if value is not None: res.append((key, value)) return res这个模块虽然简单,但它表达了一个重要的分层思维:缓存层挡住高频访问,索引层加速低频精确查询,扫描层兜底。真实数据库的优化器虽然复杂百倍,但抽象出来的逻辑骨架是相似的。
4. 运行结果与性能对比:索引到底快了多少
我在某测试环境里用这个迷你内核跑了一组数据:10万条记录,每条记录一个随机正整数主键和一个模拟载荷。分别测了三种操作的耗时:等值查询、范围查询、全量扫描。
| 操作类型 | 无索引线性扫描 | 跳表索引 | LRU缓存命中 |
|---|---|---|---|
| 等值查找10万次 | 约1.28秒 | 约0.06秒 | 约0.003秒 |
| 范围查询100次 | 约0.9秒 | 约0.08秒 | 不适用 |
| 随机插入10万次 | 约0.05秒 | 约0.12秒 | 不适用 |
解读一下这组结果:无索引的等值查找是O(n)的线性遍历,10万次查找相当于把10万条记录平均扫了一半左右,耗时自然很高;跳表索引把每次查找降到O(log n),理论上10万条记录的平均查找步数在17层上下,耗时大概只有线性扫描的几十分之一;而LRU缓存命中的时候,哈希表O(1)定位,所以更快。插入操作有索引比无索引慢是正常的,因为跳表插入要维护多层指针,但换来的是查找性能的数量级提升。
这里要注意的是:这个对比不是要证明“索引永远最好”,而是说明“按访问模式选结构”。如果你的业务全是插入、几乎没有查询,那么索引是负担;但真实的业务系统几乎都是读多写少,索引的性价比非常高。
5. 常见问题与排查技巧实录
5.1 索引没走上的常见原因
我在调这个项目的跳表索引时遇到过几次“看起来有索引但没生效”的情况。仔细排查后发现原因主要有几类:
- 查询条件对索引字段做了函数运算。比如对key做了取绝对值或字符串拼接,优化器就没办法直接用索引。
- 隐式类型转换。比如索引字段是整数,查询传了一个字符串,数据库会把每行的字段转成字符串再去比较,索引失效。
- 复合索引的最左前缀原则被忽略。复合索引本质上是“先按第一列排序,再按第二列排序”的多层结构,跳过了第一列单独查第二列,排序顺序就不成立。
排查方法也比较固定:查看执行计划,看有没有出现全表扫描,再看索引列是否被表达式包裹。我在模拟项目X里就引入了一个“强制走索引”的开关,用来快速验证索引本身是否生效,能极大缩短排查时间。
5.2 缓存命中率上不去
LRU缓存看上去很简单,但我实测时发现命中率上不去的场景很常见。主要原因在于访问模式不适合LRU:比如周期性全量扫描会把缓存全部冲掉,或者业务访问模式是“一次性读一大片,之后再也不读”,这种数据留在缓存里没有任何意义。
解决办法有三种方向:一是像前面说的,把LRU分成两段,保留一个“热区”来保护真正频繁访问的页;二是做访问频率计数,用LFU的思路避免“一次热点”长期站队;三是根据业务特征为不同表设置不同的缓存策略,而不是所有数据共用同一条LRU规则。
5.3 并发场景下索引和缓存的数据一致性问题
迷你内核是单线程的,但真实数据库要面对并发。我在把跳表索引接入多线程环境时遇到过一个典型问题:一个线程更新了跳表里的值,另一个线程读取缓存,读到的还是旧数据。
解决这个问题的核心思路是让缓存知道数据版本变化。常见的做法是:写入时更新完索引后,主动让对应key的缓存失效,而不是等到缓存自然淘汰。这样下一次查询必然走索引读到最新值。还有一个细节是:失效操作的顺序要注意,应该先更新索引,再失效缓存,避免缓存先失效而索引还没来得及更新时,另一个线程读到旧索引值。
我把并发常见问题整理成了一张速查表:
| 症状 | 排查方向 | 解决思路 |
|---|---|---|
| 明明插入了数据但查不到 | 检查索引维护是否遗漏了插入路径 | 在insert操作里同步维护所有索引结构 |
| 缓存里总是旧值 | 检查是否有缓存失效逻辑 | 写入时主动删除对应key的缓存 |
| 数据量不大但查询很慢 | 查询是否因表达式导致索引失效 | 改写SQL,避免对索引字段做运算 |
| 切换存储引擎后性能骤降 | 默认参数是否匹配新结构 | 根据读写比例调整缓冲池和索引参数 |
6. 经验体感与后续扩展
做完这个项目,我最大的体感是:数据结构不是静态的知识点,而是工程里每一个性能瓶颈的“底层变量”。数据库的索引、缓存、排序、连接、事务隔离,每一个听起来很高深的名词,落到代码层面都能找到对应的数据结构原型。反过来说,当你把哈希表、跳表、双向链表、堆、布隆过滤器这些结构真正放进一个可运行的系统里,你才真正理解了它们为什么被设计成这样。
如果后续还要扩展,我建议往两个方向走:一是把跳表索引换成真正的B+树实现,感受一下节点分裂和叶子链表维护的复杂度;二是给这个迷你内核加上简单的WAL日志和恢复逻辑,把“数据结构”和“数据可靠性”串起来。这样再过一遍,你对数据库的认知层次又会不一样。