Numbering 的安全核心不在格式化,而在同一租户、业务字段和前缀下如何选取下一个数。当前实现依赖事实表与最终唯一约束,不是数据库序列。
1. 计数分区
事实查询键是 TableName + Field + Prefix;Tenant 由 adapter 自动加入。Prefix 包含所有非序号段,因此年月、分支或业务字段变化会形成独立区间。
实现用 ListAsync 把匹配事实加载到内存再 Max,不是数据库 MaxAsync。在大分区下会产生读放大。
2. 插入事实
新事实保存 TableName、Field、SequenceNumber、完整串、Prefix、IsActivated、IsSubNumber 和 ParentSequenceNumber。唯一索引只约束完整字符串,不约束 Prefix+SequenceNumber。
如果规则格式改变,两个不同数值可能格式化成相同字符串,仍由唯一索引拒绝。
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. 核查命令
# 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'