记一次线上问题排查与解决:从 Redis 超时到 Apache Common-pool2 源码剖析
线上游戏一直都会“偶尔”出现超时异常
1 |
|
这个“偶尔”的确切频率呢,是每天每个业务大概会有个几条到几十条不等,全部加起来每天在百余条。
由于我们的游戏业务重度依赖于 Redis 的 Pub/Sub,每天 publish 的消息在上亿条,所以这百余条的异常是显得微不足道的。另外“得益于”游戏模式与同步模型,即使 Redis 超时了丢一两条消息,对游戏的体验也不大(可能会产生“卡房”)。
所以一直以来大家都是以「网络抖动」为原因忽略了该异常。
最近我得空查了一下该问题,结果是不查不知道,一查吓一跳——查出来了个存在 N 年之久的性能问题。
我们 Redis 的客户端是用的 Jedis,用过的肯定都知道生产环境下 Jedis 要用连接池的。
我们也不例外,我先翻出关于 JedisPool 的陈年老代码看了一眼,
1 |
|
这配置是我在来到公司之前就在用的,各业务少说也用了有 4 年了吧,从没动过,也没见出过大问题。
所以我最初看到它时,内心也比较相信它,没感觉它有啥大问题。
但在了解了 JedisPool 底层是用 Apache Commons-pool2 ,又看了一遍 ACP 之后,发现这配置的问题还不小。
JedisPool 剖析
Jedis 的对象池的资源管理内部是使用 Apache Commons-pool2 (后边都简称 ACP 了)开源工具包来实现的。
首先看看这个对象池到底是怎样管理的
ACP 是一个通用的资源池管理框架,内部会定义好资源池的接口和规范,具体创建对象实现交由具体框架来实现。
- 从资源池获取对象,会调用
ObjectPool#borrowObject
,如果没有空闲对象,则调用PooledObjectFactory#makeObject
创建对象,JedisFactory 是具体的实现类。 - 创建完对象放到资源池中,返回给客户端使用。
- 使用完对象会调用
ObjectPool#returnObject
,其内部会校验一些条件是否满足,验证通过,对象归还给资源池。 - 条件验证不通过,比如资源池已关闭、对象状态不正确(Jedis连接失效)、已超出最大空闲资源数,则会调用
PooledObjectFactory#destoryObject
从资源池中销毁对象。
ObjectPool 和 KeyedObjectPool 是两个基础接口。
ObjectPool 接口资源池列表里存储都是对象,默认实现类 GenericObjectPool。
KeyedObjectPool 接口用键值对的方式维护对象,默认实现类是 GenericKeyedObjectPool。
在实现过程会有很多公共的功能实现,放在了 BaseGenericObjectPool 基础实现类当中。
SoftReferenceObjectPool 是一个比较特殊的实现,在这个对象池实现中,每个对象都会被包装到一个 SoftReference 中。
SoftReference 软引用,能够在 JVM GC 过程中当内存不足时,允许垃圾回收机制在需要释放内存时回收对象池中的对象,避免内存泄露的问题。
PooledObject 是池化对象的接口定义,池化的对象都会封装在这里。
DefaultPooledObject 是 PooledObject 接口缺省实现类,PooledSoftReference 使用 SoftReference 封装了对象,供 SoftReferenceObjectPool 使用。
Jedis 客户端资源池参数
Jedis 客户端资源池参数都是基于 JedisPoolConfig 构建的。 JedisPoolConfig 继承了 GenericObjectPoolConfig
1 |
|
JedisPoolConfig 默认构造器中会将
testWhileIdle
参数设置为 true(默认为 false)minEvictableIdleTimeMillis
设置为 60 秒(默认为 30 分钟)timeBetweenEvictionRunsMillis
设置为 30 秒(默认为 -1)numTestsPerEvictionRun
设置为 -1(默认为 3)
即:每隔 30 秒执行一次空闲资源监测,发现空闲资源超过 60 秒未被使用,从资源池中移除。
GenericObjectPoolConfig 里的参数我大致将其分为三组:
关键参数:
- maxTotal:资源池中的最大连接数,默认为 8
- maxIdle:资源池允许的最大空闲连接数,默认为 8
- minIdle:资源池确保的最少空闲连接数,默认为 0
空闲资源检测相关参数:
- testWhileIdle:是否开启空闲资源检测,默认 false
- timeBetweenEvictionRunsMillis:空闲资源的检测周期(单位为毫秒),默认 600000 即 10 分钟
- minEvictableIdleTimeMillis:资源池中资源的最小空闲时间(单位为毫秒),达到此值后空闲资源将被移除,默认 1800000 即 30 分钟
- softMinEvictableIdleTimeMillis:资源池中资源的最小空闲时间(单位为毫秒),达到此值后空闲资源将被移除,默认 1800000 即 30 分钟,与 minEvictableIdleTimeMillis 的区别见后边的源码解析
- numTestsPerEvictionRun:做空闲资源检测时,每次检测资源的个数,默认为 3
其他
- blockWhenExhausted:当资源池用尽后,调用者是否要等待。只有当值为 true 时,下面的 maxWaitMillis 才会生效。默认为 true
- maxWaitMillis:当资源池连接用尽后,调用者的最大等待时间(单位为毫秒),默认为 -1 表示永不超时
- testOnBorrow:向资源池借用连接时是否做连接有效性检测(ping),检测到的无效连接将会被移除,默认 fase
- testOnReturn:向资源池归还连接时是否做连接有效性检测(ping),检测到无效连接将会被移除,默认 fase
- jmxEnabled:是否开启 JMX 监控,默认为 ture
这里边最关键就是「关键参数」,阿里云上有关于的设置建议:
maxTotal(最大连接数)
想合理设置 maxTotal(最大连接数)需要考虑的因素较多,如:
- 业务希望的 Redis 并发量;
- 客户端执行命令时间;
- Redis 资源,例如 nodes (如应用 ECS 个数等) * maxTotal 不能超过 Redis 的最大连接数(可在实例详情页面查看);
- 资源开销,例如虽然希望控制空闲连接,但又不希望因为连接池中频繁地释放和创建连接造成不必要的开销。
假设一次命令时间,即 borrow|return resource 加上 Jedis 执行命令 ( 含网络耗时)的平均耗时约为 1ms,一个连接的 QPS 大约是 1s/1ms = 1000,而业务期望的单个 Redis 的 QPS 是 50000(业务总的 QPS/Redis 分片个数),那么理论上需要的资源池大小(即 MaxTotal)是 50000 / 1000 = 50。
但事实上这只是个理论值,除此之外还要预留一些资源,所以 maxTotal 可以比理论值大一些。这个值不是越大越好,一方面连接太多会占用客户端和服务端资源,另一方面对于 Redis 这种高 QPS 的服务器,如果出现大命令的阻塞,即使设置再大的资源池也无济于事。
maxIdle 与 minIdle
maxIdle 实际上才是业务需要的最大连接数,maxTotal 是为了给出余量,所以 maxIdle 不要设置得过小,否则会有
new Jedis
(新连接)开销,而 minIdle 是为了控制空闲资源检测。连接池的最佳性能是 maxTotal=maxIdle,这样就避免了连接池伸缩带来的性能干扰。如果您的业务存在突峰访问,建议设置这两个参数的值相等;如果并发量不大或者 maxIdle 设置过高,则会导致不必要的连接资源浪费。
从这个建议可以看出 maxTotal 和 maxIdle 应设置为相同的值,也可以算出对应的值,但是 minIdle 却没说。
那么 minIdle 设置为多少合适?还有那么多空闲资源检测的参数如何配?
我们直接来看源码吧。
空闲资源监测源码剖析
在资源池初始化之后,会有个空闲资源监测任务流程如下:
对应源代码:
创建资源池对象时,在构造器中开启了资源监测任务
1 |
|
1 |
|
Eviector 是个 TimerTask,通过启用的调度器,每间隔 timeBetweenEvictionRunsMillis 运行一次
1 |
|
先看里边的 evict()
方法:
1 |
|
这里默认策略 evictionPolicy,由 org.apache.commons.pool2.impl.DefaultEvictionPolicy
提供默认实现。
1 |
|
从 DefaultEvictionPolicy
的实现可以看出:
当资源空闲时间大于资源配置的 idleSoftEvictTime(softMinEvictableIdleTimeMillis),并且空闲资源列表大小超过 minIdle 最小空闲资源数时,返回 true。
EvictionConfig 配置初始化时,idleSoftEvictTime 如果使用的默认值-1 < 0
,则赋予值为Long.MAX_VALUE
。1
2
3
4
5
6
7
8
9
10
11
12
13
14public EvictionConfig(final long poolIdleEvictTime, final long poolIdleSoftEvictTime,
final int minIdle) {
if (poolIdleEvictTime > 0) {
idleEvictTime = poolIdleEvictTime;
} else {
idleEvictTime = Long.MAX_VALUE;
}
if (poolIdleSoftEvictTime > 0) {
idleSoftEvictTime = poolIdleSoftEvictTime;
} else {
idleSoftEvictTime = Long.MAX_VALUE;
}
this.minIdle = minIdle;
}当检测的资源空闲时间过期后,即大于资源池配置的最小空闲时间(minEvictableIdleTimeMillis),返回 true。表示这些资源处于空闲状态,该时间段内一直未被使用到。
以上两个满足其中任一条件,则会销毁资源对象。
所以关于 softMinEvictableIdleTimeMillis 和 minEvictableIdleTimeMillis 我们可以得出以下结论:
- 如果要连接池只根据 softMinEvictableIdleTimeMillis 进行逐出,那么需要将 minEvictableIdleTimeMillis 设置为负数(即最大值)
- 如果要连接池只根据 minEvictableIdleTimeMillis 进行逐出,那么需要将 softMinEvictableIdleTimeMillis 设置为负数(即最大值)
然后是 ensureMinIdle()
方法,就比较简单了:
1 |
|
从上边的分析可以看出,我们可以利用 softMinEvictableIdleTimeMillis 和 minIdle 两个参数来配置比较合理的策略。
比如 softMinEvictableIdleTimeMillis 设置为 180 秒,minIdle 设置为 5,也就是当资源空闲时间超过 180 秒,并且 idleObjects 空闲列表大小超过了 5 个时,才会将资源从池中移除掉。
这样,就保证了资源池有一定数量(minIdle)的资源连接存在,也不会导致频繁创建新的资源连接。
对象池初始化时机
在生产中还有一个现象:在我们的服务重新部署后的那一小段时间,Redis 超时的概率会更高。
这就涉及到一个问题了:资源池里的对象是什么时候初始化进去的(这里的资源池指的是 idleObjects 空闲资源对象缓存列表),是在创建对象的时候还是归还对象的时候?
这就要看 create()
方法了:
1 |
|
这里出现了一个 allObjects,它是一个 ConcurrentHashMap。可以看出新创建的对象不直接加入到 idleObjects 队列中,而是加入到allObjects 这个 Map 里,只有在对象 return 的时候才返回到 idleObjects 中(相关逻辑可以在 returnObject(final T obj)
里找到)。
所以在启动后可能会出现超时现象,是因为每次请求都会创建新的资源,这个过程会有一定的开销。
这里我们可以使用预热的方式来进行优化,示例代码如下:
1 |
|
注意这里的 jedis.close();
,看过源码就会知道只是把这个连接放回资源池了,而不是真正的 close。
问题回顾
看完了 ACP,再回过来头来看一下之前的 JedisPool 配置
1 |
|
问题就很明显了:
setTestWhileIdle(false)
,未开启空闲资源检测,却将空闲资源检测的配置几乎都配完了- maxTotal 是 maxIdle 的 6 倍,资源池的伸缩范围比较大,会频繁的创建、销毁连接
- minIdle 为 0,没有为连接池预留资源,当流量突然进来时,会大量创建 jedis 连接
- 第 12 行的 timeout = 300ms,为 connectionTimeout 和 soTimeout 共用,当 OPS 高的时候(超过 50),就会因 maxTotal-maxIdle 的差值频繁创建销毁连接,而连接的创建是开销比较大的(废话,要不我们用连接池干嘛),在 OPS 高的时候再考虑网络波动、资源负载等外部因素,300ms 有可能不够建立起一条连接
setMaxWaitMillis(-1)
,当资源池连接用尽后,调用者无限等待。如果没有大 key,资源池配置合理,我们每次操作 redis 应该都是毫秒级操作,也就不会出现一直等不到连接的情况;而如果有大 key,属于应该改进的情况,而不是无限等待。(个人猜测,是 当初把 maxWaitMillis 和 timeout 这两个配置弄混了)
在深入了解了 ACP 后,配置调优的事儿也就是易如反掌了。
在合理的配置 JedisPool 资源池参数后并进行预热后,该问题也就从线上消失了。
总结
平时看似不起眼的一个小问题,可能背后就掩盖着一个大问题。
Apache Commons-pool2 是常用的一个开源的对象池组件,我们常用的数据库连接池 DBCP 和 Redis 的 Java 客户端 Jedis 都使用它来管理连接,我们应该熟练掌握它,并合理的配置其参数来有效的提升性能。