前言
一天,开发突然找过来说 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 缓存即可,
建议关闭 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)博主。