简介:面向Java初学者与课程设计练习者的模拟银行账户转账系统源码,围绕A、B两个初始金额均为1000元的账户展开,通过随机方向与随机金额互相转账,并在余额不足或账户余额为0时阻止交易,帮助理解图形界面事件处理、多线程协同与简单业务规则校验。压缩包共2个文件,均为java源码,整体约2KB,分别承载窗口界面与线程逻辑,体量轻便,便于直接阅读、运行和改造。目前已有4171人学习下载,适合用作课堂示例或小型实验参考。实现中包含“交易开始”“清屏”按钮,开始后按钮切换为“结束交易”,结束后将交易记录写入ab.txt,读者可据此掌握按钮状态切换、界面刷新、并发转账控制及文件保存等常见环节,也能按需扩展金额校验、异常处理和记录格式。
1. 余额扣成负数只花了 3 秒:模拟银行账户转账系统的第一道坎
「模拟银行账户转账系统」听起来像一份课设题目,但我第一次在本地用 200 个线程压这套代码时,账户余额在 3 秒内变成了负数——而方法上明明挂着@Transactional。这件事把银行账户转账系统真正的难点暴露得很清楚:钱从 A 挪到 B 只是最后一行UPDATE,真正决定这套代码能不能用的是扣款那一行的条件、加锁的顺序、事务的边界,以及失败之后怎么把钱找回来。
这份资源做的就是这样一套可运行的闭环:开户、余额查询、行内转账、交易流水、单笔与单日限额、对账任务,前端用 Element Plus 把转账页和流水页搭出来。它不接任何真实支付通道,但把资金变动该有的约束都留了下来。适合三类人:想弄明白事务和行锁到底怎么落到 SQL 上的后端新手,需要一套能直接改的业务骨架的熟手,以及要在本地复现并发问题做验证的测试同学。下面按「这套系统长什么样 → 一个请求怎么走 → 数据怎么记 → 怎么跑起来 → 怎么改」的顺序拆。
2. 技术栈与目录结构:这套转账系统用什么撑起事务与并发
2.1 业务边界:从开户到对账的闭环
先把边界的范围说清楚,否则读代码时很容易把注意力放错地方。这套系统覆盖的是行内、同币种、账户到账户的转账,核心链路是:用户在页面上选付款账户和收款账户,填金额,点确认;后端校验账户状态与余额,扣付款方、加收款方,同时落一笔订单和两条记账分录;异步任务按账户维度把余额和分录快照对一遍,不一致就报警。
不在范围内的部分同样重要:跨行清算、真实支付通道对接、外汇换算、利息计提、账单分期,这些都没有。模拟系统的价值不在于功能多,而在于把「约束」留下来。整套代码里我数了一下,真正撑住正确性的约束只有三条:账户状态必须正常、余额必须足够、同一个请求号只能成功一次。后面所有的表和锁,都是为这三条服务的。二次开发时你加功能,也建议先问一句「这个改动会不会破坏这三条」。
2.2 技术选型与版本清单
下面是这套代码实际用到的组合,版本号以pom.xml和package.json里的为准,本地跑之前对一下大版本就行,小版本差异一般不影响。
| 层次 | 选型 | 版本 | 在这套系统里承担的职责 |
|---|---|---|---|
| 后端框架 | Spring Boot | 2.7.x | Web 层、事务管理、定时任务 |
| 持久层 | MyBatis-Plus | 3.5.x | 单表 CRUD 走 Wrapper,扣款这类关键 SQL 手写 XML |
| 数据库 | MySQL / InnoDB | 8.0 | 行锁、唯一索引、DECIMAL金额 |
| 连接池 | HikariCP | Boot 内置 | 压测时最容易成为瓶颈的一环 |
| 缓存与分布式锁 | Redis | 6.x(可选) | 单机模式可以整个关掉,只影响限流和令牌 |
| 认证 | Spring Security + JWT | 5.7.x | 登录态、接口鉴权 |
| 前端 | Vue 3 + Element Plus + Vite | — | 转账表单、流水表格、账户卡片 |
| 构建 | Maven + JDK | 3.8 / 8 或 11 | 打包成可执行 jar |
| 压测 | JMeter 5.5 或 JUnit 并发用例 | — | 验证余额守恒 |
两个选型值得单独说一句。第一,金额字段一律用DECIMAL(18,2)加 Java 的BigDecimal,任何地方出现double都是埋雷,0.1 + 0.2那种误差在账务系统里就是账对不平。第二,持久层选 MyBatis-Plus 而不是 JPA,原因是扣款需要写UPDATE ... SET balance = balance - ? WHERE id = ? AND balance >= ?这种带条件的原子更新,用 JPA 得绕@Modifying加clearAutomatically,反而更绕。常见做法是:单表增删改查用 Wrapper 省事,涉及资金的三条 SQL 全部手写在 XML 里,改的时候一眼能看到条件。
2.3 目录结构:一次转账请求会经过哪些包
目录按职责分包,没有做 Maven 多模块,因为多模块会让第一次跑起来的人多踩两个坑(依赖顺序、启动类扫描路径),对一套教学向的代码不划算。
bank-transfer/ ├── src/main/java/com/demo/bank/ │ ├── BankApplication.java # 启动类,@EnableScheduling 开着对账任务 │ ├── common/ # Result 统一返回、BizException、全局异常处理 │ ├── controller/ # TransferController、AccountController │ ├── service/ # TransferService、AccountService、ReconcileService │ ├── mapper/ # AccountMapper、TransferOrderMapper、JournalMapper │ ├── entity/ # Account、TransferOrder、AccountJournal │ └── dto/ # TransferRequest、TransferResult ├── src/main/resources/ │ ├── application.yml # 公共配置 │ ├── application-dev.yml # 本地连接串,改成自己的库 │ └── mapper/AccountMapper.xml # 扣款、入账、加锁查询三条关键 SQL ├── sql/ │ ├── schema.sql # 建表语句 │ └── data.sql # 种子账户,含一个冻结状态的账户用于测异常分支 └── web/ # Vue 3 + Vite 前端 └── src/views/Transfer.vue一次转账请求的完整落点是:TransferController收参数并做 Bean Validation,TransferService开事务、占幂等坑、按账号升序加行锁、条件更新扣款、入账、改订单状态、写两条分录,最后ReconcileService在凌晨跑一遍对账。要改业务规则,八成落在TransferService那一个方法里;要改数据口径,去AccountMapper.xml;要加新页面,前端只动web/src/views。这个分层不算精细,但对二次开发足够友好——你至少知道自己该改哪一层,而不是在一堆 Util 里翻。
3. 转账主链路拆解:一个请求从 Controller 到落库的六个关键动作
3.1 入参 DTO:把能挡住的错误挡在事务外面
事务开启得越晚越好,因为事务一开就占着数据库连接,而连接池是有上限的。所以第一道关是 DTO 上的注解校验,参数不合法根本进不了 Service。
@Data public class TransferRequest { @NotBlank(message = "付款账户不能为空") private String fromAccountNo; @NotBlank(message = "收款账户不能为空") private String toAccountNo; @NotNull(message = "转账金额不能为空") @DecimalMin(value = "0.01", message = "转账金额必须大于 0") @Digits(integer = 16, fraction = 2, message = "金额最多两位小数") private BigDecimal amount; /** 前端进入转账页时生成一次,同一个页面重复点击沿用同一个值 */ @NotBlank(message = "幂等令牌不能为空") @Size(max = 64) private String requestNo; @Size(max = 64, message = "备注最多 64 个字符") private String remark; }@Digits(integer = 16, fraction = 2)这一行是血泪经验。前端输入框再怎么做限制,接口总能被 Postman 直接打,0.001这种金额一旦进来,DECIMAL(18,2)会静默四舍五入,账面上就出现一笔对不上的差额。@DecimalMin挡住 0 和负数,@Digits挡住精度溢出,两个注解合起来才算把金额挡住了。requestNo由前端在进入转账页时生成一个 UUID,页面不刷新就不换——这是后面幂等的基础,如果每次点击都新生成,那幂等就形同虚设。
Controller 层只做两件事:@Validated触发校验,以及从 JWT 里取当前用户,校验付款账户是否属于他。别把归属校验放进 Service 的事务里,那会让每个非法请求都白占一次连接。
3.2 核心服务:事务边界、加锁顺序和条件更新
这是整套代码最该逐行读的地方。方法不长,但每一行都在处理一个具体的失败场景。
@Service @RequiredArgsConstructor public class TransferService { private final AccountMapper accountMapper; private final TransferOrderMapper orderMapper; private final IdempotentService idempotentService; /** * 行内转账:占幂等坑、加锁、扣款、入账、落订单与分录,全在一个本地事务里 */ @Transactional(rollbackFor = Exception.class, timeout = 10) public TransferResult transfer(TransferRequest req) { // 1. 幂等:靠 t_transfer_order 上的唯一索引挡重复请求 Long orderId = idempotentService.tryLock(req.getRequestNo(), req.getFromAccountNo()); // 2. 按账号字典序升序加锁,A->B 和 B->A 两条路径锁的是同一个顺序,不会互相等 boolean fromFirst = req.getFromAccountNo().compareTo(req.getToAccountNo()) < 0; String firstNo = fromFirst ? req.getFromAccountNo() : req.getToAccountNo(); String secondNo = fromFirst ? req.getToAccountNo() : req.getFromAccountNo(); Account first = accountMapper.selectForUpdate(firstNo); Account second = accountMapper.selectForUpdate(secondNo); // 3. 业务校验必须放在锁内,否则读到的是加锁前的旧余额 if (first == null || second == null) { throw new BizException("ACCOUNT_NOT_FOUND", "账户不存在"); } Account from = fromFirst ? first : second; Account to = fromFirst ? second : first; if (!"NORMAL".equals(from.getStatus())) { throw new BizException("ACCOUNT_STATUS", "付款账户状态异常:" + from.getStatus()); } if (from.getAccountNo().equals(to.getAccountNo())) { throw new BizException("SAME_ACCOUNT", "不能给同一个账户转账"); } // 4. 扣款用带条件的 UPDATE 兜底,影响行数为 0 就是余额不足 int rows = accountMapper.debit(from.getId(), req.getAmount()); if (rows != 1) { throw new BizException("BALANCE_NOT_ENOUGH", "余额不足"); } accountMapper.credit(to.getId(), req.getAmount()); // 5. 订单置成功,写两条分录(借出、贷入) orderMapper.markSuccess(orderId, req.getAmount()); journalService.record(orderId, from, to, req.getAmount()); return TransferResult.success(orderId, from.getBalance().subtract(req.getAmount())); } }逐段说。第 1 步的幂等只保证「同一个 requestNo 不会产生第二笔订单」,它不保证业务成功,业务失败会抛出异常让整个事务回滚,具体机制在 3.3 展开。第 2 步的排序加锁是防死锁的关键,两个线程一个做 A→B 一个做 B→A,如果都按「先锁自己再锁对方」的顺序,就会形成环,MySQL 会直接返回Deadlock found when trying to get lock,这条我在第 5 章还会细说。
第 3 步的位置很容易写错。很多人习惯先selectById查余额、判断够不够,再去 update——这是典型的 check-then-act,两个线程可能同时读到 1000 块都判定够,然后各扣 800,余额变 -600。加了FOR UPDATE之后,第二个线程会阻塞到第一个提交,读到的是新余额,校验才有意义。
第 4 步的debit返回影响行数,这是最后一道保险。哪怕前面的锁因为某种原因没生效(比如事务传播配置错了、或者被this.自调用绕过了代理),WHERE balance >= #{amount}也会让这行更新匹配不到记录,rows == 0直接抛异常回滚。双重保险的意义在于:出错时是报错,不是默默把钱扣成负数。
timeout = 10是防止慢 SQL 把连接池拖死。压测时如果出现大量Connection is not available, request timed out,先看这个值够不够,再看有没有长事务——比如在事务里调了外部接口或者发了 MQ 消息。
3.3 幂等令牌:唯一索引比 Redis 更靠谱
幂等这件事,用 Redis 的SETNX也能做,但多一个组件就多一个「Redis 挂了怎么办」的问题。这套代码用的是数据库唯一索引,简单、可靠、失败可见。
@Service @RequiredArgsConstructor public class IdempotentService { private final TransferOrderMapper orderMapper; /** * 先占坑:插入一条 PROCESSING 订单,request_no 上有唯一索引 * 插入成功说明是首次请求,返回订单 id 继续走业务流程 */ @Transactional(propagation = Propagation.REQUIRES_NEW) public Long tryLock(String requestNo, String fromAccountNo) { TransferOrder order = new TransferOrder(); order.setRequestNo(requestNo); order.setFromAccountNo(fromAccountNo); order.setStatus("PROCESSING"); order.setAmount(BigDecimal.ZERO); try { orderMapper.insert(order); // uk_request_no 挡住重复 return order.getId(); } catch (DuplicateKeyException e) { TransferOrder exist = orderMapper.selectByRequestNo(requestNo); if ("SUCCESS".equals(exist.getStatus())) { // 已经成功过,直接把上次的结果返回,前端当成成功处理 throw new DuplicateSuccessException(exist.getId()); } throw new BizException("DUPLICATE_REQUEST", "请求已处理中,请勿重复提交"); } } }参数说明:requestNo来自前端,跨请求唯一;status有三个值PROCESSING / SUCCESS / FAILED;REQUIRES_NEW让占坑独立于外层事务提交。
这里有个不得不做的取舍。占坑用REQUIRES_NEW独立提交,好处是外层业务回滚后,这条记录还在,你可以凭它排查「用户说转账失败了但订单表里为什么有一条」;坏处是失败之后同一个requestNo再也进不来,用户重试必须换一个新的号。反过来,如果把占坑放进主事务里,失败后记录一起回滚,重试是自然的,但一旦进程在提交前挂掉,你什么都看不到。
我一般会选前者,然后在FAILED状态下允许「同一个 requestNo 二次尝试」:占坑捕获到DuplicateKeyException时,如果查到状态是FAILED,就把这行改回PROCESSING继续走,而不是直接拒。这样既留了审计痕迹,又不至于让用户换个页面重新生成令牌。代码里为了简洁没写这段,二次开发时值得补上。
提示:
request_no的唯一索引一定要建在数据库上,别只在代码里select一次判空。并发下「查不到就插入」这个动作本身就不是原子的。
3.4 行锁与余额更新:三条 SQL 定生死
MyBatis 的 XML 里,真正关键的就三条语句,其余都是普通 CRUD。
<!-- 1. 加锁查询,锁住这一行直到事务提交 --> <select id="selectForUpdate" resultType="com.demo.bank.entity.Account"> SELECT id, account_no, user_id, balance, status, version FROM t_account WHERE account_no = #{accountNo} FOR UPDATE </select> <!-- 2. 扣款:余额判断写进 WHERE,交给数据库原子执行 --> <update id="debit"> UPDATE t_account SET balance = balance - #{amount}, version = version + 1, update_time = NOW() WHERE id = #{id} AND status = 'NORMAL' AND balance >= #{amount} </update> <!-- 3. 入账:不需要判断余额,但状态要判 --> <update id="credit"> UPDATE t_account SET balance = balance + #{amount}, version = version + 1, update_time = NOW() WHERE id = #{id} AND status = 'NORMAL' </update>FOR UPDATE在 InnoDB 上加的是排他行锁,前提是account_no上有唯一索引——如果没有索引,这条语句会退化成锁全表,并发一上来整个库就卡住了。这是很多人第一次写行锁时踩的坑,而且现象很迷惑:单测全过,压测直接超时。
balance >= #{amount}写在WHERE里,是利用数据库把「读余额、比较、写余额」三步合成一个原子操作。Java 里再判断一次不是不行,但那只是给人看的,真正的正确性来自这一行。
version字段在纯行锁场景下其实是冗余的,因为FOR UPDATE已经把并发挡住了。留着它是为了两个场景:一是将来某些查询不想加锁,可以用乐观锁重试;二是排查问题时能看出这一行被改过几次。如果你打算把加锁方式换成乐观锁,扣款语句就要改成WHERE id = ? AND version = ?,并且要在 Service 里写重试循环——注意重试次数别超过 3 次,否则高冲突下会拖垮响应时间。
4. 数据模型与复式记账:五张表如何保证钱不会凭空消失
4.1 核心表结构:字段取舍的四个决定
CREATE TABLE t_account ( id BIGINT NOT NULL AUTO_INCREMENT, account_no VARCHAR(32) NOT NULL COMMENT '账号,对外展示', user_id BIGINT NOT NULL COMMENT '归属用户', balance DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '可用余额', frozen_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '冻结金额,预留给退款/挂账', status VARCHAR(16) NOT NULL DEFAULT 'NORMAL' COMMENT 'NORMAL/FROZEN/CLOSED', version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_account_no (account_no), KEY idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户表'; CREATE TABLE t_transfer_order ( id BIGINT NOT NULL AUTO_INCREMENT, request_no VARCHAR(64) NOT NULL COMMENT '幂等令牌', from_account_no VARCHAR(32) NOT NULL, to_account_no VARCHAR(32) NOT NULL, amount DECIMAL(18,2) NOT NULL, status VARCHAR(16) NOT NULL COMMENT 'PROCESSING/SUCCESS/FAILED', fail_reason VARCHAR(128) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_request_no (request_no), KEY idx_from_time (from_account_no, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='转账订单'; CREATE TABLE t_account_journal ( id BIGINT NOT NULL AUTO_INCREMENT, order_id BIGINT NOT NULL, account_id BIGINT NOT NULL, direction TINYINT NOT NULL COMMENT '1=借(出账) 2=贷(入账)', amount DECIMAL(18,2) NOT NULL, balance_after DECIMAL(18,2) NOT NULL COMMENT '记账后余额快照', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_acc_dir (order_id, account_id, direction), KEY idx_account_time (account_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='记账分录';四个决定值得说。
金额用DECIMAL(18,2)而不是BIGINT存分,是因为这套模拟系统要跟人打交道,页面上、对账报表里都是元,转来转去容易出错。真要做支付核心,用整数存分更保险,这个取舍看你的目标。
账号account_no单独一列并加唯一索引,是因为加锁查询走的就是它。id是自增主键,但如果用id加锁,前端传的就得是内部 id,等于把内部标识暴露出去,不好。
分录表上uk_order_acc_dir这个唯一索引是防重复记账的。同一个订单、同一个账户、同一个方向,只能有一条分录。万一因为重试导致业务代码跑了两遍,第二遍插入会被数据库直接拒掉,事务回滚,余额也就不会被加两次。这种「用约束兜底」的思路,在账务系统里比在代码里写一堆 if 判断可靠得多。
balance_after存记账后的余额快照,是为了对账。只存流水金额的话,对账时得把历史流水从头累加一遍才知道某个时间点的余额应该是多少;存了快照,直接拿账户余额和最后一条分录对比就行。
4.2 复式记账:一笔转账落两条分录
借贷记账法的规则很简单:一笔业务拆成两条,借方合计等于贷方合计,账才平。放到转账上,付款方是「借」(钱出去了),收款方是「贷」(钱进来了),金额相等。
@Service @RequiredArgsConstructor public class JournalService { private final JournalMapper journalMapper; private final AccountMapper accountMapper; /** * 一笔转账写两条分录,方向相反、金额相等 * 必须在转账主事务内调用,余额快照才是准的 */ public void record(Long orderId, Account from, Account to, BigDecimal amount) { // 重新查一次,拿到 update 之后的最新余额 BigDecimal fromAfter = accountMapper.selectBalance(from.getId()); BigDecimal toAfter = accountMapper.selectBalance(to.getId()); AccountJournal debit = new AccountJournal(); debit.setOrderId(orderId); debit.setAccountId(from.getId()); debit.setDirection(1); // 借:出账 debit.setAmount(amount); debit.setBalanceAfter(fromAfter); AccountJournal credit = new AccountJournal(); credit.setOrderId(orderId); credit.setAccountId(to.getId()); credit.setDirection(2); // 贷:入账 credit.setAmount(amount); credit.setBalanceAfter(toAfter); journalMapper.insert(debit); journalMapper.insert(credit); } }参数说明:direction用 1/2 而不是字符串,省空间也省得写错;balanceAfter重新查一次,不能直接用传入的from.getBalance(),那个是加锁时的旧值,扣款之后已经不准了。
如果哪天要加手续费,别急着在转账方法里再写一次扣款,正确做法是三笔分录:付款方出账amount + fee、收款方入账amount、手续费账户入账fee,借贷依然平衡。分红、退款、冲正都是同一个套路——先保证分录平衡,再去动账户余额。
注意:分录表和账户余额必须在同一个事务里写。分开写(比如分录走异步)会让对账任务永远在报差异,而且报的是假差异。
4.3 对账脚本:两条 SQL 找出所有不平的账
对账不一定非要写一套定时任务,两条 SQL 就能覆盖八成问题,本地开发时直接手跑更直接。
-- 查 1:账户余额与最后一条分录的余额快照不一致 SELECT a.account_no, a.balance, j.balance_after, j.id AS last_journal_id FROM t_account a JOIN t_account_journal j ON j.account_id = a.id JOIN ( SELECT account_id, MAX(id) AS max_id FROM t_account_journal GROUP BY account_id ) t ON t.account_id = j.account_id AND t.max_id = j.id WHERE a.balance <> j.balance_after; -- 查 2:借贷不平衡的订单(借方合计应等于贷方合计) SELECT order_id, SUM(CASE WHEN direction = 1 THEN amount ELSE -amount END) AS diff FROM t_account_journal GROUP BY order_id HAVING diff <> 0; -- 查 3:状态成功但没有分录的订单(事务写一半的痕迹) SELECT o.id, o.request_no, o.amount FROM t_transfer_order o LEFT JOIN t_account_journal j ON j.order_id = o.id WHERE o.status = 'SUCCESS' GROUP BY o.id, o.request_no, o.amount HAVING COUNT(j.id) = 0;查 1 定位的是「余额被改过但没记账」,通常是有人手工改库或者某段代码漏了写分录。查 2 定位的是「分录本身不平」,一般是record方法里第二条 insert 抛异常被吞了。查 3 最值得警惕,出现就说明事务边界有问题——理论上一个本地事务不可能只提交一半,出现这种东西基本意味着有人在事务里做了异步操作,或者用了REQUIRES_NEW。
线上跑的时候,把这三条挂到凌晨的定时任务里,结果非空就往告警群推一条,带上差异账户和订单号。别做成「自动修复」,账务系统的自动修复是另一个量级的工程,模拟系统里手查手改就够了。
5. 跑通与排障:从建库启动到 200 线程压测的避坑记录
5.1 本地启动:四步跑起来
先确认本机有 JDK 8(或 11)、Maven 3.8、MySQL 8、Node 16 以上。数据库连接串在application-dev.yml里,默认账号密码是root/root,不对就改。
# 1. 建库,字符集一定要指定,否则中文备注会乱码 mysql -uroot -p -e "CREATE DATABASE bank_demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 导表结构和种子数据(种子里有 A001/B001 两个余额各 10000 的账户,外加一个冻结账户) mysql -uroot -p bank_demo < sql/schema.sql mysql -uroot -p bank_demo < sql/data.sql # 3. 打包并启动后端,dev profile 会打印 SQL mvn clean package -DskipTests java -jar target/bank-transfer-1.0.0.jar --spring.profiles.active=dev # 4. 前端另开一个终端 cd web && npm install && npm run dev连接串里有几个参数不能省:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/bank_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false username: root password: root hikari: maximum-pool-size: 20 connection-timeout: 3000 max-lifetime: 1800000 transaction: default-timeout: 10serverTimezone=Asia/Shanghai少了它,插入的时间会差 8 小时,对账按时间范围筛的时候就会漏数据,这种问题看起来像玄学,其实是时区没对上。allowPublicKeyRetrieval=true是 MySQL 8 用caching_sha2_password插件时需要的,不加会报公钥检索失败。maximum-pool-size: 20是本地开发的值,压测时如果 200 线程同时打进来,20 个连接肯定不够,但别急着调大——先看是不是有长事务占着连接不放。
启动成功之后,用种子账户登录,进转账页,从 A001 转 100 到 B001,然后去流水页看两条记录。如果流水页空白但账户余额变了,说明分录没写进去,去查t_account_journal和事务配置。
5.2 并发压测:断言余额守恒,而不是断言不报错
验证这套代码对不对,最有效的断言不是「没有异常」,而是「总额守恒」。用 JUnit 写一个并发用例就够,比配 JMeter 快。
@Test void concurrentTransferKeepsTotalBalance() throws Exception { int threads = 200; BigDecimal amount = new BigDecimal("10.00"); BigDecimal before = accountMapper.sumBalance(List.of("A001", "B001")); ExecutorService pool = Executors.newFixedThreadPool(threads); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(threads); AtomicInteger success = new AtomicInteger(); AtomicInteger failed = new AtomicInteger(); for (int i = 0; i < threads; i++) { pool.submit(() -> { try { start.await(); // 所有线程一起冲,制造真实竞争 TransferRequest req = new TransferRequest(); req.setFromAccountNo("A001"); req.setToAccountNo("B001"); req.setAmount(amount); req.setRequestNo(UUID.randomUUID().toString()); transferService.transfer(req); success.incrementAndGet(); } catch (Exception e) { failed.incrementAndGet(); // 余额不足是可接受的失败 } finally { done.countDown(); } }); } start.countDown(); assertTrue(done.await(60, TimeUnit.SECONDS)); BigDecimal after = accountMapper.sumBalance(List.of("A001", "B001")); // 核心断言:总额必须一分不差 assertEquals(0, before.compareTo(after), "转账前后总额不守恒,差额 " + before.subtract(after)); // 辅助断言:成功笔数 * 单笔金额 = 收款方增量 System.out.println("成功 " + success.get() + " 笔,失败 " + failed.get() + " 笔"); }参数说明:threads = 200是为了让竞争充分暴露,本地机器开 500 也行;requestNo每笔随机生成,测的是并发扣款而不是幂等;done.await(60, ...)给足超时,否则锁冲突时容易误判。
如果总额不守恒,差额通常是 10 的整数倍,说明有一笔转账只扣了没加,或者只加了没扣——直接去查t_account_journal里借贷不平的订单。如果出现余额为负,那就是锁或者条件更新出了问题。这两种失败模式完全不一样,别混着排查。
再补一个幂等验证:把同一个requestNo连续打 10 次,确认只有一笔成功、订单表只有一条记录、余额只变一次。这个是最好写的测试,也是最容易在重构中被破坏的测试。
5.3 排查记录:五条踩坑,从现象倒推原因
一、事务失效,扣了款没入账
现象:压测时偶发总额不守恒,差额正好是一笔转账金额,日志里没有任何异常。 原因:transfer方法内部用this.xxx()调了另一个带@Transactional的方法,或者把transfer放在同一个类里被本类调用。Spring 的事务是基于代理的,this调用不走代理,注解等于没写。 解决:把需要独立事务的方法拆到另一个 Bean 里;或者注入自身代理(@Lazy注入自己);更稳妥的做法是收敛成「一个入口方法只开一次事务」,别到处挂注解。
二、死锁,两个方向同时转账
现象:压测日志里出现Deadlock found when trying to get lock; try restarting transaction,集中在 A→B 和 B→A 同时发生的时刻。 原因:加锁顺序不一致。线程 1 先锁 A 再锁 B,线程 2 先锁 B 再锁 A,形成环路。 解决:按账号(或 id)排序后加锁,见 3.2 的compareTo。另外,生产环境要捕获死锁异常做一次重试,因为即便顺序一致,间隙锁之类的场景仍可能产生死锁,业务层不能假设「永远不会死锁」。
三、余额出现负数
现象:t_account.balance出现 -50.00。 原因:先查后改。select查出余额 100,判断够,两个线程都通过,然后各自update扣 75。修改语句没带balance >= amount条件。 解决:条件更新兜底(3.4 的debit),并且检查影响行数。这一条和上一条经常同时出现,改完锁不改 SQL,或者改完 SQL 不改锁,都不算修好。
四、重复提交产生两笔订单
现象:用户点了一次确认,订单表出现两条记录,金额相同,时间差 200 毫秒。 原因:前端按钮没做防抖,且requestNo每次点击都重新生成,幂等形同虚设。 解决:requestNo在进入转账页时生成一次并保存在组件状态里,提交成功后清空;后端request_no建唯一索引,捕获DuplicateKeyException返回友好提示而不是 500。
五、金额精度误差,对账差一分钱
现象:对账脚本报差异,差额 0.01,手工核对时怎么算都是对的。 原因:某个 DTO 或中间计算用了double,或者BigDecimal除法没指定精度(divide不传RoundingMode会直接抛异常,但setScale不传模式会静默截断)。 解决:全链路BigDecimal,除法一律divide(x, 2, RoundingMode.HALF_UP),数据库字段DECIMAL(18,2),前端展示统一两位小数。改完再跑一遍对账 SQL,差值为空才算完。
提示:排查账务问题,第一件事永远是把「账户余额」「订单状态」「分录」三张表按订单号对一遍,比看日志快得多。
6. 二次开发:把模拟转账系统改成能验证风控规则的沙盘
这套代码的天花板不在功能,而在它把「资金变动」的骨架搭完整了。要往上加东西,我一般按三个方向改,每个方向都有对应的验证手段。
| 改造方向 | 改哪里 | 关键风险 | 验证方式 |
|---|---|---|---|
| 单笔/单日限额 | TransferService锁内、扣款前 | 限额校验放在事务外会超限 | 并发打满后统计当日累计金额 |
| 冻结与解冻 | 新增t_account_freeze,改余额口径 | 可用余额与冻结余额口径混用 | 对账 SQL 增加「余额 = 可用 + 冻结」 |
| 异步通知 | 订单成功后发 MQ / Webhook | 事务未提交就发消息,消费方查不到数据 | 消费端幂等 + 消息表补偿 |
限额这块最容易写错位置。很多人把限额校验放在 Controller 里,先用 Redis 计数再进事务,看起来没问题,但事务回滚之后计数不会退,用户明明转账失败了当天额度却没了。正确的位置是在TransferService的行锁之内、扣款之前:把当日累计金额从t_transfer_order里按from_account_no + created_at聚合出来(数据量大了再加一张日汇总表),和当前金额相加后跟阈值比,超了直接抛异常。因为整段在锁内,同一个账户的并发请求是串行的,不会出现两个请求各自算出「还没超」的情况。
冻结逻辑是另一个常见需求。做法是给账户加frozen_amount字段,冻结时balance - amount, frozen_amount + amount,解冻反向操作,实际扣款时只动frozen_amount。注意页面上展示的「可用余额」应该是balance,别把冻结金额算进去,否则用户会以为自己还能再转一笔。
异步通知有个经典翻车点:在事务里直接mqTemplate.send()。消息发出去的那一刻事务还没提交,消费方拿着订单号去查,查不到,只能重试或者丢弃。稳妥做法是本地消息表——在同一个事务里往t_message插一条待发送记录,再由定时任务扫描发送,发送成功标记状态。这套代码里没有 MQ,但ReconcileService的定时任务骨架可以直接拿来改。
验证改造是否成功,我有个固定动作:不写新测试,先把 5.2 那个守恒用例跑一遍。限额、冻结、通知这些改动都会经过转账主链路,守恒断言一挂,说明你的改动破坏了资金一致性,问题范围比读代码快十倍地缩小到了事务和 SQL 上。
说个自己的教训。早期我给这套代码加「转账手续费」,直接在transfer方法末尾补了一句「从付款方再扣 1 块」,单测全过,压测也过,直到跑对账才发现手续费账户的余额和分录永远差一截——因为我只扣了钱,没给手续费账户写贷方分录,账不平。从那以后,我每次动账户余额之前都强制走一遍「这笔钱从哪个账户出、进哪个账户、分录写几条、借贷是否相等」这四个问题,答不上来就不改代码。这套模拟系统的价值也在这里:它让你在没有任何真实资金风险的环境里,把这四个问题练成肌肉记忆。
希望这套代码能帮你在本地把并发和事务这两件事真正跑一遍,而不是停留在背八股。
本文还有配套的精品资源,点击获取