记一次线上问题排查与解决:从 Redis 超时到 Apache Common-pool2 源码剖析

线上游戏一直都会“偶尔”出现超时异常

1
redis.clients.jedis.exceptions.JedisConnectionException: java.net.SocketTimeoutException: Read timed out

这个“偶尔”的确切频率呢,是每天每个业务大概会有个几条到几十条不等,全部加起来每天在百余条。

由于我们的游戏业务重度依赖于 Redis 的 Pub/Sub,每天 publish 的消息在上亿条,所以这百余条的异常是显得微不足道的。另外“得益于”游戏模式与同步模型,即使 Redis 超时了丢一两条消息,对游戏的体验也不大(可能会产生“卡房”)。

所以一直以来大家都是以「网络抖动」为原因忽略了该异常。

最近我得空查了一下该问题,结果是不查不知道,一查吓一跳——查出来了个存在 N 年之久的性能问题。

我们 Redis 的客户端是用的 Jedis,用过的肯定都知道生产环境下 Jedis 要用连接池的。

我们也不例外,我先翻出关于 JedisPool 的陈年老代码看了一眼,

1
2
3
4
5
6
7
8
9
10
11
12
13
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxIdle(50);
config.setMinIdle(0);
config.setMaxTotal(300);
config.setMaxWaitMillis(-1);
config.setTestOnBorrow(false);
config.setTestWhileIdle(false);
config.setNumTestsPerEvictionRun(3);
config.setTimeBetweenEvictionRunsMillis(600000);
config.setMinEvictableIdleTimeMillis(1800000);
config.setSoftMinEvictableIdleTimeMillis(1800000);
int timeout = 300;
JedisPool jedisPool = new JedisPool(config, "host", 6379, timeout, "password");

这配置是我在来到公司之前就在用的,各业务少说也用了有 4 年了吧,从没动过,也没见出过大问题。

所以我最初看到它时,内心也比较相信它,没感觉它有啥大问题。

但在了解了 JedisPool 底层是用 Apache Commons-pool2 ,又看了一遍 ACP 之后,发现这配置的问题还不小。

JedisPool 剖析

Jedis 的对象池的资源管理内部是使用 Apache Commons-pool2 (后边都简称 ACP 了)开源工具包来实现的。

首先看看这个对象池到底是怎样管理的

