第一部分:关系型数据库深度探索


第3章:事务管理与并发控制

在多用户并发访问数据库的场景中,如何确保数据的正确性一致性是数据库管理系统的核心挑战。PostgreSQL通过其健壮的事务管理并发控制机制,提供了强大的解决方案。本章将深入探讨事务的ACID特性、PostgreSQL的隔离级别、MVCC(多版本并发控制)原理,以及如何通过锁定机制来避免并发问题,并通过实际场景的演练,让你彻底掌握在PostgreSQL中构建高并发、高可靠性应用的关键。

3.1 事务(Transaction)基础与ACID特性

事务是数据库操作的最小逻辑单元,它将一系列的数据库操作(如插入、更新、删除)视为一个不可分割的整体。一个事务要么全部成功提交,要么全部失败回滚

事务必须满足ACID特性,这是衡量数据库系统可靠性的重要标准:

  • 原子性(Atomicity):事务中的所有操作要么全部完成,要么全部不完成。如果事务中的任何一个操作失败,整个事务都会被回滚到初始状态,就像从未发生过一样。
    • 例子:银行转账,从A账户扣款和给B账户加款必须同时成功或同时失败。如果扣款成功但加款失败,整个事务必须回滚。
  • 一致性(Consistency):事务的执行必须使数据库从一个一致性状态转换到另一个一致性状态。这意味着事务不能破坏数据库的完整性约束(如外键、唯一性约束等)。
    • 例子:库存管理中,如果一个订单扣除了10件商品库存,那么库存数量必须相应减少10,不能出现负数或其他不合理的值。
  • 隔离性(Isolation):并发执行的事务之间互不干扰。每个事务都感觉自己是系统中唯一运行的事务。
    • 例子:两个用户同时购买同一件商品,他们的购买操作应该是相互隔离的,不会因为并发而导致库存计算错误。
  • 持久性(Durability):一旦事务成功提交,其对数据库的修改是永久性的,即使系统发生故障(如断电),这些修改也不会丢失。
    • 例子:一个订单成功提交后,即使服务器立即崩溃,该订单的信息也必须在数据库恢复后依然存在。

实战举例:

在PostgreSQL中,你可以使用BEGIN;(或START TRANSACTION;)开启一个事务,使用COMMIT;提交事务,使用ROLLBACK;回滚事务。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
-- 场景:用户 Alice 购买了商品 ID 为 1 的 Laptop Pro,数量 1
-- 这需要:1. 创建订单。 2. 创建订单项。 3. 减少商品库存。

BEGIN; -- 开始一个事务

-- 1. 创建订单
INSERT INTO orders (user_id, total_amount, status)
VALUES (1, 1200.00, 'processing')
RETURNING order_id; -- 获取新生成的 order_id

-- 假设上面插入的 order_id 是 3
-- DECLARE new_order_id INTEGER;
-- SELECT order_id FROM orders WHERE user_id = 1 AND status = 'processing' ORDER BY order_date DESC LIMIT 1 INTO new_order_id;
-- 实际应用中,RETURNING 是更优雅的方式来获取新生成的ID

-- 2. 创建订单项
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (3, 1, 1, 1200.00);

-- 3. 减少商品库存
UPDATE products
SET stock_quantity = stock_quantity - 1
WHERE product_id = 1;

-- 模拟一个错误发生,例如尝试更新不存在的商品
-- UPDATE products SET stock_quantity = stock_quantity - 1 WHERE product_id = 999;

-- 假设所有操作都成功,提交事务
COMMIT;

-- 如果上述任何一步出现问题,例如库存不足或商品不存在,我们可以回滚事务
-- ROLLBACK;
3.2 事务隔离级别

