PostgreSQL的每张普通表中,除了开发者自己声明的字段之外,还隐藏着一批系统列。其中xmin与xmax直接参与MVCC可见性判断,却容易被当成普通字段误用。了解这两个系统列,能帮助我们理解UPDATE的真实执行代价、行锁的生效时机,也能在应用层用极少代码实现乐观并发控制。

xmin和xmax到底是什么
先看底层存储。PostgreSQL的堆文件中,每个行版本都拥有一个行头,行头里记录了该版本的事务信息。xmin记录的是插入这个行版本的事务ID,xmax记录的是删除或锁定这个行版本的事务ID。如果xmax为0,表示这个行版本没有被删除,也没有被任何事务锁定。
这两个系统列不需要建表时声明,也不占用业务字段的存储空间。你可以像查询普通列一样把它们读出来,通过这个办法快速验证一个行版本是什么时候修改的。下面这段建表和查询语句展示了最基本的用法:
CREATE TABLE product (
id integer PRIMARY KEY,
name text,
price numeric
);
INSERT INTO product VALUES (1, '示例商品', 100);
SELECT xmin, xmax, id, name, price FROM product;
执行上面的SELECT后,你会看到xmin是一个很长的整型数字,而xmax通常是0。这个数字就是插入操作所属事务的事务ID。即便没有显式开启事务,PostgreSQL也会为单条INSERT分配一个事务ID,并把语句执行和提交放在同一个隐式事务里。
有一点需要特别说明:xmin和xmax的值只有事务ID,并不包含提交时间。判断一个行版本是否对当前会话可见,PostgreSQL还需要把这些事务ID和当前SNAPSHOT中的事务状态表做对比。所以“xmin已经提交,且xmax未提交”才是行版本可见性的标准条件。
INSERT、UPDATE、DELETE时系统列如何变化
不同的数据操作会让xmin和xmax产生完全不同的变化。INSERT发生时会生成一个新的行版本,xmin被设置为当前事务ID,xmax保持为0。DELETE会将目标行版本的xmax设置为删除事务的事务ID,之后这个行版本就不再对任何新事务可见。
UPDATE比较特别,它实际是DELETE加INSERT的组合。执行UPDATE时,原行版本的xmax会被设置为当前事务ID,表示这个旧版本已经失效;同时系统会生成一个全新的行版本,新行版本的xmin也是当前事务ID。下面这个例子用事务ID函数展示了UPDATE后的现象:
BEGIN; SELECT pg_current_xact_id() AS current_txid; SELECT id, xmin, xmax FROM product WHERE id = 1; UPDATE product SET price = 120 WHERE id = 1; SELECT id, xmin, xmax, price FROM product WHERE id = 1; COMMIT;
你在结果中会看到,第一次查询时xmin是一个旧值,xmax是0;UPDATE完成以后,xmin变成了与current_txid相同的数字。这说明新版本确实由当前事务创建。旧版本已经无法通过普通SELECT看到,因为它的xmax被标成了当前事务ID,而当前事务已经提交,所以旧版本对后续事务都不可见。
DELETE也可以用RETURNING来观察两个系统列。把被删除行的xmin和xmax同时返回,能够直观看到删除事务ID写入了xmax:
BEGIN; INSERT INTO product VALUES (2, '测试商品', 50); DELETE FROM product WHERE id = 2 RETURNING id, xmin::text AS insert_xid, xmax::text AS delete_xid; COMMIT;
在这个结果集里,insert_xid是INSERT所属事务的ID,delete_xid是DELETE所属事务的ID。CREATE TABLE或者ALTER TABLE这类DDL操作本身也会产生事务ID,但通常不会直接影响普通行的xmin。
用xmin实现乐观锁
乐观锁常见的方法是维护一个version字段,每次更新时让version加一,然后通过WHERE version = ?来判断并发冲突。这套方案完全可以用xmin替代,因为每次UPDATE或DELETE都会改变行版本对应的事务ID。对应用来说,xmin就是一个天然的行版本号。
具体做法很简单:第一次读取数据时把xmin当作版本号保存下来;提交更新时,在UPDATE的WHERE条件中加入这个版本号。如果其他事务在两次操作之间修改过这行数据,当前事务更新到的行版本已经不是当初读到的那个,xmin条件必然不成立,UPDATE返回0行,应用可以据此提示用户重新加载数据。
-- 应用第一次读取,cur_version是查询得到的xmin值 SELECT xmin AS cur_version, id, name, price FROM product WHERE id = 1; -- 用户填写修改后提交更新 UPDATE product SET price = 130 WHERE id = 1 AND xmin = 12345; -- 替换成第一步查到的cur_version -- 如果 UPDATE 返回 0 行,说明读取之后已经有其他事务修改了这行
这种做法的最大优点是不用额外增加version列,也不影响实体映射代码。但它有一个明显的限制:xmin并不是一个长期稳定的版本号。PostgreSQL的事务ID是32位整数,当系统累计达到约42亿个事务后,事务ID会回卷。为了防止回卷导致数据损坏,autovacuum会把足够老的行标记为冻结状态,被冻结行的xmin会变成特殊值2。假如应用长期使用xmin做乐观锁,一旦行被冻结,再做条件更新就可能失效。
因此,xmin这种乐观锁比较适合短期更新的内部系统。对于需要长期保存、跨版本迁移的核心业务数据,更推荐使用显式的版本字段。你可以在应用中把更新成功返回的行数和业务逻辑结合起来,在UPDATE后检查结果行数,实现同样的并发控制效果。
使用xmin和xmax的常见误区
误区一:认为xmax非零就代表数据已经删除。实际上,xmax除了标记删除,还承担行锁的功能。当某个事务执行SELECT FOR UPDATE或者SELECT FOR SHARE锁定了某一行时,这行的xmax会被设置为锁定事务的事务ID,但业务数据并没有被删除。其他事务看到xmax非零后,会根据锁模式决定是继续等待还是报出锁冲突。所以单独看xmax无法区分行是被删除还是被锁定,必须参考事务提交状态。
误区二:把xmin当作可跨数据库比较的纯数值。事务ID在每个数据库集群中是全局递增的,但不同数据库实例、不同集群之间没有可比性。此外,事务ID和当前快照的xmin边界有关,把另一个连接里看到的值拿过来直接放进SQL,很容易得到错误判断。
误区三:尝试给系统列建立普通索引来加速查询。PostgreSQL不允许把xmin、xmax这类系统列直接放入普通索引的索引键中。即使某些版本允许以表达式索引的方式规避限制,也会破坏系统列原本的语义,让维护者误以为它只是一个普通数值。正确的性能优化方向是利用可见性映射和VACUUM机制减少死元组扫描,而不是依赖系统列索引。
最后还要提醒一下,当前会话在快照未开启时,xmax的值有可能是0,也有可能是某个尚未提交的事务ID。如果你在应用代码里查询xmax做决策,建议把事务隔离级别设置为READ COMMITTED以上,并确保查询和更新处于同一事务中。真正严谨的并发控制,永远是从业务语义出发选择合适的锁策略,xmin和xmax只是辅助我们理解数据库内部行为的重要参考。