PostgreSQL 的事务隔离级别是基于其 MVCC(多版本并发控制) 机制实现的。虽然 SQL 标准定义了四种隔离级别,但 PostgreSQL 的实现略有不同:它实际上只用了三种底层机制来覆盖这些级别。


1. 隔离级别概览

隔离级别 脏读 (Dirty Read) 不可重复读 (Non-repeatable Read) 幻读 (Phantom Read) 序列化异常
Read Uncommitted 不可能发生* 可能发生 可能发生 可能发生
Read Committed (默认) 不可能发生 可能发生 可能发生 可能发生
Repeatable Read 不可能发生 不可能发生 不可能发生* 可能发生
Serializable 不可能发生 不可能发生 不可能发生 不可能发生

:在 PostgreSQL 中,Read Uncommitted 的表现与 Read Committed 完全一致。


2. 各级别的实现原理

所有的隔离级别都依赖于 快照(Snapshot),但获取快照的时机决定了隔离的强度。

A. Read Committed (读已提交)

这是 PostgreSQL 的默认级别。

  • 实现原理:在该级别下,事务中的每一条查询语句(Statement)开始执行时,都会重新获取一个新的快照。
  • 现象:如果在同一个事务中执行两次相同的查询,而在这两次查询之间有另一个事务提交了修改,那么第二次查询会看到新的数据(产生不可重复读)。
  • 写冲突:如果尝试更新一行正在被其他事务修改的行,它会等待那个事务提交或回滚。如果提交了,它会在新版本的数据上重新评估 WHERE 条件。

B. Repeatable Read (可重复读)

  • 实现原理:快照是在事务内第一条非控制语句执行时获取的,并且在整个事务期间保持不变
  • 幻读解决:由于快照在整个事务中是固定的,即使其他事务插入了新行,当前事务也看不到,因此在 PostgreSQL 中这个级别已经解决了幻读
  • 写冲突 (Serialization Error):如果当前事务尝试更新一行,而该行在事务开始后被其他已提交事务修改过,Postgres 会抛出错误:could not serialize access due to concurrent update。应用层必须准备好捕获此错误并重试事务。

C. Serializable (可序列化)

这是最严格的级别,实现了真正的可串行化。

  • 实现原理 (SSI - Serializable Snapshot Isolation):它基于可重复读的快照,但额外增加了“谓词锁(Predicate Locks)”监测。
  • 监控依赖:它不会真的阻塞其他事务,而是监控事务之间的读写依赖关系(Read-Write dependencies)。如果系统检测到多个并发事务的提交可能会导致逻辑上的不一致(如“写偏斜” Write Skew),它会强制回滚其中一个事务。
  • 代价:由于需要持续监控内存中的锁信息,性能开销最大,且事务失败率最高。

3. 底层核心:可见性检查 (Visibility Check)

无论哪个级别,底层都通过比较元组(Tuple)的 xmin / xmax 与当前快照来实现:

  1. 快照信息:包含 active_xids(当前活跃的事务 ID 列表)。
  2. 判断逻辑
    • 如果 Tuple 的 xmin 还在 active_xids\(\rightarrow\) 不可见(说明创建它的事务还没完)。
    • 如果 Tuple 的 xmax 不在 active_xids 中且已提交 \(\rightarrow\) 不可见(说明它已被删除)。

在 PostgreSQL 的 MVCC 设计中,这四个概念共同构成了数据的“时空坐标系”。我们可以把它们拆解为两部分:xmin/xmax/xip_list 负责“时间(可见性)”,而 ctid 负责“空间(物理位置)”


1. 深度理解它们的关系

你可以把数据库想象成一本不断被修订的账本:

  • xmin (出生证明):记录了是谁(哪个事务)创建了这一行。
  • xmax (死亡证明):记录了是谁删除了这一行,或者是谁通过更新操作产生了这一行的“下一代”。
  • xip_list (活跃名单):这是事务快照里的内容。它告诉当前事务:“虽然这些事务 ID 很大,但它们还没干完活,你不能看它们的数据。”
  • ctid (物理门牌号):记录了这一行在磁盘文件里的具体位置。如果数据被更新了,旧行的 ctid 就像一个指路牌,指向新行的 ctid

它们是如何协同工作的?

当你执行 SELECT 时,数据库会拿出一张快照(包含 xip_list),然后去检查每一行的 xminxmax。只有当 xmin 已提交且 xmax 无效(或未提交)时,这一行对你才是可见的。如果这一行被更新了,你会顺着 ctid 链条找到符合你快照版本的那个“前世”。


2. 如何查看这些隐藏字段

这些字段默认是隐藏的,你无法通过 SELECT * 看到它们,必须在 SQL 中显式指明列名。

A. 查看表的物理分布与版本

