每秒以 1500 个及以上的速率对密码进行哈希处理:Rust版本的算法性能比JVM更好!
作者是芬兰广播公司Yle的 Yle ID 团队的一员,该团队负责构建 Yle 所有数字产品和服务中使用的帐户服务。目前,注册的 Yle ID 数量略多于 300 万。
在这篇文章中,我们探讨了在大型互动电视直播活动中遇到的一个问题,大量的密码哈希运算导致我们的身份验证后端崩溃。我们首先介绍一下密码哈希的一些背景知识,然后介绍它给我们的服务带来了哪些挑战,以及我们如何通过使用 AWS Lambda 函数和 Rust 来克服这些挑战。
背景
首先,简单介绍一下密码的存储以及它所带来的挑战。
当用户在服务中注册帐户时,他们的密码不会以明文形式存储在应用程序的数据库中。相反,为了防止在数据泄露的情况下密码泄露,我们会根据密码计算加密哈希,然后将其与帐户的其余数据一起存储。用于计算这些哈希的算法经过精心设计,因此,给定生成的哈希,不可能逆转该过程并取回原始密码。这样,即使有人掌握了存储的帐户数据,他们也无法使用这些帐户登录。
哈希算法的另一个重要特性是它们需要大量资源(处理能力或内存或两者兼有)。这样做是为了防止对密码的暴力攻击:如果包含密码哈希的数据库被泄露,攻击者可以尝试计算不同密码的哈希,直到得到与数据库中的哈希之一匹配的哈希。找到匹配的哈希意味着他们猜出的密码是正确的。如果所选算法太快或占用的内存太少,则可以在很短的时间内尝试大量不同的密码,从而增加找到匹配的机会。攻击者可以尝试使用预先计算的流行密码哈希列表(称为彩虹表)来加快该过程。通过在哈希操作之前对密码进行加盐可以防止这种类型的攻击,但这超出了本文的范围。
那么,如果我们不存储用户的密码,我们如何登录呢?很简单,当用户稍后登录时输入密码时,我们会再次计算相同的哈希值,并将其与我们存储在数据库中的哈希值进行比较,以确定给定的密码是否正确。这意味着每次用户尝试登录时,我们都必须进行昂贵的哈希计算。(当他们重置密码或新用户注册帐户时也是如此。)
问题
在正常情况下,对这些密码进行哈希处理不是问题,因为这些请求的数量相当低,但在大型活动中,这些高 CPU 使用率操作(登录、注册、密码重置)可能会出现大幅激增。例如,在大型现场活动期间,主持人可能会鼓励观众登录Yle 应用程序以某种方式参与其中(聊天、投票、玩游戏等)。这种提示可以直接看作是我们后端收到的请求数量的快速激增。
在一次这样的现场活动中,请求率的峰值超出预期,耗尽了我们集群中所有可用的 CPU,导致后端陷入停顿。我们最近增加了密码哈希的资源要求,以匹配现代应用程序的推荐要求,这一事实使问题更加严重。
后端忙于计算所有这些密码哈希值,这意味着它也无法响应任何其他请求。解决此问题的明显方法是为应用程序分配更多资源。但是,这只有在我们可以预测流量高峰时才有效。那么重大新闻事件等意外事件怎么办?当然,我们可以随时准备大量容量以防万一,但这确实很浪费,而且成本高昂。
解决方案
因此,总结一下当前架构的问题如下:
- 我们有一项操作占用了应用程序中迄今为止最多的资源(CPU)。
- 该操作可能会彻底破坏整个应用程序。
- 该操作是强制性的,如果不影响安全性,就无法减少资源消耗。
- 负载可能变化很大并且难以预测。
我们的后端基于 Scala 构建,由于历史原因,我们使用了第三方 Scala 实现的密码哈希算法。
我们运行了一些基准测试,将该实现与Bouncy Castle和相同算法的内置 JDK 实现进行了比较,但在资源消耗方面没有显著差异。
我们还针对 Rust 版本的算法进行了基准测试,发现它比 JVM 实现性能更高。
然后,我们面临着将 Rust 组件集成到我们的应用程序中的挑战,尽管 Rust 版本速度更快,但仅凭它只会让问题进一步恶化。随着用户数量的增加和事件规模的扩大,仅使用相同数量级的更快实现只能为我们争取一些时间,而不能解决根本问题。
经过思考,我们想出了将哈希计算隔离到其自己的微服务中的方法。我们假设使用基于请求率自动扩展的平台(例如AWS Lambda),可以让我们不必担心分配正确数量的资源。尽管与其他操作相比,哈希计算仍然需要相对较长的时间,但从主后端的角度来看,它现在将是一个 HTTP 请求,在此期间它可以自由地处理其他请求。
我们构建了此新微服务的原型实现来测试我们的假设。这是一个非常小而简单的 HTTP API,它将接收用户的密码,进行哈希计算并返回结果哈希。有了这个原型,我们运行了性能测试,模拟了发生问题期间的流量。测试使用的资源量与事件期间配置的资源量相同。虽然测试仅使用了登录请求,但它们仍能为我们提供良好的性能估计,因为与密码哈希计算相比,任何其他操作的资源使用量都微不足道。
使用事件的流量(1500 个请求/秒)运行测试对新实现没有任何问题。我们能够将数字增加到 3000 个请求/秒,然后才开始看到响应代码中的任何错误。响应时间非常好,第 90 个百分位徘徊在 300 毫秒左右。(请记住,哈希计算是故意慢的,大部分时间都花在实际算法上。)即使增加了 HTTP 请求的开销,新版本的响应时间仍然比旧版本略快。
成功了!
使用相同的基础设施将性能提高 100% 正是我们所希望的。
我们在负载测试期间看到的错误来自基础设施的其他部分,而不是密码哈希算法的缓慢。
修复一个瓶颈后,另一个瓶颈又会出现,这种情况很常见,但这恰恰表明我们的解决方案非常有效,而且我们此后成功运行了更大的负载测试。
现在,为了使实施准备就绪,我们在微服务中添加了一些额外的参数,使我们能够处理任何未来的哈希算法更改(以及仍使用旧哈希版本的现有用户)。我们还让主应用程序回退到计算哈希本身,以防微服务由于某些原因(例如网络问题)不可用。
这一变化的额外好处是,我们现在可以减少主应用程序的 CPU 分配,即使考虑到 Lambda 函数的成本,也可以降低我们服务的总体运营成本。