MassTransitService收件箱模式

在真实企业项目中,通常无需手动手写发件箱轮询,我们会使用MassTransit Outbox,用于解决双写不一致问题。

我们可以打开CleanArchitecture模板的应用层的UseCase业务编排,看看Deposit.cs :

public sealed class Deposit(
                        IEntityFactory entityFactory,
                        IOutputPort<DepositOutput> outputHandler,
                        IAccountRepository accountRepository,
                        IUnitOfWork unitOfWork,
                        IServiceBusClient serviceBus) : IUseCase<DepositInput>
{
    private readonly IEntityFactory _entityFactory = entityFactory ?? throw new ArgumentNullException(nameof(entityFactory));
    private readonly IOutputPort<DepositOutput> _outputHandler = outputHandler ?? throw new ArgumentNullException(nameof(outputHandler));
    private readonly IAccountRepository _accountRepository = accountRepository ?? throw new ArgumentNullException(nameof(accountRepository));
    private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
    private readonly IServiceBusClient _serviceBus = serviceBus ?? throw new ArgumentNullException(nameof(serviceBus));

    public async Task ExecuteAsync(DepositInput input, CancellationToken cancellationToken = default)
    {
        var account = await _accountRepository.GetAsync(input.AccountId, cancellationToken);
        if (account == null)
        {
            _outputHandler.Error($"The account {input.AccountId} does not exist or is already closed.");
            return;
        }

        var credit = account.Deposit(_entityFactory, input.Amount);

        await _accountRepository.UpdateAsync(account, credit, cancellationToken);

        await _serviceBus.PublishEventAsync(new Contracts.Events.DepositCompleted() { AccountId = input.AccountId, Amount = input.Amount.ToMoney().ToDecimal() }, cancellationToken);

        await _unitOfWork.SaveAsync(cancellationToken);

        DepositOutput output = new DepositOutput(
            credit,
            account.GetCurrentBalance());

        _outputHandler.Default(output);
    }
}

这个代码比较精彩,可以仔细看,这个代码把输入/输出端口模式使用上了,我们甚至可以直接脱离HTTP上下文,能够被API、GRPC甚至是控制台程序复用。

它依赖仓储IAccountRepository 获取聚合根,调用聚合根的业务方法 account.Deposit(...),最后调用 IUnitOfWork.SaveAsync() 统一提交事务。

await _accountRepository.UpdateAsync(...); // 1. 更新
await _serviceBus.PublishEventAsync(...);  // 2. 发消息到 RabbitMQ/Redis
await _unitOfWork.SaveAsync(...);          // 3. 提交数据库事务

如果第二步消息发送成功了,但是第三步的数据库SaveAsyc因为网络抖动或者主键冲突导致失败了,或者是数据库回滚了,那么消费者就会收到存款成功但是没有落库的情况。

事务性收发件

那么怎么避免这种情况,那么应该使用事务性发件箱模式

可以在数据库新建一张叫做OutboxMessage的表:

CREATE TABLE OutboxMessages (
    Id UNIQUEIDENTIFIER PRIMARY KEY,
    OccurredOn DATETIME2 NOT NULL,
    Type NVARCHAR(255) NOT NULL,
    Content NVARCHAR(MAX) NOT NULL,
    ProcessedOn DATETIME2 NULL
);

在用例中统一落库:

  // 业务更新
    await _accountRepository.UpdateAsync(account, credit, cancellationToken);

    // 不直接调 RabbitMQ 发网络请求,而是向本地 Outbox 表塞一条记录
    _context.OutboxMessages.Add(new OutboxMessage(
        id: Guid.NewGuid(),
        type: nameof(DepositCompleted),
        content: JsonSerializer.Serialize(new DepositCompleted { ... })
    ));

    // 关键点:业务数据和 Outbox 消息在【同一个数据库事务中原子提交】!
    // 如果报错,全部回滚,绝对不会产生“幽灵消息”!
    await _unitOfWork.SaveAsync(cancellationToken);

