事务与隔离级别深度解析:从脏读防御到并发控制
什么是事务?为什么我们需要它?
在数据库世界中,事务(Transaction)是一个不可分割的工作单元,它要么全部执行成功,要么全部失败回滚。想象一下银行转账的场景:A向B转账100元,这个操作实际上包含两个步骤 - 从A账户扣除100元,向B账户增加100元。如果扣款成功但存款失败,或者扣款失败但存款成功,都会导致数据不一致。事务就是为了解决这类问题而生的。
事务的四大特性(ACID):
- 原子性(Atomicity):事务中的所有操作要么全部完成,要么全部不完成
- 一致性(Consistency):事务执行前后,数据库必须保持一致性状态
- 隔离性(Isolation):并发事务之间互不干扰
- 持久性(Durability):事务完成后,对数据的修改是永久性的
事务的基本操作:BEGIN、COMMIT、ROLLBACK
让我们通过实际的SQL示例来理解事务的基本操作:
-- 开始一个事务
BEGIN;
-- 执行一系列数据库操作
UPDATE accounts SET balance = balance - 100 WHERE user_id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE user_id = 'B';
-- 检查业务逻辑,决定提交还是回滚
-- 如果所有操作都成功,提交事务
COMMIT;
-- 或者在发生错误时回滚事务
-- ROLLBACK;
实际应用场景:电商订单处理
BEGIN;
-- 1. 扣减库存
UPDATE products SET stock = stock - 1 WHERE product_id = 123 AND stock > 0;
-- 2. 创建订单
INSERT INTO orders (order_id, user_id, product_id, quantity, total_amount)
VALUES ('ORD001', 'USER001', 123, 1, 99.99);
-- 3. 扣减用户余额
UPDATE users SET balance = balance - 99.99 WHERE user_id = 'USER001' AND balance >= 99.99;
-- 如果所有操作都成功,提交事务
COMMIT;
-- 如果任何一步失败(如库存不足、余额不足),执行回滚
-- ROLLBACK;
并发事务带来的问题:脏读、不可重复读、幻读
当多个事务同时访问相同数据时,会出现各种并发问题:
1. 脏读(Dirty Read)
一个事务读取了另一个未提交事务修改的数据。
场景示例:
-- 事务A
BEGIN;
UPDATE users SET balance = 200 WHERE user_id = 'A'; -- 将余额改为200,但未提交
-- 事务B(在另一个连接中)
BEGIN;
SELECT balance FROM users WHERE user_id = 'A'; -- 可能读取到200(脏数据)
-- 如果事务A回滚,事务B读取的就是不存在的数据
2. 不可重复读(Non-repeatable Read)
在同一事务中,多次读取同一数据得到不同结果。
场景示例:
-- 事务A
BEGIN;
SELECT balance FROM users WHERE user_id = 'A'; -- 第一次读取,返回100
-- 事务B提交了更新
UPDATE users SET balance = 150 WHERE user_id = 'A';
COMMIT;
-- 事务A再次读取
SELECT balance FROM users WHERE user_id = 'A'; -- 返回150,与第一次读取不一致
3. 幻读(Phantom Read)
在同一事务中,相同的查询条件返回不同的行集。
场景示例:
-- 事务A
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'pending'; -- 返回5条记录
-- 事务B插入新记录并提交
INSERT INTO orders (order_id, status) VALUES ('ORD006', 'pending');
COMMIT;
-- 事务A再次查询
SELECT COUNT(*) FROM orders WHERE status = 'pending'; -- 返回6条记录,出现"幻影行"
隔离级别:解决并发问题的钥匙
SQL标准定义了四种隔离级别,从宽松到严格依次为:
1. 读未提交(Read Uncommitted)
- 允许读取未提交的数据变更
- 可能发生:脏读、不可重复读、幻读
- 性能最高,数据一致性最差
2. 读已提交(Read Committed)
- 只能读取已提交的数据
- 避免脏读,但可能发生不可重复读和幻读
- PostgreSQL的默认隔离级别
3. 可重复读(Repeatable Read)
- 保证在同一事务中多次读取同一数据的结果一致
- 避免脏读和不可重复读,但可能发生幻读
- MySQL的InnoDB默认隔离级别
4. 可序列化(Serializable)
- 最高隔离级别,完全串行化执行
- 避免所有并发问题
- 性能最低,数据一致性最强
PostgreSQL中的隔离级别实践
设置隔离级别
-- 设置当前会话的隔离级别
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 或者在事务开始时指定
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
不同隔离级别的效果演示
读已提交 vs 可重复读:
-- 会话1:设置可重复读并开始事务
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM users WHERE user_id = 'A'; -- 返回100
-- 会话2:更新数据并提交
UPDATE users SET balance = 150 WHERE user_id = 'A';
COMMIT;
-- 会话1:再次读取(可重复读级别下仍看到100)
SELECT balance FROM users WHERE user_id = 'A'; -- 返回100,不是150
COMMIT;
-- 提交后重新读取
SELECT balance FROM users WHERE user_id = 'A'; -- 返回150
处理序列化失败
在可序列化隔离级别下,当检测到可能违反序列化执行的情况时,数据库会抛出序列化失败错误:
-- 会话1
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM users WHERE user_id = 'A';
-- 会话2
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
UPDATE users SET balance = balance - 50 WHERE user_id = 'A';
COMMIT; -- 成功
-- 会话1尝试提交
UPDATE users SET balance = balance - 30 WHERE user_id = 'A';
-- 可能收到错误:ERROR: could not serialize access due to read/write dependencies among transactions
-- 处理序列化失败的重试逻辑
DO $$
DECLARE
retry_count INTEGER := 0;
max_retries INTEGER := 3;
BEGIN
WHILE retry_count < max_retries LOOP
BEGIN
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 业务逻辑
UPDATE users SET balance = balance - 30 WHERE user_id = 'A';
COMMIT;
EXIT; -- 成功则退出循环
EXCEPTION
WHEN serialization_failure THEN
retry_count := retry_count + 1;
IF retry_count = max_retries THEN
RAISE EXCEPTION '达到最大重试次数';
END IF;
-- 等待随机时间后重试
PERFORM pg_sleep(random() * 0.001 * retry_count);
ROLLBACK;
END;
END LOOP;
END $$;
实际业务中的隔离级别选择建议
适合读已提交的场景
- 大多数Web应用
- 报表查询系统
- 对实时性要求较高的应用
-- 电商商品浏览(适合读已提交)
BEGIN;
-- 读取商品信息和库存
SELECT * FROM products WHERE product_id = 123;
-- 其他用户可能同时更新库存,这里能看到最新的数据
COMMIT;
适合可重复读的场景
- 财务计算
- 数据统计分析
- 需要事务内数据一致性的场景
-- 财务报表生成(适合可重复读)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 计算期初余额
SELECT SUM(amount) FROM transactions WHERE date < '2024-01-01';
-- 在此期间的其他交易不会影响这里的计算结果
-- 计算期末余额
SELECT SUM(amount) FROM transactions WHERE date < '2024-02-01';
COMMIT;
适合可序列化的场景
- 银行核心系统
- 票务系统的座位分配
- 任何不能接受并发异常的关键业务
-- 座位预定系统(必须使用可序列化)
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 检查座位是否可用
SELECT COUNT(*) FROM seats WHERE show_id = 1 AND seat_number = 'A1' AND status = 'available';
-- 如果可用,立即预定
UPDATE seats SET status = 'reserved', user_id = 'USER001'
WHERE show_id = 1 AND seat_number = 'A1' AND status = 'available';
COMMIT;
性能考虑与最佳实践
尽量使用较低的隔离级别:在满足业务需求的前提下,选择较低的隔离级别以获得更好的性能。
保持事务简短:长时间的事务会持有锁,影响系统并发性能。
合理设计重试机制:对于可能发生序列化失败的操作,实现合理的重试逻辑。
使用显式锁:在复杂场景下,可以考虑使用SELECT FOR UPDATE等显式锁机制。
-- 使用悲观锁处理库存扣减
BEGIN;
SELECT * FROM products WHERE product_id = 123 FOR UPDATE;
-- 检查库存并更新
UPDATE products SET stock = stock - 1 WHERE product_id = 123;
COMMIT;
总结
事务和隔离级别是数据库并发控制的基石。理解不同隔离级别对脏读、不可重复读和幻读的防护能力,能够帮助我们在数据一致性和系统性能之间做出合理的权衡。在实际开发中,应该根据具体业务需求选择适当的隔离级别,并设计相应的事务处理策略。
记住,没有”最好”的隔离级别,只有”最适合”当前业务场景的选择。通过合理运用事务机制,我们可以构建出既可靠又高性能的数据应用系统。
文档信息
- 本文作者:JiliangLee
- 本文链接:https://leejiliang.cn/2025/11/21/%E4%BA%8B%E5%8A%A1%E4%B8%8E%E9%9A%94%E7%A6%BB%E7%BA%A7%E5%88%AB%E4%B8%BA%E4%BB%80%E4%B9%88%E8%A6%81%E7%94%A8%E4%BA%8B%E5%8A%A1%E5%A6%82%E4%BD%95%E9%81%BF%E5%85%8D%E8%84%8F%E8%AF%BB-BEGINCOMMITROLLBACK-%E4%B8%8E%E9%9A%94%E7%A6%BB%E7%BA%A7%E5%88%AB%E8%A7%A3%E9%87%8A/
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)