高性能 Java 计算服务的性能调优实战
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了高性能 Java 计算服务的性能调优实战相关的知识,希望对你有一定的参考价值。
作者:vivo 互联网服务器团队- Chen Dongxing、Li Haoxuan、Chen Jinxia
随着业务的日渐复杂,性能优化俨然成为了每一位技术人的必修课。性能优化从何着手?如何从问题表象定位到性能瓶颈?如何验证优化措施是否有效?本文将介绍分享 vivo push 推荐项目中的性能调优实践,希望给大家提供一些借鉴和参考。
一、背景介绍
在 Push 推荐中,线上服务从 Kafka 接收需要触达用户的事件,之后为这些目标用户选出最合适的文章进行推送。服务由 Java 开发,CPU 密集计算型。
随着业务的不断发展,请求并发及模型计算量越来越大,导致工程上遇到了性能瓶颈,Kafka 消费出现严重的积压现象,无法及时完成目标用户的分发,业务增长诉求得不到满足,故亟需进行性能专项优化。
二、优化衡量指标和思路
我们的性能衡量指标是吞吐量 TPS ,由经典公式 TPS = 并发数 / 平均响应时间RT 可以知道,若需提高 TPS,可以有 2 种方式:
- 提高并发数,比如提升单机的并行线程数,或者横向扩容机器数;
- 降低平均响应时间 RT,包括应用线程(业务逻辑)执行时间,以及 JVM 本身的 GC 耗时。
实际情况中,我们的机器 CPU 利用率已经很高,达到 80% 以上,提升单机并发数的预期收益有限,故把主要精力投入到降低 RT 上。
下面将从 热点代码 和 JVM GC 两个方面进行详解,我们如何分析定位到性能瓶颈点,并使用 3 招将吞吐量提升 100% 。
三、热点代码优化篇
如何快速找到应用中最耗时的热点代码呢?借助阿里巴巴开源的 arthas 工具,我们获取到线上服务的 CPU 火焰图。
火焰图说明:火焰图是基于 perf 结果产生的 SVG 图片,用来展示 CPU 的调用栈。
y 轴表示调用栈,每一层都是一个函数。调用栈越深,火焰就越高,顶部就是正在执行的函数,下方都是它的父函数。
x 轴表示抽样数,如果一个函数在 x 轴占据的宽度越宽,就表示它被抽到的次数多,即执行的时间长。注意,x 轴不代表时间,而是所有的调用栈合并后,按字母顺序排列的。
火焰图就是看顶层的哪个函数占据的宽度最大。只要有“平顶”(plateaus),就表示该函数可能存在性能问题。
颜色没有特殊含义,因为火焰图表示的是 CPU 的繁忙程度,所以一般选择暖色调。
3.1 优化1:尽量避免原生 String.split 方法
3.1.1 性能瓶颈分析
从火焰图中,我们首先发现了有 13% 的 CPU 时间花在了 java.lang.String.split 方法上。
熟悉性能优化的同学会知道,原生 split 方法是性能杀手,效率比较低,频繁调用时会耗费大量资源。
不过业务上特征处理时确实需要频繁地 split,如何优化呢?
通过分析 split 源码,以及项目的使用场景,我们发现了 3 个优化点:
(1)业务中未使用正则表达式,而原生 split 在处理分隔符为 2 个及以上字符时,默认按正则表达式方式处理;众所周知,正则表达式的效率是低下的。
(2)当分隔符为单个字符(且不为正则表达式字符)时,原生 String.split 进行了性能优化处理,但中间有些内部转换处理,在我们的实际业务场景中反而是多余的、消耗性能的。
其具体实现是:通过 String.indexOf 及 String.substring 方法来实现分割处理,将分割结果存入 ArrayList 中,最后将 ArrayList 转换为 string[] 输出。而我们业务中,其实很多时候需要 list 型结果,多了 2 次 list 和 string[] 的互转。
(3)业务中调用 split 最频繁的地方,其实只需要 split 后的第 1 个结果;原生 split 方法或其它工具类有重载优化方法,可以指定 limit 参数,满足 limit 数量后可以提前返回;但业务代码中,使用 str.split(delim)[0] 方式,非性能最佳。
3.1.2 优化方案
针对业务场景,我们自定义实现了性能优化版的 split 实现。
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
/**
* 自定义split工具
*/
public class SplitUtils
/**
* 自定义分割函数,返回第一个
*
* @param str 待分割的字符串
* @param delim 分隔符
* @return 分割后的第一个字符串
*/
public static String splitFirst(final String str, final String delim)
if (null == str || StringUtils.isEmpty(delim))
return str;
int index = str.indexOf(delim);
if (index < 0)
return str;
if (index == 0)
// 一开始就是分隔符,返回空串
return "";
return str.substring(0, index);
/**
* 自定义分割函数,返回全部
*
* @param str 待分割的字符串
* @param delim 分隔符
* @return 分割后的返回结果
*/
public static List<String> split(String str, final String delim)
if (null == str)
return new ArrayList<>(0);
if (StringUtils.isEmpty(delim))
List<String> result = new ArrayList<>(1);
result.add(str);
return result;
final List<String> stringList = new ArrayList<>();
while (true)
int index = str.indexOf(delim);
if (index < 0)
stringList.add(str);
break;
stringList.add(str.substring(0, index));
str = str.substring(index + delim.length());
return stringList;
相比原生 String.split ,主要有几方面的改动:
- 放弃正则表达式的支持,仅支持按分隔符进行 split;
- 出参直接返回 list。分割处理实现,与原生实现中针对单字符的处理类似,使用 string.indexOf 及 string.substring 方法,分割结果放入 list 中,出参直接返回 list,减少数据转换处理;
- 提供 splitFirst 方法,业务场景只需要分隔符前第一段字符串时,进一步提升性能。
3.1.3 微基准测试
如何验证我们的优化效果呢?首先选用 jmh 作为微基准测试工具,对照选用 原生 String.split 以及 apache 的 StringUtils.split方法,测试结果如下:
选用单字符作为分隔符
可以看出,原生实现与apache的工具类性能差不多,而自定义实现性能提升了约 50%。
选用多字符作为分隔符
当分隔符使用 2 个长度的字符时,原始实现的性能大幅降低,只有单 char 时的 1/3 ;而apache的实现也降低至原来的 2/3 ,而自定义实现与原来基本保持一致。
选用单字符作为分隔符,只需返回第 1 个分割结果
选用单字符作为分隔符,并只需第 1 个分割结果时,自定义实现的性能是原生实现的 2 倍,并是取原生实现完整结果的 5 倍。
3.1.4 端到端优化效果
经微基准测试验证收益后,我们将优化部署到在线服务中,验证端到端整体的性能收益;
重新使用arthas采集火焰图,split 方法耗时降低至 2% 左右;端到端整体耗时下降了 31.77% ,吞吐量上涨了 45.24% ,性能收益特别明显。
3.2 优化2:加快 map 的查表效率
3.2.1 性能瓶颈分析
从火焰图中,我们发现 HashMap.getOrDefault 方法耗时占比也特别多,达到了 20%,主要在查询权重 map 上,这是因为:
- 业务中确实需高频调用,特征交叉处理后数量膨胀,单机的调用并发达到了约 1000w ops/s。
- 权重 map 本身也很大,存储了 1000 万多的 entry,占用了很大一块内存;同时 hash 碰撞的概率也增大,碰撞时的查询效率由 O(1) 降低成了 O(n) (链表) 或 O(logn) (红黑树)。
Hashmap 本身是非常高效的 map 实现,起初我们尝试了调整加载因子 loadFactor 或 换用其它 map 实现,均未取得明显收益。
如何才能提升 get 方法的性能呢?
3.2.2 优化方案
分析过程中我们发现查询 map 的 key(交叉处理后的特征 key )是字符串型,且平均长度在 20 以上;我们知道 string 的 equals 方法其实是遍历比对 char[] 中的字符,key 越长则比对效率越低。
public boolean equals(Object anObject)
if (this == anObject)
return true;
if (anObject instanceof String)
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length)
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0)
if (v1[i] != v2[i])
return false;
i++;
return true;
return false;
是否可以将 key 的长度缩短,或者甚至换成数值型?通过简单的微基准测试,我们发现思路应该是可行的。
于是与算法同学沟通,巧的是算法同学正好也有相同诉求,他们在切换新训练框架过程中发现 string 的效率特别低,需要把特征换成数值型。
一拍即合,方案很快确定:
- 算法同学将特征 key 映射成 long 型数值,映射方法为自定义的 hash 实现,尽量减少 hash 碰撞概率;
- 算法同学训练输出新模型的权重 map ,可以保留更多 entry ,以打平基线模型的效果指标;
- 打平基线模型的效果指标后,在线服务端灰度新模型,权重 map 的 key 改用 long 型,验证性能指标。
3.2.3 优化效果
在增加了 30% 的特征 entry 数下(模型效果超过基线),工程上的性能也达到了明显收益;
端到端整体耗时下降了 20.67%,吞吐量上涨了 26.09%;此外内存使用上也取得了良好收益,权重map的内存大小下降了30%。
四、JVM GC优化篇
Java 设计垃圾自动回收的目的是将应用程序开发人员从手动动态内存管理中解放出来。开发人员无需关心内存的分配与回收,也不用关注分配的动态内存的生存期。这完全消除了一些与内存管理相关的错误,代价是增加了一些运行时开销。
在小型系统上开发时,GC 的性能开销可以忽略,但扩展到大型系统(尤其是那些具有大量数据、许多线程和高事务率的应用程序)时,GC 的开销不可忽视,甚至可能成为重要的性能瓶颈。
上图 模拟了一个理想的系统,除了垃圾收集之外,它是完全可伸缩的。红线表示在单处理器系统上只花费 1% 时间进行垃圾收集的应用程序。这意味着在拥有 32 个处理器的系统上,吞吐量损失超过 20% 。洋红色线显示,对于垃圾收集时间为 10% 的应用程序(在单处理器应用程序中,垃圾收集时间不算太长),当扩展到 32 个处理器时,会损失 75% 以上的吞吐量。
故 JVM GC 也是很重要的性能优化措施。
我们的推荐服务使用高配计算资源(64核256G),GC的影响因素挺可观;通过采集监控在线服务 GC 数据,发现我们的服务 GC 情况挺糟糕的,每分钟YGC累计耗时约 10s。
GC 开销为何这么大,如何降低 GC 的耗时呢?
4.1 优化3:使用堆外缓存代替堆内缓存
4.1.1 性能瓶颈分析
我们 dump 了服务的存活堆对象,使用 mat 工具进行内存分析,发现有 2 个对象特别巨大,占了总存活堆内存的 76.8%。其中:
- 第 1 大对象是本地缓存,存储了细粒度级别的常用数据,每台机器千万级别数据量;使用 caffine 缓存组件,缓存自动刷新周期设定 1 小时;目的是尽量减少 IO 查询次数;
- 第 2 大对象是模型权重 map 本身,常驻内存中,不会 update,等新模型载入后被作为旧模型进行卸载。
4.1.2 优化方案
如何能尽量缓存较多的数据,同时避免过大的 GC 压力呢?
我们想到了把缓存对象移到堆外,这样可以不受堆内内存大小的限制;并且堆外内存,并不受 JVM GC 的管控,避免了缓存过大对 GC 的影响。经过调研,我们决定采用成熟的开源堆外缓存组件 OHC 。
(1)OHC 介绍
简介
OHC 全称为 off-heap-cache,即堆外缓存,是 2015 年针对 Apache Cassandra 开发的缓存框架,后来从 Cassandra 项目中独立出来,成为单独的类库,其项目地址为GitHub - snazy/ohc: Java large off heap cache 。
特性
- 数据存储在堆外,只有少量元数据存储堆内,不影响 GC
- 支持为每个缓存项设置过期时间
- 支持配置 LRU、W_TinyLFU 驱逐策略
- 能够维护大量的缓存条目
- 支持异步加载缓存
- 读写速度在微秒级别
(2)OHC 用法
快速开始:
OHCache ohCache = OHCacheBuilder.newBuilder().
keySerializer(yourKeySerializer)
.valueSerializer(yourValueSerializer)
.build();
可选配置项:
在我们的服务中,设置 capacity 容量 12G,segmentCount 分段数 1024,序列化协议使用 kryo。
4.1.3 优化效果
切换到堆外缓存后,服务 YGC 降低到了 800ms / 每分钟,端到端的整体吞吐量上涨了约 20%。
4.2 思考题
在Java GC优化中,我们把本地缓存对象从Java堆内移到了堆外,取得了不错的性能收益。 还记得上文提到的另一个巨型对象, 模型权重 map 吗 ?模型权重 map 能否也从 Java 堆内移除?
答案是可以的。我们使用C++改写了模型推理计算部分,包括权重map的存储与检索、排序得分计算等逻辑;然后将C++代码输出为 so 库文件,Java程序通过 native 方式调用,实现将权重map从 Jvm 堆内移出,获得了很好的性能收益。
五、结束语
通过上文介绍的 3 个措施,我们从 热点代码优化 与 Jvm GC两方面改善了服务负载与性能,整体吞吐量翻了 1 倍,达到了阶段性的预期目标。
不过性能调优是永无止境的,而且每个业务场景、每个系统的实际情况也都是千差万别,很难用1篇文章去涵盖介绍所有的优化场景。希望本文介绍的一些调优实战经验,比如如何确定优化方向、如何着手分析以及如何验证收益,能给大家一些借鉴和参考。
Day819.缓存优化系统性能 -Java 性能调优实战
缓存优化系统性能
Hi,我是阿昌
,今天学习记录的是关于缓存优化系统性能
。
缓存 是提高系统性能
的一项必不可少的技术,无论是前端、还是后端,都应用到了缓存技术。
-
前端
使用缓存,可以降低多次请求服务的压力; -
后端
使用缓存,可以降低数据库操作的压力,提升读取数据的性能。
一、前端缓存技术
如果是一位 Java 开发工程师,可能会想,有必要去了解前端的技术吗?
不想当将军的士兵不是好士兵,作为一个技术人员,不想做架构师的开发不是好开发。
作为架构工程师的话,就很有必要去了解前端的知识点了,这样有助于设计和优化系统。
前端做缓存,可以缓解服务端的压力,减少带宽的占用,同时也可以提升前端的查询性能。
1、本地缓存
平时使用拦截器(例如 Fiddler)或浏览器 Debug 时,经常会发现一些接口返回 304 状态码 + Not Modified 字符串,如下图中的极客时间 Web 首页。
如果对前端缓存技术不了解,就很容易对此感到困惑。
浏览器常用的一种缓存就是这种基于 304 响应
状态实现的本地缓存了,通常这种缓存被称为协商缓存
。
协商缓存,顾名思义就是与服务端协商之后,通过协商结果来判断是否使用本地缓存。
一般协商缓存可以
- 基于
请求头部中的If-Modified-Since 字段
与返回头部中的 Last-Modified 字段
实现 - 也可以基于
请求头部中的 If-None-Match 字段
与返回头部中的 ETag 字段
来实现。
两种方式的实现原理是一样的,前者是基于时间实现的,后者是基于一个唯一标识实现的,相对来说后者可以更加准确地判断文件内容是否被修改,避免由于时间篡改导致的不可靠问题。下面我们再来了解下整个缓存的实现流程:
- 当浏览器第一次请求访问服务器资源时,服务器会在返回这个资源的同时,在 Response 头部加上 ETag 唯一标识,这个唯一标识的值是根据当前请求的资源生成的;
- 当浏览器再次请求访问服务器中的该资源时,会在 Request 头部加上 If-None-Match 字段,该字段的值就是 Response 头部加上 ETag 唯一标识;
- 服务器再次收到请求后,会根据请求中的 If-None-Match 值与当前请求的资源生成的唯一标识进行比较,如果值相等,则返回 304 Not Modified,如果不相等,则在 Response 头部加上新的 ETag 唯一标识,并返回资源;
- 如果浏览器收到 304 的请求响应状态码,则会从本地缓存中加载资源,否则更新资源。
本地缓存中除了这种协商缓存,还有一种就是强缓存的实现。
强缓 存指的是只要判断缓存没有过期,则直接使用浏览器的本地缓存。
如下图中,返回的是 200 状态码,但在 size 项
中标识的是 memory cache
。
强缓存是利用 Expires 或者 Cache-Control 这两个 HTTP Response Header 实现的,它们都用来表示资源在客户端缓存的有效期。
Expires 是一个绝对时间,而 Cache-Control 是一个相对时间,即一个过期时间大小,与协商缓存一样,基于 Expires 实现的强缓存也会因为时间问题导致缓存管理出现问题。
建议使用 Cache-Control
来实现强缓存。具体的实现流程如下:
- 当浏览器第一次请求访问服务器资源时,服务器会在返回这个资源的同时,在 Response 头部加上 Cache-Control,Cache-Control 中设置了过期时间大小;
- 浏览器再次请求访问服务器中的该资源时,会先通过请求资源的时间与 Cache-Control 中设置的过期时间大小,来计算出该资源是否过期,如果没有,则使用该缓存,否则请求服务器;
- 服务器再次收到请求后,会再次更新 Response 头部的 Cache-Control。
2、网关缓存
除了以上本地缓存,还可以在网关中设置缓存,也就是熟悉的 CDN
。
CDN 缓存 是通过不同地点的缓存节点缓存资源副本,当用户访问相应的资源时,会调用最近的 CDN 节点返回请求资源,这种方式常用于视频资源的缓存。
二、服务层缓存技术
前端缓存一般用于缓存一些不常修改
的常量数据或一些资源文件,大部分接口请求的数据都缓存在了服务端,方便统一管理缓存数据。
服务端缓存 的初衷是为了提升系统性能。
例如,数据库由于并发查询压力过大,可以使用缓存减轻数据库压力;
在后台管理中的一些报表计算类数据,每次请求都需要大量计算,消耗系统 CPU 资源,我们可以使用缓存来保存计算结果。
服务端的缓存也分为进程缓存
和分布式缓存
,在 Java 中进程缓存就是 JVM 实现的缓存,常见的有经常使用的容器类,ArrayList、ConcurrentHashMap 等,分布式缓存则是基于 Redis 实现的缓存。
1、进程缓存
对于进程缓存,虽然数据的存取会更加高效,但 JVM 的堆内存数量是有限的,且在分布式环境下很难同步各个服务间的缓存更新,所以一般缓存一些数据量不大、更新频率较低的数据。常见的实现方式如下:
//静态常量
public final staticS String url = "https://time.geekbang.org";
//list容器
public static List<String> cacheList = new Vector<String>();
//map容器
private static final Map<String, Object> cacheMap= new ConcurrentHashMap<String, Object>();
除了 Java 自带的容器可以实现进程缓存,还可以基于 Google 实现的一套内存缓存组件 Guava Cache 来实现。
Guava Cache 适用于高并发的多线程缓存,它和 ConcurrentHashMap 一样,都是基于分段锁实现的并发缓存。
Guava Cache 同时也实现了数据淘汰机制,当设置了缓存的最大值后,当存储的数据超过了最大值时,它就会使用 LRU 算法
淘汰数据。
可以通过以下代码了解下 Guava Cache 的实现:
public class GuavaCacheDemo
public static void main(String[] args)
Cache<String,String> cache = CacheBuilder.newBuilder()
.maximumSize(2)
.build();
cache.put("key1","value1");
cache.put("key2","value2");
cache.put("key3","value3");
System.out.println("第一个值:" + cache.getIfPresent("key1"));
System.out.println("第二个值:" + cache.getIfPresent("key2"));
System.out.println("第三个值:" + cache.getIfPresent("key3"));
运行结果:
第一个值:null
第二个值:value2
第三个值:value3
那如果数据量比较大,且数据更新频繁,又是在分布式部署的情况下,想要使用 JVM 堆内存作为缓存,这时又该如何去实现呢?
Ehcache 是一个不错的选择,Ehcache 经常在 Hibernate 中出现,主要用来缓存查询数据结果。
Ehcache 是 Apache 开源的一套缓存管理类库,是基于 JVM 堆内存实现的缓存,同时具备多种缓存失效策略,支持磁盘持久化以及分布式缓存机制。
2、分布式缓存
由于高并发对数据一致性的要求比较严格,一般不建议使用 Ehcache 缓存有一致性要求的数据。
对于分布式缓存,建议使用 Redis 来实现
,Redis 相当于一个内存数据库,由于是纯内存操作,又是基于单线程串行
实现,查询性能极高,读速度超过了 10W 次 / 秒。
Redis 除了高性能的特点之外,还支持不同类型的数据结构,常见的有 string、list、set、hash 等,还支持数据淘汰策略、数据持久化以及事务等。
三、数据库与缓存数据一致性问题
在查询缓存数据时,会先读取缓存,如果缓存中没有该数据,则会去数据库中查询,之后再放入到缓存中。
当数据被缓存之后,一旦数据被修改(修改时也是删除缓存中的数据)或删除,就需要同时操作缓存和数据库。这时,就会存在一个数据不一致
的问题。.
例如,在并发情况下,当 A 操作使得数据发生删除变更,那么该操作会先删除缓存中的数据,之后再去删除数据库中的数据,此时若是还没有删除成功,另外一个请求查询操作 B 进来了,发现缓存中已经没有了数据,则会去数据库中查询,此时发现有数据,B 操作获取之后又将数据存放在了缓存中,随后数据库的数据又被删除了。此时就出现了数据不一致的情况。
那如果先删除数据库,再删除缓存呢?可以试一试。
在并发情况下,当 A 操作使得数据发生删除变更,那么该操作会先删除了数据库的操作,接下来删除缓存,失败了,那么缓存中的数据没有被删除,而数据库的数据已经被删除了,同样会存在数据不一致的问题。所以,还是需要先做缓存删除操作,再去完成数据库操作。
那又该如何避免高并发下,数据更新删除操作所带来的数据不一致的问题呢?
通常的解决方案是,如果需要使用一个线程安全队列来缓存更新或删除的数据,当 A 操作变更数据时,会先删除一个缓存数据,此时通过线程安全的方式将缓存数据放入到队列中,并通过一个线程进行数据库的数据删除操作。
当有另一个查询请求 B 进来时,如果发现缓存中没有该值,则会先去队列中查看该数据是否正在被更新或删除,如果队列中有该数据,则阻塞等待,直到 A 操作数据库成功之后,唤醒该阻塞线程,再去数据库中查询该数据。
但其实这种实现也存在很多缺陷,例如,可能存在读请求被长时间阻塞,高并发时低吞吐量等问题。
所以在考虑缓存时,如果数据更新比较频繁且对数据有一定的一致性要求,我通常不建议使用缓存。
四、缓存穿透、缓存击穿、缓存雪崩
对于分布式缓存实现大数据的存储,除了数据不一致的问题以外,还有缓存穿透、缓存击穿、缓存雪崩等问题,平时实现缓存代码时,应该充分、全面地考虑这些问题。
缓存穿透 是指大量查询没有命中缓存,直接去到数据库中查询,如果查询量比较大,会导致数据库的查询流量大,对数据库造成压力。
通常有两种解决方案,一种是将第一次查询的空值缓存
起来,同时设置一个比较短的过期时间。但这种解决方案存在一个安全漏洞,就是当黑客利用大量没有缓存的 key 攻击系统时,缓存的内存会被占满溢出。另一种则是使用布隆过滤算法
(BloomFilter),该算法可以用于检查一个元素是否存在,返回结果有两种:可能存在或一定不存在。
这种情况很适合用来解决故意攻击系统的缓存穿透问题,在最初缓存数据时也将 key 值缓存在布隆过滤器的 BitArray 中,当有 key 值查询时,对于一定不存在的 key 值,可以直接返回空值,对于可能存在的 key 值,会去缓存中查询,如果没有值,再去数据库中查询。BloomFilter 的实现原理与 Redis 中的 BitMap 类似,首先初始化一个 m 长度的数组,并且每个 bit 初始化值都是 0,当插入一个元素时,会使用 n 个 hash 函数来计算出 n 个不同的值,分别代表所在数组的位置,然后再将这些位置的值设置为 1。
假设插入两个 key 值分别为 20,28 的元素,通过两次哈希函数取模后的值分别为 4,9 以及 14,19,因此 4,9 以及 14,19 都被设置为 1。
那为什么说 BloomFilter 返回的结果是可能存在和一定不存在呢?
假设查找一个元素 25,通过 n 次哈希函数取模后的值为 1,9,14。此时在 BitArray 中肯定是不存在的。而当查找一个元素 21 的时候,n 次哈希函数取模后的值为 9,14,此时会返回可能存在的结果,但实际上是不存在的。
BloomFilter 不允许删除任何元素的,为什么?假设以上 20,25,28 三个元素都存在于 BitArray 中,取模的位置值分别为 4,9、1,9,14 以及 14,19,如果我们要删除元素 25,此时需要将 1,9,14 的位置都置回 0,这样就影响 20,28 元素了。
因此,BloomFilter 是不允许删除任何元素的,这样会导致已经删除的元素依然返回可能存在的结果,也会影响 BloomFilter 判断的准确率,解决的方法则是重建一个 BitArray。
那什么缓存击穿呢?在高并发情况下,同时查询一个 key 时,key 值由于某种原因突然失效(设置过期时间或缓存服务宕机),就会导致同一时间,这些请求都去查询数据库了。这种情况经常出现在查询热点数据的场景中。
通常会在查询数据库时,使用排斥锁来实现有序地请求数据库,减少数据库的并发压力。缓存雪崩则与缓存击穿差不多,区别就是失效缓存的规模。
雪崩一般是指发生大规模的缓存失效情况,例如,缓存的过期时间同一时间过期了,缓存服务宕机了。对于大量缓存的过期时间同一时间过期的问题,我们可以采用分散过期时间来解决;而针对缓存服务宕机的情况,我们可以采用分布式集群来实现缓存服务。
五、总结
从前端到后端,对于一些不常变化的数据,都可以将其缓存起来,这样既可以提高查询效率,又可以降低请求后端的压力。
对于前端来说,一些静态资源文件都是会被缓存在浏览器端,除了静态资源文件,我们还可以缓存一些常量数据,例如商品信息。
服务端的缓存,包括了 JVM 的堆内存作为缓存以及 Redis 实现的分布式缓存。如果是一些不常修改的数据,数据量小,且对缓存数据没有严格的一致性要求,我们就可以使用堆内存缓存数据,这样既实现简单,查询也非常高效。
如果数据量比较大,且是经常被修改的数据,或对缓存数据有严格的一致性要求,我们就可以使用分布式缓存来存储。
在使用后端缓存时,我们应该注意数据库和缓存数据的修改导致的数据不一致问题,如果对缓存与数据库数据有非常严格的一致性要求,就不建议使用缓存了。
同时,应该针对大量请求缓存的接口做好预防工作,防止查询缓存的接口出现缓存穿透、缓存击穿和缓存雪崩等问题。
在基于 Redis 实现的分布式缓存中,更新数据时,为什么建议直接将缓存中的数据删除,而不是更新缓存中的数据呢?
更新效率太低,代价很大,且不一定被访问的频率高,不高则没必要缓存,还不如直接删掉,而且还容易出现数据不一致问题
以上是关于高性能 Java 计算服务的性能调优实战的主要内容,如果未能解决你的问题,请参考以下文章
分布式技术专题「系统性能调优实战」终极关注应用系统性能调优及原理剖析(下册)
性能提升60%↑ 成本降低50%↓ 个推分享Spark性能调优实战经验