转账不翻车、并发不干扰,PostgreSQL的ACID特性到底有啥魔法?

1. 什么是ACID?

ACID是数据库事务的四个核心特性,是保证数据可靠性和一致性的基石。这四个字母分别代表:

  • 原子性(Atomicity):事务是"不可分割的最小单位",要么完全执行(Commit),要么完全不执行(Rollback);
  • 一致性(Consistency):事务执行前后,数据库始终处于"合法状态"(满足所有约束条件);
  • 隔离性(Isolation):多个并发事务之间"互不干扰",每个事务都像独自在数据库中运行;
  • 持久性(Durability) :事务提交后,修改会永久保存,即使系统崩溃也不会丢失。

PostgreSQL作为关系型数据库的佼佼者,通过事务日志(WAL)MVCC(多版本并发控制)锁机制等技术,完整实现了ACID特性。

2. 原子性:要么全做,要么全不做

原子性是事务的"灵魂"------它保证了复杂操作的"整体性"。比如转账业务中,"扣减A账户余额"和"增加B账户余额"必须同时成功或同时失败,不能出现"钱扣了但没到账"的情况。

2.1 事务的开始与结束

PostgreSQL中,事务通过BEGIN(或START TRANSACTION)开启,COMMIT提交(确认修改),ROLLBACK回滚(撤销所有修改):

sql 复制代码
-- 开启事务
BEGIN;
-- 执行操作:扣减A账户200元
UPDATE accounts SET balance = balance - 200 WHERE id = 1;
-- 执行操作:增加B账户200元
UPDATE accounts SET balance = balance + 200 WHERE id = 2;
-- 提交事务(修改生效)
COMMIT;

如果中间任何一步出错(比如B账户不存在),PostgreSQL会自动回滚整个事务,所有修改都会被撤销。

2.2 回滚与保存点(Savepoint)

当事务需要"部分回滚"时,可以用保存点(Savepoint)。比如,在一个长事务中,你可能想尝试某个操作,失败后只回滚该操作,而保留之前的修改:

sql 复制代码
BEGIN;
-- 第一步:扣减A账户200元(成功)
UPDATE accounts SET balance = balance - 200 WHERE id = 1;
-- 创建保存点sp1
SAVEPOINT sp1;
-- 第二步:尝试给C账户转200元(失败,C账户不存在)
UPDATE accounts SET balance = balance + 200 WHERE id = 3;
-- 回滚到保存点sp1(仅撤销第二步)
ROLLBACK TO sp1;
-- 调整操作:给B账户转200元(成功)
UPDATE accounts SET balance = balance + 200 WHERE id = 2;
-- 提交事务(最终A扣200,B加200)
COMMIT;

保存点的作用是将原子性"细分",但整个事务依然保持原子性------只有COMMIT后,所有修改才会永久生效。

2. 一致性:从"合法状态"到"合法状态"

一致性是指事务执行前后,数据库必须满足所有"数据完整性约束"。这些约束包括:

  • 主键约束(Primary Key):确保每行数据的唯一性;
  • 外键约束(Foreign Key) :确保关联表的数据一致性(比如订单表的user_id必须存在于用户表);
  • Check约束 :确保字段值符合自定义规则(比如balance >= 0);
  • 非空约束(NOT NULL):确保字段不存空值。

2.1 约束的"强制检查"

PostgreSQL会在事务执行每个修改操作 时自动检查约束。比如,当你尝试插入一条balance=-100的记录时:

sql 复制代码
-- 尝试插入非法数据(balance为负)
INSERT INTO accounts (user_id, balance) VALUES (3, -100);

PostgreSQL会立即抛出错误:

sql 复制代码
ERROR:  check constraint "accounts_balance_check" violated by row

此时事务会进入"终止状态",必须通过ROLLBACK撤销所有修改,才能继续操作。

2.2 一致性的"双重保障"

一致性是应用层+数据库层共同作用的结果:

  • 应用层:在数据进入数据库前,先验证合法性(比如前端检查"转账金额不能为负");
  • 数据库层:通过约束强制拦截非法数据,避免"脏数据"进入系统。

3. 隔离性:并发事务的"互不干扰"

当多个用户同时操作数据库时,隔离性保证每个事务"看不到"其他事务的中间状态。PostgreSQL通过隔离级别和**MVCC(多版本并发控制)**实现这一特性。

3.1 隔离级别的"四种境界"

PostgreSQL支持四种隔离级别(从弱到强):

