Skip to content
bitzorcas
中EN

Guide

Numbering 计数、接续、补偿与并发

解释 MAX+1 算法、事实表唯一约束、三次冲突重试、业务表回退、占位与补偿,并发和事务真实边界。

Last updated

Numbering 的安全核心不在格式化,而在同一租户、业务字段和前缀下如何选取下一个数。当前实现依赖事实表与最终唯一约束,不是数据库序列。

1. 计数分区

事实查询键是 TableName + Field + Prefix;Tenant 由 adapter 自动加入。Prefix 包含所有非序号段,因此年月、分支或业务字段变化会形成独立区间。

是否是否

Tenant + Table + Field + Prefix

读取全部匹配事实

MAX > 0?

MAX + 1

有 BusinessSerialContext?

业务表 MAX + 1

1

实现用 ListAsync 把匹配事实加载到内存再 Max,不是数据库 MaxAsync。在大分区下会产生读放大。

2. 插入事实

新事实保存 TableName、Field、SequenceNumber、完整串、Prefix、IsActivated、IsSubNumber 和 ParentSequenceNumber。唯一索引只约束完整字符串,不约束 Prefix+SequenceNumber。

如果规则格式改变,两个不同数值可能格式化成相同字符串,仍由唯一索引拒绝。

3. 并发冲突过程

GenerateRecordCaller BCaller AGenerateRecordCaller BCaller Aread MAX=7read MAX=7insert INV-000008successinsert INV-000008unique violationreread MAX and retry, at most 3

这能在理想事务可见性下避免已提交重复串,但不等同原子 increment。冲突后事务是否仍可继续取决于 adapter、数据库和外层事务行为。

4. 异常识别

IsUniqueConstraintViolation 把异常消息转大写,匹配 UNIQUE CONSTRAINT、UNIQUE KEY、DUPLICATE KEY、CANNOT INSERT DUPLICATE KEY、2627 或 2609。

它不检查结构化 provider error code,也没有 unwrap inner exception。不同语言、不同数据库或包装异常可能无法进入重试。

5. 三次重试

只有 _records.AddAsync 被唯一冲突 catch。业务表判重也最多循环三次,但它没有插入占位来推进 MAX;若业务表已存在候选而事实表没有对应更高记录,每次重新读取相同 MAX,可能连续生成同一个冲突串并最终失败。

这意味着“业务表不一致时自动跳过冲突号”的源码注释比真实算法更强。

6. 业务表 MAX 接续

事实分区完全为空时,Generator 可调用业务 owner 提供的:

安全实现业务表接续端口
public sealed class InvoiceSerialContext(IInvoiceReadStore invoices)
: IBusinessSerialContext
{
// ① 业务 owner 自己解析前缀并执行参数化查询。
public Task<long> GetMaxSequenceFromBusinessTableAsync(
string prefix, CancellationToken cancellationToken)
=> invoices.GetMaxSerialCounterAsync(prefix, cancellationToken);
// ② Numbering 不获得表名 SQL 权限,只得到布尔判重结果。
public Task<bool> ExistsInBusinessTableAsync(
string value, CancellationToken cancellationToken)
=> invoices.SerialExistsAsync(value, cancellationToken);
}

不要根据 request.TableName 拼 SQL;接口设计正是为避免动态表名注入。

7. 接续的竞态

两个首次调用可能同时发现事实表为空、读到相同业务 MAX 并竞争插入。唯一约束可让一个失败并重试;若外层事务隔离看不到对方提交,仍可能耗尽三次。

初始化/迁移最好预建计数事实或在专用事务中建立水位,而不是把首次并发接续留给请求路径。

8. 占位模式

SequenceNumberRequest.IsEnabled=false 会插入 IsActivated=false 的事实。它仍参与 MAX,所以默认规则下该号形成空洞,不会再次分配。

源码没有公开“激活指定占位”API;只有补偿规则可自动取最小未激活记录。

9. 断号补偿

IsCompensate=true 时只在第一轮执行:按同一 Table/Field/Prefix 找未激活、未删除事实,取 SequenceNumber 最小项,设置 IsActivated=request.IsEnabled 和 ModifyTime,再返回。

如果请求仍传 false,该记录保持未激活,下一次可能再次被补偿。

10. 补偿竞态

补偿是 List→First→Update,没有基于版本/IsActivated 的条件更新。并发请求可能选择同一记录并都返回成功。该路径也不做业务表判重。

商业场景必须用原子 claim(条件更新或锁)并检查影响行数,成功后再返回。

11. 父号与子号

ParentSequenceNumber 非空只会设置 IsSubNumber=true 并保存父字符串。它不验证父号存在、租户一致、状态、层级或格式,也不参与计数分区。

因此当前是关联元数据,不是受不变量保护的子号体系。

12. 事务边界

ISequenceNumberGenerator 注释说实现不自管理事务,由 Command 的 TransactionPipelineBehavior 包裹。但当前仓库没有生产 Command 调用证据。

若生成事实与业务聚合不在同一数据库/事务,可能出现已占号但业务写失败,或业务写成功但事实提交失败。是否允许空号必须由产品明确。

13. 推荐集成顺序

在业务命令事务中分配并固化
// ① Handler 由事务管线包围时,先验证业务输入。
var allocation = await numbering.GenerateAsync(request, cancellationToken);
if (allocation.IsFailure)
return Result.Failure(allocation.Error);
// ② 把返回字符串作为业务事实保存,不在读取时重新生成。
invoice.AssignSerialNumber(allocation.Value!.SequenceNumberStr);
await invoices.AddAsync(invoice, cancellationToken);
// ③ 若要求无空号,仍需证明事实表和业务表共享同一原子事务。
return Result.Success();

14. 生产加固方向

优先考虑 provider-neutral 的 compare-and-swap/原子水位端口,而不是继续加载全分区行。补偿也应使用相同原子 claim。唯一完整串约束仍保留为最终防线。

15. 核查命令

Terminal window
# MAX+1、业务回退、补偿与异常字符串。
sed -n '200,360p' src/Platform/Numbering/*Infrastructure/SequenceNumber/NumberingSequenceNumberStore.cs
# 当前无锁、无原子 increment、无条件 claim。
rg -n "DistributedLock|Interlocked|Increment|CompareAndSwap|UpdateWhereAsync" src/Platform/Numbering -g '*.cs'

模块总览 · 测试与 GA

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%