EasyAdminBlazor 日志系统源码解析:为什么数据库日志需要有界队列?

把日志写进数据库看起来很直接: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
    });
}

这段代码体现了三条纪律:

  1. 上下文补充失败不能影响写日志。整个块被 try/catch 包住,只捕获 ObjectDisposedException / InvalidOperationException(作用域释放、无法解析服务等预期情况)。
  2. 所有字段都截断(Truncate),避免超长消息把数据库列写爆:消息 2000、异常 12000、路径 500、TraceId 100。
  3. 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 可以直接用,也可以只借鉴它的有界队列 + 批量 + 退避这套结构。

相关推荐
Sylven1 小时前
【DevOps 开发流程】问题+标签驱动开发,可视化你和AI的开发进度
后端
松就是我902981 小时前
Agent系统设计七条通用原则-CSDN
后端
136096757231 小时前
repo 与 index:站点目录该怎么切
后端
那就叫王师傅1 小时前
AD基础学习01
后端
付威20231 小时前
Rust 1.99 更新了什么?一个新手视角的升级前后对比
人工智能·后端
我的div丢了肿么办1 小时前
进程和线程以及go语言中的协程
后端·go
墨家句子1 小时前
4G 显存老显卡怎么跑模型:llama.cpp 加 GGUF 配置
linux·后端
HLAIA光子1 小时前
RAG Chunks 切分优化后成本骤降 86%
后端·性能优化·架构
后端LV1 小时前
Caffeine 快到 1450 万 ops/s,为什么我还是不敢说数据一致
java·后端