jpa与mybatis混用引起线程卡死

背景

最近生产环境上出现了一个问题:某台服务器节点出现不工作的情况,观察当时的详细日志,发现有很多线程在请求了某个接口后就再没在日志中出现,假设请求为/query,说明线程在请求了/query后卡死了。于是查看当时的jstack文件,发现有很多线程都处于waiting状态,而waiting的原因是正在从c3p0连接池获取数据库连接,从栈信息看出线程此时执行的方法是methodA,统计了一下methodA出现的次数,正好是100次,这个数字正好等于配置的数据库连接池最大连接数,那么可以判断出,是大量请求都在获取数据库连接,连接数不断增大至最大连接数,而没有连接被释放,导致大量线程卡死。

这里有两个问题:
1.为什么线程等待获取数据库连接会一直卡着?因为生产上没有配置下面这一项:

# 当连接池用完时客户端调用getConnection()后等待获取新连接的时间,超时后将抛出
# SQLException,如设为0则无限期等待。单位毫秒。Default: 0
c3p0.checkoutTimeout=1000

所以线程会一致等待获取数据库连接而不会超时并抛出异常;
2. 为什么数据库连接一直没有释放?
在请求/query中,我们混用了jpa和mybatis,先进行了jpa查询,后进行mybatis查询,而等待数据库连接而卡死这一现象正好出现在mybatis查询方法methodA中,难道混用jpa和mybatis对连接数有影响?这个问题我排查了很久,下面详细说明。

实验

准备新建一个boot项目复现生产上的情况,项目同时引入jpa和tk mybatis,连接池选用c3p0,和生产保持一致,简单贴下所需依赖:

<!--整合hibernate和jpa-->
        <!--整合hibernate和jpa-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.32</version>
        </dependency>
                <!--    tk mybatis    -->
        <dependency>
            <groupId>tk.mybatis</groupId>
            <artifactId>mapper-spring-boot-starter</artifactId>
            <version>4.2.2</version>
        </dependency>
        <!--    c3p0    -->
        <dependency>
            <groupId>com.mchange</groupId>
            <artifactId>c3p0</artifactId>
            <version>0.9.5.4</version>
        </dependency>

下面是c3p0连接池配置,最大连接数、最小连接数、初始连接数都设置为1,便于一次请求就能复现生产上的情况。

c3p0.jdbcUrl=jdbc:mysql://localhost:3306/wuxia?useSSL=false
c3p0.user=root
c3p0.password=123456
c3p0.driverClass=com.mysql.cj.jdbc.Driver
c3p0.minPoolSize=1
c3p0.maxPoolSize=1
# 最大空闲时间,60秒内未使用则连接被丢弃。若为0则永不丢弃。Default: 0
c3p0.maxIdleTime=60
#当连接池中的连接耗尽的时候c3p0一次同时获取的连接数。Default: 3
c3p0.acquireIncrement=1
c3p0.maxStatements=1000
c3p0.initialPoolSize=1
#每60秒检查所有连接池中的空闲连接。Default: 0
c3p0.idleConnectionTestPeriod=60
#定义在从数据库获取新连接失败后重复尝试的次数。Default: 30
c3p0.acquireRetryAttempts=30
#两次连接中间隔时间,单位毫秒。Default: 1000
c3p0.acquireRetryDelay=1000
# 获取连接失败将会引起所有等待连接池来获取连接的线程抛出异常。但是数据源仍有效
# 保留,并在下次调用getConnection()的时候继续尝试获取连接。如果设为true,那么在尝试
# 获取连接失败后该数据源将申明已断开并永久关闭。Default: false
c3p0.breakAfterAcquireFailure=false
#因性能消耗大请只在需要的时候使用它。如果设为true那么在每个connection提交的
#时候都将校验其有效性。建议使用idleConnectionTestPeriod或automaticTestTable
#等方法来提升连接测试的性能。Default: false
c3p0.testConnectionOnCheckout=false
#如果设为true那么在取得连接的同时将校验连接的有效性。Default: false
c3p0.testConnectionOnCheckin=true
# 当连接池用完时客户端调用getConnection()后等待获取新连接的时间,超时后将抛出
# SQLException,如设为0则无限期等待。单位毫秒。Default: 0
c3p0.checkoutTimeout=0

注意c3p0.checkoutTimeout配置为0。
简单贴下代码:

    @GetMapping("/user/{id}")
