用错JPA Attribute Converters会万劫不复

简单介绍JPA Attribute Converters

老早之前,在JPA/Hibernate项目里尝试使用了mysql5.7的新特性json字段,个人感觉对于那些结构不稳定而且还啰里八嗦的字段用json应该会挺灵活的。问题在于ORM怎么映射呢?Google后给出的方案是JPA Attribute Converters。下面给出在User表中userSetting使用json类型的代码片段(随便臆想出来的代码):

// 定义entity
@Entity
public class User {
    @Column(columnDefinition = "json DEFAULT NULL")
    @Convert(converter = UserSettingConverter.class)
    private UserSetting userSetting;
    // getter() setter()...
}

// json字段映射的pojo
public class UserSetting {
    private String item;
    // getter() setter()...
}

// 自定义converter实现AttributeConverter接口双向转化的方法
@Converter
public class UserSettingConverter implements AttributeConverter<UserSetting, String> {

    // json to object
    @Override
    public UserSetting convertToEntityAttribute(String attribute) {
        if (attribute == null) {
            return null;
        }
        return JsonUtils.parse(attribute, UserSetting.class);
    }

    // object to json
    @Override
    public String convertToDatabaseColumn(UserSetting dbData) {
        return JsonUtils.toJson(dbData);
    }
}
问题早已埋好

