Skip to the content.

TeamsApp升级之路 - 大用户并发的 Teams http 请求优化

我们这篇文章继续上篇文章的优化,来看看如何优化 #6。

经过上一篇文章的分析和优化,我们目前的参与抽奖的步骤是:

  1. 增加当前用户到抽奖人列表。一次简单的写操作,只增加当前一个用户
  2. 如果第三步报错,说明当前这个用户是否已经参与了本次抽奖,结束这个请求
  3. 从 SQL DB 读取抽奖的基本信息,不包含参与有人。一次简单的数据库读操作
  4. 判断抽奖是否已经结束,如果已经开奖了,那就结束这个请求
  5. 根据当前的抽奖信息,生成 Teams 前端展示的 adaptive card
  6. 调用 Teams 的 bot api,更新本次抽奖的 adaptive card

在正常情况下一次抽奖为:一次数据库写操作 (#1),一次数据库读操作 (#3),一次 http 请求 (#6)。

如果是用户反复点击参与抽奖按钮,那就是一次数据库写操作 (#1)。

所以,当我们假设的 3000 用户在 15 秒内参与了同一个抽奖,那每秒要发送 200 个 http 请求给 teams。Teams 实际上对于发给给它的请求有一些调用频次的限制,详细信息可以参考这个官方文档。

https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit

所以针对这个问题,我们改如何改进?

我准备使用的方法是延迟发送,当我们需要发送的时候,我们先等个 1 到 2 秒时间,如果在这个等待时间里,有其他的对这个 adaptive card 的更新操作,我们就可以合并到一起,这样的话,针对某一次抽奖,不管在这 1 到 2 秒时间内有多少人参与抽奖,我们最终只给 Teams 发送一次更新 activity (adaptive card) 请求。

大家可能觉得说起来容易,如何实现这个呢?这是我使用的延迟发送的示意性代码:

private static Dictionary<string, DelayContext> _delayContexts = new Dictionary<string, DelayContext>();

public static void UpdateActivityWithDelay()
{
    var delayContext = new DelayContext(...);
    var key = $"{channelId}_{mainActivityId}";

    var first = !_delayContexts.ContainsKey(key);
    _delayContexts[key] = delayContext;
    if (first)
    {
        var thread = new Thread(SendUpdatedActivity);
        thread.Start(key);
    }
}

private static void SendUpdatedActivity(object? keyObject)
{
    Thread.Sleep(DelayUpdateActivityInMilliseconds);

    string key = (string)keyObject!;
    DelayContext? delayContext;
    _delayContexts.TryRemove(key, out delayContext);

    botClient.UpdateActivityAsync();
}

可以看到当我们需要发送更新 activity (adaptive card) 的时候调用 UpdateActivityWithDelay 请求,它会把发送的上下文内容保存到一个列表里 _delayContexts,然后起一个线程,在这个线程里先等待 sleep 一些时间,然后再从列表里获取发送的上下文,然后发送。

上面之所以说是一个示意性代码,因为上面代码完全没有考虑线程安全,多线程竞争等问题。,我们来对它一步步优化。

首先我们要先把列表从 Dictionary<string, DelayContext> 改成 ConcurrentDictionary<string, DelayContext>

接下来我们要增加一个锁,防止往列表里读写的多线程竞争的情况。如下:

private static object _syncObject = new object();

public static void UpdateActivityWithDelay()
{
    var delayContext = new DelayContext(...);
    var key = $"{channelId}_{mainActivityId}";

    lock(_syncObject)
    {
        var first = !_delayContexts.ContainsKey(key);
        _delayContexts[key] = delayContext;
        if (first)
        {
            var thread = new Thread(SendUpdatedActivity);
            thread.Start(key);
        }
    }
}

private static void SendUpdatedActivity(object? keyObject)
{
    Thread.Sleep(DelayUpdateActivityInMilliseconds);

    string key = (string)keyObject!;
    DelayContext? delayContext;
    lock(_syncObject)
    {
        _delayContexts.TryRemove(key, out delayContext);
    }

    if (delayContext != null)
    {
        botClient.UpdateActivityAsync();
    }
}

最后我们就可以把所有我们需要保存的上下文内容插入到 DelayContext 里。

全部代码如下:

private static int DelayUpdateActivityInMilliseconds = 1500;
private static object _syncObject = new object();
private static ConcurrentDictionary<string, DelayContext> _delayContexts = new ConcurrentDictionary<string, DelayContext>();

public static void UpdateActivityWithDelay(IBotClientFactory botClientFactory, IActivityBuilder activityBuilder, string serviceUrl, string channelId, string mainActivityId, Competition competition)
{
    var delayContext = new DelayContext(botClientFactory, activityBuilder, serviceUrl, channelId, mainActivityId, competition);
    var key = $"{channelId}_{mainActivityId}";

    lock(_syncObject)
    {
        var first = !_delayContexts.ContainsKey(key);
        _delayContexts[key] = delayContext;
        if (first)
        {
            var thread = new Thread(SendUpdatedActivity);
            thread.Start(key);
        }
    }
}

private static void SendUpdatedActivity(object? keyObject)
{
    Thread.Sleep(DelayUpdateActivityInMilliseconds);

    string key = (string)keyObject!;
    DelayContext? delayContext;
    lock(_syncObject)
    {
        _delayContexts.TryRemove(key, out delayContext);
    }

    if (delayContext != null)
    {
        using var botClient = delayContext.BotClientFactory.CreateBotClient(delayContext.ServiceUrl);
        var updatedActivity = delayContext.ActivityBuilder.CreateMainActivity(delayContext.Competition);
        botClient.UpdateActivityAsync(delayContext.ChannelId, delayContext.MainActivityId, updatedActivity).GetAwaiter().GetResult();
    }
}

我们再来看我们假设的场景:3000用户在15秒内参与同一个抽奖,经过上面的优化,一共只需要在15秒内发送 10 个请求给 Teams 就可以了。

总结一下,经过上一篇文章和这篇文章的优化,参与抽奖的流程变成了:

  1. 增加当前用户到抽奖人列表。一次简单的写操作,只增加当前一个用户
  2. 如果第三步报错,说明当前这个用户是否已经参与了本次抽奖,结束这个请求
  3. 从 SQL DB 读取抽奖的基本信息,不包含参与有人。一次简单的数据库读操作
  4. 判断抽奖是否已经结束,如果已经开奖了,那就结束这个请求
  5. 延迟 1.5 秒后,根据当前的抽奖信息,生成 Teams 前端展示的 adaptive card,然后发送 Teams 的 bot api,更新本次抽奖的 acitivity
Written on January 23, 2023