FastKV的轻量化回归

658 阅读10分钟

一、前言

FastKV 最早一版发布,已有4年多了( 前文:FastKV:一个真的很快的KV存储库 )。
期间收到了不少网友的反馈,这些反馈很宝贵,一方面这些反馈对于 FastKV 这个库的改进很有帮助,另一方面也是支撑我持续维护的动力。
这段时间工作上确实很繁忙,这几天终于腾出点时间,回头来清理技术债了。
每过一段时间我都会回头看一下,每次回顾代码,都有觉得有需要改进的地方;
这次回顾,看到过去确实走了不少弯路,做代码优化之余,也顺便分享一下做这些改动的思路历程。

二、移除多进程支持

最初的时候FastKV是不支持多进程的,不是实现不了,而是支持多进程性价比不高。
我当时的想法是,需要用到多进程的APP其实不多,这不多的APP里面需要用到的跨进程读写更是寥寥,这样的话,如果有需要跨进程读写,用AIDL实现即可。
但后来发布FastKV之后,有一些网友问是否支持多进程,于是我想,既然有人提了,那就实现一下吧。

classDiagram
AbsFastKV <|-- FastKV
AbsFastKV <|-- MPFastKV
AbsFastKV : -Map data
AbsFastKV: +get()
class FastKV{
+set()
}
class MPFastKV{
+set()
}

非多进程版本和多进程版本重要区别在于I/O策略,其他部分,如协议格式,数据解析,读取接口之类的操纵是相同。
于是很自然的,就做了一个抽象,来复用相同的处理。
然而,多进程支持写完了之后,我回头去问这些提问的网友,问其APP是否需要多进程,结果是:要么不回复,要么回复其实也不需要多进程……

实现这个MPFastKV, 其副作用还是比较明显的:

  1. 增加了抽象类之后,FastKV的调用链路变的更复杂了,一些操作需要多次往返父类和子类;
  2. 需要为MPFastKV定制一些其专属操作的接口;
  3. 最重要的是,有一些实现由于要兼容 MPFastKV, 拖着这个包袱,FastKV的迭代可谓负重前行。

我其实很久之前就有移除 MPFastKV 的念头,直到前段时间和一个网友讨论其遇到的问题,发现在需要支持 MPFastKV 的情况下,问题的解决确实变得很困难。
于是,这一次改动,我决定移除“支持多进程”这个特性。
移除了MPFastKV之后,抽象类自然也不需要了,实现变得紧凑了,代码可读性也变好了。

classDiagram
class FastKV{
 -Map data
 +get()
 +set()
}

要真有用到 MPFastKV 的怎么办?

  1. 历史的版本Maven中央仓库会保留,不影响使用;
  2. 我在改动之前切了一个备份分支:github.com/BillyWei01/…
    如果有需要修改的内容,可以沿改分支迭代。

三、移除“外部文件”特性

FastKV的初版就支持一个特性:为了避免大字符串(有一个大小阈值,可以设定)影响主文件的加载和更新,对大字符串和大数组做特殊处理,另起文件写入。
无论是FastKV也好,SharePreferences、MMKV也好,其定位都是用来保存轻量级数据 ,一般是不建议用来保存大字符串的。
不过也仅是建议,用的时候不会想那么多。不过,真要写入大字符串了,也仅是性能上有些损耗,不影响正常使用。
而为了减少大字符串的影响,当时设计了“另起文件写入,主文件仅记录文件名“这么一个策略。

最初的版本,为了数据一致性,写大字符串到其他文件用的同步写入,即当前线程保存好大字符串了再将其文件名更新到主文件。
当时的想法是:日常使用中应该没有很多要写大字符串的吧,万一真有,确保数据落盘优先,宁愿牺牲一点性能。
后来,在网上看到这篇文章:键值存储方式对比, 文中提到,在大量写入大字符串的性能测试,FastKV表现最差——那可不嘛,SharePerences用的异步,扔后台就不管;MMKV用到的mmap内存映射,扔给操作系统就不管了,只有FastKV傻乎乎在当前线程等待写入完成。
于是,就改成了异步写入大字符串 —— 开启了“潘多拉魔盒”。

