赞
踩
在对项目进行压测时,我们发现在压测进行一段时间后,会出现连接池等待超时的错误,为了使系统在高并发、请求量激增的时候也能保持稳定,我们必须对这个错误进行分析并解决。最后的解决办法是将springboot的配置文件中添加一条配置:spring.jpa.open-in-view = false
。我想从头开始,对出现的问题进行分析,再一步步缩小解决办法的空间,最后得到比较合适的解决办法。大概解决思路会是:列出拥有的信息 -> 列出可能导致问题发生的原因 -> 缩小解决空间 -> 得到合适的解决办法集 -> 选出最好的解决办法。
首先对项目进行概括:
项目是使用springboot搭建的;
使用的结构是很常见的3层:controller、service、persistent;
persistent层使用的springdataJPA支持;
数据库连接池使用的是springboot默认的Hikari连接池:
Hikari默认请求等待时间是不超过3000毫秒,连接池最大连接数是10;
数据库使用的是MySQL。
发现这个错误做了哪些操作:
当时在对某一个创建功能的接口进行压测,压测工具使用的是locust,设置并发数为200个请求/s就出现了连接池请求等待超时的报错。
报错信息:
[2020-01-14 09:53:39.854] [http-nio-8083-exec-284 ERROR] [o.h.e.j.s.SqlExceptionHelper] HikariPool-1 - Connection is not available, request timed out after 30000ms.
[2020-01-14 09:53:39.854] [http-nio-8083-exec-284 DEBUG] [o.s.w.s.m.m.a.ExceptionHandlerExceptionResolver] Using @ExceptionHandler public final org.springframework.http.ResponseEntity<java.lang.Object> com.ringleai.common.handler.GlobalExceptionHandler.handleAllException(java.lang.Exception,org.springframework.web.context.request.WebRequest) throws java.lang.Exception
[2020-01-14 09:53:39.854] [http-nio-8083-exec-284 DEBUG] [o.s.w.s.m.m.a.HttpEntityMethodProcessor] Using 'application/json', given [*//*] and supported [application/json, application/*+json, application/json, application/*+json]
[2020-01-14 09:53:39.855] [http-nio-8083-exec-284 DEBUG] [o.s.w.s.m.m.a.HttpEntityMethodProcessor] Writing [BaseResponse {"code":500, "error":"Internal Server Error"}]
[2020-01-14 09:53:39.855] [http-nio-8083-exec-284 DEBUG] [o.s.w.s.m.m.a.ExceptionHandlerExceptionResolver] Resolved [org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction; nested exception is org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection]
[2020-01-14 09:53:39.855] [http-nio-8083-exec-284 DEBUG] [o.s.w.s.DispatcherServlet] Completed 200 OK
当问题发生时,第一时间应该先看一下日志里面的报错信息。通过观察日志发现,在压测的时候并不是所有的请求都会报出等待连接超时的错误,将第一个报错的线程的位置找到,我们发现第一个报错的线程是在压测进行一段时间后才出现的。这意味着,我们的业务代码没有逻辑上的问题,不是业务代码出现比如死循环之类的情况导致请求不释放连接池连接——因为请求的入参相同、没有报错成功返回的请求占比不小。
在确定业务代码的逻辑没问题后,我们大概分析出了以下可能的原因:
数据库层面
Mysql给的连接数太少了,比Hikari连接池默认的10个还低,导致200个请求同时进来,同一时间点只能有单单几个请求能连上数据库进行操作,请求堆积后导致连接池等待时间超过3000毫秒,报错;
连接池层面
业务代码层面
注意在业务代码层面,我们一开始是觉得请求开始占用连接池连接的时候是进入service层的事务,并且在执行完service层代码回到controller层后,就会自动释放连接池连接,其实并不然。
我们首先得去查询一下Mysql现在设置的最大连接数是多少,那么我们在数据库里新建一个查询,输入:show variables like '%max_connections%';
查看一下最大连接数。
运行后得到结果:
可以很清楚的看到数据库最大连接现在是100,比Hikari连接池默认的最大10条连接要多得多,所以数据库是没有问题的,最大100条连接也够用了。
我们在项目的配置文件中加入新的配置:spring.datasource.hikari.maximum-pool-size=100
,将连接池默认的最大10个连接数改成数据库设置的最大100个连接。
接着再重启项目,开始压测。为了验证新加入的配置有效,我们在数据库新建查询:show status like 'Threads%';
,在启动服务前执行,查看不开服务时我们数据库的连接信息。
如下:
我们主要关注:Threads_connected
和Threads_running
,Threads_connected
代表数据库连接数,也就是说占用了几条连接,Threads_running
代表当前时间点连接活跃的数量,也就是正在操作数据库的连接的数量。
再启动一下我们的项目,重新执行一下这条查询:
在没有进行压测时,项目启动就占用了101条数据库连接,说明我们的配置有效。
压测时我们不断的执行上面的查询数据库连接的查询语句,发现数据库报错:too many connections
,这说明数据库的所有连接都被使用了,并发的效果达到了。压测完毕后看一下日志文件,果然报错的请求少了不少,但是报错的请求占比还是很大,这说明增大Hikari的最大连接数是有帮助的,但是无法完全解决我们的问题,而且现在并发量才200,如果再高到1000,肯定还是顶不住。再说我们的项目按服务进行了分库分表,每个服务管理自己的数据库,如果每个服务都将Hikari的最大连接数设成100甚至更多,那么对于Mysql来说可能无法应对这么多的数据库连接操作。
我们把之前的最大连接数配置注释掉,再新加入配置:spring.datasource.hikari.connection-timeout=6000
,将等待时间由默认的3000毫秒设置成6000毫秒,这个6000毫秒已经是极限了,因为当用户调用我们这个接口的时候,如果等待这么久,体验会很差。
启动项目,进行压测,查看日志看结果,哈哈,发现报错的信息从原来的3000毫秒变成了6000毫秒,这个方案直接pass。
6000毫秒的报错日志:
[2020-01-14 14:18:55.854] [http-nio-8083-exec-10 ERROR] [o.h.e.j.s.SqlExceptionHelper] HikariPool-1 - Connection is not available, request timed out after 6001ms.
[2020-01-14 14:18:55.854] [http-nio-8083-exec-10 DEBUG] [o.s.w.s.m.m.a.ExceptionHandlerExceptionResolver] Using @ExceptionHandler public final org.springframework.http.ResponseEntity<java.lang.Object> com.ringleai.common.handler.GlobalExceptionHandler.handleAllException(java.lang.Exception,org.springframework.web.context.request.WebRequest) throws java.lang.Exception
[2020-01-14 14:18:55.854] [http-nio-8083-exec-10 DEBUG] [o.s.w.s.m.m.a.HttpEntityMethodProcessor] Using 'application/json', given [*//*] and supported [application/json, application/*+json, application/json, application/*+json]
[2020-01-14 14:18:55.854] [http-nio-8083-exec-10 DEBUG] [o.s.w.s.m.m.a.HttpEntityMethodProcessor] Writing [BaseResponse {"code":500, "error":"Internal Server Error"}]
[2020-01-14 14:18:55.854] [http-nio-8083-exec-10 DEBUG] [o.s.w.s.m.m.a.ExceptionHandlerExceptionResolver] Resolved [org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction; nested exception is org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection]
[2020-01-14 14:18:55.854] [http-nio-8083-exec-10 DEBUG] [o.s.w.s.DispatcherServlet] Completed 200 OK
我们将两条新增的配置都打开:
再执行一下压测,最后发现确实出现报错的请求比之前不这么配置的时候要少很多,效果的话是增大连接池最大连接数比增大最大连接等待时间要好,但是这个办法不是最合适的解决办法,我们继续往下排查。
在我们压测的时候,使用了一个JDK自带的工具:JMC。JMC可以监听压测时JVM虚拟机内存和连接的情况,并且有一个飞行记录器的东西,可以看到我们测试的接口中,什么方法占用的时间是最长的。通过分析JMC的图表,我们可以看到我们的业务代码里面占用时间最久的是Java自己的包,所以我们的业务代码中应该也不会出现类似死循环这种情况,把请求卡在一个地方很久。说明不是service层执行时间过久的原因。
JMC的图表示例(不是真实测试的图表):
为了知道一个请求是在执行完某行指令释放连接,我们可以通过以下方式进行模拟:让连接池只有一条连接,这样可以使得,当连接池请求被占用后,新进来的请求拿不到连接只能等着,为了让等待的请求不报错,我们将等待的时间设置大一点;设置完后,我们先发送一个请求A,请求A先进入Service层拿到连接并且打个断点让A一直停在service层不释放连接;这时再发送请求B,请求B不需要打断点,因为连接池唯一的连接被请求A占用,B无法拿到连接就会一直卡在Service层之前;这个时候我们再一步步移动请求A,看请求A执行到哪一行代码后,请求B能拿到连接池的连接。如此,我们就能知道是哪里出了问题。
实际操作步骤:
我们本来预计,请求A在出service方法回到controller层的时候,请求B就可以马上获得连接直接返回,但是真实的情况让人大跌眼镜,在请求A走出service方法后,请求B还是一直卡着,直到请求A被放行完全执行完毕后,请求B才能拿到连接将值返回!
那么我们再还原一下,重新发送请求A和请求B,在请求A走出service层后,我们继续让请求A执行,看一下请求A究竟要执行到哪里才会释放连接。
最后,直到请求A执行过return方法,走到拦截器那一层的时候,我们终于在一个类里面找到了请求A释放连接的指令:
public class OpenEntityManagerInViewInterceptor extends EntityManagerFactoryAccessor implements AsyncWebRequestInterceptor { public static final String PARTICIPATE_SUFFIX = ".PARTICIPATE"; public OpenEntityManagerInViewInterceptor() { } public void preHandle(WebRequest request) throws DataAccessException { String key = this.getParticipateAttributeName(); WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request); if (!asyncManager.hasConcurrentResult() || !this.applyEntityManagerBindingInterceptor(asyncManager, key)) { EntityManagerFactory emf = this.obtainEntityManagerFactory(); if (TransactionSynchronizationManager.hasResource(emf)) { Integer count = (Integer)request.getAttribute(key, 0); int newCount = count != null ? count + 1 : 1; request.setAttribute(this.getParticipateAttributeName(), newCount, 0); } else { this.logger.debug("Opening JPA EntityManager in OpenEntityManagerInViewInterceptor"); try { EntityManager em = this.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 var8) { throw new DataAccessResourceFailureException("Could not create JPA EntityManager", var8); } } } } public void postHandle(WebRequest request, @Nullable ModelMap model) { } public void afterCompletion(WebRequest request, @Nullable Exception ex) throws DataAccessException { if (!this.decrementParticipateCount(request)) { EntityManagerHolder emHolder = (EntityManagerHolder)TransactionSynchronizationManager.unbindResource(this.obtainEntityManagerFactory()); this.logger.debug("Closing JPA EntityManager in OpenEntityManagerInViewInterceptor"); // 请求A在这里释放连接池连接 EntityManagerFactoryUtils.closeEntityManager(emHolder.getEntityManager()); } } // *************************************省略非主要代码 }
在请求A执行完里面的afterCompletion()方法中的指令EntityManagerFactoryUtils.closeEntityManager(emHolder.getEntityManager());
后,请求B马上就拿到了连接,返回了数据。我们可以发现,这个类:OpenEntityManagerInViewInterceptor
是一个拦截器,有preHandle()、postHandle()、afterCompletion()方法,并且是在preHandle()里面就开启了session、获取了连接池的连接,最后会在afterCompletion()里面关闭缓存、释放连接。这和我们想要达到的效果不一样呀,因为这样会导致session保持、连接池连接占用太久了,我们想要的是请求进入事务后才开启session,执行完事务里面的代码后就释放连接。为了达到这个效果,我们得去看一下为什么请求会在拦截器里面进行连接池的连接和释放。
通过搜索这个拦截器的相关知识,我们知道了这个拦截器是和springboot一个默认开启的配置有关系的:Open Session In View
。这个默认配置非常有争议,开启它一方面会提高一定的开发效率,另一方面呢又会导致出现一些耗尽连接池、进行不必要查询的问题。下面对这个重要、有争议的配置进行梳理。
OSIV,就像它说的那样,在 ‘View’ 就将Session打开了,这意味着,请求不是在执行到事务里面才获得session,而是在更早的 ‘View’ 就获得了session,那么 ‘View’ 在哪里呢? ‘View’ 是一个拦截器,如果你使用的是Hibernate,那么它的名字叫OpenSessionInViewInterceptor
,如果你使用的是SpringdataJPA,那么它的名字是OpenEntityManagerInViewInterceptor
(代码贴在上一小节),这两个拦截器的内容非常相似,我们项目中使用的是后者。
那么拦截器在spring里面是什么时候用呢?
上面这张图片讲述了用户发送请求后,spring的各大组件是如何工作的。可以看到,在图片右上角处理器映射器执行后返回给前端控制器一个执行链,这个执行链就是程序执行的顺序,首先执行的是一些拦截器:HandlerInterceptor1
和HandlerInterceptor2
等等,最后才是Handler,这个Handler就是我们在controller层、service层、dao层写的业务代码。关于拦截器的细节如图:
拦截器里面有3个方法:
所以OSIV就是一个拦截器,功能是在拦截器的preHandle方法将session打开,等我们业务代码执行完毕回到拦截器后,在afterCompletion方法里面将session关闭。
在springboot项目中,OSIV是默认打开的,而springboot2.0版本及以上,会在项目启动的时候打印出警告日志:
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
- 1
如果我们想要让这个警告消失,我们只能在配置文件中加上:
spring.jpa.open-in-view=false
将OSIV开启后,spring会帮每个请求绑定一个拦截器,拦截器里面就将和数据库连接的session打开,使得请求走出带有@Transactional的 service层后,也可以使用到懒加载的元素属性。
例子如下:我们使用的是springboot默认配置,在默认配置中OSIV是打开的。我们假设有一个实体类——User,User类里面呢关联了另外一张记录用户权限的表:Permission,意思就是说,一个用户可以有很多权限,我们将这个用户的权限用一个Set集合保存。
User类如下:
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue
private Long id; // 用户id
private String username; // 用户名
@ElementCollection
private Set<String> permissions; // 用户权限集合
// getters and setters
}
就像其他的 one-to-many 一对多和 many-to-many 多对多关系一样,用户权限是一个懒加载的集合,将用户对象查询出来后,如果我们将session关闭,再去拿用户权限集合的属性会抛出懒加载异常。
接下来我们实现Service层,添加一个带事务注解的方法:
@Service public class SimpleUserService implements UserService { private final UserRepository userRepository; public SimpleUserService(UserRepository userRepository) { this.userRepository = userRepository; } @Override @Transactional(readOnly = true) public Optional<User> findOne(String username) { // 根据用户名查询用户,返回对应的Optional集合,集合里有User对象或者为空 return userRepository.findByUsername(username); } }
我们在 controller层去写一个简单的实现,接收从srevice层拿到的装有User对象的Optional集合:
@RestController @RequestMapping("/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/{username}") public ResponseEntity<?> findOne(@PathVariable String username) { return userService .findOne(username) .map(DetailedUserDto::fromEntity) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } }
最后我们写一个测试,看一下OSIV是否能帮助我们将用户权限集合懒加载,使我们在controller层也能拿到用户权限集合的属性:
@SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") class UserControllerIntegrationTest { @Autowired private UserRepository userRepository; @Autowired private MockMvc mockMvc; @BeforeEach void setUp() { // 添加初始化数据 User user = new User(); user.setUsername("root"); user.setPermissions(new HashSet<>(Arrays.asList("PERM_READ", "PERM_WRITE"))); userRepository.save(user); } @Test void givenTheUserExists_WhenOsivIsEnabled_ThenLazyInitWorksEverywhere() throws Exception { mockMvc.perform(get("/users/root")) .andExpect(status().isOk()) // 验证拿到的用户名是否和初始数据的用户名一致 .andExpect(jsonPath("$.username").value("root")) // 验证拿到的用户权限集合是否和初始数据的用户权限集合一致 .andExpect(jsonPath("$.permissions", containsInAnyOrder("PERM_READ", "PERM_WRITE"))); } }
结果:
测试完美通过。
我们对测试中请求从发起到结束进行分析:
OpenSessionInViewInterceptor
拦截器。执行链尾部有一个Handler对象,这个Handler对象里面有对应用户请求路径在controller层对应的方法;OpenSessionInViewInterceptor
拦截器,在它的preHandle方法将session打开;这和OSIV关闭后不一样的地方在于,正常情况下请求在进入Service层事务方法时才会开启session,在走完Service层事务方法的代码后、回到controller层之间会将session关闭;如果我们要想在service层之外使用用户权限集合,只能在service层session开启的时间段将用户权限集合人为初始化,如果在session关闭之后再去使用用户权限集合里面的属性就会抛出懒加载异常。
所以OSIV的好处之一就是,我们在请求从进入到结束、在Controller层或者其他地方,都可以使用懒加载对象的属性,因为session开启于请求的整个生命周期,不限于Service层的事务方法。
试想一下,如果我们关闭了OSIV,我们要怎么才能在controller层也使用用户权限集合的属性呢?
@Override
@Transactional(readOnly = true)
public Optional<User> findOne(String username) {
Optional<User> user = userRepository.findByUsername(username);
// 手动初始化用户权限集合
user.ifPresent(u -> Hibernate.initialize(u.getPermissions()));
return user;
}
最常用但是也最容易出错的方法就是使用Hibernate.initialize()
去对用户权限集合做初始化,很明显手动初始化会影响开发者的开发效率,有了OSIV的帮助,开发者们可以免去一些初始化相关的操作。
在OSIV打开后,假设我们有一个请求:
如果我们的程序执行的很快,请求从进入到返回所需要的时间很短,能及时释放连接池连接,那么OSIV造成的负面影响我们可能会忽略,但是一旦请求执行的接口需要线程同步、等待的时候,OSIV就会造成连接池耗尽的情况。
我们在Service层加入一个远程请求指令:
@Override
public Optional<User> findOne(String username) {
Optional<User> user = userRepository.findByUsername(username);
if (user.isPresent()) {
// remote call,等待远程服务执行完毕
}
return user;
}
在加入远程请求指令后,请求执行所花总时间会根据远程服务执行完成的时间变化,如果远程服务返回的时间比较长,请求保持连接池连接的时间就会非常久,这也是导致上文中我们项目出现请求等待连接池连接时间超过3000毫秒的问题!
因为session在请求的整个生命周期都处于打开状态,一些配置属性可能会导致在事务方法外出现一些不必要的查询,并且session会使用自动提交模式去执行这些查询,每个SQL语句都被视为一个事务,并在执行后立即自动提交,这会带来非常大的数据库压力。
如果我们只是开发一个简单的CRUD服务,开了也没事,因为我们可能永远都碰不到这些问题;但是如果我们的服务需要经常请求远程服务、在service层之外有非常多的其他逻辑处理,那非常建议大家不开OSIV。
当然,如果我们一开始就不打开OSIV,在开发阶段,我们需要使用OSIV,这个时候再打开OSIV是不需要做太大改动的;而如果我们一开始就打开了OSIV,开发阶段遇到它引发的问题,需要关闭OSIV的时候,那要做的事情就可多了,因为很容易就抛出懒加载异常。
Hibernate.initialize(Object proxy)是可以拉取懒加载的元素的,但是不建议使用,因为这个方法是hibernate的api,我们需要减少对于外部api的引用、减少程序被侵入的程度。
更推荐的getPermissions()使用示例:
Optional<User> user = userRepository.findByUsername(username);
user.ifPresent(u -> {
Set<String> permissions = u.getPermissions();
System.out.println("Permissions loaded: " + permissions.size());
});
虽然这两种方法都可以解决懒加载的问题,但是还是不推荐使用,因为他们都需要一条额外的查询,hibernate会产生两条sql语句去查询用户和他的权限集合:
> select u.id, u.username from users u where u.username=?
> select p.user_id, p.permissions from user_permissions p where p.user_id=?
我们可以在Dao层的方法上加上注解@EntityGraph,这样在使用Dao层的方法进行查询时,会直接将附带的懒加载信息一并查出来,:
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = "permissions")
Optional<User> findByUsername(String username);
}
并且只会执行一条sql:
> select u.id, u.username, p.user_id, p.permissions from users u left outer join user_permissions p on u.id=p.user_id where u.username=?
在熟悉了OSIV之后,我发现我们的项目因为是微服务架构,按不同的服务进行了分库分表,没有使用懒加载的特性,所以可以直接将OSIV关掉,缩短请求占用连接池连接的时间,这样我们就有了三种解决办法:
spring.jpa.open-in-view = false
,将OSIV关闭;对于解决办法1,虽然能减少很多连接池等待时间的报错,但是无法应对更高并发量的情况,所以pass;
对于解决办法2,我们只需要给每个服务多加一行配置,并且压测非常顺利,没有再出现报错的情况;
对于解决办法3,手动将session解绑、重绑增大了软件层面的无用功,并且多做了操作浪费资源、时间,pass;
综上,最后解决压测时连接池等待超时报错的问题,采用了解决办法2,在配置文件添加:
spring.jpa.open-in-view = false
。
Ps:只要在项目配置文件加上:logging.level.org.springframework.orm.jpa=debug
,我们就能很轻易看到请求是什么时候开启和关闭session的。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。