当前位置: 首页 > ops >正文

从零手写Java版本的LSM Tree (三):MemTable 内存表

🔥 推荐一个高质量的Java LSM Tree开源项目!
https://github.com/brianxiadong/java-lsm-tree
java-lsm-tree 是一个从零实现的Log-Structured Merge Tree,专为高并发写入场景设计。
核心亮点:
⚡ 极致性能:写入速度超过40万ops/秒,完爆传统B+树
🏗️ 完整架构:MemTable跳表 + SSTable + WAL + 布隆过滤器 + 多级压缩
📚 深度教程:12章详细教程,从基础概念到生产优化,每行代码都有注释
🔒 并发安全:读写锁机制,支持高并发场景
💾 数据可靠:WAL写前日志确保崩溃恢复,零数据丢失
适合谁?

  • 想深入理解LSM Tree原理的开发者
  • 需要高写入性能存储引擎的项目
  • 准备数据库/存储系统面试的同学
  • 对分布式存储感兴趣的工程师
    ⭐ 给个Star支持开源!

第3章:MemTable 内存表

什么是MemTable?

MemTable (内存表) 是LSM Tree中接收所有写入操作的内存数据结构。它扮演着缓冲区的角色,将随机写入转换为顺序写入,是LSM Tree高性能的关键组件。

为什么选择跳表?

在LSM Tree的MemTable实现中,我们选择了跳表 (Skip List) 作为底层数据结构。让我们看看为什么:

数据结构对比

数据结构插入查找删除有序遍历并发性能
红黑树O(log n)O(log n)O(log n)O(n)需要复杂锁
B+树O(log n)O(log n)O(log n)O(n)锁开销大
跳表O(log n)O(log n)O(log n)O(n)并发友好

跳表的优势

  1. 并发友好: Java的ConcurrentSkipListMap提供了优秀的并发性能
  2. 实现简单: 相比红黑树,跳表实现更简单
  3. 缓存友好: 较好的空间局部性
  4. 有序性: 天然支持有序遍历

MemTable 实现分析

让我们深入分析MemTable的实现:

package com.brianxiadong.lsmtree;import java.util.concurrent.ConcurrentSkipListMap;
import java.util.List;
import java.util.ArrayList;public class MemTable {// 使用并发跳表作为底层存储,键为String,值为KeyValue对象private final ConcurrentSkipListMap<String, KeyValue> data;// 最大容量阈值,超过此值将触发刷盘操作private final int maxSize;// 当前大小,使用volatile确保多线程可见性private volatile int currentSize;// 构造函数:初始化MemTablepublic MemTable(int maxSize) {this.data = new ConcurrentSkipListMap<>();  // 创建线程安全的跳表this.maxSize = maxSize;                     // 设置最大容量this.currentSize = 0;                       // 初始大小为0}// 核心方法...
}

代码解释: 这个MemTable类使用ConcurrentSkipListMap作为底层存储,它提供了线程安全的有序键值存储。maxSize字段控制何时触发刷盘操作,而currentSize使用volatile关键字确保在多线程环境下的可见性。

核心设计要点

  1. ConcurrentSkipListMap: Java并发包提供的线程安全跳表实现
  2. volatile currentSize: 确保大小检查的可见性
  3. immutable maxSize: 刷盘触发阈值

跳表原理深入

跳表结构图解

跳表查找12的真实路径演示:Level 3: [1]--------①水平-------->[17]---------> NULL|                        (17>12停止)|②下降↓
Level 2: [1]---③水平--->[9]---④水平--->[17]-------> NULL|             (17>12停止)  |⑤下降到Level 1↓
Level 1: [1]-->[4]----->[9]------->[17]-->[25]---> NULL|⑥检查      (下个是17>12,无需水平移动)|⑦下降到Level 0  ↓
Level 0: [1]-->[4]----->[9]--⑧找到-->[12🎯]-->[17]-->[25]---> NULL(找到目标!)真实搜索路径: 1(L3) → 1(L2) → 9(L2) → 9(L1) → 9(L0) → 12(L0) ✓
关键洞察: Level 1并未被"跳过",而是在此处做了快速判断后继续下降
跳表精髓: 通过多层索引快速"跳过"不必要的节点比较,而非跳过层级

跳表结构说明: 跳表通过多层索引实现快速查找。上层作为下层的"快速通道",每个节点在多个层级上建立索引。查找时从顶层开始,利用稀疏索引快速逼近目标,然后逐层下降精确定位。

