在上周的邮件中,我谈到了如何实现Outbox模式[1]。它是可靠分布式消息处理的关键工具之一。但实现它只是第一步而已。
[1]:https://www.milanjovanovic.tech/blog/implementing-the-outbox-pattern
真正的挑战是,如何让其处理庞大的消息量。
今天我们要更上一层楼。从一个基本的Outbox处理器开始,将其升级为一个能够每天可以处理超过20亿条消息的高性能处理器。
让我们跳进去吧!
开始点这是我们的起点。我们有一个OutboxProcessor
会轮询未处理的消息并将它们发布到队列里。首先,我们可以调整的是频率和批大小。
internal sealed class OutboxProcessor(NpgsqlDataSource dataSource, IPublishEndpoint publishEndpoint)
{
private const int 批大小 = 1000;
public async Task<int> Execute(CancellationToken cancellationToken = default)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var 消息 = await connection.QueryAsync<OutboxMessage>(
@"""
SELECT *
FROM outbox_messages
WHERE 处理时间 IS NULL
ORDER BY 发生时间 LIMIT @批大小 OFFSET 0 """,
new { 批大小 },
transaction: transaction);
foreach (var message in 消息)
{
try
{
var 消息类型 = Messaging.Contracts.AssemblyReference.Assembly.GetType(message.Type);
var 反序列化消息 = JsonSerializer.Deserialize(message.Content, 消息类型);
await publishEndpoint.Publish(反序列化消息, 消息类型, cancellationToken);
await connection.ExecuteAsync(
@"""
UPDATE outbox_messages
SET 处理时间 = @ProcessedOnUtc
WHERE id = @Id
""",
new { ProcessedOnUtc = DateTime.UtcNow, message.Id },
transaction: transaction);
}
catch (Exception ex)
{
await connection.ExecuteAsync(
@"""
UPDATE outbox_messages
SET 处理时间 = @ProcessedOnUtc, 错误 = @Error
WHERE id = @Id
""",
new { ProcessedOnUtc = DateTime.UtcNow, 错误 = ex.ToString(), message.Id },
transaction: transaction);
}
}
await transaction.CommitAsync(cancellationToken);
return 消息.Count;
}
}
假设我们一直运行 OutboxProcessor
,并且我将批量处理的数量增加到了 1000
。
我们能处理多少条信息?
我会运行出箱处理程序1分钟,并数一下处理了多少消息。
基准实现每分钟处理了81,000条消息,在每分钟内,相当于1,350 MPS(每秒的消息数)。
还可以,但看看咱们能不能把它弄得更好。
每一步的衡量你无法改进你无法衡量的事物,是吧?所以,我会使用一个 Stopwatch
(计时器)来测量总的执行时间和每个步骤的时间。
我还将发布和更新步骤分开,注意,这样我就可以分别测量发布和更新所需的时间。这在未来会很重要,因为我希望分别优化每个步骤的表现。
基于基准实现,运行时间如下所示:
- 查询所需时间:约70毫秒
- 发布所需时间:约320毫秒
- 更新所需时间:约300毫秒
内部 sealed 类 OutboxProcessor(
NpgsqlDataSource dataSource,
IPublishEndpoint publishEndpoint,
ILogger<OutboxProcessor> logger)
{
private const int 批处理大小 = 1000;
public async Task<int> Execute(CancellationToken cancellationToken = default)
{
var totalStopwatch = Stopwatch.StartNew();
var stepStopwatch = new Stopwatch();
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
stepStopwatch.Restart();
var messages = (await connection.QueryAsync<OutboxMessage>(
@"
SELECT *
FROM outbox_messages
WHERE processed_on_utc IS NULL
ORDER BY occurred_on_utc LIMIT @BatchSize
",
new { BatchSize },
transaction: transaction)).AsList();
var 查询时间 = stepStopwatch.ElapsedMilliseconds;
var updateQueue = new ConcurrentQueue<OutboxUpdate>();
stepStopwatch.Restart();
foreach (var message in messages)
{
try
{
var messageType = Messaging.Contracts.AssemblyReference.Assembly.GetType(message.Type);
var deserializedMessage = JsonSerializer.Deserialize(message.Content, messageType);
await publishEndpoint.Publish(deserializedMessage, messageType, cancellationToken);
updateQueue.Enqueue(new OutboxUpdate
{
Id = message.Id,
ProcessedOnUtc = DateTime.UtcNow
});
}
catch (Exception ex)
{
updateQueue.Enqueue(new OutboxUpdate
{
Id = message.Id,
ProcessedOnUtc = DateTime.UtcNow,
错误 = ex.ToString()
});
}
}
var 发布时间 = stepStopwatch.ElapsedMilliseconds;
stepStopwatch.Restart();
foreach (var outboxUpdate in updateQueue)
{
await connection.ExecuteAsync(
@"
UPDATE outbox_messages
SET processed_on_utc = @ProcessedOnUtc, 错误 = @Error
WHERE id = @Id
",
outboxUpdate,
transaction: transaction);
}
var 更新时间 = stepStopwatch.ElapsedMilliseconds;
await 事务.CommitAsync(cancellationToken);
totalStopwatch.Stop();
var 总时间 = totalStopwatch.ElapsedMilliseconds;
OutboxLoggers.Processing(logger, 总时间, 查询时间, 发布时间, 更新时间, messages.Count);
return messages.Count;
}
private 结构体 OutboxUpdate
{
public Guid Id { get; init; }
public DateTime ProcessedOnUtc { get; init; }
public string? 错误 { get; init; }
}
}
现在,到了有趣的部分了!
基于优化的读查询我首先想优化的是获取未处理消息的查询,如果不需要所有列(提示:我们不需要),执行SELECT *
查询可能会有影响。
下面的 SQL 查询:
SELECT *
FROM outbox_messages
WHERE processed_on_utc IS NULL
ORDER BY occurred_on_utc LIMIT @BatchSize
我们可以修改查询,只返回我们所需的列。这将节省一点带宽,并且不会显著提升性能。
SELECT id AS Id, type AS Type, content AS Content
FROM outbox_messages
WHERE processed_on_utc IS NULL
ORDER BY occurred_on_utc LIMIT @BatchSize
让我们来看看这个查询的执行计划。你会发现它正在执行全表扫描。我在PostgreSQL上运行这个查询,从EXPLAIN ANALYZE
命令得到的结果如下所示:
限制(LIMIT) (cost=86169.40..86286.08 rows=1000 width=129) (实际时间=122.744..124.234 行=1000 循环=1)
-> 合并收集(GATHER MERGE) (cost=86169.40..245080.50 rows=1362000 width=129) (实际时间=122.743..124.198 行=1000 循环=1)
计划中的并行工人数: 2
实际启动的并行工人数: 2
-> 排序 (cost=85169.38..86871.88 rows=681000 width=129) (实际时间=121.478..121.492 行=607 循环=3)
排序键: occurred_on_utc
排序方法: top-N 堆排序,内存使用量: 306kB
工作者 0: 排序方法: top-N 堆排序,内存使用量: 306kB
工作者 1: 排序方法: top-N 堆排序,内存使用量: 306kB
-> 并行顺序扫描 outbox_messages 表 (cost=0.00..47830.88 rows=681000 width=129) (实际时间=0.016..67.481 行=666667 循环=3)
过滤器: processed_on_utc IS NULL
规划时间: 0.051 毫秒
执行时间: 124.298 毫秒
现在,我将创建一个“涵盖”查询以用于获取未处理消息的索引。该索引包含了满足查询所需的所有列,因此无需实际访问表本身。
索引将建立在 occurred_on_utc
和 processed_on_utc
这两列上。它还将包含 id
,type
和 content
这几列。最后,我们将只筛选未处理的消息。
-- 创建索引如果不存在 `idx_outbox_messages_unprocessed`
-- 在 `public.outbox_messages` 上 (occurred_on_utc, processed_on_utc)
-- 包含 (id, 类型, 内容)
-- 且 processed_on_utc 为空
CREATE INDEX IF NOT EXISTS idx_outbox_messages_unprocessed
ON public.outbox_messages (occurred_on_utc, processed_on_utc)
INCLUDE (id, type, content)
WHERE processed_on_utc IS NULL
让我来说明每个决定背后的原因。
- 对
occurred_on_utc
建立索引会使这些条目按升序排列。这与查询中的ORDER BY occurred_on_utc
语句相匹配,这意味着查询可以直接使用索引而无需额外排序结果。结果已经是正确的排序。 - 在索引中加入我们选择的列,这样可以直接从索引中获取这些列的值。这样就无需从表行中读取这些值。
- 在索引中筛选未处理的消息,这满足了
WHERE processed_on_utc IS NULL
的条件。
需注意:PostgreSQL 的索引行最大大小为 2712B(想知道是怎么来的吗?别问了)。INCLUDE
列表中的列也是索引行的一部分(B-tree 结构中的元组)。由于 content
列包含序列化的 JSON 消息,因此它是最可能的问题所在。这个限制是无法避免的,所以建议尽量保持消息内容尽可能小。可以考虑不把这列放在 INCLUDE
列表里,这样只会稍微影响一下性能。
这是创建索引后更新的执行计划:
限制 (cost=0.43..102.82, rows=1000, width=129) (实际时间=0.016..0.160 rows=1000 loops=1)
-> 只读索引扫描 outbox_messages 表,使用 idx_outbox_messages_unprocessed 索引 (cost=0.43..204777.36, rows=2000000, width=129) (实际时间=0.015..0.125 rows=1000 loops=1)
Heap Fetches: 0
计划时间: 0.059 ms
执行时间: 0.189 ms
因为我们使用了覆盖索引,执行计划里只有Index Only Scan
和Limit
两个操作。不用做任何过滤或排序,这就是性能大幅提升的原因。
查询对查询时间有什么影响?
- 查询时间从70毫秒缩短到1毫秒(减少了98.5%)
接下来我们可以优化的是我们发布消息到队列的方式。我用MassTransit的IPublishEndpoint
来发布消息到RabbitMQ。
更准确地说,我们正在向一个交易所提交消息。然后,交易所会将其转发到合适的队列。
那么我们怎么才能优化它呢?
我们可以进行一个小优化,为序列化使用的消息类型引入缓存。频繁执行每种消息类型的反射成本很高,所以只需做一次反射并保存结果。
var messageType = Messaging.Contracts.AssemblyReference.Assembly.GetType(message.Type);
获取消息类型,该类型由 message.Type
定义,并通过 Messaging.Contracts.AssemblyReference.Assembly.GetType
方法解析。
缓存可以使用 ConcurrentDictionary
,我们将使用 GetOrAdd
来获取缓存类型。
我会把这段代码移到 GetOrAddMessageType
方法里。
private static readonly ConcurrentDictionary<string, Type> TypeCache = new(); // 类型缓存
/// 获取或添加消息类型,以减少重复加载
private static Type GetOrAddMessageType(string typeName)
{
return TypeCache.GetOrAdd(
typeName, // 消息类型名称
name => Messaging.Contracts.AssemblyReference.Assembly.GetType(name)); // 获取指定名称的类型
}
// 这里我们使用缓存来存储消息类型,以提高性能
这是我们消息发布的步骤,看起来是这样的。最大的问题是我们在等待Publish
完成。Publish
需要一些时间,因为它要等待消息代理的确认。我们在循环中这么做,效率更低得多。
var updateQueue = new ConcurrentQueue<OutboxUpdate>();
foreach (var message in messages)
{
try
{
var messageType = Messaging.Contracts.AssemblyReference.Assembly.GetType(message.Type);
var deserializedMessage = JsonSerializer.Deserialize(message.Content, messageType);
// 等待消息中间件确认。
await publishEndpoint.Publish(deserializedMessage, messageType, cancellationToken);
updateQueue.Enqueue(new OutboxUpdate
{
Id = message.Id,
UtcProcessedOn = DateTime.UtcNow
});
}
catch (Exception ex)
{
updateQueue.Enqueue(new OutboxUpdate
{
Id = message.Id,
UtcProcessedOn = DateTime.UtcNow,
Error = ex.ToString()
});
// 记录异常信息
}
}
我们可以通过批量发布消息来改进这一点。实际上,IPublishEndpoint
有一个 PublishBatch
扩展方法。如果我们进一步查看,会发现如下内容:
// MassTransit 的实现
// 批量发布消息
public static Task PublishBatch(
this IPublishEndpoint endpoint,
IEnumerable<object> messages,
CancellationToken cancellationToken = default) // (默认值)取消令牌
{
// 将每个消息发布出去
// 等待所有任务完成
return Task.WhenAll(messages.Select(x => endpoint.Publish(x, cancellationToken)));
}
因此,我们可以将消息集转换成一个可以使用 Task.WhenAll
等待的任务项列表。
var updateQueue = new ConcurrentQueue<OutboxUpdate>();
var publishTasks = messages
.Select(message => PublishMessage(message, updateQueue, publishEndpoint, cancellationToken))
.ToList();
await Task.WhenAll(publishTasks);
// 为了提高可读性,我将消息发布提取到了一个单独的方法中。
private static async Task PublishMessage(
OutboxMessage message,
ConcurrentQueue<OutboxUpdate> updateQueue,
IPublishEndpoint publishEndpoint,
CancellationToken cancellationToken)
{
try
{
var messageType = GetOrAddMessageType(message.Type);
var deserializedMessage = JsonSerializer.Deserialize(message.Content, messageType); // 将消息内容反序列化为指定的消息类型。
await publishEndpoint.Publish(deserializedMessage, messageType, cancellationToken);
updateQueue.Enqueue(new OutboxUpdate
{
Id = message.Id,
ProcessedOnUtc = DateTime.UtcNow
});
}
catch (Exception ex)
{
updateQueue.Enqueue(new OutboxUpdate
{
Id = message.Id,
ProcessedOnUtc = DateTime.UtcNow,
Error = ex.Message // 捕获异常消息,以供错误记录使用。
});
}
}
消息发布这步改了什么?
- 发布时间: 320毫秒 → 289毫秒 (减少了9.8%)
正如你可以看到的,它并没有快太多。但这对我们利用我准备要做的其他优化很有必要。
更新查询优化我们优化过程中的下一步是应对更新已处理的出箱信息的查询。
当前的实现效率不高,因为我们每次发送发送箱消息时都要向数据库发送一次查询请求,这样更加自然流畅。
foreach (var outboxUpdate in updateQueue)
{
await connection.ExecuteAsync(
@"""
UPDATE outbox_messages
SET processed_on_utc = @ProcessedOnUtc, error = @Error
WHERE id = @Id
""",
outboxUpdate,
transaction: transaction);
}
如果你到现在还不知道,批量处理才是关键。我们希望发送一个大的 UPDATE
操作到数据库。
我们需要手动编写这个批处理查询的SQL。然后,我们将使用Dapper中的DynamicParameters
类型来提供所有参数值。
var updateSql =
@"""
更新表 outbox_messages
设置 processed_on_utc = v.processed_on_utc 和 error = v.error
来自 (值为
{0}
) AS v(id, processed_on_utc, error)
其中 outbox_messages.id 与 v.id::uuid 相等
""";
var updates = updateQueue.ToList();
var paramNames = string.Join(',', updates.Select((_, i) => $"(@Id{i}, @ProcessedOn{i}, @Error{i})"));
var formattedSql = string.Format(updateSql, paramNames);
var parameters = new DynamicParameters();
for (int i = 0; i < updates.Count; i++)
{
parameters.Add($"Id{i}", updates[i].Id.ToString());
parameters.Add($"ProcessedOn{i}", updates[i].ProcessedOnUtc);
parameters.Add($"Error{i}", updates[i].Error);
}
await connection.ExecuteAsync(formattedSql, parameters, transaction: transaction);
这将生成一个这样的SQL查询。
UPDATE outbox_messages
SET processed_on_utc = v.processed_on_utc,
error = v.error
FROM (VALUES
(@Id0, @ProcessedOn0, @Error0),
(@Id1, @ProcessedOn1, @Error1),
(@Id2, @ProcessedOn2, @Error2),
-- 中间还有好几百行数据
(@Id999, @ProcessedOn999, @Error999)
) AS v(id, processed_on_utc, error)
WHERE outbox_messages.id = v.id::uuid
而不是发送一个更新请求给每个消息,我们可以发送一个查询来更新所有消息。
这显然会让我们在性能上得到明显的好处:
- 更新时间:300毫秒 → 52毫秒(减少了82.6%)
让我们用当前的优化来测试一下性能改进的效果,目前为止我们所做的主要是在提高OutboxProcessor
的速度。
这是我看到的每个步骤的大致数字。
- 查询时长:约1毫秒左右
- 发布时长:约289毫秒左右
- 更新时长:约52毫秒左右
我会运行出箱处理一分钟,然后统计处理的消息数。
优化后的实现每分钟处理了162,000条消息,相当于每秒2,700条消息。
参考一下,这让我们每天可以处理超过2.3亿条消息
但我们才刚开始呢。
并行处理发送队列如果我们想继续深入,我们就必须扩展开 OutboxProcessor
。这里我们可能面临的问题是重复处理同一个消息。因此,我们需要为当前的消息组实现某种锁定机制。
PostgreSQL 提供了一个方便的 FOR UPDATE
语句,我们可以在当前事务中使用 FOR UPDATE 语句来锁定选中的行。然而,我们必须添加 SKIP LOCKED
语句以允许其他查询跳过锁定的行。否则,其他查询将会被阻塞,直到当前事务结束为止。
这里是更新的查询:
SELECT id AS Id, type AS Type, content AS 内容
FROM 发送箱消息
WHERE utc处理时间 IS NULL
ORDER BY utc发生时间 LIMIT @BatchSize
FOR UPDATE SKIP LOCKED /* 跳过已锁定的行 */
要扩展规模 OutboxProcessor
,我们可以简单地运行多个进程的实例。
我会模拟这个,使用Parallel.ForEachAsync
,其中我可以设置MaxDegreeOfParallelism
,用逗号连接更合适。
var 并行选项 = new 并行选项()
{
MaxDegreeOfParallelism = _maxParallelism,
CancellationToken = cancellationToken
};
await Parallel.ForEachAsync(
Enumerable.Range(0, _最大并行度),
并行选项,
async (_, token) =>
{
await 处理出箱消息(token);
});
我们一分钟可以处理 179,000 条消息,或者利用五个(5名)工人达到 2,983 MPS 的处理速度。
我还以为这会快很多。这是啥情况?
在没有使用并行处理的情况下,我们得到了大约2700 MPS(每秒多少,具体单位需要根据上下文确定)。
一个新的瓶颈出现了,批量发布消息的过程。
发布时间从大约 289 毫秒增至大约 1540 毫秒。
有趣的是,如果你把单个工人的原始发布时间乘以工人的数量,你就能大致得出新的发布时间。
我们在等着消息代理的确认,结果浪费了很多宝贵的时间。
这个问题我们该怎么解决?
批量消息发布RabbitMQ 支持批量发送消息。我们可以通过调用 ConfigureBatchPublish
方法启用此功能。MassTransit 会在将消息发送到 RabbitMQ 之前缓存消息,从而提高吞吐量。
builder.Services.AddMassTransit(x =>
{
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host(builder.Configuration.GetConnectionString("队列连接字符串"), hostCfg =>
{
hostCfg.ConfigureBatchPublish(batch =>
{
batch.Enabled = true;
});
});
cfg.ConfigureEndpoints(context);
});
});
只需做这么小小的改动,让我们用五个工人重新运行测试。
这次,我们一分钟内能处理1,956,000条消息。
这大约有惊人的 32,500 MPS。
每天处理的消息超过2.8亿条,这是每天的处理量。
我可以今天就收工了,不过还有一样东西想让你看看。
关闭发布者确认功能(注意:危险)不过还有一件事你可以做(不过我不推荐这样做)的是关闭发布确认。这意味着发送 Publish
消息时,不会等待服务器确认。这可能会导致可靠性问题,甚至可能丢失消息。
话说,我还是做到了在没有开启出版者确认的情况下获得大约37,000 MPS。
cfg.Host(builder.Configuration.GetConnectionString("Queue"), hostCfg =>
{
hostCfg.PublisherConfirmation = false; // 这很危险,不建议这样做。
hostCfg.ConfigureBatchPublish((batch) =>
{
batch.Enabled = true;
});
});
扩展时的关键考虑因素
虽然我们已经达到了很高的吞吐量,但在实际系统中使用这些技术时,请多考虑以下几点。
- 消费者容量:如果增加生产者的处理能力而不相应地提高消费者的处理能力,可能会造成积压。在扩展时,请考虑整个管道。
- 传递保证:我们的优化确保至少一次传递。设计消费者以处理偶尔的重复消息,使其幂等(即执行多次操作与执行一次操作的效果相同)。
- 消息排序:使用
FOR UPDATE SKIP LOCKED
进行并行处理可能导致消息顺序颠倒。为了严格排序,可以在消费者端采用收件箱模式来缓冲消息。收件箱允许我们在消息到达顺序不正确的情况下,仍能以正确的顺序处理这些消息。 - 可靠性和性能的权衡:关闭发布者确认可以提高速度,但也会增加消息丢失的风险。根据您的具体需求,在性能和可靠性之间权衡。
通过处理这些因素,你将能够打造出一个高性能的Outbox处理器,它能与你的系统架构完美融合。
总结.我们已经从最初的Outbox处理器走了很长的路程。以下是我们取得的成就:
- 使用智能索引提升了数据库查询效率
- 利用批处理提高了消息发布的效率
- 用批处理简化了数据库更新流程
- 利用并行工作者扩展了 Outbox 处理能力
- 利用了 RabbitMQ 的批量发布功能来优化消息发布
结果?我们将每秒处理的消息从1,350条提升至令人惊讶的32,500 MPS。这意味着每天可以处理超过28亿条消息!
扩展不仅仅是单纯追求速度——更重要的是在每一步中找出并解决瓶颈。通过测量、优化并重新审视我们的方法,我们取得了显著的性能提升。
今天的分享就到这里,希望对你有帮助。
附注:你可以在这里查看源代码:这里查看源代码。
原发布于https://www.milanjovanovic.tech 在2024年10月12日发布。
附注:无论你什么时候准备好,我都可以帮你三种方法
- 实用的干净架构: 加入这门课程,已有超过三千零五十名学生参与,学习我用来开发生产就绪应用程序的干净架构系统。了解如何在现代软件架构中应用最佳实践。
- 模块化单体架构: 加入这门课程,已有超过一千名工程师参与,这将改变你构建现代系统的方法。你将学习如何在实际场景中应用模块化单体架构的最佳方法。
- Patreon 社区: 加入由一千零五名工程师和软件架构师组成的社区。你还可以获得我在YouTube视频中使用的源代码,未来视频的优先观看权,以及我的课程专属优惠。
共同学习,写下你的评论
评论加载中...
作者其他优质文章