READ UNCOMMITTED / READ COMMITTED
READ COMMITTED 是 PostgreSQL 的默认隔离级别,也是绝大多数应用的合理选择。它的核心是「语句级快照」:每条语句在开始执行的那一刻取一个快照,只看到该时刻之前已提交的数据。PostgreSQL 中请求 READ UNCOMMITTED 会被静默提升为 READ COMMITTED——因为 PG 的 MVCC 实现从根上不允许脏读,标准里最弱的那个级别在这里根本不存在。
基础概念
先纠正一个最常见的预期偏差:
| 你以为 | PostgreSQL 18 的真实行为 |
|---|---|
READ UNCOMMITTED 能读到未提交数据 | 不可能:PG 把它当作 READ COMMITTED |
READ COMMITTED 的行为与 READ UNCOMMITTED 相同 | 语义上等同,但没有任何脏读 |
| 同一事务内两次读同一行结果一定相同 | 不一定:语句级快照,中间别人提交就会变 |
SELECT 读到的行 UPDATE 时一定还能改 | 不一定:若已被别人改动,会阻塞并重新检查 |
四个隔离级别在 PostgreSQL 中的实际映射:
| 请求的级别 | PostgreSQL 实际使用 | 脏读 | 不可重复读 | 幻读 | 序列化异常 |
|---|---|---|---|---|---|
READ UNCOMMITTED | READ COMMITTED | 不可能 | 可能 | 可能 | 可能 |
READ COMMITTED(默认) | READ COMMITTED | 不可能 | 可能 | 可能 | 可能 |
REPEATABLE READ | REPEATABLE READ(快照隔离) | 不可能 | 不可能 | 不可能 | 可能 |
SERIALIZABLE | SERIALIZABLE(SSI) | 不可能 | 不可能 | 不可能 | 不可能 |
READ COMMITTED 的两条核心规则:
规则 1(读):每条语句开始时取新快照
→ 同一事务内两次 SELECT 之间若别人提交了修改,第二次能看到新值
规则 2(写):UPDATE / DELETE 时若目标行已被并发事务修改
→ 阻塞等待对方结束
→ 对方提交:PG 用最新版本「重新检查」WHERE 条件(EvalPlanQual)
→ 对方回滚:正常更新语法与用法
-- 事务级设置(必须在事务开始后、任何查询之前)
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
COMMIT;
-- 也可在 BEGIN 时直接指定
BEGIN ISOLATION LEVEL READ UNCOMMITTED; -- PG 视为 READ COMMITTED
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 会话级设置(持续到会话结束或再次修改)
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 查看当前级别
SHOW transaction_isolation;最简可运行示例:
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
COMMIT;BEGIN
balance
---------
1000.00
(1 row)
COMMITSHOW default_transaction_isolation; default_transaction_isolation
-------------------------------
read committed
(1 row)详细说明
语句级快照:READ COMMITTED 的读取语义
READ COMMITTED 的快照不是「事务开始时」取的,而是「每条语句开始时」取的。这意味着同一事务里的两条 SELECT 可能看到不同版本的数据:
会话 A(READ COMMITTED):
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 读到 1000.00
-- 此刻会话 B 提交了修改(balance 改为 800.00)
SELECT balance FROM accounts WHERE id = 1; -- 读到 800.00(不可重复读)
COMMIT;-- 会话 A 输出
BEGIN
balance
---------
1000.00
(1 row)
balance
---------
800.00
(1 row)
COMMIT-- 会话 B 在此期间执行
BEGIN;
UPDATE accounts SET balance = 800.00 WHERE id = 1;
COMMIT;这就是不可重复读(non-repeatable read):同一事务内读同一行得到不同结果。它不违反 READ COMMITTED 的契约,因为这个级别的契约只管「不读未提交数据」,不管「读的一致性」。
不可重复读:为什么它经常是 bug 源头
单看「读两次不一样」似乎无害,但当它与应用逻辑结合时就会出事:
# 危险:READ COMMITTED 下的「检查—使用」间隙
cur.execute("SELECT status FROM orders WHERE id = 42") # 读到 'pending'
# 此刻另一个事务把它改成 'cancelled' 并提交
if status == "pending": # 基于过期信息判断
cur.execute("UPDATE orders SET status = 'shipped' WHERE id = 42")
# 把一个已取消的订单发货了-- SQL 层面的等价复现
BEGIN; -- READ COMMITTED(默认)
SELECT status FROM orders WHERE id = 42; -- 'pending'
SELECT status FROM orders WHERE id = 42; -- 可能已是 'cancelled'
UPDATE orders SET status = 'shipped' WHERE id = 42;
COMMIT;BEGIN
status
---------
pending
(1 row)
status
-----------
cancelled
(1 row)
UPDATE 1
COMMIT修正方式只有两类:把判断与写入合并为一条原子语句,或用锁把中间间隙封住:
-- 方式 1:条件写(把判断下推到 WHERE,由 PG 保证原子性)
UPDATE orders SET status = 'shipped'
WHERE id = 42 AND status = 'pending';
-- 受影响行数为 0 表示条件不满足,业务据此判断UPDATE 0-- 方式 2:显式加锁,把间隙封住
BEGIN;
SELECT status FROM orders WHERE id = 42 FOR UPDATE; -- 加行锁
UPDATE orders SET status = 'shipped' WHERE id = 42;
COMMIT;BEGIN
status
---------
pending
(1 row)
UPDATE 1
COMMIT更新冲突与 EvalPlanQual:READ COMMITTED 的写规则
这是 READ COMMITTED 最需要理解透彻的机制。当 UPDATE 发现目标行已被另一个未提交事务修改时,它不会立刻放弃,而是:
UPDATE 遇到「行已被并发未提交事务修改」:
1. 阻塞等待对方事务结束
2. 对方 ROLLBACK → 直接用本方数据更新(无事发生)
3. 对方 COMMIT → 取出该行的「最新已提交版本」
4. 用最新版本重新检查本语句的 WHERE 条件(EvalPlanQual 重检查)
5. 条件仍满足 → 基于最新版本执行更新
6. 条件不再满足 → 跳过该行(UPDATE 0),不报错会话 A(READ COMMITTED):
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100;
-- 先执行,拿到行锁,未提交
会话 B(同时执行同一条语句):
UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100;
-- B 被阻塞,等待 A
会话 A 提交后:
-- B 用 A 提交后的最新版本重新检查 balance >= 100
-- 若仍满足则继续扣减;若不满足则该行被跳过(UPDATE 0)-- 会话 B 最终输出
UPDATE 1 -- 若 A 提交后余额仍 >= 100
-- 或
UPDATE 0 -- 若 A 提交后余额已 < 100,该行被跳过这个「重新检查」机制意味着:在 READ COMMITTED 下,一条 UPDATE ... WHERE 语句总是基于「最新已提交数据」判断条件,不会误改已被别人改成不符合条件的行。 但要注意它不保证跨语句的一致性——重检查只针对当前这条语句。
-- 验证「条件写」的收敛性:库存扣减不会超卖
BEGIN;
UPDATE inventory SET qty = qty - 1 WHERE sku = 'A100' AND qty > 0;
-- 若受影响行数为 0,说明库存已空,业务应据此失败或重试
COMMIT;BEGIN
UPDATE 1
COMMIT丢失更新(lost update):READ COMMITTED 的经典陷阱
「读出来 → 在应用里算好新值 → 写回去」这个模式在 READ COMMITTED 下会丢失更新:
会话 A:
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 读到 1000
-- 应用计算:1000 - 100 = 900
会话 B(同时):
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 也读到 1000
-- 应用计算:1000 - 200 = 800
UPDATE accounts SET balance = 800 WHERE id = 1; -- 提交
COMMIT;
会话 A 继续:
UPDATE accounts SET balance = 900 WHERE id = 1; -- 覆盖了 B 的 800
COMMIT;
-- 结果:900,B 的扣减 200 被完全抹掉(丢失更新)-- 会话 A 输出
BEGIN
balance
---------
1000.00
(1 row)
UPDATE 1
COMMIT
-- 最终 balance = 900.00,业务上丢了 200注意:这不是 PG「没检测到冲突」。PG 在 A 的 UPDATE 时确实看到了 B 的提交,但 A 写的是一个基于旧值算出的常量 900,重检查只验证 WHERE 条件(这里没有条件),不会帮你重新计算表达式。
三种正确写法:
-- 写法 1(推荐):把计算交给数据库,用相对更新
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;BEGIN
UPDATE 1
COMMIT-- 写法 2:加行锁,让「读—算—写」串行化
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;BEGIN
balance
---------
1000.00
(1 row)
UPDATE 1
COMMIT-- 写法 3:升级到 REPEATABLE READ,让冲突以 40001 暴露出来
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;BEGIN
balance
---------
1000.00
(1 row)
ERROR: could not serialize access due to concurrent update写法 3 的思路值得牢记:当业务逻辑必须在应用层计算新值时,用更高的隔离级别把「静默丢失」变成「显式冲突」。
幻读:READ COMMITTED 无法防止
幻读(phantom read)指「同一事务内两次执行同一个范围查询,第二次看到了新插入的行」。
会话 A(READ COMMITTED):
BEGIN;
SELECT count(*) FROM orders WHERE amount > 1000; -- 得到 5
-- 会话 B 插入一条 amount = 2000 的订单并提交
SELECT count(*) FROM orders WHERE amount > 1000; -- 得到 6(多出一行)
COMMIT;-- 会话 A 输出
BEGIN
count
-------
5
(1 row)
count
-------
6
(1 row)
COMMIT-- 会话 B 在此期间执行
INSERT INTO orders (amount) VALUES (2000.00);
COMMIT;READ COMMITTED 下幻读是允许的,因为它只承诺「语句级快照」。要防止幻读,需要升级到 REPEATABLE READ(PG 的 RR 能防幻读,这一点比 SQL 标准的最低要求更强)。
值得强调的是:UPDATE 语句在 READ COMMITTED 下对「幻影行」的处理与 SELECT 不同。一条 UPDATE ... WHERE range 会看到语句开始后新提交的行(它用最新快照重新扫描),不会漏掉它们:
-- 会话 A
BEGIN;
UPDATE orders SET reviewed = true WHERE amount > 1000;
-- 此语句执行期间,会话 B 插入并提交了一条 amount = 2000 的订单
-- A 的这条语句仍会看到并更新它
COMMIT; -- UPDATE 6用 FOR UPDATE 消除「检查—使用」间隙
在 READ COMMITTED 下,SELECT ... FOR UPDATE 是最直接的加固手段:它读出的同时给行加锁,其它事务想改这些行必须等你结束。
BEGIN;
-- 锁定账户行,后续读到的余额在本事务内不会被别人改动
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 应用层计算并写回,安全
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;BEGIN
balance
---------
1000.00
(1 row)
UPDATE 1
COMMIT不同锁子句的取舍:
| 锁子句 | 行为 | 适用 |
|---|---|---|
FOR UPDATE | 排他行锁,阻止其它 FOR UPDATE/FOR NO KEY UPDATE/UPDATE | 需要修改这些行 |
FOR NO KEY UPDATE | 较弱,允许并发 FOR KEY SHARE | 改非键列,需与外键检查共存 |
FOR SHARE | 共享行锁,阻止修改但允许并发共享读 | 只读但要防止被改 |
FOR KEY SHARE | 最弱,只阻止键值修改 | 外键引用检查 |
FOR UPDATE SKIP LOCKED | 跳过已被锁定的行 | 任务队列消费(不必等待) |
-- 任务队列的经典写法:抢任务,跳过别人正在处理的
BEGIN;
SELECT id, payload FROM jobs
WHERE status = 'pending'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1; -- 返回 id = 1
UPDATE jobs SET status = 'running' WHERE id = ...;
COMMIT;并发写同一行的两个会话:完整时间线
下面把 READ COMMITTED 的典型并发场景完整走一遍,便于对照理解「阻塞 + 重检查」:
时间线(两个会话都是默认的 READ COMMITTED):
时刻 会话 A 会话 B
---- ---------------------------------- ----------------------------------
t1 BEGIN; BEGIN;
t2 UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
-- 拿到行锁,未提交
t3 UPDATE accounts
SET balance = balance - 50
WHERE id = 1;
-- 被阻塞,等待 A
t4 SELECT balance FROM accounts
WHERE id = 1;
-- 看到自己未提交的新值(自己的事务可见)
t5 COMMIT;
-- 释放锁
t6 -- 解除阻塞,取出 A 提交后的最新版本
-- 用最新版本重新检查 WHERE(无附加条件)
-- 基于最新值继续 -50
t7 SELECT balance FROM accounts
WHERE id = 1; -- 看到 850
t8 COMMIT;初始 balance = 1000,两个会话都提交后结果是 850——两个扣减都被正确累计,没有丢失更新。这正是「相对更新(balance = balance - x)」在 READ COMMITTED 下天然安全的原因。
-- 最终验证
SELECT balance FROM accounts WHERE id = 1; balance
---------
850.00
(1 row)设置方式与生效范围
READ COMMITTED 涉及三层配置,优先级从高到低:
| 层级 | 语法 | 生效范围 |
|---|---|---|
| 事务级 | SET TRANSACTION ISOLATION LEVEL ... | 当前事务,必须在任何查询前 |
BEGIN 时指定 | BEGIN ISOLATION LEVEL ... | 当前事务 |
| 会话级 | SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL ... | 当前连接后续所有事务 |
| 数据库级 | ALTER DATABASE db SET default_transaction_isolation = ... | 该库所有新连接 |
| 角色级 | ALTER ROLE app SET default_transaction_isolation = ... | 该角色所有新连接 |
| 实例级 | ALTER SYSTEM SET default_transaction_isolation = ... | 全实例 |
-- 事务级:必须在事务开始后、任何查询之前设置
BEGIN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT 1; -- 查询开始
COMMIT;BEGIN
SET
?column?
----------
1
(1 row)
COMMIT-- 错误!事务内已有查询后不能再改隔离级别
BEGIN;
SELECT 1;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;ERROR: SET TRANSACTION ISOLATION LEVEL must be called before any query# postgresql.conf:把默认级别显式写出(虽然默认就是它)
default_transaction_isolation = 'read committed'-- 会话级修改
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED;
SHOW transaction_isolation;SET
transaction_isolation
-----------------------
read committed
(1 row)适用场景与不适用场景
READ COMMITTED 是「够用且便宜」的默认值,但并非万能:
| 场景 | READ COMMITTED 是否合适 | 说明 |
|---|---|---|
| 普通 CRUD、单条语句业务 | 合适 | 语句级原子性足够 |
相对更新(x = x + n) | 合适 | 数据库自己完成计算 |
| 高并发吞吐优先的系统 | 合适 | 冲突少、无 40001 重试 |
| 长报表 / 需要一致快照的统计 | 不合适 | 多条 SELECT 间数据会变 |
| 「读—算—写」复合逻辑 | 需加固 | 用 FOR UPDATE 或条件写 |
| 跨行不变式(总和、配平) | 不合适 | 需 REPEATABLE READ / SERIALIZABLE |
| 对账、结算、批量核对 | 不合适 | 需快照一致性 |
-- 不推荐:READ COMMITTED 下做多表一致性统计
BEGIN; -- 默认 READ COMMITTED
SELECT sum(balance) FROM accounts; -- 时刻 T1 的总额
SELECT sum(amount) FROM transfers; -- 时刻 T2 的总额
COMMIT;
-- 两个数来自不同时刻,可能对不上账BEGIN
sum
-----------
150000.00
(1 row)
sum
----------
149900.00
(1 row)
COMMIT-- 推荐:需要一致快照时升级到 REPEATABLE READ
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT sum(balance) FROM accounts;
SELECT sum(amount) FROM transfers;
COMMIT;BEGIN
sum
-----------
150000.00
(1 row)
sum
-----------
150000.00
(1 row)
COMMIT与其他隔离级别的对比
| 对比项 | READ COMMITTED | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|
| 快照粒度 | 每条语句 | 整个事务 | 整个事务 |
| 不可重复读 | 可能 | 不可能 | 不可能 |
| 幻读 | 可能 | 不可能(PG 特性) | 不可能 |
| 丢失更新 | 可能(读—算—写) | 报错 40001 | 报错 40001 |
| 写偏斜 | 可能 | 可能 | 不可能 |
| 冲突时行为 | 阻塞 + 重检查,通常成功 | 阻塞 + 报 40001 | 阻塞 + 报 40001 |
| 需要应用重试 | 一般不需要 | 需要 | 需要 |
| 并发吞吐 | 最高 | 中 | 较低 |
| 适用 | 默认、大多数业务 | 一致性读、长查询 | 资金、库存等强一致 |
-- 同一条语句在两种级别下的冲突表现:
-- READ COMMITTED:阻塞后重检查,通常成功
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1; -- UPDATE 1
COMMIT;
-- REPEATABLE READ / SERIALIZABLE:若快照后该行被改,报 40001
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
COMMIT;BEGIN
UPDATE 1
COMMIT
BEGIN
ERROR: could not serialize access due to concurrent update实战示例
场景:一个「抢购」流程,展示 READ COMMITTED 下如何用「条件写 + 相对更新」安全扣减库存而不超卖。
第 1 步:准备数据
DROP TABLE IF EXISTS flash_orders, stock;
CREATE TABLE stock (
sku text PRIMARY KEY,
qty int NOT NULL CHECK (qty >= 0)
);
CREATE TABLE flash_orders (
id bigserial PRIMARY KEY,
sku text NOT NULL,
user_id int NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO stock (sku, qty) VALUES ('A100', 3);DROP TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 1第 2 步:条件写扣库存(不会超卖)
BEGIN;
-- 只有 qty > 0 时才会扣减;受影响行数为 0 表示抢购失败
UPDATE stock SET qty = qty - 1 WHERE sku = 'A100' AND qty > 0;
-- 若上一步 UPDATE 1,则记录订单;若 UPDATE 0,业务应回滚或跳过
INSERT INTO flash_orders (sku, user_id) VALUES ('A100', 1001);
COMMIT;BEGIN
UPDATE 1
INSERT 0 1
COMMIT第 3 步:并发争抢,观察最后一个失败
会话 1 / 2 / 3(并发执行同一条语句):
UPDATE stock SET qty = qty - 1 WHERE sku = 'A100' AND qty > 0;
结果:前两个得到 UPDATE 1,第三个等待后重新检查
发现 qty 已为 0,条件不满足 → UPDATE 0(抢购失败,不超卖)-- 会话 3 输出
UPDATE 0第 4 步:验证库存与订单一致
SELECT qty FROM stock WHERE sku = 'A100';
SELECT count(*) AS orders, (SELECT qty FROM stock WHERE sku = 'A100') AS remaining
FROM flash_orders WHERE sku = 'A100'; qty
-----
1
(1 row)
orders | remaining
--------+-----------
1 | 1
(1 row)初始 3 件,卖出 1 件,剩 1 件——语句级原子性保证了「扣减」与「条件判断」不可分割。
第 5 步:对比错误写法(先读后写)
-- 不推荐:READ COMMITTED 下先读后写会超卖
BEGIN;
SELECT qty FROM stock WHERE sku = 'A100'; -- 读到 1
-- 应用判断 1 > 0,决定下单
UPDATE stock SET qty = qty - 1 WHERE sku = 'A100'; -- 无条件扣减
SELECT qty FROM stock WHERE sku = 'A100'; -- 读到自己的结果
COMMIT;BEGIN
qty
-----
1
(1 row)
UPDATE 1
qty
-----
0
(1 row)
COMMIT单看这个会话没问题,但当多个会话并发执行同一段代码时,SELECT 与 UPDATE 之间的窗口会让 qty 被扣成负数——或者因为 UPDATE 无条件而扣穿。这就是「条件写」比「先读后写」更可靠的直接理由。
第 6 步:清理
DROP TABLE flash_orders, stock;DROP TABLE注意事项
- PostgreSQL 没有真正的 READ UNCOMMITTED。 请求它只会得到 READ COMMITTED,脏读在任何情况下都不可能发生。错误! 指望用
READ UNCOMMITTED提升性能(没有任何效果)。
BEGIN ISOLATION LEVEL READ UNCOMMITTED;
SHOW transaction_isolation;BEGIN
transaction_isolation
-----------------------
read committed
(1 row)- 同一事务内两次
SELECT结果可能不同。 这是语句级快照的必然结果,不是 bug。错误! 在事务内缓存第一次的读取结果并假设它一直有效。
BEGIN;
SELECT count(*) FROM orders; -- 第一次
SELECT count(*) FROM orders; -- 第二次,可能已变
COMMIT;- 「读—算—写」在 READ COMMITTED 下会丢失更新。 PG 不会帮你重算表达式,它只重检查 WHERE 条件。不推荐把计算放在应用层再用常量写回。
-- 错误!应用算出 900 再写回,会覆盖并发扣减
UPDATE accounts SET balance = 900 WHERE id = 1;
-- 正确:把计算下推给数据库
UPDATE accounts SET balance = balance - 100 WHERE id = 1;- 幻读在 READ COMMITTED 下是允许的。 需要「范围查询结果稳定」的场景必须升级隔离级别。错误! 依赖未加锁的范围读做「先查不存在再插入」的防重。
-- 错误!两个事务可能都查到「不存在」然后都插入
BEGIN;
SELECT 1 FROM users WHERE email = 'a@b.c';
INSERT INTO users (email) VALUES ('a@b.c');
COMMIT;
-- 正确:用唯一约束兜底,或加锁
INSERT INTO users (email) VALUES ('a@b.c') ON CONFLICT (email) DO NOTHING;SET TRANSACTION ISOLATION LEVEL必须在任何查询之前。 一旦执行过 SQL,再改会报 25001。
-- 错误!SET TRANSACTION ISOLATION LEVEL must be called before any query
BEGIN;
SELECT 1;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;FOR UPDATE只保护「被锁住的行」。 它不保护范围查询的结果集,也不保护尚未存在的行。不推荐 用FOR UPDATE代替真正的隔离级别。
-- 错误认知:以为锁了已存在的行就能防插入
BEGIN;
SELECT * FROM users WHERE email = 'a@b.c' FOR UPDATE; -- 无行可锁!
INSERT INTO users (email) VALUES ('a@b.c'); -- 仍可能冲突或重复
COMMIT;最佳实践
- 把 READ COMMITTED 当作默认值,但显式写出关键事务的隔离级别,让代码意图明确、便于审计。
BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE inventory SET qty = qty - 1 WHERE sku = 'A100' AND qty > 0;
COMMIT;- 用「条件写」替代「先读后写」:把业务判断下推到
WHERE,用受影响行数判断结果。
UPDATE accounts SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
-- 受影响行数 0 → 余额不足,业务处理失败分支- 涉及「读—算—写」时优先加行锁;高并发抢购场景用
FOR UPDATE SKIP LOCKED避免排队。
BEGIN;
SELECT qty FROM inventory WHERE sku = 'A100' FOR UPDATE;
UPDATE inventory SET qty = qty - 1 WHERE sku = 'A100';
COMMIT;- 需要一致快照的长查询 / 报表用
REPEATABLE READ READ ONLY,而不是在 READ COMMITTED 下拼多条 SELECT。
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT sum(balance) FROM accounts;
SELECT sum(amount) FROM transfers;
COMMIT;- 为无法用锁表达的跨行不变式升级到 SERIALIZABLE,并准备好 40001 重试逻辑。
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM oncall WHERE shift = 'night';
INSERT INTO oncall (doctor) VALUES ('dr-x');
COMMIT;
-- 冲突时捕获 SQLSTATE 40001 并重试下一节
继续学习:REPEATABLE READ