Seata-AT模式+TDDL:構建Insert操作的後映象在執行SELECT LAST_INSERT_ID()時報錯
一、問題
程式碼環境在第三部分。
1)錯誤資訊:
java.sql.SQLException: Parameter index out of range (1 > number of parameters, which is 0).
2)錯誤現場:
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 處理思路
再來看一下錯誤現場截圖中可以看出,因為statementProxy.getGeneratedKeys();
執行報錯,才進入了executeQuery("SELECT LAST_INSERT_ID()")
導致了報錯,那麼:
getGeneratedKeys 是什麼情況?這個操作是否可以不報錯?(放到其他篇章補充) 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);
事務處理有三個核心步驟:
建立事務(並設定 autoCommit 設定為 false)
org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin
中會把 autoCommit 設定為 false。這個設定會對於下文 Seata 執行邏輯有影響,非常重要con.setAutoCommit(false);
執行標註了@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
}
}
建立 prepareStatement
prepareStatement(handler, ms.getStatementLog());
prepareStatement
執行 sqlupdate(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 個關鍵操作
建立 prepareStatement
給 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 模式下的的關鍵方法,包含以下步驟:
構建 beforeImage 執行業務 sql(本篇出問題時是執行了 insert ) 構建 afterImage 將 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
MySQLInsertExecutor
的beforeImage
構建時,在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 個步驟:
獲取所有新插入記錄的主鍵值集合
MySQLInsertExecutor
的afterImage
構建時,進入到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
中
插入 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 中有兩個重要事項
con.setAutoCommit(true);
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
的建立以及釋放都梳理了。情況總結一下:
@Transactional註解
,開啟了 Spring 的事務在此事務中建立了一個 Connection
物件在多個 sql 執行的過程中,透過此 Connection
物件建立了多個PreparedStatement
物件,有些PreparedStatement
物件在使用後就立即釋放了。Spring事務結束的時候釋放connection,以及connection中剩餘的 PreparedStatement
物件
從本次 Demo 上下文來看,對於報錯之處statementProxy.getTargetStatement().executeQuery("SELECT LAST_INSERT_ID()");
來說以下兩種修復方案似乎都可以考慮:
執行SELECT LAST_INSERT_ID()時,新建一個 PreparedStatement
若複用 PreparedStatement
,也可用clearParameters()
方法將 Insert 時設定的三個引數清除掉
後續會對 SELECT LAST_INSERT_ID() 做進一步的調研,並結合更多的場景驗證修復方案的可行性。
以上兩種方案應該採用哪種,會有什麼弊端?歡迎讀者老師討論給出建議。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024922/viewspace-2930886/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- yii執行phpunit時報錯PHP
- mysql last_insert_id() (轉載)MySqlAST
- mysql中last_insert_id()用法MySqlAST
- 使用docker構建jenkins映象並執行容器DockerJenkins
- eclipse 打war包後執行時報 cas.client 錯誤Eclipseclient
- 結合db link執行insert時報ORA-01732
- 執行dbca時報錯:Xlib:No protocol specifiedProtocol
- insert into select
- [Dockerfile構建映象]Docker
- DockerFile構建映象Docker
- docker構建映象Docker
- docker 構建自己的映象Docker
- 在 ABAP 層執行 Open SQL 的幕後操作 - 武俠版SQL
- Jboss 後臺執行模式模式
- 安裝RACDB後執行SQL/PLUS時報錯SP2-0667的解決辦法SQL
- Centos 6.4安裝rac,執行root.sh時報錯CentOS
- 基於ubuntu映象構建redis映象UbuntuRedis
- Docker 構建 kubectl 映象Docker
- Docker映象構建(五)Docker
- Packer構建openStack映象
- StarRocks 容器映象構建
- 使用Packer構建映象
- insert into select 批量載入出錯解決方案
- 如何讓 ABAP 報表在後臺作業的模式下執行模式
- mysqPoint型別查詢和插入操作:insert和select型別
- Docker映象構建原理解析(不裝docker也能構建映象)Docker
- scp 在後臺執行
- AIX6.1上打10G PATCH時報有Java程式在執行的錯誤解決AIJava
- 【RMAN】在備庫執行rman備份時報錯RMAN-06820 ORA-17629
- 【Maven】構建可執行JarMavenJAR
- 執行時報錯RuntimeError: expected device cpu but got device cuda:0ErrordevGo
- insert into 與 select into 的區別
- docker之手動構建新的映象Docker
- Seata-AT模式:MySQL自增ID的場景下推薦在 Mybatis 中使用 useGeneratedKeys模式MySqlMyBatis
- 使用dockerfile構建nginx映象DockerNginx
- 用dockerfile構建docker映象Docker
- Docker 映象構建之 DockerfileDocker
- JAVA專案映象構建Java