Bitcask介绍

Bitcask是一种“基于日志结构的哈希表”(A Log-Structured Hash Table for Fast Key/Value Data)

Bitcask 最初作为分布式数据库 Riak 的后端出现,Riak 中的每个节点都运行一个 Bitcask 实例,各自存储其负责的数据。

抛开论文,我们先通过一篇博客 # Bitcask — a log-structured fast KV store 来了解bitcask的细节信息,下面是简要的译文。

Bitcask 设计

Bitcask 借鉴了大量来自日志结构文件系统和涉及日志文件合并的设计,例如 LSM 树中的合并。它本质上是一个目录,包含固定结构的追加日志文件和一个内存索引。内存索引以哈希表的形式存储所有键及其对应的值所在数据文件中的偏移量和其他必要信息,用于快速查找到对应的条目。

数据文件

数据文件是追加日志文件,存储键值对和一些元信息。一个 Bitcask 实例可以拥有多个数据文件,其中只有一个处于活动状态,用于写入,其他文件为只读文件。

数据文件中的每个条目都有固定的结构,我们可以用类似下面的数据结构来描述:

struct log_entry {
uint32_t crc;
uint32_t timestamp;
uint32_t key_size;
uint32_t value_size;
char key[key_size];
char value[value_size];
};

键目录(KeyDir)

键目录是一个内存哈希表,存储 Bitcask 实例中所有键及其对应的值所在数据文件中的偏移量和一些元信息,例如时间戳,可以用类似下面的数据结构来描述:

struct key_entry {
uint32_t file_id;
uint32_t offset;
uint32_t timestamp;
};

写入数据

将新的键值对存储到 Bitcask 时,引擎首先将其追加到活动数据文件中,然后在键目录中创建一个新条目,指定值的存储位置。这两个动作都是原子性的,意味着条目要么同时写入两个结构,要么都不写入。

更新现有键值对

Bitcask 直接支持完全替换值,但不支持部分更新。因此,更新操作与存储新键值对非常相似,唯一的区别是不会在键目录中创建新条目,而是更新现有条目的信息,可能指向新的数据文件中的新位置。

与旧值对应的条目现在处于“游离状态”,将在合并和压缩过程中显式地进行垃圾回收。

删除键

删除键是一个特殊的操作,引擎会原子性地将一个新的条目追加到活动数据文件中,其中值等于一个标志删除的特殊值,然后从内存键目录中删除该键的条目。该标志值非常独特,不会与现有值空间冲突。

读取键值对

从存储中读取键值对需要引擎首先使用键目录找到该键对应的数据文件和偏移量。然后,引擎从相应的偏移量处执行一次磁盘读取,检索日志条目。检索到的值与存储的校验码进行正确性检查,然后将值返回给客户端。

该操作本身非常快速,只涉及一次磁盘读取和几次内存访问,但可以使用文件系统预读缓存进一步提高速度。

合并和压缩

正如我们在更新和删除操作中看到的,与键关联的旧条目保持原样,处于“游离状态”。这会导致 Bitcask 消耗大量磁盘空间。为了提高磁盘利用率,引擎会定期将较旧的已关闭数据文件压缩成一个或多个新数据文件,其结构与现有数据文件相同。

合并过程遍历 Bitcask 中所有只读文件,生成一组数据文件,只包含每个存在的键的“最新”版本。

快速启动

如果 Bitcask 发生故障并需要重启,它必须读取所有的数据文件并构建一个新的键目录(KeyDir),如果没有专门存储,需要读取所有文件重建。

其实上面的合并和压缩操作可以部分缓解这个问题,一方面它们减少了需要读取的最终会被废弃的数据量,在合并的同事,可以生成一个hint提示文件,hint记录了key和key指向的meta信息。 这样读取hint文件就可以快速重建键目录(KeyDir)。

*Bitcask 评价

优点

  • 读写操作延迟低:Bitcask 的读写操作都非常快速,因为它只需要一次磁盘查找即可检索任何值。
  • 高写入吞吐量:Bitcask 的写入操作是追加式的,并且不需要进行磁盘寻道,因此可以实现高写入吞吐量。
  • 可预测的查找和插入性能:由于其简单的设计,Bitcask 的查找和插入性能非常可预测,这对于实时应用程序非常重要。
  • 崩溃恢复快:Bitcask 的崩溃恢复速度很快,因为它只需要重建 KeyDir 即可。
  • 备份简单:Bitcask 的备份非常简单,只需复制数据文件目录即可。

缺点

  • KeyDir 占用内存:KeyDir 需要将所有键存储在内存中,这对系统的 RAM 容量提出了较高的要求,尤其是在处理大型数据集时。

解决方案

  • 分片:可以将键进行分片,将数据分布到多个 Bitcask 实例中,从而水平扩展系统并降低对内存的需求。这种方法不会影响基本的 CRUD(Create、Read、Update、Delete)操作。

为何要考虑自研轻量级KV系统

