转账的业务控制
手动实现转账业务的事务控制:原理、实现与问题排查
转账是金融类业务的核心场景,其核心诉求是保证资金转移的原子性——要么转出、转入操作全部成功,要么全部失败,避免出现“钱扣了但没到账”或“钱到账但没扣”的资金不一致问题。在未使用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),简化代码; - 无论哪种方式,都需遵循“异常必处理、连接必释放、事务必校验”的原则,保证资金数据的一致性。
本文的实现方案兼顾了“原理清晰”和“实战可用”,可直接落地到项目中,也可基于此扩展分库分表、分布式事务等复杂场景。
浙公网安备 33010602011771号