博主:爱码叔
个人博客站点: icodebook
公众号:爱码叔漫画软件设计(搜:爱码叔)
专注于软件设计与架构、技术管理。擅长用通俗易懂的语言讲解技术。对技术管理工作有自己的一定见解。文章会第一时间首发在个站上,欢迎大家关注访问!
前言
近期的项目中使用Spring Data JPA。JPA带来很大的便捷,但它内部映射关系及持久化机制如果理解不到位会出现很多问题。不同的配置将会产生不同的执行过程。如果不了解其运行机制,很容易在一个问题上摸索很久,找不到答案。近期碰到一个问题,在一对多关系中,先进行了一方的查询,然后找到需要删除多方数据,做删除操作。看似简单的删除,但JPA在不同的onToMany配置下,却呈现出不同的执行结果。正好借此机会做了oneToMany不同配置的实验,在此做个记录。也希望通过实验,找到不同场景下最佳的配置方式。
进入正文前,我先啰嗦一下级联操作。一方在oneToMany上设置的级联保存和更新很好理解,多方会随着一方进行保存和更新。但是级联删除其实只是指一方删除时会把关联的多方数据全部删除,并不能删除一方维护的多方list中remove掉的数据。所以本文所讨论的实验和是否设置级联删除是没有关系的。
本文基于实验,我们先设定有如下对象,User为一方,ContactInfo为多方。每个user有多个contactInfo。
所做的操作是先查询User,然后对关联的ContactInfo做增删改。
public class User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String userName;
private String password;
@Fetch(FetchMode.SUBSELECT)
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private List<ContactInfo> contactInfos = new ArrayList<>();
}
public class ContactInfo {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String phoneNumber;
private String address;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
@JsonIgnore
private User user;
}
一对多关系,通过@onToMany注解实现,此注解有个属性mappedBy,这个属性默认为空(上面示例代码未设置,取默认值),代表一方要维护关系。如果mappedBy设置为一方对象的值,如mappedBy = "user",代表一方放弃维护关系,具体表现就是在插入或者删除操作的时候,一方不会去update多方的外键。这在后面的实验中会有所体现。
在讲解实验前,为了照顾没时间看完全文的读者,我先给出最终的结论:一方应放弃维护关系,由多方自行维护。这适用于绝大多数的场景。下文会详细描述整个实验过程以及如何得出的结论。
我们先看上面示例代码这种配置(不设置mappedBy),也就是一方不放弃维护关系的实验。
一方不放弃维护关系
关系配置代码
User类
@Fetch(FetchMode.SUBSELECT)
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private List<ContactInfo> contactInfos = new ArrayList<>();
ContactInfo类
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
@JsonIgnore
private User user;
实验如下
1、多方新增
持久化代码:
User user=userRepository.findById(1L).get();
user.getContactInfos().add(ContactInfo.builder()
.address("朝阳望京街道")
.phoneNumber("18612938250").build());
userRepository.save(user);
JPA执行过程:
1、先插入一条userId为空的contactInfo(由于未设置user)
insert into contact_info (address, phone_number, user_id) values (?, ?, ?)
2、然后更新userId
update contact_info set user_id=? where id=?
分析:
步骤1的insert操作是一方级联persist触发的操作。步骤2是因为一方还要维护外键,所以会对多方新增的数据update外键。
问题:
如果数据库设置了外键不能为空,那么步骤1无法执行。为了避免这个问题,可以在构造ContactInfo的时候把user对象设置进来。
2、多方更新:
持久化代码:
User user=userRepository.findById(1L).get();
user.getContactInfos().get(0).setPhoneNumber("88888888");
userRepository.save(user);
JPA执行过程:
1、直接根据多方主键进行更新
update contact_info set address=?, phone_number=?, user_id=? where id=?
分析:
因为设置了级联update,所以save user的时候会update多方contactInfo
3、多方删除:
A)仅从一方的list中remove
持久化代码:
User user=userRepository.findById(1L).get();
ContactInfo deletedContact = user.getContactInfos().get(1);
user.getContactInfos().remove(deletedContact);
userRepository.save(user);
JPA执行过程:
只是把deletedContact的user_Id更新为null,相当于断开了关系连接。如果您的表设计外键不能为空,则数据库报错。
update contact_info set user_id=null where user_id=? and id=?
分析:
所以从list中移除deletedContact,意味着user和此条contactInfo的关系断开了。又因为一方没有放弃关系的维护,这个操作会触发被remove掉的deletedContact的外键userId被置空。
此时去掉userRepository.save(user),什么都不会发生。这好像是废话,不过结合下面的实验对比来看,是有不同效果的。
问题:
并没有删除掉deletedContact数据,只是外键被置空。如果一方和多方是聚合关系,并且不想真正删除多方数据(多方数据可以和别的一方数据再次关联),那么适用这种方式。但如果是组合关系,那么不存在多方和一方再次关联的情况,是不适用这种方式的。
另外数据库也存在如果设置外键不能为空,不能更新的问题。
B)一方list中remove,并且多方显示delete
持久化代码:
User user=userRepository.findById(1L).get();
ContactInfo deletedContact = user.getContactInfos().get(1);
user.getContactInfos().remove(deletedContact);
contactInfoRepository.delete(deletedContact);
userRepository.save(user);
JPA执行过程:
1、remove操作把此条记录的user_id更新为null。
update contact_info set user_id=null where user_id=? and id=?
2、显式delete方法彻底删除多方的数据
delete from contact_info where id=?
分析:
1、更新外键为空,这是因为一方要维护关系。
2、删除多方数据,是因为显示调用了多方的delete方法。
如果我们想彻底删除掉多方的数据,这里其实做了一次无用的更新外键为空的操作。这个操作不但无用,而切一旦设置了外键不能为空,还会导致sql执行报错!
因此想彻底删除多方时,不要用这种方式(即一方不放弃维护关系)!
在这个实验中,我还做了个小测试,我把userRepository.save(user)可以去掉。发现程序正确执行,并且和去掉前的结果一样。我推断是因为此时持久化操作从多方delete发出,但是外键维护关系一方未放弃,还是会执行update的操作。
C)只在多方delete
持久化代码:
User user=userRepository.findById(1L).get();
ContactInfo deletedContact = user.getContactInfos().get(1);
contactInfoRepository.delete(deletedContact);
userRepository.save(user);
JPA执行过程:
什么都没发生!
分析:
由于先进行了查询,所以jpa认为被删除的contactInfo数据和user的关系还在。直接删除contactInfo无效。必须先从一方持有的list中remove掉才行。
一方不放弃维护关系实验结论:
由于双方都维护外键关系,一方维护关系体现在对多方外键的更新上。而remove操作,只是断开关联。但不会删除多方数据。remove之后,多方显式调用delete操作,多方才会被删除。
在这种配置下,插入和删除,都会多执行一条update多方外键的sql,很多情况下是完全没必要的。而且如果数据库外键如果不能为空会报错。
适用场景:
1、多方的外键可以为空。也就是说多方和一方的关系是聚合,允许多方不关联一方。
2、只想update多方外键为空,而不想彻底删除多方数据。也就是3-A)的场景。
不适用场景:
1、想彻底删除多方数据,而且多方外键不能为空
一方放弃维护关系
关系配置代码
User
@Fetch(FetchMode.SUBSELECT)
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY, mappedBy = "user")
private List<ContactInfo> contactInfos = new ArrayList<>();
注:User中加上了mappedBy,代表user放弃维护外键关系
1、多方新增
A)没有给contactInfo设置user
持久化代码:
User user=userRepository.findById(1L).get();
user.getContactInfos().add(ContactInfo.builder()
.address("朝阳望京街道")
.phoneNumber("18612938250").build());
userRepository.save(user);
JPA执行过程:
只会新增一条userId为空的contactInfo
insert into contact_info (address, phone_number, user_id) values (?, ?, ?)
分析:
由于一方放弃维护关系,那么不会有update外键的操作。而由于设置了级联persist,所以多方数据会级联插入。但是导致插入的多方数据没有外键。如果数据库做了限制则会报错。
这种方式是错误的方式,即使成功插入也没有外键值。插入的数据和代码表述的含义不一致。
B)contactInfo设置user
持久化代码:
User user=userRepository.findById(1L).get();
user.getContactInfos().add(ContactInfo.builder()
.address("朝阳望京街道")
.phoneNumber("18612938250")
.user(user).build());
userRepository.save(user);
JPA执行过程:
新增contactInfo,user_id正常
insert into contact_info (address, phone_number, user_id) values (?, ?, ?)
分析:
1、由于多方放弃维护多方外键,所以新增的时候不会去更新外键。
2、但由于级联新增的设置,所以还是会插入多方数据。
3、多方需手动设置外键的关联对象,插入时外键才会有值。
这是一方放弃关系维护时,正确的多方插入姿势!!别忘了给插入的多方数据设置关联的一方对象!
2、多方更新
持久化代码:
User user=userRepository.findById(1L).get();
user.getContactInfos().get(0).setPhoneNumber("88888888");
userRepository.save(user);
JPA执行过程:
直接根据多方主键进行更新。和一方未放弃维护关系时一致
update contact_info set address=?, phone_number=?, user_id=? where id=?
分析:
由于更新前,先进行了查询,并且配置了双向关联,所以被更新的contactInfo数据是有关联user的,因此更新正常。
3、多方删除
A)仅从一方的list中remove
User user=userRepository.findById(1L).get();
ContactInfo deletedContact = user.getContactInfos().get(1);
user.getContactInfos().remove(deletedContact);
userRepository.save(user);
JPA执行过程:
什么都没有发生
分析:
remove操作只是使关系断开。但由于一方放弃外键关系维护,所以不会更新多方外键。而由于没有显式delete多方,所以也不会删除contactInfo数据。这种删除方式显然是错误的。
B)仅在多方delete
User user=userRepository.findById(1L).get();
ContactInfo deletedContact = user.getContactInfos().get(1);
contactInfoRepository.delete(deletedContact);
userRepository.save(user);
JPA执行过程:
什么都没有发生
分析:
由于先进行了查询,所以jpa认为被删除的contactInfo和user的关系还在。直接显式删除contactInfo无效。这种删除方式也是错误的。
C)从一方的list中remove,并且多方显式执行delete
User user=userRepository.findById(1L).get();
ContactInfo deletedContact = user.getContactInfos().get(1);
user.getContactInfos().remove(deletedContact);
contactInfoRepository.delete(deletedContact);
userRepository.save(user);
JPA执行过程:
根据主键直接删除掉contactInfo
delete from contact_info where id=?
结论:由于一方放弃了外键关系所以维护,所以remove的时候,一方不会去更新多方外键为null。在remove后关系断开,多方显式调用delete,可以删除掉contactInfo。
这是一方放弃关系维护时,正确的多方删除姿势!!别忘了先要在一方维护的多方list中remove掉删除数据,然后多方显式调用delete。
另外,去掉userRepository.save(user),删除操作也是可以正常被触发的。
实验总结
我先用表格的方式呈现实验结果:
一方不放弃维护关系 | 一方放弃维护关系 | 不放弃时正确操作 | 放弃时正确操作 | 结论 | |
多方新增 | 1、插入多方数据 2、更新主键 | 1、插入多方数据 | 如果数据库不允许多方外键为空,需要在多方设置好一方对象 | 1、多方设置一方对象 2、一方save | 建议采用一方放弃方式,避免插入时执行两条sql |
多方更新 | 直接根据多方主键进行更新 | 直接根据多方主键进行更新。 | 一方save | 一方save | 无区别 |
多方删除 | A) 更新多方外键为空 B) 1、更新多方外键为空, 2、删除多方数据 | 直接删除多方数据 | A)只从一方的list中remove多方 B)一方list中remove,并显式删除多方 | 一方list中remove,并显式删除多方 | 需要彻底删除多方数据时,建议一方放弃的方式。 如果不想删除多方,只想去掉外键,只能采用一方不放弃的方式 |
从上面总结可以看出,绝大多数场景下,应该采取一方放弃维护关系的方式。这避免了插入和删除时执行两条sql的问题,而且也不会因为数据库设置了外键字段不能为空,导致update的sql报错。新增时候,多方自己设置外键,一条insert语句搞定。删除时候也是一条delete语句搞定,效率更高。
只有在一方和多方是聚合关系,并且不想彻底删除多方的场景下,一方不放弃维护关系的方式才有用武之地。
其实看到最后,我们可以得出这样的结论:
一方设置mappedBy,放弃关系维护。这适用于绝大多数场景。
正确的多方新增方式:
手动在多方对象设置一方对象
正确的多方删除方式:
1、从一方维护的多方list中remove,
2、显式delete多方对象。