Spring Boot中建议关闭Open

Stella981
• 阅读 473

前言

一天,开发突然找过来说KLock分布式锁失效了,高并发情况下没有锁住请求,导致数据库抛乐观锁的异常。一开始我是不信的,KLock是经过线上大量验证的,怎么会出现这么低级的问题呢?然后,协助开发一起排查了一下午,最后经过不懈努力和一探到底的摸索精神最终查明不是KLock锁的问题,问题出在Spring Data Jpa的Open-EntityManager-in-view这个配置上,这里先建议各位看官关闭Open-EntityManager-in-view,具体缘由下面慢慢道来

问题背景

假设我们有一张账户表account,业务逻辑是先用id查询出来,校验下,然后用于其他的逻辑操作,最后在用id查询出来更新这个account,业务流程如下:

  • 请求一:查询id =6的记录,此时JpaVersion =6,业务处理,再次查询id =6的记录,JpaVersion =6,然后更新数据提交
  • 请求二:查询id =6的记录,此时JpaVersion =6, 业务处理,此时请求一结束了,再次查询id=6的记录,JpaVersion =6,更新数据提交失败

首先,请求一和请求二是模拟的并发请求,然后问题出在,当请求一事务正常提交结束后,请求二最后一次查询的JpaVersion还是没有变化,导致了当前版本和数据库中的版本不一致二抛乐观锁异常,而KLock锁是加在第二次查询更新的方法上面的,可以肯定KLock锁没有问题,锁住了请求,直到请求一结束后,请求二才进方法。

2019-11-20 18:32:00.573 [/] pay-settlement-app [http-nio-8086-exec-4] ERROR c.k.p.p.s.a.e.ControllerExceptionHandler - Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; nested exception is org.hibernate.StaleStateException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1 
org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; nested exception is org.hibernate.StaleStateException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1
    at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:320)
    at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:244)
    at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:488)
    at org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:59)
    at org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:213)
    at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:147)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
    at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:133)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
    at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:92)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)

Open-EntityManager-in-view的前世今生

Open-EntityManager-in-view简述下就是在视图层打开EntityManager,spring boot2.x中默认是开启这个配置的,作用是绑定EntityManager到当前线程中,然后在试图层就开启Hibernate Session。用于在Controller层直接操作游离态的对象,以及懒加载查询。在应用配置中可以使用spring.jpa.open-in-view=true/false来开启和关闭它,最终控制的其实是OpenEntityManagerInViewInterceptor拦截器,如果开启就添加此拦截器,如果关闭则不添加。然后在这个拦截器中会开启连接,打开Session,业务Controller执行完毕后关闭资源。打开关闭代码如下:

    public void preHandle(WebRequest request) throws DataAccessException {
        String key = getParticipateAttributeName();
        WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
        if (asyncManager.hasConcurrentResult() && applyEntityManagerBindingInterceptor(asyncManager, key)) {
            return;
        }

        EntityManagerFactory emf = obtainEntityManagerFactory();
        if (TransactionSynchronizationManager.hasResource(emf)) {
            // Do not modify the EntityManager: just mark the request accordingly.
            Integer count = (Integer) request.getAttribute(key, WebRequest.SCOPE_REQUEST);
            int newCount = (count != null ? count + 1 : 1);
            request.setAttribute(getParticipateAttributeName(), newCount, WebRequest.SCOPE_REQUEST);
        }
        else {
            logger.debug("Opening JPA EntityManager in OpenEntityManagerInViewInterceptor");
            try {
                EntityManager em = createEntityManager();
                EntityManagerHolder emHolder = new EntityManagerHolder(em);
                TransactionSynchronizationManager.bindResource(emf, emHolder);

                AsyncRequestInterceptor interceptor = new AsyncRequestInterceptor(emf, emHolder);
                asyncManager.registerCallableInterceptor(key, interceptor);
                asyncManager.registerDeferredResultInterceptor(key, interceptor);
            }
            catch (PersistenceException ex) {
                throw new DataAccessResourceFailureException("Could not create JPA EntityManager", ex);
            }
        }
    }
    
    public void afterCompletion(WebRequest request, @Nullable Exception ex) throws DataAccessException {
        if (!decrementParticipateCount(request)) {
            EntityManagerHolder emHolder = (EntityManagerHolder)
                    TransactionSynchronizationManager.unbindResource(obtainEntityManagerFactory());
            logger.debug("Closing JPA EntityManager in OpenEntityManagerInViewInterceptor");
            EntityManagerFactoryUtils.closeEntityManager(emHolder.getEntityManager());
        }
    }

