当你的应用跑着跑着,突然所有请求都卡住了,后台日志里疯狂报“无法获取连接”或者“等待连接超时”,第一反应是不是数据库挂了?其实很多时候,数据库好端端的,真正的问题出在一个特别隐蔽的角落——你的事务太长了,长到把连接都占着不放,最后把连接池给活活耗死。今天我们就用大白话,把这个坑从头到尾扒一遍。

一、先搞清楚连接池是怎么回事

1.1 连接池就是数据库连接的“共享单车”

想象一下,你的应用要访问数据库,每次都得先跟数据库建立一条网络连接。这玩意儿不像打个电话那么快,背后要做TCP握手、身份验证、分配资源,一套流程下来可能要几十毫秒。如果每次请求都现建立连接,那性能就太差了。

所以大家都用连接池,就像小区门口的共享单车停放点。应用启动的时候,提前把一批连接放在池子里,谁要用就从池子里借一辆,用完再还回来。别人也能接着用,这样大家都不用在“造车”上浪费时间。

在Java的Spring Boot项目里,最常用的连接池就是HikariCP,它默认有最大连接数,比如10个。意思就是,同一时刻最多只能有10个人同时在骑车。

1.2 事务和连接是什么关系

这里有个关键点:在Spring里,一旦你的方法标上了@Transactional,Spring就会在这一刻从连接池里取一个连接,然后跟数据库说“我要开始一个事务了”。这个连接会被“绑”在这个事务上,直到事务提交或者回滚,连接才会还回池子里。

如果事务很快,那没问题,借车还车一气呵成。但如果事务特别慢,或者里面做了很多耗时的操作,那么这个连接就会一直被霸占着。更可怕的是,如果你的方法里同时有一堆请求进来,每个请求都开一个事务,每个事务都占着一个连接,那连接池很快就被掏空了。

二、长事务是怎么一步步把连接池耗尽的

2.1 从一个“慢查询”开始

我们写一个很常见的场景:一个导入功能,需要批量处理用户传过来的Excel数据,每一条数据都要做一些校验、查询、更新数据库的操作。为了确保所有数据要么全成功、要么全失败,程序员很自然地在最外层加了一个@Transactional,然后循环处理每一行。

