iOS 持久化方案有哪些?
简单列举一下,iOS 的本地缓存方案有挺多,各有各的适用场景:
-
NSUserDefault
:系统提供的最简便的 key-value 本地存储方案,适合比较轻量的数据存储,比如一些业务 flag。主要原因还是其底层是用 plist 文件存储的,在数据量逐步变大后,可能会发生性能问题。
- 存文件,归档:
无论是自己转换业务数据为二进制再 writeFile,还是直接利用系统的
NSKeyedArchiver
接口归档成文件,都属于文件存储的方案。优势是开发简单,业务可以自行控制单文件的存储内容以避免可能发生的性能问题。 - sqlite、FMDB:
底层利用到数据的存储方案,比较适用数据量大,有查询,排序等需求的存储场景,缺点就是开发略复杂一些。
- CoreData、其他 ORM 方案:
CoreData 感觉好像应用并不是很广泛?
- Key-Value 接口的缓存方案:
这里特指提供 Key-Value 形式接口的缓存库,底层缓存可能使用文件或者 sqlite 都有。本文讨论的
YYCache
底层是混合使用文件 +sqlite 的存储方式。基于接口简便,性能优于NSUserDefault
的特性,应该适用于大多数的业务场景,但是无法适用上面数据库类似的使用场景。
聊聊 YYCache 的优秀设计
这里其实 yy 大神本人在博文《YYCache 设计思路》中对其设计思路有比较详尽的介绍,建议大家可以先去读一读,本文就其相对于其他缓存库的一些优势点聊一聊。
高性能的线程安全方案
首先高性能是 YYCache
比较核心的一个设计目标,挺多代码逻辑都是围绕性能这个点来做的。
作为对比,yy 提出了 TMMemoryCache
方案的性能缺陷。TMMemoryCache
的线程安全采用的是比较常见的通过 dispatch_barrier
来保障并行读,串行写的方案。该方案我在上一篇《AFNetworking 源码解析与面试考点思考》中有介绍。那么 TMMemoryCache
存在性能问题的原因会是因为其 dispatch_barrier
的线程安全方案吗?
答案应该在其同步接口的设计上:
- (id)objectForKey:(NSString *)key
{if (!key)
return nil;
__block id objectForKey = nil;
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
[self objectForKey:key block:^(TMMemoryCache *cache, NSString *key, id object) {
objectForKey = object;
dispatch_semaphore_signal(semaphore);
}];
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
#if !OS_OBJECT_USE_OBJC
dispatch_release(semaphore);
#endif
return objectForKey;
}
TMCache
在同步接口里面通过信号量来阻塞当前线程,然后切换到其他线程(具体代码在其异步接口里面,是通过 dispatch_async 到一个并行队列来实现的)去执行读取操作。按照 yy 的说法主要的性能损耗应该在这个线程切换操作,同步接口没必要去切换线程执行。
yy 这边的思路是通过自旋锁来保证线程安全,但仍然在当前线程去执行读操作,这样就可以节省线程切换带来的开销。(不过我在 YYCache 的最新代码里看到的是普通的互斥锁,并没有用自旋锁,应该是后面又做了方案上的修改?)
除了加锁串行,dispatch_sync 实现同步的方案是否可行呢?
除了 yy 提供的加锁串行方案,我们来看看前面介绍过的 barrier 并行读串行写方案是否也存在性能问题。如果使用该方案,同步接口可能是这样的:
- (id)objectForKey:(NSString *)key
{
__block id object = nil;
dispatch_sync(concurrent_queue, ^{object = cache[key]; // 读接口,不用 barrier,保证读与读能够并行
});
return object;
}
- (void)setObject:(id)object forKey:(NSString *)key
{
dispatch_barrier_sync(concurrent_queue, ^{ // 写接口,barrier 保证与读互斥
cache[key] = object;
});
}
经过 demo 验证,可以发现虽然是 dipatch 到一个 concurrent_queue 中执行,但是由于是 sync 同步派发,实际上并不会切换到新的线程执行。也就是说该方案也能做到节省线程切换的开销。
划重点: dispatch_sync
不会切换调用线程执行,这个结论好像也是个面试考点?
那么该方案与加锁串行的方案相比,性能如何呢?
barrier 实现并行读串行写 vs 互斥锁串行处理的性能比较
单线程测试
首先跑了下 YYCache 自带的 benchmark,其原理是测试单线程做 20000 次读或者写的总耗时。其中 TMCache new 表示修改为 dispatch_sync
后的测试数据。
===========================
Memory cache set 200000 key-value pairs
NSDictionary: 67.53
NSDict+Lock: 73.47
YYMemoryCache: 133.08
PINMemoryCache: 257.59
NSCache: 457.63
TMCache: 7638.25
TMCache new: 297.58
===========================
Memory cache get 200000 key-value pairs
NSDictionary: 43.32
NSDict+Lock: 53.68
YYMemoryCache: 93.15
PINMemoryCache: 141.12
NSCache: 73.89
TMCache: 7446.88
TMCache new: 210.80
从结论看,单线程用 dispatch_sync
的方案,比 YYCache
的锁串行方案要慢 2 倍多一点,比原始的信号量强行同步操作要快 25 到 35 倍。
所以开发过程中需要避免类似 TMCache 原始写法的同步接口实现方案。
多线程测试
display_barrier
是并行读,串行写的方案,理论上在多线程并发的场景会更有优势,所以我尝试写了个多线程的 benchmark 来对比性能,代码如下:
typedef void(^exec_block)(id key, id value);
+ (void)benchmark:(NSString *)type exec:(exec_block)block keys:(NSArray *)keys values:(NSArray *)values
{
int count = 10000;
printf("Memory cache %s %i pairs\n", type.UTF8String, count);
__block NSTimeInterval begin, end, time;
begin = CACurrentMediaTime();
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t queue = dispatch_queue_create(type.UTF8String, DISPATCH_QUEUE_CONCURRENT);
for (int i = 0; i < count; i++) {
dispatch_group_async(group, queue, ^{block(keys[i], values[i]); // 执行不同 cache 的具体 set 或者 get 操作
});
}
dispatch_group_notify(group, queue, ^{end = CACurrentMediaTime();
time = end - begin;
printf("%s: %8.2f\n", type.UTF8String, time * 1000);
});
}
因为是并发执行,所以结束时间是通过 dispatch_group 来拿的。函数接收外部传入的 exec_block
作为输入,block 内部执行具体的 YYCache
和TMCache
的 set/get 方法。
这个测试方案存在一个问题,整个耗时大头在 dispatch_group_async
的派发上,block 内部是否执行 cache 的 get/set 方法,对整体耗时结果影响不大。所以最终我也没有得到一个比较准确的测试结果,或许固定创建几个线程来做并发测试会更靠谱一些。
高性能的本地存储方案
除了多线程的高性能实现,YYCache
在本地持久化如何提高性能也有个小策略。核心问题应该就是二进制数据从文件读写和从 sqlite 读写究竟哪个更快?sqlite 官网有一个测试结论:
表格中数值表示存文件耗时除以存数据库耗时,大于 1 表示存数据库更快,表示为绿色。
基于这个结论和自己的实测结果,YYCache 采取的方案是大于 20k 的采取直接存储文件,然后在 sqlite 里面存元信息(比如说文件路径),小于 20k 的直接存储到 sqlite 里面。
数据完整性保障:
对于有关联的数据,存储时一定需要保障其完整性,要么全成功,要么全失败。比如 YYCache
在存储文件时,存在数据库的元信息和实际文件的存储就必须保障原子性。如果云信息存储成功,但是文件存储失败,就会导致逻辑问题。具体 YYCache
代码如下:
if (![self _fileWriteWithName:filename data:value]) {return NO;}
if (![self _dbSaveWithKey:key value:value fileName:filename extendedData:extendedData]) {[self _fileDeleteWithName:filename];
return NO;
}
return YES;
这里可以看到,只有文件存成功了才会存数据库元信息,如果数据库元信息存失败了,会去删除已经存储成功的文件。
我们业务开发存储关联数据的时候,也需要注意这个逻辑。
缓存淘汰策略
除了性能之外,YYCache
也新增了一些实用功能。
比如 LRU 算法,基于存储时长、数量、大小的缓存控制策略等。
LRU 算法采用经典的双链表 + 哈希表的方案实现的,很适合不熟悉的同学参考学习,这里就不展开了。
原文链接:http://www.luoyibu.cn/posts/1…
欢迎扫码关注我的微信公众号