在我们之前对 SQL Server 使用出站箱(Outbox)模式的探索基础上,本文将该模式适配到 PostgreSQL,并探讨其局限性。目标是确保数据库更新与消息发布之间的事务一致性。我们将使用 .NET 8、Brighter 和 PostgreSQL 实现跨分布式系统的订单创建与事件发布。
本项目的核心是发送一个创建订单的命令。当订单成功创建后,会发布两条消息 OrderPlaced 与 OrderPaid。若发生故障,不应发布任何消息。
本项目需要以下 3 条消息: CreateNewOrder, OrderPlaced 和 OrderPaid
public class CreateNewOrder() : Command(Guid.NewGuid())
{
public decimal Value { get; set; }
}
public class OrderPlaced() : Event(Guid.NewGuid())
{
public string OrderId { get; set; } = string.Empty;
public decimal Value { get; set; }
}
public class OrderPaid() : Event(Guid.NewGuid())
{
public string OrderId { get; set; } = string.Empty;
}
由于仅 OrderPlaced 和 OrderPaid 事件发布到 RabbitMQ,需使用 JSON 序列化实现映射器:
public class OrderPlacedMapper : IAmAMessageMapper
{
public Message MapToMessage(OrderPlaced request)
{
var header = new MessageHeader();
header.Id = request.Id;
header.TimeStamp = DateTime.UtcNow;
header.Topic = "order-placed";
header.MessageType = MessageType.MT_EVENT;
var body = new MessageBody(JsonSerializer.Serialize(request));
return new Message(header, body);
}
public OrderPlaced MapToRequest(Message message)
{
return JsonSerializer.Deserialize(message.Body.Bytes)!;
}
}
public class OrderPaidMapper : IAmAMessageMapper
{
public Message MapToMessage(OrderPaid request)
{
var header = new MessageHeader();
header.Id = request.Id;
header.TimeStamp = DateTime.UtcNow;
header.Topic = "order-paid";
header.MessageType = MessageType.MT_EVENT;
var body = new MessageBody(JsonSerializer.Serialize(request));
return new Message(header, body);
}
public OrderPaid MapToRequest(Message message)
{
return JsonSerializer.Deserialize(message.Body.Bytes)!;
}
}
对OrderPlaced和OrderPaid, 我们记录接收到的消息:
public class OrderPlaceHandler(ILogger logger) : RequestHandlerAsync
{
public override Task HandleAsync(OrderPlaced command, CancellationToken cancellationToken = default)
{
logger.LogInformation("{OrderId} placed with value {OrderValue}", command.OrderId, command.Value);
return base.HandleAsync(command, cancellationToken);
}
}
public class OrderPaidHandler(ILogger logger) : RequestHandlerAsync
{
public override Task HandleAsync(OrderPaid command, CancellationToken cancellationToken = default)
{
logger.LogInformation("{OrderId} paid", command.OrderId);
return base.HandleAsync(command, cancellationToken);
}
}
CreateNewOrder处理器会等待 10ms 模拟处理, 随后发布 OrderPlaced。若订单金额能被 3 整除, 则抛出异常(模拟业务错误), 否则发布OrderPaid。
public class CreateNewOrderHandler(IAmACommandProcessor commandProcessor,
ILogger logger) : RequestHandlerAsync
{
public override async Task HandleAsync(CreateNewOrder command, CancellationToken cancellationToken = default)
{
try
{
string id = Guid.NewGuid().ToString();
logger.LogInformation("Creating a new order: {OrderId}", id);
await Task.Delay(10, cancellationToken); // 模拟处理过程
_ = commandProcessor.DepositPost(new OrderPlaced { OrderId = id, Value = command.Value });
if (command.Value % 3 == 0)
{
throw new InvalidOperationException("invalid value");
}
_ = commandProcessor.DepositPost(new OrderPaid { OrderId = id });
return await base.HandleAsync(command, cancellationToken);
}
catch
{
logger.LogError("Invalid data");
throw;
}
}
}
要将出站箱模式与 PostgreSQL 集成,首先确保存在OutboxMessages表。
CREATE TABLE IF NOT EXISTS "outboxmessages"
(
"id" BIGSERIAL NOT NULL,
"messageid" UUID NOT NULL,
"topic" VARCHAR(255) NULL,
"messagetype" VARCHAR(32) NULL,
"timestamp" TIMESTAMP NULL,
"correlationid" UUID NULL,
"replyto" VARCHAR(255) NULL,
"contenttype" VARCHAR(128) NULL,
"dispatched" TIMESTAMP NULL,
"headerbag" TEXT NULL,
"body" TEXT NULL,
PRIMARY KEY (Id)
);
注册出站箱和事务:
services
.AddServiceActivator(opt => { /* 订阅配置(见前文) */ })
.UsePostgreSqlOutbox(new PostgreSqlOutboxConfiguration(ConnectionString, "OutboxMessages"))
.UseOutboxSweeper(opt => opt.BatchSize = 10);
1. 仅同步操作:Brighter 的 PostgreSQL 出站箱不支持异步方法。
2. 事务隔离:消息与数据库更新无法共享事务,故障时可能导致不一致(尝试解决但始终遇到事务中止错误)。
Brighter v10 计划使 PostgreSQL 出站箱实现与 SQL Server 对齐,包括异步支持和事务保证。
尽管当前 Brighter 对 PostgreSQL 的集成存在局限性(如缺乏异步支持和事务一致性), 但在中等吞吐量场景下仍可行。对于需要严格事务保证的关键负载,SQL Server 的出站箱模式实现更成熟。通过理解这些权衡,团队可根据扩展性和可靠性需求选择合适工具。
GitHub 完整代码
using System.Data;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Npgsql;
using Paramore.Brighter;
using Paramore.Brighter.Extensions.DependencyInjection;
using Paramore.Brighter.Extensions.Hosting;
using Paramore.Brighter.MessagingGateway.RMQ;
using Paramore.Brighter.Outbox.PostgreSql;
using Paramore.Brighter.PostgreSql;
using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection;
using Paramore.Brighter.ServiceActivator.Extensions.Hosting;
using Serilog;
const string ConnectionString = "Host=localhost;Username=postgres;Password=password;Database=brightertests;";
await using (NpgsqlConnection connection = new(ConnectionString))
{
await connection.OpenAsync();
await using NpgsqlCommand command = connection.CreateCommand();
command.CommandText =
"""
CREATE TABLE IF NOT EXISTS "outboxmessages"
(
"id" BIGSERIAL NOT NULL,
"messageid" UUID NOT NULL,
"topic" VARCHAR(255) NULL,
"messagetype" VARCHAR(32) NULL,
"timestamp" TIMESTAMP NULL,
"correlationid" UUID NULL,
"replyto" VARCHAR(255) NULL,
"contenttype" VARCHAR(128) NULL,
"dispatched" TIMESTAMP NULL,
"headerbag" TEXT NULL,
"body" TEXT NULL,
PRIMARY KEY (Id)
);
""";
_ = await command.ExecuteNonQueryAsync();
}
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Paramore.Brighter", Serilog.Events.LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateLogger();
IHost host = new HostBuilder()
.UseSerilog()
.ConfigureServices(
(ctx, services) =>
{
RmqMessagingGatewayConnection connection = new()
{
AmpqUri = new AmqpUriSpecification(new Uri("amqp://guest:guest@localhost:5672")),
Exchange = new Exchange("paramore.brighter.exchange"),
};
_ = services
.AddHostedService()
.AddServiceActivator(opt =>
{
opt.Subscriptions =
[
new RmqSubscription(
new SubscriptionName("subscription"),
new ChannelName("queue-order-placed"),
new RoutingKey("order-placed"),
makeChannels: OnMissingChannel.Create,
runAsync: true
),
new RmqSubscription(
new SubscriptionName("subscription"),
new ChannelName("queue-order-paid"),
new RoutingKey("order-paid"),
makeChannels: OnMissingChannel.Create,
runAsync: true
),
];
opt.ChannelFactory = new ChannelFactory(
new RmqMessageConsumerFactory(connection)
);
})
.AutoFromAssemblies()
.UsePostgreSqlOutbox(new PostgreSqlOutboxConfiguration(ConnectionString, "OutboxMessages"))
.UseOutboxSweeper(opt =>
{
opt.BatchSize = 10;
})
.UseExternalBus(
new RmqProducerRegistryFactory(
connection,
[
new RmqPublication
{
MakeChannels = OnMissingChannel.Create,
Topic = new RoutingKey("order-paid"),
},
new RmqPublication
{
MakeChannels = OnMissingChannel.Create,
Topic = new RoutingKey("order-placed"),
},
]
).Create()
);
}
)
.Build();
await host.StartAsync();
CancellationTokenSource cancellationTokenSource = new();
Console.CancelKeyPress += (_, _) => cancellationTokenSource.Cancel();
while (!cancellationTokenSource.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(10));
Console.Write("Type an order value (or q to quit): ");
string? tmp = Console.ReadLine();
if (string.IsNullOrEmpty(tmp))
{
continue;
}
if (tmp == "q")
{
break;
}
if (!decimal.TryParse(tmp, out decimal value))
{
continue;
}
try
{
using IServiceScope scope = host.Services.CreateScope();
IAmACommandProcessor process = scope.ServiceProvider.GetRequiredService();
await process.SendAsync(new CreateNewOrder { Value = value });
}
catch
{
// ignore any error
}
}
await host.StopAsync();
public class CreateNewOrder() : Command(Guid.NewGuid())
{
public decimal Value { get; set; }
}
public class OrderPlaced() : Event(Guid.NewGuid())
{
public string OrderId { get; set; } = string.Empty;
public decimal Value { get; set; }
}
public class OrderPaid() : Event(Guid.NewGuid())
{
public string OrderId { get; set; } = string.Empty;
}
public class CreateNewOrderHandler(IAmACommandProcessor commandProcessor,
ILogger logger) : RequestHandlerAsync
{
public override async Task HandleAsync(CreateNewOrder command, CancellationToken cancellationToken = default)
{
try
{
string id = Guid.NewGuid().ToString();
logger.LogInformation("Creating a new order: {OrderId}", id);
await Task.Delay(10, cancellationToken); // emulating an process
_ = commandProcessor.DepositPost(new OrderPlaced { OrderId = id, Value = command.Value });
if (command.Value % 3 == 0)
{
throw new InvalidOperationException("invalid value");
}
_ = commandProcessor.DepositPost(new OrderPaid { OrderId = id });
return await base.HandleAsync(command, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Invalid data");
throw;
}
}
}
public class OrderPlaceHandler(ILogger logger) : RequestHandlerAsync
{
public override Task HandleAsync(OrderPlaced command, CancellationToken cancellationToken = default)
{
logger.LogInformation("{OrderId} placed with value {OrderValue}", command.OrderId, command.Value);
return base.HandleAsync(command, cancellationToken);
}
}
public class OrderPaidHandler(ILogger logger) : RequestHandlerAsync
{
public override Task HandleAsync(OrderPaid command, CancellationToken cancellationToken = default)
{
logger.LogInformation("{OrderId} paid", command.OrderId);
return base.HandleAsync(command, cancellationToken);
}
}
public class OrderPlacedMapper : IAmAMessageMapper
{
public Message MapToMessage(OrderPlaced request)
{
var header = new MessageHeader();
header.Id = request.Id;
header.TimeStamp = DateTime.UtcNow;
header.Topic = "order-placed";
header.MessageType = MessageType.MT_EVENT;
var body = new MessageBody(JsonSerializer.Serialize(request));
return new Message(header, body);
}
public OrderPlaced MapToRequest(Message message)
{
return JsonSerializer.Deserialize(message.Body.Bytes)!;
}
}
public class OrderPaidMapper : IAmAMessageMapper
{
public Message MapToMessage(OrderPaid request)
{
var header = new MessageHeader();
header.Id = request.Id;
header.TimeStamp = DateTime.UtcNow;
header.Topic = "order-paid";
header.MessageType = MessageType.MT_EVENT;
var body = new MessageBody(JsonSerializer.Serialize(request));
return new Message(header, body);
}
public OrderPaid MapToRequest(Message message)
{
return JsonSerializer.Deserialize(message.Body.Bytes)!;
}
}