Seata-AT模式+TDDL:構建Insert操作的後映象在執行SELECT LAST_INSERT_ID()時報錯

帶你聊技術發表於2023-01-05


一、問題

程式碼環境在第三部分。

1)錯誤資訊:

java.sql.SQLException: Parameter index out of range (1 > number of parameters, which is 0).

2)錯誤現場:

Seata-AT模式+TDDL:構建Insert操作的後映象在執行SELECT LAST_INSERT_ID()時報錯

MySQLInsertExecutor#getPkValuesByAuto中,在執行genKeys = statementProxy.getTargetStatement().executeQuery("SELECT LAST_INSERT_ID()");時出現錯誤:java.sql.SQLException: Parameter index out of range (1 > number of parameters, which is 0).

3)疑問:

"SELECT LAST_INSERT_ID()" 語句確沒有引數,那麼引發報錯的引數從哪裡來的呢?

二、故障原因排查

2.1 debug 故障環節

透過 debug 進入到TGroupPreparedStatement#executeQueryOnConnection中(注意:TGroupPreparedStatement是 TDDL 元件中的),看一下里邊是什麼操作

@Override
protected ResultSet executeQueryOnConnection(Connection conn, String sql)
      throws SQLException {
   PreparedStatement ps = createPreparedStatementInternal(conn, sql);
   Parameters.setParameters(ps, parameterSettings);
   this.currentResultSet = ps.executeQuery();
   return this.currentResultSet;
}

上下文的引數資訊如下:

  • sql

    • SELECT LAST_INSERT_ID()
  • parameterSettings

    {Integer@10685} 1 -> {ParameterContext@15869} "setLong(1, 1010)" 
    {Integer@14902} 2 -> {ParameterContext@15870} "setInt(2, 1)" 
    {Integer@14904} 3 -> {ParameterContext@15871} "setTimestamp1(3, 2023-01-03 15:08:32.263)"

    看到這三個引數後,意識到原來它們是Insert記錄的時候在prepareStatement中設定的 3 個引數:

INSERT INTO tstock (`sku_id`,`stock_num`,`gmt_created`) VALUES(?,?,?);

2.2 prepareStatement 為什麼要設定引數呢?

prepareStatement介面繼承自Statement介面,增加了引數佔位符功能,當執行 SQL 語句時,可使用“?”作為引數佔位符,然後使用其提供的其他方法為佔位符設定引數值。其例項物件包含已編譯的 SQL 語句,由於已預編譯過,所以其執行速度要快於 Statement 物件。因此,多次執行的 SQL 語句經常建立為 PreparedStatement 物件,以提高效率。所以使用引數是很正常的現象。

2.3 原因初定

那麼報錯的直接原因是構建 afterImage的時候,prepareStatement 是複用了Insert操作的prepareStatement,而prepareStatement邏輯中,會在執行 sql 的時候會把引數設定一遍;由於未清空引數,只把 sql 從 INSERT INTO tstock (sku_id,stock_num,gmt_created) VALUES(?,?,?); 變成了 SELECT LAST_INSERT_ID() ,給沒有佔位符的 sql 指定引數,就引發了錯誤:java.sql.SQLException: Parameter index out of range (1 > number of parameters, which is 0).

2.4 處理思路

再來看一下錯誤現場Seata-AT模式+TDDL:構建Insert操作的後映象在執行SELECT LAST_INSERT_ID()時報錯截圖中可以看出,因為statementProxy.getGeneratedKeys();執行報錯,才進入了executeQuery("SELECT LAST_INSERT_ID()")導致了報錯,那麼:

  1. getGeneratedKeys 是什麼情況?這個操作是否可以不報錯?(放到其他篇章補充)
  2. prepareStatement 在整個執行的上下文中的生命週期是怎樣,此處是否有補償處理的機會?

本篇先梳理 prepareStatement 在整個執行的上下文中的生命週期是怎樣,嘗試找一下補償處理的辦法。

三、程式碼環境梳理