事务隔离级别定义了当多个事务并发执行时,一个事务对数据所做的修改在何时以及以何种方式对其他事务可见。PostgreSQL支持SQL标准定义的四种隔离级别:

  1. 读未提交(Read Uncommitted):最低的隔离级别。一个事务可以读取另一个未提交事务的数据,这会导致脏读(Dirty Read)。PostgreSQL实际上不支持这个级别,它会自动提升到READ COMMITTED
  2. 读已提交(Read Committed):默认隔离级别。一个事务只能看到其他事务已经提交的数据。这避免了脏读,但可能出现不可重复读(Non-repeatable Read)和幻读(Phantom Read)
    • 不可重复读:在同一个事务中,两次读取同一行数据,但由于另一个已提交的事务修改了该行并提交,两次读取的结果不同。
    • 幻读:在同一个事务中,两次执行相同的查询,但第二次查询返回了第一次查询没有的“新”行,因为另一个已提交的事务插入了新行。
  3. 可重复读(Repeatable Read):确保在一个事务中,多次读取同一行数据时,总是能看到相同的值。这避免了脏读不可重复读,但仍然可能出现幻读。PostgreSQL通过MVCC来严格实现此级别,它实际上也避免了幻读。
  4. 串行化(Serializable):最高的隔离级别。确保并发执行的事务与串行执行的事务产生的结果相同,完全避免了脏读不可重复读幻读。这是通过在检测到可能导致不一致结果的并发事务时强制回滚其中一个事务来实现的(即序列化失败)。

设置隔离级别:

1
2
3
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

实战举例:隔离级别对并发的影响

让我们通过两个并发事务来演示READ COMMITTEDREPEATABLE READ的区别。

场景设置:

1
2
3
4
5
6
7
-- 准备数据
DROP TABLE IF EXISTS accounts;
CREATE TABLE accounts (
    id SERIAL PRIMARY KEY,
    balance NUMERIC(10, 2) NOT NULL DEFAULT 0
);
INSERT INTO accounts (balance) VALUES (1000.00);

演示一:READ COMMITTED (脏读,不可重复读)

在两个不同的会话(例如,两个psql终端)中执行以下操作:

会话 1:

1
2
3
4
5
6
7
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 第一次读取:1000.00

-- (等待)
SELECT balance FROM accounts WHERE id = 1; -- 第二次读取:假设会话2已提交,这里会看到更新后的值 900.00
COMMIT;

会话 2:

1
2
3
4
5
6
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 此时会话1的第一次读取完成。
-- 会话1的第二次读取会发生在此之后。
COMMIT;

结果分析:READ COMMITTED级别下,会话1的第二次读取看到了会话2提交的更改,导致了不可重复读。由于PostgreSQL没有“读未提交”,因此不会发生脏读。

演示二:REPEATABLE READ (避免不可重复读,但仍有幻读风险 - 在PG中被有效避免)

在两个不同的会话中执行以下操作:

会话 1:

1
2
3
4
5
6
7
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 第一次读取:1000.00

-- (等待)
SELECT balance FROM accounts WHERE id = 1; -- 第二次读取:仍旧是 1000.00
COMMIT;

会话 2:

1
2
3
4
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

结果分析:REPEATABLE READ级别下,会话1在整个事务期间都看到了它开启事务时的快照数据,所以两次读取结果相同,成功避免了不可重复读。PostgreSQL的MVCC实现使得REPEATABLE READ级别也能够有效防止幻读,除非有非常特殊的复杂查询模式。

3.3 多版本并发控制(MVCC)

PostgreSQL实现并发控制的核心机制是多版本并发控制(MVCC)。MVCC的原理是,在修改数据时,它不会直接覆盖旧数据,而是创建数据的新版本。每个事务都“看到”数据库在特定时间点的一个一致性快照(snapshot),这意味着读取操作不会阻塞写入操作,写入操作也不会阻塞读取操作。

MVCC的优势:

  • 读不阻塞写,写不阻塞读:大大提高了并发性能。
  • 避免了锁粒度过大导致的死锁和性能瓶颈
  • 提供了强大的事务隔离能力

MVCC原理简述:

当一行数据被修改时,PostgreSQL会创建一个新的行版本。旧版本仍然存在,直到不再有任何活跃的事务需要它。每行都会有两个隐藏的系统列:xmin(创建该行版本的事务ID)和xmax(删除或更新该行版本的事务ID)。一个事务在查询数据时,只会看到其事务ID在其快照中可见的行版本。

实战举例:MVCC的体现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
-- 会话 1 (隔离级别默认为 READ COMMITTED)
BEGIN;
SELECT stock_quantity FROM products WHERE product_id = 1; -- 假设为 50

