一次应用卡死,无响应的排查过程

January 9, 2023

问题情形

springboot + jpa + querydsl + mysql + druid 的web应用,服务启动后,几次请求之后便无响应,甚至请求都没有进到controller断点,无任何报错。

排查过程

  1. jps 查看应用进程号 (可选步骤:top -Hp 进程号 显示该进程下的线程)
  2. jstack -l 进程号 查看该进程的堆栈
  3. 搜索应用包名关键字,定位到具体某个线程,查看该线程的状态,发现都是 waiting 状态
  4. 查看堆栈轨迹,发现都卡在了 druid 获取数据库连接上,连接池中没有可用连接,新进来的请求全部卡在 一个锁上,等待有连接释放。且由于没有配置 maxWait,不会超时,会无限期永远等待下去,造成了应用卡死的表象

原因

本地测试环境,并没有什么并发,数据库连接也足够,所以怀疑是哪里没有释放连接,即连接泄露造成的。

查看druid的监控页面,发现activeCount(活跃连接数)一直在增加,poolingCount(池中连接数)一直在减少,直到降为了0,然后卡主。怀疑得到了佐证。

但是又发现有些接口是正常的,activeCount 和 poolingCount 都是正常的,只是个别接口会有上述情况。观察问题接口,里面有2个查询方法,怀疑其中有查询没有释放连接,最后定位到是 querydsl 的 transform 方法在没有加事务的情况下不会释放连接,这是querydsl的bug,github上也有相关issue,使用版本是5.0 。

解决办法

加上spring事务注解 Transactional(readonly=true) 解决

原理

EntityManager的创建

SharedEntityManagerCreatorcreateSharedEntityManager 方法,返回的是一个 jdk 动态代理,Invocation Handler 是 SharedEntityManagerInvocationHandler.

凡是与JPA集成的所有框架的增删改查操作本质上都是操作 EntityManager,querydsl 也不例外.

都是一样的套路:

1
2
Query query=entityManager.createQuery(...);
query.getResultList() //或其他方法

下面从 没有加事务 和 加了事务 两种情况来讨论

未加事务时

entityManager.createQuery 入手,因为 entityManager 是一个动态代理,会进到 SharedEntityManagerInvocationHandler 中的 invoke 方法,首先会获取到当前真正要使用的 entityManager:

1
2
3
// Determine current EntityManager: either the transactional one
// managed by the factory or a temporary one for the given invocation.
EntityManager target = EntityManagerFactoryUtils.doGetTransactionalEntityManager(this.targetFactory, this.properties, this.synchronizedWithTransaction);

因为没有加事务,此处获取到的是 null.

接着往下走,会创建一个新的 entityManager:

1
2
3
4
5
6
7
if (target == null) {
	logger.debug("Creating new EntityManager for shared EntityManager invocation");
	target = (!CollectionUtils.isEmpty(this.properties) ?
						this.targetFactory.createEntityManager(this.properties) :
						this.targetFactory.createEntityManager());
	isNewEm = true;
}

然后就是正常的反射调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
				Object result = method.invoke(target, args);
				if (result instanceof Query) {
					Query query = (Query) result;
					if (isNewEm) {
						Class<?>[] ifcs = cachedQueryInterfaces.computeIfAbsent(query.getClass(), key ->
								ClassUtils.getAllInterfacesForClass(key, this.proxyClassLoader));
						result = Proxy.newProxyInstance(this.proxyClassLoader, ifcs,
								new DeferredQueryInvocationHandler(query, target));
						isNewEm = false;
					}
					else {
						EntityManagerFactoryUtils.applyTransactionTimeout(query, this.targetFactory);
					}
				}
				return result;

重点来了,当返回结果类型是 Query 时,会对结果再进行一次动态代理,Invocation Handler 是 DeferredQueryInvocationHandler。而上面提到的的 createQuery 方法便正是要创建 Query,所以,后续该query上的所有增删改查操作都会进入到 DeferredQueryInvocationHandler 中的 invoke 方法.

