mysql菜鸟教程
15.5 实战:银行转账事务处理
在学习了事务的ACID特性、开始事务、提交与回滚以及隔离级别之后,现在是时候将这些知识应用到实际的业务场景中了。银行转账是事务最经典的应用案例,它完美地体现了事务的原子性、一致性、隔离性和持久性。本节将通过一个完整的转账案例,演示如何在MySQL中实现安全可靠的转账操作,并探讨并发转账可能带来的问题及其解决方案。
一、场景描述
假设我们有一个简单的银行账户系统,包含一张 accounts 表,存储每个账户的余额。我们需要实现从账户A向账户B转账一定金额的功能。转账过程必须满足:
账户A的余额足够支付转账金额。
从账户A扣除金额。
向账户B增加相同金额。
整个过程要么全部成功,要么全部失败(例如,如果扣除后系统崩溃,则不能加款,扣除也必须撤销)。
此外,在高并发场景下,多个转账同时操作同一账户时,必须避免数据不一致(如超额支出)。
二、准备数据
首先,创建账户表并插入测试数据。
CREATE DATABASE IF NOT EXISTS bank;
USE bank;
CREATE TABLE accounts (
account_id INT PRIMARY KEY AUTO_INCREMENT,
account_name VARCHAR(50) NOT NULL,
balance DECIMAL(10,2) NOT NULL DEFAULT 0.00,
version INT NOT NULL DEFAULT 0, -- 用于乐观锁(可选)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 插入两个测试账户,初始余额各1000元
INSERT INTO accounts (account_name, balance) VALUES
('张三', 1000.00),
('李四', 1000.00);三、基础转账事务
我们先编写一个简单的转账事务,演示如何保证原子性。
-- 从张三(account_id=1)向李四(account_id=2)转账200元 START TRANSACTION; -- 步骤1:检查张三余额是否足够 SELECT balance FROM accounts WHERE account_id = 1 FOR UPDATE; -- 假设在应用程序中判断,如果余额 < 200,则执行 ROLLBACK -- 步骤2:扣除张三的余额 UPDATE accounts SET balance = balance - 200 WHERE account_id = 1; -- 步骤3:增加李四的余额 UPDATE accounts SET balance = balance + 200 WHERE account_id = 2; -- 步骤4:提交事务 COMMIT;
如果在步骤2和3之间发生错误,可以执行 ROLLBACK 撤销所有修改。SELECT ... FOR UPDATE 会对账户记录加行锁,防止其他事务同时修改,避免并发问题。
四、封装为存储过程
为了便于复用,可以将转账逻辑封装在存储过程中,并在过程中处理异常。
DELIMITER // CREATE PROCEDURE Transfer( IN from_id INT, IN to_id INT, IN amount DECIMAL(10,2), OUT result_code INT, -- 0成功,1余额不足,2其他错误 OUT result_msg VARCHAR(255) ) BEGIN DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET result_code = 2; SET result_msg = '转账过程发生异常,已回滚'; END; START TRANSACTION; -- 检查余额(加锁防止并发修改) SELECT balance INTO @bal FROM accounts WHERE account_id = from_id FOR UPDATE; IF @bal < amount THEN ROLLBACK; SET result_code = 1; SET result_msg = '余额不足'; ELSE UPDATE accounts SET balance = balance - amount WHERE account_id = from_id; UPDATE accounts SET balance = balance + amount WHERE account_id = to_id; COMMIT; SET result_code = 0; SET result_msg = '转账成功'; END IF; END // DELIMITER ;
调用存储过程:
CALL Transfer(1, 2, 200, @code, @msg); SELECT @code, @msg; SELECT * FROM accounts;
五、并发转账的挑战
现在考虑两个并发转账操作:
转账1:从张三向李四转 200 元。
转账2:从张三向王五转 800 元(假设王五是账户3)。
如果两个事务同时执行,不加控制可能发生什么?
丢失更新示例(不加锁)
假设张三有1000元,两个转账同时读取余额,都读到1000,然后各自扣除并更新。最终余额可能变成200(1000-200-800=0?实际上两个更新串行执行,最终余额会是1000-200=800,然后800-800=0,但因为是并发,可能后一个更新覆盖前一个,导致只扣除一个)。更典型的情况是:事务A读余额1000,事务B也读1000;事务A扣除200更新为800,事务B扣除800更新为200,事务B的更新覆盖了事务A,导致只扣除了800,实际应该扣除1000,但余额只剩200,账对不上。这就是丢失更新。
解决方案
1. 悲观锁(使用 SELECT ... FOR UPDATE)
在转账存储过程中,我们已经使用了 FOR UPDATE 对 from_id 的行加锁。这样,第一个事务锁定该行后,第二个事务试图同样 FOR UPDATE 时会等待,直到第一个事务提交或回滚。这保证了两个转账不会同时修改同一账户。
2. 乐观锁(使用版本号)
适用于并发冲突较少的场景。在表中增加一个 version 列,更新时检查版本号是否变化。
-- 转账时,先读取余额和版本号 SELECT balance, version INTO @bal, @ver FROM accounts WHERE account_id = from_id; -- 在更新时检查版本号 UPDATE accounts SET balance = balance - amount, version = version + 1 WHERE account_id = from_id AND version = @ver; -- 如果影响行数为0,说明版本号变化,需重试或报错
六、隔离级别的作用
不同的隔离级别对并发转账的影响:
读未提交:可能产生脏读,导致错误判断余额(例如读到另一个事务未提交的扣款)。
读已提交:避免了脏读,但可能出现不可重复读和幻读,但在转账场景中,不可重复读影响不大(我们通常只关心最新余额)。
可重复读(MySQL默认):通过 MVCC 保证同一事务内多次读取结果一致,加上 FOR UPDATE 锁,可有效避免并发问题。
可串行化:所有事务串行执行,性能低,但绝对安全。
在实际应用中,使用 REPEATABLE READ 加上适当的行锁已经足够。
测试并发转账
为了验证并发转账的安全性,可以开启两个 MySQL 客户端模拟并发。
会话A:
START TRANSACTION; SELECT * FROM accounts WHERE account_id = 1 FOR UPDATE; -- 锁定张三 -- 此时不提交,等待
会话B:
START TRANSACTION; SELECT * FROM accounts WHERE account_id = 1 FOR UPDATE; -- 会被阻塞 -- 直到会话A提交或回滚
这证明了行锁可以防止并发冲突。
七、完整转账业务中的其他考虑
日志记录:转账成功后,应在另一张表中记录交易流水,便于审计和对账。
分布式事务:如果涉及跨数据库或跨系统转账,可能需要分布式事务协议(如 XA),但 MySQL 对 XA 支持有限,通常采用最终一致性方案。
性能优化:对于热点账户,频繁加锁可能导致性能瓶颈,可以考虑使用账户缓存、异步对账等策略。
八、总结
银行转账是事务的典型应用,必须保证 ACID 特性。
使用 START TRANSACTION、COMMIT、ROLLBACK 实现原子操作。
通过 SELECT ... FOR UPDATE 加行锁避免并发冲突。
可以根据业务选择合适的隔离级别,通常 MySQL 默认的 REPEATABLE READ 足够。
封装存储过程可以使逻辑更清晰,并集中处理异常。
实际生产环境还需考虑日志、分布式事务、性能优化等。
通过本节实战,你应该能够理解并实现一个安全可靠的转账功能,并能应对常见的并发问题。下一章我们将学习触发器和事件,进一步提升数据库的自动化能力。

发表评论
所有评论