为了账号安全,请及时绑定邮箱和手机立即绑定

扩展外置模式以应对海量消息(每天处理2B+消息)

标签:
大数据 架构

Scaling the Outbox Pattern \(2B+ messages per day\)

在上周的邮件中,我谈到了如何实现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_utcprocessed_on_utc 这两列上。它还将包含 idtypecontent 这几列。最后,我们将只筛选未处理的消息。

    -- 创建索引如果不存在 `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 ScanLimit两个操作。不用做任何过滤或排序,这就是性能大幅提升的原因。

查询对查询时间有什么影响?

  • 查询时间从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;  
    });  
});
扩展时的关键考虑因素

虽然我们已经达到了很高的吞吐量,但在实际系统中使用这些技术时,请多考虑以下几点。

  1. 消费者容量:如果增加生产者的处理能力而不相应地提高消费者的处理能力,可能会造成积压。在扩展时,请考虑整个管道。
  2. 传递保证:我们的优化确保至少一次传递。设计消费者以处理偶尔的重复消息,使其幂等(即执行多次操作与执行一次操作的效果相同)。
  3. 消息排序:使用 FOR UPDATE SKIP LOCKED 进行并行处理可能导致消息顺序颠倒。为了严格排序,可以在消费者端采用收件箱模式来缓冲消息。收件箱允许我们在消息到达顺序不正确的情况下,仍能以正确的顺序处理这些消息。
  4. 可靠性和性能的权衡:关闭发布者确认可以提高速度,但也会增加消息丢失的风险。根据您的具体需求,在性能和可靠性之间权衡。

通过处理这些因素,你将能够打造出一个高性能的Outbox处理器,它能与你的系统架构完美融合。

总结.

我们已经从最初的Outbox处理器走了很长的路程。以下是我们取得的成就:

  1. 使用智能索引提升了数据库查询效率
  2. 利用批处理提高了消息发布的效率
  3. 用批处理简化了数据库更新流程
  4. 利用并行工作者扩展了 Outbox 处理能力
  5. 利用了 RabbitMQ 的批量发布功能来优化消息发布

结果?我们将每秒处理的消息从1,350条提升至令人惊讶的32,500 MPS。这意味着每天可以处理超过28亿条消息!

扩展不仅仅是单纯追求速度——更重要的是在每一步中找出并解决瓶颈。通过测量、优化并重新审视我们的方法,我们取得了显著的性能提升。

今天的分享就到这里,希望对你有帮助。

附注:你可以在这里查看源代码:这里查看源代码

原发布于https://www.milanjovanovic.tech 在2024年10月12日发布。

附注:无论你什么时候准备好,我都可以帮你三种方法

  1. 实用的干净架构: 加入这门课程,已有超过三千零五十名学生参与,学习我用来开发生产就绪应用程序的干净架构系统。了解如何在现代软件架构中应用最佳实践。
  2. 模块化单体架构: 加入这门课程,已有超过一千名工程师参与,这将改变你构建现代系统的方法。你将学习如何在实际场景中应用模块化单体架构的最佳方法。
  3. Patreon 社区: 加入由一千零五名工程师和软件架构师组成的社区。你还可以获得我在YouTube视频中使用的源代码,未来视频的优先观看权,以及我的课程专属优惠。
点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消