在Spring MVC时代,懒加载的问题也比较常见,那个时候是通过定义一个OpenEntityManagerInViewFilter的过滤器解决问题的,效果和拦截器是一样的,算是同门师兄弟的关系。如果没有配置,在懒加载的场景下就会抛出LazyInitializationException的异常。

问题的真实原因

了解了Open-EntityManager-in-view后,我们来分析下具体的原因。由于在view层就开启Session了,导致了同一个请求第二次查询时根本就没走数据库,直接获取的Hibernate Session缓存中的数据,此时无论怎么加锁,都读不到数据库中的数据,所以只要有并发就会抛乐观锁异常。这让我联想到了老早前一个同事和我说的他们遇到的一个并发问题,即使给@Transactional事务的隔离级别设置为串行化执行,还是会报乐观锁的异常。有可能就是这个问题导致的,在这个案例中,加锁不好使,即使使用数据库的串行化隔离级别也不好使。因为第二次查询根本就不走数据库了。

解决方案

真实原因已经定位到了,KL博主给出了几种方案解决问题,如下:

  • 方案一、将KLock前置,把加分布式锁的逻辑移到第一次使用id查询之前,即让查询发生在别的请求事务结束之前,这样无论第一次查询还是第二次查询获取到的都是别的事务已提交的内容

  • 方案二、使用spring.jpa.open-in-view=false关闭,这个方案比较简单粗暴,但是影响会比较大,其他的代码很可能已经依赖了懒加载的功能特性,贸然去掉会带来大量的回归测试工作,所以虽然博主建议关闭这个特性,但是在已经使用了的系统中不推荐

  • 方案三、局部控制Open-EntityManager-in-view行为,就是人为编码控制EntityManager的绑定,在有影响的地方先取消绑定,然后执行完后在添加回来,不添加回来会导致Jpa自己的解绑逻辑报错。代码如下:

    /**

    • @author: kl @kailing.pub
    • @date: 2019/11/20

    */ @Component public class OpenEntityManagerInViewManager extends EntityManagerFactoryAccessor { public void cancel() { EntityManagerFactory emf = obtainEntityManagerFactory(); EntityManagerHolder emHolder = (EntityManagerHolder) TransactionSynchronizationManager.unbindResourceIfPossible(emf); EntityManagerFactoryUtils.closeEntityManager(emHolder.getEntityManager()); } public void add() { EntityManagerFactory emf = obtainEntityManagerFactory(); if (!TransactionSynchronizationManager.hasResource(emf)) { EntityManager em = createEntityManager(); EntityManagerHolder emHolder = new EntityManagerHolder(em); TransactionSynchronizationManager.bindResource(emf,emHolder); } } }

  • 方案四: 方案三为了达到效果有点费劲哈,其实还有一种方案,在第二次查询前使用EntityManager的clear清除Session缓存即可,

  • 方案五:方案四的clear的操作比较重,会清除持久性上下文,导致所有托管实体被分离。对没有被刷新到数据库的实体所做的更改将不会被持久化,如果开发对代码不怎么熟悉可能会有影响。这个是最后补充的解决方案,更轻量,使用Hibernate Session实例的evict方法驱逐首次查询的对象实例,代码如下:

    entityManager.unwrap(Session.class).evict(obj)

