type
status
date
slug
summary
tags
category
icon
password

1、问题背景

生产环境线上的 java 服务突然出现大量 http 请求报错,来自上游服务的大量 http 请求出现无法调通的情况。
立刻登录容器,查看服务的内存、网络IO、CPU、硬盘等都很正常。jstack 查看线程的堆栈信息,发现了问题,绝大部分线程都处于 WAITING 状态,被阻塞在获取 Druid 数据库连接。
notion image
查看线程的具体分类,发现足足 130 个 tomcat 线程处于 WAITING 状态,看来不只是来自上游服务的 http 请求,基本上是所有 http 请求都被阻塞住了。
notion image
查看线程的堆栈信息,发现线程都阻塞在获取 AQS 锁上面,更上一的操作是 DruidDataSource.getConnection ,说明线程从Druid连接池获取连接的时候被阻塞了。
怀疑是数据库连接问题,先查看数据库运行情况。数据库运行正常,没有出现慢查询、大事务、死锁等问题,数据库连接数也只有几个,说明基本没有数据访问。
这种情况可能就是程序里面在获取数据库连接的时候出现死锁了。这种问题一般只能重启服务处理。但是程序里面大量使用了 Spring 事务,直接重启服务担心事务只执行了一部分,出现数据不一致的问题。
紧急解决方案是使用 arthas 工具热修改线上代码,临时把 Druid 的最大线程池 maxActive 增大,让程序能获取到连接,可以继续执行下去。
先确认 Spring 容器管理的 DruidDataSource Bean 是怎么注入的。这里发现是通过自动配置注入的,Bean 名称为 dataSource
开始使用 arthas 修改 dataSource 的属性。
程序终于没有卡住,可以继续执行。等所有事务都执行完毕(报错或者回滚)后,然后修改配置文件,增加连接池的最大连接数为 40(之前设置是 20,有点小),并增加连接超时时间为 30s(这个之前没有设置),然后再重启容器。

2、分析

在获取数据库连接的时候为什么会出现死锁问题呢?网上查找资料,发现很多人在使用Druid线程池的时候都出现了死锁问题https://github.com/alibaba/druid/issues/1160,基本上可以确认:
💡
一个线程在持有一个 connection 没有释放的情况下尝试去获取一个新的 connection。比如:在开启一个事务的场景下,下一个业务调用创建新的事务,在高并发的情况下容易出现死锁问题。
给出的解决方案是:
  • 增加最大连接数 maxActive
  • 增加连接超时时间 maxWait
也就是我们上面的临时解决方案。都2024年了,Druid 这种使用这么广泛的连接池为什么还会出现这种情况呢?查看 Druid 线程池的源码,发现 Druid 线程池在创建和回收连接的时候,使用了经典的生产者消费者模式。
新建 DruidDataSource 的时候使用了 ReentrantLock,并新建两个Condition
  • notEmpty:当线程池连接为 0 的时候,获取连接的线程会 await 阻塞在这里,直到有可用的连接,才会被 signal 唤醒。
  • empty:当线程池连接达到最大的时候,生产连接的线程会 await 阻塞在这里,直到需要生产连接,才会被 signal 唤醒。
查看上面堆栈报错的地方 DruidDataSource.takeLast,发现线程确实被阻塞在 notEmpty.await() 这个地方,说明没有可用的连接了,大家都在等待获取连接。
我们在分析下一个线程在持有一个连接下再获取另外一个连接这个场景,假如线程池的最大连接数是 4,有A、B两个线程,每个线程执行都需要 3 个连接。
  1. A 线程获取到 2 个连接,在获取第 3 个连接之前,CPU切换到 B 线程执行。
  1. B 线程获取到 2 个连接,CPU切换到 A 线程执行。
  1. 此时数据库已经没有可用的连接了,A 线程等待获取连接。
  1. B 线程也等待获取连接。
A、B线程在各自持有连接的情况下,都在等待对方释放连接,构成了死锁的条件。在我们的业务代码里面,确实存在不少使用 Spring 嵌套事务的情况,在当前事务未提交的情况下,在调用下一个方法的时候又新建了事务。每个 Spring 事务底层都需要一个数据库连接。
这种死锁问题一般只发生在数据库连接释放不够及时的情况下。而当时我们刚好有一个大客户做了大批量经办,构成了触发死锁的第二个条件:高并发
要解决死锁问题,增加最大连接数 maxActive 可以理解,那么为什么增加超时时间 maxWait 呢?再看下 Druid 的源码,在调用 DruidDataSource.getConnectionInternal(long maxWait) 获取连接的时候
如果设置了 maxWait 且大于0,就调用 pollLast(nanos)方法,查看这个方法,发现在获取连接的时候调用了带超时的notEmpty.awaitNanos(estimate),在一定时间内获取不到连接就会自动失败。这种超时失败就可以打破死锁。

3、问题复现

写一个简单的嵌套事务例子,例如UserController类
MyAspect类
设置最大连接数为 10,没有设超时时间,jmeter 使用最大并发20左右去压测,使用 jprofiler 观察程序运行状况。
发现 tomcat 很快就出现卡住的情况。查看线程堆栈,出现了和生产环境相同的错误信息,都处于WAITING状态,被阻塞在获取数据库连接。
notion image
查看 jprofiler 上的 monitor 锁监控,所有的 tomcat 线程都被阻塞在同一个 AQS 锁上。
notion image
通过 Druid 的 MBean 查看数据库连接池的使用情况,发现 10 个连接都被用完了,连接池已经没有可用的连接。
notion image
至此已经证明:一个线程在持有一个连接没有释放的情况下尝试去获取一个新的连接,在高并发的情况下容易出现死锁问题

4、解决方案

  1. 增加最大数据库连接数。那么这个最大连接数设置多少呢?Hikari官方给出了建议 https://github.com/brettwooldridge/HikariCP/wiki/FAQ最大连接数 = 最大并发线程数 *(每个线程最多持有的连接数 - 1),可以避免死锁。上面A、B线程哪个例子,最大并发=2,每个线程最多持有的连接数=3,所以设置线程数为 2 * (3 - 1) + 1 = 5 可以避免死锁。
    1. notion image
  1. 设置连接最大超时时间。可以根据具体业务调整,建议为 30s。但是注意,设置连接最大超时时间只能打破死锁,并不能从根本解决死锁问题。
  1. 使用多个数据库连接池。这种方案可以从根本解决死锁问题,对单个线程需要获取 N 个不同数据库连接的代码,可以尽量让他们各自创建 N个数据源 DataSource,然后 Spring 配置多个 transactionManager,在对应业务注解可以加上
    Insert语句导致Mysql数据库死锁问题ShedLock:一个比 Spring Quartz 更简单的分布式锁框架
    mcbilla
    mcbilla
    一个普通的干饭人🍚
    Announcement
    type
    status
    date
    slug
    summary
    tags
    category
    icon
    password
    🎉欢迎来到飙戈的博客🎉
    -- 感谢您的支持 ---
    👏欢迎学习交流👏