怎么说呢,SharePeferences的apply这种异步,是整个文件数据的异步,是原子性的;
而“大字符串异步写入其他文件,主文件更新其文件文件名”的操作,则不是原子性的,一致性不好做
一旦有连续的”新增、修改、删除,读取“的组合,其情况会变得非常复杂。
比如连续调用put, 要考虑取消此前的写入(异步的),避免冗余写入;
各种put/remove之间夹一个get操作, 又要确保get操作能拿到数据……
加上主文件中记录的“文件名”而不是字符串,为了这个异步,我当时真的写得很痛苦。

在考虑了各种辅助措施之后,自己设定的几个单元测试用例终于通过了。
然而,还是有测试用例兜不住的情况:github.com/BillyWei01/…
issue描述的问题,过程如下:

sequenceDiagram
    participant App as 应用程序
    participant Main as 主线程
    participant Memory as 内存数据
    participant MainFile as 主文件(.kva/.kvb)
    participant AsyncPool as 异步线程池
    participant ExternalFile as 外部文件

    Note over App,ExternalFile: 大字符串保存流程中的数据丢失风险

    App->>Main: putString("key", "大字符串数据")
    Main->>Main: 调用 saveArray()
    
    rect rgb(255, 240, 240)
        Note over Main: 关键问题区域
        Main->>Main: 1. 生成随机文件名 fileName
        Main->>Main: 2. 调用 wrapArray() 写入文件名到主文件
        Main->>Memory: 3. 更新内存数据结构 (c.value = fileName)
        Main->>Main: 4. 调用 updateChange() 
        Main->>MainFile: 5. 主文件立即更新完成 ✓
        Main->>AsyncPool: 6. 提交异步任务 externalExecutor.execute()
        Main-->>App: 7. 方法返回 (主文件已更新)
    end

    rect rgb(255, 255, 240)
        Note over AsyncPool,ExternalFile: 异步执行阶段
        AsyncPool->>AsyncPool: 等待线程池调度...
        Note over AsyncPool: 🚨 如果此时App闪退
        Note over AsyncPool: 异步任务还未执行
        AsyncPool->>ExternalFile: Utils.saveBytes() 保存大字符串
    end

    rect rgb(240, 240, 255)
        Note over App,ExternalFile: 问题场景:App闪退
        App->>App: 💥 App闪退/被杀死
        Note over AsyncPool: 异步任务被取消
        Note over ExternalFile: 外部文件未创建
        Note over MainFile: 主文件中已记录新文件名
        Note over MainFile: 但对应的外部文件不存在
    end

    rect rgb(255, 240, 255)
        Note over App,ExternalFile: 重启后的问题
        App->>Main: App重启,读取数据
        Main->>MainFile: 读取主文件
        MainFile-->>Main: 返回文件名引用
        Main->>ExternalFile: 根据文件名读取外部文件
        ExternalFile-->>Main: ❌ 文件不存在
        Main-->>App: 返回 null (数据丢失)
    end

简单概括,就是异步保存大字符串,写入过程中,APP退出,大字符串写入失败;
而主文件记录大字符串的文件名是很快的(写入mmap), 结果就是文件名保存成功了,但是对应的文件没成功,update丢失了。
有人可能会说,同步写入和异步写入都不能保证在APP退出的情况下完整写入,都会丢失update。
也没错,但同步写入和异步写入的区别在于:

  • 同步写入:先完成大字符串的写入,再来更新主文件,如果大字符串没写入完成就APP退出了,原来的值也没变;
  • 异步写入:上述情况,原来的值被新的文件名覆盖,而新的文件名对应的值也没写成功——新旧value都没了!

为此,网友和我分别提出了不同的方案:

  • 网友:不用随机文件名,用key生成固定名称;
  • 我:先异步写入文件,写成功了再回来更新主文件记录。

这次改动,我两种方案都尝试了,都有各种问题,具体过程我就不具体分析了.
简而言之,好比本来就深处泥潭,越是挣扎,反而越是深陷。

down.png

问题的源头,其实就是“大字符串写到外部文件,主文件记录文件名“这个策略。
执行这个策略后,同步写入新文件就会耗时,异步写入一致性问题又难以解决(或许有走得通的路,但是代价不会少)。
因此,这次改动,我移除了这个策略:回归到所有key-value全部写到相同的地方。移除此策略后,代码相对简洁了许多。
不过,虽然目前写入大字符串不再写到外部文件,但读取部分的代码还是保留了,以便兼容旧版本:旧版本写入到外部文件的大字符串,新版本依旧支持读取。

舍却一念起,刹觉天地宽。
走不通的时候,不一定要一条路走到黑,放下执念,海阔天空。