正如这篇【史上被复制最多的StackOverflow Java代码段中包含一个Bug】说的那样,搜出来的答案不一定没有bug的,用converter却不看converter的官方文档,活该出bug。该项目用上面converter的写法运行了好久都没有被发现有啥问题,直到最近我开启了druid的spring监控(尽管spring监控有点小bug#2770,需要关闭spring.aop.auto才能统计正常),我发现有个批量处理的方法本来应该只涉及到几千行左右的数据更新,居然统计出来22万行,玩儿呢?一通log之后发现,只有那些用了json converter的entity会时不时地发update语句,尽管没有被修改过。网上看到有位仁兄跟我状况一模一样,里面有个人提到可能跟hibernate的dirty check有关,这引起了我的注意。根据Hibernate的开发者Vlad Mihalcea的这两篇【How do JPA and Hibernate define the AUTO flush mode】【How does AUTO flush strategy work in JPA and Hibernate】,JPA每次查询之前都会尝试flush,flush就意味要做dirty check。假如我那些用了converter的entity每次dirty check都被认为是脏的,被修改过的,确实就会出现成吨的update语句。

为什么dirty check会失败

这时候,我终于想起来去看converter的官方文档了。

文档关键部分

再看下我程序的日志,确实部署的时候就出现了文档中说的这个warning,只是一直没注意到!!!另外又翻到了写converter这块hibernate代码实现的开发者的一些讨论【Could not find matching type descriptor for requested Java class】。连蒙带猜地看n遍并且做了一系列的断点debug验证,综合起来,我总结一下这里面的前因后果:为了实现flush的时候hibernate能自动检测出哪些持久化entity被修改过,hibernate会通过deep copy(类似clone)对所有持久化的entity做一个快照,flush的dirty check过程其实就是比对持久化entity和快照是否一致,不一致就去发udpate语句。而dirty check的实现,hibernate对jdk已有的类型都有很好的支持,可是如果是你通过converter自定义的类,很抱歉你得自己去实现JavaTypeDescriptor并注册到JavaTypeDescriptorRegistry中,如果你没有做这一步,hibernate就只好先看下你这个类有没有实现Scerializable接口,如果实现了Hibernate就通过将entity和快照对象序列化成byte array的方式来比对是否一致,如果没有实现Hibernate就只能直接调用equals方法来比对了。我的例子显然进equals了,而没有重写equals的话,实际就是直接比对物理地址,显然持久化entity对象跟快照对象的物理地址是不可能一致的,所以就理所当然dirty check fail了,然后就每dirty check一次就update一次,22万就是这么来的。

解决方案
  1. 按照官网的推荐,实现JavaTypeDescriptor,并注册到JavaTypeDescriptorRegistry。由于官网没有给出例子,不太会写,看了其他JavaTypeDescriptor的实现又长又臭,而且看到里面有equals和hashcode方法,感觉跟方法3类似,于是我把它作为最后最后不得已的选择。

PS: 写这篇文章的时候才发现今年9月份Vlad Mihalcea的博客给出了json orm的最佳实践 ,理论上应该加个依赖就行,不用自己写了。

  1. 实现Scerializable接口,很多人都选择这个简单粗暴偷懒的方法。像上面的例子,只需要让UserSetting implements Scerializable就可以了。效率问题暂不考虑,相比22万,这点序列化的效率还不在考虑的范围。简单测试了这个方法是成功的。但是!!!可能是天妒懒人吧,应用在我真实的项目中,不知道为何,transaction内的一个局部变量,我确认没有被任何持久化对象引用,却也被要求要序列化,这么implement Scerializable岂不没完没了了,因为还有option 3所以我也没仔细去研究这个问题的根源。

  2. 重写equals和hashCode方法。既然不得不选择这个方法,还是有必要看一下,为什么光重写equals不行,非得还要再重写hashCode方法。又是一顿搜索和jdk文档,大概意思是jdk要求equals返回true的两个对象必须hashCode也一致,否则可能导致HashTable/Map等的contains方法失效,因为它们的结构是通过对象的hashCode散列来得到具体放置的位置的。了然,有理,于是我简单粗暴地写加了个BaseJsonEntity,让所有映射json的pojo去继承(again不讨论实现的效率):
    \color{red}{2019.12.12更新:}按照这篇How to Write an Equality Method in Java的说法,equals方法不能轻易重写,因为你写对的概率几乎为0,包括使用lombok生成的equals和hashCode方法也存在文章说的Pitfall #3: Defining equals in terms of mutable fields问题,所以还是要使用Vlad Mihalcea给出的方案

public class BaseJsonEntity {
    // 地址相等直接返回true,不等就比对json是否一致(原谅我这个实现其实挺笨拙的)
    @Override
    public boolean equals(Object obj) {
        if(super.equals(obj)) {
            return true;
        } else {
            if(obj != null && obj instanceof BaseJsonEntity) {
                String objJson = ((BaseJsonEntity)obj).toJson();
                return this.toJson().equals(objJson);
            }
        }
        return false;
    }

    // 转成json直接利用jdk在String类中重写的hashCode()方法,懒惰如初
    @Override
    public int hashCode() {
        return toJson().hashCode();
    }

    // 定义将对象自己转成json的私有方法
    private String toJson() {
        String json = JsonUtils.toJson(this);
        return json;
    }

}
还没完

改完发布测试,druid监控到的更新行数确实大幅下降了。你以为完事了?闲着蛋疼的我决定认真数一数我那个批量处理里面究竟应该更新多少行才是精确的。数完发现还真对不上,我的心哇凉哇凉的,求放过!这次现象是,我所有json entity的dirty check都正常了,除了一个。又是大半天的debug,我发现快照的对象确实比持久化entity对象打出来的json少了一丁点东西,过了好久我才想起来,这少的恰恰是我在实现AttributeConverter#convertToEntityAttribute(String attribute)方法的时候加的一点点额外处理,因为有个字段前端不需要,我偷懒直接在这个方法里面把这个字段去掉了。
这就奇怪了,deep copy出来的快照对象怎么会走converter的逻辑呢? debug了下源代码,发现deep copy的过程居然是把持久化对象的所有属性值全部转成数据库字段的形式,然后把这些字段当作刚从数据库查出来的样子,通过entity配置的映射来创建出entity实例对象(代码复用来说确实妙)。因此converter的双向转化的实现必须要注意一致性,除了json转obj,obj转json,不能有其他别的逻辑,一旦不一致就会导致快照deep copy出不一样的对象,进而导致dirty check失败,然后就又回到了万劫不复的疯狂update。

懒惰出Bug,Bug促进步。
Who knows what is right ! Just be myself !

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,843评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,538评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,187评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,264评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,289评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,231评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,116评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,945评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,367评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,581评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,754评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,458评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,068评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,692评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,842评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,797评论 2 369
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,654评论 2 354