ASP.NET Core fire-and-forget方法中用DbContext调用存储过程报错如何解决?
解决方案
问题根因
- 单例注册报错:EF Core的
DbContext天生不是线程安全的设计,单例实例被多线程并发调用时会触发线程安全校验异常,官方不推荐任何场景下将普通业务DbContext注册为单例。 - 连接池占满报错:当前每条日志都单独启动异步任务、单独申请DbContext和数据库连接,海量日志场景下并发请求数超过默认连接池上限(默认100),就会出现拿连接超时的问题。
优化方案
首选方案:加异步批量消费逻辑(推荐)
核心思路是用异步队列缓冲日志,后台定时批量写入,大幅降低数据库连接申请频率,从根源解决连接池耗尽问题,完全不影响主流程性能。
实现步骤
- 注册异步队列和后台消费服务,在
Startup.cs/Program.cs中添加:
// 注册无界Channel作为日志缓冲区,单例全局唯一 services.AddSingleton<Channel<LogData>>(Channel.CreateUnbounded<LogData>(new UnboundedChannelOptions { SingleReader = true, // 仅单个后台线程消费,无需处理多线程消费冲突 AllowSynchronousContinuations = false })); // 注册日志批量消费后台服务 services.AddHostedService<LogBatchConsumerService>(); // LogDbContext保持Scoped生命周期即可 services.AddDbContext<LogDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("LogDbProvider")) .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)); // 关闭跟踪提升性能
- 实现后台批量消费服务:
public class LogBatchConsumerService : BackgroundService { private readonly Channel<LogData> _logChannel; private readonly IServiceScopeFactory _scopeFactory; // 可根据实际场景调整批量大小和刷盘间隔 private const int BatchWriteSize = 100; private const int FlushIntervalSeconds = 1; public LogBatchConsumerService(Channel<LogData> logChannel, IServiceScopeFactory scopeFactory) { _logChannel = logChannel; _scopeFactory = scopeFactory; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var logBatch = new List<LogData>(); using var flushTimer = new PeriodicTimer(TimeSpan.FromSeconds(FlushIntervalSeconds)); while (!stoppingToken.IsCancellationRequested) { // 攒够批量大小就退出循环准备写入 while (logBatch.Count < BatchWriteSize && _logChannel.Reader.TryRead(out var logItem)) { logBatch.Add(logItem); } // 有日志就执行批量写入 if (logBatch.Any()) { try { await BatchWriteToDbAsync(logBatch); } catch (Exception ex) { // 这里可以加本地兜底日志,不要向上抛异常避免后台服务退出 File.AppendAllText("log_write_failures.log", $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} 批量写入失败:{ex.Message}\r\n"); } finally { logBatch.Clear(); } } // 等待固定间隔后再次检查,避免空跑占CPU await flushTimer.WaitForNextTickAsync(stoppingToken); } } private async Task BatchWriteToDbAsync(List<LogData> logs) { using var scope = _scopeFactory.CreateScope(); var logContext = scope.ServiceProvider.GetRequiredService<LogDbContext>(); // 复用同一个DbContext和数据库连接写入整个批次,大幅减少连接占用 foreach (var log in logs) { var parms = new List<SqlParameter> { new SqlParameter("@ErrorId", log.ErrorIdentifier), new SqlParameter("@Application", log.Application), new SqlParameter("@Host", log.Host), new SqlParameter("@Type", log.Type), new SqlParameter("@Source", log.Source), new SqlParameter("@Message", log.Message), new SqlParameter("@User", log.User), new SqlParameter("@ThreadID", log.ThreadID), new SqlParameter("@Process", log.Process), new SqlParameter("@stackTrace", log.StackTrace), new SqlParameter("@eventid", log.EventId), new SqlParameter("@category", log.Category), new SqlParameter("@TimeUtc", log.TimeUtc) }; const string sql = "EXEC [AppObject].[usp_LogError] @ErrorId, @Application, @Host, @Type, @Source, @Message, @User, @ThreadID, @Process, @stackTrace, @eventid, @category, @TimeUtc"; await logContext.Database.ExecuteSqlRawAsync(sql, parms.ToArray()); } } }
- 修改日志入口方法,删除原来的
Task.Run逻辑,直接写入队列即可:
// 注入Channel<LogData>到你的日志类中 private readonly Channel<LogData> _logChannel; private void Log(LogCategory category, string message, ProcessType type) { var log = BuildErrorObj(category, message, string.Empty, null, type); // 写入内存队列,完全不阻塞主流程 _logChannel.Writer.TryWrite(log); }
临时缓解方案(不推荐长期使用)
如果暂时不想改现有逻辑,可以在数据库连接字符串中加大连接池上限,比如添加Max Pool Size=200,但仅能缓解并发不高的场景,海量日志下还是会出现连接耗尽问题。
其他注意事项
- Fire-and-forget场景下不要捕获异常后重新抛出,未被观察的Task异常会导致进程崩溃,建议加本地文件兜底日志。
- 不要每条日志都调用
Task.Run,会额外消耗线程池资源,高并发下容易导致线程池饥饿。 - 如果可以修改存储过程,建议添加表值参数支持,整个批次的日志一次性传入数据库执行,性能比循环单条执行高10倍以上。
内容的提问来源于stack exchange,提问作者Mousam
相关产品推荐
相关产品推荐

