作者 | 我爱吃海米

导读 

挪动端开发中,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图片和无用类优化实际

百度晓得上云与架构演进