PAYATHON 2026

多次执行时如何生成无重复的自增 invoice number

支付阿杰

结论

不要查询最新一张 invoice 后再加 1,也不要依赖 post_title 保证唯一性。更可靠的方式是增加一张独立的序列表,由数据库通过原子 UPDATE 分配编号。

这种做法不需要修改 WordPress 的 wp_posts 表。序列表只生成数字,最终得到的 2019-0002 仍可写入 custom post type 的 post_title。

为什么现有做法会产生重复编号

下面的逻辑存在典型的 race condition:

  1. 请求 A 读取当前最大编号 2019-0001。
  2. 请求 B 同时也读取到 2019-0001。
  3. 两个请求都算出下一个编号是 2019-0002。
  4. 两条 invoice 最终使用同一个编号。

查询和写入即使只相隔几毫秒,也无法避免这个问题。应用层的”先查再加”不是原子操作,并发控制必须交给数据库。

建立独立的序列表

可以按”用户 + 年份”分别记录当前序号:

CREATE TABLE wp_invoice_sequences (
    scope_key VARCHAR(191) NOT NULL,
    current_value BIGINT UNSIGNED NOT NULL,
    PRIMARY KEY (scope_key)
) ENGINE=InnoDB;

例如,用户 42 在 2019 年的序列可以使用:

42:2019

如果该用户的第一张 invoice 要从 2019-0002 开始,初始化时应保存前一个数字 1:

INSERT INTO wp_invoice_sequences (scope_key, current_value)
VALUES ('42:2019', 1);

以后每次先递增,再返回递增后的值。

序列表最好在用户保存起始编号时初始化,不要等到支付回调时临时创建。这样也能明确处理”这个编号系列已经存在”的情况。

使用原子更新生成编号

MySQL 可以通过 LAST_INSERT_ID(expr) 完成原子递增:

UPDATE wp_invoice_sequences
SET current_value = LAST_INSERT_ID(current_value + 1)
WHERE scope_key = '42:2019';

SELECT LAST_INSERT_ID();

数据库会锁定这一行。即使多个支付请求同时执行,每次更新也会依次取得不同的数字。

LAST_INSERT_ID() 的值属于当前数据库连接,所以 UPDATE 和后面的 SELECT 必须使用同一个连接。两者之间不要执行可能改变该值的其他查询。

WordPress 示例

建表时可以使用 WordPress 的 dbDelta():

global $wpdb;

$table = $wpdb->prefix . 'invoice_sequences';
$charset_collate = $wpdb->get_charset_collate();

$sql = "CREATE TABLE {$table} (
    scope_key varchar(191) NOT NULL,
    current_value bigint unsigned NOT NULL,
    PRIMARY KEY (scope_key)
) {$charset_collate};";

require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);

初始化指定用户和年份的序列:

function initialize_invoice_sequence(
    int $user_id,
    int $year,
    int $starting_number
): bool {
    global $wpdb;

    if ($starting_number < 1) {
        throw new InvalidArgumentException(
            'Starting invoice number must be greater than zero.'
        );
    }

    $table = $wpdb->prefix . 'invoice_sequences';
    $scope = sprintf('%d:%d', $user_id, $year);

    $result = $wpdb->insert(
        $table,
        [
            'scope_key'     => $scope,
            'current_value' => $starting_number - 1,
        ],
        ['%s', '%d']
    );

    return $result === 1;
}

如果主键已经存在,初始化就会失败。通常这正是需要的行为。已经启用的编号系列不应直接重置,否则可能再次生成历史编号。

分配下一个编号:

function allocate_invoice_number(
    int $user_id,
    int $year
): string {
    global $wpdb;

    $table = $wpdb->prefix . 'invoice_sequences';
    $scope = sprintf('%d:%d', $user_id, $year);

    $updated = $wpdb->query(
        $wpdb->prepare(
            "UPDATE {$table}
             SET current_value = LAST_INSERT_ID(current_value + 1)
             WHERE scope_key = %s",
            $scope
        )
    );

    if ($updated !== 1) {
        throw new RuntimeException(
            'Invoice sequence does not exist or could not be updated.'
        );
    }

    $sequence = $wpdb->get_var('SELECT LAST_INSERT_ID()');

    if ($sequence === null || !ctype_digit((string) $sequence)) {
        throw new RuntimeException('Could not read the allocated invoice number.');
    }

    return sprintf('%d-%04d', $year, (int) $sequence);
}

创建 custom post type 时,直接使用已经分配的编号:

try {
    $invoice_number = allocate_invoice_number(
        get_current_user_id(),
        2019
    );

    $post_id = wp_insert_post(
        [
            'post_type'   => 'invoice',
            'post_status' => 'publish',
            'post_title'  => $invoice_number,
        ],
        true
    );

    if (is_wp_error($post_id)) {
        throw new RuntimeException($post_id->get_error_message());
    }
} catch (Throwable $e) {
    error_log('Invoice creation failed: ' . $e->getMessage());
}

%04d 表示数字至少补齐到四位,所以 2 会显示为 0002。它不会限制最大位数,编号超过 9999 后仍会正常显示为五位数。

自定义起始编号如何处理

起始编号应当作为一个编号系列的初始化设置:

  • 尚未开具 invoice 时,可以设置任意合法的起始编号。
  • 已经开具 invoice 后,不应允许向后重置。
  • 如果要从更大的编号继续,可以向前推进当前值。
  • 如果要重新从较小的编号开始,应创建新的编号系列,不要覆盖旧系列。

如果允许用户将”下一张编号”提高到某个值,可以使用:

UPDATE wp_invoice_sequences
SET current_value = GREATEST(current_value, 199)
WHERE scope_key = '42:2019';

这样下一次至少会从 200 开始,也不会把已经使用到更高位置的序列调回去。

需要注意的问题

编号可能出现空缺

如果编号已经分配,但后续的 wp_insert_post() 失败,这个编号通常不应再次使用。因此,序列可能有空缺,但不会重复。

如果业务或当地法规要求发票编号完全连续,就需要设计”作废 invoice”记录,或者把编号分配和数据库写入放进同一个事务,不能简单回收失败的编号。具体合规要求应根据业务所在地确认。

所有写入入口必须使用同一个分配器

序列表只能保证通过它分配的数字不重复。如果某些后台代码、导入脚本或管理员仍可手工填写 post_title,标题还是可能重复,因为 wp_posts.post_title 没有唯一索引。

如果需要数据库级别的保护,可以再建立一张 invoice 编号登记表,并为完整编号添加 UNIQUE 约束。

幂等性和编号唯一性是两件事

支付平台可能会针对同一笔支付重复发送回调。原子序列能保证两次回调取得不同的编号,却仍可能错误地创建两张 invoice。

因此,还应保存支付平台的 transaction ID,并通过唯一索引或幂等检查,确保同一笔支付只能创建一次 invoice。

不要使用 transient 或普通 WordPress option 模拟锁

先执行 get_option() 再执行 update_option(),本质上仍是”先读后写”,遇到并发时同样可能丢失更新。Transient 还可能由对象缓存管理,不适合用作可靠的数据库序列。

独立序列表配合原子更新,既能保留自定义起始编号,也不需要修改 WordPress 核心表。对于这个场景,这是直接且稳定的方案。

备注:内容仅供参考。