DeferredQueryInvocationHandler 中的 invoke 方法中最后有一个finally块,其中便包含了 entityManager 的关闭操作,最终会转到 datasource 上,也就是在此时druid连接池进行连接的回收(DruidDataSource 的 recycle 方法):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
			finally {
				if (queryTerminatingMethods.contains(method.getName())) {
					// Actual execution of the query: close the EntityManager right
					// afterwards, since that was the only reason we kept it open.
					if (this.outputParameters != null && this.target instanceof StoredProcedureQuery) {
						StoredProcedureQuery storedProc = (StoredProcedureQuery) this.target;
						for (Map.Entry<Object, Object> entry : this.outputParameters.entrySet()) {
							try {
								Object key = entry.getKey();
								if (key instanceof Integer) {
									entry.setValue(storedProc.getOutputParameterValue((Integer) key));
								}
								else {
									entry.setValue(storedProc.getOutputParameterValue(key.toString()));
								}
							}
							catch (IllegalArgumentException ex) {
								entry.setValue(ex);
							}
						}
					}
					EntityManagerFactoryUtils.closeEntityManager(this.entityManager);
					this.entityManager = null;
				}
			}

EntityManagerFactoryUtils.closeEntityManager 其实就是调用了 entityManager(此处的 entityManager 是 Hibernate的实现 SessionImpl) 的 close 方法:

1
2
3
4
5
6
7
8
9
10
11
12
	public static void closeEntityManager(@Nullable EntityManager em) {
		if (em != null) {
			try {
				if (em.isOpen()) {
					em.close();
				}
			}
			catch (Throwable ex) {
				logger.error("Failed to release JPA EntityManager", ex);
			}
		}
	}

所以,是Spring 为我们自动加上了创建连接和关闭连接的代码,从而我们无需在应用代码中手动调用。

那为什么 querydsl 的transform方法没有释放连接呢?

回看上面的 DeferredQueryInvocationHandler 的 finally 块,它有一个 if条件

1
queryTerminatingMethods.contains(method.getName())

1
2
3
4
5
6
7
8
9
10
	static {
		...

		queryTerminatingMethods.add("execute");  // JPA 2.1 StoredProcedureQuery
		queryTerminatingMethods.add("executeUpdate");
		queryTerminatingMethods.add("getSingleResult");
		queryTerminatingMethods.add("getResultStream");
		queryTerminatingMethods.add("getResultList");
		queryTerminatingMethods.add("list");  // Hibernate Query.list() method
	}

所以就很明显,只有这6个方法,Spring会帮我们自动关闭entityManager,从而释放连接。transform 并没有在其中,需要我们手动释放。自然,querydsl的 getResultList 方法是没问题的。

这也是一个 querydsl 的bug,github issue 上有提到。

加了Spring的事务

那为什么加上事务便OK了呢?

首先还是从 entityManager.createQuery 开始,与上面不同的是,由于加了事务,EntityManagerFactoryUtils.doGetTransactionalEntityManager 能得到值,便不会进入 target==null 的 if块,因此 isNewEm 为 false,得到 query 后会进入

1
2
3
				else {
						EntityManagerFactoryUtils.applyTransactionTimeout(query, this.targetFactory);
					}

即此时的query不会是动态代理对象,而是原生的 hibernate 的 QueryImpl。因此此时query上的增删改查便不会自动释放连接,那这个工作由谁来完成?自然是Spring.

加了事务注解后,Spring 会为目标对象生成cglib代理(具体体现在 spring-aop 的 TransactionInterceptor),在方法开始前会开启事务,获取连接,方法结束后,会释放连接,并且保证同一个事务内使用的是同一个连接。保证了连接的释放。

总结

Spring大量使用了代理,包括基于jdk和基于字节码的代理。

未加事务时,生成的query是个代理对象,在目标方法调用结束后会自动释放连接(entityManager.close)

加了事务,生成的query是hibernate的原生QueryImpl对象,但是service类或方法是Spring生成的cglib代理对象,在方法开始前会开启事务,获取连接,方法结束后,会释放连接,并且保证同一个事务内使用的是同一个连接。保证了连接的释放。