事务和隔离级别
事务保证一组 SQL 要么都成功,要么都失败。
最典型的例子是转账:
这两步必须作为一个整体。不能只扣了 A 却没给 B 加钱。
一、事务基本语法
START TRANSACTION; -- 开启事务
-- 执行一组 SQL
COMMIT; -- 提交:全部生效
ROLLBACK; -- 回滚:全部撤销
完整示例:转账
-- 准备数据
CREATE TABLE accounts (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
balance DECIMAL(10, 2) NOT NULL DEFAULT 0.00
) ENGINE=InnoDB;
INSERT INTO accounts (username, balance) VALUES ('alice', 1000.00), ('bob', 500.00);
-- 转账:alice 给 bob 转 100
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE username = 'alice';
UPDATE accounts SET balance = balance + 100 WHERE username = 'bob';
COMMIT;
如果中间出问题:
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE username = 'alice';
-- 发现有问题,撤销
ROLLBACK;
ROLLBACK 会撤销当前事务里所有未提交的修改,回到 START TRANSACTION 之前的状态。
BEGIN 和 START TRANSACTION 的区别
BEGIN; -- 等价于 START TRANSACTION
START TRANSACTION; -- 标准写法
两者基本一样,START TRANSACTION 是 MySQL 推荐写法。
二、自动提交 autocommit
MySQL 默认开启自动提交:每条 SQL 执行完自动 COMMIT。
-- 这条 SQL 执行后自动提交,不需要手动 COMMIT
UPDATE accounts SET balance = balance + 10 WHERE id = 1;
查看自动提交状态:
SELECT @@autocommit; -- 1 = 开启,0 = 关闭
关闭自动提交:
关闭后每条 SQL 都在事务里,需要手动 COMMIT 或 ROLLBACK。
用 START TRANSACTION 显式开启事务时,会临时关闭自动提交,直到 COMMIT 或 ROLLBACK。
三、ACID 四个特性
先重点理解原子性:"要么都成功,要么都失败"。
四、SAVEPOINT 保存点
事务太长时,可以设置保存点,回滚到指定位置而不是全部撤销。
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE username = 'alice';
SAVEPOINT sp1; -- 设置保存点
UPDATE accounts SET balance = balance + 100 WHERE username = 'bob';
-- 第二步出问题,只回滚到 sp1,第一步的扣款保留
ROLLBACK TO sp1;
-- 可以继续执行其他操作
UPDATE accounts SET balance = balance + 50 WHERE username = 'bob';
COMMIT;
语法
SAVEPOINT 保存点名; -- 创建保存点
ROLLBACK TO 保存点名; -- 回滚到保存点
RELEASE SAVEPOINT 保存点名; -- 删除保存点
五、并发问题
多个事务同时操作同一份数据时,会出现以下问题。
① 脏读(Dirty Read)
读到了别人还没提交的数据。
事务 A:把 alice 余额改成 800(还没 COMMIT)
事务 B:读到 alice 余额是 800
事务 A:ROLLBACK,余额回到 1000
事务 B:以为是 800,实际是 1000 → 脏读
② 不可重复读(Non-Repeatable Read)
同一个事务里,两次读同一行,结果不一样(被别人 UPDATE 了)。
事务 B:读 alice 余额 = 1000
事务 A:UPDATE alice 余额为 800,COMMIT
事务 B:再读 alice 余额 = 800 → 不可重复读
③ 幻读(Phantom Read)
同一个事务里,两次同样的查询,结果集行数不一样(被别人 INSERT 或 DELETE 了)。
事务 B:SELECT COUNT(*) FROM accounts WHERE balance > 500 → 结果是 2
事务 A:INSERT 一个新账户 balance=600,COMMIT
事务 B:再查 SELECT COUNT(*) → 结果是 3 → 幻读
三种问题的区别
六、隔离级别
隔离级别越高,问题越少,但并发性能越差。
READ UNCOMMITTED → 什么都挡不住
READ COMMITTED → 挡住脏读
REPEATABLE READ → 挡住脏读和不可重复读(MySQL InnoDB 默认)
SERIALIZABLE → 全挡住,但并发最差
MySQL InnoDB 默认 REPEATABLE READ,大多数场景够用。
查看当前隔离级别
SELECT @@transaction_isolation;
设置隔离级别
-- 当前会话生效
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 全局生效(需要权限)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
各级别适合什么场景
七、SELECT ... FOR UPDATE
先查询再更新时,用 FOR UPDATE 加锁,防止并发问题。
扣库存场景
START TRANSACTION;
-- 查库存并加排他锁,其他事务不能修改这行
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- 库存 > 0 才扣减
UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock > 0;
COMMIT;
FOR UPDATE 会对查到的行加排他锁:
- 其他事务读这行可以,但不能修改,必须等当前事务
COMMIT 或 ROLLBACK
- 必须在事务里使用,否则没意义
注意事项
① 必须在事务里使用(START TRANSACTION 之后)
② 查询条件尽量走索引,否则可能锁表
③ 锁持有时间越短越好,用完马上 COMMIT
FOR UPDATE vs 普通 SELECT
-- 普通查询:不加锁,并发时可能读到旧数据
SELECT stock FROM products WHERE id = 1;
-- 加锁查询:其他事务修改这行时会等待
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
八、并发更新的正确姿势
错误做法:应用层读改写
① 读出 view_count = 10
② 应用里 10 + 1 = 11
③ 写回 view_count = 11
并发时两个请求都读到 10,都写回 11,少加了一次。
正确做法:SQL 里直接算
UPDATE posts SET view_count = view_count + 1 WHERE id = 1;
数据库层面处理并发,不会丢更新。
九、事务只对 InnoDB 有效
CREATE TABLE accounts (
...
) ENGINE=InnoDB; -- 业务表必须用 InnoDB
如果表是 MyISAM,即使写了 START TRANSACTION,也没有事务能力。
十、事务使用原则