Skip to main content
Septvean's Documents
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

PostgreSQL 事务与锁

事务解决的是“多个操作必须一起成功或一起失败”,锁解决的是“多个事务同时操作数据时,谁先执行、谁等待”。

PostgreSQL 的并发控制核心是:

MVCC 负责让读写尽量互不阻塞,锁负责处理真正冲突的数据操作,隔离级别负责决定事务能够看到什么。


1. 什么是事务

事务是一组不可分割的数据库操作。

例如卖出股票需要:

  1. 扣减持仓
  2. 写入成交记录
  3. 更新账户资金
  4. 写入资金流水

这些操作必须全部成功,否则全部撤销。

BEGIN;

UPDATE trading.position
SET quantity = quantity - 100
WHERE account_id = 1
  AND security_id = 1001;

INSERT INTO trading.trade_log (
    account_id,
    security_id,
    trade_type,
    quantity
)
VALUES (1, 1001, 'SELL', 100);

UPDATE trading.account
SET available_cash = available_cash + 150000
WHERE account_id = 1;

COMMIT;

如果中间出错:

ROLLBACK;

前面已经执行的修改全部撤销。PostgreSQL 还会把每条未显式包含在 BEGIN 中的 SQL,当作一个隐式事务执行。PostgreSQL 18:事务


2. ACID 特性

2.1 原子性 Atomicity

事务中的操作:

  • 要么全部成功
  • 要么全部失败

不能只扣持仓,却没有增加资金。

2.2 一致性 Consistency

事务执行前后,数据必须符合约束和业务规则。

例如:

quantity bigint NOT NULL CHECK (quantity >= 0)

一致性并不意味着数据库自动理解全部业务。必须结合:

  • 主键
  • 唯一约束
  • 外键
  • CHECK
  • 正确的 SQL
  • 锁或串行化隔离级别

2.3 隔离性 Isolation

两个事务同时运行时,不应看到对方未提交的中间状态。

2.4 持久性 Durability

事务成功提交后,在默认持久化配置下,修改会通过 WAL 持久记录;数据库即使异常重启,也能恢复已提交事务。


3. 基本事务控制

3.1 提交事务

BEGIN;

INSERT INTO market.security (
    exchange_code,
    code,
    name
)
VALUES ('SSE', '600519', '贵州茅台');

COMMIT;

也可以写:

START TRANSACTION;

3.2 回滚事务

BEGIN;

UPDATE market.security
SET name = '错误名称'
WHERE code = '600519';

ROLLBACK;

更新不会生效。

3.3 事务出错后的状态

BEGIN;

INSERT INTO market.security (
    exchange_code,
    code,
    name
)
VALUES ('SSE', '600519', '重复证券');

-- 如果违反唯一约束,事务进入 aborted 状态

SELECT * FROM market.security;

随后通常会看到:

ERROR: current transaction is aborted,
commands ignored until end of transaction block

必须执行:

ROLLBACK;

不能只忽略错误继续执行。


4. SAVEPOINT 保存点

PostgreSQL 没有任意层级的真正嵌套事务,但可以使用保存点局部回滚。

BEGIN;

INSERT INTO market.security (
    exchange_code,
    code,
    name
)
VALUES ('SSE', '600519', '贵州茅台');

SAVEPOINT before_second_row;

INSERT INTO market.security (
    exchange_code,
    code,
    name
)
VALUES ('SSE', '600519', '重复数据');

-- 第二条失败后,回到保存点
ROLLBACK TO SAVEPOINT before_second_row;

INSERT INTO market.security (
    exchange_code,
    code,
    name
)
VALUES ('SZSE', '000001', '平安银行');

COMMIT;

结果:

  • 第一条保留
  • 重复数据撤销
  • 第三条正常提交

不再需要保存点时:

RELEASE SAVEPOINT before_second_row;

保存点特别适合批量导入时跳过少量错误数据。不过每行一个保存点也有成本,大批量导入更适合暂存表、COPY 和集合化 SQL。


5. 序列不会随事务回滚

如果表使用:

security_id bigint GENERATED ALWAYS AS IDENTITY

