注意Spring事務這一點,避免出現大事務

咖啡拿鐵發表於2022-12-05

背景

本篇文章主要分享壓測的(高併發)時候發現的一些問題。之前的兩篇文章已經講述了在高併發的情況下,訊息佇列和資料庫連線池的一些總結和最佳化。廢話不多說,進入正題。

事務,想必各位CRUD之王對其並不陌生,基本上有多個寫請求的都需要使用事務,而Spring對於事務的使用又特別的簡單,只需要一個@Transactional註解即可,如下面的例子:

    @Transactional
    public int createOrder(Order order){
        orderDbStorage.save(order);
        orderItemDbStorage.save(order.getItems());
        return order.getId();
    }

在我們建立訂單的時候, 通常需要將訂單和訂單項放在同一個事務裡面保證其滿足ACID,這裡我們只需要在我們建立訂單的方法上面寫上事務註解即可。

事務的合理使用

對於上面的建立訂單的程式碼,如果現在需要新增一個需求,再建立訂單之後傳送一個訊息到訊息佇列或者呼叫一個RPC,你會怎麼做呢?很多同學首先會想到,直接在事務方法裡面進行呼叫:

    @Transactional
    public int createOrder(Order order){
        orderDbStorage.save(order);
        orderItemDbStorage.save(order.getItems());
        sendRpc();
        sendMessage();
        return order.getId();
    }

這種程式碼在很多人寫的業務中都會出現,事務中巢狀rpc,巢狀一些非DB的操作,一般情況下這麼寫的確也沒什麼問題,一旦非DB寫操作出現比較慢,或者流量比較大,就會出現大事務的問題。由於事務的一直不提交,就會導致資料庫連線被佔用。這個時候你可能會問,我擴大點資料庫連線不就行了嗎,100個不行就上1000個,再上篇文章已經講過資料庫連線池大小依然會影響我們資料庫的效能,所以,資料庫連線並不是想擴多少擴多少。

那我們應該怎麼對其進行最佳化呢?在這裡可以仔細想想,我們的非db操作,其實是不滿足我們事務的ACID的,那麼幹嘛要寫在事務裡面,所以這裡我們可以將其提取出來。

    public int createOrder(Order order){
        createOrderService.createOrder(order);
        sendRpc();
        sendMessage();
    }

在這個方法裡面先去呼叫事務的建立訂單,然後再去呼叫其他非DB操作。如果我們現在想要更復雜一點的邏輯,比如建立訂單成功就傳送成功的RPC請求,失敗就傳送失敗的RPC請求,由上面的程式碼我們可以做如下轉化:

    public int createOrder(Order order){
        try {
            createOrderService.createOrder(order);
            sendSuccessedRpc();
        }catch (Exception e){
            sendFailedRpc();
            throw e;
        }
    }

通常我們會捕獲異常,或者根據返回值來進行一些特殊處理,這裡的實現需要顯示的捕獲異常,並且再次丟擲,這種方式不是很優雅,那麼怎麼才能更好的寫這種話邏輯呢?

TransactionSynchronizationManager

在Spring的事務中剛好提供了一些工具方法,來幫助我們完成這種需求。在TransactionSynchronizationManager中提供了讓我們對事務註冊callBack的方法:

public static void registerSynchronization(TransactionSynchronization synchronization)
            throws IllegalStateException 
{

        Assert.notNull(synchronization, "TransactionSynchronization must not be null");
        if (!isSynchronizationActive()) {
            throw new IllegalStateException("Transaction synchronization is not active");
        }
        synchronizations.get().add(synchronization);
    }

TransactionSynchronization也就是我們事務的callBack,提供了一些擴充套件點給我們:

public interface TransactionSynchronization extends Flushable {

    int STATUS_COMMITTED = 0;
    int STATUS_ROLLED_BACK = 1;
    int STATUS_UNKNOWN = 2;

    /**
     * 掛起時觸發
     */

    void suspend();

    /**
     * 掛起事務丟擲異常的時候 會觸發
     */

    void resume();


    @Override
    void flush()
;

    /**
     * 在事務提交之前觸發
     */

    void beforeCommit(boolean readOnly);

    /**
     * 在事務完成之前觸發
     */

    void beforeCompletion();

    /**
     * 在事務提交之後觸發
     */

    void afterCommit();

    /**
     * 在事務完成之後觸發
     */

    void afterCompletion(int status);
}

我們可以利用afterComplettion方法實現我們上面的業務邏輯:

    @Transactional
    public int createOrder(Order order){
        orderDbStorage.save(order);
        orderItemDbStorage.save(order.getItems());
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
            @Override
            public void afterCompletion(int status) {
                if (status == STATUS_COMMITTED){
                    sendSuccessedRpc();
                }else {
                    sendFailedRpc();
                }
            }
        });
        return order.getId();
    }

這裡我們直接實現了afterCompletion,透過事務的status進行判斷,我們應該具體傳送哪個RPC。當然我們可以進一步封裝TransactionSynchronizationManager.registerSynchronization將其封裝成一個事務的Util,可以使我們的程式碼更加簡潔。

透過這種方式我們不必把所有非DB操作都寫在方法之外,這樣程式碼更具有邏輯連貫性,更加易讀,並且優雅。

afterCompletion的坑

這個註冊事務的回撥程式碼在我們在我們的業務邏輯中經常會出現,比如某個事務做完之後的重新整理快取,傳送訊息佇列,傳送通知訊息等等,在日常的使用中,大家用這個基本也沒出什麼問題,但是在打壓的過程中,發現了這一塊出現了瓶頸,耗時特別久,透過一系列的監測,發現是從資料庫連線池獲取連線等待的時間較長,最終我們定位到了afterCompeltion這個動作,居然沒有歸還資料庫連線。

