PostgreSQL 序列缓存为数据库后端预留数值,不属于某个应用对象,也不只属于一笔事务。理解这个边界,才能解释为什么序列保存的位置会领先于真正返回的号码,为什么重新连接也会留下空洞,以及为什么调小 CACHE 不能让标识连续。首先要区分分配器状态,与应用真正成功提交的业务记录,不能把两者当成同一个计数器。

三个不同的数字

一个新建的升序序列使用 CACHE 5 时,第一次 nextval 可以返回一,而 last_value 已记录五。后者是预留边界,不是已插入行数,也不表示一到五都已经交给应用。currval 表示当前会话最近为该序列建立的值,其他连接取号不会替换它。监控时若不说明这些字段的作用域,就很容易把预留数量误当成业务数量。

同一后端后续取号,可以使用已经预留的数值。PostgreSQL 实现中既有后端局部的快速路径,也有补充缓存时的缓冲锁;把每次 nextval 都说成对普通表记录加一把行锁并不准确。缓存减少反复访问序列状态,但不能据此认定某个应用的插入延迟主要来自序列竞争。修改参数前,应先测量真实瓶颈,而不是根据取号机制猜测整体性能。

会话寿命也影响空号

即使正常退出,会话结束时没有用完的预留值也会被放弃。因此,相同取号速率下,频繁建立数据库会话的服务,可能比使用长期连接池的服务放弃更多预留。缓存跟随服务器会话;应用看到一个连接对象,并不足以说明底层数据库会话是否一直存在,连接复用策略也是实验条件的一部分。

不同会话还可以分别持有不同区间。一个先返回一,另一个返回六,第一个随后再返回二。这是分配交错,不代表提交顺序,也不能证明这些值已经丢失。即使 CACHE 为一,事务仍可能先取号后提交,顺序与另一个事务相反。提交之前写入的时间戳同样不能自动提供真实提交顺序,不能只给字段换个名字就获得新的保证。

坏掉的样子

不要把 CACHE 当成崩溃后最大跳号数的公式。多个会话可以同时持有预留,而实现还会把一部分未来序列进度提前写入日志。事务回滚、走冲突处理分支的插入,以及取到但没有使用的值,都会增加空洞。缺号本身既不能证明丢了业务数据,也不能证明没有丢;发现故障时,应核对已提交记录和恢复证据,再判断是否只是分配器行为。

正常运行期间,非循环分配在管理员没有把序列重置回已用范围时,会提供不同数值。但循环、手工重置,以及显式导入编号,都可能改变这个前提。表仍然需要主键或唯一约束保证存储完整性。减小缓存不能代替这些约束,不能找回已经删除的记录,也不会自动让业务编号流程具备可审计性。在数据库外持久使用一个号码之前,应先提交取号事务;崩溃可能使尚未提交的序列变化是否保留变得不确定。

调整序列设置时,还要考虑活跃会话已经拿到的预留值。不要为了补洞,在并发写入期间把序列随意重置到表中最大编号。如果业务确实要求已签发号码连续,应先明确签发、回滚、取消和外部发布的边界,再评估事务计数器。没有经过实际负载测量,就不存在可以普遍承诺的最佳缓存大小或吞吐提升倍数。

快速验证

下面的无套接字练习,使用两个先后运行的单用户后端,访问同一个新初始化的可丢弃集群。以非 root 账户执行脚本,并把 PostgreSQL 18 的 initdb 与 postgres 放入 PATH。绝不能让单用户模式访问正在运行的集群。该模式具有隐式超级用户权限,不能验证并发客户端、真实进程间锁竞争或故障切换;本例只研究缓存预留的生命周期。

#!/bin/sh
set -eu
umask 077
export LC_ALL=C
lab=$(mktemp -d "${TMPDIR:-/tmp}/xbcatm-sequence-cache.XXXXXX")
initdb -D "$lab/data" --no-locale --encoding=UTF8 \
  --auth-local=trust --auth-host=reject >"$lab/init.log" 2>&1
postgres --single -j -D "$lab/data" -c io_method=sync -c log_min_messages=notice postgres \
  >"$lab/session-a.log" 2>&1 <<'SQL'
CREATE SEQUENCE cache_probe CACHE 5;

SELECT nextval('cache_probe') AS first_value;

SELECT last_value AS reserved_through FROM cache_probe;

BEGIN;

SELECT nextval('cache_probe') AS rolled_back_value;

ROLLBACK;

SELECT nextval('cache_probe') AS after_rollback;

DO $$ BEGIN
  IF currval('cache_probe') <> 3 OR
     (SELECT last_value FROM cache_probe) <> 5 THEN
    RAISE EXCEPTION 'cache reservation or rollback result wrong';
  END IF;
  RAISE NOTICE 'cache_probe_a_ok';
END $$;

SQL
if grep -E '(ERROR|FATAL|PANIC):' "$lab/session-a.log"; then exit 1; fi
grep -q 'cache_probe_a_ok' "$lab/session-a.log"
postgres --single -j -D "$lab/data" -c io_method=sync -c log_min_messages=notice postgres \
  >"$lab/session-b.log" 2>&1 <<'SQL'
SELECT nextval('cache_probe') AS after_clean_session_end;

DO $$ BEGIN
  IF currval('cache_probe') <> 6 OR
     (SELECT last_value FROM cache_probe) <> 10 THEN
    RAISE EXCEPTION 'unused cache values were unexpectedly reused';
  END IF;
  RAISE NOTICE 'cache_probe_b_ok';
END $$;

DROP SEQUENCE cache_probe;

SQL
if grep -E '(ERROR|FATAL|PANIC):' "$lab/session-b.log"; then exit 1; fi
grep -q 'cache_probe_b_ok' "$lab/session-b.log"
cat "$lab/session-a.log" "$lab/session-b.log"
printf 'retained_lab %s\n' "$lab"

完整脚本已在 PostgreSQL 18.6 执行。第一个后端返回一,记录预留边界五,在随后回滚的事务中分配二,再返回三。断言核对缓存边界仍为五。单用户模式的 -j 使用空行划分命令,因此脚本保留必要的空行,确保事务边界没有因整段输入被视为同一条命令而改变。观察结果时,也需要区分真正执行的语句与后端输出的提示文字。

第一个后端正常结束后,第二个返回六,并记录预留边界十。四和五从未返回给调用方,二则已经返回,但属于最后回滚的事务。这是在没有制造崩溃的情况下,演示两种不同空洞来源。两个成功通知都出现,序列最终被删除;脚本打印的目录保留可丢弃集群与日志,以便检查完整输出。

脚本同时检查错误文本和进程状态,因为独立后端即使报告 SQL 错误,也可能以成功状态结束。这个练习验证预留与会话结束的关系,不是吞吐基准或持久性演练。它不能提供固定崩溃跳号上限,也不能据此推荐生产缓存大小。迁移结论时,必须保持实验覆盖范围与实际业务问题一致。

来源