1. 从一次并发报错说起:nextval() 到底安不安全
前阵子在帮一个团队做 PostgreSQL 高并发写入方案的评审,那位同学设计的逻辑很简单:业务端开启多个线程,每个线程在插入主表之前,先调用 nextval() 拿一个全局唯一 ID,再把 ID 传给子表做关联。看起来没什么毛病,但他现场抛出一个问题:“我们上生产之后,发现偶尔会有两个线程拿到同一个 ID,是不是 nextval() 不是线程安全的?”
我当时的反应是:先别急着给 nextval() 扣帽子,多半是自己的使用姿势出了问题。后来我让他把代码中事务隔离级别、连接池配置、以及是否在同一个事务边界内调用 nextval() 的时序图给我看,问题很快定位了。事实证明,nextval() 本身几乎不可能让两个会话拿到同一个值,它真正要命的地方在于:调用时机、事务回滚、序列缓存这三个外部因素,会让人误以为它“不安全”。
这篇博文,我就把 nextval() 的线程安全机制、底层实现原理、常见误用场景,以及多线程环境下的正确取号策略完整拆一遍。内容面向已经会用 PostgreSQL 但还没深挖过序列机制的开发者和 DBA,争取做到看完就能排查自己的问题。
标题说“解析”,那我就从最基础的 PostgreSQL 序列入手,一路延伸到真实的并发场景。全程没有空话,全是实测和代码,你可以照着复现。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. PostgreSQL 序列机制概述:nextval() 到底在操作什么
2.1 nextval() 的本质:一个数据库侧的原子计数器
在 PostgreSQL 里,序列(SEQUENCE)是一个特殊的数据库对象。它不存业务数据,只做一件事:生成严格递增的整数。你通过 CREATE SEQUENCE 创建一个序列后,每次调用 nextval('seq_name'),PostgreSQL 就会返回序列的当前值,然后把序列的内部状态往后推一格。
关键点来了:这个“取当前值 + 推进状态”的动作,在 PostgreSQL 内部是原子的。原子性是什么意思?就是在任意一个时刻,无论有多少个会话、多少个线程同时执行 nextval(),数据库都会保证每一个调用拿到的值都是唯一的,并且不会有两笔调用拿到同一个值。
为了让你理解得更直观,我打一个比方:假设你开了一家面馆,门口放着一个取号机。取号机内部有一个数字,每吐一张号它就加 1。取号机的设计保证,任何人按下按钮,拿到的票号都一定比之前所有票号大,而且两台取号机之间不会互相干扰。PostgreSQL 的序列就是这样一个高度可靠的“取号机”,nextval() 就是那个按钮。
2.2 为什么说“线程安全”这个词放在这里容易让人误解
很多从 Java、C++ 转过来的同学一听到“线程安全”,脑子里出现的画面是:多个线程同时调用同一个函数,函数内部有共享变量,需要通过加锁或者原子类来保证数据不冲突。
但 nextval() 不是应用进程内的一个静态方法,它是数据库服务器上的一个 SQL 函数。每个连接通过 libpq 或 JDBC 驱动把 nextval() 请求发给 PostgreSQL 服务器,由数据库进程来完成序列状态的读取和更新。所以,真正需要保证并发安全的地方,在 PostgreSQL 内部,而不在你的业务代码里。
换句话说:只要 PostgreSQL 服务器没有 bug,nextval() 在多线程环境下的唯一性是有数据库底层保证的。你的 Java 线程池、Python 的 gevent、Go 的 goroutine,在它们调用 nextval() 的那一刻,全部被收敛到了数据库服务器这一个点上,由服务器的锁机制串行化处理。
搞清楚这个定位之后,你再回头看团队里“两个线程拿到同一个 ID”的问题,就要重新思考了:到底数据库返回了重复值,还是你的应用逻辑在中间环节做了不该做的处理?下文我会给到一个极容易踩的坑:事务回滚。
2.3 序列的底层存储:一张只有一行数据的系统表
PostgreSQL 的序列虽然在 SQL 层用起来像一个函数,但内部实现其实很有意思。每个序列都对应一张底层表,表里存了序列的当前值、步长、最小值、最大值、缓存大小、是否循环等元数据。
每次调用 nextval(),PostgreSQL 会在系统表面加上一把行级锁,然后读取记录的 last_value 字段,加上步长,再把新值写回去,最后释放锁。注意,为了性能考虑,PostgreSQL 对这种“只读一行、高频更新”的场景做了大量优化,包括使用更轻量级的锁策略和缓存机制。这也是为什么高并发下频繁调用 nextval() 虽然会产生锁竞争,但依然比很多人想象中要快的原因。
需要特别注意的一点:PostgreSQL 序列本身不参与事务的回滚机制。也就是说,如果你在一个事务里调用了 nextval(),之后事务回滚了,那这个序列的值也已经消耗掉了,不会因为你的事务回滚而把序列值“还回去”。这是理解“线程安全误解”最核心的一环,我放到后面单独展开。
3. 多线程并发取号实测:到底会不会拿到重复值
3.1 测试环境准备
先说明一下,以下测试我在 PostgreSQL 13、14、16 三个版本上都跑过,结论一致。环境是 CentOS 7 上的 PostgreSQL 16,连接池用的是自带的 psql 并发模拟,以及一段 Python 脚本做多线程压测。
sql复制DROP SEQUENCE IF EXISTS test_seq;
CREATE SEQUENCE test_seq START 1 INCREMENT 1;
-- 造一张表,用来记录每次拿到的序号
DROP TABLE IF EXISTS seq_log;
CREATE TABLE seq_log (
id bigint PRIMARY KEY,
thread_name t