SELECT 
    ctid,       -- 物理位置 (块号, 行号)
    xmin,       -- 创建事务 ID
    xmax,       -- 删除/更新事务 ID
    * -- 业务数据
FROM your_table_name;

B. 查看当前事务的快照信息 (xip_list)

如果你想看当前时刻,数据库认为哪些事务是“活跃”的,可以执行:

SELECT pg_current_snapshot();
-- 输出示例: 500:505:501,502
-- 含义解释: 
-- 500 (xmin): 比 500 小的都已提交
-- 505 (xmax): 大于等于 505 的都没开始
-- 501,502 (xip_list): 501 和 502 还在运行中,不可见

3. 实战演示:观察更新过程中的变化

我们可以通过一个简单的实验观察它们的关系:

第一步:插入数据

INSERT INTO test_table (name) VALUES ('Alice');
SELECT ctid, xmin, xmax, name FROM test_table;
-- 结果: ctid=(0,1), xmin=1001, xmax=0, name='Alice'

第二步:更新数据

UPDATE test_table SET name = 'Bob' WHERE name = 'Alice';
SELECT ctid, xmin, xmax, name FROM test_table;
-- 结果: ctid=(0,2), xmin=1002, xmax=0, name='Bob'

注:此时如果你用另一个事务查,可能会看到旧的 ctid=(0,1),因为对那个事务来说,xmin=1002 还在 xip_list 里。


4. 重点提示:ctid 的不稳定性

在开发中,请记住:永远不要把 ctid 作为长期业务主键!

  • ctid 是会变的。一旦执行了 VACUUM FULL 或者数据被更新,行的物理位置就会改变。
  • 它仅用于单次查询内的快速定位,或者是定位数据库页损坏等底层运维场景。

通过查看这些字段,你可以直观地看到 PostgreSQL 是如何通过“空间(新旧行)换取时间(高并发)”的

4. 总结与建议

  • 大多数场景(95%):使用默认的 Read Committed。它平衡了性能和一致性,只需注意“不可重复读”的逻辑干扰。
  • 报表/数据对账:使用 Repeatable Read。确保你导出的所有数据都来自同一个时间点,保证报表的一致性。
  • 极高一致性(金融转账):使用 Serializable 或在 Read Committed 下配合 SELECT ... FOR UPDATE

MVCC

PostgreSQL 的 MVCC(多版本并发控制)设计是其作为工业级数据库的核心基石。它通过数据的“逻辑删除”而非“物理更新”,实现了读写互不阻塞的特性。

以下是 PostgreSQL MVCC 的深度设计原理分析。


1. 物理行结构:元组(Tuple)

在 PostgreSQL 中,每一行数据被称为一个 Tuple。为了支持多版本,每个 Tuple 的头部都包含了一组隐藏的系统字段:

  • xmin:存储创建该 Tuple 的事务 ID (\(XID\))。
  • xmax:存储删除或更新该 Tuple 的事务 ID。如果该行当前有效且未被锁定,则为 0。
  • t_ctid:指向该行的最新版本位置(如果当前行被更新,它会指向新版本的物理地址)。
  • t_infomask:一组位标记,用于快速判断事务状态(如事务是否已提交、回滚)。

2. 事务快照(Snapshot)

当一个事务开始时(取决于隔离级别),Postgres 会生成一个 快照(Snapshot)。这个快照决定了当前事务能看到哪些数据。一个快照包含三个核心要素:

  1. xmin (Lowest active XID):所有小于此 ID 的事务都已经提交,其结果可见。
  2. xmax (First unassigned XID):所有大于或等于此 ID 的事务在快照创建时尚未开始,其结果不可见。
  3. xip_list (Active XIDs):处于 xminxmax 之间且当前仍在运行的事务列表。这些事务的结果不可见。

3. 核心操作的内部流程

更新(UPDATE)的艺术

PostgreSQL 不会直接修改原始数据。当你更新一行时:

  1. 标记旧行:将旧 Tuple 的 xmax 设置为当前事务的 \(XID\)
  2. 插入新行:在磁盘空闲处插入一个全新的 Tuple,其 xmin 设置为当前 \(XID\),并将旧 Tuple 的 t_ctid 指向新 Tuple 的位置。
  3. 链条形成:这种设计形成了一个版本链(Update Chain)

删除(DELETE)

删除操作只是将目标 Tuple 的 xmax 设置为当前事务的 \(XID\),并没有真正的物理擦除。


4. 可见性检查(Visibility Check)

当事务查询一行数据时,会根据快照进行判断。简化后的逻辑如下:

  • 如果 xmin 对应的事务已回滚 \(\rightarrow\) 不可见
  • 如果 xmin 对应的事务尚未提交 \(\rightarrow\) 不可见(除非是当前事务自己)。
  • 如果 xmin 已提交
    • 如果 xmax 为 0 \(\rightarrow\) 可见
    • 如果 xmax 对应的事务已回滚 \(\rightarrow\) 可见
    • 如果 xmax 对应的事务尚未提交 \(\rightarrow\) 可见(删除操作未生效)。
    • 如果 xmax 对应的事务已提交 \(\rightarrow\) 不可见(说明该行已被标记删除)。

