Elsa Structured Log Persistence:为 .NET 工作流引擎搭建可插拔的结构化日志持久化层

原创2026-10-02 11:10:511,388 阅读
文章标签:后端工作流自动化流程编排低代码

Elsa Structured Log Persistence:为 .NET 工作流引擎搭建可插拔的结构化日志持久化层

导读

本文围绕 Elsa 开源仓库中的 specs/005-structured-log-persistence 特性规范,系统讲解结构化日志持久化的设计目标、存储抽象、SQLite 落地方式、schema 迁移、写入缓冲与保留策略。读者将掌握如何在 Elsa Server 中以零配置保留内存日志、通过一行扩展开启 SQLite 持久化、理解 FluentMigrator 迁移与有界写入队列的底层实现,并为未来接入 SQL Server、PostgreSQL 等关系型 Provider 建立清晰的架构认知。

特性背景与目标

结构化日志持久化(Structured Log Persistence)是 004-diagnostics-structured-logs 诊断模块的延续。原始需求描述非常明确:"将结构化日志存储变成可插拔的关注点,保留内存支持,优先提供易用的 SQLite 持久化,使用 FluentMigrator 管理 schema,暂缓 OpenTelemetry/导出器范围"(见 spec.md)。

围绕这一输入,特性规范在两次设计会话(2026-05-12、2026-05-13)中收敛出一组关键决策,构成整个特性的骨架:

  • 保留内存存储为默认,SQLite 作为首个可选持久化 Provider;
  • SQLite 不作为一次性实现,而是作为第一个关系型 Provider,为后续 SQL Server、PostgreSQL、MySQL 等铺路;
  • 不使用 EF Core,schema 管理交给 FluentMigrator,运行时热路径使用显式 SQL/Dapper 风格访问;
  • SQLite 写入采用异步批量 + 有界队列,进程崩溃时允许丢失已排队未落盘的事件;
  • 时间戳统一以 UTC ISO-8601 文本存储;
  • 保留策略默认不删除任何记录,只有显式配置 MaxAge/MaxRows 后才执行清理;
  • OpenTelemetry、OTLP、Logstash、Datadog、Splunk、Loki、Seq 等导出器全部明确推迟(见 research.md)。

对应地,需求检查清单 requirements.md 全部勾选完成,三项用户故事(US1 内存行为兼容、US2 SQLite 持久化、US3 关系型可扩展性)也已在任务清单 tasks.md 中全部落实为代码与测试。

存储职责拆分:Store、Live Feed 与 Provider Facade

特性最核心的架构动作是把原本合并在 IStructuredLogProvider 中的多份职责拆开。旧契约同时承担追加、最近查询、来源列举与实时订阅,而持久化需要的是可查询的存储,SignalR 需要的是实时投递,两者并不天然耦合(论证见 research.md 的 "Split store and live-feed responsibilities" 决策)。

拆分后的契约定义在 contracts/persistence-contract.md,源码全部落在 src/modules/Elsa.Diagnostics.StructuredLogs/Contracts:

IStructuredLogSink:追加写入边界

public interface IStructuredLogSink
{
    ValueTask WriteAsync(StructuredLogEvent logEvent, CancellationToken cancellationToken = default);
    ValueTask WriteManyAsync(IReadOnlyCollection<StructuredLogEvent> logEvents, CancellationToken cancellationToken = default);
}

它只负责接收脱敏后的事件,不承诺可查询,未来导出器包也可以直接复用这个边界。

IStructuredLogStore:可查询存储

public interface IStructuredLogStore : IStructuredLogSink
{
    ValueTask<RecentStructuredLogsResult> QueryAsync(StructuredLogFilter filter, CancellationToken cancellationToken = default);
    ValueTask<IReadOnlyCollection<StructuredLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default);
}

源码实现见 IStructuredLogStore.cs,查询与来源列举成为任何存储实现都必须提供的两件事。

IStructuredLogLiveFeed:SignalR 实时通道

public interface IStructuredLogLiveFeed
{
    ValueTask PublishAsync(StructuredLogEvent logEvent, CancellationToken cancellationToken = default);
    IAsyncEnumerable<StructuredLogStreamItem> SubscribeAsync(StructuredLogFilter filter, CancellationToken cancellationToken = default);
}

Live Feed 拥有订阅过滤与丢事件摘要(subscriber 队列溢出时),但不承诺任何持久性。

