壹 ❀ 引

公司前端组基本每个月会举行一次前端月会,用于做前端组基础设施以及其它重要信息的同步,会议最后一个环节就会分享本月前端同学在开发中所遇到的奇怪bug,或者一些有趣的问题。在分享的问题中,我发现一个关于缓存库memoizee引发的性能问题还挺有意思,毕竟一个提升性能问题的库居然还能引发其它性能缺陷,经典矛盾文学了,废话不多说,本文开始。

贰 ❀ 使用memoizee提升性能

我们抛开react 17中常用的useMemo类似的缓存函数hook,在react 16版本中,对于一个计算量较大的函数,可能很多同学都会想到借用缓存函数来做结果缓存处理,以达到性能提升的目的,而不同于自己造的轮子,现有的缓存函数库memoizee就是不错的推荐。

说在前面,所有的性能提升无非围绕空间换时间或者时间换空间来展开,而函数缓存就是典型的空间换时间,且思路都是将接受的参数作为key,计算的结果作为value,并形成key-value键值对存入一个对象。当下次再调用这个函数,且参数相同时,我们就能从对象中直接取回结果返回,从而避免重复的复杂的逻辑计算。

一个最简单的函数缓存例子:

// 用于缓存每个key的计算结果
const res = {};
const memoize = (num)=>{
// 假设之前已经计算过了,直接返回
if(res[num]!==undefined){
return res[num];
};
// 新参数?重新计算,并做缓存
const square = num*num;
res[num] = square;
return square;
};
console.log(memoize(2));// 4
console.log(memoize(3));// 9
// 这一次就走了缓存
console.log(memoize(2));// 4

上面这个例子虽然有缓存作用,但本质上是对于参数的缓存,它的功能非常单一,只能用于求数字的平方。那假设我现在要求数字的加法,或者数字的除法,我们岂不是得自己定义很多个这样的缓存函数?所以本质上,我们其实希望有一个函数,能起到一个包装器的作用,我们传入的任意函数都能被这个包装器转成缓存函数,同时在执行时也能对于相同参数做到缓存效果。那么三方库memoizee的作用就是如此。

简单科普下用法,毕竟本文的核心是分享使用memoizee所带来的性能问题,基本用法如下,更多用法请参照文档:

import memoize from 'memoizee'

const o1 = {a:1,b:2};
const o2 = o1;
const o3 = {a:1,b:2};
const fn = function (obj) {
console.log(1);
return obj.a + obj.b;
};
// fn作为参数,得到了一个有缓存效果的fn
const memoizeFn = memoize(fn); memoizeFn(o1);
// o2与o1是同一个对象,走缓存
memoizeFn(o2);
// o3是一个新对象,不走缓存
memoizeFn(o3);

使用memoizee还有一个好处就是,我们函数的参数不一定都是数字字符串这类的基本类型,有时候还可能是一个对象。比如上述例子我们借用了memoizee生成了一个带有缓存效果函数memoizeFn,它所接收的参数就是对象,我们通过fn内部的console用于检验到底有没有走缓存,效果很明显,console一共执行两次,分别由参数o1o3触发。所以借用三方库的好处就是,很多的边界场景它都有帮你考虑。

叁 ❀ memoizee引发的性能问题

前面说了,无论是memoizee还是我们自定义的缓存函数,本质上性能的提升都离不开空间换时间,缓存后直接拿结果虽然快,但随着缓存的结果越来越多,一千,一万到数十万,memoizee是否真的能符合我的高性能的预期呢?一个简单的例子来颠覆你的认知:

const fn = function (a) {
return a * a;
};
// 使用缓存
console.time('使用缓存');
const memoizeFn = memoize(fn); for (let i = 0; i < 100000; i++) {
memoizeFn(i);
} memoizeFn(90000);
console.timeEnd('使用缓存'); // 不使用缓存
console.time('不使用缓存');
for (let i = 0; i < 100000; i++) {
// 单纯执行,啥也不缓存
fn(i);
}
fn(90000);
console.timeEnd('不使用缓存');

上述代码分为两部分,使用了memoize做缓存,我们模拟了10W次执行,然后再次执行memoizeFn(90000)以达到取缓存的效果。而不使用缓存的部分则是现执行现使用,不走任何缓存。而让人惊讶的时,使用缓存的代码耗时6.5S,而没缓存的部分仅需2.74ms,后者比前者快2442倍!

我知道你现在心里已经产生了疑惑,我们再来做个更有趣的对比,在文章开头我们写了一个劣质的缓存函数,没关系,我们稍加改造,如下:

console.time('使用自定义的缓存');
const res = {};
const memoizeFn_ = (num)=>{
// 假设之前已经计算过了,直接返回
if(res[num]!==undefined){
return res[num];
};
// 新参数?重新计算,并做缓存
const square = num*num;
res[num] = square;
return square;
};
for (let i = 0; i < 100000; i++) {
// 单纯执行,啥也不缓存
memoizeFn_(i);
}
memoizeFn_(90000);
console.timeEnd('使用自定义的缓存');

