分维度流水号取号器(并发安全)
触发场景
- 新增一套业务编号规则(合同号 / 订单号 / 工单号 / 结算单号)。
- 排查编号重号或跳号(号被消耗但业务没落库)。
- 现有实现是"先 SELECT MAX 再 +1",需要改成并发安全。
编号形态先定清楚
先写死结构,再决定每一段怎么来:
HT-{主体码}-{类型码}-{YYYYMM}-{4位流水}
└HT 固定前缀
└MX 主体码 —— 展示值,可空、可重复(见下方"规范化")
└CG 类型码 —— 只做格式校验,枚举 SSOT 放常量表,不硬编码在代码里
└202609 周期维度
└0037 流水 —— 事务内取,永久不可变
周期维度用「取号时的年月」,不是业务日期。跨月补号/补录会自然落到当前月份的号段,这是可接受的;若业务要求按业务日期归月,必须显式传入 period。
三层防重号(缺一层都可能出问题)
| 层 | 做法 | 兜住什么 |
|---|---|---|
| ① 唯一键维度 | UNIQUE(dimension_1, dimension_2, period) 放在序列计数器表上 |
同维度并发串行化 |
| ② 行锁 + 同事务 | SELECT ... FOR UPDATE 递增,且与业务写入同一事务 |
跨维度互不干扰;避免"烧号" |
| ③ 业务表唯一索引 | 业务表的 xx_no 上加 UNIQUE,捕获冲突后重试(2–3 次) |
代码缺陷 / 人工改库 |
🔴 维度必须用主键,不能用可空展示码
这是最常见的错误来源。反面例子:
# ✗ 错误:用 organization_code 做维度
UNIQUE(org_code, type_code, period) # org_code 可空、无唯一约束
后果:① 未填 code 的组织 → 编号退化成 HT--CG-202609-0001(含空段);
② 两个组织填了同一个 code → 共享同一计数器,并发下必然重号。
# ✓ 正确:用主键做维度,展示码只用来拼串
UNIQUE(tenant_id, type_code, period)
🔴 与业务写入必须同事务
取号不得单独提交。若取号 commit 后业务写入失败,号被消耗而业务未落库 → 编号出现空洞(在审计/对账场景会被当成漏号调查)。
async def create_contract(session, ...):
no = await next_contract_no(session, ...) # 不加锁提交,只递增
session.add(Contract(contract_no=no, ...))
await session.commit() # 一次提交,同生共死
🔴 首行创建竞态:用 SAVEPOINT 化解
维度行首次取号时还不存在,两个并发事务会同时 INSERT,其中一个必然撞唯一键。
不能用 session.rollback() —— 那会把调用方正在进行的业务写入一起丢掉。要用保存点:
async def _insert_sequence_row(session, ...) -> Row | None:
row = Sequence(...)
try:
async with session.begin_nested(): # SAVEPOINT
session.add(row)
await session.flush()
return row
except IntegrityError:
return None # 败给并发,转加锁重读
随后必须用加锁读重新获取:
row = await _lock_sequence_row(session, ...) # SELECT ... FOR UPDATE
为什么必须加锁读:MySQL 默认隔离级别是 REPEATABLE READ,普通 SELECT 走一致性快照,
看不到并发事务刚提交的行;而锁定读(FOR UPDATE)总是读取最新已提交版本。
用普通 SELECT 重读会取到 None,进而误判为"行不存在"。
完整流程:
row = await _lock_sequence_row(session, ...)
if row is None:
row = await _insert_sequence_row(session, ...)
if row is None:
row = await _lock_sequence_row(session, ...) # 加锁重读
if row is None:
raise SequenceAllocationError(...) # 异常情况,需人工介入
row.current_seq += 1
await session.flush()
return build_no(..., row.current_seq)
展示码规范化(易踩)
组织编码类的展示字段常常自带分隔符(如 YX-001)。裸嵌入会破坏编号结构:
HT-YX-001-CG-202609-0037 ← 段数不定,无法解析
规范化为只含大写字母数字,并设长度上限:
cleaned = re.sub(r"[^A-Z0-9]", "", (code or "").strip().upper())[:8]
return cleaned or f"T{tenant_id}" # 缺失/规范化后为空 → 回退
残留风险:规范化后可能撞码
两个组织编码规范化后相同(如 YX-001 与 YX001)→ 产出完全相同的编号串 →
第 ③ 层持续冲突且重试难收敛。这属数据治理,代码兜不住,必须提供上线前的检测 SQL:
SELECT UPPER(REPLACE(REPLACE(REPLACE(code,'-',''),' ',''),'_','')) AS norm_code,
COUNT(*) n, GROUP_CONCAT(id) ids
FROM tenants WHERE code IS NOT NULL AND TRIM(code) <> ''
GROUP BY norm_code HAVING COUNT(*) > 1; -- 结果应为空
流水位宽
按 4 位左补零,但超过 9999 不要截断。截断会破坏唯一性;编号列给足长度(如 VARCHAR(64))即可,
超过 4 位时自然变 5 位:
f"{seq:04d}" # 9999 → '9999',10000 → '10000'(不截断)
预览接口(不占号)
表单上要显示"下一个编号",但不能真的占用。做法:不加锁、不递增、不建行,只读 current_seq + 1 拼串。
⚠️ 必须在接口与前端都说明:预览值仅供参考,并发下可能与实际取号不同,不得当承诺。
实际编号以创建接口的返回值为准。
服务分层(可迁移性)
取号服务只依赖本模块自己的模型,不要 import 组织/用户等其他模块的 Model。
展示码由调用方经端口(Port)解析后作为参数传入:
async def next_contract_no(session, *, tenant_id, contract_type,
tenant_code: str | None = None, # 由调用方经端口取得
period: str | None = None) -> str:
好处:服务零外部依赖 → 单测不需要起外部表;将来模块剥离时不用改取号逻辑。
另外:不自行 commit(事务边界归端点层)、不读全局状态(当前时间用可注入的 now 参数)。
验证:20 并发零重号(必须真机跑)
纯函数部分本地可跑;并发部分必须连真库。若本地没有 DB 配置,在服务器上做隔离运行:
# 1) 复制线上 app 到临时目录(绝不动线上目录)
cp -r /var/www/<app>/app /tmp/ctest/app
# 2) 覆盖本次新增/修改的文件
cp -r overlay/app/contracts /tmp/ctest/app/
cp -f overlay/app/models/__init__.py /tmp/ctest/app/models/__init__.py
# 3) 用环境变量提供 DB 配置(不落盘密钥副本)
set -a; . /var/www/<app>/.env; set +a
export APP_DIR=/tmp/ctest
cd /tmp/ctest && /var/www/<app>/venv/bin/python /tmp/overlay/test_xxx.py
测试表用临时前缀(如 zzz_seqtest_):在测试进程里改 Model.__table__.name,再 table.create(sync_conn),
finally 里 DROP 并校验残留为 0。
断言清单:
[ ] N 个并发事务全部成功(无异常)
[ ] 返回编号去重后 = N
[ ] 业务表 COUNT(*) = N 且 COUNT(DISTINCT xx_no) = N
[ ] SQL 层 GROUP BY xx_no HAVING COUNT(*)>1 → 空
[ ] 计数器 current_seq = N
[ ] 序列计数器表仅 1 行(同维度)
[ ] 维度隔离:同主体异类型 / 异主体同类型,各自从 0001 起
[ ] 流水连续 1..N,无空洞、无 0 号
[ ] preview 连续两次相同,且未在计数器表建行
[ ] 临时表清理后残留 0
并发数要大于连接池上限才有意义。若用应用运行时连接池(常见 pool_size=5, max_overflow=5),
20 并发会排队、退化接近串行 —— 测试应使用独占引擎并放大池(如 pool_size=20, max_overflow=0),
以获得真实的锁竞争。
易错点清单
| # | 易错点 | 后果 |
|---|---|---|
| 1 | 唯一键用可空展示码做维度 | 空值致编号含空段;重复值致共享计数器、并发重号 |
| 2 | 取号后单独 commit | 业务写入失败时"烧号",编号出现空洞 |
| 3 | 首行 INSERT 撞唯一键后 session.rollback() |
连调用方的业务写入一起回滚,数据丢失 |
| 4 | 保存点回滚后用普通 SELECT 重读 | REPEATABLE READ 快照看不到并发刚提交的行 → 误判不存在 |
| 5 | 展示码未规范化(保留连字符) | 编号段数不定、无法解析 |
| 6 | 未做展示码重复检测 | 两组织产出相同编号串,唯一索引持续冲突 |
| 7 | 流水 str(seq).zfill(4)[:4] 之类截断 |
超 9999 后重号 |
| 8 | 预览接口真的递增/建行 | "只是看一下"就消耗了号 |
| 9 | 类型码枚举硬编码在代码里 | 枚举变更要改代码;违背常量 SSOT |
| 10 | 并发测试的并发数小于连接池上限 | 测出来"通过",实际是串行,没验证到竞态 |