事务获取了 ID 1001,随后回滚,下一次插入可能直接使用 1002。

因此主键出现空号是正常现象:

999, 1000, 1002, 1003

不要使用自增 ID 判断:

  • 表中有多少数据
  • 是否删除过记录
  • 事务是否全部成功
  • 严格的业务先后顺序

PostgreSQL 的序列变更对其他事务立即可见,而且不会因事务回滚而恢复。PostgreSQL 18:事务隔离


6. MVCC:多版本并发控制

PostgreSQL 使用 MVCC 管理并发。

假设一行数据为:

security_id = 1001
quantity    = 1000

事务 A 更新:

UPDATE trading.position
SET quantity = 900
WHERE security_id = 1001;

PostgreSQL 通常不是直接覆盖原来的行版本,而是生成新版本:

旧版本:quantity = 1000
新版本:quantity = 900

在事务 A 提交前:

  • 事务 A 能看到 900
  • 其他普通查询仍看到符合自身快照的旧版本 1000
  • 其他事务看不到未提交的 900

所以 PostgreSQL 通常表现为:

普通读不阻塞写,写也不阻塞普通读。

但下面这些操作仍可能互相阻塞:

  • 两个事务修改同一行
  • SELECT ... FOR UPDATE
  • 显式表锁
  • 某些 DDL
  • TRUNCATE
  • VACUUM FULL

旧版本最终由 VACUUM 清理。长时间不结束的事务可能导致旧版本无法及时回收,产生表膨胀。


7. 事务隔离级别

PostgreSQL 支持四个标准名称,但内部只有三个不同级别:

隔离级别 PostgreSQL 行为 快照范围
Read Uncommitted 等同于 Read Committed 每条语句
Read Committed 默认级别 每条语句
Repeatable Read PostgreSQL 快照隔离 整个事务
Serializable 最严格,可串行化 整个事务并检测异常

7.1 并发现象

现象 含义
脏读 读取其他事务尚未提交的数据
不可重复读 同一事务两次读取同一行,结果不同
幻读 同一条件两次查询,结果集行数发生变化
串行化异常 并发结果无法对应任何一种串行执行顺序

PostgreSQL 中:

隔离级别 脏读 不可重复读 幻读 串行化异常
Read Committed 不会 可能 可能 可能
Repeatable Read 不会 不会 不会 可能
Serializable 不会 不会 不会 不会

PostgreSQL 的 Repeatable Read 比 SQL 标准的最低要求更强,不允许幻读。PostgreSQL 18:隔离级别


7.2 Read Committed

默认级别:

SHOW transaction_isolation;

通常返回:

read committed

它的关键特点是:

每条 SQL 开始时获取一个新快照。

会话 A:

BEGIN;

SELECT quantity
FROM trading.position
WHERE account_id = 1
  AND security_id = 1001;

第一次返回:

1000

会话 B:

BEGIN;

UPDATE trading.position
SET quantity = 1200
WHERE account_id = 1
  AND security_id = 1001;

COMMIT;

会话 A 再查询:

SELECT quantity
FROM trading.position
WHERE account_id = 1
  AND security_id = 1001;

可能返回:

1200

因为第二条 SELECT 获得了新快照。

Read Committed 适合大多数普通业务:

  • 单行增删改查
  • 基于主键更新
  • INSERT ... ON CONFLICT
  • 短事务

7.3 Repeatable Read

BEGIN ISOLATION LEVEL REPEATABLE READ;

在该事务中,多次查询看到一致的数据快照:

BEGIN ISOLATION LEVEL REPEATABLE READ;

SELECT *
FROM market.daily_quote
WHERE trade_date = DATE '2026-09-14';

-- 其他事务即使提交了新数据
-- 当前事务重新查询时仍使用原快照

SELECT *
FROM market.daily_quote
WHERE trade_date = DATE '2026-09-14';

COMMIT;

适合:

  • 同一报表中的多个查询必须基于一致数据
  • 多步骤统计
  • 数据导出
  • 需要稳定快照的分析

如果事务尝试修改一个在事务开始后被其他事务修改过的行,可能收到:

ERROR: could not serialize access due to concurrent update

