如何创建唯一的 billing identifier
结论
不要用 SELECT MAX(billing_id) + 1 生成编号。两个并发请求可能读到相同的最大值,进而生成相同的 billing identifier。
更稳妥的方案是:
- 使用数据库的
AUTO_INCREMENT主键生成唯一数字。 - 插入记录后,通过
insert_id()取得本次插入生成的 ID。 - 根据 ID、年份和固定前缀生成 billing identifier。
- 为
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。
备注:内容仅供参考。
