mysql菜鸟教程

专栏导航

15.5 实战:银行转账事务处理

      在学习了事务的ACID特性、开始事务、提交与回滚以及隔离级别之后,现在是时候将这些知识应用到实际的业务场景中了。银行转账是事务最经典的应用案例,它完美地体现了事务的原子性、一致性、隔离性和持久性。本节将通过一个完整的转账案例,演示如何在MySQL中实现安全可靠的转账操作,并探讨并发转账可能带来的问题及其解决方案。

一、场景描述

假设我们有一个简单的银行账户系统,包含一张 accounts 表,存储每个账户的余额。我们需要实现从账户A向账户B转账一定金额的功能。转账过程必须满足:

  1. 账户A的余额足够支付转账金额。

  2. 从账户A扣除金额。

  3. 向账户B增加相同金额。

  4. 整个过程要么全部成功,要么全部失败(例如,如果扣除后系统崩溃,则不能加款,扣除也必须撤销)。

此外,在高并发场景下,多个转账同时操作同一账户时,必须避免数据不一致(如超额支出)。

二、准备数据

首先,创建账户表并插入测试数据。

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提交或回滚

这证明了行锁可以防止并发冲突。

七、完整转账业务中的其他考虑

  1. 日志记录:转账成功后,应在另一张表中记录交易流水,便于审计和对账。

  2. 分布式事务:如果涉及跨数据库或跨系统转账,可能需要分布式事务协议(如 XA),但 MySQL 对 XA 支持有限,通常采用最终一致性方案。

  3. 性能优化:对于热点账户,频繁加锁可能导致性能瓶颈,可以考虑使用账户缓存、异步对账等策略。

八、总结

  • 银行转账是事务的典型应用,必须保证 ACID 特性。

  • 使用 START TRANSACTIONCOMMITROLLBACK 实现原子操作。

  • 通过 SELECT ... FOR UPDATE 加行锁避免并发冲突。

  • 可以根据业务选择合适的隔离级别,通常 MySQL 默认的 REPEATABLE READ 足够。

  • 封装存储过程可以使逻辑更清晰,并集中处理异常。

  • 实际生产环境还需考虑日志、分布式事务、性能优化等。

通过本节实战,你应该能够理解并实现一个安全可靠的转账功能,并能应对常见的并发问题。下一章我们将学习触发器和事件,进一步提升数据库的自动化能力。


所有评论

关于我 备案号:蜀ICP备2023042032号-1