天天看點

一次線上故障:資料庫連接配接池洩露後的思考

雲栖号: https://yqh.aliyun.com 第一手的上雲資訊,不同行業精選的上雲企業案例庫,基于衆多成功案例萃取而成的最佳實踐,助力您上雲決策!

一:初步排查

早上作為能效平台系統的使用高峰期,系統負載通常比其它時間段更大一些,某個時間段會有大量使用者登入。當天系統開始有使用者報障,釋出系統線上無法建構釋出,然後後續有使用者不能登入系統,系統發生假死,當然系統不是真的當機,而是所有和資料庫有關的連接配接都被阻塞,随後檢視日志發現有大量報錯。

一次線上故障:資料庫連接配接池洩露後的思考

和資料庫連接配接池相關:

Caused by: org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection; nested exception is java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30002ms.            

可以看出上面的報錯和資料庫連接配接有關,大量逾時。通過對線上debug日志的分析,也驗證了資料庫連接配接池被大量消耗。

[DEBUG] c.z.h.p.HikariPool: HikariPool-1 - Timeout failure stats (total=20, active=20, idle=0, waiting=13)            

這是開始大量報錯前的日志。我們可以看到此時HikariPool連接配接池已經無法擷取連接配接了,active=20表示被擷取正在被使用的資料庫連接配接。waiting表示目前正在排隊擷取連接配接的請求數量。可以看出,已經有相當多的請求處于挂起狀态。

是以當時我們的解決辦法是調整資料庫連接配接池大小,開始初步認為是,高峰時期,我們設定的連接配接池數量大小,不足以支撐早高峰的連接配接數量導緻的。

jdbc.connection.timeout=30000 
jdbc.max.lifetime=1800000 
jdbc.maximum.poolsize=200 
jdbc.minimum.idle=10 
jdbc.idle.timeout=60000 
jdbc.readonly=false            

我們将将資料庫連接配接池的數量調整到了200。

二:事務

2.1事務濫用的後果

及時将配置調整成了200,服務重新開機也恢複了正常,但是我仍然認為系統存在連接配接洩露的風險,我試圖從日志表現出的行為裡尋找蛛絲馬迹。我在通路日志看到每次在系統崩潰前,其實都有人在做建構,而且建構經常點選沒反應,我當時添加的建構debug日志也顯示了這一點。我開始懷疑是建構造成的連接配接洩露。

在這裡我簡單說下建構代碼處的邏輯

  • 使用者觸發建構
  • 将job加入增量job緩存,用于更新job狀态
  • jenkinsClient調用jenkins的api,開始建構
  • 将建構資訊寫入資料庫(jobname,version)

我開始觀察自己寫的代碼,可是看了多遍,我也發現不了這段代碼和資料庫連接配接有啥關系,大多數人包括當時自己來說,資料庫連接配接的洩露,大多數情況應該是服務和資料庫連接配接的過程中發生了阻塞,導緻連接配接洩露。但是現在來看,很容易能發現問題所在,看當時的代碼:

@Transactional(rollbackFor = Exception.class) 
   public void build(BuildHistoryReq buildHistoryReq) { 
       //1.封裝操作 
       //2.調用jenkins Api 
       //3.資料庫更新寫入 
   }            

這就是當時的代碼入口,當然代碼處沒有這麼簡單。可以看到我在方法入口就加上了Transactional注解,這裡的意思其實就是發生錯誤,抛出異常時,資料庫復原。

問題就出現在了這裡,當有使用者點選建構時,請求剛進入build方法時,就會從資料庫連接配接擷取一個連接配接。可是此時,程式并沒有和資料庫相關的操作,如果此時代碼在步驟1或者2處出現io或者網絡阻塞,就會導緻,事務無法送出,連接配接也就會一直被該請求占用。而再大的連接配接池也會被耗費殆盡。進而造成系統崩潰。

2.2事務注解的正确用法

通常情況下作為非業務部門,沒有涉及到核心的業務,像支付,訂單,庫存相關的操作時,事務在可讀層面并沒有特别高的要求。通常也隻涉及到,多表操作同時更新時,保證資料一緻性,要麼同時成功要麼同時失敗。而使用

