1. 从一条报错说起:openGauss 的事务槽到底是什么
先抛一个场景。某个深夜,我接到一个电话,说测试环境的 openGauss 数据库突然“写不进去了”,应用日志里反复出现类似这样的错误:
code复制ERROR: cannot allocate transaction slot, please check the undo space
第一反应是磁盘满了?查了一下磁盘,剩余空间还很充足。再查数据库连接,也没满。后来翻到数据库日志,发现一条很关键的信息:undo 事务槽分配失败。
这里面的“事务槽”,就是 openGauss 在 UStore 引擎下用于管理 UNDO 日志的核心结构。很多人一开始接触 openGauss 的 UNDO 机制时,会下意识地把它类比成 Oracle 的回滚段,或者 MySQL 的 undo log。方向上没错,但 openGauss 对事务槽的管理方式,和这两种数据库有本质上的差异。如果没搞懂事务槽的分配、占用和回收模型,线上出了类似的问题,排查起来会非常被动。
这篇文章不打算从源码逐行讲,而是站在“一线运维 + 开发协作”的角度,把 openGauss 的 undo 事务槽是什么、为什么存在、一个事务从开始到结束占用槽位的完整过程、以及事务槽耗尽的排查手段讲透。适合谁看?DBA、负责 openGauss 内核相关业务的开发,以及正在从 PostgreSQL 迁移到 openGauss、想搞清楚 UStore 引擎原理的同学。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么 openGauss 要打破 PostgreSQL 的 MVCC 老路,重新设计 UNDO 机制
想理解事务槽,得先理解 openGauss 为什么需要一套自己的 UNDO 机制。这里牵扯到一个引擎层面的选择。
PostgreSQL 的 MVCC 实现方式是:旧版本数据直接留在数据页里。每次 UPDATE,物理上是在页面里插入一条新版本的行,旧版本不立即删除,而是通过 xmin/xmax 和版本链来标记可见性。旧版本数据一直堆在那里,直到执行 VACUUM 才能清理。
这种机制有个长期被诟病的毛病:表膨胀。频繁更新的表,页面里堆满了旧版本,查询要扫描大量无用数据,索引效率也下降。VACUUM 不及时,表能膨胀到实际数据量的几倍甚至几十倍。
openGauss 内置了两种存储引擎,AStore 沿用了 PostgreSQL 的 MVCC 思路,而 UStore 走的是另一条路:旧版本数据不再留在表页面里,而是被搬到了独立的 UNDO 存储区域。更新一条记录时,表页里只保留最新版本,更新前的镜像写入 UNDO 日志。这样一来,表页面不会因为历史版本堆积而膨胀,VACUUM 的压力也大幅降低。
那 UNDO 日志怎么组织?总得有个索引,让系统能快速找到一个事务产生的所有 UNDO 记录。这就是事务槽存在的意义。
可以这样理解:UNDO 存储区域是一栋大楼,里面有很多房间(undo segment),每个房间又分成很多储物柜(transaction slot)。每个需要写 UNDO 的事务,进来先领一个储物柜,把自己的事务 ID 贴在柜子上,然后把产生的所有 UNDO 记录串起来放在柜子里。别的会话要读取旧版本时,通过数据行上记录的 xmin 找到对应的事务 ID,再通过事务槽找到这个事务的 UNDO 记录链,从而构造出旧版本。
没有事务槽,UNDO 记录就是一盘散沙,查询旧版本时无从定位。换句话说,事务槽就是 UNDO 系统里连接“数据行版本”和“UNDO 记录”的桥。
这里还牵扯到另一个关键差异:AStore 回滚时要把旧版本重新放回页面里,涉及大量页面操作,回滚开销很大。而 UStore 回滚时,只需要沿着事务槽找到 UNDO 记录,把数据页恢复到修改前状态即可。长事务回滚的效率,UStore 明显占优。
搞清楚这个背景,就能理解事务槽为什么不能简单地“用完就删”。它承担的任务不只是记录“这个事务写了几条 UNDO”,还包括:事务处于什么状态、产生了多少 UNDO、这些 UNDO 是否还能被其他事务访问。这些信息直接决定了槽位什么时候才能真正被回收复用。
3. 事务槽长什么样:职责、状态机与核心结构
3.1 事务槽物理上放在哪里
事务槽不是独立存在的对象,它存在于 UNDO 段中。openGauss 的 UNDO 空间按段划分,每个段里除了存放 UNDO 记录数据之外,还划分了一块专门区域用来存放事务槽。
一个事务槽本质上是一个固定长度的数据结构,在段内连续排列,通过槽位号可以快速定位。这个设计很像操作系统里的文件描述符表,每次分配就是找一个空闲项,填上信息;回收就是把该位置空出来,等待复用。
每个事务槽需要记录的关键信息,核心是以下几个:
- 事务 ID(xid):标识占用这个槽位的事务。
- UNDO 段标识(u_xid):标识该事务的 UNDO 记录落在哪个 UNDO 段。
- UNDO 记录指针(urp):指向该事务第一条 UNDO 记录的位置。后续的 UNDO 记录通过链式关系串联,这样顺着 urp 就能遍历整个事务的 UNDO 记录。
- 事务槽状态:当前事务的生命周期状态,决定这个槽位是否可被复用。
- 其他辅助信息:比如事务提交序号、回滚标志、undo 记录数量等,用于保证可见性判断和异常恢复的正确性。
3.2 事务槽的四种状态
事务槽在整个生命周期中,状态大体可以归纳为四类:
| 状态 | 含义 | 槽位是否可复用 |
|---|---|---|
| ACTIVE | 事务正在运行中,不断写入 UNDO | 不可复用 |
| COMMITTED | 事务已提交,但旧版本可能仍被其他并发事务读取 | 不可复用 |
| ROLLBACK | 事务已回滚,UNDO 记录正在被清理或等待清理 | 不可复用 |
| EXPIRED | 事务产生的 UNDO 记录已经确认无人使用 | 可复用 |
这里特别要强调 COMMITTED 状态。很多第一次接触事务槽的人会想:事务都提交了,槽位还不释放?是不是泄漏了?
不是泄漏。事务提交之后,它的 UNDO 记录不能被立即删除,因为数据库里可能还有尚未结束的读事务,需要基于这些 UNDO 记录读取旧版本数据。只有等系统确认没有活跃事务再需要这些旧版本时,才能把 UNDO 记录清理掉,槽位状态才能变成 EXPIRED,回归可分配池。
这个机制和 PostgreSQL 的 VACUUM 清理逻辑本质上是一个道理:不允许清理任何活跃事务还需要访问的数据。只是 openGauss 在 UNDO 机制里,用状态机把这个过程量化了。
3.3 为什么要把槽位状态做得这么细
回到 3.2 的状态表,你会发现一个核心逻辑:槽位的可复用性最终取决于“还有没有其他人依赖这里的数据”。把状态拆得细,是为了精确控制 UNDO 空间回收的安全边界。
举个例子。事务 A 更新了一行数据,随后提交,状态变成 COMMITTED。事务 B 在 A 提交之前就开始了,按 MVCC 规则,B 读这行数据时应该看到 A 更新前的旧版本。如果 A 提交后立刻把 UNDO 记录删掉、把事务槽释放,B 就再也无法构建旧版本了,读到的数据就是错的。
所以事务提交不等于 UNDO 可清理。只有记录在这个槽位上的 UNDO 记录不再被任何活跃事务需要时,才能真正回收。openGauss 的 undo 回收线程会基于当前系统内最老活跃事务的快照,统一推进可清理边界。这个边界之前的事务槽全部可以置为 EXPIRED。
4. 一个事务的一生:事务槽分配、写入、提交与回收
4.1 什么时候分配事务槽
大部分人以为事务一开始就要分配事务槽,其实不是。只读事务不产生 UNDO,自然不需要事务槽。只有当事务第一次执行 INSERT、UPDATE、DELETE 这类会产生 UNDO 的操作时,才会去分配事务槽。
这个设计很合理。一个纯查询的事务,开事务槽纯属浪费。把分配时机推迟到真正需要 WRITE 时,可以减少短事务对事务槽资源的无谓占用,提升并发上限。
分配过程大致是这样:
- 事务执行 DML 语句,进入 UNDO 写入流程。
- 事务管理器在自己所属的 UNDO 段中查找空闲事务槽。
- 找到后,将事务 ID 写入槽位,状态置为 ACTIVE。
- 后续该事务产生的每一条 UNDO 记录,都会记录到这个事务槽的管理范围内,并保留前一条 UNDO 记录的指针,形成单向链表。
如果这个阶段找不到空闲事务槽,就会抛出分配失败的错误。这也是第 1 节那个线上场景的直接原因。
4.2 事务提交后,槽位状态如何迁移
事务执行 COMMIT 时,事务槽状态从 ACTIVE 变为 COMMITTED。此时 UNDO 记录还不能清理。
系统是怎么知道什么时候能清理呢?这里涉及一个“全局回收水位”的概念。UNDO 回收线程会维护一个系统内最老活跃事务的快照,比这个快照更早结束的事务,其 UNDO 记录可以被安全清理。提交时间早于回收水位的事务槽,会被批量置为 EXPIRED。
所以 COMMITTED 状态通常会持续一段时间,长短取决于系统内最老活跃事务的持续时间。如果一个长事务一直不结束,哪怕它自己没写多少数据,也会把回收水位牢牢卡住,导致很多早已提交的事务槽一直无法释放。
4.3 事务回滚时,事务槽如何参与
事务回滚是 UStore 引擎的优势场景。ROLLBACK 命令发出后,系统会通过事务槽找到该事务的第一条 UNDO 记录,然后沿着记录链逐条回放,把数据恢复到修改前状态。
回滚完成后,事务槽状态会先被标记为回滚状态,然后由回收线程确认数据恢复已对所有会话可见,最终置为 EXPIRED。
这里有个细节值得注意:回滚本身也是一个数据修复过程,期间其他并发会话可能正在读取相关数据。所以回滚后也不能立刻回收槽位,同样要等待回收水位确认。
4.4 槽位复用的完整条件
一个事务槽从分配到最后重新可用,经历的路程是:
code复制空闲 -> ACTIVE -> COMMITTED(或ROLLBACK) -> EXPIRED -> 空闲
影响这个路程长度的最大变量,不是事务本身的执行时间,而是系统中是否存在持续长时间不结束的旧事务。旧事务只要存在,回收水位就无法推进,所有早于水位的事务槽都会滞留在 COMMITTED 或 ROLLBACK 状态。
这也解释了为什么生产环境会反复强调“避免长事务”。长事务的危害不仅仅是锁资源,它还会在 UNDO 机制里造成连锁反应,让事务槽回收停滞。等到新事务继续涌入,事务槽分配必然失败。
5. 事务槽耗尽的排查链路与恢复方案
5.1 典型报错与现象
事务槽耗尽时,报错信息通常和“transaction slot”相关。应用中表现为所有涉及写入的操作失败,只读查询不受影响。如果同时积累了大量的 UNDO 无法清理,磁盘空间也会持续上涨。
要注意区分两个容易混淆的问题:事务槽耗尽和数据库用户账号被锁。这两个问题经常被放在一起搜索,是因为它们都会导致应用无法正常写入或登录,但本质完全不同。账号锁定通常是 failed_login_attempts 参数导致登录失败次数超限,和 UNDO 机制没有关系。排查时先确认错误码是认证失败还是执行失败,避免从一开始就走错方向。
5.2 排查步骤
第一步,先看当前系统内最老的活跃事务。这是所有问题排查的起点。使用查询观察 pg_stat_activity 中 xact_start 最早的会话,确认是否有长时间未提交的事务:
sql复制SELECT pid, state, xact_start, now() - xact_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active' AND xact_start IS NOT NULL
ORDER BY xact_start ASC;
第二步,观察 UNDO 的整体使用情况和事务槽分布。openGauss 提供相关的 UNDO 管理视图,比如查询各个 UNDO 段中事务槽的状态分布:
sql复制SELECT * FROM gs_undo_zone;
重点看 UNDO 段中的活跃事务数量、已提交未回收数量,以及是否有大量事务槽处于 COMMITTED 状态、系统回收进度是否长期停滞。
第三步,检查 UNDO 回收线程是否正常工作。openGauss 中负责 UNDO 清理的后台线程是 undo worker(由 autovacuum_undo_workers 参数控制并行度)。如果线程异常退出或者被禁用,事务槽回收就会停摆:
sql复制SELECT * FROM gs_global_undo_status;
第四步,结合参数判断当前系统配置的事务槽总容量是否合理。如果长期并发事务数远超默认配置,就要考虑扩容 UNDO 段数量。
5.3 恢复方案
确认长事务和回收停滞之间的关系后,恢复就相对简单了。最有效的手段是终止卡住回收水位的最老事务:
sql复制SELECT pg_terminate_backend(<pid>);
终止长事务后,回收水位会自动前移,大量积压的 COMMITTED 事务槽会被批量推进到 EXPIRED,新事务就能重新分配槽位了。
如果系统里不存在长事务,但事务槽依然耗尽,就需要检查是不是 UNDO 空间配置偏小,或者并发事务数确实超过了系统承载能力。这种情况下可以调整 UNDO 段数量参数,在维护窗口重启数据库实例使配置生效,从根本上扩容事务槽池。
恢复后务必持续观察一段时间,重点看事务槽的回收速率是否恢复到健康水平,而不是只盯着“当前能不能写入”。
6. 监控事务槽的实用命令与参数调优建议
6.1 关键参数
| 参数 | 作用 | 注意事项 |
|---|---|---|
| undo_segment_count | UNDO 段数量,决定事务槽总量上限 | 调整后需要重启实例生效,不要随意调大 |
| undo_space_limit_size | UNDO 空间总上限 | 默认 0 表示不限制,生产建议不设限或设置足够大 |
| autovacuum_undo_workers | UNDO 回收线程数量 | 调大可以加速回收,但会占用额外的 CPU 和 IO |
| max_concurrent_undo_trans | 可并发分配的 UNDO 事务数上限 | 直接关联事务槽池大小 |
事务槽总量不是凭空计算的,它取决于 UNDO 段数量和每段内可分配事务槽数量的乘积。调参之前,先评估业务并发模型,确认峰值并发写事务数的大致范围,再决定参数怎么改。
6.2 日常监控思路
监控事务槽和监控数据库连接数、活跃会话数一样,应该成为 openGauss 日常巡检的一部分。最直观的指标是事务槽当前 ACTIVE 状态的数量与历史峰值的对比,以及 COMMITTED 状态积压数量是否持续增长。
如果采样脚本允许自定义 SQL,可以定期采集 UNDO 视图中的槽位分布情况,写入监控库。观察趋势比看绝对值更有价值:事务槽使用率缓慢爬升,往往是系统里潜伏着长事务的信号。等到使用率冲高到 80% 以上再处理,往往已经很被动了。
6.3 开发和业务侧配合
事务槽耗尽不是纯粹的数据库运维问题,它和业务侧的执行习惯高度相关。我见过不少案例,根因是应用层的事务管理代码不规范,一个事务里塞了大量不相关的操作,事务长时间不提交。
建议业务侧落地两条硬性规范:
- 事务内只做与业务强相关的操作,禁止在事务中执行外部接口调用、批量大数据处理。
- 为事务设置明确的超时时间,Java 端的 @Transactional 是默认不超时的,必须在配置层加上超时控制。
这两条做扎实了,openGauss 侧的事务槽回收压力会小很多。
7. 个人实践中的几点提醒
7.1 事务槽并不是调大就万事大吉
有些团队一遇到事务槽不够用,第一反应是把 undo_segment_count 调大。这是治标不治本。事务槽总量扩大,只能缓解分配失败的问题,但如果回收线程赶不上分配速率,槽位池再大也只是把问题往后推迟。新事务不断涌入,积压的 COMMITTED 槽位越来越多,终究还是会触顶。
正确的思路是先处理回收停滞的根源,确认没有长事务卡水位,再看容量是否真的不够。容量不足才扩容,否则只是把钱和资源花在了错误的地方。
7.2 回滚操作也要关注事务槽
生产环境里还有一种容易被忽略的事务槽占用场景:大批量 DML 之后执行回滚。回滚过程中,事务槽一直处于 ACTIVE 状态,而且 UNDO 记录量可能非常大。如果同时有其他写事务在运行,事务槽池的占用会迅速上升。
所以线上大批量操作之前,除了评估执行时间,也要评估回滚路径可能带来的资源压力。提前把 autovacuum_undo_workers 调大一些,可以在回滚完成后加速清理。
7.3 别把账号锁定和事务槽耗尽混为一谈
像文章开头提到的那样,搜索“openGauss 数据库账号被锁”和“undo”时经常被关联到同一个话题里。实际排查中,账号锁定属于认证层问题,事务槽耗尽属于存储引擎执行层问题,两者独立。但如果应用没有做好错误分类,用户侧看到的现象都是“数据库不可用”,很容易误判。
遇到这类问题,抓数据库日志和错误码是第一优先级。确认报错是来自认证模块还是执行引擎,再决定走上层应用排查还是数据库内核排查,能省下大量时间。
openGauss 的 UNDO 事务槽机制并不复杂,核心就是把“数据修改前的镜像”以安全、可控的方式管理起来。理解它的状态流转和回收水位原理,再配合日常监控和规范的事务管理,线上基本不会踩到大坑。如果哪天事务槽真的告急了,先查最老活跃事务,再查回收线程,最后再讨论容量参数。这个排查顺序,我已经验证过很多次,基本不会走弯路。