5. 辅助组件:CLOG (Commit Log)

为了快速知道一个 \(XID\) 到底有没有提交,Postgres 维护了一个 CLOG

  • 它是一组存储在内存(并通过磁盘持久化)的状态位。
  • 每个事务占用 2 bits 空间,记录四种状态:IN_PROGRESS(进行中)、COMMITTED(已提交)、ABORTED(已回滚)、SUB_COMMITTED(子事务提交)。
  • 可见性检查时,系统会先查 CLOG 确认 xmin / xmax 的最终状态。

6. 设计权衡(Trade-offs)

优势 挑战
读写完全并发:读取数据时不需要加任何锁,极大地提升了吞吐量。 数据膨胀(Bloat):旧版本数据长期驻留在磁盘。
事务回滚极快:只需在 CLOG 中标记状态,无需物理处理。 HOT 更新限制:如果新行无法存在同一页面,会产生索引开销。
Time Travel 能力:理论上可以查询过去任意时刻的数据(如果未被 Vacuum)。 索引维护:每次更新都要插入新行,会导致所有索引都需要更新指向新 ctid。

7. 进阶:HOT (Heap Only Tuple) 优化

为了缓解每次更新都要更新索引的问题,Postgres 引入了 HOT 技术。如果更新后的新行能放在与旧行相同的数据页(Page)中,且没有更新索引列,索引就可以继续指向旧行,通过旧行的隐藏指针直接跳转到新行,从而减少 I/O 压力。

这种设计使得 PostgreSQL 在处理复杂并发事务时表现得非常稳健,但也要求开发者必须通过合理的 VACUUM 策略来管理其带来的副作用。


清理膨胀数据

在 PostgreSQL 的 MVCC 机制下,数据更新和删除产生的“死元组”(Dead Tuples)如果堆积过多,就会导致表膨胀(Bloat)。清理这些数据主要有三种方式,它们在性能影响和彻底性上有所不同。


1. 自动清理(Autovacuum)

这是 PostgreSQL 默认的后台机制。它像一个勤劳的清洁工,在系统闲暇时自动回收过期空间。

  • 工作原理:扫描表中的死元组并将其标记为“可用空间”,后续的新数据可以复用这些空间。
  • 注意点:它不会把空间还给操作系统(磁盘占用不会减少),只能让表不再继续长大。
  • 调优参数
    • autovacuum_vacuum_scale_factor: 当表中死元组占比达到多少(默认 20%)时触发。
    • autovacuum_vacuum_cost_limit: 限制清理过程消耗的 I/O 资源,避免影响正常业务。

2. 手动清理:VACUUM FULL

当表已经膨胀到严重影响查询性能,且磁盘空间告急时,这是最彻底的手段。

  • 工作原理:它会创建一个全新的、紧凑的表文件,将有效数据复制进去,然后删除旧文件。
  • 优点物理释放空间,磁盘占用立即减少。
  • 代价:它会请求 ACCESS EXCLUSIVE 锁。这意味着在执行期间,该表完全无法读取和写入
  • 适用场景:维护窗口期,或非核心业务表。

3. 在线平替:pg_repack (推荐)

对于生产环境,VACUUM FULL 的锁表代价太高。业内通用的方案是使用开源扩展工具 pg_repack

  • 特点:它能在不长时间锁表的情况下重新整理表空间。
  • 原理
    1. 创建一个新表和日志记录表。
    2. 将旧数据迁移到新表。
    3. 利用触发器将迁移期间产生的增量变化应用到新表。
    4. 最后通过一个简短的元数据交换(秒级锁)完成替换。
  • 安装建议:它是 DB运维的必备神器,尤其是在高频更新的大表场景下。

4. 如何诊断膨胀程度?

在动手清理前,你需要先确认是否真的存在膨胀。

  • 查看死元组数量
    SELECT relname, n_dead_tup, last_vacuum, last_autovacuum 
    FROM pg_stat_user_tables 
    WHERE n_dead_tup > 0;
    
  • 查看空间利用率:可以使用扩展 pgstattuple 来查看精确的空闲比例:
    CREATE EXTENSION pgstattuple;
    SELECT * FROM pgstattuple('your_table_name');
    

总结建议

情况 解决方案
日常维护 保持 Autovacuum 开启,并针对大表调优触发阈值。
紧急释放磁盘空间 如果能停机,使用 VACUUM FULL;不能停机,使用 pg_repack
预防膨胀 尽量避免超长事务(Long-running transactions),因为它们会阻止清理进程回收死元组。

