Skip to the content.

TeamsApp升级之路 - 大用户并发抽奖的性能分析和数据库优化

我们这篇文章来分析一下之前遇到的大用户并发抽奖的性能问题,看看到底问题出在哪里,想想看后面如何解决。

做性能分析,我们需要先设想一个大用户的场景,然后基于这个场景来做评估,评估数据量,计算量,传输量,存储量等等。有了正确的评估后,就可以对症下药,设计相应的优化方案。

之前 LuckyDraw 遇到的问题就是当一个 Teams 的 team 里有 2000 用户同时点击参与抽奖,luckydraw 基本上就卡死了,之前在 azure app insights 里看过错误日志,基本上都是 azure storage table 的错误,错误是指 storage table 返回无法处理请求。

所以这次分析我假设的场景是 3000 个用户一个 Teams 的一个 team 里,或者在一个 group chat 里,然后有人发了一个抽奖,大家看到后纷纷点击参与抽奖,所有的用户在15秒内都完成了点击参与抽奖。所以在这个场景下,我们至少要能 handle 每秒处理 200 个参与抽奖的请求。

有了这个假设后,我们再来看一下单个参与抽奖的操作有哪些,我过了一遍代码,有这些步骤:

  1. 从 storage table 读取抽奖的完整信息(包含目前所有的已经参与的人)
  2. 判断抽奖是否已经结束,如果已经开奖了,那就结束这个请求
  3. 判断当前这个用户是否已经参与了本次抽奖,如果是,那就结束这个请求
  4. 增加当前用户到抽奖人列表,然后把整个抽奖信息(包含目前所有参与人)保存回 storage table,这里一定要包含所有参与人的原因是 storage table的设计,所有信息在一条记录里,所以无法分开保存
  5. 根据当前的抽奖信息,生成 Teams 前端展示的 adaptive card
  6. 调用 Teams 的 bot api,更新本次抽奖的 adaptive card

根据当前代码,我们不难看出步骤 #2, #3, #5 是纯在内存操作,#1需要从storage table 读取数据,#4需要写入数据到 storage table,#6需要发送http请求。所以我们要重点看 #1,#4 和 #6。

结合我们假设的高并发场景,加上上面的步骤,我们不难发现:

在 #1 中,当目前参与人已经有2000人后,每一个点击参与的动作,我们都需要从 storage table 里读取出前 2000 个人的数据,一个参与人包含了 aad object id,名字,参与时间,所以一个人的数据量大约在 100 characters,2000 人就是 100K 的数据,加上一些额外的 payload,encoding之类的,一个storage table 的请求返回数据量应该不会低于 120K。

在 #4 中,情况和 #1 类似,当目前参与人已经有2000人后,每一个点击参与的动作,我们都需要把 2000 多个参与人的数据传给 storage table 来保存,因为是写操作,对于 storage table来说肯定比 #1 要慢不少。

在 #6 中,每个参与抽奖的操作要促发一次发给 teams server 的http请求,每个这样的请求需要 0.5-2 秒钟才能完成,这个要看是发送给那个区域的 teams service。我们假设是一秒钟,再结合我们假设的场景是每秒钟有 200 个参与抽奖的请求,也就是说我们的 luckydraw bot service,有 200 个 http 请求在路上。这个对于单个 instance 的 app service 来说挺有压力的,如果当时另一个企业还有抽奖,这个数据会更高,而且通过实际测试,我还发现 teams 本身对于这个频率的请求,会促发 throttle。

有了以上的分析,针对 #1, 和 #4 我们可以有一个初步的想法:开分抽奖信息的主体和参与人信息。之前 storage table 有很多的设计上的限制,如果分开保存,性能会很差,但是 SQL DB没有这个问题,这样的话,我们就可以不必要把所有参与人都读出来,或者同时都写入。这个可以大大提高性能

所以,我们可以改成:

  1. 从 SQL DB 读取抽奖的基本信息,不包含参与有人。一次简单的数据库读操作
  2. 判断抽奖是否已经结束,如果已经开奖了,那就结束这个请求
  3. 判断当前这个用户是否已经参与了本次抽奖,如果是,那就结束这个请求。增加一个简单的数据库读操作,判断当前用户是否已经在数据库里。
  4. 增加当前用户到抽奖人列表。一次简单的写操作,只增加当前一个用户

可以看到这个优化改成了数据库的两次读和一次写,三个操作数据量都很少。

实际上还可以进一步优化,把 #3 和 #4 合并成一次数据库的的写操作,插入当前的用户,如果用户已经存在,让它报错 PK conflict。所以只要把数据库表的设计做的合理,就可以变成两次数据库操作:

  1. 增加当前用户到抽奖人列表。一次简单的写操作,只增加当前一个用户
  2. 如果第三步报错,说明当前这个用户是否已经参与了本次抽奖,结束这个请求

可以看到我们已经把 1 到 4 步优化成了一次读,一次写,还有优化的空间吗?

我考虑了很久,觉得在大用户量高并发的情况下,用户点击参与抽奖之后,可能无法立刻知道自己已经参与成功了,因为 #6 刷新 adaptive card 会有一点延迟,并且,当有很多用户一起点击的时候,我们目前显示最后2-3个用户的名字,用户要到抽奖详情里才能看到自己的名字。

所以在等待过程中,用户大概率会反复点击 “参与抽奖” 按钮,所以上面的 #4,在很多情况下会是一个普遍的情况,在这种情况下,#1 的读实际上就可以省掉。我们来换一个顺序看看:

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

如果按照上面的顺序,在普通情况下是数据库的一次读一次写操作,但是当用户反复点击“参与按钮”的时候,就变成了一次写失败的操作,省掉了一次读操作。

虽然看上去省了没多少,但是在高并发情况下,很多用户有反复点击的习惯,这就会能省下很多数据库操作,在高并发情况下,这种节省非常关键。

我们会在下一篇文章里介绍如何优化 #6 步,看看能达到什么效果。

Written on January 22, 2023