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 星球不完全指南》
因作者并非相关领域的专业人士,技术水平有限,若本文存在错误的内容,又或编写的数据库项目存在错误的设计,恳请大家批评指正。