生产实践

在同一个事务中根据查询结果修改多张表,核心在于保证原子性(Atomicity)一致性(Consistency)。在 Java 中,使用 LangChain4j 通常意味着你处于一个业务应用层,这通常结合 Spring 框架的 @Transactional 来实现。

以下是结合 Spring Boot + Spring Data JPA 的详细解析和代码示例。


1. 业务场景设定

需求:在 RAG 系统中,用户消耗积分进行提问。

  1. 查询用户当前的剩余积分Table: User)。
  2. 如果积分足够,修改用户积分表(扣分)。
  3. 同时记录一条审计日志Table: AuditLog)。
  4. 如果中途报错,扣分和日志都要回滚。

2. 详细代码实现

A. 实体类定义

@Entity
public class User {
    @Id
    private Long id;
    private Integer balance; // 积分余额
    // getters/setters
}

@Entity
public class AuditLog {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String action;
    private LocalDateTime createdAt;
    // getters/setters
}

B. 业务服务逻辑

这是最关键的部分,通过 @Transactional 开启事务。

@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private AuditLogRepository auditLogRepository;

    /**
     * 根据查询结果更新多张表
     * @param userId 用户ID
     * @param costPoints 扣除积分数
     */
    @Transactional // 核心:开启数据库事务
    public void processUserQuery(Long userId, int costPoints) {
        
        // 1. 查询数据 (SELECT)
        // 使用 findById 会将对象纳入 JPA 一级缓存(持久化上下文)
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new RuntimeException("用户不存在"));

        // 2. 根据查询结果进行逻辑判断
        if (user.getBalance() < costPoints) {
            throw new RuntimeException("积分不足,操作取消");
        }

        // 3. 修改第一张表 (UPDATE)
        // 在事务内直接修改对象属性,JPA 在事务提交时会自动执行 update 语句
        user.setBalance(user.getBalance() - costPoints);
        userRepository.save(user); 

        // 4. 修改第二张表 (INSERT)
        AuditLog log = new AuditLog();
        log.setAction("消耗积分: " + costPoints);
        log.setCreatedAt(LocalDateTime.now());
        auditLogRepository.save(log);

        // 事务结束:如果运行到这里没报错,Spring 会提交事务(Commit)
        // 如果中间抛出 RuntimeException,事务会自动回滚(Rollback)
    }
}

3. 深度解析:底层发生了什么?

  1. 事务开启 (Begin):当方法被调用时,Spring 拦截器会通过 PlatformTransactionManager 获取一个数据库连接,并执行 SET AUTOCOMMIT = 0
  2. 查询与锁定
    • 在默认的 Read Committed 级别下,SELECT 不会加锁。
    • 进阶技巧:如果担心并发下积分被“扣成负数”,应使用悲观锁。在 findById 时加上 Pessimistic Lock,SQL 会变成 SELECT ... FOR UPDATE,防止其他事务同时修改该行。
  3. MVCC 版本生成
    • 根据前文提到的 PostgreSQL MVCC 原理,当你执行 userRepository.save(user) 时,数据库并不会删除旧行,而是生成一个新的 Tuple,并将旧 Tuple 的 xmax 设为当前事务 ID。
  4. 原子提交
    • 只有当整个方法执行完毕,Spring 才会发出 COMMIT 命令。
    • 一旦提交,CLOG (Commit Log) 会将当前事务标记为 COMMITTED,此时新生成的积分数据和日志记录对其他事务才真正可见。

4. 关键注意事项

  • 异常回滚:默认情况下,Spring 只对 RuntimeExceptionError 进行回滚。如果你抛出的是受检异常(Checked Exception),需要在注解中声明:@Transactional(rollbackFor = Exception.class)
  • 同类调用失效:如果在同一个类中,方法 A(无事务)调用方法 B(有事务),事务会失效。这是因为 Spring AOP 是基于代理的,同类调用不经过代理对象。
  • 数据库隔离级别
    • 如果业务非常严格,可以使用 @Transactional(isolation = Isolation.REPEATABLE_READ)。但在 PostgreSQL 中,通常默认的 Read Committed 配合 SELECT FOR UPDATE 是性能与安全的最优平衡。

修改同一条记录导致的“数据覆盖”

在 PostgreSQL 中,当多个并发事务尝试同时修改同一条记录时,就会出现所谓的“更新丢失” (Lost Update) 现象,即后提交的事务无意中覆盖了先提交事务的修改。

根据你所使用的事务隔离级别不同,PostgreSQL 处理这种冲突的机制也完全不同。


1. 默认级别:Read Committed (读已提交)