使用自定义缓存毕竟有存储和查询的操作,所以耗时上肯定比不使用缓存要稍微慢一点,但整体耗时只差2ms,到这里我们可以断定memoizee在实现上一定有猫腻,遇事不决读源码,于是我们发现了memoizee中的如下代码:

module.exports = function () {
var lastId = 0, argsMap = [], cache = [];
return {
get: function (args) {
// 注意这一句代码,这里使用了indexOf用来查询之前这个参数有没有执行过
var index = indexOf.call(argsMap, args[0]);
return index === -1 ? null : cache[index];
}
// 删除了部分不相关的代码
};
};

不卖关子,当使用memoizee做缓存,且函数参数只有一个时,memoizeeget查询实现其实借用了indexOf。站在时间复杂度层面,从数组中遍历查询一个元素,最快是O(1),最坏是O(N),这种情况一般以最坏的情况来作为时间复杂度,因此时间复杂度是O(N)

那为什么上面那个例子耗时这么逆天?那是因为缓存函数本身就是边执行边查询边缓存的操作,打个最简单的比方,假设执行到1000,那么它就要查之前缓存的999有没有缓存过,以我们提供的10W为标准,其实在memoizee中真就执行了10W次indexOf,且越往后面执行查询的代价就越大,耗时这么久自然就好理解了。

上图就是当参数执行到58152时,需要查之前存的5W多个缓存,你要之后后面还有把这种操作执行4W多次,且缓存还是递增的。

另外,memoizee在一个参数或者多个参数时,get的实现逻辑其实不同,但尴尬的是,不管几个参数,其实都是借用indexOf,下图就是我改为多个参数的代码断点:

get: function (args) {
var index = 0, set = map, i;
while (index < length - 1) {
i = indexOf.call(set[0], args[index]);
if (i === -1) return null;
set = set[1][i];
++index;
}
i = indexOf.call(set[0], args[index]);
if (i === -1) return null;
return set[1][i] || null;
},

叁 ❀ 解决

说到这里,有些同学可能都疑惑了,我使用memoizee本身就是为了提升性能,结果你memoizee自己就有性能问题,那到底用不用?或者说怎么用?

其实我们使用缓存函数本质是为了减少那种特别复杂的逻辑处理,比如上面只是求一个数字的平方的处理就根本没必要使用缓存,不走缓存瞬间快几千倍。

其次,由于memoizee在查询缓存时借用了indexOf,站在量大的数据面前性能问题是无法避免的,而其它同事之所以遇到这个问题,是因为某个客户在项目中对于工作项不同类型定义了多个属性配置,而每个配置下又支持自定义N个工作项属性,在程序经常有根据工作项属性ID去查对应工作项属性的逻辑,所以这一查直接卡爆了。

还记得我们上面三段代码的对比吗?我们自定义的缓存函数之所以快,是因为我们使用的是cache[key],即便你cache存了几十万条数据,通过对象直接读取key的时间复杂度其实是O(1),所以针对项目中的需求,性能优化小组的同学自己定义了一个根据ID查询工作项属性的工具函数:

export const getterCache = (fn) => {
const cache = new Map();
return (...args) => {
const [uuid] = args;
// 这里的时间复杂度是O(1)
let data = cache.get(uuid);
if (data === undefined) {
// 没有缓存
data = fn.apply(this, args); // 执行原函数获取值
cache.set(uuid, data);
}
return data;
};
};

好处就是借用了Mapget方法,相对于indexOfO(N),脑补一下就知道能快不少了。

重回memoizee,会议结论是,谨慎使用memoizee做函数缓存(不再推荐),若你的函数结果本身就不复杂,那更不要使用了,而对于调用非常庞大的场景,你可能还得手动定义缓存函数,另一点,由于公司react已升到17,所以现在大部分缓存已经使用了useMemo,也暂未发现性能损耗的问题。后续有空再看看useMemo是如何实现的缓存吧,本文到这里结束。