此时需要回滚并重试整个事务。


7.4 Serializable

BEGIN ISOLATION LEVEL SERIALIZABLE;

Serializable 并不是让全部事务真的排队逐个运行。PostgreSQL 仍允许并发执行,但会监控读写依赖;如果结果不可能对应某种串行执行顺序,就中止其中一个事务。

例如账户风险上限是 100 万:

  1. 当前总持仓为 80 万
  2. 事务 A 读取总持仓,增加股票 A 15 万
  3. 事务 B 同时读取总持仓,增加股票 B 15 万
  4. 两个事务各自认为最终只有 95 万
  5. 实际提交后可能成为 110 万

Repeatable Read 仍可能出现这种“写偏差”;Serializable 会尝试检测,并让其中一个事务失败:

ERROR: could not serialize access due to
read/write dependencies among transactions

应用必须针对 SQLSTATE 40001 重试整个事务。

适合:

  • 跨多行、跨多表的业务不变量
  • 复杂额度检查
  • 无法通过唯一约束或单条原子 SQL 保证的规则

不应该简单地把所有业务都改成 Serializable,因为它有额外监控成本,并要求应用实现可靠重试。

7.5 稳定的只读报表

BEGIN TRANSACTION
ISOLATION LEVEL SERIALIZABLE
READ ONLY
DEFERRABLE;

数据库可能先等待一个安全快照。一旦开始执行,这类事务适合要求高一致性的长时间只读统计。


8. 丢失更新问题

下面是一种危险的应用逻辑:

SELECT quantity
FROM trading.position
WHERE account_id = 1
  AND security_id = 1001;

两个应用同时读到:

quantity = 100

然后:

  • 应用 A 计算为 80,执行 SET quantity = 80
  • 应用 B 计算为 70,执行 SET quantity = 70

最终 A 的更新可能被 B 覆盖。

8.1 使用原子更新

如果只是加减,尽量让数据库直接计算:

UPDATE trading.position
SET quantity = quantity - 20
WHERE account_id = 1
  AND security_id = 1001;

并发事务修改同一行时,后一个事务会等待,然后基于最新行版本继续更新。

防止持仓变成负数:

UPDATE trading.position
SET quantity = quantity - 100
WHERE account_id = 1
  AND security_id = 1001
  AND quantity >= 100
RETURNING quantity;

如果返回零行,说明:

  • 持仓不足
  • 持仓不存在
  • 或并发事务已经先卖出了持仓

这种“条件检查和更新写在一条 SQL 中”的方式,通常比先查再改安全。

8.2 使用悲观锁

如果后续计算比较复杂:

BEGIN;

SELECT quantity
FROM trading.position
WHERE account_id = 1
  AND security_id = 1001
FOR UPDATE;

-- 应用检查数量、手续费、风控规则

UPDATE trading.position
SET quantity = quantity - 100
WHERE account_id = 1
  AND security_id = 1001;

INSERT INTO trading.trade_log (...);

COMMIT;

8.3 使用乐观锁

表中增加版本号:

version integer NOT NULL DEFAULT 0

更新时携带旧版本:

UPDATE trading.position
SET quantity = 900,
    version = version + 1
WHERE account_id = 1
  AND security_id = 1001
  AND version = 5
RETURNING version;

如果返回零行,说明数据已经被其他事务修改,应重新读取并重试。


9. 行级锁

PostgreSQL 支持四种显式行锁:

行锁 强度 常见用途
FOR UPDATE 最强 即将修改或删除该行
FOR NO KEY UPDATE 较强 修改非关键字段
FOR SHARE 共享 允许其他共享读取锁,阻止修改
FOR KEY SHARE 最弱 防止删除或修改关键键值

行锁不会阻塞普通 SELECT,但会阻塞对同一行的不兼容修改或加锁操作。锁一般一直持有到事务提交或回滚。PostgreSQL 18:显式锁与行锁

9.1 FOR UPDATE

BEGIN;

SELECT *
FROM trading.position
WHERE account_id = 1
  AND security_id = 1001
FOR UPDATE;

其他事务仍能普通查询这行,但以下操作可能等待:

