关于io:度加剪辑App的MMKV应用优化实践

作者 | 我爱吃海米

导读 

挪动端开发中,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 that
was mapped into memory using mmap(2) back to the filesystem.
Without use of this call, there is no guarantee that changes are
written back before munmap(2) is called.

思考到老版本的降级周期问题,这个bug还能够用较为trick的形式躲避: 在MMKV\_ID创立时,趁着IO闲暇时不留神,连忙写入一组小的占位数据,提前走通从0到1的过程。这样在IO忙碌时就不会再执行msync。

// 保障至多有一个key-value
if (!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.316
at 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图片和无用类优化实际

百度晓得上云与架构演进

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理