2025年10月08日·阅读约1分钟

并发安全的发票编号:避免重复与缺失

学习实用方案,实现并发安全的发票与工单编号,让多人创建时不出现重复或意外缺口。

并发安全的发票编号:避免重复与缺失

两个人同时创建记录时会出什么问题

想象一下一个繁忙的办公室,时间是下午 4:55。两个人在一秒内完成发票并点击保存。两台屏幕短暂显示“发票 #1042”。一条记录成功保存,另一条失败;更糟的是,两条可能都被存储为相同的编号。这是现实中最常见的症状:在负载下才会出现的重复编号。

工单的情况也类似。两名客服同时为同一客户创建新工单,而系统通过查看最新记录来“取下一个编号”。如果两个请求在任一写入之前读取到相同的“最新”值,它们就可能都选择相同的下一个编号。

第二种更微妙的症状是:编号被跳过。你可能看到 #1042,然后是 #1044,而 #1043 丢失了。这通常发生在错误或重试之后。一个请求预占了编号,但保存因为校验错误、超时或用户关闭标签页而失败。或者后台作业在网络短暂中断后重试并获取了一个新编号,即便第一次尝试已经消耗了一个。

对于发票来说,这很重要,因为编号是审计链的一部分。会计人员期望每张发票有唯一标识,客户也可能在付款或支持邮件中引用发票编号。对于工单,编号是所有对话、报表和导出使用的引用。重复会造成混淆。缺失的编号在审查时也会引发疑问,即便并没有不当行为发生。

这里要早早设定的关键预期是:并非所有编号方法都能同时做到并发安全和无缺口。并发安全的发票编号(在多人情况下也不重复)是可以实现的,应视为底线。无缺口编号也是可行的,但需要额外规则,常常会改变你对草稿、失败和取消的处理方式。

一个好的思路是先问清楚你希望编号保证什么:

  • 绝不能重复(始终唯一)
  • 应该大体递增(可选的美观要求)
  • 绝不能跳号(只有在你专门为此设计时)

一旦确定规则,选择技术方案就容易得多。

为什么会出现重复和缺口

大多数应用遵循一个简单模式:用户点击保存,应用请求下一个发票或工单编号,然后插入带该编号的新记录。单用户时看起来很安全。

问题出现在两个保存几乎同时发生的时候。两个请求可能在任一插入完成前都到达“获取下一个编号”这一步。如果两次读取看到相同的“下一个”值,它们会尝试写入相同的编号。这就是竞态条件:结果取决于时机而不是逻辑。

一个典型时间线如下:

  • 请求 A 读取下一个编号:1042
  • 请求 B 读取下一个编号:1042
  • 请求 A 插入发票 1042
  • 请求 B 插入发票 1042(或者在唯一约束阻止时失败)

当数据库没有任何东西阻止第二次插入时,就会产生重复。如果你只在应用代码中检查“这个编号是否已被占用?”,那么在检查与插入之间仍可能发生竞态,导致失败。

缺口是另一类问题。它们发生在系统“预留”了一个编号,但记录从未变成真正提交的发票或工单时。常见原因包括付款失败、晚发现的校验错误、超时或用户在分配编号后关闭标签页。即便插入失败且没有保存,编号可能已经被消耗。

隐藏的并发会让问题更糟,因为这通常不仅仅是“两个用户点击保存”。你还可能遇到:

  • 并行创建记录的 API 客户端
  • 批量运行的导入任务
  • 在夜间生成发票的后台作业
  • 在网络不稳定时来自移动应用的重试

所以根本原因是:(1) 多个请求读取相同计数器值造成的时序冲突,和 (2) 在事务成功前就已经分配了编号。任何并发安全的编号方案都必须决定你能容忍哪种结果:不重复、无缺口,或两者兼顾,以及在草稿、重试、取消等事件下的具体行为。

在选择方案前先决定编号规则

在设计并发安全的发票编号前,把编号在业务中的含义写清楚。最常见的错误是先选技术实现,后来发现会计或法律规则有不同期待。

先把两个经常混淆的目标分开:

  • 唯一:不允许两张发票或工单共享相同编号。
  • 无缺口:编号既唯一又严格连续(没有缺失)。