隔离级别 脏读(Dirty Read) 不可重复读(Non-repeatable Read) 幻读(Phantom Read)
读未提交(Read Uncommitted) 允许 允许 允许
读已提交(Read Committed) 禁止 允许 允许
可重复读(Repeatable Read) 禁止 禁止 禁止
串行化(Serializable) 禁止 禁止 禁止

注意 :PostgreSQL的默认隔离级别是读已提交(Read Committed),这是"性能与一致性的平衡选择"。

3.2 隔离级别的"实战演示"

我们用一个简单的例子,看不同隔离级别的行为差异:

场景:查询账户余额

假设accounts表中有一条记录:id=1, balance=1000

(1)读已提交(默认)
sql 复制代码
-- 事务A(读已提交)
BEGIN;
-- 第一次查询:得到1000
SELECT balance FROM accounts WHERE id=1;
-- 此时事务B执行:UPDATE accounts SET balance=1500 WHERE id=1; COMMIT;
-- 第二次查询:得到1500(不可重复读)
SELECT balance FROM accounts WHERE id=1;
COMMIT;

结论:读已提交级别下,每个语句都会读取"最新的已提交数据",因此会出现"不可重复读"。

(2)可重复读
sql 复制代码
-- 事务A(可重复读)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 第一次查询:得到1000(快照生成)
SELECT balance FROM accounts WHERE id=1;
-- 事务B执行UPDATE并提交
-- 第二次查询:依然得到1000(快照一致)
SELECT balance FROM accounts WHERE id=1;
COMMIT;

结论:可重复读级别下,事务会基于"开始时的快照"进行所有查询,避免了不可重复读和幻读。

(3)串行化
sql 复制代码
-- 事务A(串行化)
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE id=1; -- 1000
-- 事务B尝试UPDATE accounts SET balance=1500 WHERE id=1;
-- 此时事务B会被阻塞,直到事务A提交或回滚
COMMIT;

结论:串行化级别下,事务会"串行执行",完全避免所有并发问题,但性能最低(适合对一致性要求极高的场景,比如金融交易)。

3.3 MVCC:"多版本数据"的魔法

PostgreSQL的隔离性依赖MVCC(多版本并发控制)。它的核心思想是:

  • 每个修改操作都会生成"新版本数据",而不是直接覆盖旧版本;
  • 事务根据"快照(Snapshot)"读取数据------快照包含事务开始时所有"已提交的数据版本";
  • 旧版本数据会在"没有事务引用"时被自动清理(通过VACUUM)。

比如,当事务B修改balance为1500时,PostgreSQL会:

  1. 保留旧版本(balance=1000);
  2. 生成新版本(balance=1500);
  3. 事务A的快照依然指向旧版本,因此看不到事务B的修改。

4. 持久性:提交后的数据"永不丢失"

持久性是指事务提交后,修改会永久保存,即使系统崩溃也不会丢失。PostgreSQL通过**WAL(Write-Ahead Logging,预写日志)**实现这一特性。

4.1 WAL的"预写原则"

WAL的核心逻辑是:所有修改必须先写入日志,再写入数据文件。具体流程如下:

  1. 事务执行UPDATE操作时,PostgreSQL先将修改内容写入WAL日志(顺序写入,速度快);
  2. WAL日志会被同步到磁盘 (通过fsync调用,保证日志不丢失);
  3. 定期将WAL中的修改"刷入"数据文件(通过Checkpoint机制)。

即使系统突然崩溃(比如断电),重启后PostgreSQL会:

  • 读取WAL日志;
  • 重放所有"已提交但未刷入数据文件"的修改;
  • 回滚所有"未提交"的修改。

4.2 Checkpoint:"缩短恢复时间"的关键

Checkpoint是PostgreSQL定期执行的"磁盘同步操作",它会:

  1. 将所有"脏页"(修改过但未写入数据文件的内存页)写入数据文件;
  2. 在WAL日志中标记一个"检查点",表示"之前的修改已安全写入磁盘"。

这样,当系统崩溃时,PostgreSQL只需要重放检查点之后的WAL日志,大大减少恢复时间。

5. 实战:用ACID保障"转账业务"

我们用一个经典的"转账场景",演示如何用ACID特性保证数据正确性:

5.1 准备工作

首先创建账户表(包含Check约束,保证余额非负):

sql 复制代码
-- 创建账户表
CREATE TABLE accounts (
    id SERIAL PRIMARY KEY,
    user_id INT NOT NULL,
    balance NUMERIC(10,2) NOT NULL CHECK (balance >= 0)
);

-- 插入测试数据:用户1有1000元,用户2有500元
INSERT INTO accounts (user_id, balance) VALUES (1, 1000.00), (2, 500.00);