我们线上的搜索系统,检索到match的doc后,需要通过id获取doc的详情,考虑到数据量级很大,redis首先排除,我们最初的选型是mongodb,在十亿级别的数据量时,整体问题不大,但是面向未来更大的数据量级,我们需要考虑更容易维护的方案。

当前mongodb的问题:

  • mongodb的存储满了后,扩容较难
  • 每天增量数据写入,影响读取性能
  • 三地的集群,数据的一致性保障并非一件简单的事情
  • 最重要的,我们的使用场景仅仅是kv查询,mongodb在这个场景有点大材小用了

为了解决上面的问题,我们考虑一种数据分版本的方案。

具体来说,对于KV场景,将每个版本的数据,根据特定的hash规则将数据分成多片,每片离线按照Bitcask的思路,生成好hint文件和数据文件,上接一个分布式服务提供查询即可。对于增量的数据,只需要按同样的hash规则,先生成好数据,将数据文件加载即可,这样可以确保数据的一致。同一组的slot文件,如果一台机器加载不下,可以多台机器加载,分布式服务做好控制即可。查询时,像对key做hash,然后并发去查询对应slot的服务即可。

轻量级KV系统设计

实际系统中,数据的key都是int64数据,value是json string,我们来设计hint和data文件格式。在不考虑校验的情况下,我们可以用最简单的文件格式来存储。

离线写入

hint格式,按照 key,value length,offset 依次写入。

|  int64  key | int32 value length |  int64 value offset |  ... | int64  key | int32 value length |  int64 value offset |

data 格式,直接append 压缩后的数据。

|  compressed value bytes | ... |  compressed value bytes |

简单的java代码实现:


FileOutputStream dataFileWriter = new FileOutputStream (outputFile);
FileOutputStream indexFileWriter = new FileOutputStream(outPutIndex);
long offset = 0;
int count = 0;
while (dataList.hasNext()) {
Document doc = dataList.next();
Long id = doc.getLong("_id");
byte[] value = compress(doc.toJson(),"utf-8");
byte[] length = intToByteLittle(value.length); dataFileWriter.write(value);
offset += value.length; indexFileWriter.write(longToBytesLittle(id));
indexFileWriter.write(length);
indexFileWriter.write(longToBytesLittle(offset)); count++;
if (count % 10000 == 0) {
log.info("already load {} items", count);
indexFileWriter.flush();
dataFileWriter.flush();
}
} indexFileWriter.close();
dataFileWriter.close();

在线读取

读取,需要先读取hint索引文件,加载到内存。

// read index
FileInputStream indexReader = new FileInputStream(indexFile); // id 8字节, offset 4字节
byte[] entry = new byte[8+4+8];
byte[] idBytes = new byte[8];
byte[] lengthBytes = new byte[4];
byte[] offsetBytes = new byte[8];
Map<Long, Pair<Long, Integer>> id2Offset = new HashMap<>();
// 这里直接加载到map里,可以优化
while(indexReader.read(entry, 0, entry.length) > 0) { System.arraycopy(entry,0, idBytes, 0, 8);
System.arraycopy(entry,8, lengthBytes, 0, 4);
System.arraycopy(entry,12, offsetBytes, 0, 8); long id = EncodingUtils.bytesToLongLittle(idBytes);
int dataLength = EncodingUtils.bytes2IntLittle(lengthBytes);
long offset = EncodingUtils.bytesToLongLittle(offsetBytes);
id2Offset.put(id, Pair.of(offset, dataLength));
}

从数据文件读取数据也会比较简单,先从hint数据获取到key对应的offset和dataLength,然后读取数据解压即可。

RandomAccessFile dataFileReader = new RandomAccessFile(dataFile, "r");
while(true) {
System.out.print("请输入查询key:");
String key = System.console().readLine();
long id = Long.parseLong(key);
if (id2Offset.containsKey(id)) {
long offset = id2Offset.get(id).getFirst();
int dataSize = id2Offset.get(id).getSecond();
dataFileReader.seek(offset);
byte[] data = new byte[dataSize];
dataFileReader.read(data, 0, data.length);
byte[] decodeData = uncompress(data);
System.out.println(new String(decodeData));
}
}

上面仅仅是demo性质的代码,实际过程中还要考虑数据的完整性检验,以及LRU缓存等。

总结

没有最好的K-V系统,只有最适合应用业务实际场景的系统,做任何的方案选择,要结合业务当前的实际情况综合权衡,有所取有所舍。

