短松江月

如何将系统从0万扩展到1000万以上用户

· simons ·
暂无

查看原文

如何将系统从0万扩展到1000万以上用户

扩展是一个复杂的话题,但在大型科技公司处理数百万个请求的服务并从零开始扩展我自己的**创业公司( AlgoMaster.io)**后,我意识到大多数系统随着其增长,经历了一系列令人惊讶的相似的阶段。

关键的见解是,你从一开始就不应该过度设计。简单开始,识别瓶颈,并逐步扩展。

在本文中,我将引导您了解将系统从零用户扩展到1000万甚至更多用户的7个阶段。每个阶段都解决了不同增长点出现的具体瓶颈。您将了解要添加什么、何时添加、为什么有帮助以及相关的权衡。

无论您是构建应用程序或网站,准备系统设计面试,还是只是对大型系统如何工作感到好奇,了解这一进展将提高您对架构的思考方式。

**免责声明:**本文中提到的用户范围和数字是近似的,旨在说明扩展之旅。实际阈值将因您的产品、工作量特征和流量模式而异。

第1阶段:单个服务器(0-100个用户)

当你刚开始的时候,你的首要任务很简单:发货并验证你的想法。在这个阶段过早优化会浪费时间和金钱在你可能永远不会面临的问题上。

最简单的架构将所有内容放在单个服务器上:您的Web应用程序、数据库和任何后台作业都在同一台计算机上运行。

img
img

这就是Instagram的开始。当Kevin Systrom和Mike Krieger在2010年推出第一个版本时,第一天就有25,000人注册了。

他们没有提前过度设计。凭借一个小团队和简单的设置,他们根据实际需求进行扩展,随着使用量的增长而增加容量,而不是为假设的未来流量而构建。

这个建筑是什么样子的

在实践中,單伺服器設定意味著:

  • 处理HTTP请求的网络框架(Django、Rails、Express、Spring Boot)
  • 存储您数据的数据库(PostgreSQL、MySQL)
  • 异步任务的后台作业处理(Sidekiq、Celery)
  • 可能是SSL终止的反向代理(Nginx)

所有这些都在一个虚拟机上运行。基本VPS(DigitalOcean Droplet、AWS Lightsail、Linode)的云提供商账单可能为每月20-50美元。

为什么这对早期阶段有效

在这个阶段,简单是你最大的优势:

  • 快速部署:一台服务器意味着一个部署、监控和调试的地方。
  • 低成本:每月20-50美元的虚拟专用服务器(VPS)可以舒适地处理您的前100名用户。
  • 更快的迭代:没有分布式系统的复杂性来减缓开发。
  • 更轻松的调试:所有日志都在一个地方,组件之间没有网络问题。
  • 全栈可见性:您可以端到端跟踪每个请求,因为只有一个执行路径。

你正在做出的权衡

这种简单性伴随着你明知故犯的权衡:

img
img

何时继续前进

当你注意到这些迹象时,你就会知道是时候进化了:

  • 数据库查询在高峰流量期间变慢:应用程序和数据库争夺相同的CPU和内存。一个重的查询可以拖累每个人的API延迟。
  • 服务器CPU或内存始终超过70-80%:您正在接近一台机器可以可靠处理的极限。
  • 部署需要重启并导致停机:即使是短暂的中断也会变得明显,用户开始抱怨。
  • 后台作业崩溃会关闭网络服务器:如果没有隔离,非面向用户的工作可能会影响用户体验。
  • 您甚至负担不起短暂的停机时间:您的产品已经变得足够关键,甚至维护窗口也不再被接受。

在某些时候,服务器开始在做所有事情的重压下挣扎。那就是你第一次建筑拆分的时候了。

分享

第2阶段:单独的数据库(1000-1000用户)

随着流量的增长,您的单个服务器开始挣扎。网络应用程序和数据库争夺相同的CPU、内存和磁盘I/O。一个沉重的查询会增加延迟,并减慢每个API响应。

第一个扩展步骤很简单:将数据库与应用程序服务器分开

img
img

这种两层架构为您提供了几个直接的好处:

  • **资源隔离:**应用程序和数据库不再争夺CPU/内存。每个人都可以使用100%的分配资源。
  • **独立扩展:**在不触及应用程序服务器的情况下升级数据库(更多RAM,更快的存储)。
  • **更好的安全性:**数据库服务器可以位于专用网络中,不会暴露在互联网上。
  • **专业优化:**针对其特定工作负载调整每台服务器。应用程序服务器的CPU高,数据库的I/O高。
  • **备份简单性:**数据库备份不会影响应用程序性能,因为它们在不同的计算机上运行。

