高併發服務優化篇:淺談數據庫連接池

被 N 多大號轉載的一篇 CSDN 博客,引起了我的注意,說的是數據庫連接池使用 threadlocal 的原因,文中結論如下圖所示。

來自 CSDN 的一篇文章,被很多號轉載過

姑且不談 threadlocal 的作用和工作原理,單說數據庫連接池這個知識點,猛地一看挺有理;仔細一看,怎麼感覺不太對啊,同學,這是什麼虎狼之詞。

$ 實踐是檢驗真理的唯一標準

個人理解,連接池提供的獲取連接的能力,需要對 "任務" 唯一,即,只有當某一線程完成了本次數據操作,將連接放回到連接池之後,其他線程才能夠再次獲取並使用。原因我們後面細說,先來親自測試一下。

連接池選一個 druid,設置連接池中只有一個 connection,方便驗證多線程應對同一個 connection 的場景。

首先,將 datasource 共享資源傳入線程,採用 datasource.getConnection() 方式獲取連接 :

注:Runnable 中故意不執行 connection.close

結果如上圖:只有一個線程可以正常執行,由於沒有被關閉,其他線程都獲取連接失敗了。說明,數據庫連接池的作用方式是某個線程任務 "獨佔" 的。

$ 退一步來講

假設如同開頭文章中描述的,用了一個功能不完備的連接池,讓多個線程拿到了同一個 connection,那麼,用 threadlocal 真的可以起到互不影響的作用麼?

//驗證思路參考自:https://blog.csdn.net/sunbo94/article/details/79409298
//Connection設置 autoCommit=false
private static final ThreadLocal<Connection> connectionThreadLocal=new ThreadLocal<>();

private static class InnerRunner implements Runnable{
   @Override
   public void run() {
       //其他代碼省略...
       String insertSql="insert into user(id,name) value("+RunnerIndex+","+RunnerIndex+")";
       statement=connectionThreadLocal.get().createStatement();
       statement.executeUpdate(insertSql);
       System.out.println(RunnerIndex+" is running");
       //讓特定的線程執行回滾,用來驗證事務之間的影響
       if (RunnerIndex==3){
          //模擬異常時耗時增加
          Thread.sleep(100);
          //從threadlocal裏拿連接對象
          connectionThreadLocal.get().rollback();
          System.out.println("3 rollback");
        }else{
          //從threadlocal裏拿連接對象
          connectionThreadLocal.get().commit();
          System.out.println(RunnerIndex +" commit");
       }
   }
}

結果如下:

只要是線程 3 的 statement.executeUpdate 語句運行在前,而事務回滾語句執行在某個 commit 之後,就會出現問題,即需要回滾的數據被提交的情況。

如下圖,3 的 insert 結果確實沒有被回滾,而是出現在了表中:

所以,對於知識,大家不能盲目的接收,建議抱些懷疑的態度,還是有必要的。

$ 話說回來,爲什麼 threadlocal 對同一個數據庫連接不起作用呢?

Connection 是什麼?

connection 可以當成是服務器和數據庫的一個會話,而 statemant 用來在會話的上下文中執行 sql 以及返回結果。一個 connection 可以包含多個 statement;然而在兩者中間,還有一個事務 (Translation) 的概念,事務用來保證其內部的語句,要麼都執行,要麼都不執行,如果 autoCommit 被開啓,則默認是一個語句一個事務。

往簡單點說,connection 是一種共享資源,更簡單一點,它是一個共享變量,在被連接池創建之後,在內存中的地址是唯一的一個變量。

ThreadLocal 能存共享變量麼?

存肯定能存,但不建議,因爲將 Connection set 進 ThreadLocalMap,也其實是保存一個內存對象的地址引用而已,真正使用的時候,還是唯一的那個對象在起作用。

ThreadLocal 最常用的功能,是爲了避免層層傳遞而提供了對象保存和獲取方法。

高中學數學的時候曾經有過一個技巧,叫證難則反,在這裏也適用。我們反過來想,如果用 threadlocal 的副本拷貝能實現 connection 的隔離,那豈不是隻要一個 connection 就可以了?實時上呢,數據庫連接常常會出現不夠用的情況,結論就顯而易見了~

$ 話又說回來,threadLocal 想要完成數據庫連接隔離的功能,需要怎麼做呢?

如果非要用 ThreadLocal 實現這個連接隔離的功能,那麼,只能是爲每個線程創建新的連接,然後保存在 Threadlocal 中,這樣,每個線程在自己的生命週期範圍內只會使用這個連接,即可實現線程隔離。

