记一次Dubbo应用性能优化
背景
新的应用以dubbo接口对外暴露调度能力。
在上线前需要整体压测,确认下系统能力与瓶颈。
目标
目标QPS: 500+
目标RT: 100ms-
整体架构
应用框架
- SpringBoot 2.5.6
- Druid 1.1.22
机器配置清单
- ECS规格: ecs.n4.2xlarge
- 2台8C16G 独享型
- CPU: 2.5 GHz主频的Intel ® Xeon ®处理器
- JDK 1.8
- 网络: 1.2Gbps,即至少支持100MB/s的内网带宽
中间件配置清单
Redis
- 规格:8C32GB
- 支持的最大连接数量:30000
- Host到Redis的Ping延时在1ms以内
DB/RDS
- 规格:mysql.x4.4xlarge.2 32C128GB
- MySQL 5.7
- 支持的最大连接数量:20000
- Host到DB的Ping延时在1ms以内
压测记录
第一轮压测
压测结果
- 压到140QPS时, RT已经飙升到8s+;
其他指标
- cpu: 已经基本跑满了,利用率780+%;
- mem: 内存占用量很低,总体稳定在34%左右。
- 网络: 总体入流量在10Mbps以下,出流量1Mbps以下,远远没有打到带宽上限。
- 磁盘: 没有频繁的磁盘IO
- Java: 没有发生频繁的GC。
原因分析
根据应用日志,发现有如下几个问题:
- Redis访问
- 单次schedule()请求,会连续串行调用40+次的redis get接口。
- 这些串行的get调用,加起来平均耗时在6s+
- 其他问题:
- apache.util中Pair类型序列化问题
Redis访问速度慢问题分析
是否是服务端问题?
查看了Redis服务端整体性能情况:
- 出口帶寬爲100MB,當時只利用了10%左右
- CPU Usage 14% 左右
- 連接數:最大支持30000,當時只有3個穩定的連接
- QPS: 高峯期QPS爲6000,而實例的max爲24w,使用率僅爲2.5%,遠遠沒有達到限流閾值
是否是客户端问题?
- 網絡流量:10MB左右,遠遠沒有打滿。
- Load: 很低,0.2以下
問題定位
在這種情況下,一般就只能懷疑是client連接問題。類似HttpClient,客戶端請求都在排隊等着獲取connection。
- 方案1: 增大連接池配置,增大最大連接數量。
- 方案2: 減少請求次數
优化方案
- Redis优化:
- 采用了方案2: 使用Redis的mget,而不是单个get。以减少网络交互
- 为啥没采用方案1?
因为 SpringBoot 1.5.x 版本默认使用Jedis作为redis client,比较好调整connection数量等。
但 SpringBoot 2.x 版本默认使用了Lettuce作为redis client,本身基于Netty的异步IO方式实现,与Jedis不同,本身就支持单个连接被多个线程同时访问。
所以官方不建议配置连接池。
- 其他问题优化
- 自己重新实现了一版Pair类型的序列化、反序列化方式
第二轮压测
压测结果
- 在250QPS时,RT基本稳定在100ms以下
- 压到300QPS时,RT突然飙升到3s+
- cpu利用率较低
- 内存占用量很低,总体稳定在34%左右
- 没有发生GC
原因分析
根据日志打点,很容易找到当时的瓶颈就在ram-client调用鉴权服务鉴权上。
- RAM接口问题:
优化方案
- RAM Client优化:
- 增大client的max-connections数量,增大到300。
这里有个插曲,当增大client的max-connections数量到300之后,重新进行了一次压测,发现QPS并没有提升,RT的瓶颈仍然在访问RAM上!
后续看了ram-client的发布记录,发现属于ram-client的一个bug。
ram-client使用了http-client作为内部实现。
当前版本中,增大max-connections数量,只是增大了httpclient全局支持的connection数量,而单个host支持的connection数量仍然是默认值,即8.
由于访问ram的endpoint就是单个host,因此相当于该配置没有生效!
后续升级了ram-client版本,新版本实现里,将max-connections值同时赋给了max-connections-per-host,因此相当于max-connections-per-host也被调大为300了
关于max-connections与max-connections-per-host参数的详细解释,参见: 异步HttpClient使用Netty作为SocketChannel的Provider
第三轮压测
压测结果
200QPS, 80ms RT
300QPS,2000ms RT
结果与上轮相比,并没有显著提升。
根据日志分析,ramclient耗时已经趋于平稳,平均10ms以下,调度耗时又上来了。
其他指标
- cpu: 利用率较高,780%+
- mem: 内存占用量很低,总体稳定在34%左右。
- 网络: 总体流量在10Mbps,远远没有打到带宽上限。
- 磁盘: 没有频繁的磁盘IO
- Java: 没有发生频繁的GC。
原因分析
根据当时的线程堆栈分析,占用CPU量最大的就是8个lettuce线程,几乎把CPU时间片占满了。
同时搜索了相关问题 ,发现不是个例。
优化方案
- Redis:
- 使用Jedis作为Client,并且调大Jedis对应的连接池数量
spring.redis.client-type=jedis
spring.redis.jedis.pool.max-active=100
spring.redis.jedis.pool.max-idle=16
spring.redis.jedis.pool.max-wait=10000
第四轮压测
压测结果
- 300QPS,100ms RT
- 350QPS,200ms RT
- 400QPS,2000ms RT
其他指标
- cpu: 利用率较高,780%+
- mem: 内存占用量很低,总体稳定在34%左右。
- 网络: 总体流量在10Mbps,远远没有打到带宽上限。
- 磁盘: 没有频繁的磁盘IO
- Java: 没有发生频繁的GC。
原因分析
打印出了当时的线程栈信息,进行了详细的分析如下:
操作 | 线程数量 |
---|---|
獲取Druid連接 | 442 |
歸還Druid連接 | 164 |
業務線程中Bind操作 | 107 |
Log4j打印 | 77 |
Jedis執行 | 2 |
- 大部分线程卡在Druid数据库连接池归还连接、获取连接的搶鎖阶段(lock())
- 小部分线程由于bind()异步线程池队列满,导致占用了业务线程
- 最后一部分线程卡(blocked)在log4j打印日志
Druid连接池问题
與 记一次Dubbo线程耗尽的问题-druid数据库连接池突发性能 文中的問題一模一樣。
异步线程队列满问题
隊列默認長度爲200,在200的長度滿了之後,使用的是ThreadPoolExecutor.CallerRunsPolicy
, 即不再在異步線程裏執行,而是在caller線程中執行。
從而導致業務線程壓力進一步增大。
Log4j日志问题
也是比較經典的問題,使用Log2j的異步logger即可解決。
优化方案
Druid优化:
- 增大连接池中initial与max的连接数量
- 使用非公平锁,而不是默认的公平锁。具体参见有赞的压测记录。
spring.datasource.druid.maxActive=600 spring.datasource.druid.initialSize=300 spring.datasource.druid.maxWait=6000 spring.datasource.druid.minIdle=300 spring.datasource.druid.poolPreparedStatements=true spring.datasource.druid.maxOpenPreparedStatements=20 spring.datasource.druid.useUnfairLock=true
bind线程池优化:
- 增大队列大小,从200增大到20000
log4j优化:
- 使用log4j2的asyncLogger。
第五轮压测
压测结果
- 在600QPS时,RT基本稳定在40ms以下。
- 由于每次调用接口,都会调用一次ram鉴权接口,ram整体限制单个租户需要小于600qps;也担心把ram接口打挂或者被ram限流,因此不再继续压测。
- 按照其他指标推测, 理论上可以打到1000QPS+,即单机500QPS+
- 终于达标了!~ 撒花庆祝~~~
其他指标
- cpu: 利用率较低,400%左右
- mem: 内存占用量很低,总体稳定在34%左右。
- 网络: 总体流量在10Mbps,远远没有打到带宽上限。
- 磁盘: 没有频繁的磁盘IO
- Java: 没有发生频繁的GC。