给多租户系统生成发票号,看起来只是“每个租户加一”,真正上线后却会同时遇到三个约束:同一租户不能重复、已提交的编号尽量连续、高并发下不能把数据库拖垮。围绕 PostgreSQL 17,可以比较 sequence、max()+1、SERIALIZABLE 和计数器行等五类实现,但评估时不能只看吞吐量,还要观察重复、空号和事务失败后的行为。
先明确“无空号”到底指什么
数据库中的“无空号”至少有三种不同含义:
- 分配时连续:每次取到的数字依次递增,但事务回滚可能留下空号。
- 已提交记录连续:失败或回滚的事务不消耗编号。
- 业务生命周期内永久连续:记录不能被物理删除,作废发票也必须保留编号和状态。
PostgreSQL sequence 适合生成唯一标识,但 nextval() 不会随着事务回滚。因此,它可以避免重复,却不能保证已提交发票之间没有空号。BIGSERIAL 和 identity column 在这一点上具有同样的边界。
真正需要“已提交记录连续”时,编号分配必须和发票插入处于同一个事务中;如果后续允许删除发票,数据库仍然无法维持永久连续。财务系统通常应将记录标记为 void 或 cancelled,而不是删除。
五种方案的实际取舍
为了把问题拆开,可以将常见实现归纳为五类:
| 方案 | 重复风险 | 回滚产生空号 | 并发特征 |
|---|---|---|---|
| 全局 sequence | 无 | 会 | 通常很快,但编号不是按租户独立连续 |
| 每租户一个 sequence | 无 | 会 | 分配快,但租户多时对象管理复杂 |
max(invoice_no) + 1 |
有 | 不一定 | 普通隔离级别下存在读写竞争 |
max()+1 配合 SERIALIZABLE |
可避免 | 可避免 | 冲突时事务会失败,应用必须重试 |
| 每租户一行事务计数器 | 无 | 可避免 | 不同租户可并行,同一租户会串行化 |
为什么裸写 max()+1 会重复
下面的查询本身并不会锁住“下一个尚不存在的编号”:
SELECT COALESCE(MAX(invoice_no), 0) + 1
FROM invoices
WHERE tenant_id = 42;
两个事务可能同时读到 100,随后都尝试插入 101。如果有唯一约束,其中一个事务会报错;如果没有唯一约束,重复编号就会进入数据表。因此唯一约束不是可选优化,而是最后一道正确性防线:
ALTER TABLE invoices
ADD CONSTRAINT invoices_tenant_number_key
UNIQUE (tenant_id, invoice_no);
SERIALIZABLE 可以让 PostgreSQL 检测这种不可串行化的执行,但代价是其中一个事务可能收到 40001 serialization_failure。这不是异常情况,应用必须实现带退避的整事务重试。热点租户越集中,重试成本通常越明显。
事务计数器为什么更容易控制
计数器表为每个租户保存一行。更新该行时,PostgreSQL 会获取行锁;同一租户的编号分配自然排队,不同租户则可以并行。更重要的是,计数器更新和发票插入可以放在同一事务中:插入失败时,编号增量也会回滚。
它并没有消除竞争,而是把竞争收敛到明确的一行。代价也很清楚:某个租户如果每秒开出大量发票,该租户的计数器行会成为热点。
可直接运行的计数器实现
下面的示例使用 PostgreSQL 17,也可以改造后用于其他仍受支持的 PostgreSQL 版本。它保证同一租户的发票号唯一,并让计数器更新与发票写入共同提交或共同回滚。
先启动一个本地实例:
docker run --rm --name invoice-pg \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
-d postgres:17
export DATABASE_URL='postgresql://postgres:postgres@localhost:5432/postgres'
创建表和发号函数:
CREATE TABLE invoice_counters (
tenant_id bigint PRIMARY KEY,
last_number bigint NOT NULL CHECK (last_number > 0)
);
CREATE TABLE invoices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
invoice_no bigint NOT NULL,
amount numeric(14, 2) NOT NULL CHECK (amount >= 0),
status text NOT NULL DEFAULT 'issued',
issued_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT invoices_tenant_number_key
UNIQUE (tenant_id, invoice_no),
CONSTRAINT invoices_status_check
CHECK (status IN ('issued', 'void', 'cancelled'))
);
CREATE OR REPLACE FUNCTION issue_invoice(
p_tenant_id bigint,
p_amount numeric
)
RETURNS TABLE (
invoice_id bigint,
tenant_id bigint,
invoice_no bigint
)
LANGUAGE plpgsql
AS $$
DECLARE
v_invoice_no bigint;
BEGIN
INSERT INTO invoice_counters AS c (tenant_id, last_number)
VALUES (p_tenant_id, 1)
ON CONFLICT (tenant_id)
DO UPDATE
SET last_number = c.last_number + 1
RETURNING last_number INTO v_invoice_no;
RETURN QUERY
INSERT INTO invoices AS i (tenant_id, invoice_no, amount)
VALUES (p_tenant_id, v_invoice_no, p_amount)
RETURNING i.id, i.tenant_id, i.invoice_no;
END;
$$;
将以上 SQL 保存为 setup.sql,然后执行:
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f setup.sql
psql "$DATABASE_URL" -c "SELECT * FROM issue_invoice(42, 199.00);"
可以用多个 psql 进程做一个简单的并发检查:
seq 1 100 | xargs -P 20 -I{} \
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -Atc \
"SELECT * FROM issue_invoice(42, 10.00);"
psql "$DATABASE_URL" -c "
SELECT
tenant_id,
count(*) AS rows,
count(DISTINCT invoice_no) AS distinct_numbers,
min(invoice_no) AS first_number,
max(invoice_no) AS last_number,
max(invoice_no) - min(invoice_no) + 1 = count(*) AS contiguous
FROM invoices
WHERE tenant_id = 42
GROUP BY tenant_id;
"
这里最关键的不是 PL/pgSQL 函数本身,而是两个操作处于同一事务。不要先提交计数器更新,再通过消息队列异步创建发票;那样一旦消费者失败,空号仍然会出现。
生产代码还应决定初始编号、年度重置和展示格式。建议把数据库中的编号保存为整数,把 ACME-2025-000123 这样的前缀与补零放在展示层处理。若编号需要按财年重新开始,计数器主键可以改成 (tenant_id, fiscal_year),同时让发票唯一约束使用相同维度。
上线前不要只跑一个吞吐数字
比较这些方案时,至少应分别测试:
- 大量租户均匀开票,观察跨租户并行能力;
- 单个热点租户集中开票,观察行锁等待和尾延迟;
- 插入约束失败或事务主动回滚后,编号是否被消耗;
SERIALIZABLE方案的40001重试次数和最终延迟;- 连接中断、超时与客户端不确定提交结果时,如何查询并去重;
- 发票作废后是否保留原记录,而不是删除后制造业务空号。
如果需求只是“唯一且大致递增”,sequence 往往更简单,也更符合 PostgreSQL 的设计。如果需求明确要求“同一租户已提交的发票连续”,事务计数器通常更容易推理,但要接受热点租户上的串行化。无论采用哪种方案,都应保留 (tenant_id, invoice_no) 唯一约束,并把“无空号”的业务定义写进测试,而不是只写在需求文档里。