我是石页兄,朋友不因远而疏,高山不隔友谊情;偶遇美羊羊,我们互相鼓励
欢迎关注微信公众号「架构染色」交流和学习
一、问题
代码环境在第三部分。
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
执行 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 个关键操作
- 创建
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() 做进一步的调研,并结合更多的场景验证修复方案的可行性。
以上两种方案应该采用哪种,会有什么弊端?欢迎读者老师讨论给出建议。
八、最后说一句
我是石页兄,如果这篇文章对您有帮助,或者有所启发的话,欢迎关注笔者的微信公众号【 架构染色 】进行交流和学习。您的支持是我坚持写作最大的动力。