后台 Worker 异步保障投递:

• 项目中的 dddotNet.Worker 可以起一个后台任务(BackgroundService); • 每隔几百毫秒扫一次 OutboxMessages 表中 ProcessedOn IS NULL 的记录,投递给 RabbitMQ; • RabbitMQ 返回确认(ACK)后,打上 ProcessedOn = DateTime.UtcNow; • 即使宕机,下次重启会继续扫表重投(保证至少投递一次 At-Least-Once Delivery)。

在真实的开发场景中,通常是无需手写发件箱轮询的,因为.net生态有两个及其成熟的解决方案:

MassTransit Outbox(推荐):如果该项目后续将 Rebus 升级为 MassTransit,直接开启:cfg.AddEntityFrameworkOutbox<GenocsContext>() 业务代码只需 await publishEndpoint.Publish(...),框架会自动劫持并走 Outbox 事务落库与后台派发。

DotNetCore.CAP:国内广泛采用的分布式事务与 EventBus 方案,原生内置基于 EF Core 的 Outbox 实现与自动重试机制。

我们这里通过MassTransit Outbox来改造一下,后续也可以将rebus升级为masstransit。

核心执行时序(Outbox 怎么解决双写问题):

实现:

在 Infrastructure.csproj 中引入了 MassTransit 的 EF Core 发件箱与 RabbitMQ 传输库,我们在GenocsContext.cs:18-21 的 OnModelCreating 中注入发件箱表映射:

namespace dddotNet.Infrastructure.PersistenceLayer.SQLServer;

public sealed class GenocsContext(DbContextOptions options) : DbContext(options)
{
    public DbSet<Account> Accounts { get; set; }
    public DbSet<Customer> Customers { get; set; }
    public DbSet<Credit> Credits { get; set; }
    public DbSet<Debit> Debits { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // MassTransit EntityFrameworkCore Outbox 实体映射
        modelBuilder.AddInboxStateEntity();
        modelBuilder.AddOutboxMessageEntity();
        modelBuilder.AddOutboxStateEntity();

        modelBuilder.Entity<Account>()
            .ToTable("Account");

        modelBuilder.Entity<Account>()
            .Ignore(p => p.Credits)
            .Ignore(p => p.Debits);

        modelBuilder.Entity<Customer>()
            .ToTable("Customer")
            .Property(b => b.SSN)
            .HasConversion(
                v => v.ToString(),
                v => new SSN(v));

        modelBuilder.Entity<Customer>()
            .ToTable("Customer")
            .Property(b => b.Name)
            .HasConversion(
                v => v.ToString(),
                v => new Name(v));

        modelBuilder.Entity<Customer>()
            .Ignore(p => p.Accounts);

        modelBuilder.Entity<Debit>()
            .ToTable("Debit")
            .Property(b => b.Amount)
            .HasConversion(
                v => v.ToMoney().ToDecimal(),
                v => new PositiveMoney(v));

        modelBuilder.Entity<Credit>()
            .ToTable("Credit")
            .Property(b => b.Amount)
            .HasConversion(
                v => v.ToMoney().ToDecimal(),
                v => new PositiveMoney(v));
    }
}

实现与架构抽象对齐的 ServiceBusClient,创建 MassTransitServiceBusClient.cs:

public sealed class MassTransitServiceBusClient(IPublishEndpoint publishEndpoint) : IServiceBusClient
{
    private readonly IPublishEndpoint _publishEndpoint = publishEndpoint ?? throw new ArgumentNullException(nameof(publishEndpoint));

    public async Task SendCommandAsync<T>(T command, CancellationToken cancellationToken = default)
        where T : Genocs.Common.CQRS.Commands.ICommand
    {
        await _publishEndpoint.Publish(command, cancellationToken);
    }

