美文网首页Android开发Java
嵌入式数据库 QuickIO 诞生记

嵌入式数据库 QuickIO 诞生记

作者: 1225fe6bdfc1 | 来源:发表于2023-05-02 14:31 被阅读0次

    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 星球不完全指南》

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

    相关文章

      网友评论

        本文标题:嵌入式数据库 QuickIO 诞生记

        本文链接:https://www.haomeiwen.com/subject/qvgajdtx.html