使用Redis构建高并发高可靠的秒杀拍卖系统 - Luis


如何构建高可靠性且一致地处理数百万并发用户的拍卖系统、抢拍系统?
诸如耐克,阿迪达斯或至尊之类的品牌在市场上创造了一种新的趋势,称为“drops”,在那里他们发布了数量有限的商品。在实际发行之前,通常是有限的运行或预发行的有限报价。
这构成了一些特殊的挑战,因为每次销售基本上都是“黑色星期五”,并且您有成千上万(或数百万)的用户试图在同一时刻购买数量非常有限的商品。
主要要求

  • 所有客户都可以实时查看项目更改(库存,描述等);
  • 处理突然增加的负载;
  • 尽量减少客户端与后端的交互;
  • 在避免竞争条件的同时处理并发性;
  • 处理同一项目的成千上万个并发请求;
  • 容错
  • 自动缩放
  • 高可用性;
  • 成本效益。

 
后端架构
一切都从我们的后台开始。我们需要将drops信息和可用项目保存在适当的数据结构中。我们将使用2个系统。PostgreSQL和Redis。
PostgreSQL,MySQL,Cassandra或任何其他持久数据库引擎都可以使用。我们需要持久且一致的事实来源来存储我们的数据。
Redis不仅仅是一个简单的键值内存数据库,它还具有特殊的数据结构(例如HASH和LIST),可以将系统性能提高几个数量级。
Redis将成为我们的主力军,并占据我们的大部分流量。选择的数据结构将是每个项目的Redis HASH和每个结帐队列的LIST。
使用HASH而不是TEXT,我们可以仅更新每个项目属性的单个元素,例如项目库存。它甚至使我们可以INCR / DECR整数字段,而无需先读取该属性。
这将非常方便,因为它将允许非阻塞并发访问我们的商品信息。
每次后台员工用户进行任何更改时,都需要对Redis进行更新,以确保在DROP数据方面我们拥有PostgreSQL数据库的精确副本。
  • 1.获取拍卖信息

当用户启动应用程序时,它需要做的第一件事是请求DROP有效负载并连接到WebSocket。
这样,它只需要一次请求信息,此后便会在每次有相关更新时实时获取信息,而无需不断轮询服务器。这称为推送架构,在这里特别有用,因为它启用了“实时”,同时为将我们的网络干扰降至最低。启用Redis缓存返回信息以保护我们的主数据库。
但是我们需要考虑Redis可能发生的某些事情,并且不能相信它将始终拥有我们的数据。始终将Redis视为高速缓存是个好习惯,即使它的行为类似于普通数据库。
一种简单的方法是验证Redis是否未返回任何数据,然后立即从PostgreSQL加载并将其保存到Redis。之后,您才应将数据返回给用户。这样,我们将您与主数据库的交互减至最少,同时保证您始终拥有正确的数据。
我们的流量通常分布不均。确定受影响最大的端点,并确保它们具有可扩展的体系结构。
  • 2.抢拍

这部分是我们整个系统中最关键和最危险的部分。我们需要考虑到市场上充斥着专门为利用这些系统而构建的机器人。整个地下经济在购买和转售这些物品时会蓬勃发展。对于本篇文章,我们将重点放在可用性和一致性上,而不会深入探讨我们必须尝试保护该系统的多种选择。
DROP启动后,结帐端点将充满请求。这意味着需要尽可能高效地处理每个请求,理想情况下,仅在保证顺序的同时使用内存数据库和O(1)操作,因为这是先来先服务的业务模型。
这是Redis闪亮的地方,它会检查所有标记!
  • O(1)读写操作;
  • 内存数据库;
  • 单线程保证顺序,而无需在我们的代码上增加互斥锁复杂性;
  • 极高的吞吐量和极低的延迟;
  • 专门的数据结构,例如HASH和LIST。

