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 与当前快照来实现:
- 快照信息:包含
active_xids(当前活跃的事务 ID 列表)。 - 判断逻辑:
- 如果 Tuple 的
xmin还在active_xids中 \(\rightarrow\) 不可见(说明创建它的事务还没完)。 - 如果 Tuple 的
xmax不在active_xids中且已提交 \(\rightarrow\) 不可见(说明它已被删除)。
- 如果 Tuple 的
在 PostgreSQL 的 MVCC 设计中,这四个概念共同构成了数据的“时空坐标系”。我们可以把它们拆解为两部分:xmin/xmax/xip_list 负责“时间(可见性)”,而 ctid 负责“空间(物理位置)”。
1. 深度理解它们的关系
你可以把数据库想象成一本不断被修订的账本:
xmin(出生证明):记录了是谁(哪个事务)创建了这一行。xmax(死亡证明):记录了是谁删除了这一行,或者是谁通过更新操作产生了这一行的“下一代”。xip_list(活跃名单):这是事务快照里的内容。它告诉当前事务:“虽然这些事务 ID 很大,但它们还没干完活,你不能看它们的数据。”ctid(物理门牌号):记录了这一行在磁盘文件里的具体位置。如果数据被更新了,旧行的ctid就像一个指路牌,指向新行的ctid。
它们是如何协同工作的?
当你执行 SELECT 时,数据库会拿出一张快照(包含 xip_list),然后去检查每一行的 xmin 和 xmax。只有当 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)。这个快照决定了当前事务能看到哪些数据。一个快照包含三个核心要素:
xmin(Lowest active XID):所有小于此 ID 的事务都已经提交,其结果可见。xmax(First unassigned XID):所有大于或等于此 ID 的事务在快照创建时尚未开始,其结果不可见。xip_list(Active XIDs):处于xmin和xmax之间且当前仍在运行的事务列表。这些事务的结果不可见。
3. 核心操作的内部流程
更新(UPDATE)的艺术
PostgreSQL 不会直接修改原始数据。当你更新一行时:
- 标记旧行:将旧 Tuple 的
xmax设置为当前事务的 \(XID\)。 - 插入新行:在磁盘空闲处插入一个全新的 Tuple,其
xmin设置为当前 \(XID\),并将旧 Tuple 的t_ctid指向新 Tuple 的位置。 - 链条形成:这种设计形成了一个版本链(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。
- 特点:它能在不长时间锁表的情况下重新整理表空间。
- 原理:
- 创建一个新表和日志记录表。
- 将旧数据迁移到新表。
- 利用触发器将迁移期间产生的增量变化应用到新表。
- 最后通过一个简短的元数据交换(秒级锁)完成替换。
- 安装建议:它是 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 系统中,用户消耗积分进行提问。
- 查询用户当前的剩余积分(
Table: User)。 - 如果积分足够,修改用户积分表(扣分)。
- 同时记录一条审计日志(
Table: AuditLog)。 - 如果中途报错,扣分和日志都要回滚。
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. 深度解析:底层发生了什么?
- 事务开启 (Begin):当方法被调用时,Spring 拦截器会通过
PlatformTransactionManager获取一个数据库连接,并执行SET AUTOCOMMIT = 0。 - 查询与锁定:
- 在默认的
Read Committed级别下,SELECT不会加锁。 - 进阶技巧:如果担心并发下积分被“扣成负数”,应使用悲观锁。在
findById时加上Pessimistic Lock,SQL 会变成SELECT ... FOR UPDATE,防止其他事务同时修改该行。
- 在默认的
- MVCC 版本生成:
- 根据前文提到的 PostgreSQL MVCC 原理,当你执行
userRepository.save(user)时,数据库并不会删除旧行,而是生成一个新的 Tuple,并将旧 Tuple 的xmax设为当前事务 ID。
- 根据前文提到的 PostgreSQL MVCC 原理,当你执行
- 原子提交:
- 只有当整个方法执行完毕,Spring 才会发出
COMMIT命令。 - 一旦提交,CLOG (Commit Log) 会将当前事务标记为
COMMITTED,此时新生成的积分数据和日志记录对其他事务才真正可见。
- 只有当整个方法执行完毕,Spring 才会发出
4. 关键注意事项
- 异常回滚:默认情况下,Spring 只对
RuntimeException和Error进行回滚。如果你抛出的是受检异常(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 采用 “阻塞并重新评估” 的策略。
- 机制:
- 事务 A 开始更新行 \(R\),对该行加行级排他锁。
- 事务 B 尝试更新行 \(R\),发现已被锁,于是进入等待状态。
- 事务 A 提交(Commit)。
- 事务 B 被唤醒。此时,B 会重新读取 A 修改后的最新版本,并重新评估
WHERE条件。如果条件依然满足,B 会在 A 的修改基础上再次修改。
- 后果:虽然不会发生物理覆盖,但如果事务 B 的逻辑是
SET balance = balance - 10(基于旧值计算),而它没有重新检查逻辑,就可能导致业务逻辑上的“数据覆盖”。
2. 高级别:Repeatable Read (可重复读)
在这个级别下,Postgres 采用 “先到先得,后者报错” 的策略。
- 机制:
- 事务 B 尝试更新已被事务 A 锁定的行。
- 当事务 A 提交后,事务 B 发现该行在自己快照开始后被修改过了。
- 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 系统中的积分扣除或配额管理:
- 首选:使用
UPDATE ... SET balance = balance - 1这种原子操作。 - 次选:如果逻辑复杂需要多步判断,使用
SELECT ... FOR UPDATE锁定用户记录。 - 注意:如果是长连接或分布式环境,乐观锁(版本号) 是扩展性最好的方案。
死锁问题以及性能问题产生,如何解决
在 PostgreSQL 中,死锁和性能问题通常是由于 MVCC 机制的副作用、不合理的锁持有时间以及查询计划失准导致的。我们可以从“监控诊断”到“治理优化”两个维度来拆解。
一、 死锁(Deadlock)的排查与解决
当两个事务互相等待对方持有的锁时,就会发生死锁。PostgreSQL 默认会在 1 秒(deadlock_timeout)后自动检测并强行终止其中一个事务。
1. 诊断死锁
- 查看日志:死锁发生时,Postgres 会在日志中详细记录
Process holding the lock和Process 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 上下文切换频繁。
总结排查路径:
- 看状态:通过
pg_stat_activity看谁在阻塞。 - 看历史:通过
pg_stat_statements找最慢的 SQL。 - 看计划:通过
EXPLAIN ANALYZE拆解慢 SQL 的执行步骤。 - 看环境:检查
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;
- 旧行
(0,1):xmax被设为事务 A 的 ID(假设是101)。 - 新行
(0,2):被创建,xmin设为101,xmax设为0。 - 指向关系:旧行的
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 (证明它还在磁盘上,只是变成了“死元组”)
总结
- 更新时:
ctid会变,xmin变新,旧行的xmax标记为更新者的 \(XID\)。 - 删除时:
ctid不变,只是xmax被填上了删除者的 \(XID\)。 - 如何知道被删:看
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()); |
报错原因排查:
- 拼写错误:如果你使用的是 PG 13 之前的版本,
pg_current_snapshot()是不存在的,你需要使用txid_current_snapshot()。 - 版本过旧:你可以通过
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-catch 和 while 循环会让代码变得臃肿,而 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 中通常被映射为 PessimisticLockingFailureException 或 CannotAcquireLockException。
提示:建议在第一次报错时打印出异常的具体类名,确保
@Retryable的value捕获到了正确的异常。
2. @Transactional 与 @Retryable 的顺序
这是一个经典陷阱。@Retryable 必须在 @Transactional 的外层。
- 如果重试注解在内层,事务报错回滚后,重试会在同一个已经“挂掉”的事务中进行,依然会失败。
- 默认情况下,Spring Retry 的 AOP 优先级高于事务,所以直接像上面代码那样写在同一个方法上通常是有效的。
3. 幂等性
确保被重试的方法是幂等的。在 JPA 中,由于 @Transactional 会在失败时回滚所有操作,重试机制通常是安全的,因为它会开启一个全新的事务。
5. 什么时候不该重试?
虽然重试能解决临时的并发冲突,但不能滥用:
- 业务逻辑错误:如“余额不足”,重试 100 次也没用。
- 高并发下的热点行:如果同一行数据每秒有几千次竞争,重试会导致大量的数据库连接积压。这种情况下,考虑使用 Redis 分布式锁 或 消息队列串行化处理 效果更好。
在实际生产环境中,绝大多数应用都应该使用默认的 Read Committed(读已提交)。
虽然 Repeatable Read 和 Serializable 听起来更安全,但它们在生产中会带来更高的架构复杂度和性能开销。
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. 总结建议
- 保持默认:将数据库全局隔离级别维持在
Read Committed。 - 局部加强:
- 如果需要强一致性且并发量小:在 Service 方法上加
@Transactional(isolation = Isolation.REPEATABLE_READ)并配合重试。 - 如果需要防止超卖:使用
Pessimistic Lock(SELECT FOR UPDATE)。 - 如果追求性能:使用
@Version乐观锁。