iOS的高性能、高实时性key-value持久化组件

今年上半年时候看到微信开发团队的这么一篇文章MMKV--基于 mmap 的 iOS 高性能通用 key-value 组件,文中提到了用mmap实现一个高性能KV组件,虽然并无展现太多的具体代码,可是基本思路讲的仍是很清楚的。
文章最后提到了开源计划,等了快半年还没看到这个组件源码,因而决定本身试着写一个。python

轮子

按照惯例先上轮子,能够给个star收藏一下哦~git

FastKV githubgithub

关于NSUserDefaults

在开始写这个组件以前,应该先调研一下NSUserDefaults性能(ps:这里有个失误,事实上我是在写完这个组件之后才调研的)。objective-c

据我所知NSUserDefaults有一层内存缓存的,因此它提供了一个叫synchronize的方法用于同步磁盘和缓存,可是这个方法如今苹果在文档中告诉咱们for any other reason: remove the synchronize call,总之就是不再须要调用这个方法了。缓存

测试结果以下(写入1w次,值类型是NSInteger,环境:iPhone 8 64G, iOS 11.4)微信

synchronize耗时:137ms微信开发

synchronize耗时:3758msapp

很明显synchronize对性能的损耗很是大,由于本文须要的是一个高性能高实时性的key-value持久化组件,也就是说在一些极端状况下数据也须要可以被持久化,同时又不影响性能。所谓极端状况,好比说在App发生Crash的时候数据也可以被存储到磁盘中,并不会由于缓存和磁盘没来得及同步而形成数据丢失。框架

从数据上咱们能够看到非synchronize下的性能仍是挺好的,比上面那篇微信的文章中的测试结果貌似要好不少嘛。那么mmapNSUserDefaults在高性能上的优点彷佛并不明显的。模块化

那么咱们再来看一下高实时性这个方面。既然苹果在文档中告诉咱们remove the synchronize,难道苹果已经解决的NSUserDefaults的高实时性和高性能兼顾的问题?抱着试一试的心态笔者作了一下测试,答案是否认的。在不使用synchronize的状况下,极端状况依旧会出现数据丢失的问题。那么咱们的mmap仍是有它的用武之地的,至少它在保证的高实时性的时候还兼顾到了性能问题。

为了便于更好的理解,在阅读接下来的部分前请先阅读这篇文章。MMKV--基于 mmap 的 iOS 高性能通用 key-value 组件

数据序列化

具体的实现笔者仍是参考了上面微信团队的MMKV,那篇文章已经讲得比较详细了,所以对那篇文章的分析在这里就再也不展开了。

在这里要提到的一个点是有关于数据序列化。MMKV在序列化时使用了Google开源的protobuf,笔者在实现的时候考虑到各方面缘由决定自定义一个内存数据格式,这样就避免了对protobuf的依赖。

自定义协议主要分为3个部分:Header Segment、Data Segment、Check Code。

Header Segment

32/64bit 32bit 32/64bit 32/64bit 32/64bit
VALUE_TYPE VERSION OBJC_TYPE length KEY length DATA length

这部分的长度是固定的,160bit或288bit。

VALUE_TYPE:数据的类型,目前有8种类型bool、nil、int3二、int6四、float、double、string、data。

VERSION:数据记录时的版本。

OBJC_TYPE length:OC类名字符串的长度。

KEY length:key的长度。

DATA length:value的长度。

Data Segment

Data Data Data
OBJC_TYPE KEY DATA

OBJC_TYPE:OC类名的字符串。

KEY:key。

DATA:value。

Check Code

16bit
CRC code

CRC code:倒数16位以前数据的CRC-16循环冗余检测码,用于后期数据校验。

空间增加

分配策略

mmap的使用涉及一个内存空间的分配问题,咱们在这里提供了两种内存分配策略。

一种策略是在MMKV的文章中提到,在append时遇到内存不够用的时候,会进行序列化排重。在序列化排重后仍是不够用的话就将文件扩大一倍,直到够用。

size_t allocationSize = 1;
while (allocationSize <= neededSize) {
    allocationSize *= 2;
}
return allocationSize;
复制代码

另外一种策略参考了python list的内存分配实现。

size_t allocationSize = (neededSize >> 3) + (neededSize < 9 ? 3 : 6);
return allocationSize + neededSize;
复制代码

内存抖动

在只考虑在添加新的key的状况下这两种内存分配策略比较好的,可是在屡次更新key时可能会出现连续的排重操做,下面用一个例子来讲明。

若是当前分配的mmap size仅仅只比当前正在使用的size多出极少极少一点,以致于接下来任何的append操做都会触发排重,可是因为每次都是对key进行更新操做,若是当前mmap的数据已是最小集合了(没有任何重复key的数据),因而在排重完成后mmap size又恰好够用,不须要从新分配mmap size。这时候mmap size又是仅仅只比当前正在使用的size多出极少极少一点,而后任何的append又会走一遍上述逻辑。

为了解决这个问题,笔者在append操做的时候附加了一个逻辑:正常状况下allocationSize是按照当前实际neededSize来计算的,若是当前是对key进行更新操做,那么计算allocationSize会迭代两次,即第一次计算的allocationSize就是第二次计算中的neededSize

size_t totalSize = dataLength + FastKVHeaderSize;
size_t neededSize = updated ? [self _fkvAllocationSizeWithNeededSize:totalSize + size] : totalSize + size;
if (neededSize > _mmsize
    || (updated && [self _fkvAllocationSizeWithNeededSize:neededSize] > _mmsize)) {
    // 从新分配mmap
}
复制代码

其余优化

有一些OC对象的存储是能够优化的,好比NSDate、NSURL,在实际存储时能够当成double和NSString来进行序列化,既提升了性能又减小了空间的占用。

性能比较

测试结果以下(1w次,值类型是NSInteger,环境:iPhone 8 64G, iOS 11.4)

add耗时:70ms (NSUserDefults Sync:3469ms

update耗时:80ms (NSUserDefults Sync:3521ms

get耗时:10ms (NSUserDefults:48ms

测试下来mmap性能确实比NSUserDefults Sync要好很多,也和微信那篇文章中对MMKV的性能测试结果基本一致。总的来讲,若是对实时性要求不高的项目,建议仍是使用官方的NSUserDefults

其余开源做品

TinyPart —模块化框架 github 掘金

Coolog —可扩展的log框架 github 掘金

WhiteElephantKiller —无用代码扫描工具 github

参考

MMKV--基于 mmap 的 iOS 高性能通用 key-value 组件

alexlee002/mmkv