04 | 连接池:别让连接池帮了倒忙
连接池一般对外提供获得连接、归还连接的接口给客户端使用,并暴露最小空闲连接数、最大连接数等可配置参数,在内部则实现连接建立、连接心跳保持、连接管理、空闲连接回收、连接可用性检测等功能。
注意鉴别客户端 SDK 是否基于连接池
我们首先要确定客户端 SDK 是否是基于连接池技术实现的。我们知道,TCP 是面向连接的基于字节流的协议:
- 面向连接,意味着连接需要先创建再使用,创建连接的三次握手有一定开销;
- 基于字节流,意味着字节是发送数据的最小单元,TCP 协议本身无法区分哪几个字节是完整的消息体,也无法感知是否有多个客户端在使用同一个 TCP 连接,TCP 只是一个读写数据的管道。
如果客户端 SDK 没有使用连接池,而直接是 TCP 连接,那么就需要考虑每次建立 TCP 连接的开销,并且因为 TCP 基于字节流,在多线程的情况下对同一连接进行复用,可能会产生线程安全问题。
TCP 连接的客户端 SDK,对外提供 API 的三种方式:
- 连接池和连接分离的 API
- 内部带有连接池的 API
- 非连接池的 API
在使用三方SDK 时,一定要先查看官方文档了解其最佳实践,或是在类似 Stackoverflow 的网站搜索
XXX threadsafe/singleton 字样看看大家的回复,也可以一层一层往下看源码,直到定位到原始 Socket 来判断 Socket 和客户端 API 的对应关系。
- 如果是分离方式,那么连接池本身一般是线程安全的,可以复用。每次使用需要从连接池获取连接,使用后归还,归还的工作由使用者负责。
- 如果是内置连接池,SDK 会负责连接的获取和归还,使用的时候直接复用客户端。
- 如果 SDK 没有实现连接池(大多数中间件、数据库的客户端 SDK 都会支持连接池),那通常不是线程安全的,而且短连接的方式性能不会很高,使用的时候需要考虑是否自己封装一个连接池。
案例:操作jedis库
启动两个线程,共享操作同一个 Jedis 实例,每一个线程循环 1000 次,分别读取Key 为 a 和 b 的 Value,判断是否分别为 1 和 2:
Jedis jedis = new Jedis("127.0.0.1", 6379);
new Thread(() -> {for (int i = 0; i < 1000; i++) {String result = jedis.get("a");if (!result.equals("1")) {log.warn("Expect a to be 1 but found {}", result);return;}}
}).start();
new Thread(() -> {for (int i = 0; i < 1000; i++) {String result = jedis.get("b");if (!result.equals("2")) {log.warn("Expect b to be 2 but found {}", result);return;}}
}).start();
TimeUnit.SECONDS.sleep(5);
执行程序多次,可以看到日志中出现了各种奇怪的异常信息,有的是读取 Key 为 b 的Value 读取到了 1,有的是流非正常结束,还有的是连接关闭异常:
分析源码:
Jedis 继承了 BinaryJedis,BinaryJedis 中保存了单个 Client 的实例,Client最终继承了 Connection,Connection 中保存了单个 Socket 的实例,和 Socket 对应的两个读写流。因此,一个 Jedis 对应一个 Socket 连接
private static JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);
new Thread(() -> {try (Jedis jedis = jedisPool.getResource()) {for (int i = 0; i < 1000; i++) {String result = jedis.get("a");if (!result.equals("1")) {log.warn("Expect a to be 1 but found {}", result);return;}}}
}).start();
最好通过 shutdownhook,在程序退出之前关闭 JedisPool:
@PostConstruct
public void init() {Runtime.getRuntime().addShutdownHook(new Thread(() -> {jedisPool.close();}));
}
JedisPool 的 getResource 方法在拿到 Jedis 对象后,将自己设置为了连接池。连接池JedisPool,继承了 JedisPoolAbstract,而后者继承了抽象类 Pool,Pool 内部维护了Apache Common 的通用池 GenericObjectPool。JedisPool 的连接池就是基于GenericObjectPool 的。
Jedis 的 API 实现是连接池和连接分离的 API,JedisPool 是线程安全的连接池,Jedis 是非线程安全的单一连接
使用连接池务必确保复用
池一定是用来复用的,否则其使用代价会比每次创建单一对象更大。对连接池来说更是如此,原因如下:
- 创建连接池的时候很可能一次性创建了多个连接,大多数连接池考虑到性能,会在初始化的时候维护一定数量的最小连接
- 大多数的连接池都有闲置超时的概念。连接池会检测连接的闲置时间,定期回收闲置的连接,把活跃连接数降到最低(闲置)连接的配置值,减轻服务端的压力.
连接池的配置不是一成不变的
最大连接数不是设置得越大越好,太大的话,需要使用过多的资源来维护,会给服务端带来更大的压力。连接池最大连接数设置得太小,很可能会因为获取连接的等待时间太长,导致吞吐量低下,甚至超时无法获取连接。
这里要强调的是,修改配置参数务必验证是否生效,并且在监控系统中确认参数是否生效、是否合理。之所以要“强调”,是因为这里有坑。
应用准备针对大促活动进行扩容,把数据库配置文件中Druid 连接池最大连接数 maxActive 从 50 提高到了 100,修改后并没有通过监控验证,结果大促当天应用因为连接池连接数不够爆了。
经排查发现,当时修改的连接数并没有生效。原因是,应用虽然一开始使用的是 Druid 连接池,但后来框架升级了,把连接池替换为了 Hikari 实现,原来的那些配置其实都是无效的,修改后的参数配置当然也不会生效