转账的业务控制

手动实现转账业务的事务控制:原理、实现与问题排查

转账是金融类业务的核心场景,其核心诉求是保证资金转移的原子性——要么转出、转入操作全部成功,要么全部失败,避免出现“钱扣了但没到账”或“钱到账但没扣”的资金不一致问题。在未使用Spring声明式事务(如@Transactional)的场景下,手动控制事务是基础且关键的实现方式。本文将从事务原理、代码实现、常见问题排查三个维度,完整讲解手动转账业务的事务控制。

一、转账业务与事务的核心关联

1. 事务的ACID原则在转账中的体现

事务是数据库操作的最小不可分割单元,其ACID特性是转账业务的核心保障:

  • 原子性(Atomicity):转账的“扣减转出人余额”和“增加转入人余额”是一个整体,要么全成、要么全败;
  • 一致性(Consistency):转账前后,转出人+转入人的总资金不变(比如张三转500给李四,两人总资金仍为2000);
  • 隔离性(Isolation):多用户同时转账时,彼此操作互不干扰;
  • 持久性(Durability):转账成功后,数据永久保存到数据库,不会因系统崩溃丢失。

2. 手动事务控制的核心目标

手动控制的本质是接管数据库事务的生命周期:替代数据库默认的“自动提交(autoCommit=true)”,手动开启事务→执行业务逻辑→成功则提交事务→失败则回滚事务,最终释放数据库连接。

二、手动实现转账事务控制的完整流程

以Spring+MySQL+C3P0+DBUtils技术栈为例,完整实现手动转账事务控制。

1. 环境准备

(1)数据库表设计

CREATE DATABASE IF NOT EXISTS spring;
USE spring;
-- 账户表
CREATE TABLE IF NOT EXISTS account (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) UNIQUE NOT NULL,
    money DOUBLE NOT NULL DEFAULT 0
);
-- 插入测试数据
INSERT INTO account (name, money) VALUES ('张三', 500), ('李四', 1500);

(2)核心依赖(Maven)

<!-- Spring核心 -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>5.0.2.RELEASE</version>
</dependency>
<!-- 数据库连接 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.28</version>
</dependency>
<!-- C3P0连接池 -->
<dependency>
    <groupId>c3p0</groupId>
    <artifactId>c3p0</artifactId>
    <version>0.9.1.2</version>
</dependency>
<!-- DBUtils简化JDBC -->
<dependency>
    <groupId>commons-dbutils</groupId>
    <artifactId>commons-dbutils</artifactId>
    <version>1.6</version>
</dependency>

2. 核心组件设计

(1)实体类:Account

映射数据库账户表,封装账户信息:

package com.xq.pojo;

public class Account {
    private Integer id;
    private String name;
    private Double money;

    // 无参/有参构造、getter/setter、toString
    public Account() {}
    public Account(String name, Double money) {
        this.name = name;
        this.money = money;
    }

    // getter/setter省略
    @Override
    public String toString() {
        return "Account{id=" + id + ", name='" + name + "', money=" + money + "}";
    }
}

(2)工具类1:ConnectionUtils(数据库连接工具)

保证线程内数据库连接唯一,避免多线程下连接混乱:

package com.xq.utils;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.mchange.v2.c3p0.ComboPooledDataSource;
import java.sql.Connection;
import java.sql.SQLException;

@Component
public class ConnectionUtils {
    // 线程本地变量:存储当前线程的Connection
    private ThreadLocal<Connection> tl = new ThreadLocal<>();
    @Autowired
    private ComboPooledDataSource dataSource;

    // 获取当前线程的Connection
    public Connection getConnection() {
        try {
            Connection conn = tl.get();
            if (conn == null) {
                // 从连接池获取连接
                conn = dataSource.getConnection();
                tl.set(conn);
            }
            return conn;
        } catch (SQLException e) {
            throw new RuntimeException("获取数据库连接失败", e);
        }
    }

    // 移除当前线程的Connection
    public void removeConnection() {
        tl.remove();
    }
}

