乐趣区

关于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 图片和无用类优化实际

百度晓得上云与架构演进

退出移动版