SpringCloud系统性能优化

背景

  • 需要对系统进行流程性测试,查找系统瓶颈点所在,当大流量来临能有效应对。现将优化处理方法进行梳理总结,供大家参考学习

系统参数

系统服务名 服务器数量 CPU(核) 内存(G)
nginx 2 4 8
gateway 4 8 16
course(不同服务器类型) 2 2 4
course(不同服务器类型) 2 8 64
user 2 2 4
login 2 2 4
数据库1 1 8 16
数据库2 1 8 16
redis 1 2 2
  • 服务器说明:在测试中有直连进行测试,实际测试某一个服务中,未全部使用服务器,会根据测试内容临时下线机器,具体方案会在后续说到。
  • 启动参数中:gateway使用内存配置为4g。 其他应用服务启动参数内存为2g

压测优化点内容

gateway优化项

  • 网关核心功能

    • 路由转发
    • 流量计算/限流
    • 统一登录验证
  • spring cloud gateway文档:https://docs.spring.io/spring-cloud-gateway/docs/2.2.6.RELEASE/reference/html/#gateway-starter

  • -Dreactor.netty.ioWorkerCount=64

    问题1:线程数量设置过小。

    1. 被压测接口:返回当前系统时间。接口响应平均响应时间在1毫秒不到
    2. 压测方法:直连tomcat压测,和通过gateway在连接对比压测
    3. 压测QPS:tomcat直连:3W+ 。 gateway链接:1500+
    4. 说明:gateway有进行redis的登录验证操作,耗时在2、3毫秒左右,redis的瓶颈在1W左右
    5. 压测过程中,对比后发现gateway服务器的cpu利用率很低,对比发现属于redis验证阻塞了主线程,导致请求无法及时转发。
    6. gateway使用reactor netty进行作为转发框架。默认设置为cpu数量同等线程数,但只适合cpu密集型任务,对于路由转发任务需要调高线程数量,以便于提高cpu利用率
      参考文章:https://blog.csdn.net/trecn001/article/details/107286396

    问题2:登录验证redis存在大key

    1. 被压测接口:同问题1一致
    2. 压测方法:同问题1一致
    3. 压测QPS:同问题1一致
    4. 说明:同问题1一致
    5. 经过解决问题1后,QPS依然维持在1W左右,通过计算,用户登录后存储在redis中字节数为1388个字节,redis带宽为128Mbit/s。换算后redis的带宽瓶颈为QPS:1W+。去掉中间程序因素,只能维持在1w左右
    6. 追踪程序后,用户登录使用的为jwt验证。会将用户所有数据进行加密存储为accessToken和一个refreshToken。
      参考文章:https://www.cnblogs.com/ruoruchujian/p/11271285.html
    7. redis存储信息包含:用户id,用户类型,accessToken,refreshToken,deviceId,jti。同时:根据jwt的规则accessToken加密串已经包含了所有的信息,所以不需要在单独存储。同时查看目前系统登录逻辑refreshToken暂时并没有使用,只是用于一个扩展项。
    8. 对登录用户信息进行优化,redis不在存储refreshToken,同时对加密token进行字段缩减。只放入userId,deviceId必要字段,加密串大大减少。缩减后剩余388个字节。redis带宽可同步增长3~4倍

应用服务优化项

  1. spring actuator至性能衰减

    1
    2
    3
    1. https://www.shuzhiduo.com/A/rV57PbN9dP/
    2. 由于actuator会注入很多*号的url。这时候程序使用PathVariable ,会造成很多次的无效匹配
    3. 解决:actuator声明一些需要使用的端点,不要使用*号进行
  2. 华为云redis查询QPS过低排查

    1
    2
    1. redis带宽不足
    2. 程序的并发高,同时用户存储了所有数据,占用大量带宽
  3. spring mvc transactional导致性能瓶颈

  4. 应用服务tomcat连接数配置,此项不作说明,需要根据业务系统来定

    1
    2
    3
    4
    server.tomcat.max-threads=300   // springboot默认是200
    server.tomcat.accept-count=200 // springboot默认是100
    server.tomcat.max-connections=8192 // springboot默认是8192 . 1024*8。
    server.tomcat.min-spare-threads=50 // springboot默认是10
  5. hystrix使用信号量配置减少CPU上下文切换,此项不作说明,需要根据业务系统来定

    1
    2
    1. 参考文章:https://blog.csdn.net/dap769815768/article/details/94630276
    2. 系统使用了okhttp,本身有配置相应线程池,不需要在使用hystrix进行线程池。以减少cpu争用