(3)工具类2:TransactionManager(事务管理器)

封装事务的开启、提交、回滚、释放连接操作,解耦事务逻辑与业务逻辑:

package com.xq.utils;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.sql.Connection;
import java.sql.SQLException;

@Component
public class TransactionManager {
    @Autowired
    private ConnectionUtils connectionUtils;

    // 开启事务
    public void beginTransaction() {
        try {
            Connection conn = connectionUtils.getConnection();
            // 关闭自动提交,开启手动事务
            conn.setAutoCommit(false);
        } catch (SQLException e) {
            throw new RuntimeException("开启事务失败", e);
        }
    }

    // 提交事务
    public void commit() {
        try {
            Connection conn = connectionUtils.getConnection();
            conn.commit();
        } catch (SQLException e) {
            throw new RuntimeException("提交事务失败", e);
        }
    }

    // 回滚事务
    public void rollBack() {
        try {
            Connection conn = connectionUtils.getConnection();
            conn.rollback();
        } catch (SQLException e) {
            throw new RuntimeException("回滚事务失败", e);
        }
    }

    // 释放连接
    public void release() {
        try {
            Connection conn = connectionUtils.getConnection();
            // 恢复自动提交(避免影响下一次使用)
            conn.setAutoCommit(true);
            // 归还连接到连接池
            conn.close();
            // 移除线程本地变量中的连接
            connectionUtils.removeConnection();
        } catch (SQLException e) {
            throw new RuntimeException("释放连接失败", e);
        }
    }
}

(4)Dao层:AccountDao(数据访问层)

封装账户的查询、更新操作,使用DBUtils简化JDBC:

package com.xq.dao;

import com.xq.pojo.Account;
import com.xq.utils.ConnectionUtils;
import org.apache.commons.dbutils.QueryRunner;
import org.apache.commons.dbutils.handlers.BeanHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
import java.sql.SQLException;

@Repository
public class AccountDao {
    @Autowired
    private QueryRunner queryRunner;
    @Autowired
    private ConnectionUtils connectionUtils;

    // 根据姓名查询账户
    public Account findAccountByName(String name) {
        try {
            String sql = "SELECT * FROM account WHERE name = ?";
            // 手动传入当前线程的Connection,保证事务一致性
            return queryRunner.query(connectionUtils.getConnection(), sql, 
                                     new BeanHandler<>(Account.class), name);
        } catch (SQLException e) {
            throw new RuntimeException("查询账户失败", e);
        }
    }

    // 更新账户信息
    public void updateAccount(Account account) {
        try {
            String sql = "UPDATE account SET money = ? WHERE name = ?";
            queryRunner.update(connectionUtils.getConnection(), sql,
                               account.getMoney(), account.getName());
        } catch (SQLException e) {
            throw new RuntimeException("更新账户失败", e);
        }
    }
}

(5)Service层:AccountServiceImpl(核心业务层)

整合事务控制与转账业务逻辑,是手动事务控制的核心:

package com.xq.service.impl;