许多系统只追求唯一并接受缺口。缺口可能由多种正常原因造成:用户打开草稿后放弃、付款在编号预留后失败、记录被创建后又作废等。对工单来说,缺口通常并不重要。即便是发票,很多团队也接受只要有审计记录可以解释缺口(作废、取消、测试等)。要实现无缺口编号,需要额外规则并往往带来更多摩擦。

接下来,决定计数器的作用域。措辞的细微差别会大幅改变设计:

  • 是对所有实体使用一个全局序列,还是按公司/租户分开?
  • 是否按年重置(例如 2026-000123)还是永不重置?
  • 发票、贷项单、工单是否使用不同的系列?
  • 是否需要对人友好的格式(前缀、分隔符),还是只需内部编号?

举例:某 SaaS 产品有多个客户公司,可能要求发票编号在公司范围内唯一并按日历年重置,而工单则在全局范围内唯一且永不重置。这是两种不同的计数器和规则,尽管 UI 看起来相似。

如果你确实需要无缺口,要明确在分配编号后允许发生哪些事件。例如,发票能否被删除,还是只能作废?用户能否在草稿阶段保存编号,还是仅在最终批准时分配?这些选择通常比数据库技术更重要。

在构建前把规则写成一段简短规范:

  • 哪些记录类型使用该序列?
  • 什么情形算编号“已使用”(草稿、发送、支付)?
  • 作用域是什么(全局、按公司、按年、按系列)?
  • 如何处理作废和修正?

在 AppMaster 中,这类规则应放在数据模型和业务流程旁边,这样团队在 API、Web UI 和移动端都能实现一致行为,避免惊讶。

常见方法及它们各自的保证

人们谈论“发票编号”时,常把两个目标混为一谈:(1) 永远不产生相同编号,(2) 永远不出现缺口。大多数系统都能比较容易保证第一点。第二点更难,因为任何事务失败、草稿被放弃或记录被作废时都可能出现缺口。

方法 1:数据库序列(快速且唯一)

PostgreSQL 序列是获得在负载下唯一且递增编号的最简单方法。数据库设计用于快速分配序列值,即便很多用户同时创建记录也能扩展。

你得到的是:唯一性和(大体上)递增。你得不到的是:无缺口。如果在分配编号后插入失败,该编号会被“烧掉”,你会看到缺口。

方法 2:唯一约束加重试(让数据库裁决)

在此方法中,你生成一个候选编号(由应用逻辑),尝试保存,并依赖 UNIQUE 约束拒绝重复。如果冲突发生,则重试并生成新编号。

这能工作,但在高并发下容易产生噪音。你可能会得到更多重试、更多失败事务和更难排查的峰值。同时除非结合严格的预留规则,否则也不能保证无缺口,这会增加复杂性。

方法 3:计数行加锁(追求无缺口)

如果你确实需要无缺口编号,常见模式是使用专门的计数表(对每个作用域一行,比如每公司或每年一行)。在事务中锁定那一行、递增并使用新值。

这在常规数据库设计中是最接近无缺口的方案,但代价是:它会产生单点“热点”,所有写入都必须排队等待。它也提高了运维风险(长事务、超时、死锁)。

方法 4:单独的预留/编号服务(仅用于特殊场景)

独立的“编号服务”可以在多个应用或数据库间集中规则。仅当你有多个系统发号且无法合并写入时才值得考虑。

代价是运维风险:你增加了另一个必须正确、高可用且一致的服务。

把并发安全编号的保证简单总结如下:

  • 序列:唯一、快速、允许缺口
  • 唯一+重试:唯一、低并发时简单、高并发时可能抖动
  • 锁定计数行:可无缺口,高并发下较慢
  • 单独服务:跨系统灵活,复杂度和故障模式最高

即便在像 AppMaster 这样的无代码工具中,这些选择依然适用:正确性应由数据库保证。应用逻辑可以帮忙重试和提供清晰错误,但最终保证应来自约束和事务。

分步示例:用序列和唯一约束防止重复

通过设计保护草稿
创建一个在记录真正发布时才分配编号的最终化步骤。
立即构建

如果主要目标是不重复(而非保证无缺口),最简单可靠的模式是:让数据库生成内部 ID,并对面向客户的编号设置唯一约束。

