Postgres 序列用一个缓存发号
一个序列在锁下逐个返回数字,缓存则把一大批小锁调用,变成偶发的大幅跳跃。只这一个设计决定——用短锁保正确、用批量换速度——就解释了序列带来的所有意外:重启后的空号、并发下的交错编号、审计敏感表里对不上的号码。没有一条是缺陷,全都源于把它当成高速唯一发号器,而不是里程表。 实际影响可以写成一句直白的契约:序列保证同一序列内唯一、数值大致递增、支持高并发;不保证连续、不保证跨会话单调、不保证已分配未用的号码在崩溃后存活。按真实契约建模,序列就从操心清单里消失;按想象中的里程表建模,每个空号都会变成一次事故。 每次拿一个数字都加锁 序列保存一个计数器,nextval 在其上加一行锁并把计数值推进,于是每次调用都竞争同一个值。在高并发插入时,这里会成为热点。每个后端都要短暂串行地经过同一行去领号,加多少 worker 都突破不了这个上限。锁很短——加一就放——但每秒几万次插入时,再短的串行也是串行。 所以批量导入和并发写入总是最先感受到序列的存在。表可以并行插,索引可以分裂,计数器一次只接待一个领号的人。性能分析通常也能证实:锁等待堆在序列对象上,别处都在空转。序列成了瓶颈时,解法不是更快的锁,而是少去几趟——这正是缓存要买的东西。 缓存换速度、留空洞 开了缓存,调用方一次把一整套拿回家,只在补货时才回到序列,锁的往返大幅减少。但一次重启会把这批还没用完的部分丢掉,所以缺号是设计上的结果。缓存设一百,意味着一百个号码才做一次加锁访问,而不是一个一访——竞争降两个数量级,代价是每次崩溃或切换最多丢一百个号。 缓存大小按插入速率定,别按审美定。缓存为一,空号最少但每次都要加锁;缓存一千,序列基本退出并发舞台,但每次重启能跳一千个号。多数在线交易负载落在二三十到一百之间:锁从性能画像里消失,空号小到没人察觉。用修改序列语句调整,这个设置长在序列自己身上,所以随备份走,从库行为也一致。 序列值还不属于事务快照。事务拿到号码后即使随后回滚,序列也不会倒退;多个会话同时调用时,较大的号码也不代表较早提交。主备切换、批量导入和失败重试都会留下正常的间隙。把序列列当作不可变的技术标识,时间顺序另用提交时间或事件编号表达。若财务流程确实要求连续票号,单独建立窄范围的提交计数器,并接受那里必须串行的成本,别让主键承担两种互相冲突的承诺。 当它是唯一值,别当连续号 序列产生的是唯一 id,绝不是无缝的 1、2、3,缺口完全可以接受。只有真正必须连续的地方才去保号;序列只当作 id 生成器使。空号有三个来源:回滚的事务、重启丢掉的缓存批、并发会话交错领号——三个都正常,一个都挡不住,除非交出并发或崩溃安全。 连续编号是法规或业务硬要求的地方——发票号、排队票——就显式另建机制:提交时在一个短事务里推进的独立计数表,只在这条窄路上承受串行代价。主键用高速且有空号的序列,对外展示的编号用慢而无缝的计数器。把两份工作塞进一个机制,等于逼自己在速度和连续之间二选一,而两种负载其实哪边都不想要对方的答案。 坏掉的样子 经典故障是审计恐慌:切换主库后财务发现发票号跳了几百,怀疑丢了数据。其实什么都没丢——备库提升时开了新的缓存窗口,旧的那批缓存随着主库一起走了。修法是沟通加隔离:文档里写明代理键会跳号,绝不把序列裸值暴露到会被人读出连续含义的地方。号码要见人,就给它配显式的无缝计数器,别用大缓存的序列顶。 第二种故障更隐蔽:两个会话领到交错的号,应用代码却假设编号靠后等于提交靠后。并发下会话甲先领到一百零一、后提交,会话乙后领到一百零二、先提交。按编号排序就排错了历史。提交顺序重要时,按提交时间戳排序,永远别按序列值。 快速验证 验证缓存行为与空号来源: SHOW SERVER_VERSION; SELECT cache_size FROM pg_sequences WHERE sequencename = 'orders_id_seq'; BEGIN; SELECT nextval('orders_id_seq'); ROLLBACK; SELECT last_value FROM orders_id_seq; 回滚照样推进序列,证明空号是结构使然,不是故障所致。 ...