import com.xq.dao.AccountDao;
import com.xq.pojo.Account;
import com.xq.service.AccountService;
import com.xq.utils.TransactionManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class AccountServiceImpl implements AccountService {
    @Autowired
    private AccountDao accountDao;
    @Autowired
    private TransactionManager transactionManager;

    @Override
    public void transfer(String sourceName, String targetName, Double money) {
        // 定义变量保存原始异常,避免finally覆盖
        Exception originalException = null;

        try {
            // 1. 开启事务
            transactionManager.beginTransaction();
            System.out.println("【事务】开启手动事务");

            // 2. 业务逻辑:查询账户
            Account sourceAccount = accountDao.findAccountByName(sourceName);
            Account targetAccount = accountDao.findAccountByName(targetName);
            System.out.println("【查询】转出人账户:" + sourceAccount);
            System.out.println("【查询】转入人账户:" + targetAccount);

            // 3. 校验账户有效性
            if (sourceAccount == null || targetAccount == null) {
                throw new RuntimeException("转出人或转入人不存在!");
            }
            // 校验余额(注意:>= 才符合业务逻辑,避免500转500时无法执行)
            if (sourceAccount.getMoney() < money) {
                throw new RuntimeException("转出人余额不足!当前余额:" + sourceAccount.getMoney() + ",转出金额:" + money);
            }

            // 4. 执行转账操作
            sourceAccount.setMoney(sourceAccount.getMoney() - money);
            targetAccount.setMoney(targetAccount.getMoney() + money);
            // 更新转出人账户
            accountDao.updateAccount(sourceAccount);
            System.out.println("【操作】扣减转出人余额完成");

            // 模拟异常(测试事务回滚)
            // int i = 10 / 0;

            // 更新转入人账户
            accountDao.updateAccount(targetAccount);
            System.out.println("【操作】增加转入人余额完成");

            // 5. 提交事务
            transactionManager.commit();
            System.out.println("【事务】事务提交成功");

        } catch (Exception e) {
            // 6. 捕获异常,回滚事务
            originalException = e;
            System.err.println("【异常】捕获到业务异常:" + e.getMessage());
            e.printStackTrace();
            try {
                transactionManager.rollBack();
                System.out.println("【事务】事务回滚成功");
            } catch (Exception rollbackE) {
                System.err.println("【异常】事务回滚失败:" + rollbackE.getMessage());
            }
            // 重新抛出异常,让调用方感知失败
            throw new RuntimeException("转账失败", e);

        } finally {
            // 7. 释放数据库连接(无论成功/失败都执行)
            try {
                transactionManager.release();
                System.out.println("【资源】释放数据库连接成功");
            } catch (Exception releaseE) {
                System.err.println("【异常】释放连接失败:" + releaseE.getMessage());
                // 若有原始异常,优先打印
                if (originalException != null) {
                    System.err.println("【核心异常】转账原始异常:");
                    originalException.printStackTrace();
                }
            }
        }
    }
}

(6)Spring配置文件:applicationContext.xml

配置数据源、组件扫描、Bean实例化:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context
        http://www.springframework.org/schema/context/spring-context.xsd">

    <!-- 组件扫描:扫描注解式Bean -->
    <context:component-scan base-package="com.xq"/>

    <!-- 配置C3P0数据源 -->
    <bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource">
        <property name="driverClass" value="com.mysql.cj.jdbc.Driver"/>
        <property name="jdbcUrl" value="jdbc:mysql://localhost:3306/spring?useSSL=false&serverTimezone=UTC"/>
        <property name="user" value="root"/>
        <property name="password" value="你的数据库密码"/>
    </bean>

    <!-- 配置QueryRunner(DBUtils),手动注入数据源 -->
    <bean id="queryRunner" class="org.apache.commons.dbutils.QueryRunner">
        <!-- 不传入数据源,避免DBUtils自动获取连接(破坏事务一致性) -->
        <constructor-arg index="0" value="#{null}"/>
    </bean>
</beans>

(7)测试类:TestAccount

验证手动事务控制效果:

package com.xq;

import com.xq.service.AccountService;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration("classpath:applicationContext.xml")
public class TestAccount {
    @Autowired
    private AccountService accountService;

    @Test
    public void testTransfer() {
        try {
            // 测试场景:张三转500给李四(张三余额500,李四1500)
            accountService.transfer("张三", "李四", 500.0);
            System.out.println("转账成功!");
        } catch (Exception e) {
            System.err.println("转账失败:" + e.getMessage());
        }
    }
}

三、手动转账事务控制的常见问题与排查

手动控制事务容易因细节疏漏导致问题,以下是高频问题及解决方案:

问题1:执行不到异常代码(如10/0)

现象

代码中写了int i=10/0;但未触发异常,进程正常退出。

根因

  • 余额判断条件错误:如用>代替>=,导致500>500为false,进不去业务分支;
  • 账户不存在:传入的转出人/转入人姓名在数据库中无数据,提前退出分支。

