写在前面
网上很多的文章都是教科书式的说教,缺乏实用价值。这也是笔者想写此系列文章的初衷,希望把实际工作的实战经验分享给大家,帮助大家解决实际问题。后续的一系列文章都是笔者在实际工作遇到的问题,比较具有代表性,从实战的角度进行分析总结,希望能够给大家带来帮助。
关键字:Spring Boot、Mysql、Mybatis
注:出于保密的目的,对文章中出现的真实名称(包括类名、方法名、数据库名、表名、字段名等)进行了替换。
一、问题背景
因为这个问题发生的场景是比较容易描述的,所以整个项目背景就不再赘述了。项目中使用的技术基本就是Spring Boot框架、Mysql数据库、Mybatis作为OR Mapping工具,开发工具使用IntelliJ IDEA。
这是一个多人合作的项目,其中一人负责数据库基本操作CRUD的实现,其中有一个表table1中,有一个批量更新状态(字段名为status)的操作,对应的方法为Table1ServiceImpl实现类中的batchUpdateTable1Status(String ids, String status)。发现的问题是这个批量更新状态的方法在做批量更新时(传入多个id)并不生效,但在做单条更新时是正常的。
二、问题定位
先把相关的代码贴出来:
Table1Mapper.xml文件
<update id="batchUpdateTable1Status" >
update table1 set status = #{status}, update_time = NOW()
where id in (#{ids})
</update>
其他内容略…
Table1Dao.java文件
public interface Table1Dao {
Integer batchUpdateTable1Status(@Param("ids") String ids, @Param("status") String status);
// 其他内容略…
}
Table1Service.java文件
public interface Table1Service {
Integer batchUpdateTable1Status(String ids, String status);
// 其他内容略…
}
Table1ServiceImpl.java文件
public class Table1ServiceImpl {
@Autowired
private Table1Dao table1Dao;
@Override
public Integer batchUpdateTable1Status(String ids, String status) {
return table1Dao.batchUpdateTable1Status(ids,status);
}
// 其他内容略…
}
看起来似乎一切都很正常,殊不知却隐藏一个大大的bug。程序中的用法如下:
@Autowired
private Table1Service table1Service;
List<String> needToUpdate = new ArrayList<>();
for(String id : existsIds) {
needToUpdate.add(id);
}
String updateIds = StringUtils.join(needToUpdate, ",");
table1Service.batchUpdateTable1Status(updateIds, "1");
如果needToUpdate变量中只有一个id时,更新成功。如果needToUpdate变量中超过一个id时,则更新不成功,也没有任何报错。没有报错,也没有执行成功,真是奇了怪了!把Mybatis的sql语句打印出来看看,到底问题在哪里?这也不难,修改项目的配置文件(我们使用的是yml文件)如下,设置logger.level.com.ibatis=DEBUG,这样就可以输出sql信息了。
#log配置
logging:
path: /tmp/logs
config: classpath:logback-spring.xml
level:
com.ibatis: DEBUG
又把代码执行一遍,看看输出的sql信息是什么。
JDBC Connection [HikariProxyConnection@1495740989 wrapping com.mysql.jdbc.JDBC4Connection@1cec0e3d] will not be managed by Spring
==> Preparing: update table1 set status = ?, update_time = NOW() where id in (?)
==> Parameters: 1(String), 19052319311696852799,19052915401102966297,19052917301921635822,19053015191594083316(String)
2019-07-04 23:57:51.269 WARN 1376 --- [l-1 housekeeper] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Thread starvation or clock leap detected (housekeeper delta=8m18s941ms)
语句模版是update table1 set status = ?, update_time = NOW() where id in (?),两个参数为1和以逗号分隔的几个id值,分别替换语句模版中的两个?的位置,最后执行的语句应该是这个样子的吧:
update table1 set status = 1, update_time = NOW() where id in (19052319311696852799,19052915401102966297,19052917301921635822,19053015191594083316)
拿着这个自己攒的sql语句到Navicat里面手动执行一下,更新成功!觉得更加奇怪了,为什么程序里面执行不成功呢?
手动生成这条sql语句的时候是想当然了,此外,Mybatis这样的输出方式也存在一定的误导性。静下心来仔细一想,知道问题出在什么地方了。其实是传参的方式用错了,实际上Mybatis有两种传参的方式,一种是#{},另一种是${}。#{}传入的参数在sql中显示为字符串;${}传入的参数在sqL中直接显示为传入的值。更加明确一点,#{}传入值,sql解析时,参数是带引号的;而${}传入值,sql解析时,参数是不带引号的。所以改成如下的sql语句,则能够实现批量更新的效果。
update table1 set status = #{status}, update_time = NOW() where id in (${ids})
到此问题已经得到解决,但是作为一个合格的码农还是得保持点好奇心的。为什么Mybatis就不能把最终执行的完整sql语句打印出来呢?那样不更容易帮助开发者定位错误吗!给Mybatis一个差评!
三、扩展分析
我们认为最终生成的sql语句是这样的:
update table1 set status = 1, update_time = NOW() where id in (19052319311696852799,19052915401102966297,19052917301921635822,19053015191594083316)
而实际上是这样的(因为使用了#{}这种传参的方式):
update table1 set status = ‘1’, update_time = NOW() where id in (‘19052319311696852799,19052915401102966297,19052917301921635822,19053015191594083316’)
因为这两个参数都是String类型,正如mybatis打印出的log所描述的一样:
==> Parameters: 1(String), 19052319311696852799,19052915401102966297,19052917301921635822,19053015191594083316(String)
总有一种被误导的感觉,如果Mybatis能够输出最终执行的sql的话,可能更有助于发现问题。尝试把logging.level的值改成trace也没有输出最终的sql语句,和debug的输出没什么两样。看来这条路是走不通了,得想其他办法了。这个也难不倒一个老码农,先把Mybatis的源代码走马观花的过一遍,也许会有些启发。
首先发现了SqlSource这个接口,这个接口只有一个getBoundSql(Object parameterObject)方法,返回一个BoundSql对象。一个BoundSql对象,代表了一次sql语句的实际执行,而SqlSource对象的责任,就是根据传入的参数对象,动态计算出这个BoundSql。这个BoundSql里面存放的就是在console里面输出的如下部分,还不是最终的执行sql。
==> Preparing: update table1 set status = ?, update_time = NOW() where id in (?)
==> Parameters: 1(String), 19052319311696852799,19052915401102966297,19052917301921635822,19053015191594083316(String)
继续探索,又发现了org.apache.ibatis.mapping.MappedStatement这个类。MappedStatement类在Mybatis框架中用于表示XML文件中一个sql语句节点,即一个<select />、<update />或者<insert />标签。Mybatis框架在初始化阶段会对XML配置文件进行读取,将其中的sql语句节点对象化为一个个MappedStatement对象。猜想Mybatis最终执行sql的时候肯定要依靠这个类的,大体上应该可以从这个类下手了。
在这个方法处(table1Service.batchUpdateTable1Status)加断点,step into进去,一直跟踪到Mybatis的代码部分。先到DefaultSqlSession的update方法处,这个方法会首先获取MappedStatement,然后再执行这个MappedStatement,当执行到完
MappedStatement ms = this.configuration.getMappedStatement(statement)语句后,居然直接返回了,并没有进入到下一条语句。尝试了强制进入(force step into)也不没有用,如果那位大侠有更好的办法进入到this.executor.update方法的话,请赐教!
既然目前还找不到好的办法直接进入this.executor.update方法,那就用我的土办法暴力闯入了。选中update方法,右键go to->declaration,原来是Executor接口的方法,再点击代码行左侧的向下箭头(⬇)处会有两个实现类,如下图2所示:
如果你不确定会执行哪个实现方法,大不了都跳转过去加上断点。这里插一句,大赞一下IDEA,实在是太好用了,简直就是开发神器,如果你还在使用Eclipse的话,劝你赶紧放弃Eclipse,转投IDEA的怀抱,保你不后悔!
加上断点后,再继续执行的话,便顺利进入到了Executor.update方法的实现类中了。
最后跟踪到SimpleExecutor的doUpdate方法中,就是这里了,在这里就要执行最终的sql喽!猜测这条sql应该在stmt(Statement)这个变量里面,展开stmt变量,再展开h,里面有个statement变量。
点击statement变量右边的View就可以查看其全部内容:
到这里就豁然开朗了,跟前面预想的完全吻合。这里就能看到完整的最终要执行的sql语句了。所以问题的根本还是出在sql语句的写法上。
update table1 set status = #{status}, update_time = NOW() where id in (#{ids})
这里传参的用法有问题,不能用#{}这种方式,这种方式中字符串类型的变量会带着引号替换的。当然ids只有一个值时更新是没问题的,当ids有多个值时,既然没有语法错误,Mybatis就正常执行了,也不会有任何错误。只是逻辑上有问题而已,多个id以逗号连接起来的一个值在数据库中是肯定不存在的,所以不能完成批量更新的操作。
改为${}方式的传递参数后,Mybatis中可以看到如下的sql语句。
HikariProxyPreparedStatement@1390780895 wrapping com.mysql.jdbc.JDBC4PreparedStatement@1994b6f0: update table1 set status = '-1', update_time = NOW()
where id in (19053116010337350268,19052817391182722422,19052817391063053316)
尽管上面这条语句能够执行成功,实际上还是存在一些问题的,因为id字段在数据库中是varchar类型,以上这条语句mysql会做隐式自动转换,故能执行成功,但会存在效率问题,关于这点将会在后续文章中再详细讨论。
实际上,下面这条语句才是正规的方式。
HikariProxyPreparedStatement@1390780895 wrapping com.mysql.jdbc.JDBC4PreparedStatement@1994b6f0: update table1 set status = '-1', update_time = NOW()
where id in ('19053116010337350268','19052817391182722422','19052817391063053316')
为了达到以上目的,可以在Java代码中进行完善,在Java代码中构造参数时给每个id前后加上单引号('')。
needToUpdate.add("'" + id + "'");
四、总结
技术来不得半点马虎,凭空想象是没有任何根据的。所以想真正的提高,还是得有打破砂锅问到底的劲头才行!
另外,基础一定得扎实,还有就是实践性的学科,必须踩过坑才能更加了解问题的本质,纸上得来终觉浅啊!
2019年7月6日 于北京通州家中