IStructuredLogProvider:不变的 Facade

拆分的巧妙之处在于 REST 端点与 SignalR 依然只面向旧的 IStructuredLogProvider.cs:

public interface IStructuredLogProvider
{
    ValueTask PublishAsync(StructuredLogEvent logEvent, CancellationToken cancellationToken = default);
    ValueTask<RecentStructuredLogsResult> GetRecentAsync(StructuredLogFilter filter, CancellationToken cancellationToken = default);
    IAsyncEnumerable<StructuredLogEvent> SubscribeAsync(StructuredLogFilter filter, CancellationToken cancellationToken = default);
    ValueTask<IReadOnlyCollection<StructuredLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default);
}

组合实现 DefaultStructuredLogProvider(Services/DefaultStructuredLogProvider.cs)把 Store 与 Live Feed 组装在一起,因此内存换成 SQLite 时,Studio 的 REST/SignalR 契约一行都不用改。内存端的既有行为也被保留为 InMemoryStructuredLogStore 与 InMemoryStructuredLogLiveFeed 两个组件(Providers/InMemory),并保留了兼容性 Facade InMemoryStructuredLogProvider。

数据模型:RelationalStructuredLogRecord

关系型记录的数据模型在 data-model.md 定义,源码落在 RelationalStructuredLogRecord.cs。每条记录包含:

  • 标识与排序:Id(事件唯一标识)、Sequence(日志路径分配的单调序号);
  • 时间:Timestamp(事件原始时间戳)、ReceivedAt(后端接收时间戳),两者均为 UTC ISO-8601 文本;
  • 日志元数据:Level(可查询表示)、Category、EventId、EventName、Message、MessageTemplate(来自 {OriginalFormat});
  • JSON 负载:ExceptionJson、ScopesJson、PropertiesJson,即异常、作用域与属性以序列化 JSON 文本存储,而标量过滤字段落为可查询列;
  • 追踪与上下文:TraceId、SpanId、CorrelationId;
  • Elsa 上下文:TenantId、WorkflowDefinitionId、WorkflowInstanceId;
  • 来源:SourceId。

规范推荐的索引覆盖 ReceivedAt、Timestamp、Level、Category、SourceId、TenantId、WorkflowDefinitionId、WorkflowInstanceId、CorrelationId、TraceId,以及用于最近排序的复合索引(如 ReceivedAt, Sequence, Id)。这些索引在迁移 M001 中全部落实(见下文)。此外还有可选的来源记录表 RelationalStructuredLogSourceRecord,携带进程与 Kubernetes/容器元数据(PodName、Namespace、ContainerName、NodeName 等)及生命周期时间戳。

配置与快速上手

零配置内存默认

不配置任何持久化时,UseStructuredLogs 保持有界内存行为(quickstart.md):

services.AddElsa(elsa =>
{
    elsa.UseStructuredLogs(options =>
    {
        options.RecentLogCapacity = 5_000;
        options.MaxRecentLogQuerySize = 1_000;
    });
});

这两个参数来自 StructuredLogsOptions.cs:RecentLogCapacity 默认 5000 条有界内存历史,MaxRecentLogQuerySize 默认 1000 并作为所有 IStructuredLogStore 最近查询的 Take 上限(ClampRecentLogQueryTake 会把负数钳制为 0,避免查询构造抛异常)。同文件还内置了脱敏配置:SensitiveNames(authorization、token、password、secret、api-key、cookie、connection-string 等)与 SensitiveTextPatterns(Bearer Token、password=...、AccountKey=... 等正则),脱敏发生在所有存储与实时投递之前(FR-005)。

开启 SQLite 持久化

services.AddElsa(elsa =>
{
    elsa.UseStructuredLogs(structuredLogs =>
    {
        structuredLogs.UseSqliteStorage("Data Source=elsa-structured-logs.db", sqlite =>
        {
            sqlite.RunMigrationsOnStartup = true;
            sqlite.Relational.WriteQueue.Capacity = 10_000;
            sqlite.Relational.WriteQueue.BatchSize = 100;
        });
    });
});

然后映射既有端点和 Hub:

app.UseStructuredLogs();

Studio 继续使用的契约不变:/diagnostics/structured-logs/recent、/diagnostics/structured-logs/sources、/diagnostics/structured-logs/storage,以及 SignalR Hub /elsa/hubs/diagnostics/structured-logs。

