作者 | 我爱吃海米
导读
挪动端开发中,IO密集问题在很多时候没有失去短缺的器重和解决,贸然的把IO导致的卡顿放到异步线程,可能会导致真正的问题被覆盖,前人挖坑前人踩。其实首先要想的是,数据存储形式是否正当,数据的应用形式是否正当。本文介绍度加剪辑对MMKV的应用和优化。
全文14813字,预计浏览工夫38分钟。
01 所有皆文件-挪动端IO介绍
挪动端的App程序很多状况是IO密集型,比如说聊天信息的读取和发送、短视频的下载和缓存、信息流利用的图文缓存等。
绝对于计算密集,IO密集场景更加多样,比方零碎SharedPreferences和NSUserDefault自带的一些问题、Android中忙碌的binder通信、文件磁盘读取和写入、文件句柄泄露、主线程操作Sqlite导致的卡顿等,解决起来相当烫手。
IO不忙碌的状况下,主线程低频次的调用IO函数是没什么问题的。然而在IO忙碌时,IO性能急剧进化,任何IO操作都可能是压死骆驼的最初一根稻草。在平时开发测试中很难遇到IO卡顿,到了线上后才会裸露进去,iOS/Android双端根本都是如此:罕用的open零碎调用,线下测试只须要4ms,线上大把用户执行工夫超过10秒;就连获取文件长度、查看文件是否存在这种惯例操作,居然也能卡顿。
以Android线上抓到的卡顿为例(>5秒):
at libcore.io.Linux.access(Native Method)at libcore.io.ForwardingOs.access(ForwardingOs.java:128)at libcore.io.BlockGuardOs.access(BlockGuardOs.java:76)at libcore.io.ForwardingOs.access(ForwardingOs.java:128)at android.app.ActivityThread$AndroidOs.access(ActivityThread.java:8121)at java.io.UnixFileSystem.checkAccess(UnixFileSystem.java:281)at java.io.File.exists(File.java:813)at com.a.b.getDownloaded(SourceFile:2)
at libcore.io.Linux.stat(Native Method)at libcore.io.ForwardingOs.stat(ForwardingOs.java:853)at libcore.io.BlockGuardOs.stat(BlockGuardOs.java:420)at libcore.io.ForwardingOs.stat(ForwardingOs.java:853)at android.app.ActivityThread$AndroidOs.stat(ActivityThread.java:8897)at java.io.UnixFileSystem.getLength(UnixFileSystem.java:298)at java.io.File.length(File.java:968)
具体源码能够参考 :
https://android.googlesource.com/platform/libcore/+/master/lu...\_io\_Linux.cpp
最终是在C++中发动了零碎调用access()和stat()。
IO问题在很多时候被鄙视,贸然的把IO导致的卡顿放到异步线程,可能会导致真正的问题被覆盖,前人挖坑前人踩。其实首先要想的是,数据存储形式是否正当,数据的应用形式是否正当。
作为一款视频剪辑工具,度加剪辑在内存、磁盘、卡顿方面有大量的技术挑战,同时也积攒了大量的技术债。我从隔壁做图片丑化工具的团队那失去了双端的IO卡顿数据,能够说是难兄难弟,不分伯仲:有卧龙的中央,十步以内必有凤雏。
上面简略介绍度加剪辑App中对文件磁盘IO这部分的应用和优化,本文是无关MMKV。
(广告工夫:度加剪辑是一款音视频剪辑软件,针对口播用户开发了很多贴心性能,比如说疾速剪辑,各类素材也比拟丰盛,比方贴纸、文字模板等,欢送下载应用。)
02 高性能kv神器-MMKV
MMKV是基于mmap的高性能通用key-value组件,性能极佳,让咱们在主线程应用kv成为了可能,堪称挪动端的Redis,实际上这两者在设计上也能找到类似的影子。
mmap是应用极其宽泛的内存映射技术,对内存的读写约等于对磁盘的读写,内存中的字节与文件中的字节相映成趣,一一对应。像Kafka和RocketMQ等消息中间件都应用了mmap,防止了数据在用户态跟内核态大量的拷贝切换, 所谓零拷贝。
为了进步性能,度加逐步从SharedPreferences向MMKV迁徙,对于Sp的卡顿逐步隐没,性能晋升成果非常哇塞。
然而,MMKV仍然有不少IO操作产生在主线程,这些函数在用户缓冲区都没有buffer(比照fread和fwrite等f打头的带有缓冲的函数),且磁盘绝对是低速设施,同步时效率较低,有时难免会呈现性能问题。
度加剪辑作为MMKV的重度甚至变态用户,随着应用越来越频繁,陆续发现了线上很多和MMKV相干的乏味问题,上面抛砖引玉简略介绍。
03 setX/encodeX卡顿-占度加剪辑总卡顿的1.2%
at com.tencent.mmkv.MMKV.encodeString(Native Method)at com.tencent.mmkv.MMKV.encode(Proguard:8)
通过剖析,卡顿根本都产生IO忙碌时刻。度加App在应用中充斥了大量的磁盘IO,在编辑页面会读取大量的视频文件、贴纸、字体等各种文件,像降噪、语音转文字等大量场景都须要本地写入;导出页面会在短时间内写入上G的视频到磁盘中:为了保障输入视频的清晰度,度加App设置了极高的视频和音频码率。
不可避免,当磁盘处于大规模写入状态,在视频合成导出、视频文件读取和下载、各类素材的下载过程中很容易发现MMKV卡顿的身影;通过减少研发打点数据以及其余辅助伎俩后,我大体演绎了两种卡顿产生的典型场景。
1、存储较长的字符串,例如云控json
这个卡顿大部分是MMKV的重写和扩容机制引起,首先简略介绍MMKV的数据存储布局。_(https://github.com/Tencent/MMKV/wiki/design)_
MMKV在创立一个ID时,例如默认的mmkv.default,会为这个ID独自创立两个4K大小(操作系统pagesize值)的文件,寄存内容的文件和CRC校验文件。
每次插入新的key-value,以append模式在内容文件的尾部追加,取值以最初插入的数据为准,即便是已有雷同的key-value,也间接append在文件开端;key与value交替存储,与Redis的AOF非常相似。
便于了解不便,省去了key长度和value长度等其余字段:
此时MMKV的dict中有两对无效的key=>value数据: {"key1":"val3", "key2", "val2"}
重写:Append模式有个问题,当一个雷同的key一直被写入时,整个文件有局部区域是被节约掉的,因为后面的value会被前面的代替掉,只有最初插入的那组kv对才无效。所以当文件不足以寄存新增的kv数据时,MMKV会先尝试对key去重,重写文件以重整布局升高大小,相似Redis的bgrewriteaof。(重写后实际上是key2在前key1在后。)
扩容:在重写文件后,如果空间还是不够,会一直的以2倍大小扩容文件直到满足需要:JAVA中ArrayList的扩容系数是1.5,GCC中std::vector扩容系数是2,MMKV的扩容系数也是2。
size_t oldSize = fileSize;do { fileSize *= 2;} while (lenNeeded + futureUsage >= fileSize);
重写和扩容都会波及到IO相干的零碎调用,重写会调用msync函数强制同步数据到磁盘;而扩容时逻辑更为简单,零碎调用次数更多:
1、ftruncate批改文件的名义大小。
2、批改文件的理论大小。Linux上ftruncate会造成“空洞文件”,而不是真正的去申请磁盘block,在磁盘已满或者没有权限时会有奇怪的谬误甚至是解体。MMKV不得不应用lseek+write零碎调用来保障文件肯定扩容胜利,测试和确认文件在磁盘中的理论大小,以避免后续MMKV的写入可能呈现SIGBUS等谬误信号。
3、确认了文件真正的长度满足要求后,调用munmap+mmap,从新对内存和文件建设映射。在解除绑定时,munmap也会同步内存数据脏页到磁盘(msync),这也是个耗时操作。
if (::ftruncate(m_diskFile.m_fd, static_cast<off_t>(m_size)) != 0) { MMKVError("fail to truncate [%s] to size %zu, %s", m_diskFile.m_path.c_str(), m_size, strerror(errno)); m_size = oldSize; return false; } if (m_size > oldSize) { // lseek+write 保障文件肯定扩容胜利 if (!zeroFillFile(m_diskFile.m_fd, oldSize, m_size - oldSize)) { MMKVError("fail to zeroFile [%s] to size %zu, %s", m_diskFile.m_path.c_str(), m_size, strerror(errno)); m_size = oldSize; return false; } } if (m_ptr) { if (munmap(m_ptr, oldSize) != 0) { MMKVError("fail to munmap [%s], %s", m_diskFile.m_path.c_str(), strerror(errno)); } } auto ret = mmap(); if (!ret) { doCleanMemoryCache(true); }
由此可见,MMKV在重写和扩容时,会产生肯定次数的零碎调用,是个重型操作,在IO忙碌时可能会导致卡顿;而且相比拟重写操作,扩容的老本更高,至多有5个IO零碎调用,呈现性能问题的概率也更大。
所以解决此问题的外围在于,要尽量减少和克制MMKV的重写和扩容次数,尤其是扩容次数。针对度加App的业务特点,咱们做了几点优化。
(1)某些key-value不常常变动(比方云控参数),在写入前先比拟是否与原值雷同,值不雷同再插入数据。 下面提过,即便是已有雷同的key-value,也间接append在文件开端,其实这次插入没有什么用途。但字符串或者内存的比拟(strcmp或者memcmp)也须要耗费点资源,所以业务方能够依据理论状况做比拟,减少命中率,进步性能。
我从文心一言随机要了一首英文诗,测试30万次的插入性能差别
auto mmkv = [MMKV mmkvWithID:@"test0"];NSString *key = [NSString stringWithFormat: @"HelloWorld!"];NSString *value = [NSString stringWithFormat:@"There are two roads in the forest \ One is straight and leads to the light \ The other is crooked and full of darkness \ Which one will you choose to walk? \\ The straight road may be easy to follow \ But it may lead you to a narrow path \ The crooked road may be difficult to navigate \ But it may open up a world of possibilities \\ The choice is yours to make \ Decide wisely and with a open heart \ Walk the path that leads you to your dreams \ And leaves you with no regrets at the end of the day"];double start = [[NSDate date] timeIntervalSince1970] * 1000;for(int i = 0; i < 300000; i++) { /** * 判断值是否雷同再写入 * 能够利用短路表达式,先执行getValueSizeForKey确定value的长度是否有变动,如果有变动不须要再比拟字符串的本质内容: * getValueSizeForKey是极其轻量的操作,getStringForKey和isEqualToString绝对较重 */ if ([mmkv getValueSizeForKey:key actualSize:true] != [value length] || ![value isEqualToString: [mmkv getStringForKey: key]]) { [mmkv setString: value forKey:key]; }}double end = [[NSDate date] timeIntervalSince1970] * 1000;NSLog(@"funcionalTest, time = %f", (end - start));
运行环境:MacBook Pro (Retina, 15-inch, Mid 2015) 12.6.5
可见此计划对于值没有任何变动的极其状况,有不小的性能晋升。理论在生产环境,尤其是在配置较低的手机设施或磁盘IO忙碌时,这两者的运行工夫差距可能会被有限放大。
如果,这个先判断再插入的逻辑,由MMKV来主动实现就更好了;但对于频繁变动的键值对,会多出求value长度和比拟字符串内容的“多余操作”,可能小小的影响MMKV的插入性能。目前能够依据本人业务特点和数据变动状况适合抉择策略。
或者,MMKV思考减少一组办法,能够叫个setWithCompare()之类的的名字,如果开发者认为key-value变动的概率不大,能够调用这个函数来升高扩容重写文件的概率。就像C++20新增的likely和unlikely关键字一样,进步命中率,均摊复杂度会变低,综合性价比会变高。
(https://en.cppreference.com/w/cpp/language/attributes/likely)
(2)提前在闲时或者异步时扩容。 这个计划我没在线上试过,然而个可行计划。如果咱们可能预估MMKV可能存放数据的大小,那么齐全能够在闲时插入一组长度靠近的占位key1-value1数据,先扩容好;当插入真正的数据key1-value2时,现实状况下至少触发一次重写,而不会再触发扩容。
腾笼换鸟。
MMKV *mmkv = [MMKV mmkvWithID:@"mmkv_id1"]; NSString *s = [NSString stringWithFormat:@""]; for (int i = 0; i < 7000; i++) { s = [s stringByAppendingString: @"a"]; } // 闲时插入占位数据 [mmkv setString:s forKey:@"key1"]; NSLog(@"setString key1 done"); s = [s stringByAppendingString: @"b"]; // 重写一次,但不会再扩容 [mmkv setString:s forKey:@"key1"];
其实说到这,就不难想到,这个思路跟Java中的ArrayList,或者STL中的vector的有参构造函数是一个意思,既然曾经晓得要存放数据的大体量级了,那么在初始化的时候无妨间接就一次性的申请好,没必要再一直的*2去扩容了。
public ArrayList(int initialCapacity) { if (initialCapacity > 0) { this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { this.elementData = EMPTY_ELEMENTDATA; } else { throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity); } }
// 3. 构造函数将n个elem拷贝给容器自身 vector<int> v3(10, 2); printV(v3); // 2 2 2 2 2 2 2 2 2 2
目前MMKV默认创立时都是先创立4K的文件,就算咱们明确晓得要插入的是100K的数据,也丝毫没有方法,只能忍耐一次扩从4K->128K的扩容。如果能反对结构器中间接指定预期文件大小,如同是更好的计划。
mmkv::getFileSize(m_fd, m_size);// round up to (n * pagesize)if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) { // 这里能够通过构造函数间接在初始化时指定文件大小 size_t roundSize = ((m_size / DEFAULT_MMAP_SIZE) + 1) * DEFAULT_MMAP_SIZE; truncate(roundSize);} else { auto ret = mmap(); if (!ret) { doCleanMemoryCache(true); }}
于是向MMKV提了pr,构造函数反对设置文件初始大小 (https://github.com/Tencent/MMKV/discussions/1135) pr_(https://github.com/Tencent/MMKV/pull/1138/files)_
插一句,MMKV反对的平台很多,包含Android、iOS、Flutter、Windows、POSIX(Linux/UNIX/MacOS)等,哪怕想加一个小小的性能,也得花上不少工夫去测试:光凑齐这么多测试设施,也不是一件很容易的事儿。
说到底,MMKV毕竟不是为大kv设计的计划。不是他不优良,切实是老铁的要求太多了。
(3)应用gzip等压缩数据,大幅升高重写和扩容概率。
(4)大字符串或者数据从MMKV切换成数据库,异步解决。
(3)和(4)在下章深刻形容。
2、新ID第一次存储key-value数据
这个问题困扰了我很久。本来认为,只有长字符串才会导致卡顿,但万万没想到,不到50字节的key-value也会频繁的卡顿,切实是让人费解。有时候想间接把他丢到异步线程算了,但又有点不甘心。于是我又胡乱增加了几个研发打点,发版后通过瞎剖析,一个乏味的景象引起了我的留神:卡顿根本都产生在某个MMKV\_ID的第一次写入,也就是文件内容(key-value对)从0到1的过程。
为什么?
我狐疑是某个IO的零碎调用导致的卡顿,借助frida神器,我在demo中用撞大运式编程法挨个尝试,有了新发现:这个过程居然呈现了msync零碎调用。下面说过,mmap可能建设文件和内存的映射,由操作系统负责数据同步。但有些时候咱们想要磁盘立即马上去同步内存的信息,就须要被动调用msync来强制同步,这是个耗时操作,在IO忙碌时会导致卡顿。
在剖析MMKV源码,断点调试和减少log后,我根本确定这是MMKV的“个性”:MMKV在文件长度有余、或者是clear所有的key时(clearAll())会被动的重写文件。其中在从0到1时第一次插入key-value时,会误触发一次msync。
优化代码:(https://github.com/Tencent/MMKV/discussions/1136)和pr(https://github.com/Tencent/MMKV/pull/1110/files),这个优化可能在一段时间后随新版本收回。
msync() flushes changes made to the in-core copy of a file thatwas mapped into memory using mmap(2) back to the filesystem.Without use of this call, there is no guarantee that changes arewritten back before munmap(2) is called.
思考到老版本的降级周期问题,这个bug还能够用较为trick的形式躲避: 在MMKV\_ID创立时,趁着IO闲暇时不留神,连忙写入一组小的占位数据,提前走通从0到1的过程。这样在IO忙碌时就不会再执行msync。
// 保障至多有一个key-valueif (!TextUtils.equals(mmkv.decodeString("a")), "b") { mmkv.encodeString("a", "b");}
这段“垃圾代码”提交后迅速喜迎好几个code review的 -1,求爹告奶后总算是通过了。好在上线后,这个卡顿简直匿影藏形:就算是一张卫生纸都有它的用途,更何况是几行垃圾代码呢。
另外,持续追究卡登时,发现了另外非常乏味的bug:第一次插入500左右字节的数据,会引发一次多余的扩容。也一并修复
issue_(https://github.com/Tencent/MMKV/issues/1120 )_和pr_(https://github.com/Tencent/MMKV/pull/1121/files)_
而且我还有新的发现:很多同学因为编程习惯问题以及对MMKV不理解,度加剪辑有很多MMKV\_ID只蕴含一组(key=>value),存在微小节约。下面说过,每个MMKV\_ID都对应着两个4K的文件,不仅占据了8K的磁盘,还耗费了8K的内存,其实外面就存着几十字节的内容。更正当的做法是做好对立标准和治理,依据业务场景的划分来创立对应的MMKV实例,数量不能太多也不能太少,更不是想在哪创立就在哪创立。
度加剪辑存在很多一个ID里就寄存一对key=>value的状况,须要对立治理。
04 getMMKV卡顿—占度加总卡顿的0.5%
at com.tencent.mmkv.MMKV.getMMKVWithID(Native Method)at com.tencent.mmkv.MMKV.mmkvWithID(Proguard:2)
此卡顿也大多产生在IO忙碌时。通过下面提到的frida神器,以及查看源码,MMKV在初始化一个MMKV\_ID文件时,会调用lstat检测文件夹是否存在,若不存在就执行mkdir(第一次)创立文件夹。而后调用open函数关上文件,仍然可能会导致卡顿。
if (rootPath) { MMKVPath_t specialPath = (*rootPath) + MMKV_PATH_SLASH + SPECIAL_CHARACTER_DIRECTORY_NAME; if (!isFileExist(specialPath)) { // lstat零碎调用 mkPath(specialPath); // stat和mkdir零碎调用 } MMKVInfo("prepare to load %s (id %s) from rootPath %s", mmapID.c_str(), mmapKey.c_str(), rootPath->c_str());}
m_fd = ::open(m_path.c_str(), OpenFlag2NativeFlag(m_flag), S_IRWXU); if (!isFileValid()) { MMKVError("fail to open [%s], %d(%s)", m_path.c_str(), errno, strerror(errno)); return false; }
open零碎调用在平时测试中根本不怎么耗时,但外部可能存在调配inode节点等操作,在IO忙碌时也可能卡住。独一无二,我在Sqlite的官网上也看到了一篇对于Sqlite和文件读写性能比照的文章,这外面提到,open、close比read、write的操作更加耗时。
于是我又做了一个测试:
char buf[500 * 1024];void testOpenCloseAndWrite() { for (int i = 0; i < sizeof(buf) / sizeof(char); i++) { buf[i] = '0' + (i % 10); } long long startTime = getTimeInUs(); for (int i = 0; i < 1000; i++) { // 能够用snprintf代替,demo测试不便拼接字符串 string s = "/sdcard/tmp/" + to_string(i); s += ".txt"; int fd = open(s.c_str(), O_CREAT | O_RDWR, "w+"); // 关上后写入100K的数据 //write(fd, buf, sizeof(buf)); close(fd); } long long endTime = getTimeInUs(); LOGE("time %lld (ms)", (endTime - startTime) / 1000);}
1、当只有open/close调用时,在一加8Pro上只创立1000个"空"文件,须要3920ms(屡次取均匀)。
2、将第14行代码勾销正文后,执行write零碎调用,写入500k的数据后,共4150ms,也就是说,多出1000次的写操作,只减少了230毫秒,每次写只须要0.23ms,和open比的确是快多了。Sqlite诚不我欺。
3、当文件曾经存在,再次执行open零碎调用耗时显著要少一些,这也意味着第一次关上MMKV实例时会绝对的慢。
度加线上抓到的open零碎调用卡顿(libcore辗转反侧,最终执行了open零碎调用)
07-29 06:48:47.316at libcore.io.Linux.open(Native Method)at libcore.io.ForwardingOs.open(ForwardingOs.java:563)at libcore.io.BlockGuardOs.open(BlockGuardOs.java:274)at libcore.io.ForwardingOs.open(ForwardingOs.java:563)at android.app.ActivityThread$AndroidOs.open(ActivityThread.java:7980)at libcore.io.IoBridge.open(IoBridge.java:560)at java.io.FileInputStream.<init>(FileInputStream.java:160)
此问题能够通过预热MMKV解决—在IO不忙碌时提前加载好MMKV(得益于MMKV外部的各种锁,甚至还能够放心大胆的在异步线程初始化,和提前在异步线程加载SharedPreferences一样)。不过要留神,没必要过早加载,尤其是在App刚启动时一股脑的初始化了所有的MMKV\_ID。对于应用频率不高的ID,毕竟加载MMKV也就意味着内存的节约,也意味着占据着一个文件句柄。举个栗子,某些ID只在度加剪辑的导出视频后应用,咱们无妨就在刚进入导出页面时去预热,而不是在过程创立的时候或者MainActivity创立的时候加载,太早了会节约内存。
来得早不如来得巧。
05 存储收缩-MMKV不是数据库
在排查其余线上问题时,偶尔发现了两个不当应用MMKV的状况:
第一是只增不删,key=>value只增不减;这种状况会导致大量垃圾数据产生,对内存耗费和磁盘占用都是节约。下一篇会重点说说及时清理空间的问题,这里不再赘述。
第二是用MMKV存储大量缓存数据,导致文件很大,通过剖析研发打点数据,不少用户的MMKV文件体积最大的有512M了!
此外,MMKV为了防止频繁的扩容,会依据均匀的key-value长度,预留至多8个键值对的空间,这也减轻了内存和文件的空间冗余:
auto sizeOfDic = preparedData.second; size_t lenNeeded = sizeOfDic + Fixed32Size + newSize; size_t dicCount = m_crypter ? m_dicCrypt->size() : m_dic->size(); size_t avgItemSize = lenNeeded / std::max<size_t>(1, dicCount); // 预留至多8个键值对的空间 size_t futureUsage = avgItemSize * std::max<size_t>(8, (dicCount + 1) / 2);
度加之前是把语音转字幕的辨认后果(ID:QUICK\_EDIT\_AI\_TXT\_CACHE)放到了MMKV里缓存,然而从来不会被动删除,只会越来越多;如果不早点解决,积重难返,总有一天App的内存会全副被这些大文件吃掉。
因为MMKV是典型的空间换工夫,磁盘大小≈内存大小:磁盘占据着512M,也意味着虚拟内存同时也会减少512M,大幅减少了OOM的危险。
Redis性能瓶颈揭秘:如何优化大key问题?(https://zhuanlan.zhihu.com/p/622474134)其实这这和Redis的大Key问题一模一样,解决方案也非常相似:压缩数据、数据切割分片、设置过期工夫、更换其余数据存储形式。
1、压缩
如果非要将很多大内容存储在MMKV里,对value做压缩可能也是一个不差的抉择。
MMKV存储整数等短value采取了相似protobuf的变长整数压缩,比方,一个int整形能够用1-5个字节示意(其实Redis的RDB也用了相似的变长编码,不过看起来和UTF8的思路更为靠近),自身来讲,MMKV对pb并没有间接的依赖。
MMKV对字符串没有压缩,将MMKV的二进制文件用vim(vim作者Bram Moolenaar于2023年8月3号逝世,致敬大佬)当做文本格式间接关上,还是能看进去key和value都是以字符串的原始模式保留的。
MMKV *mmkv = [MMKV mmkvWithID: @"mm1"];[mmkv setString: @"This is value" forKey:@"I am Key"];
看到这不得不说句容易挨打的话:哪怕是把key改短点,也能很无效的升高扩容概率。比如说度加剪辑某个key,从"key\_draft\_crash\_project\_id" 缩短为 "kdcpi"后,就升高了不少卡顿。是的,我试过了的确有成果,但我不倡议你这么做,毕竟代码可读性也非常重要。
而Protobuf自身就有对字符串压缩的反对
GzipOutputStream::Options options;options.format = GzipOutputStream::GZIP;options.compression_level = _COMPRESSION_LEVEL;GzipOutputStream gzipOutputStream(&outputFileStream, options);
Redis在保留RDB文件时,也有对字符串的压缩反对,采取的是LZF压缩算法
/* Try LZF compression - under 20 bytes it's unable to compress even * aaaaaaaaaaaaaaaaaa so skip it */ if (server.rdb_compression && len > 20) { n = rdbSaveLzfStringObject(rdb,s,len); if (n == -1) return -1; if (n > 0) return n; /* Return value of 0 means data can't be compressed, save the old way */ }
本人入手饥寒交迫。
既然MMKV对字符串没有压缩,那就先本人实现。对局部长字符串进行压缩解压后,线上数据显示,文本json的gzip压缩率在9-11倍左右,这就意味着文件体积会放大至十分之一,并且内存耗费也放大至十分之一。设想一下,50M的文件在被动压缩后,霎时变成5M,对磁盘和内存是如许大的解放。当然,压缩和解压缩是耗费CPU资源的操作,一加8Pro上测试,338K的JSON文本应用Java自带的GZIPOutputStream压缩须要4ms,压缩后体积是25K;通过GZIPInputStream解压(留神buffer要设置成4096以上,太少会减少耗时)的工夫也是4ms,有肯定耗时,但也能承受;看起来,有时候反其道而行之,用工夫换空间如同也是值得的!如果想要压缩速度更快,能够换lz4、snappy等压缩算法,但压缩率也会随之升高,该当基于本人的业务特点来抉择最合适的压缩算法,在压缩率和压缩速度上找到平衡点。
预计有的同学会有疑难:既然用MMKV是空间换工夫,那为什么还要反过来用工夫换空间,岂不是瞎折腾,玩儿呢?
要害就在于:
1、IO文件操作作用在磁盘,运行工夫不稳固,抽起风来很要命,少则几毫秒,多则几十秒,所以咱们违心用空间换工夫来削掉波峰,晋升稳定性。
2、而CPU操作的运行工夫大体上比较稳定,个别只在CPU由大核切换小核、手机没快电、温度太高、CPU降频等多数状况才会劣化,且劣化趋势不显著,为了节约内存,所以用CPU换空间。
2、设置正当的过期工夫。
为大key设置过期工夫,以便在数据生效后主动清理,防止长时间累积,尾大不掉。MMKV 1.3.0曾经反对了过期设置,到期主动清理。
3、更换数据存储形式,例如Sqlite
用户产生的可有限增长的大规模数据,用数据库(Sqlite等)更加正当。数据库的访问速度相比MMKV尽管要慢,然而内存等资源耗费不大,只有正当使用异步线程并解决好线程抵触的问题,数据库的性能和稳定性也相当靠谱。
度加剪辑对在编辑页面和导出页面对内存的需要较高,咱们的内存优化计划也比拟激进,大体上遵循了以下4点:
1、不把MMKV当数据库用,简单和大规模的缓存数据思考从MMKV切换到Sqlite或者他数据库
2、必须要用MMKV存储字符串等大数据的状况,应用gzip等算法压缩后存储,应用时解压
3、对于只是一次性的读取和写入MMKV,操作完之后及时close该MMKV\_ID的实例,以开释虚拟内存
4、MMKV的数据要做到不必的时候立刻删除,善始善终。
06 总结
自从切换成MMKV后,就再也不想用Sp了,回顾过来的苦难,回忆明天的幸福生活,MMKV带来的这点卡顿,跟Sp导致的卡顿比,就是”小巫见大巫“,间接能够忽略不计。
我剖析了利用商店top级别的利用,有不少App在应用MMKV时也多少存在度加剪辑遇到的问题。大家应用MMKV次要存储的内容是云控的参数、以及AB试验的参数(千万不要小瞧,大厂的线上试验贼多,且试验的参数一点也不简略),文件大小超过1M的场景相当的多。
不过让我更为好奇的是,局部头部App既没有引入MMKV,我也没发现相似的二进制映射文件,特地想理解这些顶级App是如何来解决key-value的,非常期待大佬们的分享。
MMKV作为极其优良的存储组件,最初用这张图片来形容TA与开发者的关系:
—— END ——
参考资料:
[1]https://www.clear.rice.edu/comp321/html/laboratories/lab10/
[2]https://developer.ibm.com/articles/benefits-compression-kafka...
[3]https://github.com/Tencent/MMKV
[4]《Linux零碎编程手册》
[5]《UNIX环境高级编程》
[6]《Android开发高手课》
举荐浏览:
百度工程师浅析解码策略
百度工程师浅析强化学
浅谈对立权限治理服务的设计与开发
百度APP iOS端包体积50M优化实际(五) HEIC图片和无用类优化实际
百度晓得上云与架构演进