解决方案

  • 修正判断逻辑:if(sourceAccount.getMoney() >= money)
  • 加执行链路打印:在关键分支加日志,确认代码执行路径;
  • 校验数据库数据:确保转出人/转入人存在且余额符合条件。

问题2:异常被“静默处理”

现象

触发异常后无任何打印,进程正常退出(exit code 0)。

根因

  • catch块仅回滚事务,未打印异常、未重新抛出;
  • finally块的释放操作抛出新异常,覆盖原始异常;
  • 工具类(如TransactionManager)吞异常(catch块无处理)。

解决方案

  • catch块中强制打印异常堆栈(e.printStackTrace()),并重新抛出异常;
  • finally块的释放操作单独加try-catch,避免覆盖原始异常;
  • 工具类的所有方法禁止空catch块,必须打印+抛出异常。

问题3:事务控制失效(扣账成功但入账失败)

现象

异常触发后,转出人余额扣减了,但转入人余额未增加。

根因

  • Dao层未使用当前线程的Connection,而是从连接池新获取连接;
  • ConnectionUtils未保证线程内连接唯一(未用ThreadLocal)。

解决方案

  • Dao层操作时,手动传入ConnectionUtils获取的当前线程连接;
  • 确保ConnectionUtils用ThreadLocal存储连接,避免多线程混乱。

问题4:测试类提示“Class not found”

现象

进程退出码1,提示Class not found: "com.xq.TestAccount"

根因

  • 类名/包名拼写错误(大小写、字母错误);
  • 类文件未编译(target目录无.class文件);
  • JUnit运行配置错误(类名填错、模块选择错误)。

解决方案

  • 核对包名/类名与文件路径一致;
  • 执行Build -> Rebuild Project重新编译;
  • 右键测试类重新生成JUnit运行配置。

四、手动事务控制的优化建议

1. 统一异常处理

自定义业务异常(如TransferException),区分“业务异常”和“系统异常”,避免异常类型混乱:

public class TransferException extends RuntimeException {
    public TransferException(String message) { super(message); }
    public TransferException(String message, Throwable cause) { super(message, cause); }
}

2. 引入日志框架

替换System.out/err,使用SLF4J+Logback记录日志,便于生产环境排查问题:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Service
public class AccountServiceImpl implements AccountService {
    private static final Logger log = LoggerFactory.getLogger(AccountServiceImpl.class);

    @Override
    public void transfer(...) {
        try {
            // 业务逻辑
        } catch (Exception e) {
            log.error("转账失败:sourceName={}, targetName={}, money={}", 
                      sourceName, targetName, money, e);
            // 回滚+抛异常
        }
    }
}

3. 避免硬编码

将数据库连接信息、事务超时时间等配置到properties文件,通过Spring加载:

# db.properties
jdbc.driver=com.mysql.cj.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/spring?useSSL=false&serverTimezone=UTC
jdbc.user=root
jdbc.password=123456

4. 补充边界校验

增加对money的非空、非负校验,避免传入负数、null导致业务异常:

if (money == null || money <= 0) {
    throw new TransferException("转出金额必须大于0!");
}

五、总结

手动实现转账业务的事务控制,核心是掌控数据库连接的生命周期事务的提交/回滚逻辑,其优势是灵活性高(可定制事务粒度),缺点是代码冗余、易出错。

在实际开发中:

  • 小型项目/简单业务:手动事务控制足够满足需求;
  • 中大型项目/复杂业务:推荐使用Spring声明式事务(@Transactional),简化代码;
  • 无论哪种方式,都需遵循“异常必处理、连接必释放、事务必校验”的原则,保证资金数据的一致性。

本文的实现方案兼顾了“原理清晰”和“实战可用”,可直接落地到项目中,也可基于此扩展分库分表、分布式事务等复杂场景。

posted @ 2025-12-04 21:48  f-52Hertz  阅读(0)  评论(0)    收藏  举报