文档章节

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

程明东
 程明东
发布于 2012/04/05 14:14
字数 2146
阅读 18574
收藏 10
问题产生场景:
  
昨天支付中心发起退款回调时,引起了我们这边一个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) {


}


}

© 著作权归作者所有

共有 人打赏支持
程明东
粉丝 16
博文 10
码字总数 6495
作品 0
海淀
高级程序员
加载中

评论(8)

F
Forest10

引用来自“Forest10”的评论

https://github.com/Forest10/forest10-tool/blob/master/src/main/java/forest10/mybatis/interceptor/BatchModifyForbiddenInterceptor.java
使用注解方式而不是使用楼主那种更改原始 SQL. 可插拔性非常好.:bowtie:

引用来自“Forest10”的评论

https://github.com/Forest10/forest10-tool/blob/master/src/main/java/forest10/mybatis/interceptor/BatchModifyForbiddenInterceptor.java
https://github.com/Forest10/forest10-tool/blob/master/src/main/java/forest10/mybatis/interceptor/MybatisAopInterceptor.java
这个现在只实现了禁止SELECT_ALL
F
Forest10

引用来自“Forest10”的评论

https://github.com/Forest10/forest10-tool/blob/master/src/main/java/forest10/mybatis/interceptor/BatchModifyForbiddenInterceptor.java
使用注解方式而不是使用楼主那种更改原始 SQL. 可插拔性非常好.:bowtie:
https://github.com/Forest10/forest10-tool/blob/master/src/main/java/forest10/mybatis/interceptor/BatchModifyForbiddenInterceptor.java
F
Forest10
https://github.com/Forest10/forest10-tool/blob/master/src/main/java/forest10/mybatis/interceptor/BatchModifyForbiddenInterceptor.java
使用注解方式而不是使用楼主那种更改原始 SQL. 可插拔性非常好.:bowtie:
ran
ran
不错
limeng32
limeng32
我的想法是针对map里key容易写错的问题使用Enum类来解决比较好,所有和表相关的常量都定义在相应的Enum类中,map里的key值只能赋予XXXEnum.XXX.toString(),这样既不会写错也方便整体修改。
daoee
daoee
没想明白,为啥不直接去掉<if test="orderNo != null and orderNo != ''">
沙发迪
沙发迪
@Intercepts({
  @Signature(type = Executor.class,method = "update",args = {MappedStatement.class,Object.class}),
  @Signature(type = Executor.class,method = "query",args = {MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class})
})
在方法intercept中得到命令类型是查询,修改
    MappedStatement mst = (MappedStatement) invocation.getArgs()[0];
    String cType = mst.getSqlCommandType().name();
    System.out.println(cType);
么么mix
么么mix
最近正在写关于Mybatis的小项目,受教了~
Mybatis3.4.x技术内幕(十六):Mybatis之sqlFragment(可复用的sql片段)

Mybatis目前最新版本为3.4.0,因此,我也将我的项目由3.3.1替换为3.4.0。在上一篇博文中,详细分析了Mybatis在使用foreach循环进行批量insert,返回主键id列表时,如果使用BatchExecutor,那...

祖大俊
2016/06/05
998
0
史上最简单的 MyBatis 教程(四)

1 前言 在史上最简单的 MyBatis 教程(一、二、三)中,咱们已经初步体验了 MyBatis 框架的特性,尤其是其支持普通的 SQL 语句,但如果仔细阅读前三篇博文的示例,大家会发现一个问题,那就是...

qq_35246620
2017/02/03
0
0
Mybatis底层原理总结(一)

Mybatis底层原理总结(一) 本文适合对Mybatis有一定了解的。 1. Mybatis 读取XML配置文件后会将内容放在一个Configuration类中,Configuration类会存在整个Mybatis生命周期,以便重复读取。...

DemonsI
08/24
0
0
mybaits 动态SQL语句

-------------------------动态查询--------------------------------------- <select id="findAll" parameterType="map" resultMap="studentMap"> select * from students <where> <if tes......

李永china
2016/05/11
116
0
Mybatis学习(1)—— SQL映射

是什么? MyBatis是一款支持普通SQL查询、存储过程和高级映射的持久层框架。MyBatis消除了几乎所有的JDBC代码、参数的设置和结果集的检索。MyBatis可以使用简单的XML或注解用于参数配置和原始...

叶枫啦啦
07/12
0
0

没有更多内容

加载失败,请刷新页面

加载更多

elastic search+kibana 5.6.12安装指南

前提准备: 1,安装jdk, We recommend installing Java version 1.8.0_131 or later. 2, 设置文件最大打开数(使用命令ulimit -n查看) ulimit -n 65536 3, 创建用户elastic/用户组elastic gro...

PageYi
13分钟前
1
0
安装mongodb碰到error: unpacking of archive failed on file /etc/init.d/mongod;5bcec214: cpio: open如何解决

今用yum安装mongodb4.0.3发现一个错误,当用yum install 安装mongo-org 时除了mongodb-org-server 没有安装以外其他的都安装正确,重新安装mongodb-org-server 时报如下错误信息 在一篇老外 ...

chanking
15分钟前
1
0
O2OA:企业办公数字化转型的更佳选择

在全球都在积极探索由新一轮信息技术所引发的第四次工业革命时,一场激发企业内生动力的数字化运动在互联网企业和传统企业之间却呈现出两种截然不同的状态。   传统企业办公数字化不彻底仍...

超能之法师
18分钟前
1
0
基于SylixOS 对 Goahead 进行配置使用 OpenSSL

1. 编译并部署OpenSSL SylixOS支持OpenSSL,git地址为:http://git.sylixos.com/repo/openssl.git 获取OpenSSL工程源码后,导入RealEvo-IDE中编译,编译完成后生成动态库文件和openssl可执行...

Baiqq
20分钟前
1
0
nginx+tomcat均衡负载

一、安装好nginx环境,启动至少两个的tomcat服务; 此处tomcat访问地址为:http://192.168.106.128:1000/、http://192.168.106.128:1001/、http://192.168.106.128:1002/ 二、修改nginx配置文...

狼王黄师傅
22分钟前
2
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部