优化总结

  1. 在分布式系统中,需要定位问题点,问题点对应了才能进行解决。上面解决方案非常简单,但难点是定位到各个问题点。同时有可能是多不同问题点叠加产生瓶颈。
  2. 定位问题中需要进行分解目标点,参考附录大致图中。以下是定位gateway网关的思路。其他接口也可以参照以下思路。使用排除法一步步测试
    • 系统经过第一次的压测,将应用服务进行了优化。随后进行第二轮压测,定位至网关redis瓶颈和线程数。定位过程如下
      • 在前面压测中,由于应用服务存在瓶颈点,一直未打满gateway,导致无法压测出gateway有瓶颈。本次进行全流程的压测,其中有一个响应在几毫秒内的接口。对比tomcat和网关后发现有巨大差异。
      • 压测中需要进行一步步排除差异,第一步:先进行了tomcat压测,排除nginx和gateway,第二步:直连其中一台gateway进行压测,排除nginx,第三步:外网域名压测。
      • 结果对比后第二步有巨大差异,而且波动很大。第三步由于nginx有2台,gateway有4台。在物理设备中有增加,但同时也同第一步的结果差异较大
      • 通过第二步对比tomcat和经过网关在转发的QPS数后,准备了一个不进行登录验证空接口进行测试(下文该接口记录为A),同时网关有进行登录验证,在准备一个需要登录验证的空接口(下文该接口记录为B)。
      • 直接进行tomcat压测,确定应用服务是否存在瓶颈点。对tomcat链接瓶颈疑问直连进行压测。确定tomcat参数正常,不存在相应瓶颈
      • 进行第二步操作细化,先使用了A接口进行压测。对比发现QPS相差较小,由于经过网关一层有相比较有下降,稍QPS少一点为正常现象。
      • 确定了A接口不存在问题。这时候在使用B接口进行测试,运行效果相差特别大。QPS有10的倍数下降。这时候可以确定为网关登录验证出现问题
      • 当前问题进行了细化,网关的登录验证产生瓶颈。这时候定位出2个问题。
      • 首先查看了redis瓶颈,通过监控发现redis没有瓶颈,带宽使用量也不高,那么就确定为redis的客户端也就是gateway存在其他瓶颈。
      • 首先对gateway的redis连接数进行优化,调高参数,进行再次压测。但调高参数发现并没有效果,同时调高的参数并没有被使用上。在细化后瓶颈点不在redis连接获取数据上
      • 对gateway的流程进行梳理,查看开启的线程数量为8个。同时经过跟踪后,redis的操作是在主线程进行。主线程数量不足导致的并发数无法提高
      • 提高主线程并发数量,QPS响应开始以倍数提高。最终测试通过

参考文章:https://www.cnblogs.com/binyue/p/6141088.html

