基于表生成事务ID的高并发控制优化方案咨询
高并发场景下事务ID生成的优化方案
背景
现有系统基于表生成事务ID,因ID需符合特定格式无法使用数据库原生序列;且事务类型数量可变,需支持动态创建序列。表结构定义如下:
public class TransactionSequence : BaseEntity<int>, IAggregateRoot { public int Year { get; set; } public string Prefix { get; set; } public long Sequence { get; set; } public string Service { get; set; } public int Length { get; set; } public bool IsCurrent { get; set; } }
事务ID生成服务核心代码片段:
public class NumberingService : INumberingService { public static int yearLength = 2; public static int monthLength = 2; public static int dayLength = 2; private readonly IRepository<TransactionSequence> _repository; private readonly NumberingConfiguration _numbering; public NumberingService(IRepository<TransactionSequence> repository, NumberingConfiguration numbering) { _repository = repository; _numbering = numbering; } public async Task<Result<string>> GetNextNumberAsync(string service, int maxlength, UserEntity sysUser, string prefix = "") { string transactionId = string.Empty; try { var spec = new CurrentYearNumberingSpec(service); var sequence = await _repository.GetBySpecAsync(spec); if (sequence == null) { await AddServiceNumberingAsync(service, maxlength, sysUser, prefix); sequence = await _repository.GetBySpecAsync(spec); } sequence.Sequence = sequence.Sequence + 1; await _repository.UpdateAsync(sequence); int month = DateTime.Now.Month; int day = DateTime.Now.Day; var length = GetLength(sequence); transactionId = sequence.Prefix + (sequence.Year % 100).ToString("D" + 2) + month.ToString("D" + 2) + day.ToString("D" + 2) + sequence.Sequence.ToString("D" + length); } catch (Exception ex) { return Result<string>.Error(ex.Message); } return Result<string>.Success(transactionId, "Retrieved the next number in the sequence succesfully!"); } private static int GetLength(TransactionSequence sequence) { return sequence.Length - sequence.Prefix.Length - dayLength - monthLength - yearLength; } }
核心问题
系统处于高并发场景,每个请求都需获取事务ID,导致当前活跃的TransactionSequence行存在严重锁竞争,性能瓶颈明显。
已尝试方案
- 带重试的ROWVERSION乐观并发:冲突频繁触发,性能最差
- SemaphoreSlim锁:单实例性能尚可,但无法在负载均衡多实例场景下扩展
- Redis分布式锁:性能与SemaphoreSlim接近,但未达到预期最优效果
- 预取大小为1的RabbitMQ队列:性能优于上述方案,但仍有优化空间
- HiLo算法:仅了解概念,尚未实现
推荐优化方案
基于ASP.NET Core 6、EF Core 6、SQL Server 2019环境,以下是成熟的高优解决方案:
1. 批量预取式HiLo算法(最优推荐)
核心逻辑是减少数据库交互次数:每次从数据库批量获取一段连续的序列范围(如一次取100个),应用内维护本地计数器,用完该批次后再去数据库更新序列。这种方式能将行锁竞争频率降低99%以上。
实现步骤:
- 表结构复用:无需新增字段,直接用
Sequence字段记录当前已分配的最大值(即最后一个被取走的序列值)。 - 原子批量更新:使用EF Core 6的
ExecuteUpdateAsync实现原子性的序列范围获取与更新,避免长时间持有行锁:
public async Task<(long BatchStart, long BatchEnd)> GetBatchSequenceAsync(string service, int batchSize = 100, UserEntity sysUser = null, string prefix = "") { var spec = new CurrentYearNumberingSpec(service); var sequence = await _repository.GetBySpecAsync(spec); if (sequence == null) { await AddServiceNumberingAsync(service, /* 对应maxlength参数 */, sysUser, prefix); sequence = await _repository.GetBySpecAsync(spec); } // 原子更新并获取更新前的旧值 var oldSequenceValue = sequence.Sequence; await _repository.Set() .Where(s => s.Id == sequence.Id) .ExecuteUpdateAsync(s => s.SetProperty(e => e.Sequence, e => e.Sequence + batchSize)); // 返回本次可用的序列范围 return (oldSequenceValue + 1, oldSequenceValue + batchSize); }
- 应用内本地缓存:为每个
service+Year组合维护本地计数器,当本地序列耗尽时,再调用批量获取接口。多实例场景下,每个实例获取不同批次,不会出现ID重复。
2. SQL Server序列+自定义格式拼接
若ID格式允许通过拼接实现,可直接利用SQL Server原生序列的高性能:
- 为每个事务类型动态创建序列(可通过存储过程自动创建),序列支持按年重置(使用
ALTER SEQUENCE ... RESTART WITH 1)。 - 在应用层或数据库视图中,将序列值与前缀、年/月/日拼接成最终事务ID。
- 优势:完全利用数据库原生高并发支持,实现简单,无锁竞争问题。
3. 变种Snowflake分布式ID生成
如果ID格式可灵活调整,可采用Snowflake算法变种:
- 将Snowflake结构中的时间戳部分替换为年/月/日的格式化字符串,前缀保留业务标识,机器ID用实例唯一标识(如容器ID、IP哈希),序列号保证单实例内并发唯一性。
- 优势:完全无数据库依赖,分布式场景下性能极高,无锁竞争。
方案对比
| 方案 | 性能 | 扩展性 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 批量预取HiLo | 极高 | 支持多实例负载均衡 | 中等 | ID格式严格固定,高并发场景 |
| SQL Server序列+拼接 | 高 | 支持多实例 | 低 | ID格式可通过拼接实现 |
| 变种Snowflake | 极高 | 全分布式无依赖 | 中等 | ID格式可调整,超高并发场景 |
内容的提问来源于stack exchange,提问作者Saleh Omar
相关产品推荐
相关产品推荐