5.2 转账事务

用户1给用户2转200元,事务必须保证:

  • 原子性:要么"用户1扣200+用户2加200",要么都不执行;
  • 一致性:转账后双方余额都非负;
  • 隔离性:并发转账时,看不到对方的中间状态;
  • 持久性:转账成功后,数据永久保存。
sql 复制代码
-- 开启转账事务
BEGIN;
-- 步骤1:扣减用户1的余额
UPDATE accounts SET balance = balance - 200.00 WHERE user_id = 1;
-- 步骤2:增加用户2的余额
UPDATE accounts SET balance = balance + 200.00 WHERE user_id = 2;
-- 步骤3:检查是否有错误(比如用户1余额不足)
-- 如果一切正常,提交事务
COMMIT;

5.3 异常处理

如果用户1的余额不足(比如只有100元),步骤1会触发Check约束错误:

sql 复制代码
ERROR:  check constraint "accounts_balance_check" violated by row

此时事务会自动终止,必须通过ROLLBACK撤销所有修改:

sql 复制代码
ROLLBACK;

6. 课后Quiz:巩固ACID知识

问题1:什么是原子性?PostgreSQL如何保证原子性?

答案 :原子性是事务要么全做要么全不做。PostgreSQL通过ROLLBACK(回滚)和Savepoint(保存点)实现原子性------当事务出错时,回滚所有修改;保存点允许部分回滚,但整个事务依然原子。

问题2:PostgreSQL的默认隔离级别是什么?它能避免哪些并发问题?

答案 :默认隔离级别是读已提交(Read Committed)。它能避免脏读,但不能避免不可重复读和幻读。

问题3:持久性是如何通过WAL实现的?

答案:WAL要求所有修改先写入日志(同步到磁盘),再写入数据文件。即使系统崩溃,重启后PostgreSQL会重放WAL日志中的未完成修改,保证数据不丢失。

问题4:假设你在开发一个电商系统,用户下订单时需要"扣减库存"和"创建订单",如何用事务保证原子性?

答案:将两个操作包裹在一个事务中:

sql 复制代码
BEGIN;
-- 扣减库存
UPDATE products SET stock = stock - 1 WHERE id = 100;
-- 创建订单
INSERT INTO orders (user_id, product_id) VALUES (1, 100);
COMMIT;

如果任何一步出错,ROLLBACK会撤销所有修改,避免"库存扣了但订单没创建"的情况。

7. 常见报错及解决

报错1:current transaction is aborted, commands ignored until end of transaction block

原因 :事务中某条语句出错(比如违反约束),导致事务进入"终止状态"。 解决 :执行ROLLBACK撤销所有修改,再重新执行正确的语句。 预防:在应用层捕获错误,及时回滚事务。

报错2:could not serialize access due to read/write dependencies among transactions

原因 :使用Serializable隔离级别时,并发事务存在"读写依赖",无法串行执行。 解决 :重试事务,或降低隔离级别到Repeatable Read预防 :避免在高并发场景使用Serializable级别。

报错3:check constraint "xxx" violated by row

原因 :修改操作违反了Check约束(比如余额为负)。 解决 :检查数据合法性,修改后重新执行。 预防:应用层先验证数据(比如前端检查"转账金额不能为负")。

参考链接

余下文章内容请点击跳转至 个人博客页面 或者 扫码关注或者微信搜一搜:编程智域 前端至全栈交流与成长,阅读完整的文章:转账不翻车、并发不干扰,PostgreSQL的ACID特性到底有啥魔法?
往期文章归档

相关推荐
程序新视界2 小时前
三种常见的MySQL数据库设计最佳实践
数据库·后端·mysql
LunarCod2 小时前
Hexo搭建/部署个人博客教程
后端·hexo·个人博客·vercel
IT_陈寒3 小时前
Vue 3.4 实战:这7个Composition API技巧让我的开发效率飙升50%
前端·人工智能·后端
风雨同舟的代码笔记4 小时前
ThreadLocal的使用以及源码分析
后端
十步杀一人_千里不留行4 小时前
和 AI 一起修 Bug 心得体会
人工智能·bug·ai编程
brzhang5 小时前
把网页的“好句子”都装进侧边栏:我做了个叫 Markbox 的收藏器,开源!
前端·后端·架构
yaocheng的ai分身5 小时前
Token-efficient tool use
ai编程·claude
猎豹奕叔6 小时前
JD到家商品系统架构设计演进
后端
阑梦清川6 小时前
深入理解动静态库和ELF文件格式
后端