PAYATHON 2026

如何创建唯一的 billing identifier

支付老李

结论

不要用 SELECT MAX(billing_id) + 1 生成编号。两个并发请求可能读到相同的最大值,进而生成相同的 billing identifier。

更稳妥的方案是:

  1. 使用数据库的 AUTO_INCREMENT 主键生成唯一数字。
  2. 插入记录后,通过 insert_id() 取得本次插入生成的 ID。
  3. 根据 ID、年份和固定前缀生成 billing identifier。
  4. 为 billing_identifier 添加 UNIQUE 约束,防止重复编号写入。

这种方案通常需要先插入、再更新。如果不想执行更新,可以在查询时动态拼接格式化编号。由于自增 ID 要等 INSERT 成功后才会产生,因此无法提前获得。

为什么 MAX(id) + 1 不安全

假设当前最大值是 100000,两个请求同时执行:

SELECT MAX(billing_id) FROM billing_identifiers;

它们都可能读到 100000,然后生成:

ABC-2013-100001

如果字段没有唯一约束,两条重复编号都可能写入数据库。如果有唯一约束,其中一个请求会失败。

即使放在事务中,普通的 SELECT MAX(...) 也不能安全地领取下一个号码。如果要自行维护序列,需要配合行锁或原子更新,通常没有必要增加这层复杂度。

推荐表结构

CREATE TABLE billing_identifiers (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    billing_year SMALLINT UNSIGNED NOT NULL,
    billing_identifier VARCHAR(64) NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uq_billing_identifier (billing_identifier)
);

各字段的用途如下:

  • id 由数据库以并发安全的方式分配。
  • billing_year 单独保存,便于查询和统计。
  • billing_identifier 使用唯一索引,避免重复。
  • 第一次插入时允许 billing_identifier 为 NULL,取得自增 ID 后再更新。

CodeIgniter 风格的实现

$year = (int) date('Y');

$this->db->trans_start();

$this->db->insert('billing_identifiers', [
    'billing_year' => $year,
    'billing_identifier' => null,
]);

$billId = $this->db->insert_id();

$billingIdentifier = sprintf(
    'law-%d-%06d',
    $year,
    $billId
);

$this->db
    ->where('id', $billId)
    ->update('billing_identifiers', [
        'billing_identifier' => $billingIdentifier,
    ]);

$this->db->trans_complete();

if ($this->db->trans_status() === false) {
    throw new RuntimeException('Failed to create billing identifier');
}

生成的编号类似:

law-2013-100001

如果在插入前调用 insert_id(),返回 0 是正常的。自增值只有在数据库成功执行 INSERT 后才会生成,因此应在同一个数据库连接上,紧接着插入操作调用该方法。

事务可以避免出现基础记录已经插入,但格式化编号更新失败的半完成状态。UNIQUE 约束则在数据库层保证编号不会重复。

如果编号不必实际存储

如果 billing identifier 只是主键的显示形式,可以只保存 id 和年份,在查询时生成编号:

SELECT
    id,
    CONCAT('law-', billing_year, '-', LPAD(id, 6, '0'))
        AS billing_identifier
FROM billing_identifiers
WHERE id = ?;

这样只需执行一次插入,也不会出现编号字段与主键不同步的问题。

另一种选择是使用生成列,具体语法取决于数据库产品和版本。例如,在支持存储生成列的 MySQL 版本中,可以采用类似的设计:

CREATE TABLE billing_identifiers (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    billing_year SMALLINT UNSIGNED NOT NULL,
    billing_identifier VARCHAR(64)
        GENERATED ALWAYS AS (
            CONCAT('law-', billing_year, '-', LPAD(id, 6, '0'))
        ) STORED,
    PRIMARY KEY (id),
    UNIQUE KEY uq_billing_identifier (billing_identifier)
);

生成列表达式能否引用 AUTO_INCREMENT 列,需要根据实际使用的数据库及版本验证。如果不支持,可以改用插入后更新,或者在查询时拼接编号。

如果编号要求每年从固定值重新开始

如果业务要求生成以下编号:

ABC-2013-100001
ABC-2013-100002
ABC-2014-100001

全局自增主键无法直接满足要求。此时应为每个年份维护一条计数器记录,通过原子 SQL 或行锁领取号码,不能使用 MAX(...)。

例如:

CREATE TABLE billing_sequences (
    billing_year SMALLINT UNSIGNED NOT NULL,
    last_number BIGINT UNSIGNED NOT NULL,
    PRIMARY KEY (billing_year)
);

事务中的核心逻辑可以写成:

START TRANSACTION;

SELECT last_number
FROM billing_sequences
WHERE billing_year = 2013
FOR UPDATE;

UPDATE billing_sequences
SET last_number = last_number + 1
WHERE billing_year = 2013;

COMMIT;

取得新值后,再生成:

ABC-2013-100001

生产环境还要处理某个年份尚未创建计数器记录时的初始化竞争。具体实现取决于 MySQL、PostgreSQL、SQL Server 等数据库提供的原子更新和返回值机制。

注意事项

  • 最终编号必须在数据库层建立 UNIQUE 约束,不能只依靠应用代码判断是否重复。
  • 自增 ID 通常能保证唯一,但不保证连续。事务回滚或插入失败都可能留下空号。
  • 如果账单编号受财务或税务规则约束,可能需要满足连续、不可复用或只能在特定时点签发等要求。这类编号不能按普通技术主键处理,应结合适用的业务和法规要求进行设计。
  • 年份应在创建记录时确定并保存。不要在之后根据当前时间重新计算,否则跨年后可能得到不同的显示编号。
  • 如果 100001 只是格式要求,可以设置自增值的起始值,也可以在格式化时增加偏移量,不需要改用 MAX(id) + 1。

备注:内容仅供参考。