百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术分析 > 正文

Spring Boot 2.x 引起的一个线上低级问题

liebian365 2024-10-23 13:52 25 浏览 0 评论


前言

一天,开发突然找过来说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缓存的问题。至此,水落石出,所有问题迎刃而解。

相关推荐

go语言也可以做gui,go-fltk让你做出c++级别的桌面应用

大家都知道go语言生态并没有什么好的gui开发框架,“能用”的一个手就能数的清,好用的就更是少之又少。今天为大家推荐一个go的gui库go-fltk。它是通过cgo调用了c++的fltk库,性能非常高...

旧电脑的首选系统:TinyCore!体积小+精简+速度极快,你敢安装吗

这几天老毛桃整理了几个微型Linux发行版,准备分享给大家。要知道可供我们日常使用的Linux发行版有很多,但其中的一些发行版经常会被大家忽视。其实这些微型Linux发行版是一种非常强大的创新:在一台...

codeblocks和VS2019下的fltk使用中文

在fltk中用中文有点问题。英文是这样。中文就成这个样子了。我查了查资料,说用UTF-8编码就行了。edit->Fileencoding->UTF-8然后保存文件。看下下边的编码指示确...

FLTK(Fast Light Toolkit)一个轻量级的跨平台Python GUI库

FLTK(FastLightToolkit)是一个轻量级的跨平台GUI库,特别适用于开发需要快速、高效且简单界面的应用程序。本文将介绍Python中的FLTK库,包括其特性、应用场景以及如何通过代...

中科院开源 RISC-V 处理器“香山”流片,已成功运行 Linux

IT之家1月29日消息,去年6月份,中科院大学教授、中科院计算所研究员包云岗,发布了开源高性能RISC-V处理器核心——香山。近日,包云岗在社交平台晒出图片,香山芯片已流片,回片后...

Linux 5.13内核有望合并对苹果M1处理器支持的初步代码

预计Linux5.13将初步支持苹果SiliconM1处理器,不过完整的支持工作可能还需要几年时间才能完全完成。虽然Linux已经可以在苹果SiliconM1上运行,但这需要通过一系列的补丁才能...

Ubuntu系统下COM口测试教程(ubuntu port)

1、在待测试的板上下载minicom,下载minicom有两种方法:方法一:在Ubuntu软件中心里面搜索下载方法二:按“Ctrl+Alt+T”打开终端,打开终端后输入“sudosu”回车;在下...

湖北嵌入式软件工程师培训怎么选,让自己脱颖而出

很多年轻人毕业即失业、面试总是不如意、薪酬不满意、在家躺平。“就业难”该如何应对,参加培训是否能改变自己的职业走向,在湖北,有哪些嵌入式软件工程师培训怎么选值得推荐?粤嵌科技在嵌入式培训领域有十几年经...

新阁上位机开发---10年工程师的Modbus总结

前言我算了一下,今年是我跟Modbus相识的第10年,从最开始的简单应用到协议了解,从协议开发到协议讲解,这个陪伴了10年的协议,它一直没变,变的只是我对它的理解和认识。我一直认为Modbus协议的存...

创建你的第一个可运行的嵌入式Linux系统-5

@ZHangZMo在MicrochipBuildroot中配置QT5选择Graphic配置文件增加QT5的配置修改根文件系统支持QT5修改output/target/etc/profile配置文件...

如何在Linux下给zigbee CC2530实现上位机

0、前言网友提问如下:粉丝提问项目框架汇总下这个网友的问题,其实就是实现一个网关程序,内容分为几块:下位机,通过串口与上位机相连;下位机要能够接收上位机下发的命令,并解析这些命令;下位机能够根据这些命...

Python实现串口助手 - 03串口功能实现

 串口调试助手是最核心的当然是串口数据收发与显示的功能,pzh-py-com借助的是pySerial库实现串口收发功能,今天痞子衡为大家介绍pySerial是如何在pzh-py-com发挥功能的。一、...

为什么选择UART(串口)作为调试接口,而不是I2C、SPI等其他接口

UART(通用异步收发传输器)通常被选作调试接口有以下几个原因:简单性:协议简单:UART的协议非常简单,只需设置波特率、数据位、停止位和校验位就可以进行通信。相比之下,I2C和SPI需要处理更多的通...

同一个类,不同代码,Qt 串口类QSerialPort 与各种外设通讯处理

串口通讯在各种外设通讯中是常见接口,因为各种嵌入式CPU中串口标配,工业控制中如果不够还通过各种串口芯片进行扩展。比如spi接口的W25Q128FV.对于软件而言,因为驱动接口固定,软件也相对好写,因...

嵌入式linux为什么可以通过PC上的串口去执行命令?

1、uboot(负责初始化基本硬bai件,如串口,网卡,usb口等,然du后引导系统zhi运行)2、linux系统(真正的操作系统)3、你的应用程序(基于操作系统的软件应用)当你开发板上电时,u...

取消回复欢迎 发表评论: