关于mybatis 动态 sql 的一些陷阱:防止批量update,delete,select...

谁说胖子不能爱 提交于 2019-11-30 07:26:12
问题产生场景:
  
昨天支付中心发起退款回调时,引起了我们这边一个bug: 有两笔退款异常,支付中心发起第一笔异常的回调的时候就把我们这边两笔退款异常对应的订单的状态全部给修改 了。当支付中心对第二笔异常回调的时候回调程序发现订单状态已经改变发出了一个异常邮件,然后回调就终止 了,所以数据库呈一个不一致状态:订单状态已改变,但没有记录订单日志,也没有记录退款状态。然后大家就来寻找这个bug,过程挺有意思的。 

首先我们请DBA从订单号,订单Id和时间多个条件查数据库的日志,想找出是哪个系统发出的这个更新订单状态的log,最后没找到。 

后来从退款回调里发现下面的代码: 
checkUpdateResult(updateDAO.execute( 
	"order.updateStatus", 
         ImmutableMap.of("orderId", order.getId(),
	                 "updateTs", TsUtils.now(),
	                 "preStatus", currStatus.getStatus(),
                	 "currentStatus",nextStatus.getStatus()))
)

这是用于更新订单状态的代码,传入了参数 orderId, updateTs,preStatus对应的mybatis mapper: 

<update id="updateStatus" parameterType="java.util.HashMap"> 

      <![CDATA[ 

      update  

	  `orders` 

      set  

	  `status` = #{currentStatus}, 

	  update_ts = #{updateTs} 

      ]]> 

      <where> 

	  <if test="id != null and id != ''"> 

	      and id = #{id, jdbcType=INTEGER} 

	  </if> 

	  <if test="paymentNo != null and paymentNo != ''"> 

	      and order_no = (select order_no from payment where payment_no=#{paymentNo}) 

	  </if> 

	  <if test="orderNo != null and orderNo != ''"> 

	      and order_no = #{orderNo, jdbcType=VARCHAR} 

	  </if> 

	  <if test="preStatus != null and preStatus != ''"> 

	      and `status` = #{preStatus, jdbcType=INTEGER} 

	  </if> 

      </where> 

  </update>


很遗憾,mapper里不是用orderId,用的是id。导致 and id = #{id,jdbcType=INTERGER}这个where条件根本没加上去,最后结果就是把 所有status = preStatus的订单全部更新了,幸运的是这个preStatus是110(退款失败,当时只有两个订单)。 

后来我想了想针对这样的bug我们如何测 试 呢?如何防止呢? 

预防 
1.禁止使用map作为mybatis的 参数,都用对象,这样你在mapper里写的参数如果在这个对象里没有,mybatis是会报错的。

2.但是我们现在系统里存在大量使用map的情况,而且也挺好用的,这个不好弄.那么mybatis是否提供一种机制,即发现如果我传入的参数在mapper里 并没有使用的话即抛出异常?是否可以通过修改代码解决?

3.检查update所影响的行数,如果更新不止一条则抛出异常 事务 回滚(这个在有事务的时候有用,如果没事务抛出异常也能快速让人知道也不错)。实际上看看上面的代码已经有 一个checkUpdateResult: 

private void checkUpdateResult(int affectNum) throws RuntimeErrorException { 

      if (affectNum < 1) { 

      throw new RuntimeErrorException(null, "update fail! affectNum: " + affectNum); 

      } 

  } 

悲催的是这个checkUpdateResult只 检查了影响行数是否小于1,如果这里的检查 条件 是 affectNum == 1也能检查这个bug啊!!! 


测试 

测试的时候,不管QA测试还是单元测试我 们往 往关 注我们被测对象,比如测试某订单,我们就关注这个订单,对其他订单很少关注。所以测试方面貌似很难发现这样的bug。特别是QA测试方面,多人测试我们很难知道到底是谁影响的。 在单元测试上我们能发现这个bug, 但也要我们想到了这个case,还是挺困难的。           

我们的解决方案是针对3.0.6版本写了一个防止批量更新的插件。 另外参照该插件,还可以写一些防止delete,select无limit 条数限制的插件。 通过这些插件可以避免批量更新、delete操作以及无limit限制的select操作(防止查询整个表的所有记录,尤其是大表)。

用法:
(1)在MapperConfig.xml中定义插件
<plugins>
<plugin
interceptor=" com.qunar.base.mybatis.ext.interceptor .BatchUpdateForbiddenPlugin">
</plugin>
</plugins>
(2)在mapper文件中修改update的动态sql
在update语句的最后面添加了[presentColumn="orderNo"],表示解析后的where条件中必须带有orderNo。因为orderNo在业务中可以标识一条记录,因此where条件带有orderNo的话,就可以保证是单条更新,
而不是批量更新。

实例:不一样的地方是添加了[presentColumn="orderNo"]

<update id="updateStatus" parameterType="java.util.HashMap">
<![CDATA[
update
ibtest.orders
set
status = #{currentStatus}
]]>
<where>
<if test="orderNo != null and orderNo != ''">
and orderNo = #{orderNo, jdbcType=VARCHAR}
</if>
<if test="preStatus != null and preStatus != ''">
and status = #{preStatus, jdbcType=INTEGER}
</if>
</where>
[presentColumn="orderNo"]
</update>

异常:
当解析后的update语句如果是批量更新的sql时,会直接抛异常:
org.apache.ibatis.exceptions.PersistenceException:
 ### Cause: java.lang.IllegalArgumentException: 
 该update语句:update    ibtest.orders   set  status = ?    WHERE status = ?
 是批量更新sql,不允许执行。因为它的的where条件中未包含能表示主键的字段orderNo,所以会导致批量更新。
 at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:8)
 at org.apache.ibatis.session.defaults.DefaultSqlSession.update(DefaultSqlSession.java:124)
 at org.apache.ibatis.submitted.dynsql.nullparameter.DynSqlOrderTest.testDynamicSelectWithTypeHandler(DynSqlOrderTest.java:66)
 at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
 at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:39)
 at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:25)
 at java.lang.reflect.Method.invoke(Method.java:597)
 at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:44)
 at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:15)
 at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:41)
 at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:20)
 at org.junit.runners.BlockJUnit4ClassRunner.runNotIgnored(BlockJUnit4ClassRunner.java:79)
 at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:71)
 at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:49)
 at org.junit.runners.ParentRunner$3.run(ParentRunner.java:193)
 at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:52)
 at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:191)
 at org.junit.runners.ParentRunner.access$000(ParentRunner.java:42)
 at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:184)
 at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:28)
 at org.junit.runners.ParentRunner.run(ParentRunner.java:236)
 at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.run(JUnit4TestReference.java:50)
 at org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:38)
 at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:467)
 at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:683)
 at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:390)
 at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:197)
 Caused by: java.lang.IllegalArgumentException: 
 该update语句:update  ibtest.orders  set    status = ?  WHERE status = ?
 是批量更新sql,不允许执行。因为它的的where条件中未包含能表示主键的字段orderNo,所以会导致批量更新。
 at org.apache.ibatis.submitted.dynsql.nullparameter.BatchUpdateForbiddenPlugin.doCheckAndResetSQL(BatchUpdateForbiddenPlugin.java:132)
 at org.apache.ibatis.submitted.dynsql.nullparameter.BatchUpdateForbiddenPlugin.checkAndResetSQL(BatchUpdateForbiddenPlugin.java:103)
 at org.apache.ibatis.submitted.dynsql.nullparameter.BatchUpdateForbiddenPlugin.intercept(BatchUpdateForbiddenPlugin.java:65)
 at org.apache.ibatis.plugin.Plugin.invoke(Plugin.java:42)
 at $Proxy7.update(Unknown Source)
 at org.apache.ibatis.session.defaults.DefaultSqlSession.update(DefaultSqlSession.java:122)
 ... 25 more

 