托管数据库服务

在这个阶段,大多数团队使用托管数据库,如Amazon RDSGoogle Cloud SQLAzure DatabaseSupabase(我在** algomaster.io**上使用Supabase)。

托管服务通常处理:

  • 自动备份(每日快照、时间点恢复)
  • 安全补丁和更新
  • 基本监控和警报
  • 可选的读取副本(我们稍后会介绍这些)
  • 故障转移至待机实例

一旦你考虑了工程时间,自托管和托管之间的成本差异通常很小。托管的PostgreSQL实例可能比原始虚拟机每月花费50-100美元,但它可以节省每周几个小时的维护。这些时间是更好的运输功能。

自我管理数据库的主要原因是:

  • 大规模的成本优化
  • 托管服务不支持的特定配置
  • 禁止托管服务的合规性要求
  • 您正在构建一个数据库产品

对于大多数团队来说,托管服务是正确的选择,直到您的数据库账单每月增长到数千美元

连接池

在这个阶段,一个经常被忽视的改进是连接池。每次数据库连接都消耗了資源:

  • 连接状态的内存(通常在PostgreSQL中每个连接5-10MB)
  • 应用程序和数据库服务器上的文件描述符
  • 用于连接管理的CPU开销

开通新连接也很贵。在TCP握手、SSL協商和資料庫身份驗證之間,您可以為每個請求新增50-100毫秒的開銷。

PgBouncer(用于PostgreSQL)这样的连接池器保持一小组数据库连接打开,并在请求之间重复使用它们。

img
img

拥有1000个用户,您可能有100个并发连接击中您的API。如果没有池化,那就是100个数据库连接消耗资源。通过池化,20-30个实际数据库连接可以通过连接重用有效地服务于这100个应用程序连接。

连接池模式:

  • 会话池:每个客户端连接一个池连接(最兼容,效率最低)
  • 交易池:每次交易后恢复到池的连接(大多数应用程序的最佳平衡)
  • 语句池:每个语句后返回的连接(最有效,但可能会破坏功能)

大多数应用程序使用事务池效果最好,这通常会将连接效率提高3-5倍

网络延迟注意事项

分离数据库会导致网络延迟。当应用程序和数据库在同一台机器上时,“网络”延迟基本上为零(回环接口)。现在,每个查询都会增加0.1-1毫秒的网络往返时间。

对于大多数应用程序来说,这可以忽略不计。但是,如果您的代码每个请求进行数百个数据库查询(反模式,但很常见),那么这个延迟就会加起来。解决方案不是将它们放回同一台机器上,而是优化您的查询模式:

  • 尽可能批量查询
  • 使用JOIN代替N+1查询模式
  • 缓存频繁访问的数据
  • 使用连接池来避免重复的连接设置开销

有了自己的服务器上的数据库,你为自己争取了成长的空间。但您还创建了一个新的单点故障:应用程序服务器现在是薄弱环节。当它下降时,或者当它根本跟不上需求时会发生什么?

第3阶段:负载平衡器+水平缩放(1K-10K用户)

您的分离架构现在更好地处理负载,但您引入了一个新问题:您的单个应用程序服务器现在是单个故障点。如果它崩溃了,你的整个应用程序就会停机。随着流量的增长,那台服务器跟不上。

下一步是在负载平衡器后面运行多个应用程序服务器

img
img

负载平衡器位于您的服务器前面,并在服务器之间分配传入的请求。如果一个服务器出现故障,负载平衡器会检测到这一点(通过运行状况检查),并仅将流量路由到运行良好的服务器。当单个服务器出现故障时,用户不会遇到停机。

负载平衡器需要决定哪个服务器处理每个请求。常见的算法包括:Round Robin加權Roun Robin最小连接IP哈希随机

大多数团队从Round Robin开始(简单,在大多数情况下效果良好),如果他们有处理时间不同的请求,则切换到最小连接。

现代负载均衡器在不同层工作:

  • **第4层(传输):**基于IP和端口的路由。速度很快,但无法检查HTTP标头。
  • **第7层(应用程序):**基于HTTP标头、URL、cookie的路由。更灵活,稍微多一点开销。