UPDATE ...
DELETE ...
SELECT ... FOR UPDATE
SELECT ... FOR SHARE

9.2 FOR NO KEY UPDATE

SELECT *
FROM trading.position
WHERE account_id = 1
  AND security_id = 1001
FOR NO KEY UPDATE;

它比 FOR UPDATE 弱,适合不会修改主键或可供外键引用的唯一键的场景。普通 UPDATE 非关键字段时,PostgreSQL通常取得这种锁。

9.3 FOR SHARE

SELECT *
FROM market.security
WHERE security_id = 1001
FOR SHARE;

允许其他事务取得共享锁,但阻止对该行进行更新或删除。

9.4 FOR KEY SHARE

SELECT *
FROM market.security
WHERE security_id = 1001
FOR KEY SHARE;

主要用于保护被外键引用的键:

  • 阻止删除该行
  • 阻止修改关键键值
  • 允许修改不影响键的其他字段

10. NOWAIT 与 SKIP LOCKED

10.1 NOWAIT

不愿意等待锁时:

SELECT *
FROM trading.position
WHERE account_id = 1
  AND security_id = 1001
FOR UPDATE NOWAIT;

如果行已经被锁,立即报错,而不是持续等待。

适合:

  • 用户立即操作
  • 尝试抢占资源
  • 应用能够提示“记录正在处理中”

10.2 SKIP LOCKED

SELECT *
FROM trading.import_job
WHERE status = 'pending'
ORDER BY created_at, job_id
FOR UPDATE SKIP LOCKED
LIMIT 1;

已经被其他工作进程锁住的任务会被跳过。

更完整的任务领取方式:

BEGIN;

WITH picked AS (
    SELECT job_id
    FROM trading.import_job
    WHERE status = 'pending'
    ORDER BY created_at, job_id
    FOR UPDATE SKIP LOCKED
    LIMIT 1
)
UPDATE trading.import_job AS j
SET status = 'running',
    started_at = clock_timestamp()
FROM picked
WHERE j.job_id = picked.job_id
RETURNING j.*;

COMMIT;

SKIP LOCKED 提供的是不一致视图,不适合普通业务查询,主要用于多消费者任务队列。PostgreSQL 18:SELECT 锁定子句

领取任务后应尽快提交,再执行耗时的网页抓取或数据处理,不要为了“占住任务”长时间保持数据库事务。


11. 多表查询时只锁指定表

SELECT
    p.account_id,
    p.quantity,
    s.code,
    s.name
FROM trading.position AS p
JOIN market.security AS s
  ON s.security_id = p.security_id
WHERE p.account_id = 1
FOR UPDATE OF p;

OF p 表示只锁持仓表对应的行,不锁证券基础信息。

多表查询使用锁定子句时,建议明确指定要锁的表,避免扩大锁范围。


12. 表级锁

即使操作的是一行,PostgreSQL 通常也会自动取得相应的表级锁。

注意:

ROW SHAREROW EXCLUSIVE 名字中虽然有 ROW,但它们仍是表级锁。

常见模式:

表级锁 自动获取它的常见操作
ACCESS SHARE 普通 SELECT
ROW SHARE SELECT ... FOR UPDATE/SHARE
ROW EXCLUSIVE INSERTUPDATEDELETEMERGE
SHARE UPDATE EXCLUSIVE VACUUMANALYZECREATE INDEX CONCURRENTLY
SHARE 普通 CREATE INDEX
SHARE ROW EXCLUSIVE CREATE TRIGGER、部分 ALTER TABLE
EXCLUSIVE REFRESH MATERIALIZED VIEW CONCURRENTLY
ACCESS EXCLUSIVE DROPTRUNCATEVACUUM FULL、普通 REINDEX、很多 DDL

最重要的一点:

只有 ACCESS EXCLUSIVE 会阻塞普通 SELECT

因此生产环境执行以下操作要格外谨慎:

TRUNCATE TABLE market.daily_quote;

VACUUM FULL market.daily_quote;

ALTER TABLE market.daily_quote ...;

即使 DDL 本身执行很快,也可能先长时间等待已有事务结束;一旦进入锁等待队列,又可能影响后续会话。

