PostgreSQL 多租户发票编号:如何兼顾无重复、少空号与并发性能

2026-09-28 28 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

给多租户系统生成发票号,看起来只是“每个租户加一”,真正上线后却会同时遇到三个约束:同一租户不能重复、已提交的编号尽量连续、高并发下不能把数据库拖垮。围绕 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) 唯一约束,并把“无空号”的业务定义写进测试,而不是只写在需求文档里。


相关推荐