源码:

package com.qunar.base.mybatis.ext.interceptor ;


import java.util.Properties;


import org.apache.commons.lang.StringUtils;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.MappedStatement.Builder;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Plugin;
import org.apache.ibatis.plugin.Signature;


/**
 * <p>
 * 禁止批量更新的插件,只允许更新单条记录
 * </p>
 * 
 * <pre>
 * mapper示例:必须在update语句的最后面定义[presentColumn="orderNo"],其中orderNo是能标识orders表的主键(逻辑主键或者业务主键)
 * <update id="updateOrder" parameterType="java.util.HashMap">
 *         <![CDATA[
 *         update 
 *             orders
 *         set 
 *             status = #{currentStatus}
 *         ]]>
 * <where>
 * <if test="orderNo != null and orderNo != ''">
 * and orderNo = #{orderNo, jdbcType=VARCHAR}
 * </if>
 * <if test="preStatus != null and preStatus != ''">
 * and status = #{preStatus, jdbcType=INTEGER}
 * </if>
 * </where>
 * [presentColumn="orderNo"]
 * </update>
 * </pre>
 * 
 * @author yi.chen@qunar.com
 * @version 0.0.1
 * @createTime 2012-04-03 18:25
 */
@Intercepts({ @Signature(type = Executor.class, method = "update", args = {
MappedStatement.class, Object.class }) })
public class BatchUpdateForbiddenPlugin implements Interceptor {


private final static String presentColumnTag = "presentColumn";// 定义where条件中必须出现的字段


/**
* <p>
* 只对update语句进行拦截
* </p>
* 
* @see org.apache.ibatis.plugin.Interceptor#intercept(org.apache.ibatis.plugin
*      .Invocation)
*/
public Object intercept(Invocation invocation) throws Throwable {
// 只拦截update
if (isUpdateMethod(invocation)) {
invocation.getArgs()[0] = checkAndResetSQL(invocation);
}
return invocation.proceed();
}


/**
* <p>
* 判断该操作是否是update操作
* </p>
* 
* @param invocation
* @return 是否是update操作
*/
private boolean isUpdateMethod(Invocation invocation) {
if (invocation.getArgs()[0] instanceof MappedStatement) {
MappedStatement mappedStatement = (MappedStatement) invocation
.getArgs()[0];
return SqlCommandType.UPDATE.equals(mappedStatement
.getSqlCommandType());
}
return false;
}


/**
* <p>
* 检查update语句中是否定义了presentColumn,并且删除presentColumn后重新设置update语句
* </p>
* 
* @param invocation
*            invocation实例
* @return MappedStatement 返回删除presentColumn之后的MappedStatement实例
*/
private Object checkAndResetSQL(Invocation invocation) {
MappedStatement mappedStatement = (MappedStatement) invocation
.getArgs()[0];
Object parameter = invocation.getArgs()[1];
mappedStatement.getSqlSource().getBoundSql(parameter);
BoundSql boundSql = mappedStatement.getBoundSql(parameter);
String resetSql = doCheckAndResetSQL(boundSql.getSql());
return getMappedStatement(mappedStatement, boundSql, resetSql);
}


/**
* <p>
* 检查update语句中是否定义了presentColumn,并且删除presentColumn后重新设置update语句
* </p>
* 
* @param sql
*            mapper中定义的sql语句(带有presentColumn的定义)
* @return 删除presentColumn之后的sql
*/
private String doCheckAndResetSQL(String sql) {
if (sql.indexOf(presentColumnTag) > 0) {
// presentColumn的定义是否在sql的最后面
if (sql.indexOf("]") + 1 == sql.length()) {
int startIndex = sql.indexOf("[");
int endIndex = sql.indexOf("]");
String presentColumnText = sql.substring(startIndex,
endIndex + 1);// [presentColumn="orderNo"]
// 剔除标记逻辑主键相关内容之后的sql,该sql才是真正执行update的sql语句
sql = StringUtils.replace(sql, presentColumnText, "");
String[] subSqls = sql.toLowerCase().split("where");
String[] keyWords = presentColumnText.split("\"");
// 获取主键,比如orderNo
String keyWord = keyWords[1];
// 判断是否带有where条件并且在where条件中是否存在主键keyWord
if (subSqls.length == 2 && subSqls[1].indexOf(keyWord) == -1) {
throw new IllegalArgumentException("该update语句:" + sql
+ "是批量更新sql,不允许执行。因为它的的where条件中未包含能表示主键的字段"
+ keyWord + ",所以会导致批量更新。");
}
} else {
throw new IllegalArgumentException("[" + presentColumnTag
+ "=\"xxx\"\"]必须定义在update语句的最后面.");
}
} else {
throw new IllegalArgumentException("在mapper文件中定义的update语句必须包含"
+ presentColumnTag + ",它用于定义该sql的主键(逻辑主键或者业务主键),比如id");
}
return sql;
}


/**
* <p>
* 通过验证关键字段不能为空之后的sql重新构建mappedStatement
* </p>
* 
* @param mappedStatement
*            重新构造sql之前的mappedStatement实例
* @param boundSql
*            重新构造sql之前的boundSql实例
* @param resetSql
*            验证关键字段不能为空之后的sql
* @return 重新构造之后的mappedStatement实例
*/
private Object getMappedStatement(MappedStatement mappedStatement,
BoundSql boundSql, String resetSql) {
final BoundSql newBoundSql = new BoundSql(
mappedStatement.getConfiguration(), resetSql,
boundSql.getParameterMappings(), boundSql.getParameterObject());


Builder builder = new MappedStatement.Builder(
mappedStatement.getConfiguration(), mappedStatement.getId(),
new SqlSource() {
public BoundSql getBoundSql(Object parameterObject) {
return newBoundSql;
}
}, mappedStatement.getSqlCommandType());


builder.cache(mappedStatement.getCache());
builder.fetchSize(mappedStatement.getFetchSize());
builder.flushCacheRequired(mappedStatement.isFlushCacheRequired());
builder.keyGenerator(mappedStatement.getKeyGenerator());
builder.keyProperty(mappedStatement.getKeyProperty());
builder.resource(mappedStatement.getResource());
builder.resultMaps(mappedStatement.getResultMaps());
builder.resultSetType(mappedStatement.getResultSetType());
builder.statementType(mappedStatement.getStatementType());
builder.timeout(mappedStatement.getTimeout());
builder.useCache(mappedStatement.isUseCache());
return builder.build();
}


public Object plugin(Object target) {
return Plugin.wrap(target, this);
}


public void setProperties(Properties properties) {


}


}

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!