在很高的级别上,当用户请求结帐端点时,我们只需要检查Drop是否已经开始(Redis),是否仍然可用(Redis),用户是否已经在队列中(Redis),以及如果他通过了所有检查,则将请求保存到结帐队列(Redis)。
这意味着我们能够在几毫秒内完成大部分O(1)操作并使用内存数据库来处理结帐请求,而不必担心并发性和一致性。
  • 3.充值信用卡并完成交易

现在,每个商品只有一个队列,所有尝试或当前尝试购买该商品的用户都按顺序存储在这些队列中,我们拥有“世界上所有时间”来异步处理它们。
只要您保证没有一个队列同时由多个工作进程处理,我们就可以根据需要拥有任意数量的服务器,并使用一个自动伸缩组根据需要进行伸缩,以处理所有队列。
这种简单的体系结构使我们可以尝试从信用卡中收取费用,甚至还可以留出一些空间用于重试和其他支票。
如果队列中的第一个用户由于CC问题或其他问题而失败,我们可以继续进行到队列中的下一个用户。
它还使我们能够以完全相同的方式处理唯一的商品条目或带有库存的商品条目(只需将库存设置为1)。
 
技术挑战
  • 负载均衡器

如果您使用的是AWS托管的负载均衡器,则可能会遇到问题。在开始下降之前,与开始时相比,流量将非常低。这意味着您的负载均衡器仅分配了几个计算单元(节点),并且在DROP期间需要几分钟的时间才能扩展到所需的流量。
好吧,我们没有几分钟,对吧?…并且我们也不希望我们的负载均衡器向用户返回502错误。
我们在这里至少有两个选择。在丢弃开始之前,使用模拟流量(例如,使用lambda)为您的Load Balancer热身,或者使用HAProxy运行您自己的Load Balancer群集。
两者都是有效的,这取决于您的团队规模和使用这些系统的经验。
第三种选择是与AWS联系,以便他们可以预热LB,但是由于这是手动过程,因此我不建议这样做。
  • Redis可伸缩性

关于我们的Redis,如果您开始有大量的项目和/或用户参与,最好扩展一下。
最好的方法是采用多写节点(集群模式)方法,而不要使用主/副本体系结构。这主要是为了避免滞后问题。请记住,我们希望保持一致性而不需要太多的代码复杂性。主动-主动的多区域挑战性很大且昂贵,但有时这是唯一的选择。
您可以使用模或确定性哈希函数在这些节点上分配放置项。
在这里使用异步非常重要。这样,我们的总延迟将仅是来自Redis节点的最大延迟,而不是来自所有已使用节点的所有延迟的总和。
在我们的用例中,将项目ID用作分区键效果很好,因为负载将均匀分布在整个键空间中。
 
Redis主-主挑战
Redis扩展到世界上多个数据中心区域时的主要问题
  • CAP定理
  • 区域间的一致性
  • 管理多个部署
  • 不再能够使用简单的Redis LIST保持一致性
  • 成本
  • 多区域故障转移
  • 复杂

每次我们需要处理分布式系统时,我们都需要按照CAP定理进行选择/妥协。我们的两个主要要求是“可用性”和“一致性”。我们需要确保我们不会过度销售我们的商品,因为它们中的大多数库存都有限。最重要的是,人们将赌注押在一个特定的项目上,这意味着如果我们以后告诉他们所买的鞋子毕竟不可用,他们就不会接受。
实际上,这意味着如果某个区域无法与主要区域联系,它将阻止“结帐”。这也意味着,如果主要区域处于脱机状态,则每个区域都将无法正常工作。
我们决定从CAP定理中获得A和C,但是不幸的是,这并不意味着我们免费获得了它们,只是因为我们放弃了P的要求……
如果仅使用Cassandra或CockroachDB,我们就能摆脱困境!
借鉴Google Spanner及其外部一致性概念的启发,我们决定“使用原子钟和GPS ”作为我们的真理来源。幸运的是,AWS发布了一项名为Time Sync的免费服务,非常适合我们的用例!
使用此AWS服务,我们全球的所有计算机都是同步的。
依靠机器时钟可以消除在交易过程中从API获取时间戳的需要,从而减少了等待时间,并且无需安装断路器(就像处理外部调用时一样)。
当订单到达时,我们只需要获取当前时间并将其发送到主Redis实例即可。使用异步模型对于处理这些请求非常重要,因为“远程”请求可能需要一段时间才能完成,从而在尝试服务于数千个并发请求时可能会造成瓶颈。
以前,我们使用每个项目一个Redis LIST来保留“订单订单”。既然我们正在使用时间戳记来保持我们在全球范围内的一致性,那么LIST不再是最好的数据结构。…除非您想在每次收到乱序的数据包或需要弹出第一个订单时执行O(N)操作时都重新洗牌。
排序集(ZSET)可以解救!
使用时间戳记作为分数,Redis将为我们保持一切正常。将新订单添加到项目非常简单:
ZADD orders:<item_id> <timestamp> <user_token>