ACP 是一个通用的资源池管理框架,内部会定义好资源池的接口和规范,具体创建对象实现交由具体框架来实现。

  1. 从资源池获取对象,会调用 ObjectPool#borrowObject,如果没有空闲对象,则调用 PooledObjectFactory#makeObject 创建对象,JedisFactory 是具体的实现类。
  2. 创建完对象放到资源池中,返回给客户端使用。
  3. 使用完对象会调用 ObjectPool#returnObject,其内部会校验一些条件是否满足,验证通过,对象归还给资源池。
  4. 条件验证不通过,比如资源池已关闭、对象状态不正确(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
2
3
4
5
6
7
8
9
public class JedisPoolConfig extends GenericObjectPoolConfig {
public JedisPoolConfig() {
// defaults to make your life with connection pool easier :)
setTestWhileIdle(true);
setMinEvictableIdleTimeMillis(60000);
setTimeBetweenEvictionRunsMillis(30000);
setNumTestsPerEvictionRun(-1);
}
}

JedisPoolConfig 默认构造器中会将

  • testWhileIdle 参数设置为 true(默认为 false)
  • minEvictableIdleTimeMillis 设置为 60 秒(默认为 30 分钟)
  • timeBetweenEvictionRunsMillis 设置为 30 秒(默认为 -1)
  • numTestsPerEvictionRun 设置为 -1(默认为 3)

即:每隔 30 秒执行一次空闲资源监测,发现空闲资源超过 60 秒未被使用,从资源池中移除。

GenericObjectPoolConfig 里的参数我大致将其分为三组:

  1. 关键参数:

    • maxTotal:资源池中的最大连接数,默认为 8
    • maxIdle:资源池允许的最大空闲连接数,默认为 8
    • minIdle:资源池确保的最少空闲连接数,默认为 0
  2. 空闲资源检测相关参数:

    • testWhileIdle:是否开启空闲资源检测,默认 false
    • timeBetweenEvictionRunsMillis:空闲资源的检测周期(单位为毫秒),默认 600000 即 10 分钟
    • minEvictableIdleTimeMillis:资源池中资源的最小空闲时间(单位为毫秒),达到此值后空闲资源将被移除,默认 1800000 即 30 分钟
    • softMinEvictableIdleTimeMillis:资源池中资源的最小空闲时间(单位为毫秒),达到此值后空闲资源将被移除,默认 1800000 即 30 分钟,与 minEvictableIdleTimeMillis 的区别见后边的源码解析
    • numTestsPerEvictionRun:做空闲资源检测时,每次检测资源的个数,默认为 3
  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
this.internalPool = new GenericObjectPool<T>(factory, poolConfig);
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
30
31
32
33
34
35
public GenericObjectPool(final PooledObjectFactory<T> factory,
final GenericObjectPoolConfig config) {

super(config, ONAME_BASE, config.getJmxNamePrefix());

if (factory == null) {
jmxUnregister(); // tidy up
throw new IllegalArgumentException("factory may not be null");
}
this.factory = factory;
// 创建空闲资源链表
idleObjects = new LinkedBlockingDeque<PooledObject<T>>(config.getFairness());
// 初始化配置
setConfig(config);
// 开启资源监测任务
startEvictor(getTimeBetweenEvictionRunsMillis());
}


final void startEvictor(final long delay) {
synchronized (evictionLock) {
// 当资源池关闭时会触发,取消 evictor 任务
if (null != evictor) {
EvictionTimer.cancel(evictor, evictorShutdownTimeoutMillis, TimeUnit.MILLISECONDS);
evictor = null;
evictionIterator = null;
}
if (delay > 0) {
// 启动 evictor 任务
evictor = new Evictor();
// 开启定时任务
EvictionTimer.schedule(evictor, delay, delay);
}
}
}

Eviector 是个 TimerTask,通过启用的调度器,每间隔 timeBetweenEvictionRunsMillis 运行一次

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Evictor extends TimerTask {
@Override
public void run() {
try {
// Evict from the pool
evict();
// Re-create idle instances.
ensureMinIdle();
} finally {
// Restore the previous CCL
Thread.currentThread().setContextClassLoader(savedClassLoader);
}
}
}

先看里边的 evict() 方法:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@Override
public void evict() throws Exception {
assertOpen();

if (idleObjects.size() > 0) {

PooledObject<T> underTest = null;
// 获取清除策略
final EvictionPolicy<T> evictionPolicy = getEvictionPolicy();

synchronized (evictionLock) {
final EvictionConfig evictionConfig = new EvictionConfig(
getMinEvictableIdleTimeMillis(),
getSoftMinEvictableIdleTimeMillis(),
getMinIdle());

final boolean testWhileIdle = getTestWhileIdle();

for (int i = 0, m = getNumTests(); i < m; i++) {
// ... 省略部分代码
// underTest 代表每一个资源
boolean evict;
evict = evictionPolicy.evict(evictionConfig, underTest,
idleObjects.size());

// evict 为 true,销毁对象
if (evict) {
destroy(underTest);
destroyedByEvictorCount.incrementAndGet();
} else {
// testWhileIdle 为 true 校验资源有效性
if (testWhileIdle) {
boolean active = false;
try {
factory.activateObject(underTest);
active = true;
} catch (final Exception e) {
destroy(underTest);
destroyedByEvictorCount.incrementAndGet();
}
if (active) {
if (!factory.validateObject(underTest)) {
destroy(underTest);
destroyedByEvictorCount.incrementAndGet();
} else {
try {
factory.passivateObject(underTest);
} catch (final Exception e) {
destroy(underTest);
destroyedByEvictorCount.incrementAndGet();
}
}
}
}
// ...
}
}
}
}
// ...
}

这里默认策略 evictionPolicy,由 org.apache.commons.pool2.impl.DefaultEvictionPolicy 提供默认实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class DefaultEvictionPolicy<T> implements EvictionPolicy<T> {

@Override
public boolean evict(final EvictionConfig config, final PooledObject<T> underTest,
final int idleCount) {

if ((config.getIdleSoftEvictTime() < underTest.getIdleTimeMillis() && config.getMinIdle() < idleCount)
|| config.getIdleEvictTime() < underTest.getIdleTimeMillis()) {
return true;
}
return false;
}
}

DefaultEvictionPolicy 的实现可以看出:

  1. 当资源空闲时间大于资源配置的 idleSoftEvictTime(softMinEvictableIdleTimeMillis),并且空闲资源列表大小超过 minIdle 最小空闲资源数时,返回 true。
    EvictionConfig 配置初始化时,idleSoftEvictTime 如果使用的默认值 -1 < 0,则赋予值为 Long.MAX_VALUE

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public 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;
    }
  2. 当检测的资源空闲时间过期后,即大于资源池配置的最小空闲时间(minEvictableIdleTimeMillis),返回 true。表示这些资源处于空闲状态,该时间段内一直未被使用到。