12.1 显式锁表

BEGIN;

LOCK TABLE market.security
IN SHARE MODE
NOWAIT;

-- 执行需要表级一致性的操作

COMMIT;

显式表锁会显著降低并发能力,一般只用于:

  • 特殊批处理
  • 维护操作
  • 无法使用行锁或 Serializable 保证的场景

13. 死锁

死锁是两个事务互相等待。

会话 A:

BEGIN;

UPDATE trading.position
SET quantity = quantity + 100
WHERE security_id = 1001;

-- 等待一会儿

UPDATE trading.position
SET quantity = quantity + 100
WHERE security_id = 1002;

会话 B:

BEGIN;

UPDATE trading.position
SET quantity = quantity + 100
WHERE security_id = 1002;

-- 等待一会儿

UPDATE trading.position
SET quantity = quantity + 100
WHERE security_id = 1001;

形成:

A 持有 1001,等待 1002
B 持有 1002,等待 1001

PostgreSQL 会自动检测死锁,并中止其中一个事务:

ERROR: deadlock detected

SQLSTATE 为:

40P01

13.1 防止死锁

所有事务按相同顺序加锁

SELECT *
FROM trading.position
WHERE security_id IN (1001, 1002)
ORDER BY security_id
FOR UPDATE;

所有代码统一按 security_id 从小到大锁定。

缩短事务

不要在事务中:

  • 等用户输入
  • 调用外部 HTTP API
  • 执行耗时文件操作
  • 长时间休眠
  • 进行复杂的非数据库计算

一开始取得需要的锁强度

不要先取得较弱锁,再在不同代码路径中以不同顺序升级。

对死锁进行整体重试

死锁无法在所有场景下完全避免。应用必须能够回滚并重试整个事务,而不是只重试最后一条 SQL。

PostgreSQL 官方同样建议按一致顺序获取多个对象的锁,并让应用能够处理死锁重试。PostgreSQL 18:死锁


14. 锁等待超时

默认情况下,如果没有死锁,事务可能持续等待锁。

可以在事务中设置超时:

BEGIN;

SET LOCAL lock_timeout = '3s';
SET LOCAL statement_timeout = '30s';
SET LOCAL transaction_timeout = '60s';

SELECT *
FROM trading.position
WHERE account_id = 1
  AND security_id = 1001
FOR UPDATE;

COMMIT;

区别:

参数 控制范围
lock_timeout 单次获取锁最多等待多久
statement_timeout 一条 SQL 最多执行多久
transaction_timeout 整个事务最多持续多久
idle_in_transaction_session_timeout 事务打开但客户端没有继续发送 SQL 的时间

例如:

SET idle_in_transaction_session_timeout = '2min';

生产环境尤其应防范 idle in transaction,因为它可能:

  • 长时间持有锁
  • 阻止 VACUUM 清理旧行
  • 导致表膨胀
  • 阻塞 DDL

这些超时值应按接口、批处理和管理会话分别配置,不建议不加区分地设置成全库相同值。PostgreSQL 18:客户端连接与事务超时


15. Advisory Lock 咨询锁

咨询锁用于锁定一个由应用定义的“逻辑资源”,而不一定是某一行数据。

例如,同一只股票同一时刻只允许一个行情同步任务运行:

BEGIN;

SELECT pg_advisory_xact_lock(1, 1001);

-- 同步 security_id = 1001 的行情

COMMIT;

这里:

  • 1 可以表示“行情同步”命名空间
  • 1001 表示证券 ID

事务级咨询锁会在 COMMITROLLBACK 时自动释放。

不等待版本:

SELECT pg_try_advisory_xact_lock(1, 1001);

返回:

  • true:取得锁
  • false:其他事务已经持有锁

还有会话级锁:

SELECT pg_advisory_lock(1001);
SELECT pg_advisory_unlock(1001);

会话级锁不会因为普通事务回滚而释放,连接池中使用不当容易残留,因此业务代码通常优先使用:

pg_advisory_xact_lock(...)

咨询锁只是约定,数据库不知道这个数字对应什么业务对象。所有相关应用代码都必须遵守同一套锁规则。PostgreSQL 18:咨询锁函数


