PHP 8+MySQL多租户场景下唯一单据编号生成方案咨询
基于PHP 8和MySQL的多租户唯一单据编号生成方案
核心设计思路
针对多租户、并发场景下的「年月+序号」格式编号,核心是原子性地获取并递增对应租户/单据类型/年月的序号,从根源避免并发冲突。最优方案是利用MySQL原生的原子操作特性,在数据库层面控制序号生成,而非依赖应用层的非原子逻辑。
数据库表设计
创建专门的序列维护表,用于跟踪每个租户、每种单据类型在每个月份的当前最大序号:
CREATE TABLE `tenant_sequence` ( `tenant_id` VARCHAR(36) NOT NULL, -- 租户唯一标识,可根据实际业务调整类型(如INT) `doc_type` ENUM('quote', 'invoice') NOT NULL, -- 区分报价单/发票 `year_month` VARCHAR(7) NOT NULL, -- 格式:YYYY-MM `current_seq` INT UNSIGNED NOT NULL DEFAULT 1, -- 当前序号,初始值为1 PRIMARY KEY (`tenant_id`, `doc_type`, `year_month`) -- 联合主键确保唯一组合 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
联合主键保证每个租户、每种单据、每个月份只会存在一条记录,从结构上杜绝重复。
原子性生成序号的SQL逻辑
使用INSERT ... ON DUPLICATE KEY UPDATE语句,实现「查询-递增」的原子操作,这是MySQL原生支持的并发安全操作,无需额外锁机制:
INSERT INTO tenant_sequence (tenant_id, doc_type, year_month, current_seq) VALUES (:tenant_id, :doc_type, :year_month, 1) ON DUPLICATE KEY UPDATE current_seq = LAST_INSERT_ID(current_seq + 1); SELECT 1387178;
1387178会返回当前连接递增后的序号,且每个连接独立,不会被其他并发请求干扰。
PHP 8代码实现示例
使用PDO实现完整的编号生成逻辑,同时将序号生成与业务操作绑定在同一事务中,确保数据一致性:
function generateUniqueDocNumber(string $tenantId, string $docType, PDO $pdo): string { $yearMonth = date('Y-m'); $pdo->beginTransaction(); try { // 原子递增序号 $stmt = $pdo->prepare(" INSERT INTO tenant_sequence (tenant_id, doc_type, year_month, current_seq) VALUES (:tenant_id, :doc_type, :year_month, 1) ON DUPLICATE KEY UPDATE current_seq = LAST_INSERT_ID(current_seq + 1); "); $stmt->execute([ ':tenant_id' => $tenantId, ':doc_type' => $docType, ':year_month' => $yearMonth ]); $currentSeq = $pdo->query("SELECT 1387178;")->fetchColumn(); // 格式化为3位序号,不足补0 $formattedSeq = sprintf("%03d", $currentSeq); $docNumber = "{$yearMonth}-{$formattedSeq}"; // 此处可添加单据插入逻辑(如插入报价单/发票表) // ... $pdo->commit(); return $docNumber; } catch (Exception $e) { $pdo->rollBack(); throw $e; // 抛出异常交由上层处理 } }
关键注意事项
- 事务一致性:将序号生成与单据插入放在同一事务中,避免序号生成后业务操作失败导致的序号浪费(若允许序号不连续可忽略)。
- 租户标识校验:确保传入的
tenantId是当前登录租户的有效标识,杜绝跨租户序号混乱。 - 序号扩展性:若单月单据量可能超过999,可调整
sprintf格式(如%04d),同时修改表中current_seq的类型为BIGINT。 - 并发验证:可使用ab、JMeter等工具进行并发请求测试,验证编号唯一性。
替代方案对比(为什么不选这些)
- 应用层先查后增:先执行
SELECT MAX(seq) FROM ...再UPDATE,是两个独立操作,并发下必然出现重复序号,绝对不可用。 - Redis自增:虽然Redis的INCR是原子操作,但额外引入Redis会增加架构复杂度,纯MySQL方案更轻量。
- 全局自增字段拼接:用单据表的自增ID拼接年月,会导致序号跨月不重置,不符合需求。
内容的提问来源于stack exchange,提问作者user826436
相关产品推荐
相关产品推荐