以上两个满足其中任一条件,则会销毁资源对象。
所以关于 softMinEvictableIdleTimeMillis 和 minEvictableIdleTimeMillis 我们可以得出以下结论:

  • 如果要连接池只根据 softMinEvictableIdleTimeMillis 进行逐出,那么需要将 minEvictableIdleTimeMillis 设置为负数(即最大值)
  • 如果要连接池只根据 minEvictableIdleTimeMillis 进行逐出,那么需要将 softMinEvictableIdleTimeMillis 设置为负数(即最大值)

然后是 ensureMinIdle() 方法,就比较简单了:

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
void ensureMinIdle() throws Exception {
ensureIdle(getMinIdle(), true);
}
private void ensureIdle(final int idleCount, final boolean always) throws Exception {
if (idleCount < 1 || isClosed() || (!always && !idleObjects.hasTakeWaiters())) {
return;
}
// 资源池里保留 idleCount(minIdle)最小资源数量
while (idleObjects.size() < idleCount) {
final PooledObject<T> p = create();
if (p == null) {
// Can't create objects, no reason to think another call to
// create will work. Give up.
break;
}
if (getLifo()) {
idleObjects.addFirst(p);
} else {
idleObjects.addLast(p);
}
}
if (isClosed()) {
// Pool closed while object was being added to idle objects.
// Make sure the returned object is destroyed rather than left
// in the idle object pool (which would effectively be a leak)
clear();
}
}

从上边的分析可以看出,我们可以利用 softMinEvictableIdleTimeMillis 和 minIdle 两个参数来配置比较合理的策略。

比如 softMinEvictableIdleTimeMillis 设置为 180 秒,minIdle 设置为 5,也就是当资源空闲时间超过 180 秒,并且 idleObjects 空闲列表大小超过了 5 个时,才会将资源从池中移除掉。

这样,就保证了资源池有一定数量(minIdle)的资源连接存在,也不会导致频繁创建新的资源连接。

对象池初始化时机

在生产中还有一个现象:在我们的服务重新部署后的那一小段时间,Redis 超时的概率会更高。

这就涉及到一个问题了:资源池里的对象是什么时候初始化进去的(这里的资源池指的是 idleObjects 空闲资源对象缓存列表),是在创建对象的时候还是归还对象的时候?

