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 text,
insert_time timestamptz DEFAULT now()
);
测试思路很简单:开 10 个线程,每个线程循环调用 10000 次 nextval(),然后把拿到的值插入 seq_log 表。如果序列有重复,主键插入就会报错;如果没有重复,最终记录总数应该正好是 100000,且 max(id) - min(id) + 1 = 100000。
3.2 Python 多线程压测脚本
python复制import threading
import psycopg2
from concurrent.futures import ThreadPoolExecutor
conn_str = "host=127.0.0.1 port=5432 dbname=test_db user=postgres password=your_password"
thread_count = 10
rows_per_thread = 10000
def worker(thread_name):
local_conn = psycopg2.connect(conn_str)
local_conn.autocommit = True
cur = local_conn.cursor()
for _ in range(rows_per_thread):
cur.execute("SELECT nextval('test_seq')")
seq_val = cur.fetchone()[0]
cur.execute(
"INSERT INTO seq_log(id, thread_name) VALUES (%s, %s)",
(seq_val, thread_name)
)
cur.close()
local_conn.close()
with ThreadPoolExecutor(max_workers=thread_count) as executor:
for i in range(thread_count):
executor.submit(worker, f"thread-{i}")
print("done")
注意脚本里的一个设计:每个线程单独建立了一个数据库连接,这是为了保证测试真的模拟了“多个独立会话并发调用 nextval()”的场景。如果所有线程共用一个连接,那其实是在串行执行,测不出并发效果。
3.3 测试结果:唯一性没有问题,但顺序会有“洞”
脚本跑完后,查询 seq_log 表的数据:
sql复制SELECT count(*) FROM seq_log;
-- 100000
SELECT min(id), max(id) FROM seq_log;
-- 1, 100000
SELECT count(DISTINCT id) FROM seq_log;
-- 100000
结论非常清楚:并发环境下,nextval() 没有产生任何重复值。10 个线程、10 万次调用,每一个值都严格唯一。
但如果你对“严格递增连续”有执念,那结果会让你失望。你按插入时间去看这些 ID 的分布,会发现线程 A 拿到的 ID 可能是 1、2、4、7,线程 B 拿到的是 3、5、6、8。也就是整体序列是递增的,但每个线程拿到的值并不连续,中间会有跳跃。
这个现象不是 nextval() 的问题,而是并发取号的天然属性:多个线程同时去取号,必然导致不同线程拿到的号交错排列。如果你希望每个线程内部拿到的 ID 也是严格连续的,那必须自己在应用层做“批量取号”,也就是一次从数据库取一段连续的值,放到应用内存中慢慢消费。这个技巧后面会专门讲。
3.4 换个姿势验证:百万级并发压测
10 万次调用的说服力可能还不够,我再把强度提升到百万级。开 30 个线程,每个线程调用 50000 次 nextval(),也就是总共 150 万次调用。这里要注意,如果每条 INSERT 都走一个事务,提交 150 万次事务,开销会非常大。所以我把压测脚本改成批量插入,每 1000 条提交一次。
结果依然稳:150 万次调用,零重复,ID 值分布在 1 到 1500000 的区间内。不过压测过程中我发现,数据库 CPU 占用明显升高,平均每次 nextval() 调用耗时从单线程时的不到 0.1 毫秒飙升到了 0.4 毫秒左右。这说明并发确实存在锁竞争,但依然可以接受。
4. 为什么 nextval() 能保证并发唯一:源码级别的解释
4.1 每个序列都是一行记录:锁就是保护伞
PostgreSQL 的序列实现代码在 src/backend/commands/sequence.c 里。核心逻辑是,nextval() 函数会先打开序列对应的关系,然后对序列行的缓冲区加上 ExclusiveLock。
所有会话的 nextval() 调用,都会试图获取这个锁。获取不到就等待,获取到了就读取 last_value、计算下一个值、写回、释放锁。这一套流程下来,任何两个并发请求都不可能同时读取到同一个 last_value,自然拿不到重复的值。
更深一层讲,PostgreSQL 的序列并发控制还考虑到了性能和正确性的平衡。它没有直接用事务快照去读取序列值,因为 MVCC 的快照机制会导致两个并发事务同时读到同一个旧值。序列用的是绕过 MVCC 的“当前读”,也就是每次 nextval() 都必须读到该序列的最新已提交状态。
4.2 序列缓存(CACHE)与并发的相互作用
创建序列时可以指定 CACHE 参数,比如 CREATE SEQUENCE test_seq CACHE 100。意思是,PostgreSQL 的每个会话第一次调用 nextval() 时,会一次性在内存里申请 100 个序列值,后续 99 次 nextval() 直接从内存返回,不再访问系统表。
这个特性对性能提升非常明显。高并发场景下,如果 CACHE 设为 1,那么每次调用都要走一次系统表锁,性能瓶颈非常容易出现在锁等待上。而 CACHE 设为 100 之后,每个数据库连接只需要偶尔去系统表“进货”一次,锁竞争会大幅下降。
但 CACHE 也有代价:如果数据库重启或者连接异常断开,内存中还没用完的序列缓存会被直接丢弃,导致序列出现“大段空洞”。举个例子,某个连接的缓存到了 101,但拿到 150 时连接断了,那 151 到 200 这几个值就永远没人用了。
我用不同 CACHE 参数实测过并发性能,结论如下表:
| CACHE 值 | 30 线程压测平均单次耗时 | CPU 占用 | 序列空洞风险 |
|---|---|---|---|
| 1 | 0.4ms | 高 | 极小 |
| 20 | 0.08ms | 中 | 低 |
| 100 | 0.05ms | 低 | 中 |
| 1000 | 0.04ms | 低 | 高 |
如果你对序列连续性没有严格要求,生产环境建议把 CACHE 设到 20 到 100 之间。如果业务上要求 ID 尽量连续(比如单据编号要让客户看着舒服),那 CACHE 只能设 1,代价是并发性能有所下降。
4.3 还有一层保护:PostgreSQL 进程模型天然隔离了线程问题
PostgreSQL 和其他很多数据库在架构上有一个显著区别:它是多进程架构,不是多线程架构。每个数据库连接对应一个独立的操作系统进程。主进程负责监听和分发连接,每个连接的查询处理都在各自的进程内完成。
这种架构带来的好处是:即使某个会话崩溃了,也不会影响其他会话;进程之间天然不存在共享内存里的线程竞争问题(除了共享内存中的缓冲区和锁,PostgreSQL 通过专门的进程锁机制来保护)。所以严格来说,nextval() 是“多进程安全”的,而在多线程应用通过多连接访问时,最终落在 PostgreSQL 端的就是多进程并发。
这可能也是“线程安全”容易让人困惑的点:你的应用是 Java 多线程,但 PostgreSQL 看到的是连接池里的多条连接,每个连接由不同进程处理。应用层的“线程”概念,其实没有直接映射到数据库内部。
5. 事务回滚与连接断开:nextval() 的“假不安全”
5.1 最经典的翻车现场:回滚后序列不回退
很多人在测试 nextval() 时会写这样的代码:
sql复制BEGIN;
SELECT nextval('test_seq'); -- 拿到 1
ROLLBACK;
SELECT nextval('test_seq'); -- 拿到 2,而不是 1
然后他得出结论:nextval() 不安全,回滚后居然不返还序列值。这个结论完全是错的。
PostgreSQL 序列设计的初衷,就是作为“全局唯一 ID 生成器”,它不承诺事务一致性。序列值一旦被取走,就永久消耗掉了。哪怕之后的事务失败、回滚、崩溃,序列也不会回到之前的值。这么做是故意的,因为如果序列支持回滚,那必须在事务结束前锁住序列,这会严重降低并发度,而且回滚时清理锁的操作会非常复杂。
正确的认识是:序列生成的值是“空洞无关”的。丢失几个值,换来的是极高的并发性能和简单的实现逻辑。绝大多数业务场景下,这个取舍是非常划算的,因为你只要求 ID 唯一,并不要求 ID 连续。
5.2 连接池中的陷阱:同一个连接上的“幻影” ID
还有一个容易踩坑的场景和连接池有关。假设你的连接池配置了最小空闲连接数,业务代码里使用完连接后归还到池中。如果某次业务操作先调用了 nextval(),再把连接归还到池中,那么这段调用记录是留在连接上的。
下次这个连接被分配给另一个线程使用时,理论上不会共享上一次的序列状态,因为序列状态在数据库端。但有一种情况会让“重复”被误判:应用在事务内调用了 nextval(),事务挂起,连接被异常回收,然后又出现在新的线程里。这时候由于事务没有被正确提交或回滚,数据库端的锁还持有着,新的线程如果继续用这个连接执行 SQL,会直接卡死或者报错。
这个问题虽然不会导致 nextval() 返回重复值,但会让业务上感觉到“序列出问题了”。排查方法很简单:检查连接池的配置,确保连接归还时事务已经干净地结束。
5.3 数据库崩溃和恢复会对序列造成什么影响
PostgreSQL 崩溃恢复后,序列不会回滚到很久以前的状态,但可能会回退到最近的检查点(checkpoint)附近的位置。这是因为序列操作虽然加了锁,但为了性能,不会每次都刷写 WAL 日志。
如果你要求序列值在数据库崩溃恢复后也绝对不能回退,那需要保证序列有足够的 WAL 日志保障。PostgreSQL 默认对序列是有 WAL 记录的,但由于缓存机制,内存中已经分配但还没落盘的序列值,在崩溃后会丢失。也就是说,如果连接申请了 1 到 100 的缓存,其中 1 到 50 已用,51 到 100 还在缓存中,这时候数据库崩溃,恢复后序列可能从 51 开始,也可能从更低的值开始,取决于 WAL 落盘时机。
对绝大多数业务来说,这个级别的回退是可以接受的,毕竟唯一性不会被破坏,只是已分配的小部分值可能在恢复后重新出现。想彻底避免这种情况,只能放弃 CACHE,并且接受性能损耗。
6. 多线程应用中 nextval() 的正确使用模式
6.1 模式一:每次插入前单独调 nextval()——最直接但最费性能
很多 ORM 框架的默认行为是:插入一行数据时,如果主键依赖数据库序列,就先调用一次 nextval() 获取 ID,然后带着这个 ID 拼接 INSERT 语句。
python复制# Python + psycopg2 示例
id = cur.fetchone("SELECT nextval('orders_seq')")[0]
cur.execute("INSERT INTO orders (id, order_no) VALUES (%s, %s)", (id, order_no))
这种方式的好处是简单直白,不需要额外设计,天然规避了序列缓存空洞问题。但坏处是,每次插入都需要多一次数据库往返。
在高并发场景下,如果单条插入就要调一次 nextval(),数据库的序列锁竞争会持续存在。实测下来,单事务内先调用 nextval() 再插入,比直接使用 INSERT 的 RETURNING id 子句要慢一些。原因是 RETURNING 语句可以把取 ID 和插入合并成一次数据库交互,减少了应用与数据库之间的通信开销。
所以我的建议是:如果 ORM 支持,优先使用 INSERT ... RETURNING id 的语法从数据库拿回生成的 ID,而不是先 nextval() 再插入。
6.2 模式二:批量预取到应用内存——高并发下的最优解
如果你的业务需要批量生成 ID,像是批量导入、批处理任务、数据迁移,那“每次调用都走数据库”绝对不可取。这时候正确做法是:一次从数据库取得一个范围内的连续序列值,放内存里慢慢消耗。
python复制def fetch_id_batch(conn, batch_size=1000):
cur = conn.cursor()
# 先把序列往后推进 batch_size 步,获得当前值,再减去步数,得到可用的起始值
cur.execute("SELECT nextval('orders_seq') FROM generate_series(1, %s)", (batch_size,))
values = [row[0] for row in cur.fetchall()]
return values
这里我用了一个 PostgreSQL 的特性:generate_series 配合 nextval(),可以一次性把序列往后推 N 步,并把 N 个值全部取回来。有人会问,为什么不直接 SELECT setval('orders_seq', nextval('orders_seq') + batch_size) 然后返回起始值?
原因是 setval 是设置性的操作,容易搞乱序列状态,除非你非常清楚自己在做什么。用 generate_series 的方式更安全、更直观,而且代码可读性好。
拿回来一批值后,应用层要确保这批值只能被当前线程(或当前进程)使用。如果是多线程应用,建议用一个并发安全的队列来管理预取值,确保不会有两个线程取出同一个值。
| 方案 | 数据库交互次数 | 序列空洞风险 | 适合场景 |
|---|---|---|---|
| 每次调用 | 1 次/条 | 无 | 插入量小、对性能不敏感 |
| RETURNING | 1 次/条 | 无 | 常规业务插入 |
| 批量预取 | 1 次/N 条 | 有 | 批量导入、批处理 |
| CACHE+N 预取 | 极少 | 有 | 超高频并发插入 |
6.3 模式三:应用层分布式 ID 生成器
如果并发量真的到了离谱的程度,比如每秒需要生成几百万个 ID,那数据库序列就不再是瓶颈最小的选择了。这时候更常见的是在应用层引入分布式 ID 生成方案,比如雪花算法(Snowflake)、号段模式(Leaf)、或者用 Redis 的 INCR 命令。
雪花算法的思路是:64 位的 long 整数,由 1 位符号位 + 41 位毫秒时间戳 + 10 位机器 ID + 12 位序列号组成。每个节点独立生成 ID,不需要数据库参与。但它的缺点是强依赖机器时钟,如果时钟回拨,可能生成重复 ID。
号段模式的思想则是:数据库只负责发号,应用层一次取一个号段(比如 1000 个 ID),然后内存中按号段分配。号段用完再取新的。这个方案既保证了性能,也规避了数据库单点瓶颈。实际上,很多公司的高并发订单系统就是这么做的。
如果团队还在用单体数据库架构,我建议先不急着引入分布式组件,而是把 PostgreSQL 序列的批量预取做好。单库单表毫秒级处理几千 TPS 是完全够用的,只有在单表达到数万 TPS 时才需要考虑更复杂的方案。
7. 面试必问考点与典型误解澄清
7.1 nextval() 属于“事务型函数”吗
严格来说,nextval() 不受事务回滚影响,它不属于事务型函数。这意味着你在一个失败的事务里调用过 nextval(),这个值不会被回滚。这在设计上是有意为之的。
从面试角度看,很多候选人会在这个问题上栽跟头。他们会下意识地认为,所有数据库操作都应该具备事务性,包括序列取号。但实际上,序列就是为了避免事务性带来的性能问题,才被设计成“不参与事务”的。你需要理解背后的原因:如果每次调用 nextval() 都要等到事务结束后才真正消耗序列值,那事务持有序列锁的时间会非常长,严重拖垮并发性能。
7.2 高并发下 nextval() 是否会阻塞
很多人担心,既然 nextval() 加锁,那并发量高时是否会阻塞其他查询?答案是:只有调用 nextval() 的会话会阻塞在锁等待上,普通的 SELECT、INSERT 不会受影响。
PostgreSQL 的序列锁是行级锁,锁的范围仅限于序列内的一条记录。并发调用 nextval() 的会话会排队依次获取锁,而这个排队过程通常非常短,微秒级别。真正受影响的是那些同时调用同一个序列 nextval() 的请求,它们会有轻微的等待。
如果你观察数据库的等待事件,可能会看到 wait_event 为 Sequence。这个等待事件并不可怕,它只意味着该会话正在等待序列锁释放。只要总耗时在可接受范围内,就无需过度优化。
7.3 UUID、Snowflake、数据库自增主键,用哪个
这个问题常常和序列线程安全一起被问到。很多开发者纠结:既然数据库并发取号有锁竞争,为什么不用 UUID?UUID 确实不需要锁,也天然全局唯一,但 UUID 的缺点是对数据库索引极其不友好。随机字符串作为主键,会导致 B+ 树索引页频繁分裂,写入性能下降,索引占用空间暴增。
Snowflake 这个方案兼顾了性能和有序性,但也有致命弱点:时钟回拨问题。虽然可以通过扩展位段来美化,但整个方案的复杂度远高于数据库序列。如果你的业务不需要跨机房、跨数据库实例生成全局唯一 ID,绝大多数场景下用 PostgreSQL 序列就够了。
7.4 一道经典面试终题:如果你发现两个线程拿到了一样的 nextval()
现场模拟一下这个问题的完整排查思路:
第一步,确认这两个值是不是真的来自数据库。可以在代码里打印调用 nextval() 的连接 ID、线程名、执行时间,看看两个值是否落在同一个数据库会话里。
第二步,检查应用的 ID 缓存逻辑。是不是自己写了一个 Map 或者本地缓存来存 nextval() 的返回值,而缓存没有做并发控制,导致两个线程拿到了同一个缓存条目。
第三步,检查是否有别的地方也在生成 ID。比如代码里有一段逻辑判断某个字段为空时,自动用当前时间戳拼接字符串当 ID,和 nextval() 的值混合使用,误以为重复了。
第四步,如果以上都没有问题,再用数据库端的日志来排查,开启 log_statement = 'all',看看数据库中实际接收到的 SQL 是否真的返回了重复值。到了这一步,99.9% 的问题都能定位到应用层。
所以这道题的终极答案是:不要怀疑序列,先怀疑你的缓存和并发控制。这也是我做了这么多年数据库优化后最深的体会。
8. 实战排查口诀与避坑清单
8.1 一次性记住的 checklist
写到这里,我把这些年踩过的坑浓缩成一张排查清单,遇到序列问题时从头到尾过一遍,基本能定位 90% 的问题:
- 序列重复性排查,永远先看应用层有没有自建缓存或并发队列。
- 事务内调用 nextval() 后回滚,序列值会有空洞,这是正常现象。
- 并发取号拿到交错但连续的 ID 是正常的,不代表有问题。
- CACHE 参数越大并发性能越好,但重启后空洞越大,需要权衡。
- 使用批量预取时,预取到应用内存的 ID 必须做线程安全分配。
- 每次插入都调用 nextval() 比用 RETURNING 子句多一次数据库交互,性能更差。
8.2 两个容易被忽略的运维细节
第一,序列的所有权问题。如果你用 CREATE SEQUENCE 创建了一个序列,后来 DROP TABLE 时没有用 CASCADE,序列不会被自动删除。这会导致数据库里残留大量无用的序列对象。我曾经在一个老项目里发现过几十个孤儿序列,都是历次表重建时留下的,白白占用系统表空间。
第二,序列权限问题。生产环境通常会有多个应用账号连接同一个数据库,如果你只在主账号下创建了序列,其他账号调用 nextval() 时会报权限不足。别笑,这个问题我见了太多次了。排查方法很简单:
sql复制GRANT USAGE, SELECT ON SEQUENCE your_seq TO your_app_user;
如果序列是在应用启动时由框架自动创建的,建议初始化时就把权限一次性授权好。
8.3 合理配置监控,把隐患消灭在萌芽期
有条件的团队,建议对序列相关的等待事件做监控。PostgreSQL 的 pg_stat_activity 视图里可以看到当前所有会话的状态和等待事件:
sql复制SELECT pid, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND wait_event = 'sequence';
这样能把正在等待序列锁的会话捞出来。如果想看总体趋势,可以在时序监控系统里对查询结果做聚合。一个健康的系统里,这个等待事件应当极少出现,如果大量出现,说明你的应用频繁调用 nextval() 且并发太高,该考虑批量预取或增加序列缓存了。
9. 结合个人实践的一点补充
我在不同的项目中使用 PostgreSQL 序列的经验差异很大。在早期的一个交易系统里,我把序列 CACHE 设成了 1,结果每逢业务高峰,数据库锁等待就明显上涨。后来优化为 CACHE 50,配合应用层先批量预取 ID 到内存,数据库压力立刻降下来了。
在另一个数据平台项目里(主要做数据集成),我用到了数据库同步工具,源端 PostgreSQL 抽数任务会往多张目标表插入数据。当时我对每条 INSERT 都单独调用 nextval(),数据库往返次数太多,同步速率上不去。后来改为在一次数据库会话中,先用 generate_series 批量取 5000 个 ID,再在应用内存中使用队列逐条消费,同步吞吐量提升了近三倍。
前面说过,nextval() 本质上是数据库侧的一个原子计数器。只要你尊重它的设计——不要求它支持事务回滚、不要求缓存之间严格连续——它就是你并发场景下最省心的 ID 来源。反过来,硬要在它上面叠加超出设计目标的业务规则,那踩坑就在所难免了。
熟悉这些细节之后,再去网上看那些“为什么我的 nextval() 不连续”“为什么并发时感觉取号变慢”之类的提问帖,你大概一眼就能从提问姿势判断出问题出在哪一层。序列本身,大多数时候是无辜的。
