Posted on ::

在生产环境中,我们经常会遇到数据库连接池打满、锁等待超时甚至服务雪崩的问题。排查时往往发现,罪魁祸首并不是某条具体的“慢SQL”,而是“长事务”(Long Transaction)。

长事务是指持有数据库事务时间过长的操作。它可能包含多条执行很快的 SQL,但由于混合了 RPC 调用、复杂的业务逻辑计算或 IO 操作,导致事务长时间不提交。这会引发锁资源无法释放、MVCC 回滚段膨胀等严重后果。

本文将分享一种在 Java Spring Boot 体系下,完全不侵入业务代码,基于基础设施层实现的长事务与死锁监控方案。


核心设计思路

不同于简单的 AOP 耗时统计,生产级的事务监控需要解决以下几个痛点:

  1. 准确性:必须精准识别事务的物理提交/回滚时间,而不仅仅是方法执行时间。
  2. 去噪:Spring 事务支持嵌套(Propagation),我们只关心最外层事务,避免日志爆炸。
  3. 现场保留:发现长事务或死锁时,需要保留 TraceID、线程堆栈、当前 SQL 及用户上下文。
  4. 隔离性:监控数据的落库不能影响主业务事务,必须异步且独立。

整体架构图

我们利用 Spring 强大的事务同步机制(Transaction Synchronization)来实现这一目标。


技术实现拆解

1. 切入点:AOP 与 TransactionSynchronizationManager

最直观的做法是用 AOP 环绕通知(@Around)拦截 @Transactional 注解。但单纯的 AOP 无法感知事务是否真正提交(例如事务可能在 finally 块中才由 TransactionManager 完成 commit)。

更“资深”的做法是:在 AOP 中仅做入口拦截,利用 TransactionSynchronizationManager 注册一个回调钩子,监听事务的生命周期。

@Aspect
@Component
public class TransactionMonitorAspect {

    @Around("@annotation(org.springframework.transaction.annotation.Transactional)")
    public Object monitorTransaction(ProceedingJoinPoint pjp) throws Throwable {
        // 1. 检查当前是否是新事务(最外层事务)
        // TransactionAspectSupport.currentTransactionStatus().isNewTransaction() 在此处可能尚不可用
        // 通常结合 ThreadLocal 标记或 TransactionSynchronizationManager.isActualTransactionActive() 判断
        boolean isOuterTransaction = !TransactionSynchronizationManager.isSynchronizationActive();

        if (isOuterTransaction) {
            // 2. 初始化事务同步管理器,开启同步
            TransactionSynchronizationManager.initSynchronization();
            // 3. 注册我们的监控钩子
            TransactionSynchronizationManager.registerSynchronization(
                new TransactionMonitorSynchronization(System.currentTimeMillis(), TraceContext.getTraceId())
            );
        }

        try {
            return pjp.proceed();
        } finally {
            // Spring 的 TransactionInterceptor 会处理清理工作,这里主要是防守
        }
    }
}

2. 核心逻辑:TransactionMonitorSynchronization

这是实现监控的核心类,继承 TransactionSynchronizationAdapter。我们重点关注 afterCompletion 方法,它会在事务 commit 或 rollback 完成后被回调。

public class TransactionMonitorSynchronization extends TransactionSynchronizationAdapter {

    private final long startTime;
    private final String traceId;
    private static final long SLOW_THRESHOLD = 5000L; // 5秒视为长事务

    public TransactionMonitorSynchronization(long startTime, String traceId) {
        this.startTime = startTime;
        this.traceId = traceId;
    }

    @Override
    public void afterCompletion(int status) {
        long duration = System.currentTimeMillis() - startTime;

        // 场景一:耗时超过阈值,标记为长事务
        if (duration > SLOW_THRESHOLD) {
            handleLongTransaction(duration, status);
        }
        
        // 场景二:虽然耗时短,但发生了死锁异常(需要结合 Exception 捕获机制)
        // 注意:afterCompletion 只能拿到 status,具体的 Exception 需要在 AOP 层或 ExceptionHandler 中传递进来
    }
    