@Transactional(rollbackFor = Exception.class)            

足以。

而上述代碼該如何改進呢??

首先分析有沒有需要使用事務的必要。在步驟3中,資料操作,看代碼後發現隻有對一張表的操作,同時和其它操作沒有相關性。而且本身屬于最後一個步驟。是以在此代碼中完全沒有必要使用,删除注解即可。

當然如果步驟3操作資料庫是多表操作,具有強相關性,資料一緻,我們可以這樣做。将和步驟3無關的步驟分開,變成兩個方法,那麼在1,2處發生阻塞也不會影響到資料庫連接配接。

public void build(BuildHistoryReq buildHistoryReq) { 
      //1.封裝操作 
      //2.調用jenkins Api 
      update**(XX); 
  } 
 
  @Transactional(rollbackFor = Exception.class) 
  public void update**(XX xx) { 
      //3.資料庫更新寫入 
  }               

這裡需要注意,注解事務的用法,方法必須是公開調用的。

三:HttpClient 4.x連接配接池

當時找到資料連接配接池洩露的原因後,我第一步就是去掉了事務,然後加上了一些日志,這時我已經能确定代碼在jenkinsclient處出現了問題,但是仍然不确定問題出在了哪,我隻能加上一些日志,同時通過監控繼續觀察。

果然在hotfix的第二天還是出現了我預料中的事情,建構釋出仍然有問題,當然此時其它功能是不受影響了。我觀察日志發現建構開始并在該處阻塞

jenkinsClient.startBuild(jobName, params);            

随後我觀察了項目監控。觀察線程情況,發現大量http-nio的線程阻塞了,而這個線程和httpclient相關。

java.lang.Thread.State: WAITING (parking) 
    at sun.misc.Unsafe.park(Native Method) 
    - parking to wait for  <0x00000007067027e8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) 
    at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) 
    at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039) 
    at org.apache.http.pool.AbstractConnPool.getPoolEntryBlocking(AbstractConnPool.java:379) 
    at org.apache.http.pool.AbstractConnPool.access$200(AbstractConnPool.java:69) 
    at org.apache.http.pool.AbstractConnPool$2.get(AbstractConnPool.java:245) 
    - locked <0x00000007824713a0> (a org.apache.http.pool.AbstractConnPool$2) 
    at org.apache.http.pool.AbstractConnPool$2.get(AbstractConnPool.java:193)            

随後我跟進源碼檢視了AbstractConnPool類的379行

一次線上故障:資料庫連接配接池洩露後的思考

可以看到線程走到379行執行了this.condition.await()後進入無限期的等待,是以此時如果沒有線程執行this.condition.signal()就會導緻該線程一直處于waiting狀态,而前端也會遲遲收不到相應,導緻請求timeout。

我們再分析下源碼,看看什麼情況下會導緻線程跑到該處:

/** 
* 擷取http連接配接,從名稱也能看出該方法會造成阻塞 
*/ 
 private E getPoolEntryBlocking( 
            final T route, final Object state, 
            final long timeout, final TimeUnit timeUnit, 
            final Future<E> future) throws IOException, InterruptedException, TimeoutException { 
 
        Date deadline = null; 
        if (timeout > 0) { 
            deadline = new Date (System.currentTimeMillis() + timeUnit.toMillis(timeout)); 
        } 
        this.lock.lock(); 
        try { 
            final RouteSpecificPool<T, C, E> pool = getPool(route); 
            E entry; 
            for (;;) { 
                Asserts.check(!this.isShutDown, "Connection pool shut down"); 
                for (;;) { 
                    entry = pool.getFree(state); 
                    if (entry == null) { 
                        break; 
                    } 
                    if (entry.isExpired(System.currentTimeMillis())) { 
                        entry.close(); 
                    } 
                    if (entry.isClosed()) { 
                        this.available.remove(entry); 
                        pool.free(entry, false); 
                    } else { 
                        break; 
                    } 
                } 
                if (entry != null) { 
                    this.available.remove(entry); 
                    this.leased.add(entry); 
                    onReuse(entry); 
                    return entry; 
                } 
 
                // New connection is needed 
                final int maxPerRoute = getMax(route); 
                // Shrink the pool prior to allocating a new connection 
                final int excess = Math.max(0, pool.getAllocatedCount() + 1 - maxPerRoute); 
                if (excess > 0) { 
                    for (int i = 0; i < excess; i++) { 
                        final E lastUsed = pool.getLastUsed(); 
                        if (lastUsed == null) { 
                            break; 
                        } 
                        lastUsed.close(); 
                        this.available.remove(lastUsed); 
                        pool.remove(lastUsed); 
                    } 
                } 
 
                if (pool.getAllocatedCount() < maxPerRoute) { 
                    final int totalUsed = this.leased.size(); 
                    final int freeCapacity = Math.max(this.maxTotal - totalUsed, 0); 
                    if (freeCapacity > 0) { 
                        final int totalAvailable = this.available.size(); 
                        if (totalAvailable > freeCapacity - 1) { 
                            if (!this.available.isEmpty()) { 
                                final E lastUsed = this.available.removeLast(); 
                                lastUsed.close(); 
                                final RouteSpecificPool<T, C, E> otherpool = getPool(lastUsed.getRoute()); 
                                otherpool.remove(lastUsed); 
                            } 
                        } 
                        final C conn = this.connFactory.create(route); 
                        entry = pool.add(conn); 
                        this.leased.add(entry); 
                        return entry; 
                    } 
                } 
 
                boolean success = false; 
                try { 
                    if (future.isCancelled()) { 
                        throw new InterruptedException("Operation interrupted"); 
                    } 
                    pool.queue(future); 
                    this.pending.add(future); 
                    if (deadline != null) { 
                        success = this.condition.awaitUntil(deadline); 
                    } else { 
                        this.condition.await(); 
                        success = true; 
                    } 
                    if (future.isCancelled()) { 
                        throw new InterruptedException("Operation interrupted"); 
                    } 
                } finally { 
                    // In case of 'success', we were woken up by the 
                    // connection pool and should now have a connection 
                    // waiting for us, or else we're shutting down. 
                    // Just continue in the loop, both cases are checked. 
                    pool.unqueue(future); 
                    this.pending.remove(future); 
                } 
                // check for spurious wakeup vs. timeout 
                if (!success && (deadline != null && deadline.getTime() <= System.currentTimeMillis())) { 
                    break; 
                } 
            } 
            throw new TimeoutException("Timeout waiting for connection"); 
        } finally { 
            this.lock.unlock(); 
        } 
    }            

從源碼我們可以看出有幾處必要條件才會導緻線程會無限期等待:

  • timeout=0 也就是說沒有給預設值,導緻: deadline = null
  • pool.getAllocatedCount() < maxPerRoute 判斷是否已經到達了該路由(host位址)的最大連接配接數。

其實整體邏輯就是,從池裡擷取連接配接,如果有就直接傳回,沒有,判斷目前請求出去的路由有沒有到達該路由的最大值,如果達到了,就進行等待。如果timeout為0就會進行無限期等待。

而這些值我本身也沒有做任何設定,我當時的第一想法就是,給http請求設定逾時時間。也就是給每個client設定必要的參數

解決

1.jenkinsClient配置設定逾時時間

public HttpClientBuilder clientBuilder() { 
        HttpClientBuilder httpClientBuilder = HttpClientBuilder.create(); 
        RequestConfig.Builder builder = RequestConfig.custom(); 
        //該參數對應AbstractConnecPool getPoolEntryBlocking方法的timeout 
        builder.setConnectionRequestTimeout(5 * 1000); 
        //資料傳輸的逾時時間 
        builder.setSocketTimeout(20 * 1000); 
        //該參數為,服務和jenkins連接配接的時間(通常連接配接的時間都很短,可以設定小點) 
        builder.setConnectTimeout(5 * 1000); 
        httpClientBuilder.setDefaultRequestConfig(builder.build()); 
        return httpClientBuilder; 
 }            

