Elsa Core 结构化日志持久化实战指南:从内存缓冲到 SQLite 可插拔存储
Elsa Core 结构化日志持久化实战指南:从内存缓冲到 SQLite 可插拔存储
导读
本文基于 Elsa Core 仓库中 005-structured-log-persistence 特性规格 及其配套设计文档,系统讲解结构化日志(Structured Logs)如何从"仅内存缓冲"演进为"可插拔的持久化存储":默认零配置使用有界内存存储,同时提供开箱即用的 SQLite 持久化方案,并预留了 SQL Server、PostgreSQL、MySQL 等关系型数据库的扩展通道。读完本文,你将掌握 Elsa 结构化日志存储抽象的完整架构、SQLite 持久化的配置与迁移方式、写缓冲队列的背压语义、保留策略的边界行为,以及如何在不改动 REST/SignalR 契约的前提下切换存储后端。
一、特性背景与设计目标
结构化日志持久化特性(Feature Branch 005-structured-log-persistence,创建于 2026-05-12)的输入需求非常明确:"将结构化日志存储做成可插拔关注点——保留内存支持,优先提供简单的 SQLite 持久化故事,用 FluentMigrator 管理 schema,并推迟 OpenTelemetry/exporter 范围。"
围绕这一输入,规格文档通过两轮澄清会话锁定了边界:
- 第一刀要小:暂不引入 Logstash、Datadog、Splunk、Loki、Seq 等厂商 sink,也不引入 OTLP logs exporter;
- 存储先保留内存、新增 SQLite:内存存储保持默认且零配置,SQLite 作为可选持久化存储;
- SQLite 不是一次性实现:它应成为第一个关系型 provider,为后续 SQL Server、PostgreSQL、MySQL 等铺路;
- 不用 EF Core:本特性避免 EF Core 及按 provider 的 EF migrations;
- schema 管理用 FluentMigrator:schema 版本化与迁移执行交给 FluentMigrator,热路径存储采用显式 SQL/Dapper 风格访问。
从仓库源码结构看,该设计已经完整落地为三个相互隔离的项目:
- Elsa.Diagnostics.StructuredLogs:核心模块,持有捕获、REST/SignalR 契约与内存实现;
- Elsa.Diagnostics.StructuredLogs.Persistence.Relational:共享关系型持久化层(存储、方言、迁移、写缓冲、保留服务);
- Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite:SQLite provider 包。
二、存储抽象:可替换的 Store 与不变的 Facade
2.1 核心契约划分
规格 FR-001 到 FR-005 要求:结构化日志模块必须把"可查询存储"与"实时流式传输、捕获/provider 门面职责"分离。仓库中的契约文件清晰地体现了这一分层:
IStructuredLogSink —— 追加型目的地(源码):
public interface IStructuredLogSink
{
ValueTask WriteAsync(StructuredLogEvent logEvent, CancellationToken cancellationToken = default);
async ValueTask WriteManyAsync(IReadOnlyCollection<StructuredLogEvent> logEvents, CancellationToken cancellationToken = default)
{
foreach (var logEvent in logEvents)
await WriteAsync(logEvent, cancellationToken);
}
}
IStructuredLogStore —— 可查询存储,继承 Sink(源码):
public interface IStructuredLogStore : IStructuredLogSink
{
ValueTask<RecentStructuredLogsResult> QueryAsync(StructuredLogFilter filter, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyCollection<StructuredLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default);
}
IStructuredLogLiveFeed —— SignalR 实时订阅源(源码):
public interface IStructuredLogLiveFeed
{
ValueTask PublishAsync(StructuredLogEvent logEvent, CancellationToken cancellationToken = default);
IAsyncEnumerable<StructuredLogStreamItem> SubscribeAsync(StructuredLogFilter filter, CancellationToken cancellationToken = default);
}
IStructuredLogProvider —— REST 端点与 SignalR 继续使用的门面(源码),聚合了 Publish、GetRecent、Subscribe、ListSources 四项能力。
规格 FR-002 明确说明:IStructuredLogProvider 可以作为 REST 端点和 SignalR 使用的门面保留,但存储专属职责必须由可替换的 store 抽象承担。仓库中的 DefaultStructuredLogProvider 正是这种组合模式的实现——它同时注入 IStructuredLogStore 与 IStructuredLogLiveFeed,PublishAsync 先写存储再发布实时流:
public async ValueTask PublishAsync(StructuredLogEvent logEvent, CancellationToken cancellationToken = default)
{
await store.WriteAsync(logEvent, cancellationToken);
await liveFeed.PublishAsync(logEvent, cancellationToken);
}
2.2 关键设计约束
- 脱敏先于一切存储(FR-005):任何事件在进入内存存储、SQLite 存储或实时订阅者之前都必须先完成 redaction。集成测试 SqliteStructuredLogStoreTests.cs 的
Logger_RedactsSensitiveValuesBeforePersisting用例验证了这一点:写入"Authorization bearer abc123"后,按Text = "[Redacted]"过滤能查到消息被脱敏为"Authorization [Redacted]"的记录。 - 配置实时流共存(FR-004):配置了持久化存储后,实时流式传输依然可用——
DefaultStructuredLogProvider的SubscribeWithDroppedEventsAsync继续把实时流转发给 live feed。 - 内存是零配置默认(FR-006/FR-007):未配置持久化 provider 时,模块使用有界内存存储,保留现有的 recent 查询、来源列表、订阅、dropped-event 汇总、容量与过滤行为。内存实现位于 Providers/InMemory 目录(
RingBuffer、InMemoryStructuredLogStore、InMemoryStructuredLogLiveFeed、InMemoryStructuredLogProvider)。
三、SQLite 持久化:第一个关系型 Provider
3.1 连接工厂与方言
关系型共享层把"provider 自有差异"隔离在两个契约上:
IRelationalStructuredLogConnectionFactory(源码):由 provider 负责创建连接:
public interface IRelationalStructuredLogConnectionFactory
{
ValueTask<DbConnection> OpenConnectionAsync(CancellationToken cancellationToken = default);
}
IRelationalStructuredLogDialect(源码):封装 provider 特有的 SQL 差异:
public interface IRelationalStructuredLogDialect
{
string ProviderName { get; }
string ParameterPrefix { get; }
string QuoteIdentifier(string identifier);
string ApplyLimit(string sql, int limit);
string ApplyOffset(string sql, int offset);
}
SQLite 的方言实现位于 SqliteStructuredLogDialect.cs:ProviderName 为 "SQLite",参数前缀为 @,标识符用双引号转义,LIMIT/LIMIT -1 OFFSET 用于分页。规格还提示:时间戳、自由文本过滤、JSON 处理等 provider 差异可在实现过程中按需追加方法,这正是未来 SQL Server、PostgreSQL、MySQL provider 的扩展点(FR-021~FR-024)。
3.2 关系型记录模型
RelationalStructuredLogRecord 是 StructuredLogEvent 的数据库表示(FR-011):
- 可查询标量列:
Id、Sequence、Level、Category、EventId、EventName、Timestamp、ReceivedAt、TraceId、SpanId、CorrelationId、TenantId、WorkflowDefinitionId、WorkflowInstanceId、SourceId; - JSON 载荷列:
ExceptionJson、ScopesJson(默认"{}")、PropertiesJson(默认"{}")、Message、MessageTemplate; - 时间戳按 FR-013 存储为 UTC ISO-8601 文本。
3.3 SQLite 配置入口
SQLite provider 通过 UseSqliteStorage 扩展方法接入 StructuredLogsFeature,并提供两个重载——指定连接字符串,或仅用配置回调。默认连接字符串为 Data Source=elsa-structured-logs.db,RunMigrationsOnStartup 默认为 true(见 SqliteStructuredLogOptions)。
四、快速上手:从内存默认到 SQLite 持久化
4.1 零配置的内存默认
不配置任何持久化存储,即可获得与特性 004 完全相同的有界内存 recent 历史与 SignalR 实时流(quickstart.md):
services.AddElsa(elsa =>
{
elsa.UseStructuredLogs(options =>
{
options.RecentLogCapacity = 5_000;
options.MaxRecentLogQuerySize = 1_000;
});
});
这两个选项在 StructuredLogsOptions 中定义:RecentLogCapacity 默认 5_000(内存环形缓冲上限),MaxRecentLogQuerySize 默认 1_000(对所有 IStructuredLogStore 实现的 recent 查询 Take 上限钳制,负值按 0 处理以避免查询构造抛异常)。此外还提供 SubscriberChannelCapacity(默认 1_000)、SourceHeartbeatTimeout(默认 30 秒)等选项。
4.2 启用 SQLite 持久化
参照 quickstart.md 的推荐配置:
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(ApplicationBuilderExtensions):
app.UseStructuredLogs();
Studio 继续使用以下契约,存储从内存切换到 SQLite 不需要任何 API 变更(FR-024):
GET/POST /diagnostics/structured-logs/recent—— recent 查询(Recent 端点 同时支持 GET 与 POST);GET /diagnostics/structured-logs/sources—— 来源列表(Sources 端点);GET /diagnostics/structured-logs/storage—— 存储诊断(Storage 端点);/elsa/hubs/diagnostics/structured-logs—— SignalR hub(EndpointRouteBuilderExtensions 中常量HubRoute)。
4.3 支持的过滤字段
StructuredLogFilter(源码)完整覆盖 recent 查询的过滤维度,也是 SQLite 存储必须支持的字段集合(FR-010):
MinimumLevel/Levels—— 日志级别过滤;CategoryPrefix—— 类别前缀;Text—— 自由文本(规格 FR-087 提示 JSON 字段上的自由文本过滤可能是 provider 特定的、初始为近似匹配);TenantId、WorkflowDefinitionId、WorkflowInstanceId、TraceId、SpanId、CorrelationId、SourceId—— 上下文标识符;From/To—— 时间范围;Take—— 返回条数限制。
集成测试 SqliteStructuredLogFilterTests.cs、SqliteStructuredLogTimestampTests.cs 分别覆盖这些过滤字段与 UTC ISO-8601 时间戳的存储/过滤一致性。
五、Schema 管理:FluentMigrator 与启动迁移
规格 FR-025~FR-030 要求:关系型 schema 的创建与升级必须使用 FluentMigrator,迁移必须带版本号、可由宿主幂等执行,provider 包只注册自己需要的 runner 依赖。
仓库中的迁移实现位于 M001_CreateStructuredLogTables.cs,版本号为 2026051301,创建 StructuredLogEvents 表并为以下列建立索引:
- 主键:
Id(String(64)); - 标量列:
Sequence(Int64)、Level(Int32)、Category(String(512))、EventId(Int32)、EventName(String(512) 可空)、TraceId/SpanId(String(64) 可空)、CorrelationId/TenantId/WorkflowDefinitionId/WorkflowInstanceId(String(256) 可空)、SourceId(String(512)); - 文本列:
Timestamp/ReceivedAt(String(40),存 UTC ISO-8601 文本)、Message/MessageTemplate(大文本)、ExceptionJson/ScopesJson/PropertiesJson(大文本 JSON); - 索引:
Timestamp、ReceivedAt降序索引,Level、Category、SourceId、TenantId、WorkflowDefinitionId、WorkflowInstanceId、CorrelationId、TraceId升序索引。
SQLite 的迁移运行器 SqliteStructuredLogSchemaMigrator 使用 FluentMigrator Runner:AddSQLite() + WithGlobalConnectionString() + ScanIn(typeof(M001CreateStructuredLogTables).Assembly).For.Migrations(),然后执行 MigrateUp()。
启动迁移由 SqliteStructuredLogStartupService 驱动,它同时实现 IHostedService 与 IStartupTask,用 SemaphoreSlim 保证执行一次:
RunMigrationsOnStartup = true(默认)时执行schemaMigrator.MigrateAsync();Relational.Retention.CleanupOnStartup = true时紧接着执行retentionService.CleanupAsync();- 关闭启动迁移(设为
false)时,宿主需自行准备 schema(FR-029)。
规格 FR-030 与 quickstart 都提醒:对于未来共享数据库(如 SQL Server、PostgreSQL),生产部署可以选择在部署时只跑一次迁移而不是从每个应用实例都跑;多实例启动锁定约束必须由各 provider 自行文档化。
六、写缓冲:有界队列、批量落盘与优雅停机
SQLite 写路径的关键约束是:日志调用绝不能因磁盘 I/O 而同步阻塞(FR-014~FR-019)。共享关系型层用 StructuredLogWriteBuffer(源码)实现了完整的背压语义。
6.1 契约与配置
IStructuredLogWriteBuffer(源码)继承 IStructuredLogSink 与 IStructuredLogStorageDiagnostics,暴露 FlushAsync 与 DroppedWriteCount。
RelationalStructuredLogOptions 中的写队列选项及默认值:
| 选项 | 默认值 | 说明 |
|---|---|---|
WriteQueue.Capacity |
10_000 |
最多等待写入的队列事件数 |
WriteQueue.BatchSize |
100 |
一次 flush 最多写入的事件数 |
WriteQueue.FlushInterval |
1 秒 |
后台 flush 的最大间隔 |
WriteQueue.ShutdownFlushTimeout |
10 秒 |
优雅停机时 flush 的最大时长 |
6.2 实现语义(对应 FR-017~FR-019)
- 队列满则丢弃最新事件:
WriteAsync在_queue.Count >= Capacity时通过Interlocked.Increment递增_droppedWriteCount并直接返回,不阻塞日志调用、不分配无界内存; - 批量落盘:后台任务
ProcessQueueAsync以FlushInterval等待信号,DequeueBatch按BatchSize取出批次交给RelationalStructuredLogStore.WriteManyAsync,后者在单个事务内批量插入; - 优雅停机 flush:
StopAsync/DisposeAsync在ShutdownFlushTimeout内执行FlushAsync;若超时未完成,CountPendingWritesAsDropped会把剩余队列计数计入 dropped 并清空——这正对应规格 FR-016:"进程崩溃可能丢失已排队但未 flush 的事件"这一明确的可接受损失语义; - flush 失败计损:flush 抛异常时,当前批次事件数会计入 dropped 计数。
集成测试 SqliteStructuredLogWriteQueueTests.cs 覆盖队列饱和时丢弃最新事件并报告 dropped 计数的行为。
七、保留策略:默认不删任何记录
规格 FR-012、FR-020 与用户故事 2 的验收场景 6 明确:SQLite 保留策略默认不删除任何记录,只有配置了 MaxAge、MaxRows(或两者)时才执行清理。
保留服务 StructuredLogRetentionService 的逻辑一目了然:
var retention = options.Value.Retention;
if (retention.MaxAge == null && retention.MaxRows == null)
return;
配置示例(来自 quickstart.md):
sqlite.Relational.Retention.MaxAge = TimeSpan.FromDays(14);
sqlite.Relational.Retention.MaxRows = 250_000;
sqlite.Relational.Retention.CleanupOnStartup = true;
清理逻辑按需执行两类 SQL:BuildDeleteOlderThan(cutoff) 删除超过 MaxAge 的记录(cutoff 由 DateTimeOffset.UtcNow.Subtract(maxAge) 计算并格式化为 UTC ISO-8601),BuildDeleteRowsBeyondMax(maxRows) 按行数上限删除超量记录。集成测试 SqliteStructuredLogRetentionTests.cs 验证了"仅当配置保留策略时才删除过期/超量记录"以及"默认不删除"。
八、扩展新关系型数据库 Provider
共享关系型层让 SQL Server、PostgreSQL、MySQL 等 provider 可以复用几乎全部核心实现(FR-021~FR-024、用户故事 3)。新增 provider 只需提供:
- 连接工厂:实现
IRelationalStructuredLogConnectionFactory; - 方言:实现
IRelationalStructuredLogDialect(提供ProviderName、ParameterPrefix、标识符引用、ApplyLimit/ApplyOffset); - 迁移 runner 注册:实现
IStructuredLogSchemaMigrator并注册对应的 FluentMigrator runner 依赖(FR-027); - 需要数据库特有 DDL 时,迁移可在共享迁移版本下按
ProviderName分支(FR-026 保持共享迁移版本号)。
规格同时要求:核心结构化日志模块不得引用 SQLite 特定类型(SC-012)。从仓库结构看,SQLite 相关类型全部隔离在 Elsa.Diagnostics.StructuredLogs.Persistence.Sqlite 包内,核心模块只依赖共享关系型契约。
九、验证与测试
9.1 命令验证
实现完成后可运行以下目标构建与测试(来自 quickstart.md):
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
仓库中实际存在的测试项目还包括共享关系型单元测试 Elsa.Diagnostics.StructuredLogs.Persistence.Relational.UnitTests 与持久化一致性测试 StructuredLogStoreConformanceTests,后者用于保证各 provider 的 store 行为一致。
9.2 手动验证步骤
- 启用 SQLite 结构化日志存储并启动 Elsa Server;
- 用不同级别、类别、工作流 ID 和关联 ID 记录若干条
ILogger日志; - 从 Studio 查询 recent 日志并验证过滤功能;
- 使用同一个 SQLite 数据库文件重启宿主;
- 再次查询 recent 日志,确认重启前的事件仍然可查(对应集成测试
QueryAsync_ReturnsLogsWrittenByPreviousProviderInstance,见 SqliteStructuredLogStoreTests.cs); - 验证时间戳按 UTC ISO-8601 存储和过滤;
- 在测试环境调低保留设置,验证清理会移除过期/超量行;
- 在测试环境打满写队列,验证最新事件被丢弃并显示 dropped 计数。
十、范围边界与默认值速查
10.1 明确不在范围内(FR-033)
本持久化切片不包含:OTLP logs 导出、Logstash/Datadog/Splunk/Loki/Seq 等厂商 sink、Parquet 导出、原始控制台流式传输以及 trace/metric 探索。这些可以等持久化稳定后以 sink/exporter 形式后续添加。
10.2 关键默认值速查表
| 项目 | 默认值 | 位置 |
|---|---|---|
RecentLogCapacity |
5_000 |
StructuredLogsOptions.cs |
MaxRecentLogQuerySize |
1_000 |
同上 |
SubscriberChannelCapacity |
1_000 |
同上 |
WriteQueue.Capacity |
10_000 |
RelationalStructuredLogOptions.cs |
WriteQueue.BatchSize |
100 |
同上 |
WriteQueue.FlushInterval |
1 秒 |
同上 |
WriteQueue.ShutdownFlushTimeout |
10 秒 |
同上 |
RunMigrationsOnStartup |
true |
SqliteStructuredLogOptions.cs |
| SQLite 连接字符串 | Data Source=elsa-structured-logs.db |
同上 |
| 保留策略 | 不删除任何记录 | StructuredLogRetentionService.cs |
十一、进一步阅读
- 特性规格全文:spec.md
- 持久化契约与接口定义:persistence-contract.md
- 数据模型设计:data-model.md
- 实现计划与项目结构:plan.md
- 快速上手:quickstart.md
- 核心模块说明:Elsa.Diagnostics.StructuredLogs/README.md