16. 查看当前锁等待

16.1 查看正在等待锁的会话

SELECT
    pid,
    usename,
    application_name,
    state,
    xact_start,
    now() - xact_start AS transaction_age,
    wait_event_type,
    wait_event,
    pg_blocking_pids(pid) AS blocking_pids,
    left(query, 200) AS query
FROM pg_stat_activity
WHERE datname = current_database()
  AND (
      wait_event_type = 'Lock'
      OR state LIKE 'idle in transaction%'
  )
ORDER BY xact_start NULLS LAST;

16.2 查看等待者与阻塞者

WITH blocked AS (
    SELECT
        pid AS waiting_pid,
        unnest(pg_blocking_pids(pid)) AS blocking_pid
    FROM pg_stat_activity
)
SELECT
    w.pid AS waiting_pid,
    w.usename AS waiting_user,
    now() - w.query_start AS waiting_duration,
    left(w.query, 150) AS waiting_query,

    b.pid AS blocking_pid,
    b.usename AS blocking_user,
    b.state AS blocking_state,
    now() - b.xact_start AS blocking_transaction_age,
    left(b.query, 150) AS blocking_query
FROM blocked
JOIN pg_stat_activity AS w
  ON w.pid = blocked.waiting_pid
JOIN pg_stat_activity AS b
  ON b.pid = blocked.blocking_pid
ORDER BY waiting_duration DESC;

16.3 查看未取得的锁

SELECT
    l.pid,
    l.locktype,
    l.mode,
    l.granted,
    l.waitstart,
    c.relname
FROM pg_locks AS l
LEFT JOIN pg_class AS c
  ON c.oid = l.relation
WHERE l.granted IS FALSE
ORDER BY l.waitstart;

pg_locks.granted = false 表示正在等待。需要注意,行锁信息主要存储在数据行中,因此行级锁等待经常表现为等待另一个事务 ID,而不是直接显示为某个 tuple 锁。PostgreSQL 18:pg_locks

16.4 查看死锁累计数量

SELECT
    datname,
    deadlocks,
    stats_reset
FROM pg_stat_database
WHERE datname = current_database();

数据库管理员还可以启用:

log_lock_waits = on

记录超过 deadlock_timeout 的锁等待,用于定位线上阻塞问题。


17. 取消阻塞会话

取消当前 SQL:

SELECT pg_cancel_backend(12345);

但取消 SQL 不一定立即释放事务已经取得的锁。如果连接仍处于未提交或 aborted 事务中,锁可能继续保留。

终止整个连接:

SELECT pg_terminate_backend(12345);

连接终止后,未提交事务会回滚并释放锁。

这属于管理操作,执行前必须确认:

  • PID 对应的用户和应用
  • 当前 SQL
  • 事务持续时间
  • 是否为关键批处理
  • 回滚可能持续多久

18. 索引与锁的关系

索引不只影响查询性能,也会间接影响锁竞争。

例如:

UPDATE trading.position
SET quantity = quantity + 100
WHERE account_id = 1
  AND security_id = 1001;

适合的索引:

PRIMARY KEY (account_id, security_id)

数据库可以迅速找到目标行。

如果没有合适索引:

  • 扫描时间更长
  • 事务持续时间更长
  • 已取得的锁更晚释放
  • 与其他事务重叠的概率更高
  • 更容易形成锁等待和死锁

但索引不能消除两个事务修改同一行时的冲突。


19. SQLAlchemy 2.0 中使用事务

19.1 推荐使用上下文管理器

from sqlalchemy.orm import sessionmaker

SessionLocal = sessionmaker(
    bind=engine,
    expire_on_commit=False,
)

with SessionLocal.begin() as session:
    session.add(trade_log)
    position.quantity -= 100

正常退出时自动提交;出现异常时自动回滚并关闭 Session。

try:
    with SessionLocal.begin() as session:
        ...
except Exception:
    # 此时事务已经回滚
    raise

不要捕获异常后什么都不做,否则事务上下文可能误以为业务正常完成。