在这个级别下,Postgres 采用 “阻塞并重新评估” 的策略。

  • 机制
    1. 事务 A 开始更新行 \(R\),对该行加行级排他锁。
    2. 事务 B 尝试更新行 \(R\),发现已被锁,于是进入等待状态
    3. 事务 A 提交(Commit)。
    4. 事务 B 被唤醒。此时,B 会重新读取 A 修改后的最新版本,并重新评估 WHERE 条件。如果条件依然满足,B 会在 A 的修改基础上再次修改。
  • 后果:虽然不会发生物理覆盖,但如果事务 B 的逻辑是 SET balance = balance - 10(基于旧值计算),而它没有重新检查逻辑,就可能导致业务逻辑上的“数据覆盖”。

2. 高级别:Repeatable Read (可重复读)

在这个级别下,Postgres 采用 “先到先得,后者报错” 的策略。

  • 机制
    1. 事务 B 尝试更新已被事务 A 锁定的行。
    2. 当事务 A 提交后,事务 B 发现该行在自己快照开始后被修改过了。
    3. Postgres 为了保证“可重复读”的语义,不允许 B 在一个它“看不见其变化”的行上进行更新。
  • 后果:事务 B 会直接报错并回滚:

    ERROR: could not serialize access due to concurrent update

  • 对策:应用程序必须捕获这个异常并进行重试(Retry)

3. 如何在开发中彻底解决“数据覆盖”?

针对你提到的“数据覆盖”,通常有三种工业级解决方案:

方案 A:原子更新 (Atomic Update)

不要在应用层计算好结果再写回,而是让数据库在更新时自增/自减。

  • 错误做法UPDATE account SET balance = 90 WHERE id = 1;(90 是在 Java 里算的)
  • 正确做法UPDATE account SET balance = balance - 10 WHERE id = 1;
  • 原理:利用数据库内部的行锁,保证计算基于最新值。

方案 B:悲观锁 (Pessimistic Locking)

在读取数据时就直接锁定,防止别人插手。

-- 在同一个事务中
SELECT * FROM account WHERE id = 1 FOR UPDATE;
-- 此时其他事务尝试 SELECT ... FOR UPDATE 或 UPDATE 都会被阻塞
UPDATE account SET balance = ... WHERE id = 1;
  • 适用场景:并发竞争非常激烈,预计冲突频繁发生。

方案 C:乐观锁 (Optimistic Locking)

通过版本号(Version)或时间戳来检测冲突。

-- 1. 先读取版本号
-- SELECT id, balance, version FROM account WHERE id = 1; (假设 version 是 5)

-- 2. 更新时检查版本号是否没变
UPDATE account 
SET balance = 80, version = version + 1 
WHERE id = 1 AND version = 5;

-- 3. 检查更新行数。如果返回 0,说明被别人改了,需要重试。
  • 适用场景:读多写少,冲突概率较低,追求高性能。

4. 总结建议

如果你正在开发 RAG 系统中的积分扣除或配额管理:

  1. 首选:使用 UPDATE ... SET balance = balance - 1 这种原子操作。
  2. 次选:如果逻辑复杂需要多步判断,使用 SELECT ... FOR UPDATE 锁定用户记录。
  3. 注意:如果是长连接或分布式环境,乐观锁(版本号) 是扩展性最好的方案。

死锁问题以及性能问题产生,如何解决

在 PostgreSQL 中,死锁和性能问题通常是由于 MVCC 机制的副作用不合理的锁持有时间以及查询计划失准导致的。我们可以从“监控诊断”到“治理优化”两个维度来拆解。


一、 死锁(Deadlock)的排查与解决

当两个事务互相等待对方持有的锁时,就会发生死锁。PostgreSQL 默认会在 1 秒(deadlock_timeout)后自动检测并强行终止其中一个事务。

1. 诊断死锁

  • 查看日志:死锁发生时,Postgres 会在日志中详细记录 Process holding the lockProcess waiting for the lock。这是最直观的线索。
  • 实时监控:查看当前正在等待锁的进程。
    SELECT pid, wait_event_type, wait_event, state, query 
    FROM pg_stat_activity 
    WHERE wait_event_type = 'Lock';
    

2. 预防与解决

  • 固定访问顺序:这是解决死锁最有效的金科玉律。例如,所有事务都必须先修改 Table A 再修改 Table B
  • 缩短事务时间:事务持有的锁在提交前不会释放。将耗时长的非数据库操作(如调用外部 AI 接口)移出事务。
  • 使用 SELECT ... FOR UPDATE NOWAIT:尝试获取锁时如果不成功立即报错,而不是死等,由应用层决定重试策略。

二、 性能问题的诊断:慢查询与资源瓶颈

PostgreSQL 性能下降通常由三类原因引起:索引失效表膨胀(Bloat)配置不当

1. 慢查询定位:EXPLAIN ANALYZE