性能优化

  • spring mvc transactional导致性能瓶颈

  • 华为云redis带宽限制

  • spring actuator至性能瓶颈(https://www.shuzhiduo.com/A/rV57PbN9dP/)

  • 优化项:tomcat进行并发配置

  • hystrix信号量配置减少CPU上下文切换

img.png

前缀树

定义

问题描述:

  • 字典树(前缀树)
    Trie树,即字典树,又称单词查找树或键树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高。
  1. Trie的核心思想是空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。

    1
    2
    3
    4
    它有3个基本性质:
    根节点不包含字符,除根节点外每一个节点都只包含一个字符。
    从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
    每个节点的所有子节点包含的字符都不相同。
  2. Aho-Corasick算法

应用

  1. java敏感词过滤实现

布隆过滤器

定义

  1. 布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。
  • 优点

    1
    2
    相比于其它的数据结构,布隆过滤器在空间和时间方面都有巨大的优势。布隆过滤器存储空间和插入/查询时间都是常数。另外, Hash函数相互之间没有关系,方便由硬件并行实现。布隆过滤器不需要存储元素本身,在某些对保密要求非常严格的场合有优势。
    布隆过滤器可以表示全集,其它任何数据结构都不能。
  • 缺点

    1
    2
    3
    但是布隆过滤器的缺点和优点一样明显。误算率是其中之一。随着存入的元素数量增加,误算率随之增加。常见的补救办法是建立一个小的白名单,存储那些可能被误判的元素。但是如果元素数量太少,则使用散列表足矣。
    另外,一般情况下不能从布隆过滤器中删除元素。我们很容易想到把位列阵变成整数数组,每插入一个元素相应的计数器加1, 这样删除元素时将计数器减掉就可以了。然而要保证安全的删除元素并非如此简单。首先我们必须保证删除的元素的确在布隆过滤器里面. 这一点单凭这个过滤器是无法保证的。另外计数器回绕也会造成问题。
    在降低误算率方面,有不少工作,使得出现了很多布隆过滤器的变种。

应用

  1. 网页URL的去重,垃圾邮件的判别,集合重复元素的判别,查询加速(比如基于key-value的存储系统)、数据库防止查询击穿, 使用BloomFilter来减少不存在的行或列的磁盘查找。

mysql事务理解

mysql知识点

事务的说明探讨

参考:1

mysql知识点

  1. 事务四大特征:原子性,一致性,隔离性和持久性(ACID)

    1
    2
    这 4 条特性,是事务管理的基石,一定要透彻理解。此外还要明确,这四个家伙当中,谁才是最终目标?
    理解:原子性是基础,隔离性是手段,持久性是目的,最终目标就是一致性。数据不一致了,就相当于数据错位了。所以说,这三个小弟都是跟着“一致性”这个老大混,为他全心全意服务。
  2. mysql隔离级别:是为了处理事务中隔离性的工具

    1
    2
    3
    4
    5
    SQL标准定义的四个隔离级别为:
    READ UNCOMMITTED
    READ COMMITTED
    REPEATABLE READ
    SERIALIZABLE

    mysql事务对应

网络基础

网络tcp/ip使用协议

tcp/ip协议

网络osi模型作用

osi模型

  1. tcp协议解析

  2. 各层常用协议
    各层常用协议

马拉车算法(Manacher)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
算法解决的问题是求最长回文子串。
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。

示例 1:

输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。

示例 2:

输入: "cbbd"
输出: "bb"

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/longest-palindromic-substring
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
  1. 类似解法还有:中心扩散法。该方法时间复杂度为O(n2)。马拉车算法降低到n

java线程池参数说明

java线程池

ThreadPoolExecutor
来窥探线程池核心类的构造函数,我们需要理解每一个参数的作用,才能理解线程池的工作原理。

1
2
3
4
5
6
7
8
9
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
......
}
  1. corePoolSize:保留在池中的线程数,即使它们空闲,除非设置了 allowCoreThreadTimeOut,不然不会关闭。
  2. maximumPoolSize:队列满后池中允许的最大线程数。
  3. keepAliveTime、TimeUnit:如果线程数大于核心数,多余的空闲线程的保持的最长时间会被销毁。unit 是 keepAliveTime 参数的时间单位。当设置 allowCoreThreadTimeOut(true) 时,线程池中 corePoolSize 范围内的线程空闲时间达到 keepAliveTime 也将回收。
  4. workQueue:当线程数达到 corePoolSize 后,新增的任务就放到工作队列 workQueue 里,而线程池中的线程则努力地从 workQueue 里拉活来干,也就是调用 poll 方法来获取任务。
  5. ThreadFactory:创建线程的工厂,比如设置是否是后台线程、线程名等。
  6. RejectedExecutionHandler:拒绝策略,处理程序因为达到了线程界限和队列容量执行拒绝策略。也可以自定义拒绝策略,只要实现 RejectedExecutionHandler 即可。默认的拒绝策略:AbortPolicy 拒绝任务并抛出 RejectedExecutionException 异常;CallerRunsPolicy 提交该任务的线程执行;
  • 来分析下每个参数之间的关系:
    提交新任务的时候,如果线程池数 < corePoolSize,则创建新的线程池执行任务,当线程数 = corePoolSize 时,新的任务就会被放到工作队列 workQueue 中,线程池中的线程尽量从队列里取任务来执行。
    如果任务很多,workQueue 满了,且 当前线程数 < maximumPoolSize 时则临时创建线程执行任务,如果总线程数量超过 maximumPoolSize,则不再创建线程,而是执行拒绝策略。DiscardPolicy 什么都不做直接丢弃任务;DiscardOldestPolicy 丢弃最旧的未处理程序;

tomcat线程池

定制版的 ThreadPoolExecutor,继承了 java.util.concurrent.ThreadPoolExecutor。 对于线程池有两个很关键的参数:

线程个数。
队列长度。

Tomcat 必然需要限定想着两个参数不然在高并发场景下可能导致 CPU 和内存有资源耗尽的风险。继承了 与 java.util.concurrent.ThreadPoolExecutor 相同,但实现的效率更高。
其构造方法如下,跟 Java 官方的如出一辙

1
2
3
4
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, handler);
prestartAllCoreThreads();
}

在 Tomcat 中控制线程池的组件是 StandardThreadExecutor , 也是实现了生命周期接口,下面是启动线程池的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
protected void startInternal() throws LifecycleException {
// 自定义任务队列
taskqueue = new TaskQueue(maxQueueSize);
// 自定义线程工厂
TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon,getThreadPriority());
// 创建定制版线程池
executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS,taskqueue, tf);
executor.setThreadRenewalDelay(threadRenewalDelay);
if (prestartminSpareThreads) {
executor.prestartAllCoreThreads();
}
taskqueue.setParent(executor);
// 观察者模式,发布启动事件
setState(LifecycleState.STARTING);
}

