Chris van Eijk:在PostgreSQL中按租户生成无间隙的发票编号

Chris van Eijk:在PostgreSQL中按租户生成无间隙的发票编号

💡 原文英文,约1600词,阅读约需6分钟。
📝

内容提要

多租户SaaS发票编号需按租户连续且唯一。在PostgreSQL 17下实测五种方案:序列会因回滚跳号且无法按租户分列;max()+1在并发下大量重复;SERIALIZABLE虽正确但重试率高。推荐每租户一行计数器,在事务末尾用UPDATE…RETURNING取号并写入发票,可零重复零跳号,且应尽量缩短持锁时间。

🔎

延伸解读

为什么序列和 max()+1 不适合多租户发票编号

序列因事务回滚不回收值,实测跳号17,973个,且无法按租户分列;max()+1在默认隔离级别下并发重复率高达87%,即使加唯一约束也只是把错误抛给用户。两者都无法同时满足按租户连续且唯一的要求。

计数器行方案:正确性与性能代价

每租户一行计数器,在事务末尾用UPDATE…RETURNING取号并写入发票,可实现零重复零跳号,回滚时计数器一并回滚。代价是同一租户的发票串行化,吞吐受限于持锁时间;将取号放在事务最后,可把10ms其他工作移出锁外,吞吐从94提升到746。

SERIALIZABLE 的局限与适用场景

SERIALIZABLE能保证无重复无跳号,但高并发下重试率高:八会话单租户时76%事务需重试,26.6%在20次重试后仍失败,吞吐仅1,475/秒。它适合冲突较少的场景,但重试逻辑需应用层处理,不适合高并发单租户发票编号。

如何验证编号无间隙且唯一

文章提供两个检查查询:用窗口函数lead()找出每个租户序列中的间隙;用group by having count(*)>1找出重复编号。注意第一个查询不检查序列起始前的间隙,若要求从1开始,需额外检查min(number)。按年编号时,需在partition by和group by中加入系列列。

❓

Q&A

在PostgreSQL中,如何为多租户SaaS的每个租户生成连续且唯一的发票编号?

推荐使用每租户一行计数器,在事务末尾通过UPDATE ... RETURNING获取编号并写入发票。这样可保证零重复、零跳号,且应尽量缩短持锁时间。

为什么PostgreSQL的序列(sequence)不适合用于按租户生成无间隙的发票编号?

序列设计上不保证无间隙:事务回滚时nextval获取的值不会回收,导致跳号;且一个序列无法为每个租户维护独立的编号系列。

使用max(number)+1为发票编号会有什么问题?

在默认隔离级别下,并发事务可能读取到相同的最大编号,导致重复编号。实测中大量发票出现重复,即使添加唯一约束也会导致保存失败。

SERIALIZABLE隔离级别能解决发票编号的重复和跳号问题吗?

能保证正确性(无重复、无跳号),但并发冲突会导致大量事务重试,实测中重试率高且仍有失败,吞吐量显著下降。

使用计数器行实现无间隙编号时,如何减少锁竞争对性能的影响?

应尽量在事务末尾获取编号,缩短持有计数器行锁的时间。例如,在发票最终确定时再分配编号,并将耗时操作放在获取编号之前。

如何检查PostgreSQL中发票编号是否存在间隙或重复?

使用两个查询:一个通过窗口函数lead()检测每个租户系列中的间隙;另一个通过GROUP BY和HAVING count(*)>1查找重复编号。

🏷️

标签

➡️

继续阅读