如果你发现某个 SQL 很慢,第一步永远是查看执行计划:

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id = 100;
  • 关注点
    • Seq Scan:全表扫描。如果数据量大,说明缺少索引。
    • Index Scan:索引扫描。
    • Actual time:实际耗时。
    • Buffers:查看是否产生了大量的磁盘 I/O。

2. 表膨胀与统计信息:VACUUM & ANALYZE

由于 MVCC 机制,频繁更新会导致死元组堆积(表膨胀)。

  • 手动修复:对频繁操作的表手动执行 ANALYZE 刷新统计信息,帮助优化器选对索引。
  • 深度清理:如果表体积异常(如 1GB 数据占用了 10GB 磁盘),需使用前文提到的 pg_repack 重新组织物理存储。

3. 监控实时负载:pg_stat_statements

这是官方推荐的性能分析神器,需要预先在 postgresql.conf 中开启。

-- 查看最耗时的前10个 SQL
SELECT query, calls, total_exec_time, mean_exec_time, rows 
FROM pg_stat_statements 
ORDER BY total_exec_time DESC 
LIMIT 10;

三、 核心优化方案建议

1. 索引优化

  • 覆盖索引 (Covering Indexes):使用 INCLUDE 子句将常用列放入索引,实现 Index Only Scan。
  • 部分索引 (Partial Indexes):只针对活跃数据建索引,例如 WHERE active = true,减小索引体积。

2. 参数调优

根据服务器内存调整核心参数(以 16GB 内存为例):

  • shared_buffers: 设置为内存的 25% (约 4GB)。
  • work_mem: 影响排序和连接的速度。并发高时设小点(16MB),复杂计算时设大点。
  • effective_cache_size: 设置为总内存的 50-75%,告诉优化器有多少内存可用于缓存。

3. 连接池管理

PostgreSQL 为每个连接创建一个进程,开销较大。

  • 必须使用连接池:在应用层使用 HikariCP,或者在数据库侧部署 PgBouncer
  • 解决问题:防止“连接爆炸”导致的内存溢出和 CPU 上下文切换频繁。

总结排查路径:

  1. 看状态:通过 pg_stat_activity 看谁在阻塞。
  2. 看历史:通过 pg_stat_statements 找最慢的 SQL。
  3. 看计划:通过 EXPLAIN ANALYZE 拆解慢 SQL 的执行步骤。
  4. 看环境:检查 Autovacuum 是否跟上了更新频率。

偶尔的瞬时卡顿(可能是锁竞争),持续性的查询缓慢(可能是索引问题)


并发操作xmin和xmax, ctid如何变,postgresql删除某一行数据,如何知道这条数据被删除

这是一个非常深刻的问题,触及了 PostgreSQL MVCC 的灵魂:数据在磁盘上从不真正“消失”,直到被清理。

为了让你看清这个过程,我们需要两个并发的会话(Session A 和 Session B)。

1. 并发修改时隐藏列的变化过程

假设我们有一行数据:id=1, name='Alice'

第一阶段:初始状态

  • ctid: (0,1)
  • xmin: 100 (创建它的事务)
  • xmax: 0 (尚未被删除或更新)

第二阶段:并发更新(事务 A 正在修改,尚未提交)

事务 A 执行 UPDATE ... SET name = 'Bob' WHERE id = 1;

  1. 旧行 (0,1)xmax 被设为事务 A 的 ID(假设是 101)。
  2. 新行 (0,2):被创建,xmin 设为 101xmax 设为 0
  3. 指向关系:旧行的 ctid 会更新,指向新行的位置 (0,2)

第三阶段:可见性冲突

  • 事务 A:能看到新行 (0,2),因为 xmin 是它自己。
  • 事务 B(并发中):仍然只能看到旧行 (0,1),因为 xmax=101 的事务还没提交,根据快照规则,它认为 101 还没发生。

2. 删除某一行,如何“知道”它被删了?

在 PostgreSQL 中,DELETE 操作其实是一个特殊的更新。它只是给行打上了一个“死亡标记”。

如何知道数据被删除了?

由于普通的 SELECT 会自动过滤掉不可见的数据,你直接查是查不到的。你需要通过以下几种底层手段:

方法 A:查看隐藏的 xmax(在事务提交前)

如果你在事务 A 删除了数据,但在 Commit 之前,你在另一个窗口查:

-- 窗口 B 查询
SELECT ctid, xmin, xmax, name FROM mvcc_demo WHERE id = 1;

如果 xmax 变成了一个非 0 的数字,说明这行正处于“待删除”或“正在被更新”的状态。

方法 B:使用 pageinspect 扩展(大杀器)

这是查看“已删除但未清理”数据的终极方法。它可以直接读取磁盘原始二进制数据,绕过 MVCC 的可见性检查。

-- 1. 安装插件
CREATE EXTENSION IF NOT EXISTS pageinspect;

