[{"data":1,"prerenderedAt":1822},["ShallowReactive",2],{"page-\u002Farchitecture\u002Fmasstransitservice收件箱模式":3},{"id":4,"title":5,"body":6,"description":12,"extension":1810,"meta":1811,"navigation":105,"path":1818,"seo":1819,"stem":1820,"__hash__":1821},"content\u002Farchitecture\u002FMassTransitService收件箱模式.md","MassTransitService收件箱模式",{"type":7,"value":8,"toc":1804},"minimark",[9,13,16,245,248,251,271,274,278,285,433,436,478,481,549,552,555,558,565,568,571,574,696,699,702,987,990,1067,1102,1117,1120,1234,1237,1246,1252,1255,1258,1261,1276,1283,1446,1453,1460,1517,1520,1523,1526,1530,1533,1536,1539,1542,1545,1548,1698,1701,1704,1707,1785,1788,1791,1797,1800],[10,11,12],"p",{},"在真实企业项目中，通常无需手动手写发件箱轮询，我们会使用MassTransit Outbox，用于解决双写不一致问题。",[10,14,15],{},"我们可以打开CleanArchitecture模板的应用层的UseCase业务编排，看看Deposit.cs ：",[17,18,23],"pre",{"className":19,"code":20,"language":21,"meta":22,"style":22},"language-cs shiki shiki-themes github-light github-dark","public sealed class Deposit(\n                        IEntityFactory entityFactory,\n                        IOutputPort\u003CDepositOutput> outputHandler,\n                        IAccountRepository accountRepository,\n                        IUnitOfWork unitOfWork,\n                        IServiceBusClient serviceBus) : IUseCase\u003CDepositInput>\n{\n    private readonly IEntityFactory _entityFactory = entityFactory ?? throw new ArgumentNullException(nameof(entityFactory));\n    private readonly IOutputPort\u003CDepositOutput> _outputHandler = outputHandler ?? throw new ArgumentNullException(nameof(outputHandler));\n    private readonly IAccountRepository _accountRepository = accountRepository ?? throw new ArgumentNullException(nameof(accountRepository));\n    private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));\n    private readonly IServiceBusClient _serviceBus = serviceBus ?? throw new ArgumentNullException(nameof(serviceBus));\n\n    public async Task ExecuteAsync(DepositInput input, CancellationToken cancellationToken = default)\n    {\n        var account = await _accountRepository.GetAsync(input.AccountId, cancellationToken);\n        if (account == null)\n        {\n            _outputHandler.Error($\"The account {input.AccountId} does not exist or is already closed.\");\n            return;\n        }\n\n        var credit = account.Deposit(_entityFactory, input.Amount);\n\n        await _accountRepository.UpdateAsync(account, credit, cancellationToken);\n\n        await _serviceBus.PublishEventAsync(new Contracts.Events.DepositCompleted() { AccountId = input.AccountId, Amount = input.Amount.ToMoney().ToDecimal() }, cancellationToken);\n\n        await _unitOfWork.SaveAsync(cancellationToken);\n\n        DepositOutput output = new DepositOutput(\n            credit,\n            account.GetCurrentBalance());\n\n        _outputHandler.Default(output);\n    }\n}\n","cs","",[24,25,26,34,40,46,52,58,64,70,76,82,88,94,100,107,113,119,125,131,137,143,149,155,160,166,171,177,182,188,193,199,204,210,216,222,227,233,239],"code",{"__ignoreMap":22},[27,28,31],"span",{"class":29,"line":30},"line",1,[27,32,33],{},"public sealed class Deposit(\n",[27,35,37],{"class":29,"line":36},2,[27,38,39],{},"                        IEntityFactory entityFactory,\n",[27,41,43],{"class":29,"line":42},3,[27,44,45],{},"                        IOutputPort\u003CDepositOutput> outputHandler,\n",[27,47,49],{"class":29,"line":48},4,[27,50,51],{},"                        IAccountRepository accountRepository,\n",[27,53,55],{"class":29,"line":54},5,[27,56,57],{},"                        IUnitOfWork unitOfWork,\n",[27,59,61],{"class":29,"line":60},6,[27,62,63],{},"                        IServiceBusClient serviceBus) : IUseCase\u003CDepositInput>\n",[27,65,67],{"class":29,"line":66},7,[27,68,69],{},"{\n",[27,71,73],{"class":29,"line":72},8,[27,74,75],{},"    private readonly IEntityFactory _entityFactory = entityFactory ?? throw new ArgumentNullException(nameof(entityFactory));\n",[27,77,79],{"class":29,"line":78},9,[27,80,81],{},"    private readonly IOutputPort\u003CDepositOutput> _outputHandler = outputHandler ?? throw new ArgumentNullException(nameof(outputHandler));\n",[27,83,85],{"class":29,"line":84},10,[27,86,87],{},"    private readonly IAccountRepository _accountRepository = accountRepository ?? throw new ArgumentNullException(nameof(accountRepository));\n",[27,89,91],{"class":29,"line":90},11,[27,92,93],{},"    private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));\n",[27,95,97],{"class":29,"line":96},12,[27,98,99],{},"    private readonly IServiceBusClient _serviceBus = serviceBus ?? throw new ArgumentNullException(nameof(serviceBus));\n",[27,101,103],{"class":29,"line":102},13,[27,104,106],{"emptyLinePlaceholder":105},true,"\n",[27,108,110],{"class":29,"line":109},14,[27,111,112],{},"    public async Task ExecuteAsync(DepositInput input, CancellationToken cancellationToken = default)\n",[27,114,116],{"class":29,"line":115},15,[27,117,118],{},"    {\n",[27,120,122],{"class":29,"line":121},16,[27,123,124],{},"        var account = await _accountRepository.GetAsync(input.AccountId, cancellationToken);\n",[27,126,128],{"class":29,"line":127},17,[27,129,130],{},"        if (account == null)\n",[27,132,134],{"class":29,"line":133},18,[27,135,136],{},"        {\n",[27,138,140],{"class":29,"line":139},19,[27,141,142],{},"            _outputHandler.Error($\"The account {input.AccountId} does not exist or is already closed.\");\n",[27,144,146],{"class":29,"line":145},20,[27,147,148],{},"            return;\n",[27,150,152],{"class":29,"line":151},21,[27,153,154],{},"        }\n",[27,156,158],{"class":29,"line":157},22,[27,159,106],{"emptyLinePlaceholder":105},[27,161,163],{"class":29,"line":162},23,[27,164,165],{},"        var credit = account.Deposit(_entityFactory, input.Amount);\n",[27,167,169],{"class":29,"line":168},24,[27,170,106],{"emptyLinePlaceholder":105},[27,172,174],{"class":29,"line":173},25,[27,175,176],{},"        await _accountRepository.UpdateAsync(account, credit, cancellationToken);\n",[27,178,180],{"class":29,"line":179},26,[27,181,106],{"emptyLinePlaceholder":105},[27,183,185],{"class":29,"line":184},27,[27,186,187],{},"        await _serviceBus.PublishEventAsync(new Contracts.Events.DepositCompleted() { AccountId = input.AccountId, Amount = input.Amount.ToMoney().ToDecimal() }, cancellationToken);\n",[27,189,191],{"class":29,"line":190},28,[27,192,106],{"emptyLinePlaceholder":105},[27,194,196],{"class":29,"line":195},29,[27,197,198],{},"        await _unitOfWork.SaveAsync(cancellationToken);\n",[27,200,202],{"class":29,"line":201},30,[27,203,106],{"emptyLinePlaceholder":105},[27,205,207],{"class":29,"line":206},31,[27,208,209],{},"        DepositOutput output = new DepositOutput(\n",[27,211,213],{"class":29,"line":212},32,[27,214,215],{},"            credit,\n",[27,217,219],{"class":29,"line":218},33,[27,220,221],{},"            account.GetCurrentBalance());\n",[27,223,225],{"class":29,"line":224},34,[27,226,106],{"emptyLinePlaceholder":105},[27,228,230],{"class":29,"line":229},35,[27,231,232],{},"        _outputHandler.Default(output);\n",[27,234,236],{"class":29,"line":235},36,[27,237,238],{},"    }\n",[27,240,242],{"class":29,"line":241},37,[27,243,244],{},"}\n",[10,246,247],{},"这个代码比较精彩，可以仔细看，这个代码把输入\u002F输出端口模式使用上了，我们甚至可以直接脱离HTTP上下文，能够被API、GRPC甚至是控制台程序复用。",[10,249,250],{},"它依赖仓储IAccountRepository 获取聚合根，调用聚合根的业务方法 account.Deposit(...)，最后调用 IUnitOfWork.SaveAsync() 统一提交事务。",[17,252,254],{"className":19,"code":253,"language":21,"meta":22,"style":22},"await _accountRepository.UpdateAsync(...); \u002F\u002F 1. 更新\nawait _serviceBus.PublishEventAsync(...);  \u002F\u002F 2. 发消息到 RabbitMQ\u002FRedis\nawait _unitOfWork.SaveAsync(...);          \u002F\u002F 3. 提交数据库事务\n",[24,255,256,261,266],{"__ignoreMap":22},[27,257,258],{"class":29,"line":30},[27,259,260],{},"await _accountRepository.UpdateAsync(...); \u002F\u002F 1. 更新\n",[27,262,263],{"class":29,"line":36},[27,264,265],{},"await _serviceBus.PublishEventAsync(...);  \u002F\u002F 2. 发消息到 RabbitMQ\u002FRedis\n",[27,267,268],{"class":29,"line":42},[27,269,270],{},"await _unitOfWork.SaveAsync(...);          \u002F\u002F 3. 提交数据库事务\n",[10,272,273],{},"如果第二步消息发送成功了，但是第三步的数据库SaveAsyc因为网络抖动或者主键冲突导致失败了，或者是数据库回滚了，那么消费者就会收到存款成功但是没有落库的情况。",[275,276,277],"h2",{"id":277},"事务性收发件",[10,279,280,281],{},"那么怎么避免这种情况，那么应该使用",[282,283,284],"strong",{},"事务性发件箱模式",[17,286,290],{"className":287,"code":288,"language":289,"meta":22,"style":22},"language-mermaid shiki shiki-themes github-light github-dark","flowchart TD\n    subgraph App[\"应用层 \u002F UseCase\"]\n        Business[\"1. 业务逻辑 (如存款)\"]\n        CreateOutbox[\"2. 创建待发事件消息\"]\n    end\n\n    subgraph Database[\"同一个 SQL Server 数据库事务\"]\n        AccountTable[(\"Account \u002F Credit 表\")]\n        OutboxTable[(\"OutboxMessages 表 (发件箱)\")]\n    end\n\n    subgraph BackgroundWorker[\"后台发布进程 (Outbox Processor)\"]\n        Poller[\"定期拉取未发送消息 \u002F CDC\"]\n        MarkSent[\"标记消息为已发送\"]\n    end\n\n    subgraph Broker[\"消息代理 (RabbitMQ)\"]\n        RabbitMQ[\"RabbitMQ 交换机\u002F队列\"]\n    end\n\n    Business -->|更新业务数据| AccountTable\n    CreateOutbox -->|插入待发消息| OutboxTable\n\n    %% 这里加上了双引号，修复了解析错误\n    AccountTable -.->|\"【同一个 SaveAsync() 本地事务】\u003Cbr\u002F>要么同时成功，要么同时回滚！\"| OutboxTable\n\n    Poller -->|读取未发送消息| OutboxTable\n    Poller -->|投递事件| RabbitMQ\n    RabbitMQ -->|ACK 确认| MarkSent\n    MarkSent -->|更新状态为 Processed| OutboxTable\n","mermaid",[24,291,292,297,302,307,312,317,321,326,331,336,340,344,349,354,359,363,367,372,377,381,385,390,395,399,404,409,413,418,423,428],{"__ignoreMap":22},[27,293,294],{"class":29,"line":30},[27,295,296],{},"flowchart TD\n",[27,298,299],{"class":29,"line":36},[27,300,301],{},"    subgraph App[\"应用层 \u002F UseCase\"]\n",[27,303,304],{"class":29,"line":42},[27,305,306],{},"        Business[\"1. 业务逻辑 (如存款)\"]\n",[27,308,309],{"class":29,"line":48},[27,310,311],{},"        CreateOutbox[\"2. 创建待发事件消息\"]\n",[27,313,314],{"class":29,"line":54},[27,315,316],{},"    end\n",[27,318,319],{"class":29,"line":60},[27,320,106],{"emptyLinePlaceholder":105},[27,322,323],{"class":29,"line":66},[27,324,325],{},"    subgraph Database[\"同一个 SQL Server 数据库事务\"]\n",[27,327,328],{"class":29,"line":72},[27,329,330],{},"        AccountTable[(\"Account \u002F Credit 表\")]\n",[27,332,333],{"class":29,"line":78},[27,334,335],{},"        OutboxTable[(\"OutboxMessages 表 (发件箱)\")]\n",[27,337,338],{"class":29,"line":84},[27,339,316],{},[27,341,342],{"class":29,"line":90},[27,343,106],{"emptyLinePlaceholder":105},[27,345,346],{"class":29,"line":96},[27,347,348],{},"    subgraph BackgroundWorker[\"后台发布进程 (Outbox Processor)\"]\n",[27,350,351],{"class":29,"line":102},[27,352,353],{},"        Poller[\"定期拉取未发送消息 \u002F CDC\"]\n",[27,355,356],{"class":29,"line":109},[27,357,358],{},"        MarkSent[\"标记消息为已发送\"]\n",[27,360,361],{"class":29,"line":115},[27,362,316],{},[27,364,365],{"class":29,"line":121},[27,366,106],{"emptyLinePlaceholder":105},[27,368,369],{"class":29,"line":127},[27,370,371],{},"    subgraph Broker[\"消息代理 (RabbitMQ)\"]\n",[27,373,374],{"class":29,"line":133},[27,375,376],{},"        RabbitMQ[\"RabbitMQ 交换机\u002F队列\"]\n",[27,378,379],{"class":29,"line":139},[27,380,316],{},[27,382,383],{"class":29,"line":145},[27,384,106],{"emptyLinePlaceholder":105},[27,386,387],{"class":29,"line":151},[27,388,389],{},"    Business -->|更新业务数据| AccountTable\n",[27,391,392],{"class":29,"line":157},[27,393,394],{},"    CreateOutbox -->|插入待发消息| OutboxTable\n",[27,396,397],{"class":29,"line":162},[27,398,106],{"emptyLinePlaceholder":105},[27,400,401],{"class":29,"line":168},[27,402,403],{},"    %% 这里加上了双引号，修复了解析错误\n",[27,405,406],{"class":29,"line":173},[27,407,408],{},"    AccountTable -.->|\"【同一个 SaveAsync() 本地事务】\u003Cbr\u002F>要么同时成功，要么同时回滚！\"| OutboxTable\n",[27,410,411],{"class":29,"line":179},[27,412,106],{"emptyLinePlaceholder":105},[27,414,415],{"class":29,"line":184},[27,416,417],{},"    Poller -->|读取未发送消息| OutboxTable\n",[27,419,420],{"class":29,"line":190},[27,421,422],{},"    Poller -->|投递事件| RabbitMQ\n",[27,424,425],{"class":29,"line":195},[27,426,427],{},"    RabbitMQ -->|ACK 确认| MarkSent\n",[27,429,430],{"class":29,"line":201},[27,431,432],{},"    MarkSent -->|更新状态为 Processed| OutboxTable\n",[10,434,435],{},"可以在数据库新建一张叫做OutboxMessage的表：",[17,437,441],{"className":438,"code":439,"language":440,"meta":22,"style":22},"language-sql shiki shiki-themes github-light github-dark","CREATE TABLE OutboxMessages (\n    Id UNIQUEIDENTIFIER PRIMARY KEY,\n    OccurredOn DATETIME2 NOT NULL,\n    Type NVARCHAR(255) NOT NULL,\n    Content NVARCHAR(MAX) NOT NULL,\n    ProcessedOn DATETIME2 NULL\n);\n","sql",[24,442,443,448,453,458,463,468,473],{"__ignoreMap":22},[27,444,445],{"class":29,"line":30},[27,446,447],{},"CREATE TABLE OutboxMessages (\n",[27,449,450],{"class":29,"line":36},[27,451,452],{},"    Id UNIQUEIDENTIFIER PRIMARY KEY,\n",[27,454,455],{"class":29,"line":42},[27,456,457],{},"    OccurredOn DATETIME2 NOT NULL,\n",[27,459,460],{"class":29,"line":48},[27,461,462],{},"    Type NVARCHAR(255) NOT NULL,\n",[27,464,465],{"class":29,"line":54},[27,466,467],{},"    Content NVARCHAR(MAX) NOT NULL,\n",[27,469,470],{"class":29,"line":60},[27,471,472],{},"    ProcessedOn DATETIME2 NULL\n",[27,474,475],{"class":29,"line":66},[27,476,477],{},");\n",[10,479,480],{},"在用例中统一落库：",[17,482,484],{"className":19,"code":483,"language":21,"meta":22,"style":22},"  \u002F\u002F 业务更新\n    await _accountRepository.UpdateAsync(account, credit, cancellationToken);\n\n    \u002F\u002F 不直接调 RabbitMQ 发网络请求，而是向本地 Outbox 表塞一条记录\n    _context.OutboxMessages.Add(new OutboxMessage(\n        id: Guid.NewGuid(),\n        type: nameof(DepositCompleted),\n        content: JsonSerializer.Serialize(new DepositCompleted { ... })\n    ));\n\n    \u002F\u002F 关键点：业务数据和 Outbox 消息在【同一个数据库事务中原子提交】！\n    \u002F\u002F 如果报错，全部回滚，绝对不会产生“幽灵消息”！\n    await _unitOfWork.SaveAsync(cancellationToken);\n",[24,485,486,491,496,500,505,510,515,520,525,530,534,539,544],{"__ignoreMap":22},[27,487,488],{"class":29,"line":30},[27,489,490],{},"  \u002F\u002F 业务更新\n",[27,492,493],{"class":29,"line":36},[27,494,495],{},"    await _accountRepository.UpdateAsync(account, credit, cancellationToken);\n",[27,497,498],{"class":29,"line":42},[27,499,106],{"emptyLinePlaceholder":105},[27,501,502],{"class":29,"line":48},[27,503,504],{},"    \u002F\u002F 不直接调 RabbitMQ 发网络请求，而是向本地 Outbox 表塞一条记录\n",[27,506,507],{"class":29,"line":54},[27,508,509],{},"    _context.OutboxMessages.Add(new OutboxMessage(\n",[27,511,512],{"class":29,"line":60},[27,513,514],{},"        id: Guid.NewGuid(),\n",[27,516,517],{"class":29,"line":66},[27,518,519],{},"        type: nameof(DepositCompleted),\n",[27,521,522],{"class":29,"line":72},[27,523,524],{},"        content: JsonSerializer.Serialize(new DepositCompleted { ... })\n",[27,526,527],{"class":29,"line":78},[27,528,529],{},"    ));\n",[27,531,532],{"class":29,"line":84},[27,533,106],{"emptyLinePlaceholder":105},[27,535,536],{"class":29,"line":90},[27,537,538],{},"    \u002F\u002F 关键点：业务数据和 Outbox 消息在【同一个数据库事务中原子提交】！\n",[27,540,541],{"class":29,"line":96},[27,542,543],{},"    \u002F\u002F 如果报错，全部回滚，绝对不会产生“幽灵消息”！\n",[27,545,546],{"class":29,"line":102},[27,547,548],{},"    await _unitOfWork.SaveAsync(cancellationToken);\n",[10,550,551],{},"后台 Worker 异步保障投递：",[10,553,554],{},"• 项目中的 dddotNet.Worker 可以起一个后台任务（BackgroundService）；\n• 每隔几百毫秒扫一次 OutboxMessages 表中 ProcessedOn IS NULL 的记录，投递给 RabbitMQ；\n• RabbitMQ 返回确认（ACK）后，打上 ProcessedOn = DateTime.UtcNow；\n• 即使宕机，下次重启会继续扫表重投（保证至少投递一次 At-Least-Once Delivery）。",[10,556,557],{},"在真实的开发场景中，通常是无需手写发件箱轮询的，因为.net生态有两个及其成熟的解决方案：",[10,559,560,561,564],{},"MassTransit Outbox（推荐）：如果该项目后续将 Rebus 升级为 MassTransit，直接开启：",[24,562,563],{},"cfg.AddEntityFrameworkOutbox\u003CGenocsContext>()"," 业务代码只需 await publishEndpoint.Publish(...)，框架会自动劫持并走 Outbox 事务落库与后台派发。",[10,566,567],{},"DotNetCore.CAP：国内广泛采用的分布式事务与 EventBus 方案，原生内置基于 EF Core 的 Outbox 实现与自动重试机制。",[10,569,570],{},"我们这里通过MassTransit Outbox来改造一下，后续也可以将rebus升级为masstransit。",[10,572,573],{},"核心执行时序（Outbox 怎么解决双写问题）：",[17,575,577],{"className":287,"code":576,"language":289,"meta":22,"style":22},"sequenceDiagram\n    autonumber\n    participant UC as UseCase (Deposit)\n    participant Repo as AccountRepo\n    participant Bus as MassTransit\n    participant DB as SQL Server\n    participant Worker as 后台调度器\n    participant MQ as RabbitMQ\n\n    UC->>Repo: 1. 更新业务聚合状态\n    Repo->>DB: 变更挂入 EF Core ChangeTracker\n    UC->>Bus: 2. _serviceBus.PublishEventAsync(...)\n    Note over Bus,DB: 【关键】：MassTransit 不直接发网络请求\u003Cbr\u002F>而是将消息实体挂入同一个 DbContext！\n    \n    UC->>DB: 3. await _unitOfWork.SaveAsync()\n    Note over DB: 【同一个本地 ACID 事务】提交：\u003Cbr\u002F>• 业务流水表更新\u003Cbr\u002F>• OutboxMessage 待发消息表插入\u003Cbr\u002F>若任一失败，全部原子回滚！\n\n    rect rgb(230, 245, 230)\n    Note over Worker,MQ: 【后台异步保障投递（至少投递一次）】\n    Worker->>DB: 轮询检测已提交的 Outbox 消息\n    Worker->>MQ: 投递到 RabbitMQ\n    MQ-->>Worker: ACK 确认\n    Worker->>DB: 标记消息为已发送 \u002F 清理\n    end\n",[24,578,579,584,589,594,599,604,609,614,619,623,628,633,638,643,648,653,658,662,667,672,677,682,687,692],{"__ignoreMap":22},[27,580,581],{"class":29,"line":30},[27,582,583],{},"sequenceDiagram\n",[27,585,586],{"class":29,"line":36},[27,587,588],{},"    autonumber\n",[27,590,591],{"class":29,"line":42},[27,592,593],{},"    participant UC as UseCase (Deposit)\n",[27,595,596],{"class":29,"line":48},[27,597,598],{},"    participant Repo as AccountRepo\n",[27,600,601],{"class":29,"line":54},[27,602,603],{},"    participant Bus as MassTransit\n",[27,605,606],{"class":29,"line":60},[27,607,608],{},"    participant DB as SQL Server\n",[27,610,611],{"class":29,"line":66},[27,612,613],{},"    participant Worker as 后台调度器\n",[27,615,616],{"class":29,"line":72},[27,617,618],{},"    participant MQ as RabbitMQ\n",[27,620,621],{"class":29,"line":78},[27,622,106],{"emptyLinePlaceholder":105},[27,624,625],{"class":29,"line":84},[27,626,627],{},"    UC->>Repo: 1. 更新业务聚合状态\n",[27,629,630],{"class":29,"line":90},[27,631,632],{},"    Repo->>DB: 变更挂入 EF Core ChangeTracker\n",[27,634,635],{"class":29,"line":96},[27,636,637],{},"    UC->>Bus: 2. _serviceBus.PublishEventAsync(...)\n",[27,639,640],{"class":29,"line":102},[27,641,642],{},"    Note over Bus,DB: 【关键】：MassTransit 不直接发网络请求\u003Cbr\u002F>而是将消息实体挂入同一个 DbContext！\n",[27,644,645],{"class":29,"line":109},[27,646,647],{},"    \n",[27,649,650],{"class":29,"line":115},[27,651,652],{},"    UC->>DB: 3. await _unitOfWork.SaveAsync()\n",[27,654,655],{"class":29,"line":121},[27,656,657],{},"    Note over DB: 【同一个本地 ACID 事务】提交：\u003Cbr\u002F>• 业务流水表更新\u003Cbr\u002F>• OutboxMessage 待发消息表插入\u003Cbr\u002F>若任一失败，全部原子回滚！\n",[27,659,660],{"class":29,"line":127},[27,661,106],{"emptyLinePlaceholder":105},[27,663,664],{"class":29,"line":133},[27,665,666],{},"    rect rgb(230, 245, 230)\n",[27,668,669],{"class":29,"line":139},[27,670,671],{},"    Note over Worker,MQ: 【后台异步保障投递（至少投递一次）】\n",[27,673,674],{"class":29,"line":145},[27,675,676],{},"    Worker->>DB: 轮询检测已提交的 Outbox 消息\n",[27,678,679],{"class":29,"line":151},[27,680,681],{},"    Worker->>MQ: 投递到 RabbitMQ\n",[27,683,684],{"class":29,"line":157},[27,685,686],{},"    MQ-->>Worker: ACK 确认\n",[27,688,689],{"class":29,"line":162},[27,690,691],{},"    Worker->>DB: 标记消息为已发送 \u002F 清理\n",[27,693,694],{"class":29,"line":168},[27,695,316],{},[10,697,698],{},"实现：",[10,700,701],{},"在 Infrastructure.csproj 中引入了 MassTransit 的 EF Core 发件箱与 RabbitMQ 传输库，我们在GenocsContext.cs:18-21 的 OnModelCreating 中注入发件箱表映射：",[17,703,705],{"className":19,"code":704,"language":21,"meta":22,"style":22},"namespace dddotNet.Infrastructure.PersistenceLayer.SQLServer;\n\npublic sealed class GenocsContext(DbContextOptions options) : DbContext(options)\n{\n    public DbSet\u003CAccount> Accounts { get; set; }\n    public DbSet\u003CCustomer> Customers { get; set; }\n    public DbSet\u003CCredit> Credits { get; set; }\n    public DbSet\u003CDebit> Debits { get; set; }\n\n    protected override void OnModelCreating(ModelBuilder modelBuilder)\n    {\n        base.OnModelCreating(modelBuilder);\n\n        \u002F\u002F MassTransit EntityFrameworkCore Outbox 实体映射\n        modelBuilder.AddInboxStateEntity();\n        modelBuilder.AddOutboxMessageEntity();\n        modelBuilder.AddOutboxStateEntity();\n\n        modelBuilder.Entity\u003CAccount>()\n            .ToTable(\"Account\");\n\n        modelBuilder.Entity\u003CAccount>()\n            .Ignore(p => p.Credits)\n            .Ignore(p => p.Debits);\n\n        modelBuilder.Entity\u003CCustomer>()\n            .ToTable(\"Customer\")\n            .Property(b => b.SSN)\n            .HasConversion(\n                v => v.ToString(),\n                v => new SSN(v));\n\n        modelBuilder.Entity\u003CCustomer>()\n            .ToTable(\"Customer\")\n            .Property(b => b.Name)\n            .HasConversion(\n                v => v.ToString(),\n                v => new Name(v));\n\n        modelBuilder.Entity\u003CCustomer>()\n            .Ignore(p => p.Accounts);\n\n        modelBuilder.Entity\u003CDebit>()\n            .ToTable(\"Debit\")\n            .Property(b => b.Amount)\n            .HasConversion(\n                v => v.ToMoney().ToDecimal(),\n                v => new PositiveMoney(v));\n\n        modelBuilder.Entity\u003CCredit>()\n            .ToTable(\"Credit\")\n            .Property(b => b.Amount)\n            .HasConversion(\n                v => v.ToMoney().ToDecimal(),\n                v => new PositiveMoney(v));\n    }\n}\n\n",[24,706,707,712,716,721,725,730,735,740,745,749,754,758,763,767,772,777,782,787,791,796,801,805,809,814,819,823,828,833,838,843,848,853,857,861,865,870,874,878,884,889,894,900,905,911,917,923,928,934,940,945,951,957,962,967,972,977,982],{"__ignoreMap":22},[27,708,709],{"class":29,"line":30},[27,710,711],{},"namespace dddotNet.Infrastructure.PersistenceLayer.SQLServer;\n",[27,713,714],{"class":29,"line":36},[27,715,106],{"emptyLinePlaceholder":105},[27,717,718],{"class":29,"line":42},[27,719,720],{},"public sealed class GenocsContext(DbContextOptions options) : DbContext(options)\n",[27,722,723],{"class":29,"line":48},[27,724,69],{},[27,726,727],{"class":29,"line":54},[27,728,729],{},"    public DbSet\u003CAccount> Accounts { get; set; }\n",[27,731,732],{"class":29,"line":60},[27,733,734],{},"    public DbSet\u003CCustomer> Customers { get; set; }\n",[27,736,737],{"class":29,"line":66},[27,738,739],{},"    public DbSet\u003CCredit> Credits { get; set; }\n",[27,741,742],{"class":29,"line":72},[27,743,744],{},"    public DbSet\u003CDebit> Debits { get; set; }\n",[27,746,747],{"class":29,"line":78},[27,748,106],{"emptyLinePlaceholder":105},[27,750,751],{"class":29,"line":84},[27,752,753],{},"    protected override void OnModelCreating(ModelBuilder modelBuilder)\n",[27,755,756],{"class":29,"line":90},[27,757,118],{},[27,759,760],{"class":29,"line":96},[27,761,762],{},"        base.OnModelCreating(modelBuilder);\n",[27,764,765],{"class":29,"line":102},[27,766,106],{"emptyLinePlaceholder":105},[27,768,769],{"class":29,"line":109},[27,770,771],{},"        \u002F\u002F MassTransit EntityFrameworkCore Outbox 实体映射\n",[27,773,774],{"class":29,"line":115},[27,775,776],{},"        modelBuilder.AddInboxStateEntity();\n",[27,778,779],{"class":29,"line":121},[27,780,781],{},"        modelBuilder.AddOutboxMessageEntity();\n",[27,783,784],{"class":29,"line":127},[27,785,786],{},"        modelBuilder.AddOutboxStateEntity();\n",[27,788,789],{"class":29,"line":133},[27,790,106],{"emptyLinePlaceholder":105},[27,792,793],{"class":29,"line":139},[27,794,795],{},"        modelBuilder.Entity\u003CAccount>()\n",[27,797,798],{"class":29,"line":145},[27,799,800],{},"            .ToTable(\"Account\");\n",[27,802,803],{"class":29,"line":151},[27,804,106],{"emptyLinePlaceholder":105},[27,806,807],{"class":29,"line":157},[27,808,795],{},[27,810,811],{"class":29,"line":162},[27,812,813],{},"            .Ignore(p => p.Credits)\n",[27,815,816],{"class":29,"line":168},[27,817,818],{},"            .Ignore(p => p.Debits);\n",[27,820,821],{"class":29,"line":173},[27,822,106],{"emptyLinePlaceholder":105},[27,824,825],{"class":29,"line":179},[27,826,827],{},"        modelBuilder.Entity\u003CCustomer>()\n",[27,829,830],{"class":29,"line":184},[27,831,832],{},"            .ToTable(\"Customer\")\n",[27,834,835],{"class":29,"line":190},[27,836,837],{},"            .Property(b => b.SSN)\n",[27,839,840],{"class":29,"line":195},[27,841,842],{},"            .HasConversion(\n",[27,844,845],{"class":29,"line":201},[27,846,847],{},"                v => v.ToString(),\n",[27,849,850],{"class":29,"line":206},[27,851,852],{},"                v => new SSN(v));\n",[27,854,855],{"class":29,"line":212},[27,856,106],{"emptyLinePlaceholder":105},[27,858,859],{"class":29,"line":218},[27,860,827],{},[27,862,863],{"class":29,"line":224},[27,864,832],{},[27,866,867],{"class":29,"line":229},[27,868,869],{},"            .Property(b => b.Name)\n",[27,871,872],{"class":29,"line":235},[27,873,842],{},[27,875,876],{"class":29,"line":241},[27,877,847],{},[27,879,881],{"class":29,"line":880},38,[27,882,883],{},"                v => new Name(v));\n",[27,885,887],{"class":29,"line":886},39,[27,888,106],{"emptyLinePlaceholder":105},[27,890,892],{"class":29,"line":891},40,[27,893,827],{},[27,895,897],{"class":29,"line":896},41,[27,898,899],{},"            .Ignore(p => p.Accounts);\n",[27,901,903],{"class":29,"line":902},42,[27,904,106],{"emptyLinePlaceholder":105},[27,906,908],{"class":29,"line":907},43,[27,909,910],{},"        modelBuilder.Entity\u003CDebit>()\n",[27,912,914],{"class":29,"line":913},44,[27,915,916],{},"            .ToTable(\"Debit\")\n",[27,918,920],{"class":29,"line":919},45,[27,921,922],{},"            .Property(b => b.Amount)\n",[27,924,926],{"class":29,"line":925},46,[27,927,842],{},[27,929,931],{"class":29,"line":930},47,[27,932,933],{},"                v => v.ToMoney().ToDecimal(),\n",[27,935,937],{"class":29,"line":936},48,[27,938,939],{},"                v => new PositiveMoney(v));\n",[27,941,943],{"class":29,"line":942},49,[27,944,106],{"emptyLinePlaceholder":105},[27,946,948],{"class":29,"line":947},50,[27,949,950],{},"        modelBuilder.Entity\u003CCredit>()\n",[27,952,954],{"class":29,"line":953},51,[27,955,956],{},"            .ToTable(\"Credit\")\n",[27,958,960],{"class":29,"line":959},52,[27,961,922],{},[27,963,965],{"class":29,"line":964},53,[27,966,842],{},[27,968,970],{"class":29,"line":969},54,[27,971,933],{},[27,973,975],{"class":29,"line":974},55,[27,976,939],{},[27,978,980],{"class":29,"line":979},56,[27,981,238],{},[27,983,985],{"class":29,"line":984},57,[27,986,244],{},[10,988,989],{},"实现与架构抽象对齐的 ServiceBusClient，创建 MassTransitServiceBusClient.cs：",[17,991,993],{"className":19,"code":992,"language":21,"meta":22,"style":22},"public sealed class MassTransitServiceBusClient(IPublishEndpoint publishEndpoint) : IServiceBusClient\n{\n    private readonly IPublishEndpoint _publishEndpoint = publishEndpoint ?? throw new ArgumentNullException(nameof(publishEndpoint));\n\n    public async Task SendCommandAsync\u003CT>(T command, CancellationToken cancellationToken = default)\n        where T : Genocs.Common.CQRS.Commands.ICommand\n    {\n        await _publishEndpoint.Publish(command, cancellationToken);\n    }\n\n    public async Task PublishEventAsync\u003CT>(T message, CancellationToken cancellationToken = default)\n        where T : Genocs.Common.CQRS.Events.IEvent\n    {\n        await _publishEndpoint.Publish(message, cancellationToken);\n    }\n}\n\n",[24,994,995,1000,1004,1009,1013,1018,1023,1027,1032,1036,1040,1045,1050,1054,1059,1063],{"__ignoreMap":22},[27,996,997],{"class":29,"line":30},[27,998,999],{},"public sealed class MassTransitServiceBusClient(IPublishEndpoint publishEndpoint) : IServiceBusClient\n",[27,1001,1002],{"class":29,"line":36},[27,1003,69],{},[27,1005,1006],{"class":29,"line":42},[27,1007,1008],{},"    private readonly IPublishEndpoint _publishEndpoint = publishEndpoint ?? throw new ArgumentNullException(nameof(publishEndpoint));\n",[27,1010,1011],{"class":29,"line":48},[27,1012,106],{"emptyLinePlaceholder":105},[27,1014,1015],{"class":29,"line":54},[27,1016,1017],{},"    public async Task SendCommandAsync\u003CT>(T command, CancellationToken cancellationToken = default)\n",[27,1019,1020],{"class":29,"line":60},[27,1021,1022],{},"        where T : Genocs.Common.CQRS.Commands.ICommand\n",[27,1024,1025],{"class":29,"line":66},[27,1026,118],{},[27,1028,1029],{"class":29,"line":72},[27,1030,1031],{},"        await _publishEndpoint.Publish(command, cancellationToken);\n",[27,1033,1034],{"class":29,"line":78},[27,1035,238],{},[27,1037,1038],{"class":29,"line":84},[27,1039,106],{"emptyLinePlaceholder":105},[27,1041,1042],{"class":29,"line":90},[27,1043,1044],{},"    public async Task PublishEventAsync\u003CT>(T message, CancellationToken cancellationToken = default)\n",[27,1046,1047],{"class":29,"line":96},[27,1048,1049],{},"        where T : Genocs.Common.CQRS.Events.IEvent\n",[27,1051,1052],{"class":29,"line":102},[27,1053,118],{},[27,1055,1056],{"class":29,"line":109},[27,1057,1058],{},"        await _publishEndpoint.Publish(message, cancellationToken);\n",[27,1060,1061],{"class":29,"line":115},[27,1062,238],{},[27,1064,1065],{"class":29,"line":121},[27,1066,244],{},[10,1068,1069,1070,1073,1074,1077,1078,1081,1082,1085,1086,1089,1090,1093,1094,1097,1098,1101],{},"在 ",[24,1071,1072],{},"Deposit"," 用例中，调用了 ",[24,1075,1076],{},"await _serviceBus.PublishEventAsync(...)","。",[1079,1080],"br",{},"\n在这个实现里，底层实际上是调用了 MassTransit 的 ",[24,1083,1084],{},"_publishEndpoint.Publish(...)","。因为这里注入的是 ",[24,1087,1088],{},"IPublishEndpoint","（而不是 ",[24,1091,1092],{},"IBus"," 或 ",[24,1095,1096],{},"ISendEndpoint","），当你的项目配置了 MassTransit EF Core Outbox 后，这个 ",[24,1099,1100],{},"Publish"," 方法根本不会立刻向 RabbitMQ 发送网络请求",[10,1103,1104,1105,1108,1109,1112,1113,1116],{},"它会将消息实体附加到当前的 EF Core ",[24,1106,1107],{},"DbContext"," 中。随后你的 ",[24,1110,1111],{},"_unitOfWork.SaveAsync()"," 执行时，业务数据和 Outbox 消息会在",[282,1114,1115],{},"同一个本地事务","中提交。",[10,1118,1119],{},"然后我们去在 DI 容器中开启 Bus Outbox 管道，创建了配置扩展MassTransitInfrastructureExtensions.cs：",[17,1121,1123],{"className":19,"code":1122,"language":21,"meta":22,"style":22}," services.AddMassTransit(x =>\n    {\n        \u002F\u002F 挂载 EF Core 事务性发件箱\n        x.AddEntityFrameworkOutbox\u003CGenocsContext>(o =>\n        {\n            o.UseSqlServer();\n            o.UseBusOutbox(); \u002F\u002F 启用总线发件箱：所有发布事件进入同一个 DbContext 事务\n        });\n\n        \u002F\u002F 配置 RabbitMQ 传输\n        x.UsingRabbitMq((context, cfg) =>\n        {\n            cfg.Host(rabbitHost, virtualHost, h =>\n            {\n                h.Username(rabbitUser);\n                h.Password(rabbitPass);\n            });\n            cfg.ConfigureEndpoints(context);\n        });\n    });\n\n    \u002F\u002F 将抽象接口注入为 Scoped，与当前请求的 DbContext 处于同一个事务生命周期\n    services.AddScoped\u003CIServiceBusClient, MassTransitServiceBusClient>();\n",[24,1124,1125,1130,1134,1139,1144,1148,1153,1158,1163,1167,1172,1177,1181,1186,1191,1196,1201,1206,1211,1215,1220,1224,1229],{"__ignoreMap":22},[27,1126,1127],{"class":29,"line":30},[27,1128,1129],{}," services.AddMassTransit(x =>\n",[27,1131,1132],{"class":29,"line":36},[27,1133,118],{},[27,1135,1136],{"class":29,"line":42},[27,1137,1138],{},"        \u002F\u002F 挂载 EF Core 事务性发件箱\n",[27,1140,1141],{"class":29,"line":48},[27,1142,1143],{},"        x.AddEntityFrameworkOutbox\u003CGenocsContext>(o =>\n",[27,1145,1146],{"class":29,"line":54},[27,1147,136],{},[27,1149,1150],{"class":29,"line":60},[27,1151,1152],{},"            o.UseSqlServer();\n",[27,1154,1155],{"class":29,"line":66},[27,1156,1157],{},"            o.UseBusOutbox(); \u002F\u002F 启用总线发件箱：所有发布事件进入同一个 DbContext 事务\n",[27,1159,1160],{"class":29,"line":72},[27,1161,1162],{},"        });\n",[27,1164,1165],{"class":29,"line":78},[27,1166,106],{"emptyLinePlaceholder":105},[27,1168,1169],{"class":29,"line":84},[27,1170,1171],{},"        \u002F\u002F 配置 RabbitMQ 传输\n",[27,1173,1174],{"class":29,"line":90},[27,1175,1176],{},"        x.UsingRabbitMq((context, cfg) =>\n",[27,1178,1179],{"class":29,"line":96},[27,1180,136],{},[27,1182,1183],{"class":29,"line":102},[27,1184,1185],{},"            cfg.Host(rabbitHost, virtualHost, h =>\n",[27,1187,1188],{"class":29,"line":109},[27,1189,1190],{},"            {\n",[27,1192,1193],{"class":29,"line":115},[27,1194,1195],{},"                h.Username(rabbitUser);\n",[27,1197,1198],{"class":29,"line":121},[27,1199,1200],{},"                h.Password(rabbitPass);\n",[27,1202,1203],{"class":29,"line":127},[27,1204,1205],{},"            });\n",[27,1207,1208],{"class":29,"line":133},[27,1209,1210],{},"            cfg.ConfigureEndpoints(context);\n",[27,1212,1213],{"class":29,"line":139},[27,1214,1162],{},[27,1216,1217],{"class":29,"line":145},[27,1218,1219],{},"    });\n",[27,1221,1222],{"class":29,"line":151},[27,1223,106],{"emptyLinePlaceholder":105},[27,1225,1226],{"class":29,"line":157},[27,1227,1228],{},"    \u002F\u002F 将抽象接口注入为 Scoped，与当前请求的 DbContext 处于同一个事务生命周期\n",[27,1230,1231],{"class":29,"line":162},[27,1232,1233],{},"    services.AddScoped\u003CIServiceBusClient, MassTransitServiceBusClient>();\n",[10,1235,1236],{},"并在 Program.cs:58 中完成了装配替换：",[17,1238,1240],{"className":19,"code":1239,"language":21,"meta":22,"style":22},"services.AddMassTransitWithOutbox(builder.Configuration);\n",[24,1241,1242],{"__ignoreMap":22},[27,1243,1244],{"class":29,"line":30},[27,1245,1239],{},[10,1247,1248,1249,1251],{},"这样即便上面的第 3 步 ",[24,1250,1111],{}," 发生主键冲突或网络断连，业务表与 Outbox 表同时回滚，绝不会向 RabbitMQ发出幽灵消息。",[275,1253,1254],{"id":1254},"回滚机制",[10,1256,1257],{},"回滚把一个原本无法回滚的网络调用，变成了一个能参与本地事务的数据表写入。",[10,1259,1260],{},"在改造前，当执行到第二步时：",[17,1262,1264],{"className":19,"code":1263,"language":21,"meta":22,"style":22},"    await _serviceBus.PublishEventAsync(...); \u002F\u002F 此时直接通过 TCP 发送 AMQP 数据包给 RabbitMQ\n    await _unitOfWork.SaveAsync(...);         \u002F\u002F 此时向 SQL Server 发送 SQL\n",[24,1265,1266,1271],{"__ignoreMap":22},[27,1267,1268],{"class":29,"line":30},[27,1269,1270],{},"    await _serviceBus.PublishEventAsync(...); \u002F\u002F 此时直接通过 TCP 发送 AMQP 数据包给 RabbitMQ\n",[27,1272,1273],{"class":29,"line":36},[27,1274,1275],{},"    await _unitOfWork.SaveAsync(...);         \u002F\u002F 此时向 SQL Server 发送 SQL\n",[10,1277,1278,1279,1282],{},"改造和配置了 ",[24,1280,1281],{},"x.AddEntityFrameworkOutbox\u003CGenocsContext>() 和 o.UseBusOutbox()"," 之后，底层的执行逻辑发生了根本性改变",[17,1284,1286],{"className":287,"code":1285,"language":289,"meta":22,"style":22},"  flowchart TD\n        subgraph App[\"1. 业务调用阶段\"]\n            Repo[\"UpdateAsync (挂载 Credit 实体)\"]\n            Publish[\"PublishEventAsync (被 Outbox 拦截)\"]\n        end\n\n        subgraph Memory[\"EF Core 内存 ChangeTracker (变更追踪器)\"]\n            Entity1[\"待插入实体：Credit\"]\n            Entity2[\"待插入实体：OutboxMessage (事件被序列化为数据)\"]\n        end\n\n        subgraph DB[\"2. 数据库事务执行阶段 (SaveAsync)\"]\n            SQL[\"BEGIN TRANSACTION;\n                 INSERT INTO Credit ...;\n                 INSERT INTO OutboxMessage ...;\"]\n            Check{执行是否异常?\u003Cbr\u002F>主键冲突 \u002F 网络故障}\n            Commit[\"COMMIT TRANSACTION;\u003Cbr\u002F>(业务表与消息表同时永久保存)\"]\n            Rollback[\"ROLLBACK TRANSACTION;\u003Cbr\u002F>(Credit 和 OutboxMessage 同时抹去)\"]\n        end\n\n        subgraph AsyncWorker[\"3. 后台发信阶段 (Outbox Processor)\"]\n            Poller[\"扫描数据库已提交的 OutboxMessage\"]\n            SendMQ[\"向 RabbitMQ 发送消息并 ACK\"]\n        end\n\n        Repo --> Entity1\n        Publish -->|【不发网络请求】仅转为实体| Entity2\n        Entity1 --> SQL\n        Entity2 --> SQL\n        SQL --> Check\n        Check -- 无异常 --> Commit --> Poller --> SendMQ\n        Check -- 发生异常 --> Rollback\n        Rollback -.->|库里根本没有消息| Poller\n",[24,1287,1288,1293,1298,1303,1308,1313,1317,1322,1327,1332,1336,1340,1345,1350,1355,1360,1365,1370,1375,1379,1383,1388,1393,1398,1402,1406,1411,1416,1421,1426,1431,1436,1441],{"__ignoreMap":22},[27,1289,1290],{"class":29,"line":30},[27,1291,1292],{},"  flowchart TD\n",[27,1294,1295],{"class":29,"line":36},[27,1296,1297],{},"        subgraph App[\"1. 业务调用阶段\"]\n",[27,1299,1300],{"class":29,"line":42},[27,1301,1302],{},"            Repo[\"UpdateAsync (挂载 Credit 实体)\"]\n",[27,1304,1305],{"class":29,"line":48},[27,1306,1307],{},"            Publish[\"PublishEventAsync (被 Outbox 拦截)\"]\n",[27,1309,1310],{"class":29,"line":54},[27,1311,1312],{},"        end\n",[27,1314,1315],{"class":29,"line":60},[27,1316,106],{"emptyLinePlaceholder":105},[27,1318,1319],{"class":29,"line":66},[27,1320,1321],{},"        subgraph Memory[\"EF Core 内存 ChangeTracker (变更追踪器)\"]\n",[27,1323,1324],{"class":29,"line":72},[27,1325,1326],{},"            Entity1[\"待插入实体：Credit\"]\n",[27,1328,1329],{"class":29,"line":78},[27,1330,1331],{},"            Entity2[\"待插入实体：OutboxMessage (事件被序列化为数据)\"]\n",[27,1333,1334],{"class":29,"line":84},[27,1335,1312],{},[27,1337,1338],{"class":29,"line":90},[27,1339,106],{"emptyLinePlaceholder":105},[27,1341,1342],{"class":29,"line":96},[27,1343,1344],{},"        subgraph DB[\"2. 数据库事务执行阶段 (SaveAsync)\"]\n",[27,1346,1347],{"class":29,"line":102},[27,1348,1349],{},"            SQL[\"BEGIN TRANSACTION;\n",[27,1351,1352],{"class":29,"line":109},[27,1353,1354],{},"                 INSERT INTO Credit ...;\n",[27,1356,1357],{"class":29,"line":115},[27,1358,1359],{},"                 INSERT INTO OutboxMessage ...;\"]\n",[27,1361,1362],{"class":29,"line":121},[27,1363,1364],{},"            Check{执行是否异常?\u003Cbr\u002F>主键冲突 \u002F 网络故障}\n",[27,1366,1367],{"class":29,"line":127},[27,1368,1369],{},"            Commit[\"COMMIT TRANSACTION;\u003Cbr\u002F>(业务表与消息表同时永久保存)\"]\n",[27,1371,1372],{"class":29,"line":133},[27,1373,1374],{},"            Rollback[\"ROLLBACK TRANSACTION;\u003Cbr\u002F>(Credit 和 OutboxMessage 同时抹去)\"]\n",[27,1376,1377],{"class":29,"line":139},[27,1378,1312],{},[27,1380,1381],{"class":29,"line":145},[27,1382,106],{"emptyLinePlaceholder":105},[27,1384,1385],{"class":29,"line":151},[27,1386,1387],{},"        subgraph AsyncWorker[\"3. 后台发信阶段 (Outbox Processor)\"]\n",[27,1389,1390],{"class":29,"line":157},[27,1391,1392],{},"            Poller[\"扫描数据库已提交的 OutboxMessage\"]\n",[27,1394,1395],{"class":29,"line":162},[27,1396,1397],{},"            SendMQ[\"向 RabbitMQ 发送消息并 ACK\"]\n",[27,1399,1400],{"class":29,"line":168},[27,1401,1312],{},[27,1403,1404],{"class":29,"line":173},[27,1405,106],{"emptyLinePlaceholder":105},[27,1407,1408],{"class":29,"line":179},[27,1409,1410],{},"        Repo --> Entity1\n",[27,1412,1413],{"class":29,"line":184},[27,1414,1415],{},"        Publish -->|【不发网络请求】仅转为实体| Entity2\n",[27,1417,1418],{"class":29,"line":190},[27,1419,1420],{},"        Entity1 --> SQL\n",[27,1422,1423],{"class":29,"line":195},[27,1424,1425],{},"        Entity2 --> SQL\n",[27,1427,1428],{"class":29,"line":201},[27,1429,1430],{},"        SQL --> Check\n",[27,1432,1433],{"class":29,"line":206},[27,1434,1435],{},"        Check -- 无异常 --> Commit --> Poller --> SendMQ\n",[27,1437,1438],{"class":29,"line":212},[27,1439,1440],{},"        Check -- 发生异常 --> Rollback\n",[27,1442,1443],{"class":29,"line":218},[27,1444,1445],{},"        Rollback -.->|库里根本没有消息| Poller\n",[10,1447,1448,1449,1452],{},"当调用了 ",[24,1450,1451],{},"await _serviceBus.PublishEventAsync(new DepositCompleted { ... });"," 的时候，由于开启了UseBusOutbox的MassTransit根本没有向RabbitMQ发送任何网络通信，它仅仅把事件对象序列化为了json文本，创建了一个OutboxMessage数据实体，直接塞进了当前同一个GenocsContext 的内存变更追踪器中。",[10,1454,1455,1456,1459],{},"当紧接着调用： ",[24,1457,1458],{},"await _unitOfWork.SaveAsync(cancellationToken);"," 之后，在底层，EF Core 的 SaveChangesAsync 默认会自动打开一个 SQL Server本地事务。它向数据库发送的实际上是这样一段批处理：",[17,1461,1463],{"className":438,"code":1462,"language":440,"meta":22,"style":22},"   BEGIN TRANSACTION;\n\n    -- ① 插入充值流水业务记录\n    INSERT INTO [Credit] ([Id], [AccountId], [Amount], [TransactionDate])\n    VALUES ('c1...', 'a1...', 100.00, '2026-10-25');\n\n    -- ② 插入待发消息发件箱记录\n    INSERT INTO [OutboxMessage] ([SequenceNumber], [MessageId], [Body], ...)\n    VALUES (1, 'm1...', '{\"AccountId\":\"a1...\", \"Amount\":100}', ...);\n\n    COMMIT TRANSACTION;\n",[24,1464,1465,1470,1474,1479,1484,1489,1493,1498,1503,1508,1512],{"__ignoreMap":22},[27,1466,1467],{"class":29,"line":30},[27,1468,1469],{},"   BEGIN TRANSACTION;\n",[27,1471,1472],{"class":29,"line":36},[27,1473,106],{"emptyLinePlaceholder":105},[27,1475,1476],{"class":29,"line":42},[27,1477,1478],{},"    -- ① 插入充值流水业务记录\n",[27,1480,1481],{"class":29,"line":48},[27,1482,1483],{},"    INSERT INTO [Credit] ([Id], [AccountId], [Amount], [TransactionDate])\n",[27,1485,1486],{"class":29,"line":54},[27,1487,1488],{},"    VALUES ('c1...', 'a1...', 100.00, '2026-10-25');\n",[27,1490,1491],{"class":29,"line":60},[27,1492,106],{"emptyLinePlaceholder":105},[27,1494,1495],{"class":29,"line":66},[27,1496,1497],{},"    -- ② 插入待发消息发件箱记录\n",[27,1499,1500],{"class":29,"line":72},[27,1501,1502],{},"    INSERT INTO [OutboxMessage] ([SequenceNumber], [MessageId], [Body], ...)\n",[27,1504,1505],{"class":29,"line":78},[27,1506,1507],{},"    VALUES (1, 'm1...', '{\"AccountId\":\"a1...\", \"Amount\":100}', ...);\n",[27,1509,1510],{"class":29,"line":84},[27,1511,106],{"emptyLinePlaceholder":105},[27,1513,1514],{"class":29,"line":90},[27,1515,1516],{},"    COMMIT TRANSACTION;\n",[10,1518,1519],{},"如果一切正常：两个 INSERT 全部成功，COMMIT TRANSACTION，数据与待发消息同时落盘，如果第 ① 步或第 ② 步有任何报错（例如主键冲突、外键约束、数据库磁盘满、网络超时中断）：\n• 数据库引擎立即触发 ROLLBACK TRANSACTION；\n• Credit 记录回滚了，OutboxMessage 待发消息记录也同步被物理撤销\u002F回滚！\n• 内存里的 DbContext 抛出异常，整个用例中止。",[10,1521,1522],{},"在整个过程中，负责真正往 RabbitMQ 发消息的是 MassTransit 注册在后台的独立工作者：",[10,1524,1525],{},"• 这个后台工作者只会去读取数据库中已经真正 Commit 成功的 OutboxMessage 记录。\n• 既然刚才的事务回滚了，数据库里压根就没有产生过这条 OutboxMessage 记录！\n• 后台工作者什么也查不到，因此 RabbitMQ 永远不会收到这笔错误的存款事件。",[1527,1528,1529],"h3",{"id":1529},"内存追踪变更器",[10,1531,1532],{},"如果没有 ChangeTracker（内存变更追踪器），上述的 Outbox 机制、工作单元以及整洁架构里的事务一致性，根本无法成立。",[10,1534,1535],{},"这个也是ef core的关键部分，下面我们拆解一下它是如何工作的：",[10,1537,1538],{},"你可以把 DbContext 想象成去超市购物，而 ChangeTracker 就是你的“购物车”：",[10,1540,1541],{},"• 当你挑选商品放进购物车时（调用 AddAsync \u002F 修改属性 \u002F 发送事件），你根本还没有付钱（没有发送任何\nSQL，也没有向网络发包）。\n• 此时所有的变化，都只是放在这辆购物车里的物品，每个物品上面贴了一张“操作便签”（状态标记）。\n• 只有当你推着购物车到收银台，执行 SaveChangesAsync()\n时，收银台（数据库）才一口气把车里所有的东西打在一张账单上完成结算（事务提交）。",[10,1543,1544],{},"ef core内部，每一个被管理的对象，都会被包装为一个EntityEntry，ChangeTracker会为其维护一个状态叫做EntiryState",[10,1546,1547],{},"EntityState 的 5 种状态",[1549,1550,1551,1573],"table",{},[1552,1553,1554],"thead",{},[1555,1556,1557,1561,1564,1567],"tr",{},[1558,1559,1560],"th",{},"状态",[1558,1562,1563],{},"含义",[1558,1565,1566],{},"场景 \u002F 如何触发",[1558,1568,1569,1572],{},[24,1570,1571],{},"SaveChanges()"," 时的动作",[1574,1575,1576,1603,1625,1646,1668],"tbody",{},[1555,1577,1578,1586,1589,1596],{},[1579,1580,1581],"td",{},[282,1582,1583],{},[24,1584,1585],{},"Added",[1579,1587,1588],{},"新增",[1579,1590,1591,1592,1595],{},"调用了 ",[24,1593,1594],{},"_dbContext.Add(entity)"," 或构造函数创建的新对象被添加",[1579,1597,1598,1599,1602],{},"生成 ",[24,1600,1601],{},"INSERT"," 语句",[1555,1604,1605,1612,1615,1622],{},[1579,1606,1607],{},[282,1608,1609],{},[24,1610,1611],{},"Unchanged",[1579,1613,1614],{},"未改变",[1579,1616,1617,1618,1621],{},"从数据库 ",[24,1619,1620],{},"GetAsync"," 查出来，且没做任何修改",[1579,1623,1624],{},"忽略，不生成 SQL",[1555,1626,1627,1634,1637,1640],{},[1579,1628,1629],{},[282,1630,1631],{},[24,1632,1633],{},"Modified",[1579,1635,1636],{},"已修改",[1579,1638,1639],{},"查出来后，修改了实体的某个属性",[1579,1641,1598,1642,1645],{},[24,1643,1644],{},"UPDATE"," 语句（只更新修改的字段）",[1555,1647,1648,1655,1658,1663],{},[1579,1649,1650],{},[282,1651,1652],{},[24,1653,1654],{},"Deleted",[1579,1656,1657],{},"已删除",[1579,1659,1591,1660],{},[24,1661,1662],{},"_dbContext.Remove(entity)",[1579,1664,1598,1665,1602],{},[24,1666,1667],{},"DELETE",[1555,1669,1670,1677,1680,1695],{},[1579,1671,1672],{},[282,1673,1674],{},[24,1675,1676],{},"Detached",[1579,1678,1679],{},"游离",[1579,1681,1682,1683,1686,1687,1690,1691,1694],{},"对象没有在 ",[24,1684,1685],{},"ChangeTracker"," 的追踪范围内（比如 ",[24,1688,1689],{},"new"," 出来但没 ",[24,1692,1693],{},"Add","）",[1579,1696,1697],{},"完全忽略，即使你改了它也不生效",[10,1699,1700],{},"他如果知道你改了什么：",[10,1702,1703],{},"当你从数据库查出一个实体时，ChangeTracker 会在内存中存放一个快照（Original Values 快照）。当调用 SaveChangesAsync() 时，它会拿当前对象的属性和底片进行比对（Dirty Checking脏检查），发现哪个属性变了，就精准生成对应字段的 UPDATE 语句。",[10,1705,1706],{},"我们来还原在用例 Deposit.cs 执行的那几毫秒内，ChangeTracker 内存里的真实演变：",[17,1708,1710],{"className":287,"code":1709,"language":289,"meta":22,"style":22},"sequenceDiagram\n    autonumber\n    participant Code as Code (UseCase 代码)\n    participant CT as EF Core ChangeTracker\u003Cbr\u002F>(内存中)\n    participant DB as SQL Server\n\n    Code->>CT: 1. accountRepository.UpdateAsync(account, credit)\n    Note right of CT: 实际上只是在内存中把实体的状态标记为\u003Cbr\u002F>Modified \u002F Added，并未发 SQL\n\n    Code->>CT: 2. _serviceBus.PublishEventAsync(...)\n    Note right of CT: MassTransit 拦截发布，把消息挂载到\u003Cbr\u002F>ChangeTracker 的 Outbox 表中 (Added)\n\n    Code->>CT: 3. unitOfWork.SaveAsync() (收银结算时刻)\n    \n    CT->>DB: 发送合并的 SQL 事务批处理 (INSERT \u002F UPDATE)\n    Note over DB: 同一个本地 ACID 事务\u003Cbr\u002F>要么同时成功，要么同时回滚\n",[24,1711,1712,1716,1720,1725,1730,1734,1738,1743,1748,1752,1757,1762,1766,1771,1775,1780],{"__ignoreMap":22},[27,1713,1714],{"class":29,"line":30},[27,1715,583],{},[27,1717,1718],{"class":29,"line":36},[27,1719,588],{},[27,1721,1722],{"class":29,"line":42},[27,1723,1724],{},"    participant Code as Code (UseCase 代码)\n",[27,1726,1727],{"class":29,"line":48},[27,1728,1729],{},"    participant CT as EF Core ChangeTracker\u003Cbr\u002F>(内存中)\n",[27,1731,1732],{"class":29,"line":54},[27,1733,608],{},[27,1735,1736],{"class":29,"line":60},[27,1737,106],{"emptyLinePlaceholder":105},[27,1739,1740],{"class":29,"line":66},[27,1741,1742],{},"    Code->>CT: 1. accountRepository.UpdateAsync(account, credit)\n",[27,1744,1745],{"class":29,"line":72},[27,1746,1747],{},"    Note right of CT: 实际上只是在内存中把实体的状态标记为\u003Cbr\u002F>Modified \u002F Added，并未发 SQL\n",[27,1749,1750],{"class":29,"line":78},[27,1751,106],{"emptyLinePlaceholder":105},[27,1753,1754],{"class":29,"line":84},[27,1755,1756],{},"    Code->>CT: 2. _serviceBus.PublishEventAsync(...)\n",[27,1758,1759],{"class":29,"line":90},[27,1760,1761],{},"    Note right of CT: MassTransit 拦截发布，把消息挂载到\u003Cbr\u002F>ChangeTracker 的 Outbox 表中 (Added)\n",[27,1763,1764],{"class":29,"line":96},[27,1765,106],{"emptyLinePlaceholder":105},[27,1767,1768],{"class":29,"line":102},[27,1769,1770],{},"    Code->>CT: 3. unitOfWork.SaveAsync() (收银结算时刻)\n",[27,1772,1773],{"class":29,"line":109},[27,1774,647],{},[27,1776,1777],{"class":29,"line":115},[27,1778,1779],{},"    CT->>DB: 发送合并的 SQL 事务批处理 (INSERT \u002F UPDATE)\n",[27,1781,1782],{"class":29,"line":121},[27,1783,1784],{},"    Note over DB: 同一个本地 ACID 事务\u003Cbr\u002F>要么同时成功，要么同时回滚\n",[10,1786,1787],{},"为什么必须共享同一个changeTracker：",[10,1789,1790],{},"这里有一个至关重要的底层设计 —— 依赖注入的生命周期（Scoped），在 ASP.NET Core 中，每次 HTTP 请求都会创建一个独立的生命周期作用域（Scope）。仓储 AccountRepository 注入的 GenocsContext 是这个 Scope 里的实例，MassTransit Outbox 拿到的也是同一个 GenocsContext 实例，因此，它们共享了同一台 ChangeTracker。",[1792,1793,1794],"blockquote",{},[10,1795,1796],{},"如果没有这套内存共享机制：仓储在自己的连接里写业务，MQ 在自己的地方发消息，两方互不知晓，就必然退化成各自为政的“双写不一致”。",[10,1798,1799],{},"正是因为有了 ChangeTracker 这个内存中转站，我们才能在业务上保持优雅解耦（业务代码只需安心调用 Update 和Publish），而在技术底层却能神不知鬼不觉地把所有的增删改和待发消息“打包捆绑”**在同一个事务里交付数据库。",[1801,1802,1803],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":22,"searchDepth":36,"depth":36,"links":1805},[1806,1807],{"id":277,"depth":36,"text":277},{"id":1254,"depth":36,"text":1254,"children":1808},[1809],{"id":1529,"depth":42,"text":1529},"md",{"date":1812,"tags":1813},"2026-08-06",[1814,1815,1816,1817],"architecture","masstransit","aspnet","outbox","\u002Farchitecture\u002Fmasstransitservice收件箱模式",{"description":12},"architecture\u002FMassTransitService收件箱模式","d-UeL5Qw1f2kfnFcN7_oH70pEiTaaMoFHcbQVn2zHD8",1792926849909]