多次执行时如何生成无重复的自增 invoice number
结论
不要查询最新一张 invoice 后再加 1,也不要依赖 post_title 保证唯一性。更可靠的方式是增加一张独立的序列表,由数据库通过原子 UPDATE 分配编号。
这种做法不需要修改 WordPress 的 wp_posts 表。序列表只生成数字,最终得到的 2019-0002 仍可写入 custom post type 的 post_title。
为什么现有做法会产生重复编号
下面的逻辑存在典型的 race condition:
- 请求 A 读取当前最大编号
2019-0001。 - 请求 B 同时也读取到
2019-0001。 - 两个请求都算出下一个编号是
2019-0002。 - 两条 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 核心表。对于这个场景,这是直接且稳定的方案。
备注:内容仅供参考。
