一、前言
自 FastKV 最早一版发布,已有4年多了( 前文:FastKV:一个真的很快的KV存储库 )。
期间收到了不少网友的反馈,这些反馈很宝贵,一方面这些反馈对于 FastKV 这个库的改进很有帮助,另一方面也是支撑我持续维护的动力。
这段时间工作上确实很繁忙,这几天终于腾出点时间,回头来清理技术债了。
每过一段时间我都会回头看一下,每次回顾代码,都有觉得有需要改进的地方;
这次回顾,看到过去确实走了不少弯路,做代码优化之余,也顺便分享一下做这些改动的思路历程。
二、移除多进程支持
最初的时候FastKV是不支持多进程的,不是实现不了,而是支持多进程性价比不高。
我当时的想法是,需要用到多进程的APP其实不多,这不多的APP里面需要用到的跨进程读写更是寥寥,这样的话,如果有需要跨进程读写,用AIDL实现即可。
但后来发布FastKV之后,有一些网友问是否支持多进程,于是我想,既然有人提了,那就实现一下吧。
非多进程版本和多进程版本重要区别在于I/O策略,其他部分,如协议格式,数据解析,读取接口之类的操纵是相同。
于是很自然的,就做了一个抽象,来复用相同的处理。
然而,多进程支持写完了之后,我回头去问这些提问的网友,问其APP是否需要多进程,结果是:要么不回复,要么回复其实也不需要多进程......
实现这个MPFastKV, 其副作用还是比较明显的:
- 增加了抽象类之后,FastKV的调用链路变的更复杂了,一些操作需要多次往返父类和子类;
- 需要为MPFastKV定制一些其专属操作的接口;
- 最重要的是,有一些实现由于要兼容 MPFastKV, 拖着这个包袱,FastKV的迭代可谓负重前行。
我其实很久之前就有移除 MPFastKV 的念头,直到前段时间和一个网友讨论其遇到的问题,发现在需要支持 MPFastKV 的情况下,问题的解决确实变得很困难。
于是,这一次改动,我决定移除"支持多进程"这个特性。
移除了MPFastKV之后,抽象类自然也不需要了,实现变得紧凑了,代码可读性也变好了。
要真有用到 MPFastKV 的怎么办?
- 历史的版本Maven中央仓库会保留,不影响使用;
- 我在改动之前切了一个备份分支:github.com/BillyWei01/...
如果有需要修改的内容,可以沿改分支迭代。
三、移除"外部文件"特性
FastKV的初版就支持一个特性:为了避免大字符串(有一个大小阈值,可以设定)影响主文件的加载和更新,对大字符串和大数组做特殊处理,另起文件写入。
无论是FastKV也好,SharePreferences、MMKV也好,其定位都是用来保存轻量级数据 ,一般是不建议用来保存大字符串的。
不过也仅是建议,用的时候可能真没想那么多,真要写入大字符串了,就是性能上些损耗,也还是能用的。
而为了减少大字符串的影响,当时设计了"另起文件写入,主文件仅记录文件名"这么一个策略。
最初的版本,为了数据一致性,写大字符串到其他文件用的同步写入,即当前线程保存好大字符串了再将其文件名更新到主文件。
当时的想法是:日常使用中应该没有很多要写大字符串的吧,万一真有,确保数据落盘优先,宁愿牺牲一点性能。
后来,在网上看到这篇文章:键值存储方式对比, 文中提到,在大量写入大字符串的性能测试,FastKV表现最差------那可不嘛,SharePerences用的异步,扔后台就不管;MMKV用到的mmap内存映射,扔给操作系统就不管了,只有FastKV傻乎乎在当前线程等待写入完成。
于是,就改成了异步写入大字符串 ------ 开启了"潘多拉魔盒"。
怎么说呢,SharePeferences的apply
这种异步,是整个文件数据的异步,是原子性的;
而"大字符串异步写入其他文件,主文件更新其文件文件名"的操作,则不是原子性的,一致性不好做 。
一旦有连续的"新增、修改、删除,读取"的组合,其情况会变得非常复杂。
比如连续调用put, 要考虑取消此前的写入(异步的),避免冗余写入;
各种put/remove之间夹一个get操作, 又要确保get操作能拿到数据......
加上主文件中记录的"文件名"而不是字符串,为了这个异步,我当时真的写得很痛苦。
在考虑了各种辅助措施之后,自己设定的几个单元测试用例终于通过了。
然而,还是有测试用例兜不住的情况:github.com/BillyWei01/...
issue描述的问题,过程如下:
简单概括,就是异步保存大字符串,写入过程中,APP退出,大字符串写入失败;
而主文件记录大字符串是很快的(写入mmap), 结果就是文件名保存成功了,但是对应的文件没成功,update丢失了。
有人可能会说,同步写入和异步写入都不能保证在APP退出的情况下完整写入,都会丢失update。
也没错,但同步写入和异步写入的区别在于:
- 同步写入:先完成大字符串的写入,再来更新主文件,如果大字符串没写入完成就APP退出了,原来的值也没变;
- 异步写入:上述情况,原来的值被新的文件名覆盖,而新的文件名对应的值也没写成功------新旧value都没了!
为此,网友和我分别提出了不同的方案:
- 网友:不用随机文件名,用key生成固定名称;
- 我:先异步写入文件,写成功了再回来更新主文件记录。
这次改动,我两种方案都尝试了,都有各种问题,具体过程我就不具体分析了.
简而言之,好比本来就深处泥潭,越是挣扎,反而越是深陷。

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

四、新增"类型兼容"
- 问题背景 :github.com/BillyWei01/...
- 问题概述 :
- 网友用
getAll
接口获取到一个map,保存text; - 读取text, 反序列化,调用
putAll
接口保存,报错。
- 网友用
- 问题原因 :
getAll
取到的整数类型,经过Gson的序列化=>反序列化,变成了浮点型。- 之前我以为同一个
key
的put/get
的类因为是相同,所以putDouble
操作时取到一个Value的容器(BaseContainer
), 直接强转成DoubleContainer
------ 但原本该key保存的是Long类型,其容器是LongContainer
, 因此抛了类型转换异常。
- 解决方案 :
- 用户解决了Gson转换类型的问题,使得序列化=>反序列化后类型一致;
- 我对
put
和get
做了类型检查,如果当前保存的类型和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 | 日志接口封装 | 记录日志 |
在完成代码重构之后,同时补充了更加完善的代码注释,以及架构文档。
对于客户端数据存储感兴趣的朋友,欢迎一起研究。
六、总结
放下执念,轻装前行: 舍弃不切实际或代价高昂的特性,是保持核心轻量与健壮的关键。
大道至简,方得始终: 高效(快)、可靠(稳)、简洁(轻),才是客户端存储库的核心价值。
在不断的回望与抉择中,唯有坚守本心,勇于舍离,方能行稳致远,历久弥新。