从Bitcask存储模型谈超轻量级KV系统设计与实现的更多相关文章

  1. Bitcask存储模型

    ----<大规模分布式存储系统:原理解析与架构实战>读书笔记 近期一直在分析OceanBase的源代码,恰巧碰到了OceanBase的核心开发人员的新作<大规模分布式存储系统:原理解 ...

  2. Bitcask 存储模型

    Bitcask 存储模型 Bitcask 是一个日志型.基于hash表结构的key-value存储模型,以Bitcask为存储模型的K-V系统有 Riak和 beansdb新版本. 日志型数据存储 何 ...

  3. LSM树存储模型

    ----<大规模分布式存储系统:原理解析与架构实战>读书笔记 之前研究了Bitcask存储模型,今天来看看LSM存储模型,两者尽管同属于基于键值的日志型存储模型.可是Bitcask使用哈希 ...

  4. 数据持久化之轻量级Kv持久化(二)

    阿里P7Android高级架构进阶视频免费学习请点击:https://space.bilibili.com/474380680本篇文章将继续从以下两个内容来介绍轻量级Kv持久化: [SharedPre ...

  5. Entity Framework 6 Recipes 2nd Edition(10-5)译 -> 在存储模型中使用自定义函数

    10-5. 在存储模型中使用自定义函数 问题 想在模型中使用自定义函数,而不是存储过程. 解决方案 假设我们数据库里有成员(members)和他们已经发送的信息(messages) 关系数据表,如Fi ...

  6. SQLite剖析之存储模型

    前言 SQLite作为嵌入式数据库,通常针对的应用的数据量相对于DBMS的数据量小.所以它的存储模型设计得非常简单,总的来说,SQLite把一个数据文件分成若干大小相等的页面,然后以B树的形式来组织这 ...

  7. LSM存储模型

    LSM存储模型 数据库有3种基本的存储引擎: 哈希表,支持增.删.改以及随机读取操作,但不支持顺序扫描,对应的存储系统为key-value存储系统.对于key-value的插入以及查询,哈希表的复杂度 ...

  8. SQLite入门与分析(八)---存储模型(1)

    写在前面:SQLite作为嵌入式数据库,通常针对的应用的数据量相对于通常DBMS的数据量是较小的.所以它的存储模型设计得非常简单,总的来说,SQLite把一个数据文件分成若干大小相等的页面,然后以B树 ...

  9. 剖析Elasticsearch集群系列第一篇 Elasticsearch的存储模型和读写操作

    剖析Elasticsearch集群系列涵盖了当今最流行的分布式搜索引擎Elasticsearch的底层架构和原型实例. 本文是这个系列的第一篇,在本文中,我们将讨论的Elasticsearch的底层存 ...

  10. 剖析Elasticsearch集群系列之一:Elasticsearch的存储模型和读写操作

    转载:http://www.infoq.com/cn/articles/analysis-of-elasticsearch-cluster-part01 1.辨析Elasticsearch的索引与Lu ...

随机推荐

  1. OSPF路由 与 ISIS路由 与路由学习对比

    转载请注明出处: 1.OSPF 路由学习规律 OSPF使用链路状态数据库(Link State Database)来存储网络拓扑信息.每个OSPF路由器通过交换链路状态更新(Link State Up ...

  2. python-微信

    wxpy/itchat已禁用 自从微信禁止网页版登陆之后,itchat 库实现的功能也就都不能用了: itchat现在叫wxpy 1.安装库wxpy: PS D:\01VSCodeScript\Pyt ...

  3. CF707B

    题目简化和分析: 这题看着玄胡很水实际. 我们需要做什么? 只需对每个工厂周围的面包店遍历一遍打擂台取最小 注意只对面包店遍历,所以对工厂设标记 如果打完擂台发现 \(ans=inf\) 则说明全是工 ...

  4. 查找数组中第K大的元素

    要查找一个数组中的第 K 大元素,有多种方法可以实现,其中常用的方法是使用分治算法或快速选择算法,这两种方法的时间复杂度到时候O(n). 快速选择算法示例: package main import & ...

  5. js正则表达式把页面中的p标签全部换成div

    documentdocument.body.innerHTML = document.body.innerHTML.replace(/<[\/]?(p)(:?\s+(:?class|style) ...

  6. 记一次 .NET 某工厂无人车调度系统 线程爆高分析

    一:背景 1. 讲故事 前些天有位朋友找到我,说他程序中的线程数爆高,让我帮忙看下怎么回事,这种线程数爆高的情况找问题相对比较容易,就让朋友丢一个dump给我,看看便知. 二:为什么会爆高 1. 查看 ...

  7. Java8新特性(Lambda表达式、Stream流、Optional类)等

    1. Lambda表达式由来 1 package java8; 2 3 public class EmployeeTest { 4 public static void main(String[] a ...

  8. BIRCH算法全解析:从原理到实战

    本文全面解析了BIRCH(平衡迭代削减聚类层次)算法,一种用于大规模数据聚类的高效工具.文章从基础概念到技术细节,再到实战应用与最佳实践,提供了一系列具体的指导和例子.无论你是数据科学新手,还是有经验 ...

  9. 超详细的Mysql锁 实战分析,你想知道的都在这里~

    1.mysql回表查询 在这里提起主要是用于说明mysql数据和索引的结构,有助于理解后续加锁过程中的一些问题. mysql索引结构和表数据结构是相互独立的,根据索引查询,只能找到索引列和主键聚簇索引 ...

  10. 基于DotNetty实现自动发布 - 自动检测代码变化

    前言 很抱歉没有实现上一篇的目标:一键发布,因为工作量超出了预期,本次只实现了 Git 代码变化检测 已完成的功能 解决方案的项目发现与配置 首次发布需要手动处理 自动检测代码变化并解析出待发布的文件 ...