    private void handleLongTransaction(long duration, int status) {
        // 采集当前线程信息、Request信息等
        TransactionInfo info = new TransactionInfo();
        info.setTraceId(traceId);
        info.setDuration(duration);
        info.setThreadName(Thread.currentThread().getName());
        info.setStatus(status == STATUS_COMMITTED ? "COMMIT" : "ROLLBACK");
        
        // 异步落库
        AsyncLogService.save(info);
    }
}

3. 精准捕获死锁与锁等待

Spring 的 DataAccessException 封装了底层的 JDBC 异常。为了区分是普通报错还是数据库死锁,我们需要解包异常链,检查 MySQL 的 Error Code。

  • 1213: Deadlock found (死锁)
  • 1205: Lock wait timeout exceeded (锁等待超时)

这一步通常可以在全局异常处理器或 AOP 的 catch 块中增强:

public boolean isLockError(Throwable t) {
    Throwable root = ExceptionUtils.getRootCause(t); // 使用 Apache Commons Lang
    if (root instanceof SQLException) {
        int errorCode = ((SQLException) root).getErrorCode();
        return errorCode == 1213 || errorCode == 1205;
    }
    return false;
}

4. 丰富的上下文采集

光知道“慢”是不够的,我们需要知道“为什么慢”。在 handleLongTransaction 中,我们可以采集以下关键信息:

  • Java 线程信息Thread.currentThread().getName(),定位是哪个业务线程。
  • Web 上下文:通过 RequestContextHolder 获取请求 URL、User ID、客户端 IP。
  • 全链路 TraceID:从 MDC (Mapped Diagnostic Context) 中提取 traceId,方便去日志系统(如 ELK、SkyWalking)查询完整的调用链路。
  • 数据库现场(高级):如果权限允许,甚至可以在检测到异常时,查询 MySQL performance_schema,记录当前的 THREAD_ID 和锁持有情况。

5. 异步落库与新事务

监控日志的保存绝对不能影响主业务事务,也不能复用当前已经结束(或回滚)的事务连接。

因此,落库操作必须:

  1. 异步执行:使用线程池或 @Async
  2. 独立事务:使用 Propagation.REQUIRES_NEW
@Service
public class AsyncLogService {

    @Async("monitorThreadPool") // 专用线程池
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void save(TransactionInfo info) {
        // 保存到独立的监控表或发送到 Kafka/ES
        transactionHistoryRepository.save(info);
    }
}

6、 业务开发中的长事务治理建议

监控只是手段,解决问题才是目的。在日常开发中,为了避免触发上述报警,建议大家遵循以下原则:

1. 事务颗粒度“最小化”

错误示范:

@Transactional
public void buyTicket() {
    // 1. 远程调用查询库存 -> 耗时不可控!
    inventoryService.check(); 
    // 2. 数据库操作
    db.update();
    // 3. 发送邮件通知 (IO操作) -> 耗时!
    emailService.send();
}

正确做法: 禁止将远程调用、IO 操作放入事务中,可做异步化处理,将非 DB 操作移出 @Transactional 方法。

2. 批量操作分批次

大批量数据(如 10 万条)的 Update/Delete,不要在一个事务中做完。建议分批提交(如每 1000 条 commit 一次),避免长时间占用 undo log 和锁。

3. 警惕死锁

严格规范表资源的访问顺序。如果业务 A 先锁表 1 再锁表 2,业务 B 必须保持同样的顺序,严禁反向操作。


总结

这套方案通过组合 Spring 的 AOPTransactionSynchronizationManager,实现了对业务代码零侵入的监控。其核心技术价值在于:

  1. 生命周期对齐:使用 afterCompletion 确保统计的是事务持有的完整物理时间(包括 commit 的 IO 时间),比单纯的方法耗时统计更准确。
  2. 最外层识别:利用 Spring 事务状态判断,避免了嵌套事务导致的监控数据冗余。
  3. 异常归因:通过底层 SQL Error Code 解析,精准区分“业务慢”和“数据库锁冲突”。
  4. 全链路串联:集成 TraceID,打通了应用监控与数据库监控的壁垒。
Table of Contents