SQLAlchemy 2.0 的 Session 采用 autobegin:执行数据库操作时会自动开始事务;flush() 只是把 SQL 发送到数据库,并不等于提交,锁仍会持有到最终 commit()rollback()SQLAlchemy 2.0:事务管理

19.2 FOR UPDATE

from sqlalchemy import select

with SessionLocal.begin() as session:
    stmt = (
        select(Position)
        .where(
            Position.account_id == 1,
            Position.security_id == 1001,
        )
        .with_for_update()
    )

    position = session.execute(stmt).scalar_one()

    if position.quantity < 100:
        raise ValueError("持仓不足")

    position.quantity -= 100
    session.add(
        TradeLog(
            account_id=1,
            security_id=1001,
            trade_type="SELL",
            quantity=100,
        )
    )

不等待锁:

stmt = stmt.with_for_update(nowait=True)

跳过已锁任务:

stmt = stmt.with_for_update(skip_locked=True)

这些参数会分别生成 PostgreSQL 的 NOWAITSKIP LOCKED 子句。SQLAlchemy 2.0:with_for_update

19.3 设置隔离级别

可以创建专用 Engine:

serializable_engine = engine.execution_options(
    isolation_level="SERIALIZABLE",
)

SerializableSession = sessionmaker(
    bind=serializable_engine,
    expire_on_commit=False,
)

然后:

with SerializableSession.begin() as session:
    ...

隔离级别应在事务真正开始前设置,不要在已经执行查询后再修改。

19.4 重试死锁和串行化失败

import random
import time

from sqlalchemy.exc import DBAPIError


RETRYABLE_CODES = {
    "40001",  # serialization_failure
    "40P01",  # deadlock_detected
}


def run_transaction(operation, max_attempts: int = 3):
    for attempt in range(max_attempts):
        try:
            with SerializableSession.begin() as session:
                return operation(session)

        except DBAPIError as exc:
            code = (
                getattr(exc.orig, "sqlstate", None)
                or getattr(exc.orig, "pgcode", None)
            )

            if code not in RETRYABLE_CODES:
                raise

            if attempt + 1 >= max_attempts:
                raise

            delay = 0.05 * (2 ** attempt)
            delay += random.uniform(0, 0.05)
            time.sleep(delay)

必须重试整个事务,而不是只重试失败语句。

被重试的事务还应具有幂等性。发送短信、HTTP 请求、消息通知等外部副作用,最好安排在成功提交之后,或者采用事务 Outbox 模式,防止重试造成重复发送。


20. 股票及时序系统的实用选择

场景 推荐方案
扣减单行持仓 条件原子 UPDATE ... RETURNING
先检查再修改复杂持仓 SELECT ... FOR UPDATE
用户编辑低冲突资料 version 乐观锁
多行额度、风险上限 Serializable 或显式锁
多工作进程领取抓取任务 FOR UPDATE SKIP LOCKED
同一股票禁止重复同步 事务级 advisory lock
一致的行情统计报表 Repeatable Read
高要求只读报表 Serializable Read Only Deferrable
大批量日行情导入 有界批次事务,分批提交

TimescaleDB hypertable 仍遵循 PostgreSQL 的事务和锁语义。导入大量行情时不要用一个持续数小时的大事务;它可能长期保留旧快照和锁,并影响 VACUUM、后台策略及其他写入任务。


21. 最佳实践总结

  1. 默认先使用 Read Committed。
  2. 能用单条原子 SQL,就不要先查再改。
  3. 复杂读后写场景使用 FOR UPDATE
  4. 跨行、跨表业务规则考虑 Serializable。
  5. Serializable 和死锁失败必须重试整个事务。
  6. 多行加锁始终使用一致顺序。
  7. 事务尽量短,不在事务中等待用户或外部 API。
  8. 使用 lock_timeoutstatement_timeout 限制无限等待。
  9. 监控 idle in transaction
  10. 给定位和连接条件建立正确索引。
  11. 优先使用事务级咨询锁,谨慎使用会话级咨询锁。
  12. 把主键空号视为正常现象。
  13. SQLAlchemy 中优先使用 with Session.begin()
  14. flush() 不等于 commit()
  15. 锁住的不是越多越安全,锁范围越精确越好。