FastKV的轻量化回归

一、前言

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生成固定名称;
  • 我:先异步写入文件,写成功了再回来更新主文件记录。

这次改动,我两种方案都尝试了,都有各种问题,具体过程我就不具体分析了.

简而言之,好比本来就深处泥潭,越是挣扎,反而越是深陷。

问题的源头,其实就是"大字符串写到外部文件,主文件记录文件名"这个策略。

执行这个策略后,同步写入就新文件会耗时,异步写入一致性问题又难以解决(或许有走得通的路,但是代价不会少)。

因此,这次改动,我移除了这个策略:回归到所有key-value全部写到相同的地方。移除此策略后,代码相对简洁了许多。

不过,虽然目前写入大字符串不再写到外部文件,但读取部分的代码还是保留了,以便兼容旧版本:旧版本写入到外部文件的大字符串,新版本依旧支持读取。

舍却一念起,刹觉天地宽。

走不通的时候,不定义要一条路走到黑,放下执念,心莲自开。

四、新增"类型兼容"

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

这个方案,解决得不彻底,put没有问题,但get的处理还差点:

如果当前保存的类型要读取的类型 不一致,应该做一下转换然后返回。

比如当前类型为Long, 然后getDouble, 可以做一下 long => double 的转换 ------ 这正是这次升级的改动点只之一。

以FastKV的Long类型容器为例:

java 复制代码
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文件中;

但随着功能的迭代,代码都放在一个文件中就显得比较臃肿了。

显然,该拆分了。

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

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

在完成代码重构之后,同时补充了更加完善的代码注释,以及架构文档

对于客户端数据存储感兴趣的朋友,欢迎一起研究。

六、总结

放下执念,轻装前行: 舍弃不切实际或代价高昂的特性,是保持核心轻量与健壮的关键。
大道至简,方得始终: 高效(快)、可靠(稳)、简洁(轻),才是客户端存储库的核心价值。

在不断的回望与抉择中,唯有坚守本心,勇于舍离,方能行稳致远,历久弥新。

相关推荐
coderlin_4 小时前
BI布局拖拽 (1) 深入react-gird-layout源码
android·javascript·react.js
2501_915918414 小时前
Fiddler中文版全面评测:功能亮点、使用场景与中文网资源整合指南
android·ios·小程序·https·uni-app·iphone·webview
ai小鬼头5 小时前
AIStarter新版重磅来袭!永久订阅限时福利抢先看
人工智能·开源·github
如何原谅奋力过但无声5 小时前
上传GitHub步骤(自用版)
github
春哥的研究所5 小时前
可视化DIY小程序工具!开源拖拽式源码系统,自由搭建,完整的源代码包分享
小程序·开源·开源拖拽式源码系统·开源拖拽式源码·开源拖拽式系统
wen's6 小时前
React Native安卓刘海屏适配终极方案:仅需修改 AndroidManifest.xml!
android·xml·react native
编程乐学7 小时前
网络资源模板--基于Android Studio 实现的聊天App
android·android studio·大作业·移动端开发·安卓移动开发·聊天app
jingshaoqi_ccc7 小时前
GitKraken最后一个免费版本和下载地址
git·github·gitkraken·版本管理工具
乌云暮年7 小时前
Git简单命令
git·gitee·github·batch命令
ajassi20007 小时前
开源 python 应用 开发(三)python语法介绍
linux·python·开源·自动化