關於mybatis 動態 sql 的一些陷阱:防止批量update,delete,select...

問題產生場景:
  
昨天支付中心發起退款回調時,引發了咱們這邊一個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

 

源碼:java

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) {


}


}
相關文章
相關標籤/搜索