后台队列与发件箱
两个层次的问题,两个包内能力:
- 后台队列:进程内异步执行( Channel + 多 worker ),fire-and-forget,进程重启即丢失;
- 发件箱(Outbox):跨进程可靠投递(至少一次),事件与业务数据同事务落库。
后台队列
builder.Services.AddLedonQueues(o => o.WorkerCount = 4); // 4 个并发 worker
await queue.EnqueueAsync(async (scope, token) =>
{
var sender = scope.GetRequiredService<IEmailSender>(); // 独立 DI 作用域
await sender.SendAsync(token);
});
- 每个任务在独立 DI 作用域内执行(Scoped 服务安全解析,不会串味);
- 有界队列(默认容量 1000,满时写入方等待)——天然背压,不会 OOM;
- 单条任务抛异常只记日志,不拖垮 worker,后续任务照常执行;
- 停机 drain(默认 5s):宿主停止时把队列里已入队未处理的任务继续处理完(有界),
优雅退出尽量不丢任务;
DrainTimeout = TimeSpan.Zero则不 drain(丢弃数量记 Warning); 正在执行中的任务仍由停机令牌取消,语义不变。
进程重启会丢失未执行任务——对丢任务零容忍的场景用发件箱。
什么时候必须用发件箱而不是后台队列
后台队列的存储是进程内 Channel——以下任一条件命中就别用它:
- 进程可能被 kill/崩溃/OOM(队列里的任务随进程消失);
- 多实例部署且任务必须被处理一次(Channel 不跨实例,且重启后不恢复);
- 任务与业务数据必须原子("数据库提交了但邮件没发"无法接受)。
发件箱把任务/事件写进业务数据库(同事务),由调度器投递——进程重启后待投递消息仍在。 需要跨服务投递时再加传输包(RabbitMQ/Kafka)。
持久化后台队列(重启不丢任务)
既想要"fire-and-forget 的简单 API",又要求任务不随进程消失——用持久化队列:
builder.Services.AddLedonDurableQueue(); // DbContext 继承 OutboxDbContext 即获得队列表
// 业务代码:与业务数据同事务入队(调用方负责 SaveChanges)
await durableQueue.EnqueueAsync(new DurableJob("send-email", "{\"to\":\"a@b.c\"}"));
await durableQueue.EnqueueAsync(new DurableJob("nightly-report", "{}", Delay: TimeSpan.FromHours(6)));
// 处理器(实现类自动发现,按 Name 匹配)
public class SendEmailHandler : IDurableJobHandler
{
public string Name => "send-email";
public Task ExecuteAsync(string payload, CancellationToken ct = default) => /* ... */;
}
- 任务落库(与发件箱同库的
QueuedJobs表),进程重启后由调度器继续投递; - 失败重试至
MaxRetries(默认 5),超限搁置(记 Warning,不阻塞后续); - 支持延迟任务(
Delay)、保留期清理(Retention,默认 1 天); - 与发件箱的分工:发件箱管"跨服务领域事件",持久化队列管"本服务后台作业"。
发件箱(Outbox)
public class OrderDbContext : OutboxDbContext; // 继承即获得 OutboxMessages 表
// 业务事务:事件与数据同一次 SaveChanges(原子性)
outbox.Add(new OrderPlaced(orderId));
await db.SaveChangesAsync(); // 调度器随后投递并标记
public record OrderPlaced(string OrderId) : IIntegrationEvent;
public class OrderPlacedHandler : IIntegrationEventHandler<OrderPlaced>
{
public Task HandleAsync(OrderPlaced e, CancellationToken ct = default) { /* 发邮件/通知 */ }
}
- 至少一次语义:投递成功才标记;失败记重试次数,下个轮询周期重试;
- 单条失败不阻塞后续消息;超过重试上限搁置,留待人工处理(监控搁置表即可发现毒消息);
- 处理器自动发现、自动注入,与进程内事件(
IEventHandler<T>)写法一致; - 认领制投递:多实例部署时同一条消息只有一个实例会投。每条消息带
LeasedBy/LeasedUntil,调度器先原子认领再投递;租约到期未处理完则由其它实例接手 (节点崩溃兜底)。没有这一步,N 个实例会把同一条消息各投一遍(N 倍重复); - 运维快照的"待投递数"走
CountPendingAsync(只数不认领),不会把别人正在投的消息抢过来。
退避与并发参数
builder.Services.AddLedonOutbox(o =>
{
o.Lease = TimeSpan.FromMinutes(2); // 认领租约:崩溃后多久有别的实例接手
});
持久化队列同样支持(LedonDurableQueueOptions.Lease)。
消息中间件传输
注册 IIntegrationEventTransport 后,发件箱把消息投给传输(broker)而非进程内 handler——消费方(另一个服务/进程)订阅 broker 后在自己的容器里派发给本地 handler:
// 发布方:注册传输即可,OutboxDispatcher 自动改走 broker
builder.Services.AddSingleton<IIntegrationEventTransport>(new MyRabbitMqTransport(...));
现成实现:Ledon.Queues.RabbitMQ 包(AddLedonRabbitMqTransport(),含发布与订阅消费两侧)。
builder.Services.AddLedonRabbitMqTransport(o =>
o.ConnectionString = "amqp://user:pass@localhost:5672");
发布方与消费方装同一个包:发布侧由发件箱自动投递;消费侧自动订阅本服务队列(默认 {交换机}.{服务名},多实例竞争消费)并派发到进程内 handler——消费方代码零改动。单条处理失败不确认,broker 重新投递(至少一次)。
传输失败由发件箱记录重试(至少一次语义不变);投递成功才标记已处理。
怎么选
| 需求 | 选择 |
|---|---|
| 请求内触发的异步副作用 | 后台队列 |
| 跨服务/跨进程通知 | 发件箱 |
| 事务提交后必须发生的动作 | 发件箱 |
| 允许丢失的临时计算 | 后台队列 |