这就要看 create() 方法了:

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
private PooledObject<T> create() throws Exception {
// 获取最大的对象数
int localMaxTotal = getMaxTotal();
long newCreateCount = createCount.incrementAndGet();
if (localMaxTotal > -1 && newCreateCount > localMaxTotal ||
newCreateCount > Integer.MAX_VALUE) {
createCount.decrementAndGet();
return null;
}

// 创建新对象
final PooledObject<T> p;
try {
p = factory.makeObject();
} catch (Exception e) {
createCount.decrementAndGet();
throw e;
}

AbandonedConfig ac = this.abandonedConfig;
if (ac != null && ac.getLogAbandoned()) {
p.setLogAbandoned(true);
}

createdCount.incrementAndGet();
// 将新创建的对象添加到 Map 中
allObjects.put(new IdentityWrapper<T>(p.getObject()), p);
return p;
}

这里出现了一个 allObjects,它是一个 ConcurrentHashMap。可以看出新创建的对象不直接加入到 idleObjects 队列中,而是加入到allObjects 这个 Map 里,只有在对象 return 的时候才返回到 idleObjects 中(相关逻辑可以在 returnObject(final T obj) 里找到)。

所以在启动后可能会出现超时现象,是因为每次请求都会创建新的资源,这个过程会有一定的开销。

这里我们可以使用预热的方式来进行优化,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
List<Jedis> minIdleList = new ArrayList<>(config.getMinIdle());
for (int i = 0; i < config.getMinIdle(); i++) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
minIdleList.add(jedis);
jedis.ping();
} catch (Exception e) {
e.printStackTrace();
} finally {
}
}

for (int i = 0; i < config.getMinIdle(); i++) {
Jedis jedis = null;
try {
jedis = minIdleList.get(i);
jedis.close();
} catch (Exception e) {
e.printStackTrace();
} finally {
}
}

注意这里的 jedis.close();,看过源码就会知道只是把这个连接放回资源池了,而不是真正的 close。

问题回顾

看完了 ACP,再回过来头来看一下之前的 JedisPool 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxIdle(50);
config.setMinIdle(0);
config.setMaxTotal(300);
config.setMaxWaitMillis(-1);
config.setTestOnBorrow(false);
config.setTestWhileIdle(false);
config.setNumTestsPerEvictionRun(3);
config.setTimeBetweenEvictionRunsMillis(600000);
config.setMinEvictableIdleTimeMillis(1800000);
config.setSoftMinEvictableIdleTimeMillis(1800000);
int timeout = 300;
JedisPool jedisPool = new JedisPool(config, "host", 6379, timeout, "password");

问题就很明显了:

  1. setTestWhileIdle(false),未开启空闲资源检测,却将空闲资源检测的配置几乎都配完了
  2. maxTotal 是 maxIdle 的 6 倍,资源池的伸缩范围比较大,会频繁的创建、销毁连接
  3. minIdle 为 0,没有为连接池预留资源,当流量突然进来时,会大量创建 jedis 连接
  4. 第 12 行的 timeout = 300ms,为 connectionTimeout 和 soTimeout 共用,当 OPS 高的时候(超过 50),就会因 maxTotal-maxIdle 的差值频繁创建销毁连接,而连接的创建是开销比较大的(废话,要不我们用连接池干嘛),在 OPS 高的时候再考虑网络波动、资源负载等外部因素,300ms 有可能不够建立起一条连接
  5. setMaxWaitMillis(-1),当资源池连接用尽后,调用者无限等待。如果没有大 key,资源池配置合理,我们每次操作 redis 应该都是毫秒级操作,也就不会出现一直等不到连接的情况;而如果有大 key,属于应该改进的情况,而不是无限等待。(个人猜测,是 当初把 maxWaitMillis 和 timeout 这两个配置弄混了)

在深入了解了 ACP 后,配置调优的事儿也就是易如反掌了。

在合理的配置 JedisPool 资源池参数后并进行预热后,该问题也就从线上消失了。

总结

平时看似不起眼的一个小问题,可能背后就掩盖着一个大问题。

Apache Commons-pool2 是常用的一个开源的对象池组件,我们常用的数据库连接池 DBCP 和 Redis 的 Java 客户端 Jedis 都使用它来管理连接,我们应该熟练掌握它,并合理的配置其参数来有效的提升性能。