$ 話又又說回來,druid、zadl 等一衆數據庫連接池是怎麼進行連接的管理工作的呢?

最大連接數爲 1 的 druid 連接池原理概覽

zdal 的連接池管理源碼一覽:

public class InternalManagedConnectionPool{
   //最大連接數
   private final int  maxSize;
   //用來存放連接的鏈表
   private final ArrayList connectionListeners;
   //內部的信號量,用來控制允許獲取資源的線程總數
   private final InternalSemaphore  permits;
   //正在使用的連接數 
   private volatile int  maxUsedConnections = 0;

   protected InternalManagedConnectionPool(...){
     //構造函數中,初始化了連接池大小和信號量大小
     connectionListeners = new ArrayList(this.maxSize);
      permits = new InternalSemaphore(this.maxSize);
 }

getConnection() 方法:

//獲取連接
 public ConnectionListener getConnection(){
    //信號量嘗試獲取許可
   if (permits.tryAcquire(poolParams.blockingTimeout, TimeUnit.MILLISECONDS)) {
         ConnectionListener cl = null;
         do {
         //加鎖資源池
         synchronized (connectionListeners) {

           if (connectionListeners.size() > 0) {
                //獲取list的最後一個
                cl = (ConnectionListener) connectionListeners.remove(connectionListeners.size() - 1);
                    
                //最大連接數 減去 正在工作的信號量 
                int size = (maxSize - permits.availablePermits());
                if (size > maxUsedConnections){
                     maxUsedConnections = size;
                }
            }
           }
        if (cl != null) {
         return cl;
         }
      }while(connectionListeners.size() > 0);

      //OK, 在連接池中找不到正在工作的連接了. 那就創建個新的
      createNewConnection(){...}

  }else{
   if (this.maxSize == this.maxUsedConnections) {
         throw new ResourceException(
         "數據源最大連接數已滿,並且在超時時間範圍內沒有新的連接釋放,poolName = "
         + poolName
         + " blocking timeout="
         + poolParams.blockingTimeout +
         "(ms)");
  }
 }

這裏把內部連接池的管理類的關鍵屬性和連接獲取方法流量進行了簡化,連接歸還就不弄了,大同小異,仔細看,我們看到了什麼

都是些常見的八股文,不過組合起來可就了不得~

$ 話又又又說回來,在 druid、zdal 中,threadlocal 的作用體現在哪裏呢?

我們知道,誠如 druid、zdal 等優秀的中間件,可不止是數據庫連接池這一個作用,阿里數據庫中間件 zdal 源碼解析 文中也有提及。

那麼,ThreadLocal 能在這裏扮演什麼角色呢?

就以 zdal 爲例,因爲阿里的數據庫規模基本都非常大,但又有一套完備的數據庫庫表拆分規範,因此,分庫鍵、分表鍵、主鍵、虛擬表名等在設計和存儲時需要遵循規範,而 zdal 中的解析操作,也需要與之相匹配。

這個解析工作是相對複雜且繁重的,然而,針對同一用戶的操作,通常庫表的路由是相對固定的,因此,當我們解析過一次 sql,通過各個字段和配置規則,計算出了庫表路由,那麼,可以直接 put 進線程上下文,供本次請求的後續數據庫操作使用。

public Object parse(...){
    SimpleCondition simpleCondition = new SimpleCondition();
    simpleCondition.setVirtualTableName("user");
    simpleCondition.put("age", 10);
    ThreadLocalMap.put(ThreadLocalString.ROUTE_CONDITION, simpleCondition);
}

public void 後續操作(){
   RouteCondition rc = (RouteCondition) ThreadLocalMap.get(ThreadLocalString.ROUTE_CONDITION);
   
    if (rc != null) {
        //不走解析SQL,由ThreadLocal傳入的指定對象(RouteCondition),決定庫表目的地
       metaData = sqlDispatcher.getDBAndTables(rc);
    } else {
       // 通過解析SQL來分庫分表
       try {
          metaData = sqlDispatcher.getDBAndTables(originalSql, parameters);
       } catch (ZdalCheckedExcption e) {
          throw new SQLException(e.getMessage());
       }
  }
}

這個也正好是對前面 ThreadLocal 正確使用方法的補充。

起因是對一篇文章敘述產生疑問,通過簡單的驗證,證實了自己的想法,然後又從幾個方面對數據庫連接和 threadlocal 進行了擴展,以上,大家如果發現有任何問題,歡迎留言幫忙指正和補充。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/WgvcHa3XHD9XJn0QzefHZw