高磁盘占用的排查与优化

[toc]

高磁盘占用的排查与优化

图片缓存区太大的案例

1、案例描述

在我们的Flutter打包的app上,出现测试人员对首页的无限列表疯狂频繁刷,冷启动刷的时候还好,越刷越后面越卡。

2、案例分析过程

经测试验证在其他机器上无法重现。后通过观察应用的磁盘占比,发现出现崩溃的该设备该应用的磁盘占比巨大,达好几G(只要占用有网络点播视频缓存、打赏动画视频、图片)。进一步观察发现在图片不大的情况下,其中图片的占比竟然也能接近上G,说明图片数量巨大。由此猜测是图片数量太多导致。随后通过高磁盘占用环境模拟,确实也出现了同样的情况。由此我们开始设想磁盘对应用性能的影响 的几种原因,并进行对应修复。经过代码排查图片库内部的同步、阻塞问题,发现了一个潜在的同步危险。

1
if (cacheFlie.existsSync()) {  /// existsSync是一个同步方法,用于检查具有该路径的文件系统实体是否存在。

3、为什么原生iOS上没发现类似问题/微信为什么没出现类似问题?

可能原因:①之前原图相同的图片显示在不同地方,也只是原图和缩略图两套而已,而不像图片万象这样,可能会存在好多套。②原生的SDWebImage在监测到内存不足的时候会自动进行清除。

图片缓存(无、太小、太大)的问题

小结:

图片缓冲区默认太小时(一定有问题) 图片缓冲区设大后(可能有问题)
出现的平台 Flutter上默认的缓存上限1000个图片或者100MB内。
原生iOS的SDWebImage一般不设大小,只根据maxCacheAge
Flutter上extended_image判断图片是否已缓存时候有性能问题,而导致卡顿。
原因滑动列表会进行很多图片的获取,从而由于任务过多,对性能产生影响,例如增加CPU使用率,导致电池消耗加快,进而可能引发崩溃。
后果 加载过的图片很容易再次出现。 可能:存的图片数量太大。影响图片存取,而崩溃。
出现的案例 ①列表划过之后再回来;
②列表页面离开后再回来。
测试人员频繁刷,导致刷满图片缓冲区后,图片数量太大,影响图片存取,从而崩溃。
解决方案 图片存储进行分区。对 url 进行 md5 取值,对 md5 取前两个字母为新文件夹。

1、图片缓冲区默认太小时

在Flutter中加载图片(一般是网络图片),我们常常会遇到下面几个问题:Flutter 图片缓存问题分析

  1. 同页面内,加载过的图片,再次出现的时候,会重新加载,特别是列表的图片;

    根源:Flutter 内置的缓存机制 PaintingBinding.instance.imageCache 的 maximumSizemaximumSizeBytes 属性默认的缓存上限1000个图片或者100MB内。

    1
    2
    3
    > const int _kDefaultSize = 1000;
    > const int _kDefaultSizeBytes = 100 << 20; // 100 MiB
    >

    即图片没有加载到100MB,加载到1000个图片,也会开始根据LRU的规则清理释放缓存。在某些情况下,比如电商,一整个页面80%的元素都用图片占满,小到图标,大到广告banner,个数很容易就到1000了,但是经过压缩剪裁,图片普遍都控制在几十kb甚至10kb以下,即使1000张也远远达不到内存的上限,并且100MB的上限对于某些机型来讲也相对较小了。

    解决:设置大一点

    1
    2
    3
    > PaintingBinding.instance.imageCache.maximumSize = 10000;
    > PaintingBinding.instance.imageCache.maximumSizeBytes = 800 << 20; // 800 MiB
    >

    附:除了 Flutter 内置的缓存机制外,还有第三方库如 cached_network_image 提供了更丰富的图片缓存功能,包括硬盘缓存等 。如果需要更复杂的图片缓存策略,可以考虑使用这类第三方库来扩展 Flutter 的图片加载和缓存能力。

    附2:extended_image 提供了一套完整的图片加载和缓存解决方案,其缓存的控制也还是通过修改 ImageCache 的配置来控制内存缓存的大小和数量。

    附2:SDWebImage 默认情况下会缓存图片一周(maxCacheAge 的默认值),并且没有对缓存空间大小设置限制(maxCacheSize 默认值未设定),这意味着理论上应用中的图片缓存可以占满整个设备存储空间。

    1
    2
    > [SDImageCache sharedImageCache].maxCacheSize = 50 * 1024 * 1024; *// 50MB*
    >
  2. 列表快速滑动时,加载完成再往回滑动,之前的图片还是需要重新加载;

    根源:等同于按需加载。

    解释:加载图片时,为了避免过快滑动,使得同时加载的图片过多导致卡顿甚至崩溃。所以若是内存中已有缓存,则直接返回缓存,若是没有则判断是否在快速滑动,若是正在快速滑动,则下一帧再加入队列处理,若是图片已经被移出屏幕(即没有在tree上),可能会被跳过。

    解决:快速滑动如此,可不处理。

  3. 有时返回上一页面时,上一页面已经加载完成的图片,会重新加载,假如没有占位图会特别明显的闪动。

    同现象1一样。

2、图片缓冲区设大后

图片分区:

SDWebImage 所有的图片都放在 _diskCachePath 目录下,_diskCachePath是只有一层吗?还是里面还会再分文件目录?

SDWebImage 的 _diskCachePath 目录是 SDWebImage 用来存储磁盘缓存的根目录。在这个根目录下,SDWebImage 会进一步组织文件结构,以避免所有图片都放在单一目录下造成文件系统性能问题。具体来说,图片文件会根据图片 URL 的 MD5 值进行散列,然后根据这个散列值来创建子目录和文件名,从而在磁盘上形成一个树状的目录结构

这种目录结构的设计有助于分散文件系统操作,减少单个目录下的文件数量,从而提高文件检索和写入的性能。此外,SDWebImage 还提供了设置选项,允许开发者自定义图片的存储路径,以及通过 addReadOnlyCachePath: 方法添加额外的只读缓存路径 。

总的来说,_diskCachePath 不是单一层级,而是一个包含多个子目录的复杂结构,每个子目录下存储着根据 MD5 散列值组织好的图片文件,以此来优化磁盘缓存的性能和效率 。

一、磁盘对应用性能的影响

1、原因猜测

可能原因:应用使用过程时候,存在对磁盘中的某个文件的操作,从而导致应用性能问题。操作的可能性如下:

序号 阶段 如果 后果
1 访问 文件量太大 访问效率问题
2 读写 文件大小太大 读写问题

2、排查方案

高磁盘占用的排查与优化

图片来源于:高磁盘占用的排查与优化.graffle

二、高磁盘占用环境模拟

步骤 阶段 操作 目的
1 资源准备1 提供图片的万象接口,以通过万象能获取到各种图片地址 避免得去找非常多的图片数据源
2 资源准备2 提供下载万象图片的接口(指定宽范围),以通过万象图片能下载到很多图片 避免得去找非常多的图片数据源
3 资源下载 下载指定宽范围的万象图片,得到各种图片
每次只添加 100 张,避免模拟功能在同步状态下执行出错;
通过下载占据磁盘空间
4 数据验证1 打印磁盘(沙盒)目录,验证下载
iOS-查看沙盒文件(真机+模拟器)
验证确实磁盘空间被增加
5 数据验证2 计算磁盘大小 验证确实磁盘空间被增加

相应的代码实现,见 附1:高磁盘占用环境模拟的相关代码

三、高磁盘占用环境优化

序号 解决 方案
1 避免单文件夹太大 存储分区处理
2 单文件夹已经太大 存储空间清理

1、避免单文件夹太大—-存储分区处理

1、背景:图片文件下文件数量太多,导致搜索效率下降

1
2
3
4
5
6
7
8
9
10
final Directory _cacheImagesDirectory = Directory(
join((await getTemporaryDirectory()).path, cacheImageFolderName));

// exist, try to find cache image file
if (_cacheImagesDirectory.existsSync()) { /// existsSync是一个同步方法,用于检查具有该路径的文件系统实体是否存在。
final File cacheFlie = File(join(_cacheImagesDirectory.path, md5Key));
if (cacheFlie.existsSync()) { /// existsSync是一个同步方法,用于检查具有该路径的文件系统实体是否存在。

}
}

2、优化方案:对图片存储,进行分区存储。获取方式同理。

3、优化步骤如下

对 url 进行 md5 取值

对 md5 取首字母为新文件夹

将该url所对应的图片存储在图片存储目录下的子目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// get md5 from key
static String keyToMd5(String key) => md5.convert(utf8.encode(key)).toString();

/// 获取网络图片最后的本地存储文件夹路径
static Future<String> getLastDirPath(String imageUrl, {bool? useSubDir}) async {
Directory cacheDir = await getTemporaryDirectory();
String imageCacheHomeDirPath = join(cacheDir.path, cacheImageFolderName);
if (useSubDir != true) { // 不使用子目录,直接一个文件夹存放
return imageCacheHomeDirPath;
}

String subDirHome = keyToMd5(imageUrl);
String imageCacheSubDirName = subDirHome.substring(0, 1);
String lastDirPath = "$imageCacheHomeDirPath/$imageCacheSubDirName";
return lastDirPath;
}

4、优化验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// 计算图片文件的查找耗时(查找失败返回-1)
static Future<int> calculateFindDuration(String imageUrl, {bool? useSubDir}) async {
String md5Key = keyToMd5(imageUrl);
final String lastDirPath = await getLastDirPath(imageUrl, useSubDir: useSubDir);
final Directory cacheImagesDirectory = Directory(lastDirPath);

int foundStartTime = DateTime.now().millisecondsSinceEpoch;
// exist, try to find cache image file
if (cacheImagesDirectory.existsSync()) { /// existsSync同步方法,检查该路径的文件夹是否存在
final File cacheFlie = File(join(cacheImagesDirectory.path, md5Key));
if (cacheFlie.existsSync()) { /// existsSync同步方法,检查该路径的文件是否存在
int foundEndTime = DateTime.now().millisecondsSinceEpoch;
int foundDuration = foundEndTime - foundStartTime;
return foundDuration;
}
}

return -1;
}

2、单文件夹已经太大—-存储空间清理

文件的属性有创建时间、修改时间

原理:变化过期时间,遍历文件属性,删除修改时间大于该过期时间的文件

1、设置过期时间的初始值,遍历文件属性,删除修改时间大于该过期时间的文件;

2、检查清理后的大小,如果大小还不在规定大小内,则继续清理;

3、继续清理:设置每次清理的过期时间增加值,对过期时间的初始值增加,并重复以上步骤;

四、优化方案的实施

目的:避免功能异常,出现集体性问题。

灰度方案:请参照 灰度系统

五、延伸

问题:一旦修改分区规则(如原本使用url进行md5后的首位值,后面改成md5后的两位值),旧文件就得重新下载。

解决:使用一致性哈希算法。16 张图解带你掌握一致性哈希算法

1、对存储节点进行hash,创建节点和hash值的哈希映射,如 {100001: “a”, 300001: “a”, 400001: “a”}

2、对数据url进行hash寻址

  • 首先,对 key 进行哈希计算,确定此 key 在环上的位置;
  • 然后,从这个位置沿着顺时针方向走,遇到的第一节点就是存储 key 的节点。(对「数据」进行哈希映射得到一个结果要怎么找到存储该数据的节点呢?)

结果:在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响

假设节点数量从 3 减少到了 2,比如将节点 A 移除:你可以看到,key-02 和 key-03 不会受到影响,只有 key-01 需要被迁移节点 B。

image-20231008173625108

隐患:一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题。如下:

image-20231008173700010

说明:一致哈希算法也用了取模运算,但与哈希算法不同的是,哈希算法是对节点的数量进行取模运算,而一致哈希算法是对 2^32 进行取模运算,是一个固定的值

附:hash算法

使用一致性哈希(Consistent Hashing)可以帮助您将大批图片文件分散存储到不同的文件中,其中每个文件对应一个字母或数字。一致性哈希算法能够提供高效的数据分布和负载均衡,同时允许动态添加或删除文件节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import 'dart:convert';
import 'package:crypto/crypto.dart';

class ConsistentHashing {
List<String> nodes;
Map<int, String> ring;

// 构造hash环
ConsistentHashing(List<String> nodes) {
this.nodes = nodes;
this.ring = {};

for (String node in nodes) {
addNode(node);
}
}

// ring[_hash(node + n.toString())] = node;
void addNode(String node) { // a、b...
for (int n = 0; n < 3; n++) { // 每个节点有3个虚拟节点(a0、a1、a2、b0、b1、b2...)
String virtualNode = node + n.toString();
int hashValue = _hash(virtualNode);
ring[hashValue] = node; // {hash_a0: "a", hash_a1: "a", hash_a2: "a"}
}
}

// ring.remove(_hash(node + n.toString()));
void removeNode(String node) { // a、b...
for (int n = 0; n < 3; n++) { // 每个节点有3个虚拟节点(a0、a1、a2、b0、b1、b2...)
String virtualNode = node + n.toString();
int hashValue = _hash(virtualNode);
ring.remove(hashValue);
}
}

// 从小到大排序所有虚拟节点的hash值,并进行遍历,找到最接近(第一个>=hashValue的节点)的虚拟节点。判断值应该存储的节点
String getNode(String key) {
if (ring.isEmpty) {
return null;
}

int hashValue = _hash(key);
List<int> sortedKeys = ring.keys.toList()..sort(); // [hash_a0, hash_b0, hash_a1, hash_b1, ...]
for (int ringKey in sortedKeys) {
if (hashValue <= ringKey) { // hash_xxx <= hash_a1 遍历找到第一个大于等于 hash_xxx 的节点
return ring[ringKey]; // ring[hash_a1] = "a" => 返回 "a"
}
}

return ring[sortedKeys[0]];
}

int _hash(String key) {
Digest hash = md5.convert(utf8.encode(key));
int hashValue = int.parse(hash.toString(), radix: 16);
return hashValue;
}
}

void main() {
// 创建36个文件节点,每个节点对应一个字母或数字
List<String> nodes = List.generate(26, (index) => String.fromCharCode(97 + index))
..addAll(List.generate(10, (index) => index.toString()));

// 初始化一致性哈希环
ConsistentHashing ring = ConsistentHashing(nodes);

// 假设有一批图片文件需要存储
List<String> imageFiles = ['image1.jpg', 'image2.jpg', 'image3.jpg'];

for (String imageFile in imageFiles) {
// 根据文件名获取对应的节点
String node = ring.getNode(imageFile);

// 将文件存储到对应的节点中
// 这里可以根据需要自行实现文件存储逻辑
print('Storing $imageFile to node $node');
}
}

附1:高磁盘占用环境模拟的相关代码

1、ts_toomany_image_data_vientiane.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// [图片处理方式:快速缩略模板](https://cloud.tencent.com/document/product/460/6929)
/// [图片处理方式:数据万象-缩放thumbnail](https://cloud.tencent.com/document/product/460/36540)
/// [图片处理方式:数据万象-旋转rotate](https://cloud.tencent.com/document/product/460/36542)
class TSTooManyImageDataVientiane {
static String newImageUrl(String imageUrl, {required int width}) {
String thumbnail = '?imageMogr2/thumbnail/';
thumbnail += '${width}x/';
thumbnail += 'format/webp/auto-orient/quality/100';

String newImageUrl = "$imageUrl$thumbnail";
// debugPrint('newImageUrl = $newImageUrl');
return newImageUrl;
}
}

2、ts_toomany_image_download_util.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import 'dart:io';

import 'package:extended_image/extended_image.dart';
import 'ts_toomany_image_data_vientiane.dart';

class TSTooManyImageDownloadUtil {
static String vientianeImageUrl = "https://images.xihuanwu.com/mcms/uploads/1647604960983901.jpg";

static void simulate_download_too_many(int imageWidthStart, int imageWidthEnd) {
int imageCount = imageWidthEnd - imageWidthStart;
for (var i = 0; i < imageCount; i++) {
int iImageWidth = imageWidthStart + i;
String iImageUrl = TSTooManyImageDataVientiane.newImageUrl(vientianeImageUrl, width: iImageWidth);
getNetworkImageData(iImageUrl);
sleep(const Duration(milliseconds: 10)); // 延迟10ms,避免下载太多计算问题
}
}
}

3、ts_toomany_image_optimize_check_util.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:extended_image_library/extended_image_library.dart';

import 'ts_toomany_image_data_vientiane.dart';
import 'ts_toomany_image_download_util.dart';

class TSTooManyImageOptimizeCheckUtil {
/// 计算图片的查找时长
static checkImageFindDuration() async {
String foundImageUrl = TSTooManyImageDataVientiane.newImageUrl(TSTooManyImageDownloadUtil.vientianeImageUrl,
width: 6600);
getNetworkImageData(foundImageUrl);
int foundDuration = await TSTooManyImageOptimizeCheckUtil.calculateFindDuration(foundImageUrl);
debugPrint("$foundImageUrl 的查找时间为:$foundDuration");
}
}

End