// 技术栈:Java + Spring Boot + MyBatis + HikariCP

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class ImportService {

    @Autowired
    private UserMapper userMapper;

    /**
     * 批量导入用户
     * 每次调用这个方法,整个方法都在一个事务里
     */
    @Transactional // 这个注解一加,连接就被借出来了
    public void importUsers(List<UserExcelRow> rows) {
        for (UserExcelRow row : rows) {
            // 模拟一次数据库查询:检查用户是否存在
            User user = userMapper.findByUsername(row.getUsername());
            if (user == null) {
                user = new User();
                user.setUsername(row.getUsername());
                user.setAge(row.getAge());
                userMapper.insert(user); // 插入
            } else {
                user.setAge(row.getAge());
                userMapper.update(user); // 更新
            }

            // 模拟一次额外的远程调用,比如调用别的服务获取用户手机号
            // 这个调用如果超时,就会让事务卡住更久
            String phone = remoteService.fetchPhone(row.getUsername());
            user.setPhone(phone);
            userMapper.updatePhone(user);

            // 为了演示,我们故意在每一条数据之间睡上500毫秒
            // 现实中可能是复杂的计算、调用外部API等慢操作
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

你看,这个事务里既有数据库操作,又有外部接口调用,还有Thread.sleep模拟慢处理。如果Excel里有100行数据,那这个方法至少要跑50秒。在这50秒里,这个事务占用的那个连接,其他请求完全不能用。

2.2 并发来了,连接池就成“停车场”了

假设连接池最大连接数是10,现在有10个用户同时调用了这个导入接口。每个调用都会开启一个事务,每个事务各自占用一个连接。好,这10个连接全被占用完了。

这时候,你的系统里可能还有别的请求,比如登录查询用户、查询订单,它们也需要连接。但它们到连接池一看,一辆“车”都没有了,于是只能在那里等着。HikariCP默认的等待超时时间是30秒左右,如果30秒之后还是等不到连接,就直接抛出异常。

现实里更糟糕的是,如果你的远程接口调用用了很长的超时时间,或者数据库上出现行锁等待,那这个事务可能会持续几分钟。这段时间内,所有需要连接的请求全部挂起,系统就像死机了一样。

2.3 你以为的“慢SQL”其实是“事务没提交”

很多人看到数据库里的慢查询日志,发现某个SQL执行了很长时间,以为就是这条SQL写得烂。但实际上,SQL本身可能执行得很快,只是它所在的事务早就开启并且一直没提交,导致连接一直不释放。连接池里的连接是有限的,别的请求拿不到连接,跟慢SQL本身没关系,纯粹是被前一个事务“占着茅坑不拉屎”。

三、怎么定位到是长事务的问题

3.1 看连接池监控

HikariCP自带监控指标,比如active(活跃连接数)、idle(空闲连接数)、wait(等待线程数)。如果你在监控面板上看到active长期等于最大连接数,而且wait一直在涨,那就可以断定是连接池不够用了。

在Spring Boot里,你可以在application.yml里配置HikariCP的指标暴露:

# 技术栈:Spring Boot 配置
spring:
  datasource:
    hikari:
      maximum-pool-size: 10        # 最大连接数
      connection-timeout: 30000    # 获取连接超时时间(毫秒)
      minimum-idle: 5              # 最小空闲连接数
      idle-timeout: 600000         # 空闲连接超时时间(毫秒)

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics

然后你访问/actuator/metrics/hikaricp.connections.active,就能看到当前活跃连接数。如果一直顶着10,那就是被占满了。

3.2 查数据库里有哪些长事务

不同的数据库查法不同。以MySQL为例,你可以执行下面的SQL,看看都有哪些事务开启了很久:

-- 查看当前所有正在执行的事务
SELECT 
  trx_id,
  trx_state,
  trx_started,
  TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds,
  trx_mysql_thread_id
FROM information_schema.innodb_trx
ORDER BY trx_started ASC;

如果发现某个事务的duration_seconds特别大,比如600秒,那基本可以确定这个事务把连接霸占了太久。然后用trx_mysql_thread_idinformation_schema.processlist里查它正在执行的SQL:

-- 查询线程ID对应的SQL
SELECT * FROM information_schema.processlist WHERE id = 具体线程ID;

这样你就能找到长事务是由哪段代码引起的。

3.3 打印连接池等待日志

HikariCP在获取不到连接时会打出很明显的警告日志,大概长这样:

HikariPool-1 - Connection is not available, request timed out after 30000ms (total=10, active=10, idle=0, waiting=5)

看到active=10idle=0waiting=5,就说明连接池已经满了,而且还有5个请求在排队。

四、怎么解决长事务导致的连接池耗尽

4.1 缩小事务范围——不要把无关操作放进事务里

这是最核心的一招。记住:事务只保护那些必须保证原子性的数据库操作,像调外部接口、发消息、计算、IO操作,一概不要放进事务里。

还是那个导入的例子,正确的做法是:先读取Excel,然后逐条在事务外做远程调用,最后再用一个短事务批量插入数据库。

// 技术栈:Java + Spring Boot + MyBatis

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

@Service
public class ImportServiceBetter {

    @Autowired
    private UserMapper userMapper;

    /**
     * 没有事务的方法,可以放心地做远程调用
     */
    public void importUsersBetter(List<UserExcelRow> rows) {
        List<User> usersToInsert = new ArrayList<>();

        for (UserExcelRow row : rows) {
            // 在外面调用远程接口,不占用事务连接
            String phone = remoteService.fetchPhone(row.getUsername());

            User user = new User();
            user.setUsername(row.getUsername());
            user.setAge(row.getAge());
            user.setPhone(phone);
            usersToInsert.add(user);

            // 不需要Thread.sleep了,因为这里没有长事务
        }

        // 最后一次性批量插入,这个事务会非常短
        batchInsertUsers(usersToInsert);
    }

    /**
     * 这个方法专门用来做批量插入,事务范围很小
     */
    @Transactional
    public void batchInsertUsers(List<User> users) {
        for (User user : users) {
            userMapper.insert(user);
        }
    }
}

这样,事务只发生在最后的批量插入里,最多几十毫秒,连接很快就能还回去。

4.2 用编程式事务替代声明式事务

如果业务逻辑确实需要在一个方法里先查询、再判断、再更新,并且要求中间不能有别人打断,那可以尝试用TransactionTemplate手动控制事务边界,比注解更灵活。

// 技术栈:Java + Spring + MyBatis

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;

@Service
public class OrderService {

    @Autowired
    private TransactionTemplate transactionTemplate;

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 处理订单状态,只有两份数据库操作在事务里
     */
    public void processOrder(Long orderId) {
        // 先做耗时的准备工作,事务还没开始
        boolean preCheck = someSlowOperation(orderId);

        if (!preCheck) {
            return;
        }

        // 只有这里才开启事务,执行完立即提交
        transactionTemplate.execute(status -> {
            // 查询订单
            Order order = orderMapper.findById(orderId);
            // 更新状态
            order.setStatus("PROCESSED");
            orderMapper.update(order);
            return null; // 返回null表示正常提交
        });
    }

    private boolean someSlowOperation(Long orderId) {
        // 模拟耗时操作,比如调用外部API
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return true;
    }
}

这样事务只包含查询和更新,非常短。外部慢操作和事务完全脱离。

4.3 调整连接池配置——治标不治本

有人会说,那把连接池调大不就行了?比如改成50个。这确实能在一定时间内缓解问题,但连接数越多,数据库的压力越大,而且长事务依旧在占着资源。如果你有100个长事务,50个连接一样会被打满。所以调大连接池只能作为临时缓解,不能根治。

正确的做法是:连接池的大小要根据数据库的CPU核数、磁盘IO、网络延迟来合理设置。一般经验值是CPU核数 * 2 + 1。当然,这只是一个参考,具体要做压测。

比如你的数据库是4核,那连接池设成10其实已经够了,问题不出在池子小,而出在连接被长事务霸占。

4.4 给事务设置超时时间

Spring的@Transactional支持timeout属性,默认是-1,也就是不超时。你可以给它设置一个合理的超时值,比如5秒。如果事务执行超过5秒,就自动回滚,释放连接。

// 技术栈:Java + Spring Boot

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class TimeoutDemoService {

    /**
     * 设置事务超时时间为5秒,超过5秒直接回滚
     */
    @Transactional(timeout = 5)
    public void doWithinTimeout() {
        // 这里面的数据库操作必须在5秒内完成
        // 否则事务回滚,连接释放
        // 模拟一个很慢的操作
        try {
            Thread.sleep(10000); // 睡10秒,必然超时
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

注意,timeoutThread.sleep并不起作用,它主要监视数据库交互的时间。但是当你的事务里真的有数据库操作一直不返回,比如锁等待,这个超时就可以救你。不过它并不能解决“在事务里调外部接口”这种问题,因为外部接口调用不会触发事务超时检查。所以还是得靠缩小事务范围。

4.5 避免在事务中使用远程调用

这是我特别想强调的一点。远程调用的不可控因素太多了:网络抖动、服务响应慢、对方宕机。如果你在事务里调用远程服务,那这个事务的生命周期就完全交给了对方。对方拖你10秒,你的连接就白占10秒。如果对方一直不返回,你设了超时还好,没设超时就等于连接无限期被霸占。

所以在设计接口时,一定要把远程调用和数据库事务彻底分开。如果你必须在一个事务里完成“扣库存”和“通知第三方”这两个动作,那应该先提交事务,再发送通知。如果担心提交后通知失败,可以用消息表或者消息队列来做最终一致性,而不要把通知操作包在事务里。

4.6 分页或分批处理大数据量操作

如果一次要处理几千上万条数据,就算没有远程调用,在一个事务里执行太多次SQL也会让事务变得很长。因为事务期间,数据库的redo log、undo log都在累积,锁也会一直被持有。正确的做法是分批提交,比如每处理100条就提交一次。

// 技术栈:Java + Spring Boot + MyBatis

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class BatchInsertService {

    @Autowired
    private UserMapper userMapper;

    private static final int BATCH_SIZE = 100;

    /**
     * 把大任务拆分成多个短事务,每批提交
     */
    public void batchProcess(List<User> allUsers) {
        // 外层没有事务注解
        for (int i = 0; i < allUsers.size(); i += BATCH_SIZE) {
            // 计算这一批的结尾下标
            int end = Math.min(i + BATCH_SIZE, allUsers.size());
            // 取出当前这一批
            List<User> subList = allUsers.subList(i, end);
            // 开启短事务处理这一批
            processBatch(subList);
        }
    }

    /**
     * 只处理一批数据,事务很短
     */
    @Transactional
    public void processBatch(List<User> users) {
        for (User user : users) {
            userMapper.insert(user);
        }
    }
}

注意:这里processBatch是同一个类里的方法,但batchProcess没有事务,processBatch有事务。因为processBatch是public方法,而且通过Spring代理调用,所以事务会生效。如果processBatch是private方法,事务就不生效了,这需要特别小心。

五、有哪些容易踩的坑

5.1 方法内部调用自己类里的加锁方法,事务失效

这是初学者最容易犯的错。Spring事务是通过AOP代理实现的,如果你在一个类内部,用this.xxx()去调用另一个带@Transactional的方法,事务是不会生效的。因为this不是代理对象,你直接调用了原始对象的方法,Spring拦截不到。

解决办法是:把带事务的方法放到另一个Bean里,或者注入自己,或者使用AopContext.currentProxy()

// 技术栈:Java + Spring Boot

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class UserService {

    @Autowired
    private UserService self; // 注入自己

    public void outerMethod() {
        // 必须通过self调用,事务才会生效
        self.innerMethod();
    }

    @Transactional
    public void innerMethod() {
        // 数据库操作
    }
}

5.2 事务里捕获了异常,却不回滚

Spring默认只在遇到运行时异常(RuntimeException)时回滚,如果遇到受检异常(如IOException)并且你没有注明rollbackFor,事务会提交而不是回滚。这会导致数据不一致,更重要的是,事务会被白白延长。

正确的做法是:在@Transactional上声明rollbackFor = Exception.class

// 技术栈:Java + Spring Boot

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class RollbackDemo {

    /**
     * 任何异常都回滚,避免数据错误
     */
    @Transactional(rollbackFor = Exception.class)
    public void doSomething() throws Exception {
        // 数据库操作
        // 如果这里抛出一个受检异常,也会回滚
        throw new Exception("模拟受检异常");
    }
}

5.3 大对象放进事务里缓存

有时候为了减少查询次数,你会把一个大List放在事务里反复使用。这个List本身不占用数据库连接,但它会拉长整个方法的执行时间,从而拉长事务时间。尽量在事务外把需要的数据准备好,事务内只做必要操作。

5.4 误以为MyBatis的查询会自己提交事务

MyBatis本身不管理事务,它用的是Spring管理的数据库连接。如果你在一个事务里执行了查询,这个查询会加入到当前事务中,连接不会释放。所以你看到代码里有很多select,以为它们是独立执行完就释放连接的,其实只要外面包着@Transactional,所有select操作都共用同一个连接,并且要等到整个事务结束才会释放。

六、怎么设计才能避免这个问题

6.1 明确事务的边界

在动手写代码前,先问自己:这个方法里,哪些操作必须同时成功或者同时失败?只有这些操作才需要放进事务。其他操作,比如写日志、发通知、调接口、算数据,一律放外面。

6.2 用好独立的事务传播行为

Spring的事务传播方式中,REQUIRES_NEW可以开启一个新事务,并挂起外部事务。如果你必须在一个长事务里调用另一个数据库操作,而这个操作又不想参与外部事务,你可以用REQUIRES_NEW。但这并不推荐经常使用,因为新事务会占用新的连接,在连接池紧张时反而加重负担。

// 技术栈:Java + Spring Boot

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AnotherService {

    /**
     * 这个事务会新建一个独立的连接,和外部事务互不干扰
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void independentTransaction() {
        // 独立的数据库操作
    }
}

更好的设计是尽量避免这种复杂传播,因为长事务+新事务很容易让你对连接的使用情况失去掌控。

6.3 引入异步处理

如果导入Excel这种任务本来就不需要实时返回结果,那么完全可以把它丢到消息队列或者异步线程池里执行。主线程立即返回“导入中”,后台慢慢处理。这样前台请求不会占用连接池。后台任务也要注意分批提交,不要把整个文件都放在一个大事务里。

// 技术栈:Java + Spring Boot

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class AsyncImportService {

    @Autowired
    private ImportServiceBetter importServiceBetter;

    /**
     * 异步执行导入,调用方不用等待
     */
    @Async
    public void asyncImport(List<UserExcelRow> rows) {
        // 这里执行导入
        importServiceBetter.importUsersBetter(rows);
    }
}

调用方直接调用asyncImport,方法会立刻返回,导入在后台线程里跑。后台线程里的事务都是短事务,连接池的压力就小很多。

6.4 监控和告警要跟上

连接池是系统的命脉之一。我建议你对下面几个指标设置监控告警:

  • 活跃连接数:连续1分钟超过最大连接数的80%,告警。
  • 连接获取等待时间:平均等待时间超过500毫秒,告警。
  • 活跃事务数:数据库里的活跃事务超过某个阈值,告警。
  • 事务平均执行时间:超过2秒,告警。

有了告警,你才能在故障发生前发现苗头,而不是等到连接池彻底耗尽、系统瘫痪了才从梦中惊醒。

七、连接池耗尽故障的排查步骤总结

你遇到这类问题时,按照下面的步骤来操作,能快速定位根因:

  1. 看应用日志,如果出现Connection is not available,直接确认连接池耗尽。
  2. 查HikariCP监控指标,确认active是否等于maximum-pool-size
  3. 执行information_schema.innodb_trx查询,找到持续时间最长的事务,记录下线程ID。
  4. 用线程ID去processlist里查正在执行的SQL,找到对应的代码位置。
  5. 检查代码,看看这个事务范围里是否包含了远程调用、大循环、Thread.sleep等操作。
  6. 根据代码逻辑,把不必要的操作移出事务,或者改成短事务。
  7. 如果问题紧急,可以先临时调大连接池或重启应用,但后续必须修复代码,否则还会再次发生。

八、总结一下这个坑的本质

连接池耗尽本质上不是因为连接池太小,而是因为有连接被借走之后迟迟不还。长事务就是那个“借了不还”的罪魁祸首。你只要记住一个原则:事务越短越好,连接占用越短越好。远程操作和耗时计算绝对不要和数据库事务混在一起。把每个事务都当成一个需要快速结束的短租合同,你就能从根上避免这种故障。

另外,不要过度依赖连接池调参来解决事务设计问题。调大连接池只会掩盖问题,让数据库承受更大的并发压力。科学的做法是:监控事务时长,优化事务边界,把长任务拆解分片,必要时采用异步处理。这样你的系统才能在高并发下稳稳地跑下去。

当然,这个坑也不只是MyBatis的锅,Spring的声明式事务让开启事务变得太容易了,一个注解下去,开发者很容易忽略背后的连接生命周期。但你只要理解了今天讲的连接释放逻辑,以后再看到@Transactional,就会本能地问一句:这个事务会不会太长?里面的操作都必要吗?有这个意识,比记住任何框架API都重要。

最后提醒一句,连接池耗尽往往不是突然发生的,它一定有一个慢慢积累的过程。如果你平时多盯一下指标,多注意一下事务耗时,很多事故都是可以避免的。希望今天的分享能让你在遇到类似问题时不再手忙脚乱。