//    @Transactional
    public ResponseEntity findUser(@PathVariable("id") Long id) throws InterruptedException, SQLException {
        System.out.println("numbusy before:" + dataSource.getNumBusyConnections());
        System.out.println("numidle before:" + dataSource.getNumIdleConnections());
        System.out.println("numtotal before:" + dataSource.getNumConnections());
//        Resume resume = resumeDao.findById(id).orElseThrow(() -> new RuntimeException("no resume"));
        Resume resume = resumeService.findById(id);
        System.out.println("numbusy in:" + dataSource.getNumBusyConnections());
        System.out.println("numidle in:" + dataSource.getNumIdleConnections());
        System.out.println("numtotal in:" + dataSource.getNumConnections());
        User user = userService.queryUser(id);
//        User user = userRepository.findById(id).orElseThrow(() -> new RuntimeException("no user"));
//        User user = queryUser(id);
        System.out.println("numbusy after:" + dataSource.getNumBusyConnections());
        System.out.println("numidle after:" + dataSource.getNumIdleConnections());
        System.out.println("numtotal after:" + dataSource.getNumConnections());
        Map result = new HashMap();
        result.put("user", user);
        result.put("resume", resume);
        return ResponseEntity.ok(result);
    }

非常简单,就是进行了两次查询,第一用jpa,第二次用mybatis,然后在两次查询前中后打印了一些连接数信息。

  1. 注释掉@Transactional注解

请求接口,发现请求卡住,利用jdk1.8的jvisualvm程序,查看此时程序的线程状态:
http-nio-8082-exec-1
发现http-nio-8082-exec-1线程处于WAITING状态,卡住了,查看线程dump:
dump
queryUser
卡住的方法正好是mybatis查询方法queryUser,再查看此时的控制台日志:

numbusy before:0
numidle before:1
numtotal before:1
Hibernate: 
    select
        resume0_.id as id1_3_0_,
        resume0_.address as address2_3_0_,
        resume0_.name as name3_3_0_,
        resume0_.phone as phone4_3_0_ 
    from
        tb_resume resume0_ 
    where
        resume0_.id=?
numbusy in:1
numidle in:0
numtotal in:1
Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@32d321bc] was not registered for synchronization because synchronization is not active

可以看出执行完jpa查询后,紧接着进行mybatis查询,需要创建SqlSession,而此时没有加上@Transactional注解,jdbc连接不能被spring管理,需要从连接池获取新的连接,而前面的jpa查询没有释放连接,导致获取不到,线程卡死。

  1. 加上@Transactional注解

请求接口,接口正常返回,打印日志:

numbusy before:1
numidle before:0
numtotal before:1
Hibernate: 
    select
        resume0_.id as id1_3_0_,
        resume0_.address as address2_3_0_,
        resume0_.name as name3_3_0_,
        resume0_.phone as phone4_3_0_ 
    from
        tb_resume resume0_ 
    where
        resume0_.id=?
numbusy in:1
numidle in:0
numtotal in:1
Creating a new SqlSession
Registering transaction synchronization for SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5b30043d]
JDBC Connection [com.mchange.v2.c3p0.impl.NewProxyConnection@5533cf8f [wrapping: com.mysql.cj.jdbc.ConnectionImpl@5769ce21]] will be managed by Spring
==>  Preparing: SELECT id,user_name FROM user WHERE id = ?
==> Parameters: 1(Long)
<==    Columns: id, user_name
<==        Row: 1, wuxia
<==      Total: 1
Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5b30043d]
numbusy after:1
numidle after:0
numtotal after:1
Transaction synchronization committing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5b30043d]
Transaction synchronization deregistering SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5b30043d]
Transaction synchronization closing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5b30043d]

由于加上了@Transactional注解,jdbc连接能够被spring管理,于是mybatis查询复用了前面jpa的连接,能够正常执行查询。

  • 去掉@Transactional注解,在配置中加上:
spring.jpa.open-in-view=false

关于此项配置,可以参考文章:
链接: https://www.jianshu.com/p/c856799a42a4
简单来说:

  • Spring会帮忙在request的一开始就打开Hibernate Session。
  • 每当App需要一个Session的时候,就会重用这个Session。
  • 在Request结束的时候,会帮忙关闭该Session。

