Elsa Structured Log Persistence:为 .NET 工作流引擎搭建可插拔的结构化日志持久化层
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 与关系型选项
- SqliteStructuredLogOptions.cs:
ConnectionString默认Data Source=elsa-structured-logs.db,RunMigrationsOnStartup默认true; - RelationalStructuredLogOptions.cs:
| 分组 | 选项 | 默认值 | 说明 |
|---|---|---|---|
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 只提供三个可替换的服务:
- Connection Factory(IRelationalStructuredLogConnectionFactory.cs):Provider 负责打开
DbConnection; - Dialect(IRelationalStructuredLogDialect.cs):隔离
ProviderName、参数前缀、标识符引用、ApplyLimit/ApplyOffset等 SQL 差异,SQLite 实现见 SqliteStructuredLogDialect.cs; - Schema Migrator / 迁移 runner 注册:Provider 决定自己的 FluentMigrator runner 依赖。
未来新增一个关系型 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 项任务全部完成。关键覆盖包括:
- US1 内存兼容:默认注册兼容、最近查询、来源列举、丢事件摘要(StructuredLogsStorageRegistrationTests.cs、InMemoryStructuredLogProviderTests.cs);
- US2 SQLite 持久化:空库迁移建表、跨服务重建持久化、脱敏落库、level/category/source/workflow/correlation/trace/时间范围/limit 过滤、队列刷盘与溢出、启动迁移开关、ISO-8601 时间戳、保留策略默认不删除(对应
SqliteStructuredLogMigrationTests、SqliteStructuredLogStoreTests、SqliteStructuredLogFilterTests、SqliteStructuredLogWriteQueueTests、SqliteStructuredLogTimestampTests、SqliteStructuredLogRetentionTests,全部位于 test/integration/Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite.IntegrationTests); - US3 可扩展性:假 Dialect 下的 SQL 构造器测试、JSON/UTC 时间戳映射测试、迁移版本元数据测试、核心模块无 SQLite 依赖测试(test/unit/Elsa.Diagnostics.StructuredLogs.Persistence.Relational.UnitTests)。
手动验证流程(来自 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 部署,这是一个无需外部数据库服务的轻量切入点;对于维护者,它则是一份可复用的关系型持久化参考实现。