sky.jpg

四、新增“类型兼容“

  • 问题背景github.com/BillyWei01/…
  • 问题概述
    1. 网友用getAll接口获取到一个map,保存text;
    2. 读取text, 反序列化,调用putAll接口保存,报错。
  • 问题原因
    1. getAll 取到的整数类型,经过Gson的序列化=>反序列化,变成了浮点型。
    2. 之前我以为同一个keyput/get输入的value的类型总会相同,所以putDouble操作时取到一个Value的容器(BaseContainer), 直接强转成DoubleContainer —— 但原本该key保存的是Long类型,其容器是LongContainer, 因此抛了类型转换异常。
  • 解决方案
    1. 用户解决了Gson转换类型的问题,使得序列化=>反序列化后类型一致;
    2. 我对putget做了类型检查,如果当前保存的类型和put/get接口指定的类型不一致
      • put接口:移除旧值,写入新值;
      • get接口:返回默认值

这个方案,解决得不彻底,put没有问题,但get的处理还差点:
如果当前保存的类型要读取的类型不一致,应该做一下转换然后返回。
比如当前类型为Long, 然后getDouble, 可以做一下 long => double 的转换 —— 这正是这次升级的改动点只之一。
以FastKV的Long类型容器为例:

static class LongContainer extends BaseContainer {
    long value;

    LongContainer(int offset, long value) {
        this.offset = offset;
        this.value = value;
    }

    @Override
    byte getType() {
        return DataType.LONG;
    }

    // 提供各种类型转换接口
    @Override
    boolean toBoolean() {
        return value != 0L;
    }

    @Override
    int toInt() {
        return (int) value;
    }

    @Override
    long toLong() {
        return value;
    }

    @Override
    float toFloat() {
        return (float) value;
    }

    @Override
    double toDouble() {
        return (double) value;
    }

    @Override
    String toStringValue() {
        return String.valueOf(value);
    }
}

我之所想到这个点,是因为在一次研究SQLite的“类型亲和性”的原理,联想到FastKV也可以做类似的处理。
SQLite虽然会在创建表的时候给字段声明类型,但是真正insert的时候,才会决定保存的记录的每一个字段的类型
比如在CREATE table的时候声明了a字段为INTEGER类型,而insert的时候,即使保存一个3.14或者'3.14'这样的值,也是不会报错的;
至于调用 getInt 的API,则是取决于数据库驱动的实现,一般来说会截断小数,返回整数。
SQLite的动态类型特性,使其用法相对灵活,兼容度高。
受其启发,FastKV的get接口也实现了类似特性。
以上面的case为例,经历 “序列化=>反序列化” 之后,map中原本的41000变成了41000.0,再put到FaskKV, 保存的内容变为Double类型,当再调用getLong时,依旧会返回41000——此过程实现了类型的兼容。
当然了,一般情况下,最好还是尽量避免这种因为序列化而导致的类型转换,因为假如整数大于2^53, 转成double会丢失精度,具体原因大家应该都清楚,这里就不展开了。

五、重构代码

最初的时候,FastKV的代码的特性不多,代码不是很复杂,所以内容大多都聚集在FastKV.java文件中;
但随着功能的迭代,代码都放在一个文件中就显得比较臃肿了。
显然,该拆分了。

image.png

这次升级,将文件操作、数据解析、内存管理、日志等内容分拆到各个辅助类中。

模块核心职责关键功能
FastKV核心API和业务协调API接口、数据管理、业务逻辑协调、生命周期管理
FileHelper文件I/O管理与工具服务mmap内存映射、A/B/C文件读写、备份恢复、文件同步、缓冲区操作
DataParser数据解析和序列化二进制数据编码解码、Container创建、类型转换
GCHelper垃圾回收和内存管理无效数据清理、内存整理、缓冲区扩容收缩
LoggerHelper日志接口封装记录日志

在完成代码重构之后,同时补充了更加完善的代码注释,以及架构文档
对于客户端数据存储感兴趣的朋友,欢迎一起研究。

六、总结

放下执念,轻装前行: 舍弃不切实际或代价高昂的特性,是保持轻量与健壮的关键。
大道至简,方得始终: 高效(快)、可靠(稳)、简洁(轻),才是客户端存储库的核心价值。
在不断的回望与抉择中,唯有坚守本心,勇于舍离,方能行稳致远,历久弥新。