7.1 什么是事务
事务(Transaction)是一组作为整体执行的 SQL 操作集合:要么全部成功(COMMIT),要么全部回滚到初始状态(ROLLBACK),不会存在"做了一半"的中间状态。
经典示例:银行转账
Alice 向 Bob 转账 100 元,需要两步:
① 从 Alice 账户扣 100 元
② 向 Bob 账户加 100 元
如果 ① 成功但 ② 失败(网络中断、程序崩溃),钱就凭空消失了!事务保证两步要么都成功,要么 ① 也回滚,账户余额保持不变。
SQLBEGIN; -- 或 START TRANSACTION(MySQL 两者等价)
UPDATE accounts SET balance = balance - 100
WHERE user_id = 1 AND balance >= 100; -- 扣款,同时检查余额
-- 检查是否成功(受影响行数为 0 表示余额不足)
-- 应用程序检查 affected_rows,为 0 则 ROLLBACK
UPDATE accounts SET balance = balance + 100
WHERE user_id = 2; -- 入账
COMMIT; -- 所有操作成功,提交事务
-- 如果中途出错,执行 ROLLBACK; 回滚所有变更
7.2 ACID 特性
ACID 是关系型数据库事务的四个核心性质:
- Atomicity 原子性 事务内的所有操作是一个不可分割的原子单元:要么全部执行,要么一个都不执行。实现机制:数据库记录 Undo Log(回滚日志),失败时逆向撤销所有操作。
- Consistency 一致性 事务执行前后,数据库必须满足所有已定义的约束(主键、外键、CHECK 约束等)。事务不能把数据库从一个合法状态变为非法状态。一致性更多是应用层的责任(正确编写业务逻辑),数据库提供约束支持。
- Isolation 隔离性 多个事务并发执行时,每个事务感觉自己是"独占"数据库的,互不干扰。隔离级别越高,并发安全性越好,但性能越差。实现机制:锁(悲观并发)或 MVCC(多版本并发控制,乐观并发)。
- Durability 持久性 已提交的事务,即使系统崩溃也不会丢失。实现机制:WAL(Write-Ahead Log,预写日志)/ Redo Log。数据先写入日志文件(顺序 I/O,极快),再异步刷新到数据文件。
7.3 事务控制语句
SQL-- 开始事务
BEGIN;
START TRANSACTION; -- MySQL 等价写法
-- 提交事务(永久保存所有变更)
COMMIT;
-- 回滚事务(撤销所有未提交的变更)
ROLLBACK;
-- SAVEPOINT:在事务中设置保存点,可部分回滚
BEGIN;
INSERT INTO orders (user_id, total_price) VALUES (1, 100);
SAVEPOINT sp1; -- 设置保存点
INSERT INTO order_items (...) VALUES (...); -- 如果这里失败
ROLLBACK TO sp1; -- 只回滚到 sp1,orders 的插入保留
COMMIT;
-- 查看当前自动提交状态(MySQL 默认开启 autocommit)
SHOW VARIABLES LIKE 'autocommit';
-- 关闭 autocommit(每条语句不再自动提交,需手动 COMMIT)
SET autocommit = 0;
7.4 四种并发问题
多个事务并发运行时,如果不加以控制,会出现以下问题:
① 脏读(Dirty Read)
事务 A 读取了事务 B 尚未提交的数据。如果 B 随后回滚,A 读到的就是"脏数据"。
| 时间 | 事务 A | 事务 B |
|---|---|---|
| T1 | UPDATE accounts SET balance = 200 WHERE id = 1;(未提交) | |
| T2 | SELECT balance FROM accounts WHERE id = 1; → 读到 200 | |
| T3 | ROLLBACK; (余额恢复为原来的值) | |
| T4 | 基于 200 进行业务计算 → 错误! |
② 不可重复读(Non-Repeatable Read)
同一事务内,两次读取同一行,结果不一样(另一事务在两次读取之间修改并提交了数据)。
| 时间 | 事务 A | 事务 B |
|---|---|---|
| T1 | SELECT balance → 100 | |
| T2 | UPDATE ... SET balance = 200; COMMIT; | |
| T3 | SELECT balance → 200(同一事务,两次读取结果不同!) |
③ 幻读(Phantom Read)
同一事务内,两次执行相同的范围查询,行数不同(另一事务插入了新行并提交)。
| 时间 | 事务 A | 事务 B |
|---|---|---|
| T1 | SELECT COUNT(*) FROM orders WHERE user_id = 1; → 5 行 | |
| T2 | INSERT INTO orders (user_id,...) VALUES (1,...); COMMIT; | |
| T3 | SELECT COUNT(*) FROM orders WHERE user_id = 1; → 6 行(幻行出现!) |
④ 丢失更新(Lost Update)
两个事务同时读取同一数据,各自修改后写回,后写入的覆盖了先写入的结果。
| 时间 | 事务 A(+100) | 事务 B(+200) |
|---|---|---|
| T1 | 读取 balance = 1000 | 读取 balance = 1000 |
| T2 | 计算 1000 + 100 = 1100 | 计算 1000 + 200 = 1200 |
| T3 | 写入 balance = 1100; COMMIT; | |
| T4 | 写入 balance = 1200; COMMIT;(A 的修改丢失!) |
7.5 四种隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 |
|---|---|---|---|---|
READ UNCOMMITTED | ❌ 存在 | ❌ 存在 | ❌ 存在 | 最高 |
READ COMMITTED | ✅ 解决 | ❌ 存在 | ❌ 存在 | 高 |
REPEATABLE READ | ✅ 解决 | ✅ 解决 | ⚠️ 部分* | 中 |
SERIALIZABLE | ✅ 解决 | ✅ 解决 | ✅ 解决 | 最低 |
* MySQL InnoDB 的 REPEATABLE READ 通过 MVCC + Next-Key Lock 解决了大部分幻读问题。PostgreSQL 的 REPEATABLE READ 也解决了幻读。
SQL-- 查看当前隔离级别
SELECT @@transaction_isolation; -- MySQL
SHOW transaction_isolation; -- MySQL
SHOW default_transaction_isolation; -- PostgreSQL
-- 设置会话级别的隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- MySQL
SET default_transaction_isolation TO 'READ COMMITTED'; -- PostgreSQL
-- 为单个事务设置隔离级别
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
-- ... 操作 ...
COMMIT;
各数据库默认隔离级别
- MySQL InnoDB:
REPEATABLE READ(防不可重复读,基本防幻读) - PostgreSQL:
READ COMMITTED(防脏读,性能较好,生产推荐) - Oracle:
READ COMMITTED - SQL Server:
READ COMMITTED
7.6 锁机制
锁的粒度
| 锁类型 | 粒度 | 并发性 | 适用场景 |
|---|---|---|---|
| 表级锁 | 整张表 | 低 | MyISAM、DDL 操作 |
| 行级锁 | 单行 | 高 | InnoDB(默认),高并发 OLTP |
| 间隙锁(Gap Lock) | 索引范围 | 中 | InnoDB REPEATABLE READ,防幻读 |
共享锁(S)与排他锁(X)
-
共享锁 (S Lock)
读锁。持有共享锁的事务可以读取数据,多个事务可以同时持有共享锁。但共享锁期间,其他事务不能修改该行。
SELECT ... LOCK IN SHARE MODE(MySQL)/SELECT ... FOR SHARE(PostgreSQL/MySQL 8+) -
排他锁 (X Lock)
写锁。持有排他锁的事务独占该行,其他事务既不能读(共享锁),也不能写(排他锁)。INSERT/UPDATE/DELETE 自动加排他锁;
SELECT ... FOR UPDATE手动加排他锁。
SQL-- FOR UPDATE:锁定查询到的行,防止其他事务修改(适合秒杀、库存扣减)
BEGIN;
SELECT stock FROM products
WHERE id = 42
FOR UPDATE; -- 加排他锁,其他事务尝试修改这行会等待
-- 检查库存并扣减
UPDATE products
SET stock = stock - 1
WHERE id = 42 AND stock > 0;
COMMIT; -- 提交后释放锁
7.7 死锁
死锁(Deadlock):事务 A 持有资源1的锁并等待资源2;事务 B 持有资源2的锁并等待资源1。两者互相等待,永远无法推进。
死锁示例
事务A:锁定 accounts 表 id=1,然后想锁 id=2
事务B:锁定 accounts 表 id=2,然后想锁 id=1
两者互相等待 → 死锁
数据库的死锁处理
InnoDB 有死锁检测机制:发现死锁时,选择一个"代价最小"的事务(通常是修改行数较少的)作为牺牲者(victim),强制回滚,并向该事务返回错误(Error Code: 1213)。应用层收到此错误后应重试事务。
如何避免死锁
- 固定加锁顺序:所有事务按相同顺序访问资源(如总是先锁 id 小的行)
- 缩短事务时长:减少事务持有锁的时间,快进快出
- 降低隔离级别:READ COMMITTED 比 REPEATABLE READ 死锁概率更低
- 一次性锁定所有资源:用
SELECT ... FOR UPDATE一次锁定所有需要修改的行,而不是分多次锁 - 避免长事务:不要在事务中做耗时的外部 I/O 操作(HTTP 请求等)
SQL-- 查看当前锁等待情况(MySQL)
SELECT * FROM information_schema.INNODB_TRX;
SELECT * FROM information_schema.INNODB_LOCKS;
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
-- MySQL 8.0+ 更简洁的方式
SELECT * FROM performance_schema.data_locks;
-- PostgreSQL 查看锁
SELECT * FROM pg_locks;
SELECT * FROM pg_stat_activity;