顺序标识让相邻的值很容易被猜到。看到第四千号订单,观察者就知道可以从附近的编号继续探测;持续收集编号,也可能看出分配活动的大致变化。但这不代表系统恰好有四千张订单,不代表每个编号都对应成功购买,也不代表最大编号最后提交。想得出这些结论,还需要序列本身没有记录的业务事实。
坏掉的样子
PostgreSQL 序列取值不会跟随普通事务回滚而退回。一次最终放弃的插入,也可能永久消耗一个号码。删除记录、缓存预留区间、显式修改序列以及多个并发写者,都会进一步拉开计数器与当前行数的距离。因此,用最大编号充当客户数或销售数量,即使没有攻击者参与,也会生成错误的经营报表。号码可以提供线索,却不能直接当作精确统计结果。
分配顺序同样不等于提交顺序。事务甲可能先取得较小编号,随后停在那里;事务乙取得较大编号后,反而先完成提交。如果消费者把递增主键当作完整的已提交事件流,就可能先处理较大的行并推进位置,而较小的行此时还不可见。等它后来提交,消费者已经越过了它。数字主键不能直接替代变更捕获位置,也不能证明所有业务事件的先后关系。
随机标识让逐个递增枚举变得困难,却不会自动实施访问控制。UUID 仍可能出现在日志、分享链接或其他接口返回值里,拿到它的人仍须通过对象权限检查。第四版 UUID 使用随机位;第七版在开头放置毫秒时间戳,目标之一是支持时间排序,因此不能把它的前缀说成随机位,也不能把其中的时间当作数据库提交时间。降低可猜测性与确认谁有权读取,是两项不同职责。
快速验证
准备一个可丢弃的 PostgreSQL 18 数据库,打开两个管理员 psql 会话,分别称为甲和乙。这里要求第十八版,是因为使用了 uuidv7() 以及第七版 UUID 的时间提取;信息提取函数本身在第十七版已经存在。确认实验模式名和两个角色名尚未使用,初始保持自动提交,隔离级别采用通常的读已提交。先在乙中建立数据:
CREATE SCHEMA article_ids;
CREATE ROLE article_id_alice NOLOGIN;
CREATE ROLE article_id_bob NOLOGIN;
SET search_path=article_ids,public;
CREATE TABLE objects (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
owner text NOT NULL,
label text NOT NULL
);
在甲中取出第一个编号,但暂时不要提交:
SET search_path=article_ids,public;
BEGIN;
INSERT INTO objects (owner,label) VALUES ('article_id_alice','allocated first') RETURNING id;
然后在乙中插入第二行并观察当前可见数据:
INSERT INTO objects (owner,label) VALUES ('article_id_bob','committed first') RETURNING id;
SELECT array_agg(id ORDER BY id) FROM objects;
乙返回编号二,此时只能看到 {2}。甲明明已经取得编号一,但那一行尚未提交,乙不能读取。这直接区分了“先拿到号码”与“先成为其他事务可见的数据”。现在回到甲完成提交:
COMMIT;
接着在乙中制造一个回滚缺口,并检查两种 UUID:
SELECT array_agg(id ORDER BY id) FROM objects;
BEGIN;
INSERT INTO objects (owner,label) VALUES ('article_id_alice','rolled back') RETURNING id;
ROLLBACK;
INSERT INTO objects (owner,label) VALUES ('article_id_alice','after gap') RETURNING id;
SELECT max(id),count(*) FROM objects;
SELECT bool_and(uuid_extract_version(public_id)=4) FROM objects;
WITH sample AS MATERIALIZED (SELECT uuidv7() AS value)
SELECT uuid_extract_version(value),uuid_extract_timestamp(value) IS NOT NULL FROM sample;
结果依次包含 {1,2}、随后放弃的编号三、新插入的编号四,以及 4|3。最后一项说明最大编号是四,真实行数却只有三。UUID 检查确认表中默认值属于第四版,第七版示例返回 7|t,说明版本为七且可以提取时间戳。每次生成的具体 UUID 都会变化,不应把某次随机值写成固定预期,也不应从这个小样本推导碰撞概率。
仍在乙中,把鲍勃的公开 UUID 保存为客户端变量。在尚未设置行策略时,切换成爱丽丝进行查询:
SELECT public_id AS bob_id FROM objects WHERE owner='article_id_bob' \gset
GRANT USAGE ON SCHEMA article_ids TO article_id_alice,article_id_bob;
GRANT SELECT ON objects TO article_id_alice,article_id_bob;
SET ROLE article_id_alice;
SELECT count(*) FROM objects WHERE public_id=:'bob_id'::uuid;
RESET ROLE;
爱丽丝得到一条记录,说明知道随机编号后,普通表级读取权限依然允许她访问别人的对象。接着启用按所属角色过滤的策略,再运行相同查找:
ALTER TABLE objects ENABLE ROW LEVEL SECURITY;
CREATE POLICY owner_only ON objects FOR SELECT USING (owner=current_user);
SET ROLE article_id_alice;
SELECT count(*) FROM objects WHERE public_id=:'bob_id'::uuid;
SELECT array_agg(id ORDER BY id) FROM objects;
RESET ROLE;
SET ROLE article_id_bob;
SELECT count(*) FROM objects WHERE public_id=:'bob_id'::uuid;
RESET ROLE;
现在爱丽丝按鲍勃的 UUID 查询得到零条,查看自己的数据只能看到编号一和四;鲍勃查询同一编号则得到一条。结果变化来自真实执行的权限策略,不是因为我们重新生成了 UUID。实验角色既不是表所有者,也不是超级用户;具有绕过行策略权限的角色不适合作为这项检查的观察者。确认甲已提交后,在乙中清理本次创建的对象:
DROP SCHEMA article_ids CASCADE;
DROP ROLE article_id_alice;
DROP ROLE article_id_bob;
分开选择标识与权限方案
内部使用序列、公开使用随机标识,可以同时保留内部连接的便利与较难猜测的 URL,但也会多出唯一索引以及两套编号的维护成本。是否值得,应由具体枚举压力和信息披露要求决定。按时间排列的 UUID 在部分工作负载下有利于插入局部性,同时会暴露时间信息;不能仅凭格式就承诺所有数据库、所有索引都变快。
无论接口接受哪种编号,都应结合已认证的主体与目标对象检查权限,其他查找入口也要遵循同样规则。本实验为了看清效果,把用户对应到数据库角色;实际应用必须建立可信身份上下文,不能直接相信请求者提交的所属用户名。统计数量应查询正确的业务口径;要求跨并发提交不丢事件时,应采用有明确完整性契约的事件位置或复制位置,而不是把主键增长误当作提交日志。
来源
- PostgreSQL 18 CREATE SEQUENCE,序列缺口与分配语义,已核验 2026-09-10。
- RFC 9562,第四版随机位和第七版时间戳布局,已核验 2026-09-12。
- PostgreSQL 18 UUID 函数,实验使用的生成与信息提取函数,已核验 2026-09-12。
- PostgreSQL 18 行安全,所属策略与特权角色绕过规则,已核验 2026-09-12。
- PostgreSQL 17 UUID 函数,已有提取函数及第七版 UUID 的版本边界,已核验 2026-10-15。