嵌入式数据库 QuickIO 诞生记

QuickIO 的诞生背景

一年前,我在业余时间编写一个后端项目,项目使用的技术栈是 Java Vert.x + MongoDB。Vert.x 是一个事件驱动的网络应用程序框架,因其异步响应的特性,读写 MongoDB 时不可避免要编写大量异步回调的代码。“回调地狱”现象的产生,让代码的可读性逐渐下降。

Vert.x MongoDB Client 相关代码示例:

JsonObject document = new JsonObject().put("title", "The Hobbit");
mongoClient.save("books", document, res -> {
    if (res.succeeded()) {
        System.out.println("Saved book with id " +  res.result());
    } else {
        res.cause().printStackTrace();
    }
});

面对使用 MongoDB 需要编写大量异步代码的问题,当时又考虑到项目存储的数据量较小,或许可以使用嵌入式的 SQLite 代替 MongoDB,从而减少项目异步代码的编写。但选择 SQLite 这种关系型数据库还不是理想方案,因为项目存储的数据是非结构化的,所以使用像 MongoDB 这种非关系型数据库更为合适。因此,我需要寻找一个嵌入式 NoSQL 数据库。

QuickIO 的灵感来源

我带着问题 Google 一下,结果意外搜索到 C# 领域存在一个嵌入式 NoSQL 数据库 —— LiteDB , 其设计灵感来自 MongoDB,它的 API 与官方的 MongoDB .NET API 非常相似。然后我又搜索 Java 领域是否存在类似的数据库,很遗憾!没找到。因此,我萌发了编写一个 Java 嵌入式 NoSQL 数据库的念头。

LiteDB 的 LINQ 语法,用 Lambda 表达式即可完成数据库的增删改查,代码表现得十分优雅。这个特点成为我设计 QuickIO 时的一个明确要借鉴的方向。接着,确定数据库的引擎使用 LevelDB, 数据的序列化和反序列化使用 Hessian,后期为了提升数据库性能,使用 Protostaff 替换了 Hessian。

后来,该项目开源到 GitHub,经过频繁的迭代,编写的嵌入式 NoSQL 数据库逐渐成型。不久前,我初次发表了《一个轻量级Java嵌入式数据库——QuickIO》一文,简单介绍了 QuickIO 这一项目。

开源地址:https://github.com/artbits/quickio

QucikIO 与 LiteDB 的异同

前面提到创作 QuickIO 的灵感源于 LiteDB , 现在展示一下 C# 的 LiteDB 和 Java 的 QuickIO 在读写数据时,编写代码风格的异同,了解其是如何借鉴和参考的。

Talk is cheap. Show me the code. —— Linus Torvalds
使用 C# 的 LiteDB 存储文档数据的示例代码,来源于官方文档,有删改。

// Create your POCO class entity
public class User
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string[] Phones { get; set; }
    public bool IsActive { get; set; }
}

// Open database (or create if doesn't exist)
using(var db = new LiteDatabase(@"C:\Temp\MyData.db"))
{
    // Get a collection (or create, if doesn't exist)
    var col = db.GetCollection<User>("Users");

    // Create your new user instance
    var user = new User
    { 
        Name = "John Doe", 
        Phones = new string[] { "8000-0000", "9000-0000" }, 
        IsActive = true
    };
    
    // Insert new user document (Id will be auto-incremented)
    col.Insert(user);
    
    // Update a document inside a collection
    user.Name = "Jane Doe";
    
    col.Update(user);
    
    // Use LINQ to query documents
    var results = col.Query()
        .Where(x => x.Name.StartsWith("J"))
        .Limit(10)
        .ToList();

    // and now we can query phones
    var r = col.FindOne(x => x.Phones.Contains("8888-5555"));
}

使用 Java 的 QuickIO 存储文档数据的示例代码。

// Create your POCO class entity
public class User extends IOEntity {
    public String name;
    public String[] phones;
    public Boolean isActive;

    public static User of(Consumer<User> customer) {
        User user = new User();
        customer.accept(user);
        return user;
    }
}

// Open database (or create if doesn't exist)
try (DB db = QuickIO.usingDB("MyData")) {
    // Get a collection
    Collection<User> col = db.collection(User.class);

    // Create your new user instance
    User user = User.of(u -> {
        u.name = "John Doe";
        u.phones = new String[]{"8000-0000", "9000-0000"};
        u.isActive = true;
    });

    // Insert new user document (_id will be auto-incremented)
    col.save(user);

    // Update a document inside a collection
    user.name = "Jane Doe";

    col.save(user);

    // Use Java lambda to query documents
    List<User> users = col.find(x -> x.name.startsWith("J"), options -> options.limit(10));

    // and now we can query phones
    User u = col.findOne(x -> Arrays.asList(x.phones).contains("8888-5555"));
}

通过上述示例代码的对比,两个数据库在查询数据时,并没有使用到 SQL 或 BSON 语句。LiteDB 通过 C# 的语言特性 LINQ 完成数据查询,因为 Java 不具备这一语言特性(表达式树),所以 QuickIO 只是使用 Lambda 表达式模拟出类似 LiteDB 的 API 风格,并且 QuickIO API 风格也有别于一些 Java ORM API 风格。综上所述,使用 QuickIO 进行数据的增删改查,类似于 Java Stream 流的操作。