查找12的完整路径(按编号顺序):

  1. ①水平: Level 3从1开始,向右到17 (17>12,停止水平移动)
  2. ②下降: 从Level 3的1下降到Level 2的1
  3. ③水平: Level 2从1向右到9 (9<12,继续)
  4. ④水平: Level 2从9向右到17 (17>12,停止水平移动)
  5. ⑤下降: 从Level 2的9下降到Level 1的9
  6. Level 1检查: 在Level 1的9处,下一个节点是17 (17>12,无需水平移动)
  7. 继续下降: 从Level 1的9下降到Level 0的9
  8. ⑥找到: Level 0从9向右到12🎯 (找到目标!)

为什么看起来"跳过"了Level 1?

  • 实际上算法会逐层检查每一层,不会真的跳过
  • 在Level 1的节点9处,发现下一个节点是17>12,所以无需水平移动
  • 但算法仍然会在Level 1停留并做判断,然后继续下降
  • 这就是为什么跳表叫"Skip List"——它能"跳过"不必要的比较,而不是跳过层级

查找过程

  1. 从最高层开始: 从Level 3的头节点开始
  2. 水平移动: 在当前层向右移动,直到下一个节点 > 目标值
  3. 向下移动: 移动到下一层继续查找
  4. 重复过程: 直到找到目标或到达Level 0

查找12的路径演示:

  • Level 3: 1 → 17 (17>12,下降)
  • Level 2: 1 → 9 → 17 (17>12,下降)
  • Level 1: 1 → 4 → 9 → 17 (17>12,下降)
  • Level 0: 1 → 4 → 9 → 12 ✓ (找到目标)
// 跳表查找伪代码
public KeyValue search(String key) {Node current = head;  // 从头节点开始// 从最高层开始向下搜索for (int level = maxLevel; level >= 0; level--) {// 在当前层水平移动,寻找合适的位置while (current.forward[level] != null &&                    // 下一个节点存在current.forward[level].key.compareTo(key) < 0) {     // 且键值小于目标键current = current.forward[level];                       // 移动到下一个节点}}// 移动到Level 0的下一个节点检查是否找到目标current = current.forward[0];if (current != null && current.key.equals(key)) {  // 找到目标键return current.value;                           // 返回对应的值}return null;  // 未找到,返回null
}

代码解释: 这个搜索算法体现了跳表的核心思想:通过多层索引快速定位。从最高层开始,每层都尽可能向右移动,然后向下到下一层继续搜索。这种"跳跃"式的搜索方式大大减少了比较次数。

插入过程

// 跳表插入伪代码
public void insert(String key, KeyValue value) {Node[] update = new Node[maxLevel + 1];  // 记录每层的插入位置Node current = head;                     // 从头节点开始// 找到每一层的插入位置for (int level = maxLevel; level >= 0; level--) {// 在当前层找到插入位置的前驱节点while (current.forward[level] != null && current.forward[level].key.compareTo(key) < 0) {current = current.forward[level];       // 向右移动}update[level] = current;  // 记录该层的前驱节点}// 随机决定新节点的层数(跳表的概率特性)int newLevel = randomLevel();Node newNode = new Node(key, value, newLevel);  // 创建新节点// 在每一层插入新节点for (int level = 0; level <= newLevel; level++) {newNode.forward[level] = update[level].forward[level];  // 新节点指向后继update[level].forward[level] = newNode;                 // 前驱指向新节点}
}

代码解释: 插入操作分为两个阶段:首先找到各层的插入位置,然后执行实际插入。随机层数的选择是跳表的关键特性,它保证了跳表的平衡性。每个新节点都会在随机选择的多个层级上建立索引。

MemTable 核心操作

1. 写入操作 (PUT)

public void put(String key, String value) {KeyValue kv = new KeyValue(key, value);  // 创建新的KeyValue对象KeyValue oldValue = data.put(key, kv);   // 插入到跳表中,返回旧值// 只有新键才增加计数(更新操作不增加大小)if (oldValue == null) {currentSize++;  // 原子性地增加当前大小}
}

代码解释: 写入操作将键值对封装成KeyValue对象后插入跳表。通过检查返回的旧值,我们只对新键增加计数,确保currentSize准确反映MemTable中的唯一键数量。

性能分析:

  • 时间复杂度: O(log n),其中n是MemTable中的键数量
  • 并发性能: ConcurrentSkipListMap支持高并发写入
  • 原子性: 单个put操作是原子的

2. 读取操作 (GET)

public String get(String key) {KeyValue kv = data.get(key);        // 从跳表中获取KeyValue对象if (kv == null || kv.isDeleted()) { // 检查是否存在或已被删除return null;                    // 不存在或已删除,返回null}return kv.getValue();               // 返回实际的值
}

代码解释: 读取操作首先从跳表中获取KeyValue对象,然后检查该对象是否存在以及是否为删除标记。这种设计统一处理了不存在的键和被逻辑删除的键。

性能分析:

  • 时间复杂度: O(log n)
  • 并发读取: 支持多线程并发读取
  • 删除处理: 自动处理墓碑标记

3. 删除操作 (DELETE)