    public async Task PublishEventAsync<T>(T message, CancellationToken cancellationToken = default)
        where T : Genocs.Common.CQRS.Events.IEvent
    {
        await _publishEndpoint.Publish(message, cancellationToken);
    }
}

在 Deposit 用例中,调用了 await _serviceBus.PublishEventAsync(...)。
在这个实现里,底层实际上是调用了 MassTransit 的 _publishEndpoint.Publish(...)。因为这里注入的是 IPublishEndpoint(而不是 IBus 或 ISendEndpoint),当你的项目配置了 MassTransit EF Core Outbox 后,这个 Publish 方法根本不会立刻向 RabbitMQ 发送网络请求

它会将消息实体附加到当前的 EF Core DbContext 中。随后你的 _unitOfWork.SaveAsync() 执行时,业务数据和 Outbox 消息会在同一个本地事务中提交。

然后我们去在 DI 容器中开启 Bus Outbox 管道,创建了配置扩展MassTransitInfrastructureExtensions.cs:

 services.AddMassTransit(x =>
    {
        // 挂载 EF Core 事务性发件箱
        x.AddEntityFrameworkOutbox<GenocsContext>(o =>
        {
            o.UseSqlServer();
            o.UseBusOutbox(); // 启用总线发件箱:所有发布事件进入同一个 DbContext 事务
        });

        // 配置 RabbitMQ 传输
        x.UsingRabbitMq((context, cfg) =>
        {
            cfg.Host(rabbitHost, virtualHost, h =>
            {
                h.Username(rabbitUser);
                h.Password(rabbitPass);
            });
            cfg.ConfigureEndpoints(context);
        });
    });

    // 将抽象接口注入为 Scoped,与当前请求的 DbContext 处于同一个事务生命周期
    services.AddScoped<IServiceBusClient, MassTransitServiceBusClient>();

并在 Program.cs:58 中完成了装配替换:

services.AddMassTransitWithOutbox(builder.Configuration);

这样即便上面的第 3 步 _unitOfWork.SaveAsync() 发生主键冲突或网络断连,业务表与 Outbox 表同时回滚,绝不会向 RabbitMQ发出幽灵消息。

回滚机制

回滚把一个原本无法回滚的网络调用,变成了一个能参与本地事务的数据表写入。

在改造前,当执行到第二步时:

    await _serviceBus.PublishEventAsync(...); // 此时直接通过 TCP 发送 AMQP 数据包给 RabbitMQ
    await _unitOfWork.SaveAsync(...);         // 此时向 SQL Server 发送 SQL

改造和配置了 x.AddEntityFrameworkOutbox<GenocsContext>() 和 o.UseBusOutbox() 之后,底层的执行逻辑发生了根本性改变

当调用了 await _serviceBus.PublishEventAsync(new DepositCompleted { ... }); 的时候,由于开启了UseBusOutbox的MassTransit根本没有向RabbitMQ发送任何网络通信,它仅仅把事件对象序列化为了json文本,创建了一个OutboxMessage数据实体,直接塞进了当前同一个GenocsContext 的内存变更追踪器中。

当紧接着调用: await _unitOfWork.SaveAsync(cancellationToken); 之后,在底层,EF Core 的 SaveChangesAsync 默认会自动打开一个 SQL Server本地事务。它向数据库发送的实际上是这样一段批处理:

   BEGIN TRANSACTION;

    -- ① 插入充值流水业务记录
    INSERT INTO [Credit] ([Id], [AccountId], [Amount], [TransactionDate])
    VALUES ('c1...', 'a1...', 100.00, '2026-10-25');

    -- ② 插入待发消息发件箱记录
    INSERT INTO [OutboxMessage] ([SequenceNumber], [MessageId], [Body], ...)
    VALUES (1, 'm1...', '{"AccountId":"a1...", "Amount":100}', ...);

    COMMIT TRANSACTION;