使用memoizee缓存函数提升性能,竟引发了indexOf的性能问题的更多相关文章

  1. TP5 生成数据库字段 和 路由 缓存来提升性能

    关于使用tp5框架如何提升部分性能,框架中很多影响性能的问题在于,很多请求都要重新加载,如果能避免过度加载的问题,就能提升部分性能,所以我们通过缓存来实现这一功能,具体如下. 首先说明 如果是linu ...

  2. KTL 一个支持C++14编辑公式的K线技术工具平台 - 第四版,稳定支持Qt5编程,zqt5语法升级,MA函数提升性能1000%,更多公式算法的内置优化实现。

    K,K线,Candle蜡烛图. T,技术分析,工具平台 L,公式Language语言使用c++14,Lite小巧简易. 项目仓库:https://github.com/bbqz007/KTL 国内仓库 ...

  3. [NewLife.XCode]实体列表缓存(最土的方法实现百万级性能)

    NewLife.XCode是一个有10多年历史的开源数据中间件,支持nfx/netcore,由新生命团队(2002~2019)开发完成并维护至今,以下简称XCode. 整个系列教程会大量结合示例代码和 ...

  4. 深入理解js的变量提升和函数提升

    一.变量提升 在ES6之前,JavaScript没有块级作用域(一对花括号{}即为一个块级作用域),只有全局作用域和函数作用域.变量提升即将变量声明提升到它所在作用域的最开始的部分.上个简历的例子如: ...

  5. JavaScript系列文章:变量提升和函数提升

    第一篇文章中提到了变量的提升,所以今天就来介绍一下变量提升和函数提升.这个知识点可谓是老生常谈了,不过其中有些细节方面博主很想借此机会,好好总结一下. 今天主要介绍以下几点: 1. 变量提升 2. 函 ...

  6. js 变量提升和函数提升原理

    关于js的变量,开始的时候我们都会被告知,变量声明应该在引用该变量之前.关于为什么要这样做呢,开始的时候本着会用就行的目的,也没去深究.不过后来经常会发现一些让人很费解的..姑且称为现象吧.先看一段代 ...

  7. js 函数提升和变量提升

    总结: 函数提升比变量提升优先级高! 词法分析 词法分析方法: js运行前有一个类似编译的过程即词法分析,词法分析主要有三个步骤: 分析参数 再分析变量的声明 分析函数说明 具体步骤如下: 函数在运行 ...

  8. 使用 HTTP 缓存机制提升系统性能

    摘要 HTTP缓存机制定义在HTTP协议标准中,被现代浏览器广泛支持,同时也是一个用于提升基于Web的系统性能的广泛使用的工具.本文讨论如何使用HTTP缓存机制提升基于Web的系统,以及如何避免误用. ...

  9. 一个用于每一天JavaScript示例-使用缓存计算(memoization)为了提高应用程序性能

    <!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content ...

  10. 写一个根据id字段查找记录的缓存函数(javascript)

    前不久在参加面试的时候遇到了这样一道题,"写一个根据id字段查找记录的缓存函数,如果之前查过,则直接返回之前查找过的对象,而无须重新查找".当时由于时间较短加上时间比较紧张,考虑并 ...

随机推荐

  1. java进阶(12)--8种数据包装类型、Integer、常用方法

    一.基本数据类型与包装类型 8种基本数据类型,对应的包装类,父类 1.byte-->java.lang.Byte-->Number 2.short-->java.lang.Short ...

  2. javaweb 项目!号 解决方案

    1:右击项目工程名称2:Properties3:  Jvav Build Path4:  Libraries5:  Add External JARS6:  找到"E:\apache-tom ...

  3. Kubeadm 安装支持IPV6 K8S1.28.x的简单过程

    Kubeadm 安装支持IPV6 K8S的简单过程 背景 手贱 找了一个晚上想尝试安装一个K8S集群 并且可以支持IPV6 协议栈的 然后就开始各种百度. 各种处理 找到了一堆歪门邪道. 但是还不知道 ...

  4. Oracle Preinstall 调优参数的学习

    Oracle Preinstall 调优参数的学习 背景 学习是一个痛苦并快乐的过程. 之前自己手工安装过很多套Oracle数据库,也总结过很多 但是很多都是比较皮毛的. 最近遇到了一些问题. 才发现 ...

  5. [转帖]Linux文件系统的几个性能测试软件小结

    https://developer.aliyun.com/article/297631#:~:text=Linux%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F%E7%9A% ...

  6. [转帖]华为FusionSphere虚拟化解决方案介绍

    https://huaweicloud.csdn.net/63566589d3efff3090b5d243.html?spm=1001.2101.3001.6650.2&utm_medium= ...

  7. WorkStation的网络损耗

    WorkStation的网络损耗 背景 对周六遇到的问题进行了一下深入思考. 发现虽然可以通过WorkStation的方式来进行Clients以及新命令的扩容. 但是Workstation的桥接网络模 ...

  8. [转帖]Linux命令拾遗-%iowait指标代表了什么?

    https://www.cnblogs.com/codelogs/p/16060759.html 简介# 一直以来,我都知道top.vmstat.mpstat中有一个叫wa(%iowait)的cpu指 ...

  9. [转帖]一起来体验96核心、192线程CPU——第四代AMD EPYC处理器独家测试

    http://k.sina.com.cn/article_1882475282_70344b12027010s1x.html 与第三代EPYC 7003系列处理器相比,新一代EPYC 9004系列处理 ...

  10. 你不知道的Linux shell操作

    Linux Shell 脚本入门教程 Linux Shell 脚本是一种强大的工具,它允许您自动化日常任务和复杂操作.在本教程中,我们将逐步介绍几个实用的 Shell 脚本示例.每个示例都将详细说明, ...