-- 2. 查看第 0 号数据页(Page)的所有元组状态
-- 即使你执行了 DELETE 并在外部 SELECT 不到了,在这里依然能看到它
SELECT * FROM heap_page_items(get_raw_page('mvcc_demo', 0));

在这个视图里:

  • 你可以看到所有 lp (Line Pointer)。
  • 即使数据被删了,只要还没 VACUUM,它的条目依然存在。
  • 通过查看 t_infomask 标记,可以知道这行是否被标记为已删除。

3. 实验代码:手把手带你“看”删除

请在两个窗口中运行以下逻辑:

窗口 A (Session A):

BEGIN;
SELECT txid_current(); -- 假设返回 2005
DELETE FROM mvcc_demo WHERE id = 1;
-- 注意:千万别 COMMIT!

窗口 B (Session B):

-- 虽然 A 删了,但 B 还能看到,且能看到 xmax 变了
SELECT ctid, xmin, xmax, name FROM mvcc_demo WHERE id = 1;
-- 结果:你会发现 xmax 变成了 2005

回到窗口 A:

COMMIT;

再次在窗口 B 查询:

SELECT ctid, xmin, xmax, name FROM mvcc_demo WHERE id = 1;
-- 结果:(0 rows),数据“消失”了。

终极验证(查看死元组):

-- 即使 SELECT 不到了,我们去查统计视图
SELECT n_live_tup, n_dead_tup 
FROM pg_stat_user_tables 
WHERE relname = 'mvcc_demo';
-- 结果:n_live_tup = 0, n_dead_tup = 1 (证明它还在磁盘上,只是变成了“死元组”)

总结

  1. 更新时ctid 会变,xmin 变新,旧行的 xmax 标记为更新者的 \(XID\)
  2. 删除时ctid 不变,只是 xmax 被填上了删除者的 \(XID\)
  3. 如何知道被删:看 xmax 是否非 0,或者查看 n_dead_tup 计数器,或者用 pageinspect 强行读取磁盘原始数据。

出现这个错误的原因很简单:PostgreSQL 中并没有 pg_current_snapshot() 这个函数

你可能记混了函数名称。在 PostgreSQL 中,用于查看当前事务快照信息的函数名为:

1. 正确的函数名

请尝试执行以下命令:

SELECT pg_export_snapshot();

或者,如果你想查看当前的事务 ID 范围(快照状态),通常使用:

SELECT pg_current_snapshot(); -- ❌ 错误
SELECT pg_snapshot_xmin(pg_current_snapshot()); -- ❌ 仍然错误(版本差异)

2. 不同版本的正确写法

PostgreSQL 在 13 版本之后对快照函数的命名进行了规范化(统一使用 snapshot 关键字),如果你使用的是较新版本,请参考下表:

功能 PostgreSQL 13+ (推荐) PostgreSQL 13 以前 (旧版)
获取当前快照对象 SELECT pg_current_snapshot(); SELECT txid_current_snapshot();
获取快照的 xmin SELECT pg_snapshot_xmin(pg_current_snapshot()); SELECT txid_snapshot_xmin(txid_current_snapshot());
获取快照的 xmax SELECT pg_snapshot_xmax(pg_current_snapshot()); SELECT txid_snapshot_xmax(txid_current_snapshot());

报错原因排查:

  1. 拼写错误:如果你使用的是 PG 13 之前的版本,pg_current_snapshot() 是不存在的,你需要使用 txid_current_snapshot()
  2. 版本过旧:你可以通过 SELECT version(); 查看你的数据库版本。如果是 12 或更低版本,请将前缀 pg_ 改为 txid_

3. 如何解决?

如果你只是想看一眼当前的事务信息,最通用的写法(兼容旧版)是:

SELECT txid_current_snapshot();

或者,如果你想看当前的事务 ID,可以使用:

-- PG 13+
SELECT pg_current_xact_id();

-- 旧版本
SELECT txid_current();

在 Spring Data JPA 体系中,处理 Repeatable Read 导致的 Serialization Failure(以及类似的死锁或并发乐观锁冲突)最优雅的方式是使用 Spring Retry

手动写 try-catchwhile 循环会让代码变得臃肿,而 Spring Retry 允许你通过注解实现自动回退和重试。


1. 引入依赖

首先,在 pom.xml 中添加 Spring Retry 和 AOP 的支持:

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2. 启用重试功能

在你的 Spring Boot 启动类或配置类上添加 @EnableRetry 注解:

@SpringBootApplication
@EnableRetry
public class MyApp {
    public static void main(String[] args) {
        SpringApplication.run(MyApp.class, args);
    }
}

3. 在 Service 层使用 @Retryable

针对可能抛出写冲突的方法,添加重试逻辑。对于 PostgreSQL 的串行化失败,它通常会抛出 CannotAcquireLockException 或底层的 PSQLException