如果一切正常:两个 INSERT 全部成功,COMMIT TRANSACTION,数据与待发消息同时落盘,如果第 ① 步或第 ② 步有任何报错(例如主键冲突、外键约束、数据库磁盘满、网络超时中断): • 数据库引擎立即触发 ROLLBACK TRANSACTION; • Credit 记录回滚了,OutboxMessage 待发消息记录也同步被物理撤销/回滚! • 内存里的 DbContext 抛出异常,整个用例中止。

在整个过程中,负责真正往 RabbitMQ 发消息的是 MassTransit 注册在后台的独立工作者:

• 这个后台工作者只会去读取数据库中已经真正 Commit 成功的 OutboxMessage 记录。 • 既然刚才的事务回滚了,数据库里压根就没有产生过这条 OutboxMessage 记录! • 后台工作者什么也查不到,因此 RabbitMQ 永远不会收到这笔错误的存款事件。

内存追踪变更器

如果没有 ChangeTracker(内存变更追踪器),上述的 Outbox 机制、工作单元以及整洁架构里的事务一致性,根本无法成立。

这个也是ef core的关键部分,下面我们拆解一下它是如何工作的:

你可以把 DbContext 想象成去超市购物,而 ChangeTracker 就是你的“购物车”:

• 当你挑选商品放进购物车时(调用 AddAsync / 修改属性 / 发送事件),你根本还没有付钱(没有发送任何 SQL,也没有向网络发包)。 • 此时所有的变化,都只是放在这辆购物车里的物品,每个物品上面贴了一张“操作便签”(状态标记)。 • 只有当你推着购物车到收银台,执行 SaveChangesAsync() 时,收银台(数据库)才一口气把车里所有的东西打在一张账单上完成结算(事务提交)。

ef core内部,每一个被管理的对象,都会被包装为一个EntityEntry,ChangeTracker会为其维护一个状态叫做EntiryState

EntityState 的 5 种状态

状态含义场景 / 如何触发SaveChanges() 时的动作
Added新增调用了 _dbContext.Add(entity) 或构造函数创建的新对象被添加生成 INSERT 语句
Unchanged未改变从数据库 GetAsync 查出来,且没做任何修改忽略,不生成 SQL
Modified已修改查出来后,修改了实体的某个属性生成 UPDATE 语句(只更新修改的字段)
Deleted已删除调用了 _dbContext.Remove(entity)生成 DELETE 语句
Detached游离对象没有在 ChangeTracker 的追踪范围内(比如 new 出来但没 Add)完全忽略,即使你改了它也不生效

他如果知道你改了什么:

当你从数据库查出一个实体时,ChangeTracker 会在内存中存放一个快照(Original Values 快照)。当调用 SaveChangesAsync() 时,它会拿当前对象的属性和底片进行比对(Dirty Checking脏检查),发现哪个属性变了,就精准生成对应字段的 UPDATE 语句。

我们来还原在用例 Deposit.cs 执行的那几毫秒内,ChangeTracker 内存里的真实演变:

为什么必须共享同一个changeTracker:

这里有一个至关重要的底层设计 —— 依赖注入的生命周期(Scoped),在 ASP.NET Core 中,每次 HTTP 请求都会创建一个独立的生命周期作用域(Scope)。仓储 AccountRepository 注入的 GenocsContext 是这个 Scope 里的实例,MassTransit Outbox 拿到的也是同一个 GenocsContext 实例,因此,它们共享了同一台 ChangeTracker。

如果没有这套内存共享机制:仓储在自己的连接里写业务,MQ 在自己的地方发消息,两方互不知晓,就必然退化成各自为政的“双写不一致”。

正是因为有了 ChangeTracker 这个内存中转站,我们才能在业务上保持优雅解耦(业务代码只需安心调用 Update 和Publish),而在技术底层却能神不知鬼不觉地把所有的增删改和待发消息“打包捆绑”**在同一个事务里交付数据库。