先把两种概念分开。用数据库生成的值(identity/sequence)作为主键用于关联、编辑和导出。把 invoice_no 或 ticket_no 保留为单独列展示给人看。

在 PostgreSQL 中的一个实用设置

下面是一个常见的 PostgreSQL 做法,把“下一个编号”逻辑放在数据库内,那里并发会被正确处理。

-- Internal, never-shown primary key
create table invoices (
  id bigint generated always as identity primary key,
  invoice_no text not null,
  created_at timestamptz not null default now()
);

-- Business-facing uniqueness guarantee
create unique index invoices_invoice_no_uniq on invoices (invoice_no);

-- Sequence for the visible number
create sequence invoice_no_seq;

现在在插入时生成显示编号(不要通过 "select max(invoice_no) + 1" 来做)。一种简单的模式是在 INSERT 中格式化序列值:

insert into invoices (invoice_no)
values (
  'INV-' || lpad(nextval('invoice_no_seq')::text, 8, '0')
)
returning id, invoice_no;

即便有 50 个用户同时点击“创建发票”,每次插入都会得到不同的序列值,唯一索引也会阻止任何意外的重复。

发生冲突时怎么办

在纯序列的方案中,碰撞很少。通常在你加入额外规则(如“按年重置”、“按租户”或允许用户编辑编号)时会出现。因此唯一约束仍然重要。

在应用层面上,用一个小的重试循环处理唯一约束错误:

  • 尝试插入
  • 如果收到 invoice_no 的唯一约束错误,则重试
  • 限定重试次数并在失败后显示明确错误

这行得通,因为重试只会在不寻常的情况下触发,例如两个不同的代码路径产生相同格式的编号。

把竞态窗口缩小

不要在 UI 计算编号,也不要通过先读取再插入来“预留”编号。尽量在靠近数据库写入的地方生成它。

如果你在 AppMaster 上使用 PostgreSQL,可以在数据设计器中把 id 设为 identity 主键,为 invoice_no 添加唯一约束,并在创建流程中生成 invoice_no,使它与插入同时发生。这样数据库就是事实来源,并发问题被限制在 PostgreSQL 最擅长的位置。

分步示例:用行锁实现无缺口计数器

将规则转成工作流
用明确且可审计的步骤自动化发票创建、重试和状态变更。
开始使用

如果你真的需要无缺口编号(没有丢失的编号),可以使用事务性计数表与行锁。思路很简单:一次只有一个事务可以为某个作用域获取下一个编号,因此编号按顺序分配。

首先决定作用域。很多团队需要按公司、按年或按系列分开序列。计数表存储每个作用域的最后使用编号。

下面是在 PostgreSQL 中使用行锁实现并发安全编号的实用模式:

  1. 创建一个表,例如 number_counters,包含 company_idyearserieslast_number 等列,并对 (company_id, year, series) 建唯一键。
  2. 启动数据库事务。
  3. 使用 SELECT last_number FROM number_counters WHERE ... FOR UPDATE 锁定目标作用域的计数行。
  4. 计算 next_number = last_number + 1,并更新计数行为 last_number = next_number
  5. 使用 next_number 插入发票或工单行,然后提交事务。

关键在于 FOR UPDATE。在负载下,你不会得到重复;也不会出现“两个用户拿到相同编号”的缺口,因为第二个事务在第一个提交(或回滚)之前无法读取并递增同一行。第二个请求会短暂等待。这种等待是无缺口的代价。

初始化新作用域

你还需要为新作用域(新公司、新年、新系列)准备策略。常见的两种选择:

  • 提前预创建计数行(例如在十二月创建下一年的行)。
  • 按需创建:尝试插入计数行 last_number = 0,如果已存在则退回到正常的锁定并递增流程。

如果你在无代码工具(如 AppMaster)里构建,确保“锁定、递增、插入”整段逻辑在同一个事务内,这样要么全部发生,要么全部不发生。

边缘情况:草稿、保存失败、取消和编辑

大多数编号错误会在这些混乱环节暴露:未发布的草稿、保存失败、发票被作废、记录在别人看到编号后被编辑。如果你想要并发安全的发票编号,需要对编号何时变成“真实”的有清晰规则。