UseSqliteStorage 的 fluent 扩展实现在 SqliteStructuredLogsModuleExtensions.cs,注册链路一目了然:先写入 SqliteStructuredLogOptions,再通过 Copy 把 Relational 子树同步到 RelationalStructuredLogOptions,然后注册 SQLite 专属的 ConnectionFactory、Dialect、SchemaMigrator、StartupService(同时注册为 IHostedService 与 IStartupTask),最后调用 AddRelationalStructuredLogPersistence() 装配共享关系型存储。

SQLite 与关系型选项

分组 选项 默认值 说明
WriteQueue Capacity 10_000 有界队列最大积压事件数
WriteQueue BatchSize 100 单次落盘批量大小
WriteQueue FlushInterval 1 秒 后台刷盘最大间隔
WriteQueue ShutdownFlushTimeout 10 秒 优雅停机刷盘超时
Retention MaxAge null 可选最大事件年龄(不配置则不删除)
Retention MaxRows null 可选最大保留行数
Retention CleanupOnStartup false 迁移后是否立即执行一次清理

Schema 管理与 FluentMigrator 迁移

需求 FR-025~FR-029 规定 schema 的创建与升级统一走 FluentMigrator,版本化、幂等、由宿主执行;SQLite 默认启动迁移且提供关闭选项。迁移源码见 M001_CreateStructuredLogTables.cs,版本号 2026051301,建表 StructuredLogEvents:

  • 标量列类型明确:Id 为主键字符串(64),Sequence 为 Int64,Timestamp/ReceivedAt 为 40 位文本,Level 为 Int32,Category/EventName/SourceId 等为受限字符串,Message/ExceptionJson/ScopesJson/PropertiesJson 为 int.MaxValue 文本;
  • 索引与规范推荐一一对应:Timestamp、ReceivedAt 为降序索引(服务最近查询的排序语义),Level、Category、SourceId、TenantId、WorkflowDefinitionId、WorkflowInstanceId、CorrelationId、TraceId 为升序索引;
  • Down() 提供 Delete.Table 回滚。

SQLite 侧的迁移由 SqliteStructuredLogSchemaMigrator.cs 接入 FluentMigrator runner,SqliteStructuredLogStartupService.cs 作为宿主启动服务执行"迁移 + 可选启动清理"。对于未来共享的关系型数据库(如 SQL Server、PostgreSQL),规范 FR-030 明确要求各 Provider 文档化多实例启动锁问题——生产部署更推荐在部署阶段执行一次迁移,而不是由每个应用实例竞争执行。

写入缓冲:有界队列、批量落盘与崩溃语义

需求 FR-014~FR-019 定义了热路径的写入行为,这也是整个特性最强调性能目标的部分:

  • 异步批量写入:日志捕获路径绝不因磁盘 I/O 阻塞 ILogger 调用方;
  • 有界队列:队列满时丢弃"最新到达"的事件,而不是阻塞日志调用,也不是无界占用内存;
  • 可见性:丢弃计数通过 IStructuredLogWriteBuffer.DroppedWriteCount 暴露,并输出警告摘要;
  • 优雅停机刷盘:FlushAsync 在 shutdown 期间尽量落盘;进程非优雅崩溃时允许丢失排队未刷盘事件(FR-016 显式声明这一宽松语义,见 contracts/persistence-contract.md 与 research.md)。

契约定义:

public interface IStructuredLogWriteBuffer : IAsyncDisposable
{
    long DroppedWriteCount { get; }
    ValueTask EnqueueAsync(StructuredLogEvent logEvent, CancellationToken cancellationToken = default);
    ValueTask FlushAsync(CancellationToken cancellationToken = default);
}

实现位于 StructuredLogWriteBuffer.cs,配套后台任务 StructuredLogWriteBufferBackgroundTask.cs 与存储诊断 StructuredLogWriteBufferStorageDiagnostics.cs。

设计取舍值得留意:同步逐条落盘被拒绝(慢盘会直接拖慢应用日志路径);可配置的同步持久化模式被推迟(首个持久化切片优先简单运维诊断,而非零丢失审计日志);无界队列被拒绝(日志尖峰会被放大为进程内存压力);丢弃旧事件被拒绝(会让已被接受为持久化的批次变得不可预测)。具体论证见 research.md。