QuickIO 的基本概况

使用场景有哪些?可用于客户端程序的数据存储,服务端小微型程序的数据存储,单机或嵌入式程序的数据存储,更多的使用场景还有待探索。

支持存储那些类型的数据?支持存储文档、键值对、文件类型的数据。示例代码如下:

// 存储文档类型的数据
db.collection(Book.class).save(Book.of(b -> {
    b.name = "On java 8";
    b.author = "Bruce Eckel";
    b.price = 129.8;
}));

// 存储键值对类型的数据
kv.write("Pi", 3.14);
kv.write(3.14, "Pi");
double d = kv.read("Pi", Double.class);
String s = kv.read(3.14, String.class);

// 存储文件类型的数据
tin.put("photo.png", new File("..."));
File file = tin.get("photo.png");

如何对每种类型的数据进行存储?文档和键值对类型的数据存储主要依靠 LevelDB + Protostaff 完成。因为 LevelDB 是 KV 数据库引擎,每条数据以key : value的格式进行存储,所以 QuickIO 使用 Snowflake 算法生成唯一 ID 作为 key,Java 对象作为 value,key 和 value 通过 Protostaff 序列化后存入 LevelDB 中,而读取数据只是上述过程的反向操作。对于文件类型的数据的存储,则是在 Java NIO 的基础上进行操作。

为何选择 LevelDB & Protostaff ?LevelDB 作为 KV 数据库引擎,其性能较为优越,提供的 API 相对简单,Java 平台的 LevelDB 库相对于 RocksDB 库的大小更小,完全满足编写嵌入式 NoSQL 数据库的需要。Protostaff 是一种 Protobuf 协议的序列化工具,而 Protobuf 是一个灵活的、高效的用于序列化数据的协议,因此,使用 Protostaff 可以提高数据序列化的效率,这点可以参考开源项目 MMKV。

QuickIO 如何实现类似 LiteDB 的 API? LevelDB 是以键值的方式存储数据,面对条件查询,QuickIO 通过遍历数据的方式进行查询,拿出每条数据进行比对,筛选出满足条件的数据。选择遍历的方式进行数据查询,是基于对 LevelDB 顺序读的性能优越的肯定,同时,也对反序列化数据的过程进行了优化,提升遍历的速度。一般情况下,条件查询,遍历10w条数据,耗时700毫秒左右。

// 查询价格大于或等于100的书籍的数据,降序排序,跳过前5条数据,限制返回10条数据
List<Book> books = collection.find(b -> b.price >= 100, options -> options.sort("price", -1).skip(5).limit(10));

如何实现索引的支持?LevelDB 自身是不支持索引的,当需要从大量的数据中查找其中一条,若只靠遍历数据的方式查询,随着数据规模的增长,迟早会力不从心。因此,QuickIO 实现了索引功能,该功能也是基于 LevelDB 设计,但只是实现了唯一索引。通过索引查询数据,速度也实现了质的飞跃。

// Book 的实体类的字段 isbn 为索引字段,实现索引查询
Book book = collection.findWithIndex(options -> options.index("isbn", "9787115585011"));

为何选择 Snowflake ID 作为 key?使用 Snowflake ID 作为 LevelDB 的 key 时,当条件查询为 id 或 createdAt 时,QuickIO 无需反序列化 LevelDB 的 value,即可完成数据的初步筛选,从而提升查询效率。同时,Snowflake ID 的范围亦可以转换为相对应的时间戳范围。

// 查询 id 比 minId 大的书籍的数据。
List<Book> books = collection.findWithID(id -> id > minId);
// 查询创建时间戳比当前时间戳小的书籍的数据。
List<Book> books = collection.findWithTime(createdAt -> createdAt < System.currentTimeMillis());

QucikIO 早期版本代码较为简单,随着不断迭代,代码和内部设计也逐渐变得复杂,因本文篇幅有限,无法一一详细探讨。关于更多的详细内容,后续我有空闲时间,再撰文分享,计划先后通过多章节详细介绍其的使用方法和内部实现。

关于作者

关于学习经历,计算机网络工程专业,因兴趣爱好而学习编程。关于工作经历,一直就职于非技术的产品岗位,不具有技术岗位的从业经验。

对于数据库的开发,作者并无相关经验,一切都是业余时间从零开始学习和探索。在编写数据库的过程中,也学习了解到一些优秀的数据库项目,例如 MongoDB、SQLite、MMKV、TiDB、LiteDB、NeDB、PoloDB 等。其中,TiDB 官方分享的文章更是深入浅出且循序渐进。TiDB 是一个分布式数据库,其底层使用到 RocksDB,而 RocksDB 又是在 LevelDB 的基础上开发的。所以 TiDB 分享的文章,对我来说具有很大的学习价值,若大家也感兴趣,推荐阅读:《TiDB 星球不完全指南》

因作者并非相关领域的专业人士,技术水平有限,若本文存在错误的内容,又或编写的数据库项目存在错误的设计,恳请大家批评指正。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349

推荐阅读更多精彩内容