那么,前面说的为什么为什么数据库连接一直没有释放这个问题就清楚了,正是因为默认spring.jpa.open-in-view=true,所以session会一直保持到请求结束,会一直占用着连接,又因为没加事务,不能复用连接,导致后面的mybatis查询获取不到新连接,进而导致线程卡死。

请求接口,正常返回,日志打印:

numbusy before:0
numidle before:1
numtotal before:1
Hibernate: 
    select
        resume0_.id as id1_3_0_,
        resume0_.address as address2_3_0_,
        resume0_.name as name3_3_0_,
        resume0_.phone as phone4_3_0_ 
    from
        tb_resume resume0_ 
    where
        resume0_.id=?
numbusy in:0
numidle in:1
numtotal in:1
Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@64f58863] was not registered for synchronization because synchronization is not active
JDBC Connection [com.mchange.v2.c3p0.impl.NewProxyConnection@76ea2d06 [wrapping: com.mysql.cj.jdbc.ConnectionImpl@3396bad0]] will not be managed by Spring
==>  Preparing: SELECT id,user_name FROM user WHERE id = ?
==> Parameters: 1(Long)
<==    Columns: id, user_name
<==        Row: 1, wuxia
<==      Total: 1
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@64f58863]
numbusy after:1
numidle after:0
numtotal after:1

可以看到虽然jdbc连接不被spring管理,但是由于配置了spring.jpa.open-in-view=false,所以jpa查询完成后关闭了jpa的session,释放了连接,所以mybatis可以获取到新连接。

总结

关于生产上的问题,还有一点补充:线上数据库线程池最大连接数是100,只有所有的连接都卡在请求/query中的方法methodA,才会导致连接释放不了,因为但凡有一个连接释放了,某个请求中的methodA方法的mybatis查询都能拿到连接,执行完整个方法,进而释放连接,那么渐渐的,这些卡住的等待获取连接的线程,都能整个执行完方法methodA而不再卡住。那么什么情况下,才能导致这种情况呢?我们发现出现这种现象总是在Full GC之后,这就是原因,Full GC之后的停顿,堆积了大量的/query请求,GC后,大量的/query请求迅速把数据库连接池占满,进而卡住。

总而言之,正是以下条件导致了大量线程卡死:

  • 代码中混用jpa和mybatis,且没有开启事务,导致连接不能复用;
  • 没有配置c3p0.checkoutTimeout,获取连接没有超时;
  • 默认开启了spring.jpa.open-in-view=true,延长了hibernate session的生命周期,导致连接没有及时释放;
  • Full GC后大量混用jpa和mybatis的并发请求进入。
  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
JPA(Java Persistence API)和MyBatis是两种在Java中用于数据持久化的框架,它们有以下几个主要区别: 1. ORM vs SQL映射:JPA是一个ORM(对象关系映射)框架,它通过将Java对象映射到数据库表格来实现数据持久化。它提供了一组注解和API,使开发人员可以直接操作Java对象,而无需编写SQL语句。相反,MyBatis是一个SQL映射框架,它允许开发人员编写自定义的SQL语句,并通过将结果映射到Java对象来实现数据持久化。 2. 查询语言:JPA使用JPQL(Java Persistence Query Language)作为其查询语言,它是一种面向对象的查询语言,类似于SQL。JPQL允许开发人员通过操作对象属性而不是表和列来查询数据。MyBatis则使用原生的SQL语句作为查询语言,开发人员可以完全控制SQL的编写和执行过程。 3. 自动化 vs 手动化:JPA框架提供了自动化的数据库操作,包括自动生成数据库表格、自动创建和更新表结构等。开发人员只需要定义实体类和注解,框架会自动处理数据库操作。而MyBatis则需要开发人员手动编写SQL语句和映射文件,更加灵活但也需要更多的手动操作。 4. 社区和文档支持:JPA是Java EE的一部分,具有广泛的社区支持和文档资源。许多Java开发人员都熟悉JPA,并且可以找到大量的教程和示例。MyBatis也有一定的社区支持,但相对来说可能没有JPA那么广泛。 选择使用JPA还是MyBatis取决于具体的项目需求和个人偏好。如果你更喜欢面向对象的查询语言和自动化的数据库操作,那么JPA可能是一个不错的选择。如果你对SQL有更高的控制需求,并且更喜欢手动编写和优化SQL语句,那么MyBatis可能更适合你。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值