其中的关键点在于:

Tomcat 有自己的定制版任务队列和线程工厂,并且可以限制任务队列的长度,它的最大长度是 maxQueueSize。
Tomcat 对线程数也有限制,设置了核心线程数(minSpareThreads)和最大线程池数(maxThreads)。

除此之外, Tomcat 在官方原有基础上重新定义了自己的线程池处理流程,原生的处理流程上文已经说过。

前 corePoolSize 个任务时,来一个任务就创建一个新线程。
还有任务提交,直接放到队列,队列满了,但是没有达到最大线程池数则创建临时线程救火。
线程总线数达到 maximumPoolSize ,直接执行拒绝策略。

Tomcat 线程池扩展了原生的 ThreadPoolExecutor,通过重写 execute 方法实现了自己的任务处理逻辑:

前 corePoolSize 个任务时,来一个任务就创建一个新线程。
还有任务提交,直接放到队列,队列满了,但是没有达到最大线程池数则创建临时线程救火。
线程总线数达到 maximumPoolSize ,继续尝试把任务放到队列中。如果队列也满了,插入任务失败,才执行拒绝策略。

最大的差别在于 Tomcat 在线程总数达到最大数时,不是立即执行拒绝策略,而是再尝试向任务队列添加任务,添加失败后再执行拒绝策略。
代码如下所示:

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
public void execute(Runnable command, long timeout, TimeUnit unit) {
// 记录提交任务数 +1
submittedCount.incrementAndGet();
try {
// 调用 java 原生线程池来执行任务,当原生抛出拒绝策略
super.execute(command);
} catch (RejectedExecutionException rx) {
//总线程数达到 maximumPoolSize,Java 原生会执行拒绝策略
if (super.getQueue() instanceof TaskQueue) {
final TaskQueue queue = (TaskQueue)super.getQueue();
try {
// 尝试把任务放入队列中
if (!queue.force(command, timeout, unit)) {
submittedCount.decrementAndGet();
// 队列还是满的,插入失败则执行拒绝策略
throw new RejectedExecutionException("Queue capacity is full.");
}
} catch (InterruptedException x) {
submittedCount.decrementAndGet();
throw new RejectedExecutionException(x);
}
} else {
// 提交任务书 -1
submittedCount.decrementAndGet();
throw rx;
}

}
}

Tomcat 线程池是用 submittedCount 来维护已经提交到了线程池,这跟 Tomcat 的定制版的任务队列有关。Tomcat 的任务队列 TaskQueue 扩展了 Java 中的 LinkedBlockingQueue,我们知道 LinkedBlockingQueue 默认情况下长度是没有限制的,除非给它一个 capacity。因此 Tomcat 给了它一个 capacity,TaskQueue 的构造函数中有个整型的参数 capacity,TaskQueue 将 capacity 传给父类 LinkedBlockingQueue 的构造函数,防止无限添加任务导致内存溢出。而且默认是无限制,就会导致当前线程数达到核心线程数之后,再来任务的话线程池会把任务添加到任务队列,并且总是会成功,这样永远不会有机会创建新线程了。
为了解决这个问题,TaskQueue 重写了 LinkedBlockingQueue 的 offer 方法,在合适的时机返回 false,返回 false 表示任务添加失败,这时线程池会创建新的线程。

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
public class TaskQueue extends LinkedBlockingQueue<Runnable> {

...
@Override
// 线程池调用任务队列的方法时,当前线程数肯定已经大于核心线程数了
public boolean offer(Runnable o) {

// 如果线程数已经到了最大值,不能创建新线程了,只能把任务添加到任务队列。
if (parent.getPoolSize() == parent.getMaximumPoolSize())
return super.offer(o);

// 执行到这里,表明当前线程数大于核心线程数,并且小于最大线程数。
// 表明是可以创建新线程的,那到底要不要创建呢?分两种情况:

//1. 如果已提交的任务数小于当前线程数,表示还有空闲线程,无需创建新线程
if (parent.getSubmittedCount()<=(parent.getPoolSize()))
return super.offer(o);

//2. 如果已提交的任务数大于当前线程数,线程不够用了,返回 false 去创建新线程
if (parent.getPoolSize()<parent.getMaximumPoolSize())
return false;

// 默认情况下总是把任务添加到任务队列
return super.offer(o);
}

}

只有当前线程数大于核心线程数、小于最大线程数,并且已提交的任务个数大于当前线程数时,也就是说线程不够用了,但是线程数又没达到极限,才会去创建新的线程。这就是为什么 Tomcat 需要维护已提交任务数这个变量,它的目的就是在任务队列的长度无限制的情况下,让线程池有机会创建新的线程。可以通过设置 maxQueueSize 参数来限制任务队列的长度。