第二阶段优化已完成,重点是提高后台任务吞吐并降低 Outbox 数据库开销。

RabbitMQ 默认从单一回调通道提升为三类任务独立并行,跨类型并发能力由 1 提升到 3;每类还能独立配置 1–16 个消费者。[RabbitMqBackgroundJobs.cs (line 143)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/RabbitMqBackgroundJobs.cs:143)
InMemory 开发模式同步改为按任务类型隔离队列,避免某类长任务堵塞其他任务。
租约恢复检查从“每发布一条执行一次”改为默认每 60 秒维护一次。
启动恢复改为数据库 NOT EXISTS 查询,不再把全部历史 Outbox 加载进内存。[BackgroundJobOutboxPublisher.cs (line 9)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobOutboxPublisher.cs:9)
完成消息默认保留 14 天,之后按每批 500 条清理,并增加对应组合索引。
/health/messaging 现在返回各状态积压量、过期租约和最老任务等待时间。[BackgroundJobMonitoringService.cs (line 16)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobMonitoringService.cs:16)
新增 Jiaowu.BackgroundJobs 运行时指标,覆盖发布量、处理量、发布耗时、处理耗时和清理量。[BackgroundJobTelemetry.cs (line 9)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobTelemetry.cs:9)
配置、Compose 和调优建议已更新。[README.md (line 257)](E:/jiaowu/README.md:257)
This commit is contained in:
2026-07-26 21:05:18 +08:00 Unverified
parent 6300645120
commit 034f1eacb8
21 changed files with 579 additions and 135 deletions
@@ -30,14 +30,22 @@ public sealed class AutomaticScheduleGeneratorTests
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0);
Assert.True(await db.BackgroundJobOutboxMessages.CountAsync() == 0);
Assert.True(await db.Database
.SqlQueryRaw<int>(
Assert.True(await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_index_list('BackgroundJobOutboxMessages')
WHERE name = 'IX_BackgroundJobOutboxMessages_State_CompletedAt'
""")
.AnyAsync(value => value > 0));
Assert.True(await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('ScheduleEntries')
WHERE name = 'ClassroomId' AND "notnull" = 0
""")
.AnyAsync(value => value > 0));
.AnyAsync(value => value > 0));
Assert.True(await db.Database
.SqlQueryRaw<int>(
"""
@@ -10,7 +10,36 @@ namespace Jiaowu.Api.Tests;
public sealed class BackgroundJobOutboxTests
{
[Fact]
public async Task Publisher_moves_pending_message_to_in_memory_transport()
public async Task In_memory_transport_keeps_job_types_on_independent_channels()
{
var transport = new InMemoryBackgroundJobTransport();
var automatic = new BackgroundJobEnvelope(
Guid.NewGuid(),
BackgroundJobKind.AutomaticSchedule,
Guid.NewGuid());
var publish = new BackgroundJobEnvelope(
Guid.NewGuid(),
BackgroundJobKind.SchedulePublish,
Guid.NewGuid());
await transport.PublishAsync(automatic, CancellationToken.None);
await transport.PublishAsync(publish, CancellationToken.None);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1));
await using var automaticReader = transport
.ReadAllAsync(BackgroundJobKind.AutomaticSchedule, timeout.Token)
.GetAsyncEnumerator(timeout.Token);
await using var publishReader = transport
.ReadAllAsync(BackgroundJobKind.SchedulePublish, timeout.Token)
.GetAsyncEnumerator(timeout.Token);
Assert.True(await automaticReader.MoveNextAsync());
Assert.Equal(automatic, automaticReader.Current);
Assert.True(await publishReader.MoveNextAsync());
Assert.Equal(publish, publishReader.Current);
}
[Fact]
public async Task Publisher_recovers_pending_message_and_cleans_old_completion()
{
var databasePath = Path.Combine(
Path.GetTempPath(),
@@ -21,18 +50,22 @@ public sealed class BackgroundJobOutboxTests
options.UseSqlite($"Data Source={databasePath};Pooling=False"));
var options = new BackgroundJobOptions
{
PollIntervalMilliseconds = 100
PollIntervalMilliseconds = 100,
CompletedRetentionDays = 1,
CleanupBatchSize = 10
};
services.AddSingleton(options);
services.AddSingleton<InMemoryBackgroundJobTransport>();
services.AddSingleton<IBackgroundJobTransport>(provider =>
provider.GetRequiredService<InMemoryBackgroundJobTransport>());
services.AddSingleton<BackgroundJobTelemetry>();
services.AddSingleton<BackgroundJobOutboxPublisher>();
var provider = services.BuildServiceProvider();
try
{
Guid jobId;
Guid oldCompletedOutboxId;
await using (var scope = provider.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -57,8 +90,14 @@ public sealed class BackgroundJobOutboxTests
SchedulePlan = plan,
ActiveSchedulePlanId = plan.Id
};
var oldCompletedOutbox = BackgroundJobOutboxMessage.Create(
BackgroundJobKind.SchedulePublish,
Guid.NewGuid());
oldCompletedOutbox.State = BackgroundJobOutboxState.Completed;
oldCompletedOutbox.CompletedAt = DateTime.UtcNow.AddDays(-2);
oldCompletedOutboxId = oldCompletedOutbox.Id;
jobId = job.Id;
db.AddRange(term, plan, job);
db.AddRange(term, plan, job, oldCompletedOutbox);
await db.SaveChangesAsync();
}
@@ -76,7 +115,9 @@ public sealed class BackgroundJobOutboxTests
.AnyAsync(x =>
x.JobId == jobId &&
x.JobKind == BackgroundJobKind.AutomaticSchedule &&
x.State == BackgroundJobOutboxState.Published);
x.State == BackgroundJobOutboxState.Published) &&
!await db.BackgroundJobOutboxMessages.AsNoTracking()
.AnyAsync(x => x.Id == oldCompletedOutboxId);
}
Assert.True(published);
@@ -93,6 +134,47 @@ public sealed class BackgroundJobOutboxTests
}
}
[Fact]
public async Task Monitoring_snapshot_reports_backlog_and_expired_leases()
{
var databasePath = Path.Combine(
Path.GetTempPath(),
$"jiaowu-monitoring-{Guid.NewGuid():N}.sqlite");
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite($"Data Source={databasePath};Pooling=False")
.Options;
try
{
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var pending = BackgroundJobOutboxMessage.Create(
BackgroundJobKind.AutomaticSchedule,
Guid.NewGuid());
pending.CreatedAt = DateTime.UtcNow.AddMinutes(-2);
var processing = BackgroundJobOutboxMessage.Create(
BackgroundJobKind.MakeupExamAuto,
Guid.NewGuid());
processing.State = BackgroundJobOutboxState.Processing;
processing.LeaseExpiresAt = DateTime.UtcNow.AddMinutes(-1);
db.AddRange(pending, processing);
await db.SaveChangesAsync();
var snapshot = await new BackgroundJobMonitoringService(db)
.GetSnapshotAsync(CancellationToken.None);
Assert.Equal(1, snapshot.Pending);
Assert.Equal(1, snapshot.Processing);
Assert.Equal(1, snapshot.ExpiredLeases);
Assert.NotNull(snapshot.OldestUnfinishedAt);
Assert.True(snapshot.OldestUnfinishedAgeSeconds >= 100);
}
finally
{
File.Delete(databasePath);
}
}
[Fact]
public async Task Runner_stops_after_retry_limit_and_ignores_duplicate_delivery()
{
@@ -109,6 +191,7 @@ public sealed class BackgroundJobOutboxTests
services.AddSingleton<InMemoryBackgroundJobTransport>();
services.AddSingleton<IBackgroundJobTransport>(provider =>
provider.GetRequiredService<InMemoryBackgroundJobTransport>());
services.AddSingleton<BackgroundJobTelemetry>();
services.AddSingleton<BackgroundJobRunner>();
var provider = services.BuildServiceProvider();