DBCP踩坑(二):连接池检查testWhileIdle失效

问题描述:

生产报错,“数据库操作异常”,日志错误信息如下:

com.MySQL.jdbc.CommunicationsException: The last packet successfully received from the server was58129 seconds ago.The last packet sent successfully to the server was 58129 seconds ago, which is longer than the server configured value of 'wait_timeout'. You should consider either expiring and/or testing connection validity before use in your application, increasing the server configured values for client timeouts, or using the Connector/J connection property 'autoReconnect=true' to avoid this problem.

现场情况及观测现象:

  • mysql端发现周五晚上因连接到了8小时,断开了一部分连接;
  • 周六周日运行正常,无报错;
  • 周一早上业务高峰期时发现大量上述报错;
  • 应用配置了testWhileIdle具体配置是minIdle=125,testWhileIdle=true,validationQuery=SELECT 1,numTestsPerEvictionRun=10,minEvictableEvictionTimeMillis=180000,timeBetweenEvictionMillis=30000

初步分析:

  • 从报错信息上看,是因为数据库使用到了超过8小时没有使用过的连接导致,可明明配置了testWhileIdle,理论上不应该有问题,只能分析代源码了。

源码分析:

DBCP在初始化参数的时候就会启动一个检查线程

 public synchronized void setTimeBetweenEvictionRunsMillis(long timeBetweenEvictionRunsMillis) {
        _timeBetweenEvictionRunsMillis = timeBetweenEvictionRunsMillis;
        startEvictor(_timeBetweenEvictionRunsMillis);
    }
    protected synchronized void startEvictor(long delay) {
        if(null != _evictor) {
            EvictionTimer.cancel(_evictor);
            _evictor = null;
        }
        if(delay > 0) {
            _evictor = new Evictor();
            EvictionTimer.schedule(_evictor, delay, delay);
        }
    }
    public void run() {
            try {
            // 逐出逻辑(包含了validate逻辑)
                evict();
            } catch(Exception e) {
                // ignored
            } catch(OutOfMemoryError oome) {
                // Log problem but give evictor thread a chance to continue in
                // case error is recoverable
                oome.printStackTrace(System.err);
            }
            try {
            // 逐出之后,确保池子中连接数满足最小连接数
                ensureMinIdle();
            } catch(Exception e) {
                // ignored
            }
        }
复制代码

每隔30秒执行一次,检查的逻辑是:

public void evict() throws Exception {
        assertOpen();
        synchronized (this) {
            if(_pool.isEmpty()) {
                return;
            }
            if (null == _evictionCursor) {
               // 为pool建立一个游标,从尾部到头遍历
                _evictionCursor = _pool.cursor(_lifo ? _pool.size() : 0);
            }
        }

        //每次检查numTestsPerEvictionRun个连接
        for (int i=0,m=getNumTests();i pair;
            synchronized (this) {
                if ((_lifo && !_evictionCursor.hasPrevious()) ||
                        !_lifo && !_evictionCursor.hasNext()) {
                      // 当游标走到头部,重新从尾部循环遍历
                    _evictionCursor.close();
                    _evictionCursor = _pool.cursor(_lifo ? _pool.size() : 0);
                }

                pair = _lifo ?
                         _evictionCursor.previous() :
                         _evictionCursor.next();
                //将连接取出
                _evictionCursor.remove();
                _numInternalProcessing++;
            }

            boolean removeObject = false;
            final long idleTimeMilis = System.currentTimeMillis() - pair.tstamp;
            //如果存活时间已经超过MinEvictableIdleTimeMillis则准备移除该链接
            if ((getMinEvictableIdleTimeMillis() > 0) &&
                    (idleTimeMilis > getMinEvictableIdleTimeMillis())) {
                removeObject = true;
            } else if ((getSoftMinEvictableIdleTimeMillis() > 0) &&
                    (idleTimeMilis > getSoftMinEvictableIdleTimeMillis()) &&
                    ((getNumIdle() + 1)> getMinIdle())) { // +1 accounts for object we are processing
                removeObject = true;
            }
            // 若果开启testwhileidle并且没有到达minEvictableIdleTimeMillis,执行test逻辑
            if(getTestWhileIdle() && !removeObject) {
                boolean active = false;
                try {
                    _factory.activateObject(pair.value);
                    active = true;
                } catch(Exception e) {
                    removeObject=true;
                }
                if(active) {
                     // 执行validationQuery
                    if(!_factory.validateObject(pair.value)) {
                        removeObject=true;
                    } else {
                        try {
                            _factory.passivateObject(pair.value);
                        } catch(Exception e) {
                            removeObject=true;
                        }
                    }
                }
            }

            if (removeObject) {
                try {
                    _factory.destroyObject(pair.value);
                } catch(Exception e) {
                    // ignored
                }
            }
            synchronized (this) {
                if(!removeObject) {
                    //对没有过期的连接放回队列
                    _evictionCursor.add(pair);
                    if (_lifo) {
                        // Skip over the element we just added back
                        _evictionCursor.previous();
                    }
                }
                _numInternalProcessing--;
            }
        }
        allocate();
    }
复制代码

OK,梳理一下思路

  • dbcp会按照timeBetweenEvictionMillis启动一个调度线程,每隔timeBetweenEvictionMillis执行一次,每次检查numTestsPerEvictionRun个连接;
  • DBCP的数据库连接池底层为一个双向链表
  • 当一个数据库链接执行过语句,数据库端会重置连接时间,即正常情况下一个连接如果被执行SELECT 1后,数据库的8小时计时会重置
  • 检查的逻辑基于一个游标从后向前遍历,且会循环遍历
  • 对超过minEvictableEvictionTimeMillis的连接执行remove,其他的执行validateQuery
  • dbcp获取连接时是从前往后获取的

感觉....似乎...没毛病啊,只能自己开脑洞想了

开脑洞

脑洞1:

pool双向链表断了?因为获取连接是从前往后遍历的,但是检查时从后往前的,假设某个节点的前指针指的出问题了,完全可能用到遍历不到的节点。

合情合理,但没有证据

  • 首先从代码入口,我们认为双向链表本身的逻辑是不可能有问题的,多半是因为高并发情况下没有锁好,导致的问题,但是经过排查发现代码中对链表进行操作的地方,都加了锁;
  • 测试环境无意中复现出了这个现象,即从mysql端看到有一些链接长期没有刷新Timeout时间,于是我们dump了当时的堆,找到pool对象,并对其进行人工遍历,发现竟然是对的上的,从前到后从后到前遍历的是一样的。

脑洞1不合情不合理。

脑洞2

有些链接跑到了链表外,导致这些链接没有被遍历到,可是周一早上确实又使用到了这个连接,所以这个脑洞不成立。

脑洞3

会不会因为高并发,导致每次执行_evictionCursor.hasPrevious()都有新节点(因为连接归还和新建都是加到头部的),嗯,有可能,合情合理。但是这也太巧了,而且我们是有交易高峰期和低谷的,大半夜的没那么大并发才对,总归可以走到头的。

不过脑洞3也给我们提供了一个思路,我们一直以为游标是可以走到头的,但是会不会是因为什么情况,游标走不到头,所以一些链接至遍历了一次呢。

找到问题

借着上面的思路,我们发现了真正的问题所在,文字不太好表达,画个图各位自己看吧。

DBCP踩坑(二):连接池检查testWhileIdle失效_第1张图片

问题解决

找到问题原因,解决就简单了,将minEvictableEvictionTimeMillis调大一些就好了。

你可能感兴趣的:(java)