2.建構JenkinsClient和更新使用的JenkinsClient分離

其實我已經嘗試用池化的思想來解決該問題了。

詭異bug(同一個JenkinsClient,調用不同的api,有的api會阻塞,有的調用仍然正常)

但hotfix的第二天,又出現了一個詭異的bug:

建構可以,但是無法同步job的狀态。這裡出現這個問題的原因在于我将建構和更新兩個過程使用的jenkinsClient分離成兩個,是以這個過程互相獨立,互不影響,是以,更新的client出了問題但是建構的client仍然能正常使用。

但是更新過程的JenkinsClient出現的問題讓我百思不得其解。我們先看看更新狀态過程會使用到的api(接口)

//擷取對應的job 
1 JobWithDetails job = client.get(UrlUtils.toJobBaseUrl(folder, jobName), JobWithDetails.class); 
 
//擷取job建構的pipeline流水 
2 client.get("/job/" + EncodingUtils.encode(jobName) + "/" + version + "/wfapi/describe", PipelineStep.class); 
 
//擷取對應job某次build的詳情 
3  client.get(url, BuildWithDetails.class);            

bug問題1:為什麼全量更新job和增量更新job使用的是同一個JenkinsClient,但是全量更新仍然正常擷取值,而增量更新job狀态的線程确出現阻塞逾時(逾時是因為前面我設定了timeout,使得請求不會一直阻塞下去)。

要回答這個問題,就要回到線程的相關問題了,

this.condition.wait()會導緻目前線程阻塞,并不會影響到另外線程。而更新使用了兩個線程。是以這個問題也比較好回答。

bug問題2:為什麼同一個線程(增量更新job線程)調用不同api,有的成功,而有的會阻塞:

解決這個問題,我們還是得回到AbstractConnPool中的方法getPoolEntryBlocking()來看:

if (pool.getAllocatedCount() < maxPerRoute) { 
                    
     }            

目前請求的路由如果已經達到最大值了就會阻塞等待。那麼同一個jenkinsclient,按理來說不可能會出現不同的路由。是以同一個client要麼都能通路,要麼都會阻塞,怎麼會出現有的能通路有的會阻塞。為了尋求問題的答案,我翻閱了JenkinsClient的源碼,結合日志,發現服務每次阻塞的方法是:

一次線上故障:資料庫連接配接池洩露後的思考

不管多少次,每次都會完美的在該地方阻塞:對應上面的api 3:

//擷取對應job某次build的詳情 
3  client.get(url, BuildWithDetails.class);            

這個url和其它兩個api拿到的路由都有差別:可以跟随我一起看源碼:

public class Build extends BaseModel { 
 
    private int number; 
    private int queueId; 
    private String url; 
}            

我們可以看到url是屬于Build的屬性,并非client我們設定的值,當然有人會覺得該值可能是通過将配置的url設定過來的。我們可以接着看,哪些方法可能會給build設定url,三個構造函數,一個set方法都可以,如果我們繼續隻看源碼仍然很難找到問題所在,是以這時候我開始啟動服務debug;

發現了問題在哪:

一次線上故障:資料庫連接配接池洩露後的思考

可以看出調用jenkins的這個api出現了兩個router,也可以看出這個url是jenkins傳回的,查閱資料可以看到,jenkins系統設定時可以設定這個url。

是以這個bug也能很好的解釋了,對于httpclient來說,每個router預設可以最多兩個連接配接。雖然是同一個調用api采用的是同一個jenkinsClient,但是卻維護了兩個router,一個是從配置中擷取,一個是jenkins傳回的,這個是配置不一緻導緻的。

JenkinsClient配置設定連接配接數:

public HttpClientBuilder clientBuilder() { 
        HttpClientBuilder httpClientBuilder = HttpClientBuilder.create(); 
        RequestConfig.Builder builder = RequestConfig.custom(); 
        builder.setConnectionRequestTimeout(5 * 1000); 
        builder.setSocketTimeout(20 * 1000); 
        builder.setConnectTimeout(5 * 1000); 
        httpClientBuilder.setDefaultRequestConfig(builder.build()); 
        //每個路由最多有10個連接配接(預設2個) 
        httpClientBuilder.setMaxConnPerRoute(10); 
        //設定連接配接池最大連接配接數 
        httpClientBuilder.setMaxConnTotal(20); 
        return httpClientBuilder; 
    }            

給JenkinsClient添加健康檢查,并手動更新不能用的Client

@Slf4j 
public class JenkinsClientManager implements Runnable { 
 
    private volatile boolean flag = true; 
    private final JenkinsClientProvider jenkinsClientProvider; 
 
    public JenkinsClientManager(JenkinsClientProvider jenkinsClientProvider) { 
        this.jenkinsClientProvider = jenkinsClientProvider; 
    } 
 
    @Override 
    public void run() { 
        while (flag) { 
            try { 
                checkJenkinsHealth(); 
                //每30秒檢查一次 
                Thread.sleep(30_000); 
            } catch (Exception e) { 
                log.warn("check health error:{}", e.getMessage()); 
            } 
        } 
    } 
 
    public void checkJenkinsHealth() { 
        log.debug("check jenkins client health start"); 
        //擷取client是否可用 
        available = isAvailable(..) 
        if (!available || !queryAvailable) { 
            //更新client 
            jenkinsClientProvider.retrieveJenkinsClient(); 
        } 
    } 
 
    private boolean isAvailable(Set<Map.Entry<String, JenkinsClient>> entries) { 
        boolean available = true; 
        for (Map.Entry<String, JenkinsClient> entry : entries) { 
            boolean running = entry.getValue().isRunning(); 
            if (!running) { 
                log.debug("jenkins running error"); 
                available = false; 
            } 
        } 
        return available; 
    } 
 
 
    @PostConstruct 
    public void start() { 
        TaskSchedulerConfig.getExecutor().execute(this); 
    } 
}            

四:JenkinsClient連接配接池

采用池化技術解決client高可用和重複利用問題

雖然我手動寫了一個JenkinsClientManager每30秒來維護一次client,但是這種手工的方式并不好:

  • 每30秒維護一次,若是在期間發生問題,那麼隻能幹等
  • 無法動态的根據系統需要,動态建構新的client,也就是無法滿足高并發下的使用問題
  • 無法配置

目前我們都知道各種池化技術:線程池、資料庫連接配接池、redis連接配接池。

筆者在實作jenkinsClient pool之前,參考了線程池、資料庫連接配接池的實作、發現其底層實作較為複雜、redis的連接配接池技術相對來說容易看懂和學習、是以采用了和jedis一樣的實作方式來實作JenkinsClient的連接配接池

一次線上故障:資料庫連接配接池洩露後的思考

這是jedis的類結構目錄,其實重點在我标記的這5個類。

jedis本身也是采用的commons-pool2提供的池技術實作的,接下來我會簡單介紹一下該工具提供的池化技術。

JenkinsClient連接配接池應該要具備哪些功能??

  • 動态建立JenkinsClient
  • 使用完的Client放回池中
  • 回收長期不用和不可用的Client
  • 能夠根據需要配置一定數量的Client

對于提到的這些功能,我将通過commons-pool2包來實作

PooledObjectFactory:該接口管理着bean的生命周期(An interface defining life-cycle methods for instances to be served by an)

  • makeObject 方法建立一個可以入池的執行個體,也就是我們需要用的Client由該方法建立
  • destroyObject 方法可以銷毀不可用或者過期的對象
  • validateObject 方法對執行個體進行驗證,在每次建立完執行個體後,都會調用該方法,同時也會以一定的頻率進行健康檢查(頻率timeBetweenEvictionRunsMillis)

GenericObjectPool:執行個體都會放入該池中進行管理:

//所有的可用連接配接 
private final Map<IdentityWrapper<T>, PooledObject<T>> allObjects = new ConcurrentHashMap<>(); 
 