最大决策点是时机。如果在用户点击“新建发票”那刻就分配编号,草稿被放弃会造成缺口。如果只在发票最终确定(发布、开具、发送或业务定义的“最终”)时分配编号,你可以把编号控制得更紧,解释起来也更容易。

失败保存和回滚是期望与数据库行为冲突的常见来源。用典型序列时,一旦编号被取走就是取走了,即便事务随后失败。这很正常也安全,但会产生缺口。如果策略要求无缺口,编号只能在最终步骤并且事务提交时才会分配。通常这意味着锁定单个计数行、写入最终编号并一起提交。如果任何步骤失败,什么都不会被分配。

作废和取消几乎不应“重新使用”编号。保留编号并改变状态更好。审计员和客户期望历史保持一致,即便文档被更正。

编辑更简单:一旦编号对外可见,就视为永久。不要在编号已共享、导出或打印后重写编号。如果需要纠正,创建新的文档并引用旧文档(例如贷项单或替代工单),不要改写历史。

很多团队采用的一套实用规则是:

  • 草稿没有最终编号(使用内部 ID 或标记为“DRAFT”)。
  • 仅在“发布/开具”时分配编号,并在同一事务中变更状态。
  • 作废和取消保留编号,但记录清晰的状态和原因。
  • 已打印/已邮件的编号绝不更改。
  • 导入保留原始编号,并将计数器设为已导入最大值之后的安全值。

迁移和导入需要特别小心。如果从其他系统迁移,按原样导入现有编号,然后把计数器设置到最大导入值之后开始。对于冲突格式(例如按年不同前缀),通常最好把“显示编号”按原样存储,并保持独立的内部主键。

举例:一个帮助台快速创建工单,但很多是草稿。仅在代理点击“发送给客户”时分配工单编号。这样避免在被放弃的草稿上浪费编号,并保持可见序列与实际客户沟通一致。在像 AppMaster 这样的无代码工具中,同样的想法适用:把草稿保留为没有公共编号的记录,在“提交”并成功提交事务的业务流程步骤中生成最终编号。

导致重复或意外缺口的常见错误

阻止重复发票编号
在数据库级别建立可在真实并发下可靠工作的发票和工单规则。
试用 AppMaster

大多数编号问题源于一个简单的想法:把编号当作显示值而不是共享状态。当多人同时保存时,系统需要一个明确的位置来决定下一个编号,以及在失败时的统一规则。

一个经典错误是用 SELECT MAX(number) + 1 在应用代码中计算下一个编号。单用户测试下看起来没问题,但两个请求都可能在任一提交前读取相同的 MAX。两者都会生成相同的下一个值,导致重复。即便你加上“检查然后重试”,在高峰流量下你仍会制造额外负载和奇怪的峰值。

另一个常见的重复来源是客户端(浏览器或移动端)生成编号并在保存前就使用它。客户端不知道其他用户在做什么,且在保存失败时无法安全地保留编号。客户端生成的编号可以用于临时标签如“Draft 12”,但不适合作为正式的发票或工单 ID。

缺口会让误以为序列应无间断的团队感到惊讶。在 PostgreSQL 中,序列为了唯一性而设计,而非完美连续。事务回滚、预取 ID 或数据库重启时都可能跳号。这是正常的。如果你的真实需求是“无重复”,序列加唯一约束通常是正确的选择。如果你确实需要“无缺口”,你需要不同的模式(通常是行锁),并接受吞吐量上的权衡。

当锁定范围过宽时也会适得其反。一个全局共享锁会让所有创建动作都排队,即便你可以按公司、地点或文档类型分区计数。这会拖慢整个系统,让用户感到保存“随机”被卡住。

以下是实现并发安全编号时值得检查的错误:

  • 在没有数据库级唯一约束的情况下使用 MAX + 1(或“查找最后编号”)。
  • 在客户端生成最终编号,然后试图“事后修复冲突”。
  • 期望 PostgreSQL 序列无缺口,并把缺口当作错误处理。
  • 把所有编号都锁在一个共享计数器,而不是在合理处做分区计数。
  • 只用单用户测试,竞态条件直到上线才显现。

