在Discord,我们一直在思考如何改进我们的服务并提高性能。毕竟,我们的应用程序越快,你就能越快地回到朋友和对话中!
在过去的六个月里,我们开始了一项支持这一努力的任务,致力于减少我们的客户端使用的带宽量,特别是在iOS和Android上,希望减少带宽使用能带来更快的响应体验。
背景
当你的客户端连接到Discord时,它会通过我们称之为"网关"的服务接收实时更新。自2017年底以来,客户端的网关连接一直使用zlib进行压缩,使消息大小减少了2到10倍。
从那时起, zstandard[1] (最初于2015年发布)获得了足够的关注,成为zlib的可行替代品。Zstandard提供更高的压缩比和更短的压缩时间,并支持 字典[2] :一种预先交换压缩内容信息的方式,进一步提高压缩比并减少总体带宽使用。
我们过去曾尝试使用zstandard,但当时收益不值得付出代价。我们2019年的测试仅限于桌面端,并且使用了太多RAM。然而,五年时间里可能发生很多变化!我们想再次尝试,字典支持对我们很有吸引力,特别是因为我们大多数网关负载都很小且形状明确。
我们认为这些负载的可预测性将是字典应用的完美场景,可以进一步减少带宽使用。
带着这些知识,我们穿上实验服,戴上护目镜,开始了实验。理论上,我们认为zstandard会比zlib更好,但我们想根据当前的工作负载验证这个理论。
我们选择对普通zstandard进行"暗启动":计划是用zlib和zstandard同时压缩一小部分生产流量,收集大量指标,然后丢弃zstandard数据。这允许我们快速实验zstandard并将其结果与zlib进行比较。如果没有这个实验,我们就必须为我们的客户端(桌面、iOS和Android)添加zstandard支持,这需要大约一个月的准备时间才能完全确定zstandard的效果。我们不知道zstandard的表现如何,也不想等待整整一个月,但暗启动允许我们在几天而不是几周内进行迭代。
一旦我们设置好实验并部署到网关集群上,我们就建立了一个仪表板来查看zstandard的表现。我们打开开关,开始通过暗启动代码发送极少量的流量,初步结果似乎...令人失望。Zstandard的表现比zlib更差。
Zstandard压缩比
Zlib压缩比
为了比较这两种压缩算法的性能,我们使用了它们的"压缩比"。压缩比是通过将未压缩负载大小除以压缩后的大小来衡量的 - 数字越大越好。
看上面的图片,它测量了各种调度类型( op 0[3] )的压缩比,使用zlib时,user_guild_settings_update的压缩比为13.95,而使用zstandard时为12.26。
下图进一步说明了zstandard的表现比zlib差:用zlib压缩的MESSAGE_CREATE负载的平均大小约为250字节,而用zstandard压缩的相同负载超过750字节!
对于大多数其他调度,我们观察到了相同的趋势:zstandard的表现并没有像我们想象的那样超过zlib。这是怎么回事?
流式Zstandard
事实证明,我们的zlib和zstandard实现之间的一个关键区别是zlib使用了流式压缩,而zstandard没有。
如前所述,我们的大多数负载相对较小,最多只有几百字节,这并没有给zstandard太多历史上下文来进一步优化它如何压缩未来的负载。使用流式压缩,zlib流在连接打开时启动,并一直存在直到websocket关闭。zlib不必为每个websocket消息重新开始,而是可以利用之前压缩数据的知识来决定如何处理新数据。这最终导致更小的负载大小。
那么问题就变成了:"我们能让zstandard也这样做吗?"答案是..."差不多吧。"我们的网关服务是用elixir编写的,虽然zstandard支持 流式压缩[4] ,但我们查看的各种elixir/erlang的zstandard绑定并不支持。
我们最终决定使用 ezstd[5] ,因为它支持字典(稍后会详细介绍)。虽然当时它不支持流式处理,但本着开源精神,我们fork了ezstd以添加流式支持,后来 贡献回上游[6] 。
然后我们重复了暗启动实验,但使用了zstandard流式处理,得到了以下结果:
消息创建压缩比
如上面的数据所示,zstandard流式将压缩比从6提高到接近10,并将负载大小从270字节降低到166字节。
这一趋势对大多数其他调度也成立:zstandard流式在压缩时间和压缩比方面都明显优于zlib。
再次看MESSAGE_CREATE,zstandard流式的每字节数据压缩时间明显低于zlib,zlib每字节需要约100微秒,而zstandard需要45微秒。
进一步推进
虽然我们最初的实验证明zstandard流式优于zlib流式,但我们剩下的问题是:"我们能把它推到多远?"我们最初的实验使用了zstandard的默认设置,我们想知道通过调整压缩设置,我们能把压缩比提高到多高。
那么我们到底能做到多远?
调优
Zstandard是高度可配置的,使我们能够调整各种压缩参数。我们将精力集中在三个我们认为对压缩影响最大的参数上:chainlog、hashlog和windowlog。这些参数在压缩速度、内存使用和压缩比之间提供了权衡。例如,增加chainlog的值通常会提高压缩比,但代价是增加内存使用和压缩时间。
我们还希望确保在我们决定的设置下,压缩上下文仍然能够适应我们主机的内存。虽然添加更多主机来吸收额外的内存使用很简单,但额外的主机需要成本,而且在某个点上,收益会递减。
我们最终选定了总体压缩级别为6,chainlog和hashlog为16,windowlog为18。这些数字略高于 这里可以看到的默认设置[7] ,并且能舒适地适应网关节点的内存。
Zstandard字典
此外,我们想研究是否可以利用zstandard的字典支持来进一步压缩数据。通过预先向zstandard提供一些信息,它可以更有效地压缩前几千字节的数据。
然而,这样做会增加额外的复杂性,因为压缩器(在这种情况下是网关节点)和解压缩器(Discord客户端)都需要有相同的字典副本才能成功通信。
要生成要使用的字典,我们需要数据...而且是大量的数据。Zstandard有一个内置的方法来从数据样本生成字典(zstd --train),所以我们只需要收集大量的样本。
值得注意的是,网关支持两种负载编码方法:JSON和 ETF[8] ,JSON字典在ETF上的表现不会像在JSON上那样好(反之亦然),所以我们必须生成两个字典:每种编码方法一个。
由于字典包含训练数据的部分,而且我们必须将字典发送给我们的客户端,我们需要确保用于生成字典的样本不包含任何可识别个人的用户数据。我们收集了涉及120,000条消息的数据,按ETF和JSON编码分开,匿名化处理,然后生成我们的字典。
一旦我们的字典构建完成,我们就可以使用收集的数据快速评估和迭代其有效性,而无需部署我们的网关集群。
我们尝试压缩的第一个负载是"READY"。作为发送给用户的第一个(也是最大的)负载之一,READY包含了连接用户的大部分信息,如公会成员资格、设置和阅读状态(哪些频道应该标记为已读/未读)。我们使用默认的zstandard设置将2,517,725字节的单个READY负载压缩到306,745字节,建立了基准线。利用我们刚刚训练的字典,相同的负载被压缩到306,098字节 - 大约减少了600字节。
最初,这些结果似乎令人沮丧,但我们接下来尝试压缩一个较小的负载,称为TYPING_START,发送给客户端以显示"XXX正在输入..."通知。在这种情况下,636字节的负载在没有字典的情况下压缩到466字节,有字典的情况下压缩到187字节。我们看到字典对较小负载的效果要好得多,这仅仅是因为zstandard的运作方式。
大多数压缩算法从已经压缩的数据中"学习",但对于小负载,没有任何数据可供学习。通过预先告知zstandard负载会是什么样子,它可以在缓冲区完全填满之前,对如何压缩前几千字节的数据做出更明智的决定。
对这些发现感到满意后,我们将字典支持部署到我们的网关集群并开始实验。利用暗启动框架,我们比较了zstandard和带字典的zstandard。
我们的生产测试得到了以下结果:
Ready负载大小
我们特别关注了READY负载大小,因为它是通过websocket发送的第一条消息之一,最有可能从字典中受益。如上表所示,READY的压缩增益很小,所以我们查看了更多调度类型的结果,希望字典能为较小的负载提供更多优势。
不幸的是,结果有些混合。例如,看看我们一直在比较的消息创建负载大小,我们可以看到字典实际上使情况变得更糟。
最终,我们决定不继续进行字典实验。字典提供的略微改进的压缩效果被它们给我们的网关服务和客户端带来的额外复杂性所抵消。数据是Discord工程的重要驱动力,数据本身就说明了问题:不值得投入更多精力。
缓冲区升级
最后,我们探索了在非高峰时段增加zstandard缓冲区。Discord的流量遵循昼夜模式,处理高峰需求所需的内存明显多于一天中其他时间所需的内存。
表面上看,对我们的网关集群进行自动扩缩容可以避免在非高峰时段浪费计算资源。然而,由于网关连接的长期存在性质,传统的自动扩缩容方法并不适用于我们的工作负载。因此,我们在非高峰时段有大量额外的内存和计算资源。有这么多额外的计算资源闲置,引发了一个问题:我们能否利用这些资源来提供更高的压缩率?
为了弄清楚这一点,我们在网关集群中构建了一个反馈循环。这个循环会在每个网关节点上运行,监控连接到它的客户端的内存使用情况。然后它会确定一个百分比,用于决定新连接的客户端中有多少应该升级其 zstandard 缓冲区。升级后的缓冲区会将 windowlog、hashlog 和 chainlog 值各增加 1,由于这些参数是以 2 的幂表示的,增加这些值 1 会使缓冲区使用的内存量大约翻倍。
部署并让反馈循环运行一段时间后,结果并不如我们最初希望的那么好。如下图所示,在 24 小时内,我们的网关节点的升级比例相对较低(最高 30%),远低于我们预期的约 70%。
经过一番深入研究,我们发现导致反馈循环表现不佳的主要问题之一是内存碎片化:反馈循环查看的是实际系统内存使用情况,但 BEAM 从系统分配的内存远远超过处理连接客户端所需的内存。这导致反馈循环认为可用内存比实际少。
为了尝试缓解这个问题,我们做了一些实验来调整 BEAM 分配器设置 - 更具体地说,是 driver_alloc 分配器,它负责(令人震惊的是)驱动程序数据分配。网关进程使用的大部分内存是 zstandard 流式上下文,它是使用 NIF[9] 在 C 中实现的。NIF 内存使用由 driver_alloc 分配。我们的假设是,如果我们能调整 driver_alloc 分配器以更有效地为我们的 zstandard 上下文分配或释放内存,我们就能减少碎片化并总体上提高升级比例。
然而,在调整分配器设置一段时间后,我们决定回滚反馈循环。虽然我们可能最终会找到正确的分配器设置来调整,但调整分配器所需的努力加上这给网关集群带来的整体额外复杂性,超过了如果成功可能看到的任何收益。
实施和推出
虽然最初的计划是只考虑为移动用户使用 zstandard,但带宽改进对桌面用户来说也足够显著,所以我们也向桌面用户推出了这项功能!由于 zstandard 作为 C 库提供,只需要在目标语言中找到绑定 - Android 用 Java,iOS 用 Objective C,桌面用 Rust - 并将它们挂钩到每个客户端即可。对于 Java ( zstd-jni[10] ) 和桌面 ( zstd-safe[11] ) 来说,实现很简单,因为已经存在绑定,但对于 iOS,我们不得不编写自己的绑定。
这是一个有风险的变更,如果出错可能会导致 Discord 完全无法使用,所以推出过程受到了实验的限制。这个实验有三个目的:如果出现问题可以快速回滚这些变更、验证我们在"实验室"中看到的结果,以及使我们能够评估这个变更是否对任何基线指标产生负面影响。
在几个月的时间里,我们成功地向所有平台的所有用户推出了 zstandard。
另一个胜利:被动会话 V2
虽然下一部分与 zstandard 工作并不直接相关,但在这个项目的暗启动阶段指导我们的指标揭示了一个令人惊讶的行为。查看发送给客户端的调度的实际大小,passive_update_v1 很突出。这个调度占了我们网关流量的 30% 以上,而实际发送的调度数量相对较小 - 约 2%。
我们使用被动会话来避免向可能甚至不打开服务器的客户端发送服务器生成的大多数消息。例如,一个 Discord 服务器可能非常活跃,每分钟发送数千条消息,但如果用户实际上没有阅读这些消息,发送它们并浪费他们的带宽是没有意义的。一旦你切换到该服务器,被动会话将"升级"为正常会话,并接收来自该公会的完整消息流。
然而,被动会话仍然需要定期发送有限的信息,这就是 PASSIVE_UPDATE_V1 的目的。定期地,所有被动会话都会收到一个更新,其中包含频道列表、成员列表和语音中的成员列表,这样你的客户端仍然可以与服务器保持同步。
深入研究 PASSIVE_UPDATE_V1 调度的实际内容,我们会发送所有的频道、成员或语音中的成员,即使只有一个元素发生了变化。被动会话最初是作为一种将 Discord 服务器扩展到数十万用户的手段而实施的,当时效果很好。
然而,随着我们 继续扩展[12] ,发送这些主要由冗余数据组成的快照不再足够。为了解决这个问题,我们引入了一个新的调度,只发送自上次更新以来发生变化的增量。这个调度,恰当地命名为 PASSIVE_UPDATE_V2,显著减少了整体带宽,从网关带宽的 35% 降至 5%,相当于整个集群范围内减少了 20%。
巨大的节省
通过被动会话 v2 和 zstandard 的综合效果,我们能够将客户端使用的网关带宽减少了近 40%。这是大量的数据!
该图显示了从 2024 年 1 月 1 日到 2024 年 8 月 12 日网关集群的相对出站带宽,其中两个标记分别是 4 月的 zstandard 推出和 5 月底的被动会话 v2。
虽然被动会话优化是 zstandard 实验的意外副产品,但它表明,通过正确的仪器仪表和批判性地查看图表,可以以合理的努力实现巨大的节省。
参考链接
- zstandard: https://facebook.github.io/zstd/
- 字典: https://github.com/facebook/zstd#dictionary-compression-how-to
- op 0: https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-opcodes
- 流式压缩: https://facebook.github.io/zstd/zstd_manual.html#Chapter7
- ezstd: https://github.com/silviucpp/ezstd
- 贡献回上游: https://github.com/silviucpp/ezstd/pull/15
- 这里可以看到的默认设置: https://github.com/facebook/zstd/blob/a761013b0390892e8728fc45171f831cf23c3792/lib/compress/clevels.h#L25
- ETF: https://www.erlang.org/doc/apps/erts/erl_ext_dist.html
- NIF: https://www.erlang.org/doc/system/nif.html
- zstd-jni: https://github.com/luben/zstd-jni/
- zstd-safe: https://crates.io/crates/zstd-safe
- 继续扩展: https://discord.com/blog/maxjourney-pushing-discords-limits-with-a-million-plus-online-users-in-a-single-server