-- 会话 2
BEGIN;
UPDATE products SET stock_quantity = 49 WHERE product_id = 1;
COMMIT;

-- 会话 1 (在会话 2 提交后)
SELECT stock_quantity FROM products WHERE product_id = 1; -- 此时会看到 49
COMMIT;

-- 如果会话 1 的隔离级别是 REPEATABLE READ:
-- 会话 1
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT stock_quantity FROM products WHERE product_id = 1; -- 假设为 50

-- 会话 2
BEGIN;
UPDATE products SET stock_quantity = 49 WHERE product_id = 1;
COMMIT;

-- 会话 1 (在会话 2 提交后)
SELECT stock_quantity FROM products WHERE product_id = 1; -- 仍旧看到 50 (因为其快照未改变)
COMMIT;

注意: MVCC会引入“死元组”(Dead Tuples),即不再被任何活跃事务引用的旧数据版本。这些死元组需要通过VACUUM操作来清理,以回收磁盘空间并更新统计信息,这将在后续的性能优化章节中详细讨论。

3.4 锁定机制(Locking)

尽管MVCC大大减少了锁的使用,但在某些情况下,仍然需要通过显式锁来保证数据一致性,尤其是在进行更新、删除或涉及到复杂逻辑的并发操作时。PostgreSQL提供了多种锁模式,从表级锁到行级锁。

常见的锁模式:

  • 共享锁(Share Lock):允许多个事务同时读取数据,但不允许任何事务写入数据。
  • 排他锁(Exclusive Lock):只允许一个事务访问数据,其他事务不能读取或写入。
  • 行级锁(Row-level Locks):对单个行进行锁定。这是PostgreSQL中最常用的锁,当更新、删除或使用SELECT FOR UPDATE时会自动获取。

实战举例:避免并发更新问题

假设有多个并发事务尝试更新同一个商品的库存。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
-- 场景:用户 A 和用户 B 同时尝试购买同一个商品 ID 为 1 的 Laptop Pro。
-- 假设当前库存为 50。

-- 会话 1:用户 A 购买 1 个 Laptop Pro
BEGIN;
-- 使用 SELECT FOR UPDATE 显式锁定行,防止其他事务同时修改此行
SELECT stock_quantity FROM products WHERE product_id = 1 FOR UPDATE; -- 假设返回 50
-- 检查库存是否足够
-- if stock_quantity >= 1 then
UPDATE products SET stock_quantity = stock_quantity - 1 WHERE product_id = 1; -- 更新为 49
COMMIT;

-- 会话 2:用户 B 购买 1 个 Laptop Pro
BEGIN;
-- 当会话 1 仍在 SELECT FOR UPDATE 时,会话 2 的此语句会被阻塞,直到会话 1 提交或回滚
SELECT stock_quantity FROM products WHERE product_id = 1 FOR UPDATE;
-- 此时会话 2 看到的是会话 1 提交后的 49
-- 检查库存是否足够
-- if stock_quantity >= 1 then
UPDATE products SET stock_quantity = stock_quantity - 1 WHERE product_id = 1; -- 更新为 48
COMMIT;

SELECT FOR UPDATE 的作用: 当一个事务使用SELECT ... FOR UPDATE语句时,它会获取被选择行的排他行级锁。这意味着其他事务在尝试修改(UPDATE/DELETE)这些行时会被阻塞,直到持有锁的事务提交或回滚。这有效地避免了**丢失更新(Lost Update)**问题,即两个并发事务都读取了相同的数据,然后各自修改并提交,导致其中一个修改被覆盖。

3.5 总结

本章我们深入学习了PostgreSQL的事务管理和并发控制机制。我们理解了ACID特性是确保数据库可靠性的基石,探讨了不同隔离级别如何影响并发事务的行为,并揭示了MVCC作为PostgreSQL高性能并发的核心原理。最后,我们通过锁定机制的实战,掌握了如何在需要时显式控制并发操作,避免数据不一致。

理解并熟练运用这些概念对于构建稳定、高性能的PostgreSQL应用至关重要。在下一章中,我们将把目光转向性能优化的关键一环:索引优化与查询性能提升,学习如何通过合理的索引设计,让你的查询飞起来。