实用测试提示:运行一个并发测试,平行创建 100 到 1,000 条记录,然后检查重复和意外缺口。如果你在无代码工具里构建,同样要确保最终编号在单一的服务器端事务内分配,而不是在 UI 流程中分配。

上线前的快速检查表

对创建流程进行压力测试
运行并行创建测试,在用户发现问题前捕获竞态条件。
开始构建

在发布发票或工单编号前,快速检查那些在真实流量下容易出错的部分。目标很简单:每条记录恰好有一个业务编号,并且当 50 个用户同时点击“创建”时规则仍保持不变。

这是一个并发安全发票编号的实用预发布清单:

  • 确认业务编号字段在数据库中有唯一约束(不仅仅是 UI 检查)。这是发生碰撞时的最后防线。
  • 确保编号在保存记录的同一数据库事务内分配。如果编号分配和保存分成多个请求,你最终会看到重复。
  • 如果你要求无缺口,只在记录最终化时分配编号(例如发票签发时,而不是草稿创建时)。草稿、放弃表单和失败支付是缺口的主要来源。
  • 为罕见冲突添加重试策略。即便有行锁或序列,也可能遇到序列化错误、死锁或唯一违规。在边缘时机用带短退避的重试通常足够。
  • 使用 20 到 100 个并发的创建动作(覆盖 UI、公共 API 和批量导入)进行压力测试。测试真实场景混合,如突发、慢网络和重复提交。

验证设置的一个快速方法是模拟繁忙的帮助台场景:两名代理打开“新工单”表单,其中一人通过 Web 应用提交,同时一个导入作业从邮箱插入工单。运行后检查所有编号是否唯一、格式是否正确,以及失败是否没有留下半保存的记录。

如果你在 AppMaster 中构建,原则相同:把编号分配保持在数据库事务内,依赖 PostgreSQL 约束,并测试会创建同一实体的所有入口点(UI 和 API)。许多团队在手动测试时觉得放心,但在真实用户涌入的第一天就会被现实打脸。

示例:繁忙的帮助台工单与下一步

想象一个客服中心,代理在 Web 应用中全天创建工单,同时集成也从聊天工具和邮件创建工单。大家期望工单编号像 T-2026-000123,并期望每个编号只对应一条工单。

天真的做法是:读取“最后工单号”,加 1,然后保存新工单。高并发下,两次请求可能在任一保存前读取相同的“最后编号”。两者都计算出相同的下一个编号,导致重复。如果你试图通过失败后重试来“修复”,经常会无意中造成缺口。

数据库可以在你的应用代码天真时阻止重复。给 ticket_number 列加唯一约束。这样,当两个请求尝试相同编号时,一个插入会失败,你可以干净地重试。这也是并发安全发票编号的核心:让数据库来强制唯一性,而不是 UI。

无缺口编号会改变工作流。如果你要求不跳号,通常不能在工单创建时就分配最终编号(草稿)。而是插入时记录状态为 Draft 并把 ticket_number 留空。只有在工单最终化时才分配编号,这样失败保存和放弃的草稿不会“烧掉”编号。

一个简单表设计示例:

  • tickets: id, created_at, status (Draft, Open, Closed), ticket_number (nullable), finalized_at
  • ticket_counters: key (例如 "tickets_2026"), next_number

在 AppMaster 中,你可以在 Data Designer 用 PostgreSQL 类型建模,然后在 Business Process Editor 里构建逻辑:

  • 创建工单:插入 status=Draft 且不带 ticket_number 的记录
  • 最终化工单:启动事务,锁定计数行,设置 ticket_number,递增 next_number,提交
  • 测试:同时运行两次“最终化”操作,确认永不出现重复

下一步建议:先确定你的规则(仅唯一还是必须无缺口)。如果可以接受缺口,数据库序列加唯一约束通常足够且流程更简单。如果必须无缺口,把编号移到最终化步骤,并把“草稿”视为一等公民。然后用多代理同时点击以及 API 集成并发触发的场景做负载测试,在真实用户到来前观察行为。

容易上手
创造一些 惊人的东西

使用免费计划试用 AppMaster。
准备就绪后,您可以选择合适的订阅。

开始吧
并发安全的发票编号:避免重复与缺失 | AppMaster