Demo 程式碼環境是 Seata 全域性註解事務中內嵌一個 Spring 註解事務

3.1 @GlobalTransactional 方法

@RequestMapping("createStock/{skuId}/{num}")
@ResponseBody
@GlobalTransactional
public StockDto createStock(@PathVariable Long skuId, @PathVariable Integer num){
    try {
        return  stockService.createStock(skuId,num);
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

3.2 @Transactional 方法

@Transactional(rollbackFor = Exception.class,value = "testSeataProxyTransactionManager")
public StockDto createStock(Long skuId, Integer num) throws Exception {
    int delcount = seataProxyStockMapper.delete(skuId);
    System.out.println("delete stock count = "+delcount);
    Stock stock = new Stock(skuId,num);
    int count = seataProxyStockMapper.insert(stock);
    if(count==0){
        throw new Exception("建立庫存失敗");
    }
    Long id = seataProxyStockMapper.getId();
    StockDto stockDto = JSON.parseObject(JSON.toJSONString(stock),StockDto.class);
    stockDto.setId(id);
    return stockDto;
}

3.3 出問題的環節

Seata 在 seataProxyStockMapper.insert(stock); 環節,AT 模式下資料來源代理邏輯中,insert 操作會把剛插入的資料構建成 afterImage ,問題就發生在這裡。

其他的一些細節也不太重要,暫不描述。

四、@Transactional 的關鍵邏輯概述

org.springframework.transaction.interceptor.TransactionInterceptor#invoke中將 createStock 中的方法加上事務能力

@Override
public Object invoke(final MethodInvocation invocation) throws Throwable {
   Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null);

   // Adapt to TransactionAspectSupport's invokeWithinTransaction...
   return invokeWithinTransaction(invocation.getMethod(), targetClass, new InvocationCallback() {
      @Override
      public Object proceedWithInvocation() throws Throwable {
         return invocation.proceed();
      }
   });
}

事務能力在invokeWithinTransaction中,程式碼如下:

//1 建立事務
TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);

Object retVal;
try {
   // 2 執行標註了@Transactional的方法
   retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
   // 處理異常
   completeTransactionAfterThrowing(txInfo, ex);
   throw ex;
}
finally {
   //3. 清除事務上下文資訊
   cleanupTransactionInfo(txInfo);
}
//4. 提交事務
commitTransactionAfterReturning(txInfo);