在Spring的AbstractPlatformTransactionManager中,對commit處理的程式碼如下:

private void processCommit(DefaultTransactionStatus status) throws TransactionException {
        try {
            boolean beforeCompletionInvoked = false;
            try {
                prepareForCommit(status);
                triggerBeforeCommit(status);
                triggerBeforeCompletion(status);
                beforeCompletionInvoked = true;
                boolean globalRollbackOnly = false;
                if (status.isNewTransaction() || isFailEarlyOnGlobalRollbackOnly()) {
                    globalRollbackOnly = status.isGlobalRollbackOnly();
                }
                if (status.hasSavepoint()) {
                    if (status.isDebug()) {
                        logger.debug("Releasing transaction savepoint");
                    }
                    status.releaseHeldSavepoint();
                }
                else if (status.isNewTransaction()) {
                    if (status.isDebug()) {
                        logger.debug("Initiating transaction commit");
                    }
                    doCommit(status);
                }
                // Throw UnexpectedRollbackException if we have a global rollback-only
                // marker but still didn't get a corresponding exception from commit.
                if (globalRollbackOnly) {
                    throw new UnexpectedRollbackException(
                            "Transaction silently rolled back because it has been marked as rollback-only");
                }
            }


            // Trigger afterCommit callbacks, with an exception thrown there
            // propagated to callers but the transaction still considered as committed.
            try {
                triggerAfterCommit(status);
            }
            finally {
                triggerAfterCompletion(status, TransactionSynchronization.STATUS_COMMITTED);
            }

        }
        finally {
            cleanupAfterCompletion(status);
        }
    }

這裡我們只需要關注 倒數幾行程式碼即可,可以發現我們的triggerAfterCompletion,是倒數第二個執行邏輯,當執行完所有的程式碼之後就會執行我們的cleanupAfterCompletion,而我們的歸還資料庫連線也在這段程式碼之中,這樣就導致了我們獲取資料庫連線變慢。

如何最佳化

對於上面的問題如何最佳化呢?這裡有三種方案可以進行最佳化:

  • 將非DB操作提到事務之外,這種方法也就是我們上面最原始的方法,對於一些簡單的邏輯可以提取,但是對於一些複雜的邏輯,比如事務的巢狀,巢狀裡面呼叫了afterCompletion,這樣做會增大很多工作量,並且很容易出現問題。

  • 透過多執行緒非同步去做,提升資料庫連線池歸還速度,這種適合於註冊afterCompletion時寫在事務最後的時候,直接將需要做的放在其它執行緒去做。但是如果註冊afterCompletion的時候出現在我們事務之間,比如巢狀事務,就會導致我們要做的後續業務邏輯和事務並行。

  • 模仿Spring事務回撥註冊,實現新的註解。上面兩種方法都有各自的弊端,所以最後我們採用了這種方法,實現了一個自定義註解@MethodCallBack,再使用事務的上面都打上這個註解,然後透過類似的註冊程式碼進行。

    @Transactional
    @MethodCallBack
    public int createOrder(Order order){
        orderDbStorage.save(order);
        orderItemDbStorage.save(order.getItems());
        MethodCallbackHelper.registerOnSuccess(() -sendSuccessedRpc());
         MethodCallbackHelper.registerOnThrowable(throwable -sendFailedRpc());
        return order.getId();
    }

透過第三種方法基本只需要把我們註冊事務回撥的地方都進行替換就可以正常使用了。

再談大事務

說了這麼久大事務,到底什麼才是大事務呢?簡單點就是事務時間執行得長,那麼就是大事務。一般來說導致事務時間執行時間長的因素不外乎下面幾種:

  • 資料操作得很多,比如在一個事務裡面插入了很多資料,那麼這個事務執行時間自然就會變得很長。

  • 鎖的競爭大,當所有的連線都同時對同一個資料進行操作,那麼就會出現排隊等待,事務時間自然就會變長。

  • 事務中有其他非DB操作,比如一些RPC請求,有些人說我的RPC很快的,不會增加事務的執行時間,但是RPC請求本身就是一個不穩定的因素,受很多因素影響,網路波動,下游服務響應緩慢,如果這些因素一旦出現,就會有大量的事務時間很長,有可能導致Mysql掛掉,從而引起雪崩。

上面的三種情況,前面兩種可能來說不是特別常見,但是第三種事務中有很多非DB操作,這個是我們非常常見,通常出現這個情況的原因很多時候是我們自己習慣規範,初學者或者一些經驗不豐富的人寫程式碼,往往會先寫一個大方法,直接在這個方法加上事務註解,然後再往裡面補充,哪管他是什麼邏輯,一把梭,就像下面這張圖一樣:

注意Spring事務這一點,避免出現大事務

當然還有些人是想搞什麼分散式事務,可惜用錯了方法,對於分散式事務可以關注Seata,同樣可以用一個註解就能幫助你做到分散式事務。

最後

其實最後想想,為什麼會出現這種問題呢?一般大家的理解都是會認為都是在完成之後做的了,資料庫連線肯定早都釋放了,但是事實並非如此。所以,我們使用很多API的時候不能望文生義,如果其沒有詳細的doc,那麼你應該更加深入瞭解其實現細節。

當然最後希望大家寫程式碼之前儘量還是不要一把梭,認真對待每一句程式碼。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31555607/viewspace-2664412/,如需轉載,請註明出處,否則將追究法律責任。

相關文章