建议关闭Open-EntityManager-in-view

在Spring boot2.x中,如果没有显示配置spring.jpa.open-in-view,默认开启的这个特性Spring会给出一个警告提示:

                logger.warn("spring.jpa.open-in-view is enabled by default. "
                        + "Therefore, database queries may be performed during view "
                        + "rendering. Explicitly configure spring.jpa.open-in-view to disable this warning");

用来告诉你,我开启这个特性了,你可以显示配置来关闭这个提示。博主猜测就是告知用户,你可能用不着吧。确实,现在微服务中的应用在使用Spring Data JPA时,已经很少使用懒加载的特性了。而且如果你的代码规范点,也用不着直接在Controller层写Dao层的代码。总结下就是根本就不需要Open-EntityManager-in-view的特性,然后它还有副作用,开启Open-EntityManager-in-view,会使数据库租用连接时长变长,长时间占用连接直接影响整体事务吞吐量。然后一不小心就会陷进Session缓存的坑里。所以,新项目就直接去掉吧,老项目去掉后回归验证下

结语

因为对业务不熟悉,不知道业务逻辑中查询了两次相同的实体,导致整个排错过程比较曲折。先是开发怀疑锁的问题,验证锁没问题后,又陷进了IDEA断点的问题,因为模拟的并发请求,断点释放一次会通过多个请求,看上去就像很多请求没进来一样。然后又怀疑了事务和加锁前后的逻辑问题,如果释放锁在释放事务前就会有问题,将断点打在了JDBC的Commit方法里,确认了这个也是正常的。最后才联想到Spring boot中默认开启了spring.jpa.open-in-view,会不会有关系,也不确定,怀着死马当活马医的心态试了下,果然是这个导致的,这个时候只知道是这个导致的,还没发现是这个导致的Session问题,以为是进KLock前就开启了事务锁定了数据库版本记录,所以查询的时候返回的老的记录,最后把事务串行化后还不行,才发现的业务查询了两次进而发现了Session缓存的问题。至此,水落石出,所有问题迎刃而解。

作者简介:

陈凯玲,2016年5月加入凯京科技。现任凯京科技研发中心架构组经理,救火队队长。独立博客KL博客(http://www.kailing.pub)博主。

点赞
收藏
评论区
推荐文章
blmius blmius
2年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Wesley13 Wesley13
2年前
java将前端的json数组字符串转换为列表
记录下在前端通过ajax提交了一个json数组的字符串,在后端如何转换为列表。前端数据转化与请求varcontracts{id:'1',name:'yanggb合同1'},{id:'2',name:'yanggb合同2'},{id:'3',name:'yang
Jacquelyn38 Jacquelyn38
2年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Easter79 Easter79
2年前
Twitter的分布式自增ID算法snowflake (Java版)
概述分布式系统中,有一些需要使用全局唯一ID的场景,这种时候为了防止ID冲突可以使用36位的UUID,但是UUID有一些缺点,首先他相对比较长,另外UUID一般是无序的。有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移
Stella981 Stella981
2年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Wesley13 Wesley13
2年前
35岁是技术人的天花板吗?
35岁是技术人的天花板吗?我非常不认同“35岁现象”,人类没有那么脆弱,人类的智力不会说是35岁之后就停止发展,更不是说35岁之后就没有机会了。马云35岁还在教书,任正非35岁还在工厂上班。为什么技术人员到35岁就应该退役了呢?所以35岁根本就不是一个问题,我今年已经37岁了,我发现我才刚刚找到自己的节奏,刚刚上路。
Wesley13 Wesley13
2年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
为什么mysql不推荐使用雪花ID作为主键
作者:毛辰飞背景在mysql中设计表的时候,mysql官方推荐不要使用uuid或者不连续不重复的雪花id(long形且唯一),而是推荐连续自增的主键id,官方的推荐是auto_increment,那么为什么不建议采用uuid,使用uuid究
Python进阶者 Python进阶者
3个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这