事务与隔离级别深度解析:从脏读防御到并发控制

2025/11/21 PG 共 4502 字,约 13 分钟

事务与隔离级别深度解析:从脏读防御到并发控制

什么是事务?为什么我们需要它?

在数据库世界中,事务(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;

性能考虑与最佳实践

  1. 尽量使用较低的隔离级别:在满足业务需求的前提下,选择较低的隔离级别以获得更好的性能。

  2. 保持事务简短:长时间的事务会持有锁,影响系统并发性能。

  3. 合理设计重试机制:对于可能发生序列化失败的操作,实现合理的重试逻辑。

  4. 使用显式锁:在复杂场景下,可以考虑使用SELECT FOR UPDATE等显式锁机制。

-- 使用悲观锁处理库存扣减
BEGIN;
SELECT * FROM products WHERE product_id = 123 FOR UPDATE;
-- 检查库存并更新
UPDATE products SET stock = stock - 1 WHERE product_id = 123;
COMMIT;

总结

事务和隔离级别是数据库并发控制的基石。理解不同隔离级别对脏读、不可重复读和幻读的防护能力,能够帮助我们在数据一致性和系统性能之间做出合理的权衡。在实际开发中,应该根据具体业务需求选择适当的隔离级别,并设计相应的事务处理策略。

记住,没有”最好”的隔离级别,只有”最适合”当前业务场景的选择。通过合理运用事务机制,我们可以构建出既可靠又高性能的数据应用系统。

文档信息

Search

    Table of Contents