前言
JPA要用好,要遵循最佳实践,否则不如不用。
比起半自动化的MyBatis,全自动化的JPA开发效率更高,但由于底层封装较多,较为复杂,JPA在解放开发人员生产力的同时,也存在一些坑,有些坑在线上环境可能会给您造成血的教训。。。
好在JPA也提供了一些方式,可以绕开这些坑,因此总结JPA的最佳实践十分有必要。
— 整体使用原则 —
简单的增删改查操作:优先考虑用JPA自带的Repository API
说明:
如果是根据主键id查询,则直接使用JPA内置的findById方法,如:
User user = userDAO.findById(id).get();
如果是要根据主键id之外属性的固定查询,则一般在对应的Repository类里定义findByXxx方法即可,如:
List findByAddress(String address);
灵活复杂的where、嵌套子查询、批量更新操作:推荐使用querydsl实现
说明:
在querydsl出现之前,相比MyBatis,JPA在如下2方面存在明显的不足:
灵活的where
嵌套子查询
而querydsl的出现给JPA加上了一对翅膀,很好的弥补了JPA在如上2方面的劣势,让JPA的能力又上了一个新台阶。
出于方便集中审核sql的需要:建议所有自定义的db操作集中放在同一个包下
说明:
很多人在使用JPA时,除了在repository类里会自定义一些db操作外,也会在上层的service、manager里自定义一些db操作(如JPAQuery),这造成了db操作分散在多处,难以集中管理,非常不方便定期审核这些db操作。
如果该db操作并未使用Repository API(如JPAQuery),则建议将之单独抽离到一个类里,打上@Component注解,跟Repository类放在同一个包下,以方便开发人员review以及DBA做sql审核。
— 保存操作最佳实践—
这里的保存操作,是指实体的新增和更新这2种操作。
persist方法的最佳实践
适用场景:
persist只会新增,不会更新,且不会select,只会insert,即与DB只有一次交互,适用于并发要求较高的场景。
使用限制:
实际DB里可以将主键id设置为自增的,但对应实体的主键id属性不可配置@GeneratedValue(strategy = GenerationType.IDENTITY)
必须要在事务内执行该方法,否则会报错
不会返回所保存实体的主键id,因此如果希望拿到保存后的id,则该方法不适用
纯新增操作的最佳实践
1)如果并发或吞吐量较小,则优先考虑使用save方法。因为persist只能新增,不能更新,而save两者皆可,也更方便。
2)如果并发较大,且满足前面提到的persist的使用限制条件,则建议优先考虑使用persist方法进行新增操作。
3)如果并发较大,但不满足上面persist的使用限制条件,则建议在@Query注解里定义hql或native sql来实现
4)如果吞吐量较大,则建议自己扩展实现批量插入(比如自己拼接insert sql,一次发送给DB),目前JPA暂时未提供批量插入的API。虽然其save方法可接受一批数据,但实际执行时时逐条插入的,是个伪批量。
只保存想要保存的字段
建议对实体类都配置@DynamicUpdate、@DynamicInsert注解,以保证JPA执行时,只会保存代码里显式set的value,因为JPA的save默认会更新/插入所有字段,假如某字段没有被显式set,则该字段最终会被JPA尝试以null保存到DB,这会带来一些不可预估的负面影响,比如误覆盖原值、触发DB字段的非null约束报错等
save方法的使用限制
JPA内置的save方法在执行时,都会先逐笔查询一次db,再发给DB进行数据持久化。即使是一批数据进行save时,依旧是逐条发给DB,因此在并发量或吞吐量较大时,性能较低,此时建议直接使用如下3种方式之一来新增或更新数据:
若是更新,则优先考虑querydsl,因为querydsl也提供了update的支持
若为新增,则考虑在@Query注解里定义hql/native sql或自己扩展实现批量新增
新增操作需确定主键id为空
尤其是采用复制方式(如BeanUtils.copyProperties())来生成新的对象实例,插入到DB时,要特别小心,千万要记得清空掉原主键id,即:将新生成的对象实例的主键id显式置为null。否则在执行JPA的save方法时,会被当做对原对象实例的更新(update),而不是新增(insert),导致最终直接更新原纪录了,而非插入一条新纪录。
save后不要对实体有任何的set操作
因为事务方法内,save后的set会连带持久化到DB,也就是说,在对某个实例对象调用save方法后,千万不要对该实例对象的属性做任何的改变,否则这些你觉得仅仅是在内存的变化,其实最后是会被更新到DB的。
如果确实存在save后再改变该实例对象的属性的需要,建议重新new一个实体来操作,不要基于原来的实例对象操作。
异常重试save时先清空主键id
当save方法执行发生异常时(请注意这个前提条件),若要重试时,一定要显式清空实体的主键id后再重试。
因为如果重试成功,则JPA返回给应用的该依旧是重试之前预获取的主键id,而不是重试成功时的主键id。此时该实例数据的主键id在DB中其实已发生了变化,但内存中的实例的主键id却没有变化,依旧是重试之前的值。也就是说,同样一条数据,在内存中的主键id与其在DB中的主键id不一致!
因此在执行save方法发生异常,进行重试时,一定要清空该实体上的主键id,即显式的设为null,这样再执行save的重试时,就能获取到与DB实际一致的主键id,保证了数据的一致性。
自动返回主键id时需配置IDENTITY策略
若需要在save某个实例后自动返回该实例的主键id,需要在该实体的主键id属性上配置注解@GeneratedValue(strategy = GenerationType.IDENTITY)
唯一键冲突异常捕获最佳实践
DB唯一键冲突异常若需要捕获,建议只捕获粗粒度的Exception异常,然后检查异常实例的getCause()里是否包含SQLIntegrityConstraintViolationException,这样扩展性会好一些,因为不同DB抛出的具体异常是不同的(比如TiDB与MySQL抛出的唯一键冲突异常完全不同)
何时适合采用querydsl进行更新操作
通过querydsl方式来更新数据时,不会像save那样先去select再update,而是只会update,即只与DB交互一次,因此相比save,querydsl的更新无疑性能更好。
若存在如下场景之一,则建议使用querydsl方式来更新:
update操作较为频繁,尤其是在高并发高吞吐场景时,建议优先考虑使用querydsl的update
update的条件不固定,即update的where条件较为灵活时
— 删除操作最佳实践—
并发量或吞吐量较大时不要使用JPA内置的delete方法
因为JPA内置的delete方法每次执行时,都会先查询一次db,且逐条发给DB进行删除,在并发或吞吐量较大时,性能低下。建议使用如下2种方式之一:
使用querydsl提供的delete API进行删除
使用@Query注解定义的hql或native sql来删除
批量删除最佳实践
1)批量删除请不要使用delete(Iterable enties)方法,虽然该方法能一次接受一批记录进行删除,但最终却是逐条发给DB删除,也就是说有多少条数据,就会与DB交互多少次。
2)批量删除切勿使用deleteAll方法,因为该方法也会默认先去查询一次DB,然后逐条发给DB删除,在数据量较大时,可能会占用较大内存,直接OOM。
3)数据量较小时,可以使用deleteInBatch进行批量删除,因为该方法只会与DB交互一次,前面2个都是伪批量删除,而这个deleteInBatch才是真批量删除。
4)使用deleteInBatch进行批量删除时,要能确定所删除的数据条数小于3000,否则很有可能会引发StackOverFlow异常
5)若所删除的记录数大于3000或者不确定所删除的记录数,建议使用如下2种方式之一:
使用querydsl提供的delete API进行删除
使用@Query注解定义的hql或native sql来删除
事务方法内先删除后保存场景的最佳实践
在同个事务方法中,若存在先删除,后保存的场景,要注意避免代码中的操作顺序(先delete再save)与实际sql执行顺序(先save再delete)不一致的情况发生,建议采用以下三种方式之一来规避这个问题:
若坚持使用JPA自带的delete方法,则一定要在数据删除之后、数据插入操作之前,先执行一次flush以刷新delete操作,让数据删除操作的事务先提交
数据删除操作不采用JPA的delete方法,改为采用@Query注解定义的hql/native sql 或者 querydsl的delete API 来删除数据
如果实际业务场景允许同一个方法中delete成功而insert失败的话,可考虑以非事务方式执行这2个操作。
结语
以上为个人在使用JPA的过程中总结出来的一些最佳实践,若有遗漏或不足,还请各位补充和指正。
若各位有JPA踩坑经历或最佳实践总结,也欢迎一起交流探讨。
个人邮箱:tom_kang@qq.com