@Service
public class ProductService {

    @Autowired
    private ProductRepository repository;

    // 当遇到指定的异常时重试,最多重试 3 次,每次间隔 1 秒
    @Retryable(
        value = { org.springframework.dao.PessimisticLockingFailureException.class, 
                  org.springframework.dao.CannotAcquireLockException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000)
    )
    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public void updateStock(Long id, int amount) {
        Product product = repository.findById(id)
            .orElseThrow(() -> new RuntimeException("Not Found"));
        
        product.setStock(product.getStock() - amount);
        repository.save(product);
    }

    // 兜底方案:如果 3 次重试都失败了,执行此方法
    @Recover
    public void recover(Exception e, Long id, int amount) {
        // 记录日志或通知管理员
        System.err.println("重试耗尽,手动记录失败任务: " + e.getMessage());
    }
}

4. 关键细节说明

1. 异常类型匹配

PostgreSQL 抛出的 SQL 错误码 40001 (Serialization Failure) 在 Spring 中通常被映射为 PessimisticLockingFailureExceptionCannotAcquireLockException

提示:建议在第一次报错时打印出异常的具体类名,确保 @Retryablevalue 捕获到了正确的异常。

2. @Transactional 与 @Retryable 的顺序

这是一个经典陷阱。@Retryable 必须在 @Transactional 的外层。

  • 如果重试注解在内层,事务报错回滚后,重试会在同一个已经“挂掉”的事务中进行,依然会失败。
  • 默认情况下,Spring Retry 的 AOP 优先级高于事务,所以直接像上面代码那样写在同一个方法上通常是有效的。

3. 幂等性

确保被重试的方法是幂等的。在 JPA 中,由于 @Transactional 会在失败时回滚所有操作,重试机制通常是安全的,因为它会开启一个全新的事务。


5. 什么时候不该重试?

虽然重试能解决临时的并发冲突,但不能滥用:

  • 业务逻辑错误:如“余额不足”,重试 100 次也没用。
  • 高并发下的热点行:如果同一行数据每秒有几千次竞争,重试会导致大量的数据库连接积压。这种情况下,考虑使用 Redis 分布式锁消息队列串行化处理 效果更好。

在实际生产环境中,绝大多数应用都应该使用默认的 Read Committed(读已提交)

虽然 Repeatable ReadSerializable 听起来更安全,但它们在生产中会带来更高的架构复杂度性能开销


1. 为什么 90% 的场景选择 Read Committed

这是 PostgreSQL、Oracle 和 SQL Server 等主流数据库的默认选择(MySQL 默认是 Repeatable Read,但很多大厂也会将其调回 Read Committed)。

  • 高性能:锁的持有时间短,并发能力强。
  • 符合直觉:只要别人提交了,我就能读到最新的。
  • 无额外负担:不需要应用端去编写复杂的“重试逻辑(Retry Logic)”。
  • 避免死锁:相比高隔离级别,产生死锁和序列化冲突的概率大大降低。

2. 隔离级别对比与适用场景

隔离级别 生产推荐度 核心特点 适用场景
Read Committed 首选 (Standard) 语句级快照。不会读到脏数据,但会有不可重复读。 绝大多数业务:电商下单、社交应用、CMS 等。
Repeatable Read 慎用 (Special) 事务级快照。能解决不可重复读,但会产生写冲突报错。 报表统计(需要事务内多次查询数据一致)。
Serializable 极少使用 (Critical) 完全隔离。性能最差,冲突率最高。 金融核心对账、极高要求的资产划转。

3. 生产中的“黄金法则”

如果你担心 Read Committed 下的并发安全问题(例如:两个人同时修改余额),不要通过提升全局隔离级别来解决

方案 A:悲观锁(SELECT FOR UPDATE)

Read Committed 级别下,对关键行加锁。这会强制并发事务排队,而不是报错。

// Spring Data JPA
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Product> findById(Long id);

方案 B:乐观锁(@Version)

这是最推荐的生产实践。利用一个版本号字段,在提交时检查。如果版本变了,JPA 会抛出 OptimisticLockException,你再配合 Spring Retry 进行重试。

@Version
private Long version;

方案 C:原子 SQL 更新

直接在 SQL 层面处理逻辑,利用数据库的行锁特性,这是最高效的。

UPDATE account SET balance = balance - 100 WHERE id = 1 AND balance >= 100;

4. 总结建议

  1. 保持默认:将数据库全局隔离级别维持在 Read Committed
  2. 局部加强
  • 如果需要强一致性且并发量小:在 Service 方法上加 @Transactional(isolation = Isolation.REPEATABLE_READ) 并配合重试。
  • 如果需要防止超卖:使用 Pessimistic Lock (SELECT FOR UPDATE)。
  • 如果追求性能:使用 @Version 乐观锁。