重庆分公司,新征程启航
为企业提供网站建设、域名注册、服务器等服务
最近在陆续做机房升级相关工作,配合DBA对产线数据库链接方式做个调整,将原来直接链接读库的地址切换到统一的读负载均衡的代理 haproxy上,方便机柜和服务器的搬迁。
切换之后线上时不时的会发生 discard connection错误,导致程序报 500错误,但不是每次都必现的。
为鼓楼等地区用户提供了全套网页设计制作服务,及鼓楼网站建设行业解决方案。主营业务为成都网站制作、成都网站建设、鼓楼网站设计,以传统方式定制建设网站,并提供域名空间备案等一条龙服务,秉承以专业、用心的态度为用户提供真诚的服务。我们深信只要达到每一位用户的要求,就会得到认可,从而选择与我们长期合作。这样,我们也可以走得更远!
开发框架: spring boot+mybatis+druid+shardingJDBC
网络架构:
appserver->MySQL(master) 写
appserver->haproxy->mysql(slave)/n 读
第一反应肯定是因为这次的读库地址的变动引起的问题,觉得问题应该是 druid链接池中的 connection保活策略没起作用,只要做下配置修改应该就可以了。结果这个问题让我们排查了好几天,我们竟然踩到了千年难遇的深坑。
这个问题排查的很坎坷,一次次的吐血,最终我们定位到问题并且优雅的修复了,我们一起来体验下这个一次一次让你绝望一次一次打脸的过程。
先说故障症状,经常出现如下错误:
discard connection
com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failureThe last packet successfully received from the server was 72,557 milliseconds ago. The last packet sent successfully to the server was 0 milliseconds ago.
根据错误日志初步判断肯定是与 db之间的链接已经断开,尝试使用了一个已经断开的链接才会引起这个错误发生。但是根据我们对 druid了解,druid有链接检查功能,按理不会拿到一个无效链接才对,带着这个线索我们上路了。
为了准确的知道 db的链接的存活时间,了解到 haproxy对转发的 db tcp链接空闲时间在 1m之内,超过 1m不活动就会被关掉。也就说我们与 db之间的原来的长链接在 1m之内会被断开。我们先不管这个时间设置的是否符合所有的大并发场景,至少在 druid的链接池里会有有效链接检查,应该不会拿到无效链接才对,我们做了配置调整。
我们看下 druid跟链接时间相关的配置:
datasource.druid.validationQuery=SELECT 1
datasource.druid.validationQueryTimeout=2000
datasource.druid.testWhileIdle=true
datasource.druid.minEvictableIdleTimeMillis=100000
datasource.druid.timeBetweenEvictionRunsMillis=20000
配置的每项的意思这里就不解释了。
我们启用了 testWhileIdle配置,让每次拿取链接的时候发起检查。根据 timeBetweenEvictionRunsMillis的配置只有大于这个时间 druid才会发起检查,所以可能的场景是拿到一个即将过期的链接,根据这个线索我们调整这个时间为 20000ms,也就是超过 20s会检查当前拿取的链接确定是否有效,检查的方式应该是使用 validationQuery配置的 sql语句才对,但是发现我们并找不到任何有关于 SELECT 1的痕迹。
首先要搞清楚 validationQuery为什么没起作用,带着这个疑问开始 debug druid源码。
if (isTestWhileIdle()) {
final long currentTimeMillis = System.currentTimeMillis();
final long lastActiveTimeMillis = poolableConnection.getConnectionHolder().getLastActiveTimeMillis();
final long idleMillis = currentTimeMillis - lastActiveTimeMillis;
long timeBetweenEvictionRunsMillis = this.getTimeBetweenEvictionRunsMillis();
if (timeBetweenEvictionRunsMillis <= 0) {
timeBetweenEvictionRunsMillis = DEFAULT_TIME_BETWEEN_EVICTION_RUNS_MILLIS;
}
if (idleMillis >= timeBetweenEvictionRunsMillis) {
boolean validate = testConnectionInternal(poolableConnection.getConnection());
if (!validate) {
if (LOG.isDebugEnabled()) {
LOG.debug("skip not validate connection.");
}
discardConnection(realConnection);
continue;
}
}
}
}
闲置时间肯定会有大于 timeBetweenEvictionRunsMillis时间的,会发起 testConnectionInternal方法检查。我们继续跟进去看,
protected boolean testConnectionInternal(DruidConnectionHolder holder, Connection conn) {
boolean valid = validConnectionChecker.isValidConnection(conn, validationQuery, validationQueryTimeout);
内部会使用 validConnectionChecker检查对象发起检查。
public boolean isValidConnection(Connection conn, String validateQuery, int validationQueryTimeout) throws Exception {
if (conn.isClosed()) {
return false;
}
if (usePingMethod) {
if (conn instanceof DruidPooledConnection) {
conn = ((DruidPooledConnection) conn).getConnection();
}
if (conn instanceof ConnectionProxy) {
conn = ((ConnectionProxy) conn).getRawObject();
}
if (clazz.isAssignableFrom(conn.getClass())) {
if (validationQueryTimeout < 0) {
validationQueryTimeout = DEFAULT_VALIDATION_QUERY_TIMEOUT;
}
try {
ping.invoke(conn, true, validationQueryTimeout * 1000);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof SQLException) {
throw (SQLException) cause;
}
throw e;
}
return true;
}
}
String query = validateQuery;
if (validateQuery == null || validateQuery.isEmpty()) {
query = DEFAULT_VALIDATION_QUERY;
}
Statement stmt = null;
ResultSet rs = null;
try {
stmt = conn.createStatement();
if (validationQueryTimeout > 0) {
stmt.setQueryTimeout(validationQueryTimeout);
}
rs = stmt.executeQuery(query);
return true;
} finally {
JdbcUtils.close(rs);
JdbcUtils.close(stmt);
}
}
debug 这里才发现,druid默认采用的是 mysql.ping来做链接有效性检查。
那是不是用 msyql.ping协议并不会让 mysql重新滑动 session闲置时间,带着这个问题打开 __information_schema.processlist__ 进程列表查看会不会刷新会话时间,通过 debug发现是会刷新时间的,说明没有问题,这条线索算是断了。
调整方向,开始怀疑是不是 haproxy的一些策略导致链接失效,开始初步怀疑 haproxy的轮训转发后端链接是不是有相关会话保持方式,是不是我们配置有误导致 haproxy的链接和 mysql链接篡位了。
当然这个猜想有点夸张,但是没办法,技术人员就要有怀疑一切的态度。
为了还原产线的网络路线,我在本地搭了一个 haproxy,了解下他的工作原理和配置,图方便我就用了yum顺手装了一个,版本是 HA-Proxy version 1.5.18不知道是我本地环境问题还是这个版本的 bug,我们配置的 mode tcp活动检查一直不生效。
listen service 127.0.0.1:60020
mode tcp
balance roundrobin
option tcplog
server server1 192.168.36.66:3306 check inter 2000 rise 2 fall 3
server server2 192.168.36.66:3306 check inter 2000 rise 2 fall 3
由于 haproxy活动检查一直不通过,所以无法转发我的链接,搞了半天我只能手动装了一个低版本的 haproxy HA-Proxy version 1.4.14。
完整的配置:
defaults
mode tcp
retries 3
option redispatch
option abortonclose
maxconn 32000
timeout connect 2s
timeout client 5m
timeout server 5m
listen test1
bind 0.0.0.0:60000
mode tcp
balance roundrobin
server s1 192.168.36.66:3306 weight 1 maxconn 10000 check inter 10s
server s2 192.168.36.66:3306 weight 1 maxconn 10000 check inter 10s
server s3 192.168.36.66:3306 weight 1 maxconn 10000 check inter 10s
1.4的版本顺利完成活动检查。
我使用 haproxy进行debug,调试下来也都没有问题,也翻了下 haproxy如何转发链接的,内部通过会话的方式保持两个链接的关系,如果是 tcp长链接应该不会出现什么问题。haproxy在 http模式下有会话保持方式,tcp应该是直接捆绑的方式,一旦到 timeout时间会主动 close和 mysql的链接,而且没有出现篡位的问题。到这里线索又断了。
没有办法,只能试着埋点 druid的检查日志,排查内部上一次的 check和报错之间的时间差和 connectionId是不是一致的。
public class MySqlValidConnectionCheckerDebug extends MySqlValidConnectionChecker {
@Override
public boolean isValidConnection(Connection conn, String validateQuery, int validationQueryTimeout) {
Long connId = 0L;
try {
Field connField = ConnectionImpl.class.getDeclaredField("connectionId");
connField.setAccessible(true);
connId = (Long) connField.get(((ConnectionProxyImpl) conn).getConnectionRaw());
} catch (Exception e) {
log.error("valid connection error", e);
} finally {
log.info("valid connection ok. conn:" + connId);
}
return true;
}
为了拿到 connectionId只能反射获取,在本地debug下没问题,能正常拿到 connectionId,但是发到验证环境进行验证的时候报错了,觉得奇怪,仔细看了下原来开发环境的配置和验证和生产的不一样,开发环境没有走读写分离。
验证和生产都是使用了 mysql的 replication的机制,所以导致我反射获取的代码报错。
datasource.druid.url=jdbc:mysql:replication
通过debug发现,原来 druid的 connection是 JDBC4Connection,变成了 ReplicationConnection,而且里面包装了两个 connection,一个 masterconnection,一个 slaveconnection,似乎问题有点浮现了。
通过debug发现 druid的检查还是会正常走到,当走到 ReplicationConnection内部的时候 ReplicationConnection有一个 currentConnection,这个链接是会在 masterConnection和 slaveConnection之间切换,切换的依据是 readOnly参数。
在检查的时候由于 druid并不感知上层的参数,readOnly也就不会设置。所以走的是 masterConnection,但是在程序里用的时候通过 spring的 TransactionManager将 readOnly传播到了 ShardingJDBC, ShardingJDBC在设置到 ReplicationConnection上,最后导致真正在使用的时候其实使用的是 slaveConnection。
找到这个问题之后去 druid github Issues搜索了下果然有人提过这个问题,在高版本的 druid中已经修复这个问题了。
修复这个问题有两个方法,第一个方法,建议升级 druid,里面已经有 MySqlReplicationValidConnectionChecker检查器专门用来解决这个问题。第二个方法就是自己实现 ValidConnectionChecker检查器,但是会有在将来出现bug的可能性。
由于时间关系文章只讲了主要的排查路线,事实上我们陆续花了一周多时间,再加上周末连续趴上十几个小时才找到这根本问题。
这个问题之所以难定位的原因主要是牵扯的东西太多,框架层面、网络链接层面、mysql服务器层面,haproxy代理等等,当然其中也绕了很多弯路。。
下面分享在这个整个排查过程中的一些技术收获。
1.mysqlConenction提供了ping方法用来做活动检查,默认MySqlValidConnectionChecker使用的是pinginternal。
ping = clazz.getMethod("pingInternal", boolean.class, int.class);
2.低版本的druid不支持自定义 ValidConnectionChecker 来做个性化的检查。
3.druid 的test方法使用注意事项,testOnBorrow 在获取链接的时候进行检查,与testWhileIdle是互斥关系。
if (isTestOnBorrow()) {
} else {
if (isTestWhileIdle()) {
3.kill mysql processlist 进程会话到链接端tcp状态有延迟,这是tcp的四次断开延迟。
4.haproxy 1.5.18 版本 mode tcp check不执行,健康检查设置无效。
5.mysql replication connection master/slave切换逻辑需要注意,会不会跟上下游的链接池组合使用出现bug,尤其是分库不表、读写分离、自定义分片。
6.排查mysql服务器的问题时,打开各种日志,操作日志,binlog日志。
7.springtransactionmanagenent 事务传播特性会影响下游数据源的选择,setreadonly、setautocommit。
8.低版本的 druid MySqlValidConnectionChecker 永远执行不到 ReplicationConnection ping 方法。
作者:王清培(沪江网资深架构师)