保留策略:默认不删除,显式才清理

FR-020 与 StructuredLogRetentionOptions 定义了保守的默认值:没有配置 MaxAge、MaxRows(或两者)时,清理任务不删除任何记录。设计评审中曾考虑"默认 14 天 + 25 万行"与"仅默认最大行数",均被拒绝——任何默认删除策略都可能让操作者感到意外。

启用保留的示例:

sqlite.Relational.Retention.MaxAge = TimeSpan.FromDays(14);
sqlite.Relational.Retention.MaxRows = 250_000;
sqlite.Relational.Retention.CleanupOnStartup = true;

清理执行体是 StructuredLogRetentionService.cs,它只负责"按策略删除过期/超量记录",不触碰实时查询与订阅。规范 Edge Cases 同时提醒:清理可能与最近查询或实时订阅存在竞争,schema 变更在可行时应尽量保留已持久化的事件。

关系型可扩展性:从 SQLite 到未来 Provider

US3(P3)确保 SQLite 不是死胡同。共享关系型包 src/modules/Elsa.Diagnostics.StructuredLogs.Persistence.Relational 承载通用实现,SQLite 只提供三个可替换的服务:

未来新增一个关系型 Provider 时,只需要补充这三类服务并复用 RelationalStructuredLogStore.cs、SQL 构造器 RelationalStructuredLogSqlBuilder.cs 与 JSON/时间戳映射 RelationalStructuredLogMapper.cs,无需改动 REST、SignalR 或 Studio 契约(FR-024)。核心模块 Elsa.Diagnostics.StructuredLogs 不引用任何 SQLite 专属类型(SC-012),该边界由单元测试 StructuredLogsStorageRegistrationTests.cs 守护(test/unit/Elsa.Diagnostics.StructuredLogs.UnitTests)。共享关系型的接入指引见 Relational 包 README。

测试与验证

任务清单 tasks.md 把测试按三条用户故事分组,共 73 项任务全部完成。关键覆盖包括:

手动验证流程(来自 quickstart.md):启动开启 SQLite 的 Elsa Server → 用不同 level/category/workflow/correlation 记录若干 ILogger 事件 → Studio 中验证过滤 → 用同一数据库文件重启宿主 → 再次查询确认重启前事件仍在 → 核对 UTC ISO-8601 时间戳 → 测试环境调低保留参数验证清理 → 灌满写队列验证丢最新事件与计数可见。

命令行校验:

dotnet build src/modules/Elsa.Diagnostics.StructuredLogs/Elsa.Diagnostics.StructuredLogs.csproj
dotnet build src/modules/Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite/Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite.csproj
dotnet test test/unit/Elsa.Diagnostics.StructuredLogs.UnitTests/Elsa.Diagnostics.StructuredLogs.UnitTests.csproj
dotnet test test/integration/Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite.IntegrationTests/Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite.IntegrationTests.csproj

明确的边界与暂缓范围

本特性刻意保持第一刀足够小(research.md 的 "Defer exporter/sink packages" 决策):OTLP logs 导出、Logstash/Datadog/Splunk/Loki/Seq 供应商 sink、Parquet 归档格式、原始控制台流、trace/metric 探索全部不在范围内,统一留待存储抽象稳定后以 sink/exporter 形式补充。Parquet 被明确评估为更适合归档与分析导出,而非热追加/查询/实时运维诊断场景。保留这两条边界,是让存储抽象不被"对外发送"需求扭曲的关键。

小结

结构化日志持久化特性在 Elsa 中确立了一条清晰的技术路线:内存为默认、SQLite 为首个关系型 Provider、FluentMigrator 管 schema、显式 SQL 走热路径、有界队列保写入不阻塞、保留策略默认不删数据、导出器明确暂缓。从契约拆分(Store/Live Feed/Provider Facade)到 SQLite 落地(Connection Factory/Dialect/Migrator),再到测试矩阵(SqliteStructuredLogMigrationTests、SqliteStructuredLogWriteQueueTests 等),整条链路都能在本仓库 specs/005-structured-log-persistence 与 src/modules/Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite 中找到完整证据。对于需要让诊断日志跨进程存活的 Elsa Server 部署,这是一个无需外部数据库服务的轻量切入点;对于维护者,它则是一份可复用的关系型持久化参考实现。

登录后查看全文
elsa-core