事務處理有三個核心步驟:

  1. 建立事務(並設定 autoCommit 設定為 false)

    org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin中會把 autoCommit 設定為 false。這個設定會對於下文 Seata 執行邏輯有影響,非常重要

    con.setAutoCommit(false);
  2. 執行標註了@Transactional 的方法

    需要關注的是以下三個 mapper 的方法呼叫,而這三個方法的執行就跟 Seata 的代理邏輯有關了,要梳理處理清楚 prepareStatement 在整個執行的上下文中的生命週期是怎樣,mybatis 中的使用邏輯自然是非常重要,必須理清楚。另外因為開啟了 Spring 的事務,所以需要注意到,這幾個 mybatis 操作是會使用同一個connection物件

  • seataProxyStockMapper.delete(skuId);//構建 beforeImage,afterImage 是空
  • seataProxyStockMapper.insert(stock);//構建 afterImage,beforeImage 是空
  • seataProxyStockMapper.getId();//查詢類操作,無資料變化不需要構建映象
  • 提交事務-commitTransactionAfterReturning

    這裡是重點了,第六部分中進行分析。

  • 五、mybatis 側的呼叫邏輯開始

    seataProxyStockMapper.delete(skuId)seataProxyStockMapper.insert(stock)都是對應與 mybatis 的程式碼邏輯org.apache.ibatis.executor.SimpleExecutor#doUpdate,在其中大家可看到三個重要且跟本篇問題密切相關的 JDBC 物件操作

    @Override
    public int doUpdate(MappedStatement ms, Object parameter) throws SQLException {
      Statement stmt = null;
      try {
        Configuration configuration = ms.getConfiguration();
        StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);
        stmt = prepareStatement(handler, ms.getStatementLog());// 1
        return handler.update(stmt);//2
      } finally {
        closeStatement(stmt);//3
      }
    }
    1. 建立prepareStatement
    • prepareStatement(handler, ms.getStatementLog());
  • 透過prepareStatement執行 sql
    • update(stmt);
  • 釋放prepareStatement
    • closeStatement(stmt);

    這三步是 JDBC 執行 SQL 的基本操作,本篇上下文重點關注這期間 prepareStatement的建立與釋放。

    5.1 建立 prepareStatement

    建立發生在 doUpdate中的prepareStatement方法內

    stmt = prepareStatement(handler, ms.getStatementLog()); //1

    原始碼在 org.apache.ibatis.executor.SimpleExecutor#prepareStatement,其中有 2 個關鍵操作

    1. 建立prepareStatement
    2. prepareStatement 設定引數,這便是引數的來源,也是本篇的關鍵之處
    private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
      Statement stmt;
      Connection connection = getConnection(statementLog);
      stmt = handler.prepare(connection, transaction.getTimeout());
      handler.parameterize(stmt);
      return stmt;
    }

    其中建立prepareStatement的程式碼是經由 io.seata.rm.datasource.AbstractConnectionProxy#prepareStatement後,新建一個TGroupPreparedStatement

    5.2 透過 prepareStatement 執行 sql

    因為 Spring 的事務處理中將 autoCommit 設定為了 false,所以這邊最終是執行了executeAutoCommitFalse方法,這是Seata AT 模式下的的關鍵方法,包含以下步驟:

    1. 構建 beforeImage
    2. 執行業務 sql(本篇出問題時是執行了 insert )
    3. 構建 afterImage
    4. 將 beforeImage 和 afterImage 構建成 undo_log
    protected T executeAutoCommitFalse(Object[] args) throws Exception {
        //...
        TableRecords beforeImage = beforeImage();
        T result = statementCallback.execute(statementProxy.getTargetStatement(), args);
        int updateCount = statementProxy.getUpdateCount();
        if (updateCount > 0) {
            TableRecords afterImage = afterImage(beforeImage);
            prepareUndoLog(beforeImage, afterImage);
        }
        return result;
    }

    另外 本問題發生時的 sql 是 insert 型別,所以對應的 executor 是 MySQLInsertExecutor

    1) beforeImage中如何使用prepareStatement

    MySQLInsertExecutorbeforeImage 構建時,在buildTableRecords中查詢記錄時,是建立了一個新的prepareStatement

    try (PreparedStatement ps = statementProxy.getConnection().prepareStatement(selectSQL)) {

    對應的堆疊情況如下:

    buildTableRecords:399, BaseTransactionalExecutor (io.seata.rm.datasource.exec)
    beforeImage:60, DeleteExecutor (io.seata.rm.datasource.exec)
    executeAutoCommitFalse:99, AbstractDMLBaseExecutor (io.seata.rm.datasource.exec)

    2) 執行 sql 時,使用的是 mybatis 的邏輯內發起構建的一個prepareStatement

    3) 構建 afterImage 對應BaseInsertExecutor#afterImage的原始碼

    protected TableRecords afterImage(TableRecords beforeImage) throws SQLException {
        //1. 獲取所有新插入記錄的主鍵值集合
        Map<String, List<Object>> pkValues = getPkValues();
        //2. 根據主鍵查詢剛插入的記錄,構建成 TableRecords
        TableRecords afterImage = buildTableRecords(pkValues);
        if (afterImage == null) {
            throw new SQLException("Failed to build after-image for insert");
        }
        return afterImage;
    }

    這裡是分 2 個步驟:

    1. 獲取所有新插入記錄的主鍵值集合

    • MySQLInsertExecutorafterImage 構建時,進入到MySQLInsertExecutor#getPkValuesByAuto中,正是報錯的發生地,所使用的prepareStatement 也是執行 mapper的insert方法時構建的prepareStatement,因這個prepareStatement剛剛執行了 insert 操作,裡邊還存有 insert 操作所構建的引數(錯誤的誘因),這些引數用於執行查詢 sql "SELECT LAST_INSERT_ID()" 時報錯。
      genKeys = statementProxy.getTargetStatement().executeQuery("SELECT LAST_INSERT_ID()");
  • 根據主鍵查詢剛插入的記錄

    • buildTableRecords方法中跟構建前映象一樣,是建立了新的PreparedStatement,並且透過try-with-resource 的方式保障這個PreparedStatement資源釋放。
      try (PreparedStatement ps = statementProxy.getConnection().prepareStatement(selectSQLJoin.toString())) {

    4) 將 beforeImage 和 afterImage 構建成 undo_log

    BaseTransactionalExecutor#prepareUndoLog中完成 undo_log 的處理,從下邊核心程式碼中可以看出 sqlUndoLog 是被 connectionProxy 物件的 appendUndoLog 方法處理,

    String lockKeys = buildLockKey(lockKeyRecords);
    if (null != lockKeys) {
        connectionProxy.appendLockKey(lockKeys);

        SQLUndoLog sqlUndoLog = buildUndoItem(beforeImage, afterImage);
        connectionProxy.appendUndoLog(sqlUndoLog);
    }

    appendUndoLog內部是將 undo_log 新增到了ConnectionContext中,處理邏輯是在io.seata.rm.datasource.ConnectionContext#appendUndoItem

    void appendUndoItem(SQLUndoLog sqlUndoLog) {
        sqlUndoItemsBuffer.computeIfAbsent(currentSavepoint, k -> new ArrayList<>()).add(sqlUndoLog);
    }

    原始碼可知,這個環節並沒有 insert undo_log 的操作,真實插入 undo_log 的邏輯是在io.seata.rm.datasource.undo.UndoLogManager#flushUndoLogs內,觸發時機是在io.seata.rm.datasource.ConnectionProxy#commit

    1. 插入 undo_log 的prepareStatement是哪個?

    • 1io.seata.rm.datasource.undo.mysql.MySQLUndoLogManager#insertUndoLog,是新建了一個prepareStatement
      try (PreparedStatement pst = conn.prepareStatement(INSERT_UNDO_LOG_SQL))
  • 插入 undo_log 的 connection 是哪個?

    • 從下邊原始碼中可知使用的是 ConnectionProxy 中的 connection,本篇的場景中是 Spring 事務中的第一個mapper所建立的 connection物件(整個 Spring 事務中都使用這一個connection物件)
      insertUndoLogWithNormal(xid, branchId, buildContext(parser.getName(), compressorType), undoLogContent, cp.getTargetConnection());

    5.3 釋放prepareStatement?No

    closeStatement(stmt)的程式碼是在 org.apache.ibatis.executor.BaseExecutor#closeStatement中,如下

       protected void closeStatement(Statement statement) {
         if (statement != null) {
           try {
             if (!statement.isClosed()) {
               statement.close();
             }
           } catch (SQLException e) {
             // ignore
           }
         }
       }

    closeStatement(...) 內會先判斷 statement 是否已關閉,若未關閉才關閉。不巧的是下邊TGroupPreparedStatement#isClosed程式碼中拋了異常:

    public boolean isClosed() throws SQLException {
       throw new SQLException("not support exception");
    }

    所以closeStatement中並沒有真實的關閉PreparedStatement,現在看來能關閉PreparedStatement的地方只能推測為,當Spring事務結束後,關閉connection的時候順帶把其建立的PreparedStatement也釋放掉了。接下來一起來看下。

    六、Spring 提交事務後,釋放 Connection 資源

    Spring 提交事務的方法 commitTransactionAfterReturning,其呼叫堆疊有點深,從下邊呼叫堆疊中可以看出,在commitTransactionAfterReturning方法中,最終會執行ConnectionProxy#close方法,從而把 connection 資源釋放掉。

    close:147, AbstractConnectionProxy (io.seata.rm.datasource)
    doCloseConnection:348, DataSourceUtils (org.springframework.jdbc.datasource)
    doReleaseConnection:335, DataSourceUtils (org.springframework.jdbc.datasource)
    releaseConnection:302, DataSourceUtils (org.springframework.jdbc.datasource)
    doCleanupAfterCompletion:370, DataSourceTransactionManager (org.springframework.jdbc.datasource)
    cleanupAfterCompletion:1021, AbstractPlatformTransactionManager (org.springframework.transaction.support)
    processCommit:815, AbstractPlatformTransactionManager (org.springframework.transaction.support)
    commit:734, AbstractPlatformTransactionManager (org.springframework.transaction.support)
    commitTransactionAfterReturning:521, TransactionAspectSupport (org.springframework.transaction.interceptor)
    invokeWithinTransaction:293, TransactionAspectSupport (org.springframework.transaction.interceptor)

    建立 Connection 的地方有setAutoCommit(false),而對應反向操作setAutoCommit(true)則在doCleanupAfterCompletion中,原始碼如下:

    protected void doCleanupAfterCompletion(Object transaction) {
        DataSourceTransactionManager.DataSourceTransactionObject txObject = (DataSourceTransactionManager.DataSourceTransactionObject)transaction;
        if (txObject.isNewConnectionHolder()) {
            TransactionSynchronizationManager.unbindResource(this.dataSource);
        }

        Connection con = txObject.getConnectionHolder().getConnection();

        try {
            if (txObject.isMustRestoreAutoCommit()) {
                //1.設定commit 為true
                con.setAutoCommit(true);
            }

            DataSourceUtils.resetConnectionAfterTransaction(con, txObject.getPreviousIsolationLevel());
        } catch (Throwable var5) {
            this.logger.debug("Could not reset JDBC Connection after transaction", var5);
        }

        if (txObject.isNewConnectionHolder()) {
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Releasing JDBC Connection [" + con + "] after transaction");
            }
            //2. 釋放連結
            DataSourceUtils.releaseConnection(con, this.dataSource);
        }

        txObject.getConnectionHolder().clear();
    }

    以上 doCleanupAfterCompletion 中有兩個重要事項

    1. con.setAutoCommit(true);

    2. DataSourceUtils.releaseConnection(con, this.dataSource);

      TGroupConnection#close 中會關閉所有的statement,程式碼如下:

      public void close() throws SQLException {
          ...
          // 關閉statement
          for (TGroupStatement stmt : openedStatements) {
           try {
              stmt.close(false);
           } catch (SQLException e) {
              exceptions.add(e);
           }
          }
          ...
          if (wBaseConnection != null && !wBaseConnection.isClosed()) {
             wBaseConnection.close();
          }

    七、小結

    在本篇案例的上下文中,connection 的建立與釋放 以及其建立的 statement 的建立以及釋放都梳理了。情況總結一下:

    1. @Transactional註解,開啟了 Spring 的事務
    2. 在此事務中建立了一個 Connection 物件
    3. 在多個 sql 執行的過程中,透過此 Connection 物件建立了多個PreparedStatement物件,有些PreparedStatement物件在使用後就立即釋放了。
    4. Spring事務結束的時候釋放connection,以及connection中剩餘的PreparedStatement物件

    從本次 Demo 上下文來看,對於報錯之處statementProxy.getTargetStatement().executeQuery("SELECT LAST_INSERT_ID()");來說以下兩種修復方案似乎都可以考慮:

    1. 執行SELECT LAST_INSERT_ID()時,新建一個PreparedStatement
    2. 若複用PreparedStatement,也可用clearParameters()方法將 Insert 時設定的三個引數清除掉

    後續會對 SELECT LAST_INSERT_ID() 做進一步的調研,並結合更多的場景驗證修復方案的可行性。

    以上兩種方案應該採用哪種,會有什麼弊端?歡迎讀者老師討論給出建議。

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

    相關文章