把日志写进数据库看起来很直接:INSERT 一下不就行了?真正跑起来才会遇到三个问题------数据库慢/挂了怎么办、内存会不会涨爆、日志本身会不会变成故障源。框架的答案是有界队列 + 后台批量写入 + 退避重试。
一、整体链路
text
业务代码 ILogger.LogError(...)
↓ DatabaseLogger.Log
↓ 补充上下文(TraceId / RequestPath / UserId / TenantCode)
↓ TryEnqueue(有界 Channel,容量 5000,写满丢最旧)
↓ DatabaseLoggerBackgroundService(BackgroundService)
↓ 攒批(BatchSize = 100)或到点(FlushInterval = 5s)
↓ FreeSql 批量插入 SysLog
↓ 失败 → 指数退避重试(2s → 最大 2min),日志进有界重试缓冲
核心设计:写日志的调用方永远不等待数据库。 这句决定了后面所有细节。
二、为什么必须有界
如果用一个无界队列,数据库一旦不可用:
text
数据库挂了 → 队列只进不出 → 内存持续增长 → 应用 OOM 崩溃
→ 而崩溃又让数据库看起来"没问题"(因为没人在写)
所以队列是固定容量的有界 Channel:
csharp
/// <summary>
/// 数据库日志的有界队列。
///
/// 队列容量固定(默认 5000),写满时按 DropOldest 策略丢弃最旧的日志,
/// 保证内存占用有上界;被丢弃的条数通过 DroppedCount 暴露以便监控。
/// </summary>
public sealed class DatabaseLoggerQueue
{
/// <summary>队列容量,达到上限后丢弃最旧日志</summary>
public const int Capacity = 5000;
private long _droppedCount;
private readonly Channel<DatabaseLogEntry> _channel = Channel.CreateBounded<DatabaseLogEntry>(new BoundedChannelOptions(Capacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
});
/// <summary>因队列已满而被丢弃的日志条数</summary>
public long DroppedCount => Interlocked.Read(ref _droppedCount);
}
| 配置 | 值 | 含义 |
|---|---|---|
Capacity |
5000 | 内存中最多缓存 5000 条日志 |
FullMode |
DropOldest |
写满时丢最旧的,优先保留最近日志 |
SingleReader |
true | 只有一个后台服务读,允许内部优化 |
SingleWriter |
false | 多线程都能写日志(必然如此) |
为什么丢最旧的而不是最新的?出故障时"最近发生了什么"更有价值;而且丢最旧的不需要阻塞正在写日志的线程。
DroppedCount 是给监控用的:日志开始被丢弃,说明数据库写入跟不上,这本身就是一个需要告警的信号。
三、写日志时补充上下文
csharp
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel)) return;
var message = formatter(state, exception);
// 尽力补充请求上下文(TraceId / 路径 / 用户 / 租户)。
// 该补充过程绝不能抛异常,否则日志本身会变成故障源。
string? traceId = null;
string? requestPath = null;
long? userId = null;
string? tenantCode = null;
try
{
var httpContext = ResolveHttpContext();
if (httpContext != null)
{
traceId = System.Diagnostics.Activity.Current?.Id ?? httpContext.TraceIdentifier;
requestPath = httpContext.Request?.Path.Value;
}
var adminContext = _serviceProvider?.GetService(typeof(AdminContext)) as AdminContext;
if (adminContext != null)
{
userId = adminContext.User?.Id;
tenantCode = adminContext.TenantCode;
}
}
catch (ObjectDisposedException)
{
// 作用域已释放,忽略上下文补充
}
catch (InvalidOperationException)
{
// 无法解析作用域服务,忽略上下文补充
}
_queue.TryEnqueue(new DatabaseLogEntry(...)
{
TraceId = Truncate(traceId, 100),
RequestPath = Truncate(requestPath, 500),
UserId = userId,
TenantCode = Truncate(tenantCode, 50),
EventId = eventId.Id
});
}
这段代码体现了三条纪律:
- 上下文补充失败不能影响写日志。整个块被 try/catch 包住,只捕获
ObjectDisposedException/InvalidOperationException(作用域释放、无法解析服务等预期情况)。 - 所有字段都截断(
Truncate),避免超长消息把数据库列写爆:消息 2000、异常 12000、路径 500、TraceId 100。 - TraceId 优先取
Activity.Current?.Id,没有分布式追踪时退化为HttpContext.TraceIdentifier------这条让"用户看到的错误提示"和"服务端日志"能对上(第 18 篇里GenericFailure返回的 TraceId 是同一套思路)。
落库的实体字段
csharp
public class SysLog : Entity<long>
{
[DisplayName("异常时间")] public DateTime CreatedTime { get; set; }
[Column(StringLength = 50)] public string LogLevel { get; set; } = string.Empty;
[Column(StringLength = 200)] public string Category { get; set; } = string.Empty;
[Column(StringLength = 2000)] public string Message { get; set; } = string.Empty;
[Column(StringLength = -2)] public string Exception { get; set; } = string.Empty;
[Column(StringLength = 100)] public string? TraceId { get; set; }
[Column(StringLength = 500)] public string? RequestPath { get; set; }
public long? UserId { get; set; }
[Column(StringLength = 50)] public string? TenantCode { get; set; }
public int? EventId { get; set; }
}
注意 TenantCode 这一列:出问题时能直接筛出"是哪个租户报的错",这是多租户场景排查的关键字段。
四、后台批量写入
csharp
public sealed class DatabaseLoggerBackgroundService(DatabaseLoggerQueue queue, MainOrmHandle mainOrmHandle) : BackgroundService
{
private const int BatchSize = 100;
private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(5);
/// <summary>数据库不可用时的初始退避时间</summary>
private static readonly TimeSpan InitialBackoff = TimeSpan.FromSeconds(2);
/// <summary>退避时间上限,避免长时间不落库</summary>
private static readonly TimeSpan MaxBackoff = TimeSpan.FromMinutes(2);
/// <summary>
/// 数据库持续失败时本地缓冲区上限。达到上限后丢弃最旧的日志(DropOldest),
/// 保证内存不会无限增长,同时优先保留最近、最相关的日志。
/// </summary>
private const int MaxRetryBuffer = 2000;
}
主循环等两件事:攒够 100 条,或者距上次写满 5 秒:
csharp
using var timer = new PeriodicTimer(FlushInterval);
var readTask = _queue.ReadAsync(stoppingToken).AsTask();
var timerTask = timer.WaitForNextTickAsync(stoppingToken).AsTask();
while (!stoppingToken.IsCancellationRequested)
{
var completedTask = await Task.WhenAny(readTask, timerTask);
if (completedTask == readTask)
{
AppendBounded(buffer, retryBuffer, await readTask);
if (buffer.Count >= BatchSize)
{
var succeeded = await FlushAsync(buffer, retryBuffer, stoppingToken);
backoff = await AdjustBackoffAsync(succeeded, backoff, stoppingToken);
}
readTask = _queue.ReadAsync(stoppingToken).AsTask();
}
else
{
if (await timerTask)
{
var succeeded = await FlushAsync(buffer, retryBuffer, stoppingToken);
backoff = await AdjustBackoffAsync(succeeded, backoff, stoppingToken);
timerTask = timer.WaitForNextTickAsync(stoppingToken).AsTask();
}
else break;
}
}
这个"批 or 超时"的模式是日志落库的标准做法:
- 日志多时按量刷,避免每条一次
INSERT; - 日志少时按时间刷,避免日志在内存里待太久(最多 5 秒);
- 用
PeriodicTimer而不是Task.Delay循环,避免累积漂移。
再有一层有界缓冲
csharp
private static void AppendBounded(List<DatabaseLogEntry> buffer, Queue<DatabaseLogEntry> retryBuffer, DatabaseLogEntry entry)
{
if (buffer.Count >= MaxRetryBuffer)
{
buffer.RemoveAt(0);
}
buffer.Add(entry);
// 队列越长说明数据库越不可用,此时同步压缩重试缓冲,避免内存持续增长
while (retryBuffer.Count + buffer.Count > MaxRetryBuffer && retryBuffer.Count > 0)
{
retryBuffer.Dequeue();
}
}
为什么 Channel 已经有界了,本地缓冲还要再有界?因为数据库写入失败时,日志会从队列挪到本地缓冲等待重试。如果缓冲无界,一样会涨爆。所以:
text
Channel 队列 5000 ← 对"业务写日志"的缓冲
本地重试缓冲 2000 ← 对"数据库故障"的缓冲
两者都是 DropOldest
两层加起来,内存占用有明确上界------这正是"有界队列"的核心价值。
五、数据库不可用时的退避
csharp
private static async Task<TimeSpan> AdjustBackoffAsync(bool succeeded, TimeSpan backoff, CancellationToken stoppingToken)
{
if (succeeded) return InitialBackoff;
var next = backoff * 2;
if (next > MaxBackoff) next = MaxBackoff;
// 退避等待,避免数据库不可用时持续高频重试
try
{
await Task.Delay(backoff, stoppingToken);
}
catch (OperationCanceledException)
{
// 停止中,直接返回下次退避时间
}
return next;
}
退避序列:2s → 4s → 8s → 16s → 32s → 64s → 120s(封顶),成功后立刻复位到 2 秒。
好处是:数据库短时抖动后能快速补写;长时间不可用也不会把 CPU 和连接数浪费在无休止重试上;期间应用照常运行。
进程退出时还会做最后一次冲刷:
csharp
finally
{
using var flushCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await FlushAsync(buffer, retryBuffer, flushCts.Token);
}
注意:这 5 秒的收尾冲刷是"尽力而为"。容器里被
SIGKILL时不会有这个机会,所以关键审计日志不能只依赖异步落库。
六、注册与级别控制
csharp
// 注册日志服务并添加 DatabaseLoggerProvider
builder.Services.AddSingleton<DatabaseLoggerQueue>();
builder.Services.AddHostedService<DatabaseLoggerBackgroundService>();
// 通过工厂注入 IServiceProvider:日志写入时尽力补充 TraceId/路径/用户/租户上下文
builder.Services.AddSingleton<ILoggerProvider>(sp => new DatabaseLoggerProvider(
sp.GetRequiredService<DatabaseLoggerQueue>(),
sp));
级别由配置控制:
csharp
public class DatabaseLoggerConfiguration
{
public LogLevel LogLevel { get; set; } = LogLevel.Information;
}
public bool IsEnabled(LogLevel logLevel) => _getCurrentConfig().LogLevel <= logLevel;
测试验证了语义:
csharp
[Fact]
public void DatabaseLogger_IsEnabled_RespectsLevel()
{
var config = new DatabaseLoggerConfiguration { LogLevel = LogLevel.Warning };
...
logger.IsEnabled(LogLevel.Critical).Should().BeTrue();
logger.IsEnabled(LogLevel.Error).Should().BeTrue();
logger.IsEnabled(LogLevel.Warning).Should().BeTrue();
logger.IsEnabled(LogLevel.Information).Should().BeFalse();
logger.IsEnabled(LogLevel.Debug).Should().BeFalse();
logger.IsEnabled(LogLevel.Trace).Should().BeFalse();
}
IsEnabled 判断在 Log 的第一行:被过滤掉的日志根本不会进队列,也就没有后续开销。
七、和"操作日志"的区别
框架里有两套日志,职责不同,不要混:
| 数据库日志(本篇) | 操作日志 | |
|---|---|---|
| 记录内容 | 程序异常、Warning/Error、框架内部日志 | 谁在什么时候做了什么业务操作 |
| 写入方式 | 有界队列 + 后台批量异步 | 业务操作成功后记录 |
| 典型表 | SysLog(错误日志页) |
SysOperationLog(操作日志页) |
| 触发方式 | ILogger 自动 |
[OperationLog] 特性 / OperationLogService.AddLog |
| 丢数据可接受度 | 极端情况下可丢(有 DroppedCount 监控) |
审计要求下不应丢 |
像角色分配菜单这种审计敏感操作,用的是操作日志:
csharp
[AdminButton("alloc_menus")]
[OperationLog("修改角色菜单权限")]
private async Task OnSaveMenu()
结论:异步有界队列适合"程序日志",不适合"审计日志"。 审计要同步落库。
八、小结
| 问题 | 答案 |
|---|---|
| 为什么要有界队列? | 数据库故障时不能把应用内存撑爆 |
| 满了丢谁? | 丢最旧的(DropOldest),并统计 DroppedCount 供监控 |
| 为什么还要本地重试缓冲? | 写库失败时日志从队列转移到重试缓冲,同样必须有界 |
| 怎么避免雪崩重试? | 指数退避 2s → 120s,成功立即复位 |
| 怎么保证性能? | 攒批 100 条或 5 秒刷一次,批量写入 |
| 怎么定位问题? | TraceId / RequestPath / UserId / TenantCode 四个上下文 |
| 日志会不会反过来搞挂应用? | 上下文补充包 try/catch、字段截断、写入不阻塞调用方 |
"有界"不只是技术细节,它是一条设计承诺:日志系统在任何情况下都不能成为压垮应用的最后一根稻草。
如果你正在用 .NET 10 + Blazor 做后台,日志落库是很常见的需求。EasyAdminBlazor 的 DatabaseLogger 可以直接用,也可以只借鉴它的有界队列 + 批量 + 退避这套结构。