某处业务执行一个save操作后,后续无任何set操作,竟然在insert后续对应的每一条数据有一个update语句;本文注重介绍排查思路
tips: 文章中并不是一步步读源码,而是从初学者找问题的角度去反找源码
0x00 前言
同事发现我们某处业务执行一个save操作后,后续无任何操作,竟然sql打印的先insert,后续对应的每一条有一个update语句;
本地开发建议打开SQL执行语句的日志打印,否则这种多余SQL等性能问题都不会发现;
复现(这儿模拟的只有一条,如果reads有100条,那么后续会增加100条 update SQL)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21//@Convert使用场景
//比如List<string>对象想转换为以逗号拼接的方式保存到数据库
//List<Obj>想通过json的方法来储存到数据库及其他场景;
(converter = ReadValueConverter.class)
(name = "read_values")
private List<ReadValue> readValues;
public class ReadValueConverter implements AttributeConverter<List<IntervalReadPo.ReadValue>, String> {
//略
}
public final static class ReadValue implements Serializable {
private BigDecimal readValue;
private String qualityMethod;
}

我们可以看到只是 save 一下,后续的flush忽略模拟普通事物transaction提交。后续并没有任何操作;理应来说不会update;
简略版优化方案,不可取
每一条update都会把所有字段更新一遍,完全没有必要,可以使用 @DynamicUpdate ,update语句只会更新对应变化的字段。
update interval_reads set read_values=? where id=?
0x01 问题排查
根据之经验及之前JPA最佳实践的文章,怀疑可能是Hibernate认为部分字段 isDirty(后续有变化)导致以为需要 update 更新;但是没有set,为什么会新增update,还是一步步来排查;
1、从业务逻辑代码,第一步先简化各种逻辑用test调用,并用排除法查找出什么地方会导致增加update语句;(缩小范围,这一步很重要)
经排查发现只要该对象调用 intervalReadPo.setReadValues 就会复现update重复的问题,而他的特殊之处就是一个使用了@Conver的entity属性;所以我们从这儿作为切入点来排查问题;
2、先 debug 尝试查找一下原因(这儿从不了解源码的思路来一步步排查)
我们既然是因为@Convert导致的,而@Convert我们可以想到在JPA从实体保存到数据库或者反向转换会调用我们的Convert代码。所以我们将断点打到我们Convert转换器中;启动项目
第一次进入断点,日志并没有打印我们核心的update,所以说明可能不相关,而且栈中并没有特殊的信息,只能看到一些 deepCopy(总结结论中这个知识点有涉及),仅仅是一些jpa的对象拷贝而已;
第二次 略,打印了insert 语句;继续下一步
第三次进入断点,打印了我们的update语句,并且在调用栈中发现了update相关的方法调用,那么就去查看为什么会调用到这个我们不需要的update,往前看可以看到JPA有一系列的 executeAction(增删改都是Action),我们定位到ActionQueue类继续排查为何会将update加入到执行队列中;
这个方法中由 EXECUTEABLE_LISTS_MAP 来存储需要执行的操作,也可以看到当前类中保存的有一个update语句。接着看一下该类中如下方法给这个MAP做增加action的操作;所以这儿打一个断点,排查到底何如增加了一个 UPDATE 的 Action;
private <T extends Executable & Comparable & Serializable> void addAction(Class<T> executableClass, T action) {
((ActionQueue.ListProvider)EXECUTABLE_LISTS_MAP.get(executableClass)).getOrInit(this).add(action);
}
同理第一次Insert Action 略;
在addAction中抓到了我们的UpdateAction,我们从栈往前分析,找到插入的地方,发现 onFlushEntity 方法中有核心的逻辑;
transaction提交时会执行flush,同步持久上下文环境,即将持久上下文环境的所有未保存实体的状态信息保存到数据库中;如:检测是否有已经save的对象字段变更,并且自动update
查看代码或者了解源码的都可以看到
isUpdateNecessary 这个Boolean导致的进入改分支。我们来看一下为什么会被识别成 dirty(需要更新的)字段,我们后续没有任何set,理应来说他们是一样的。虽然 hibernate调用save后默认是持久化状态,我们只需要调用entity的 setXXX() 方法,JPA就会默认帮我们update。通过 isDirty() 来检测是否字段有变化;
我们依次debug 往下查看JPA是如何判断一个对象是否被修改的(比较花时间,省略)
java
//this.isUpdateNecessary(event, mightBeDirty)
--> //this.dirtyCheck(event)
--> //dirtyProperties = persister.findDirty(xxx);
--> //TypeHelper.findDirty(xxx)
--> //boolean dirty = 其他简要条件 && properties[i].getType().isDirty(previousState[i], currentState[i], includeColumns[i], session))
//其中核心的isDirty()方法
后续到最核心的部分发现比较两个字段的对象是否相等来判断是否有变化(isDirty),我们debug发现,两个对象并不相等,我们的对象是JSON转换的ArrayList,而自动会用到ArrayList的equals方法。其equals方法是通过比较元素的equals和hashcode,不想等的原因找到了,因为我们没有重写对象的equals和hashCode方法所以导致的不相等;重写对应的方法重新debug发现update语句消失了;
这儿Convert是一个List
0x02 结论 & 解决方案
JPA 中调用 save 后会将对象deepCopy一次快照来做为持久化对象,后续 flush(事物提交)的时候hibernate需要检测出哪些持久化entity被修改过,flush的dirty check过程其实就是比对持久化entity和快照是否一致,不一致就去发udpate语句。而是否相等则是通过 Objects.equals 方法来比较;deepCopy属性的时候普通String等基本类型Class已经帮我们实现好了,而自定义的类则没有对应的方法来比较是否相等。导致JPA以为对象发生变化而执行更新操作;
将@Convert使用的对象,都加上equals和hashCode方法。可以使用lombok提供的注解(@Data或@EqualsAndHashCode)
0x03 引申问题
为什么我们引用其他的Entity,@OneToOne等关系的也是对象,我没有实现equals和hashcode为什么不会出现这个问题呢?
是因为deepCopy中有特殊的逻辑;会有一个特殊的EntityType类来拷贝,而拷贝仅仅是返回对象引用,并不是深拷贝一个对象,所以不会出现后续比较的问题;