对于大多数网络应用程序来说,第7层负载平衡更可取,因为它能够:

  • 基于路径的路由(/api/*到API服务器,/static/*到CDN)
  • 基于标题的路由(移动版和桌面版的不同版本)
  • 负载平衡器的SSL终止
  • 安全请求/响应检查

垂直与水平缩放

在添加更多服务器之前,你可能会问:为什么不买一个更大的服务器呢?这是经典的垂直与水平缩放的权衡。

垂直缩放意味着移动到更大的服务器。它很早就运行良好,通常不需要更改代码。但你最终会遇到两个问题:硬硬件限制和快速增加的成本。

更大的机器定价是非线性的,因此翻倍CPU或内存的成本可能会高出3-4倍。即使是最大的实例也有上限。

水平扩展意味着增加更多的服务器。一开始更难,因为您的应用程序必须是无状态的,因此任何服务器都可以处理任何请求。但它实际上为您提供了无限的容量和内置冗余。如果一个服务器出现故障,系统会继续运行。

会话问题

这就是水平缩放变得棘手的地方。如果用户登录并且他们的会话位于服务器1的内存中,那么当下一个请求到达服务器2时会发生什么?从应用程序的角度来看,会话缺失,因此用户看起来已注销。

这是有状态的服务器问题,也是水平扩展的最大障碍。

有两种常见的处理方式:

1.粘性会话(会话亲和力)

负载平衡器将来自同一用户的所有请求路由到同一服务器,通常使用cookie或IP哈希。

优点:

  • 不需要更改应用程序
  • 适用于任何会话存储

缺点:

  • 如果该服务器出现故障,用户将失去他们的会话
  • 如果一些用户比其他用户更活跃,负载分布不均匀
  • 限制真正的水平缩放(无法在服务器之间自由移动用户)
  • 新服务器需要时间来“热身”会话

2.外部会话存储

将会话数据从应用程序服务器移入共享存储,如RedisMemcached

img
img

现在,任何服务器都可以处理任何请求,因为会话数据是集中的。这是大多数大型系统使用的模式。与Redis查找(亚毫秒)提供的灵活性相比,Redis查找的额外延迟可以忽略不计。

您现在可以处理更多的流量,并在服务器故障中幸存下来。但随着用户群的增长,您会注意到一些事情:无论您添加多少台应用程序服务器,它们都在锤击同一个数据库。数据库正在成为你的下一个瓶颈。

第4阶段:缓存+读取副本+CDN(10K-100K用户)

拥有超过10,000名用户,一个新的瓶颈出现了:您的数据库。每个请求都会到达数据库,随着流量的增加,查询延迟也会增加。处理100 QPS(每秒查询)的数据库开始在1000 QPS时挣扎。

读取重的应用程序(大多数是,读写比为10:1或更高)特别困难。

本阶段介绍了三种互补解决方案:缓存读取副本CDN。它们可以一起将数据库负载减少90%或更多。

缓存层

大多数网络应用程序遵循80/20规则:80%的请求访问20%的数据。浏览10,000次的产品页面不需要10,000次数据库查询。在每个页面视图上加载的用户配置文件不需要每次都被新获取。

缓存将经常访问的数据存储在内存中,以便近乎即时地检索。数据库查询需要1-100毫秒,而缓存读取需要0.1-1毫秒。

img
img

最常见的缓存模式是缓存-side(也称为延迟加载):

  1. 应用程序首先检查缓存
  2. 如果存在数据(缓存命中),请立即返回
  3. 如果没有(缓存缺失),请查询数据库
  4. 将结果存储在缓存中以备将来请求(使用TTL)
  5. 返回数据

Redis和Memcached是这里的标准选择。Redis功能更丰富(支持列表、集合、排序集等数据结构;持久性;pub/sub;Lua脚本),而Memcached对于纯键值缓存来说更简单且略快。

大多数团队选择Redis,因为附加功能很有用(使用排序集作为排行榜、列表等),性能差异可以忽略不计。

缓存什么

不是所有东西都应该被缓存。好的缓存候选者包括:

img
img

可怜的缓存候选:

  • 高度个性化的数据(每个用户都不同,重复使用率低)
  • 频繁更改数据(不断的无效开销)
  • 大斑点(消耗记忆力,没有成比例的好处)
  • 陈旧导致问题的交易数据

缓存无效

缓存最难的部分不是添加它,而是保持它的准确性。当基础数据发生变化时,缓存的数据会变得陈旧。这是著名的“计算机科学中的两个难题”之一。

常见的策略包括:

img
img

大多数系统从基于TTL的过期开始(将缓存设置为5-60分钟后过期),并为陈旧导致问题的数据添加显式无效。例如:

def update_user_profile(user_id, new_data):
    # Update database
    db.update("users", user_id, new_data)
    # Invalidate cache
    cache.delete(f"user:{user_id}")

下一次读取将错过缓存,并从数据库中获取新数据。

阅读副本

即使使用缓存,一些请求仍然会击中数据库,特别是写入缓存错误。读取副本通过在数据库的多个副本中分配读取流量来提供帮助。

img
img

主数据库处理所有写入。然后将更改复制(通常是异步)到一个或多个读取副本。您的应用程序将读取查询发送到副本,并将写入工作负载保持在主服务器上,这减少了争用并提高了整体吞吐量。

复制后

一个重要的考虑因素是复制滞后。由于复制通常是异步的(为了性能),副本可能比主复制晚几毫秒到几秒。

对于大多数应用程序来说,这是可以接受的。如果社交媒体提要落后一秒钟,大多数用户不会注意到。但有些流程需要更强的一致性。

常见的失败模式是读写一致性

用户立即更新他们的个人资料并刷新。如果该读数落在尚未赶上的副本上,他们会看到旧数据,并假定更新失败。

解决方案:

  1. 写入后从主读取:在写入后的短时间内(N秒),将用户的读取路由到主。
  2. 会话级一致性:跟踪用户的最后一次写入时间戳,并且仅从超过该点的副本中读取。
  3. 显式从主读:对于关键读(查看刚刚更新的数据),始终点击主读。

大多数框架都内置了对读/写拆分的支持。例如,Rails(ActiveRecord)、Django和Hibernate可以将读取路由到副本,并自动写入主。

内容交付网络(CDN)

图像、CSS、JavaScript和视频等静态资产很少更改,根本不需要攻击您的应用程序服务器。它们也是您提供的最大文件,如果您直接提供它们,它们在带宽和计算方面都很昂贵。

CDN透過在稱為邊緣位置(或存在點)的全球分散式伺服器上快取靜態資產來解決這個問題。

img
img

以下是东京用户请求图像时的情况:

  • 请求被路由到东京CDN边缘(低延迟,例如往返约50毫秒)。
  • 如果文件已被缓存(缓存命中),CDN会立即提供。
  • 如果它没有缓存(缓存错过),CDN会从您的来源(可能在美国,~300毫秒)获取它,在边缘存储一个副本,然后将其返回给用户。
  • 东京的下一个用户从边缘获取缓存版本,同样在~50毫秒。

流行的CDN包括Cloudflare(强大的免费层级)、AWS CloudFrontFastlyAkamai

有了缓存、读取副本和CDN,您的系统可以处理稳定的增长。下一個挑战是交通繁忙。病毒式帖子、营销活动,甚至是凌晨3点和下午3点之间的差异,都会产生10倍的流量变化。此时,手动调整容量将停止工作。

第5阶段:自动升级+无状态设计(10K-50K用户)

超过10万用户,流量模式变得难以预测。你可能有:

  • 每日高峰(美国上午,欧盟晚上)
  • 每周模式(B2B工作日更高,消费者周末更高)
  • 营销活动激增(数小时流量增加10倍)
  • 病毒时刻(100倍流量,不可預測的持续時間)

此时,手动添加和删除服务器不再可行。您需要自动响应的基础设施。

此阶段侧重于自动放大(自动调整容量),并确保您的应用程序真正无状态(可以自由添加或删除服务器,而不会丢失数据或对用户产生影响)。

无状态建筑

为了使自动縮放工作,您的应用程序服务器必须是可互换的。任何请求都可以发送到任何服务器。任何服务器都可以在不丢失数据的情况下被终止。新服务器可以立即开始处理请求。

img
img

当新服务器加入集群时,它通常:

  1. 启动应用程序
  2. 在负载均衡器上注册(或被发现)
  3. 连接到Redis、数据库和其他共享服务
  4. 立即开始处理请求

当服务器被移除时:

  1. 负载平衡器停止发送新请求
  2. 飞行中请求完成(优雅关机)
  3. 服务器终止

没有数据丢失,因为没有重要的东西存储在本地。

自动升级策略

自动调度会根据指标调整容量。扩展系统持续监控指标,并根据阈值添加或删除服务器。

大多数团队从基于 CPU 的扩展开始。它很简单,适用于大多数工作量,而且很容易推理。为后台工作者添加队列深度缩放。

缩放参数

配置自动縮放时,您将设置以下参数:

Minimum instances: 2       # Always running, even at zero traffic
Maximum instances: 20      # Cost ceiling and resource limit
Scale-up threshold: 70%    # CPU percentage to trigger scale-up
Scale-down threshold: 30%  # CPU percentage to trigger scale-down
Scale-up cooldown: 3 min   # Wait time after scaling up before next action
Scale-down cooldown: 10 min # Wait time after scaling down
Instance warmup: 2 min     # Time for new instance to become fully operational

重要注意事项:

  • 最小实例:至少应为2个冗余。如果一个失败,另一个处理流量,而替换的旋转。
  • 冷却期:防止粉碎(快速放大和缩小)。缩小冷却时间通常更长,因为移除容量比增加容量风险更大。
  • 实例预热:新服务器需要时间来启动、加载代码、预热缓存、建立数据库连接。在它们准备好之前,不要将它们算作容量。
  • 不对称缩放:积极放大(快速反应加载),保守地缩小(不要太快删除容量)。

用于无状态身份验证的JWT

在这种规模下,许多团队使用JWT(JSON Web Tokens)从基于会话的身份验证转向基于令牌的身份验证。使用基于会话的身份验证,每个请求都需要会话存储查找。使用JWT,身份验证状态包含在令牌本身中。

JWT有三个部分:

Header.Payload.Signature

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjM0NTZ9.signature_here

有效负载包含用户ID、角色和到期等声明。签名确保令牌没有被篡改。任何服务器都可以使用共享密钥验证签名,而无需查询数据库。

与JWT的权衡:

  • 优点:真正无状态,没有对每个请求进行会话存储查询
  • 优点:跨服务(微服务、移动应用程序、第三方API)工作
  • 缺点:在到期前无法使单个令牌失效(用户注销,但令牌仍然有效)
  • 缺点:令牌大小添加到每个请求中(500字节与32字节会话ID)

一个常见的模式是短命访问令牌(例如15分钟)加上长命刷新令牌(例如,7天)。这限制了受损或陈旧的令牌的使用时间。

此时,您的应用程序层会弹性扩展。流量激增,更多的服务器旋转。交通下降,他们旋转下来。

但一个新的上限即将到来:数据库只能处理这么多的写入,整体变得更难安全更改,一些操作太慢,无法同步运行。那就是你把重型机械带进来的时候。

第6阶段:分片+微服务+消息队列(500K-1M用户)

拥有50万多名用户,您将达到以前的优化无法解决的新上限:

  • 即使读取被卸载到副本,写入也压倒了单个主数据库。
  • 单体运输变得痛苦。对通知的微小更改迫使整个应用程序完全重新部署。
  • 以前的快速操作开始需要几秒钟,因为请求路径中同步发生了太多工作。
  • 产品的不同部分需要不同的缩放轮廓。搜索和提要可能需要个人资料页面的10倍容量。

这就是重型机械的用家:数据库分片服务和异步处理(消息队列)。

数据库分片

读取副本解决了读取扩展问题,但所有写入仍然进入一个主数据库。在大量时,这个主要成为瓶颈。您受一台机器能够处理的内容的限制:

  • 写入吞吐量(插入、更新、删除)
  • 存储容量(即使是大磁盘也有限制)
  • 连接计数(即使有池)

分片将基于分片密钥将数据拆分为多个数据库。每个碎片都包含数据的子集,并处理该子集的读写。

img
img

分片策略

img
img

与简单的基于哈希的分片相比,一致的散列是一种流行的改进。你把钥匙放在一个戒指上,而不是hash(key) % num_shards。当您添加新碎片时,只有与其位置相邻的键移动,而不是所有键。这意味着添加第四个碎片可以移动25%的数据,而不是75%。

什么时候碎片

碎片是单向的门。一旦你碎片:

  • 跨碎片查询变得昂贵或不可能(跨碎片连接数据)
  • 跨越碎片的交易很复杂(两阶段提交或放弃原子性)
  • 模式更改必须应用于所有碎片
  • 操作(备份、迁移)乘以碎片计数
  • 应用程序代码变得更加复杂(碎片路由逻辑)

在分片之前,用尽以下选项:

  1. 优化查询:添加缺失的索引,重写缓慢的查询,在有帮助的地方去规范化
  2. 垂直扩展:升级到更大的数据库服务器(更多的CPU、RAM、更快的SSD)
  3. 读取副本:如果读取繁重,请添加副本来处理读取
  4. 缓存:通过缓存经常访问的数据来减少数据库的负载
  5. 存档:将旧数据移至冷库(分离数据库、对象存储)
  6. 连接池:减少连接开销

只有当您真正被写入约束,单个节点物理上无法处理您的吞吐量,或者当您的数据集超过一台机器上适合的数据集时,才会碎片。

微服务

随着产品和团队的发展,整体变得更加难以安全进化。您可能从微服务中受益的常见信号:

  • 更改一个区域(如通知)需要重新部署整个应用程序。
  • 团队在不协调每个版本的情况下无法独立发货。
  • 应用程序的不同部分有不同的扩展需求(搜索需要10台服务器,配置文件查看需求2台)
  • 工程师经常在同一代码库中发生冲突。
  • 一个子系统中的错误会关闭整个应用程序。

微服务将应用程序分成通过网络进行通信的独立服务。

img
img

每项服务:

  • 拥有其数据(仅直接写入的数据库)
  • 独立部署(无需触摸结账即可发货通知)
  • 独立缩放(搜索可以与配置文件分开缩放)
  • 使用适合目的的技术(搜索可能使用Elasticsearch,付款可能需要具有强烈一致性的Postgres)
  • 公开一个明确的API合同(其他服务通过稳定的端点集成)

权衡是运营复杂性的巨大飞跃。最安全的方法是从一个提取开始:选择具有最干净边界和最明确的独立缩放需求的服务。避免提前分成数十项服务。

消息队列和非同步处理

并非所有事情都需要在请求路径中同步发生。当用户下订单时,有些步骤必须立即完成,而其他步骤可以在后台完成。

必须同步:

  • 验证付款方式
  • 检查库存
  • 创建订单记录
  • 退货订单确认

可以异步:

  • 发送确认电子邮件
  • 更新分析儀表板
  • 通知仓库履行
  • 更新推荐引擎
  • 同步到会计系统

KafkaRabbitMQSQS等消息队列将生产者与消费者脱钩。订单服务发布像OrderPlaced这样的事件,下游系统独立使用它。

img
img

异步处理的好处:

  • 弹性:如果电子邮件服务停机,邮件会排队。訂單仍未完成。服务恢复时发送电子邮件。
  • 可扩展性:消费者根据队列深度独立扩展。假期匆忙?在不触及订单服务的情况下添加更多仓库通知处理器。
  • 解耦合:订单服务不需要知道谁消费了该事件。您可以在不更改生产者的情况下添加新的消费者(欺诈检测、客户关系管理同步)。
  • 平滑爆裂:队列吸收尖峰,让下游系统以可持续的速度处理,而不是超载。
  • 重试处理:失败的消息可以自动重试。Dead letter 队列捕获反复失败以进行调查的消息。

一个常见的现实世界模式是“现在写作,以后再做繁重的工作”。

例如,在社交应用程序中,创建帖子通常是快速写作和即时成功响应。扇出、索引、通知和饲料更新等昂贵的工作是异步发生的,这就是为什么你有时会在类似计数或饲料传播中看到小延迟。

此时,您的架构可以在单个区域内处理大规模的架构。但您的用户并不全部在一个地方,您的基础设施也不应该在一个地方。

一旦您拥有跨大洲的用户,延迟就会变得明显,单个数据中心将成为您整个全球用户群的单点故障。

第7阶段:多区域+高级模式(1M-1000万+用户)

全球有数百万用户,出现了新的挑战:

  • 澳大利亚用户在访问美国服务器时遇到了300毫秒的延迟
  • 数据中心中断(火灾、网络分区、云提供商问题)会关闭您的整个服务
  • 您的数据库架构无法高效地同时提供重写实时更新和重读分析仪表板
  • 不同的地区有不同的数据居住要求(欧盟的GDPR,数据本地化法)

此階段涵蓋多區域部署高階快取和CQRS等特殊模式

多區域架構

部署到多个地理区域可以实现两个主要目标:

  1. 延迟降低:用户连接到附近的服务器。东京用户访问了东京服务器(20毫秒),而不是美国服务器(200毫秒)。
  2. 灾难恢复:如果一个地区失败,其他地区将继续提供流量。真正的高可用性。

img
img

有两种主要方法:

主动-被动(初级-中级)

一个区域(主区域)处理所有写入。其他地区提供读取服务,如果主读取失败,可以接管。

优点:

  • 实施更简单
  • 不需要写冲突解决
  • 写作的一致性很强

缺点:

  • 对于远离主用户来说,写入延迟更高
  • 故障转移不是即时的(DNS传播、复制推广)
  • 主要区域仍然是单点故障

活跃-活跃

所有区域都处理读取和写入。这需要解决一个难题:当美国和欧盟的用户同时更新同一记录时会发生什么?

优点:

  • 所有操作的最低延迟
  • 真正的高可用性,任何区域故障都是无缝的
  • 没有单点故障

缺点:

  • 冲突解决很复杂(如果做错了,可能会导致数据问题)
  • 最终一致,不适用于所有数据类型
  • 推理和调试更复杂

大多数公司从主动-被动开始。主动-主动需要解决分布式共识问题并接受最终的一致性。

全球范围内的CAP定理

CAP定理在全球范围内变得非常真实。它指出,分布式系统只能提供三个保证中的两个:

  • 一致性:每次阅读都会收到最新的写入
  • 可用性:每个请求都会收到响应(不是错误)
  • 分区公差:尽管有网络分区,系统仍在继续

由于区域之间的网络分区是不可避免的(海底电缆被切断,云提供商中断),您确实在分区期间在一致性和可用性之间做出选择。

大多数全局系统为大多数操作选择最终一致性

  • 用户的帖子可能需要1-2秒才能出现在其他地区的关注者面前
  • 产品评级可能会在不同地区短暂地显示略有不同的平均值
  • 用户配置文件更新可能需要片刻时间来传播

只有不一致导致实际问题(付款、库存减少、金融交易)的操作才需要很强的一致性,这些操作可能会被路由到主要区域。

CQRS模式

随着系统的增长,读写模式会显著差异:

  • 写入需要事务、验证、规范化数据、审计日志
  • 读取需要去规范化的数据、快速聚合、全文搜索
  • 写入量可能是读取量的1/100

**CQRS(命令查询责任隔离)**将这些问题完全分开。

img
img

写入端使用针对数据完整性和事务保证优化的规范化模式。读取端使用针对查询性能优化的去规范化视图。事件使两者同步。

真实世界的例子:推特的时间线架构。

  • 写入路径:当您发推文时,它会写入具有适当索引、约束和事务的规范化推文表。
  • 事件:“推文创建”事件触发。
  • 预测:扇出服务读取事件,并将推文添加到每个关注者的时间线上(一个非规范化的、针对“显示我的饲料”查询优化的每个用户数据结构)。
  • 读取路径:当您打开推特时,您从预先计算的时间线中读取,而不是连接推文、关注和用户的复杂查询。

CQRS增加了复杂性,但可以:

  • 读取和写入路径的独立缩放
  • 针对每个访问模式优化的模式
  • 不同的技术选择(PostgreSQL用于写入,Elasticsearch用于读取)
  • 两种操作的性能都更好

高级缓存模式

在全球范围内,缓存变得更加复杂:

多层缓存

img
img

img
img

缓存加热

当新的缓存服务器启动(或缓存在维护后过期)时,第一个请求会面临缓存错过,导致延迟峰值和源加载。缓存预热在流量到达之前预先填充缓存:

  • 部署时:在启动期间,在接收流量之前,将热门项目加载到缓存中
  • 活动前:在营销推广之前,使用可能访问的产品/页面的缓存
  • 缓存复制:添加新的缓存节点时,从现有节点复制状态

Netflix在高峰时段之前用热门内容预热边缘缓存。当晚上开始观看时,观看最多的节目已经缓存在边缘位置。

写入后(回写)缓存

对于重写工作负载,先写入缓存,然后异步持久到数据库:

  1. 写入到缓存(立即返回给用户)
  2. 缓存确认写入
  3. 后台进程会定期刷新写入数据库

这大大降低了写入延迟,但引入了风险:如果缓存在刷新前失败,写入就会丢失。仅在以下时间使用:

  • 一些数据丢失是可以接受的(分析计数器、浏览次数)
  • 缓存高度可用(具有复制和持久性的Redis)
  • 耐用性可以为了性能而牺牲

您现在已经构建了一个全球分布式系统,以低延迟处理全球数百万用户。但旅程并没有到此结束。在真正的大规模中,即使是最好的现成解决方案也开始显示出它们的局限性。

超过1000万用户

拥有1000万用户甚至更多,你进入了现成的解决方案并不总是有效的领域。这种规模的公司通常会根据其特定的访问模式构建定制基础设施。这些问题成为您工作量特有的问题。

专业数据存储

没有一个数据库可以很好地处理所有访问模式。“多语言持久性”的概念意味着为不同的用例使用不同的数据库:

img
img

img
img

每个数据库都针对特定的访问模式进行了优化。将PostgreSQL用于时间序列数据有效,但效率低下。使用Elasticsearch进行交易是可能的,但很危险。

大规模定制解决方案

在极端规模上,一些公司构建了定制基础设施,因为他们的要求超出了通用系统所能提供的:

  • **Facebook的TAO:**一个用于社交图的自定义数据系统,旨在满足Facebook在现成选项无法实现时大规模的延迟和吞吐量需求。
  • **谷歌Spanner:**一个全球分布式的SQL数据库,旨在跨区域提供强大的一致性,结合了当时难以组合的属性。
  • **Netflix的EVCache:**基于Memcache的大规模缓存层,具有额外的复制、可靠性和操作工具,以支持Netflix的流量模式。
  • **Discord的存储之旅:**MongoDB(2015)→Cassandra(2017)→ScyllaDB(2022)。每一次移动都是由之前选择的限制驱动的,Discord分享了关于这些迁移背后的权衡的详细文章。
  • **Uber的Schemaless:**一个基于MySQL的存储层,旨在保持事务语义,同时扩展到单个MySQL设置之外,对团队来说操作简单。

这些不是你最初会选择的选项,但它们表明,扩展是一个持续的旅程,而不是一个目的地。适用于100万用户的架构很少是您想要的1亿用户。

边缘计算

下一个前沿是将计算推向更接近用户。边缘计算不是在集中式数据中心运行所有逻辑,而是在全球CDN边缘位置运行代码:

  • Cloudflare Workers:250多个边缘位置的JavaScript/WASM
  • AWS Lambda@Edge:CloudFront边缘的Lambda功能
  • Fastly Compute@Edge:在Fastly的边缘网络上计算
  • Deno Deploy:全球分布式JavaScript运行时

边缘计算代表了一个根本性的转变:许多请求不是“请求→CDN→来源→CDN→响应”,而是“请求→边缘→响应”,边缘有足够的计算能力来处理逻辑。

现在我们已经涵盖了从单一服务器到全球规模基础设施的全部进展,一个重要的问题仍然存在:您如何知道何时采取每一步?过早扩展会浪费资源;过晚扩展会导致中断。

摘要

将系统从零扩展到数百万用户遵循可预测的进展。每个阶段都解决了在特定阈值出现的问题:

img
img

要记住的关键原则

  1. 简单开始:不要针对你还没有的问题进行优化。单个服务器很好,直到它不正常。
  2. 首先测量:在添加基础设施之前确定实际的瓶颈。CPU绑定问题需要与I/O绑定问题不同的解决方案。
  3. 无状态服务器是先决条件:在您的服务器不保持本地状态之前,您无法水平扩展或自动扩展。
  4. 积极缓存:大多数数据的读取频率远远高于写入频率。缓存可为读取繁重的工作负载提供10-100倍的性能提升。
  5. 尽可能异步:并非所有事情都需要在请求路径中发生。电子邮件发送、分析、通知都可以排队。
  6. 不情愿地分片:数据库分片是一条具有重大复杂性的单向门。先用尽其他选项。
  7. 接受权衡:在网络分区期间,完美的一致性和可用性不会共存。了解哪些操作真正需要强烈的一致性。
  8. 复杂性有成本:您添加的每个组件都是可能失败的组件,需要监控,需要专业知识才能操作。

规模化之路不是一次性实施所有事情。这是关于了解每个阶段会出现哪些问题,并在正确的时间应用正确的解决方案。

最好的架构是最简单的,可以满足您当前需求,当这些需求发生变化时,有一条清晰的发展路径。

就是这样。非常感谢您的阅读!

如果您觉得这篇文章有帮助,请点赞❤️并与他人分享。