要查看每个商品的订单,您可以:
ZPOPMIN orders:<item_id>
 

成本
运行多区域设置通常会比较昂贵,我们的情况也不例外。
在全球范围内复制PostgreSQL,Redis和EC2并不便宜,这迫使我们迭代解决方案。
最后,我们需要了解我们需要优化的地方以及可以妥协的地方。我认为这种双重性几乎适用于所有情况。
需要最低延迟的用户流正在加载应用程序并在队列中下订单。其他所有内容都相对滞后。这意味着我们可以专注于该路径,而对其他用户交互的要求则不那么严格。
我们所做的最重要的事情是删除了本地PostgreSQL实例,调整了后端以仅将Redis用于关键路径,并容忍最终的一致性,以便我们可以摆脱它。这也有助于降低API计算需求(双赢!)。
我们还为API和异步工作程序使用了AWS竞价型实例
最后,我们优化了PostgreSQL集群,以使用比平常小的实例来摆脱困境。
总而言之,我们的Redis被用作:
  • 快取
  • 结帐队列
  • 异步任务队列
  • 使用通道的Websockets后端

这代表了在成本和整体复杂性上的大量节省。
 
多区域故障转移
多区域故障转移可以采用不同的形式。不幸的是,没有一个可以称为完美。我们将需要选择要折衷的地方:
  • RTO(恢复时间目标)
  • RPO(恢复点目标)
  • 成本
  • 复杂

每个用例都会有所不同,解决方案几乎总是“视情况而定”。
对于此用例,我们使用了3种机制:
  • RDS自动备份-多区域单一区域时间点恢复
  • 使用AWS Lambda的脚本化手动快照-多区域恢复
  • 跨区域只读副本—多区域可用性和最佳数据持久性

为了避免大部分闲置的PostgreSQL副本造成高昂的成本,此实例是一台较小的计算机,可以在紧急情况下进行扩展并升级为“主要”实例。这意味着我们需要忍受几分钟的离线时间,但是我觉得这是对这种用例的一个很好的折衷。
提升只读副本通常比恢复快照更快。另外,通过这种方式丢失数据的机会也更少。
只要确保较小的实例可以跟上写入负载即可。您可以通过监视其副本延迟来监视它(或者更好的是,为其配置警报!)。重新启动将“修复”延迟,但是如果大小上的差异是问题的根源,则您只是在推送问题,而您应该调高实例大小。
手动快照为您提供了额外的安全性和“数据版本控制”层,但以S3存储为代价。
AWS在这里有一篇关于DR的非常好的文章。
最后,我建议确保一致性和持久性,即使为此过程进行一些手动交互也要付出代价。如果您有工程设计时间和能力来安全地自动化整个过程,并不时地运行该演练以确保所有自动化都是可靠的,那就去做吧。这是Netflix之类的广告所宣传的圣杯。
如果不是这样,则丢失整个区域的情况极不可能发生,并且只要您有警报,就可以在不到30分钟的时间内手动完成故障转移到新区域的过程(如果按顺序执行了上述步骤)。