public void delete(String key) {KeyValue tombstone = KeyValue.createTombstone(key);  // 创建删除标记(墓碑)KeyValue oldValue = data.put(key, tombstone);        // 将墓碑插入跳表// 只有新键才增加计数(即使是删除操作)if (oldValue == null) {currentSize++;  // 墓碑也占用空间,需要计入大小}
}

代码解释: 删除操作通过插入墓碑标记来实现,而不是物理删除。这保证了删除操作的持久性和可见性。注意即使是删除操作,如果是新键也会增加currentSize,因为墓碑同样占用内存空间。

删除策略:

  • 逻辑删除: 插入墓碑标记而非物理删除
  • 一致性: 确保删除操作的可见性
  • 空间考虑: 墓碑占用空间,需要压缩清理

4. 刷盘检查

public boolean shouldFlush() {return currentSize >= maxSize;  // 当前大小超过阈值时需要刷盘
}// 获取当前大小的方法
public int size() {return currentSize;  // 返回当前的键值对数量
}

代码解释: 刷盘检查是LSM Tree中的关键操作。当MemTable大小超过阈值时,就需要将数据刷盘到SSTable文件中,为新的写入腾出内存空间。

刷盘触发:

  • 大小阈值: 超过maxSize时触发
  • 内存压力: 系统内存不足时强制刷盘
  • 时间阈值: 定期刷盘保证持久性

并发控制深入

ConcurrentSkipListMap 并发机制

// Java ConcurrentSkipListMap 的并发策略示例
public class ConcurrentSkipListMap<K,V> {// 使用 CAS (Compare-And-Swap) 操作进行无锁更新private boolean casNext(Node<K,V> cmp, Node<K,V> val) {// 原子性地比较并交换指针,避免使用锁return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);}// 无锁读取操作public V get(Object key) {return doGet(key);  // 执行无锁查找}// 无锁写入(大部分情况下)public V put(K key, V value) {return doPut(key, value, false);  // 执行无锁插入}
}

代码解释: ConcurrentSkipListMap的高并发性能来源于其巧妙的无锁设计。它大量使用CAS操作来实现原子更新,避免了传统锁机制的开销。这使得读操作完全无锁,写操作在大多数情况下也能避免阻塞。

小结

MemTable是LSM Tree的核心组件,它通过以下特性实现了高性能:

  1. 跳表结构: 提供O(log n)的操作性能和良好的并发性
  2. 并发安全: ConcurrentSkipListMap确保线程安全
  3. 内存效率: 紧凑的数据结构减少内存开销
  4. 有序性: 支持高效的有序遍历和范围查询

思考题

  1. 为什么ConcurrentSkipListMap比ConcurrentHashMap更适合MemTable?
  2. 如何处理MemTable刷盘过程中的新写入?
  3. 跳表的随机层数如何影响性能?
http://www.xdnf.cn/news/12887.html

相关文章:

  • 图表类系列各种样式PPT模版分享
  • 高性能低功耗之道:全志A133在智能硬件中的全面应用
  • 设计模式-抽象工厂模式
  • CSS3 常用功能详细使用指南
  • App Trace技术解析:传参安装、一键拉起与快速安装
  • 【Linux】Linux安装并配置RabbitMQ
  • Maven 多仓库治理与发布策略深度实践
  • Kubernetes 网络模型深度解析:Pod IP 与 Service 的负载均衡机制,Service到底是什么?
  • 大模型的LoRa通讯详解与实现教程
  • 时序数据库IoTDB在工业物联网时序数据管理中的应用
  • Ray框架:分布式AI训练与调参实践
  • WEB3全栈开发——面试专业技能点P4数据库
  • 数据结构-文件
  • Unity3D SM节点式动画技能编辑器实现
  • AIGC(AI Generated Content)测试结合自动化工具与人工评估
  • 在 Windows 11 上创建新本地用户账户
  • C++ 设计模式 《爬虫围城危机:小明用代理模式自救》
  • 代码随想录算法训练营第十一天| 150. 逆波兰表达式求值、239. 滑动窗口最大值、347.前 K 个高频元素
  • Gartner 人力优化策略分析报告学习心得
  • SEO长尾关键词增效策略
  • 大模型面试题:大模型训练过程中如何估计显卡利用率?
  • 【ESP32】ESP-IDF开发 | 低功耗蓝牙开发 | 蓝牙空中串口 + 服务端和客户端例程
  • LlamaIndex 工作流 分支和循环
  • AI是如何换装的?
  • 三维图形、地理空间、激光点云渲染技术术语解析笔记
  • 压缩机PCB Layout注意事项
  • Java并发编程-理论基础
  • Context API 应用与局限性
  • 非Root用户启动SSH服务经验小结
  • (四)Linux性能优化-CPU-软中断