//空閑的可用連接配接 
private final LinkedBlockingDeque<PooledObject<T>> idleObjects; 
 
//擷取可用連接配接 
T borrowObject() throws Exception, NoSuchElementException, 
            IllegalStateException; 
 
//資源釋放(将連接配接放回連接配接池) 
void returnObject(T obj) throws Exception;            

配置(BaseObjectPoolConfig,但是我們繼承GenericObjectPoolConfig,該類給出了大量的預設值)

連結池中最大連接配接數,預設為8 
maxTotal 
#連結池中最大空閑的連接配接數,預設也為8 
maxIdle 
#連接配接池中最少空閑的連接配接數,預設為0 
minIdle 
#連接配接空閑的最小時間,達到此值後空閑連接配接将可能會被移除。預設為1000L*60L*30L 
minEvictableIdleTimeMillis 
#連接配接空閑的最小時間,達到此值後空閑連結将會被移除,且保留minIdle個空閑連接配接數。預設為-1 
softMinEvictableIdleTimeMillis 
#當連接配接池資源耗盡時,等待時間,超出則抛異常,預設為-1即永不逾時 
maxWaitMillis 
#當這個值為true的時候,maxWaitMillis參數才能生效。為false的時候,當連接配接池沒資源,則立馬抛異常。預設為true 
blockWhenExhausted 
#空閑連結檢測線程檢測的周期,毫秒數。如果為負值,表示不運作檢測線程。預設為-1. 
timeBetweenEvictionRunsMillis 
#在每次空閑連接配接回收器線程(如果有)運作時檢查的連接配接數量,預設為3 
numTestsPerEvictionRun 
#預設false,create的時候檢測是有有效,如果無效則從連接配接池中移除,并嘗試擷取繼續擷取 
testOnCreate 
#預設false,borrow的時候檢測是有有效,如果無效則從連接配接池中移除,并嘗試擷取繼續擷取 
testOnBorrow 
#預設false,return的時候檢測是有有效,如果無效則從連接配接池中移除,并嘗試擷取繼續擷取 
testOnReturn 
#預設false,在evictor線程裡頭,當evictionPolicy.evict方法傳回false時,而且testWhileIdle為true的時候則檢測是否有效,如果無效則移除 
testWhileIdle            

了解了這些我們對于需要開發的連接配接池就很輕松了:

  • 實作PooledObjectFactory(JenkinsFactory)該工廠類就是負責JenkinsClient的生命周期
  • 自定義連接配接池Pool,通過組合的方式引入架構的連接配接池GenericObjectPool,當然我們也可以用繼承的方式來實作(組合優先于繼承)

五:反思

連接配接池寫完,目前也隻是在測試環境運作,還在觀察階段

有個特别的問題也需要指出來,該問題是筆者在開發時沒有注意的問題,也是此次線上産生問題的原因

筆者将原來更新頻率從15s調整到了10s,問題就暴露出來了,對于1個job,可能會拉出上百個build,每次會調用3個api接口,如果每次有十個job,每次更新會在10秒内完成,随着job增加,和建構曆史增加(雖然有設定保留多少版本,但是api還是會拉出很奇怪的曆史build),會超量發出大量http請求。是以我在代碼層面也做了改動,每次隻更新每個job的前5個最新的build,這樣下來,請求量會降低很多

List<Build> buildList = builds.stream().sorted(Comparator.comparing(Build::getNumber).reversed()).limit(5).collect(toList());            

by陳朗:

本次事故整體來講,還是筆者技術有限,解決問題時繞了很多彎,花了大量時間研究源碼。我也總結了以下幾點

  • 對于連接配接、鎖等這些可能會阻塞的場景,都需要給出逾時設定
  • 資源消耗型,需要有池化的思想,提高資源使用率,保證系統穩定
  • 基礎很重要,需要持續不斷的學習,這樣解決問題才能深入底層,找出問題所在,而不是浮于表面

原文釋出時間:2020-01-18

本文作者:槽神

本文來自阿裡雲雲栖号合作夥伴“

51CTO

”,了解相關資訊可以關注“