从零到千万用户:系统扩展的七个阶段

系统扩展是一个复杂的话题,但在大厂处理过百万级请求、并将自己的创业公司 AlgoMaster.io 从零开始搭建后,我发现大多数系统在成长过程中都会经历相似的阶段。

关键洞察是:不要从一开始就过度设计。从简单开始,识别瓶颈,逐步扩展。

在本文中,我将带你了解系统从零到千万用户甚至更高级别的七个扩展阶段。每个阶段都会解决在特定增长点上出现的具体瓶颈。你将学到添加什么、何时添加、为什么有帮助以及涉及的权衡。

无论你是在开发 App 或网站、准备系统设计面试,还是只是好奇大型系统如何工作,了解这个演进过程都会帮助你更好地思考架构问题。

免责声明: 本文提到的用户范围和数字是近似值,用于说明扩展历程。实际的阈值取决于你的产品、工作负载特征和流量模式。

阶段一:单服务器架构(0-100 用户)

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

最简单的架构是将所有东西放在单服务器上:你的 Web 应用、数据库和任何后台任务都在同一台机器上运行。

Instagram 就是这样起步的。2010 年,Kevin Systrom 和 Mike Krieger 发布第一个版本时,第一天就有 25,000 人注册。

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

这种架构长什么样

实际上,单服务器设置意味着:

  • Web 框架(Django、Rails、Express、Spring Boot)处理 HTTP 请求
  • 数据库(PostgreSQL、MySQL)存储数据
  • 后台任务处理(Sidekiq、Celery)处理异步任务
  • 可能前面有一个反向代理(Nginx)用于 SSL 终止

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

为什么这对早期阶段有效

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

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

你在接受的权衡

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

何时进入下一阶段

当你注意到这些迹象时,就该进化了:

  • 高峰流量期间数据库查询变慢:应用和数据库争夺相同的 CPU 和内存。一个重查询会降低所有人的 API 延迟。
  • **服务器 CPU 或内存持续超过 70-80%**:你正在接近单台机器可以可靠处理的极限。
  • 部署需要重启并导致停机:即使是短暂的中断也变得明显,用户开始抱怨。
  • 后台任务崩溃导致 Web 服务器宕机:没有隔离,非面向用户的工作会影响用户体验。
  • 你承担不起哪怕是短暂的停机:你的产品已经足够关键,以至于维护窗口也变得不可接受。

在某一点上,服务器开始在做所有事情的重压下挣扎。那就是进行第一次架构拆分的时机。

阶段二:数据库分离(100-1000 用户)

随着流量增长,你的单服务器开始吃力。Web 应用和数据库争夺相同的 CPU、内存和磁盘 I/O。一个重查询可能导致延迟飙升,减慢每个 API 响应。

第一个扩展步骤很简单:将数据库从应用服务器分离出来

这种两层架构给你带来几个直接的好处:

  • 资源隔离:应用和数据库不再争夺 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)这样的连接池器保持一组数据库连接打开并在请求之间重用它们。

有 1,000 个用户时,你可能有 100 个并发连接访问你的 API。没有池化,那就是 100 个消耗资源的数据库连接。有了池化,20-30 个实际的数据库连接可以通过连接重用高效地服务这 100 个应用连接。

连接池模式:

  • 会话池化:每个客户端连接一个池连接(最兼容,效率最低)
  • 事务池化:每个事务后将连接返回池(大多数应用的最佳平衡)
  • 语句池化:每个语句后将连接返回(最高效,但可能破坏功能)

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

网络延迟考量

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

对于大多数应用,这可以忽略不计。但如果你的代码每个请求进行数百次数据库查询(这是一种反模式,但很常见),这种延迟会累积。解决方案不是将它们放回同一台机器,而是优化你的查询模式:

  • 尽可能批处理查询
  • 使用 JOIN 而不是 N+1 查询模式
  • 缓存频繁访问的数据
  • 使用连接池避免重复的连接设置开销

数据库在独立服务器上后,你已经获得了成长的空间。但你也创造了新的单点故障:应用服务器现在是薄弱环节。当它宕机,或者当它根本跟不上需求时会发生什么?

阶段三:负载均衡器 + 水平扩展(1000-10000 用户)

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

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

负载均衡器位于你的服务器前面,将传入请求分发到它们之间。如果一台服务器故障,负载均衡器通过健康检查检测到这一点,并仅将流量路由到健康的服务器。用户不会经历单台服务器故障时的停机。

负载均衡器需要决定哪个服务器处理每个请求。常见算法包括:轮询加权轮询最少连接IP 哈希随机

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

现代负载均衡器在不同的层级运行:

  • 第 4 层(传输层):基于 IP 和端口路由。快速,但不能检查 HTTP 头。
  • 第 7 层(应用层):基于 HTTP 头、URL、Cookie 路由。更灵活,开销略大。

对于大多数 Web 应用,第 7 层负载均衡更可取,因为它支持:

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

垂直扩展 vs 水平扩展

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

垂直扩展意味着迁移到更大的服务器。它在早期效果很好,通常不需要代码更改。但你最终会遇到两个问题:硬件硬限制和成本快速增加。

更大的机器定价是非线性的,因此 CPU 或内存翻倍可能花费 3-4 倍。即使最大的实例也有天花板。

水平扩展意味着添加更多服务器。起初更难,因为你的应用必须是无状态的,所以任何服务器都可以处理任何请求。但它给你实际上无限的容量和内置的冗余。如果一台服务器故障,系统继续运行。

会话问题

这是水平扩展变得棘手的地方。如果用户登录并且他们的会话存储在服务器 1 的内存中,当下一个请求落到服务器 2时会发生什么?从应用的角度看,会话缺失,所以用户看起来已经登出。

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

有两种常见的方法来处理它:

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

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

优点:

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

缺点:

  • 如果该服务器故障,用户失去会话
  • 如果某些用户比其他用户更活跃,负载分布不均匀
  • 限制真正的水平扩展(不能自由地在服务器之间移动用户)
  • 新服务器需要时间来”预热”会话

2. 外部会话存储

将会话数据从应用服务器移出到共享存储,如 RedisMemcached

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

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

阶段四:缓存 + 只读副本 + CDN(10000-100000 用户)

有 10,000+ 用户时,新的瓶颈出现了:你的数据库。每个请求都命中数据库,随着流量增长,查询延迟增加。处理 100 QPS(每秒查询)良好的数据库在 1,000 QPS 时开始挣扎。

读密集型应用(大多数是,读写比为 10:1 或更高)受影响尤其严重。

这个阶段引入三个互补的解决方案:缓存只读副本CDN。它们一起可以将数据库负载减少 90% 或更多。

缓存层

大多数 Web 应用遵循 80/20 规则:80% 的请求访问 20% 的数据。一个被浏览 10,000 次的产品页面不需要 10,000 次数据库查询。每次页面加载都获取的用户资料不需要每次都重新获取。

缓存将频繁访问的数据存储在内存中以实现近乎即时的检索。虽然数据库查询需要 1-100 毫秒,但缓存读取需要 0.1-1 毫秒。

最常见的缓存模式是旁路缓存(也称为懒加载):

  1. 应用首先检查缓存
  2. 如果数据存在(缓存命中),立即返回
  3. 如果不存在(缓存未命中),查询数据库
  4. 将结果存储在缓存中以供将来请求(带 TTL)
  5. 返回数据

Redis 和 Memcached 是这里的标准选择。Redis 功能更丰富(支持列表、集合、有序集合等数据结构;持久化;发布/订阅;Lua 脚本),而 Memcached 更简单,在纯键值缓存上略快。

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

缓存什么

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

不好的缓存候选:

  • 高度个性化的数据(对每个用户不同,低复用)
  • 频繁变化的数据(不断的失效开销)
  • 大 Blob(消耗内存而没有成比例的收益)
  • 陈旧性导致问题的交易数据

缓存失效

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

常见策略包括:

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

1
2
3
4
5
def update_user_profile(user_id, new_data):
# 更新数据库
db.update("users", user_id, new_data)
# 使缓存失效
cache.delete(f"user:{user_id}")

下一次读取将缓存未命中并从数据库获取新数据。

只读副本

即使有缓存,一些请求仍会命中数据库,特别是写入缓存未命中。只读副本通过将读取流量分发到数据库的多个副本上来帮助。

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

复制延迟

一个重要的考虑因素是复制延迟。由于复制通常是异步的(为了性能),副本可能落后主数据库毫秒到秒。

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

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

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

解决方案:

  1. 写入后从主数据库读取:在写入后的短时间内(N 秒),将该用户的读取路由到主数据库。
  2. 会话级一致性:跟踪用户的最后写入时间戳,只从赶上该点之后的副本读取。
  3. 显式从主数据库读取:对于关键读取(查看刚刚更新的数据),总是命中主数据库。

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

内容分发网络(CDN)

静态资源如图片、CSS、JavaScript 和视频很少改变,根本不需要命中你的应用服务器。它们也是你提供的最大文件,如果你直接提供它们,在带宽和计算方面都很昂贵。

CDN 通过在称为边缘位置(或存在点)的全球分布式服务器上缓存静态资源来解决这个问题。

以下是东京用户请求图片时发生的情况:

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

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

有了缓存、只读副本和 CDN,你的系统可以处理稳定增长。下一个挑战是尖峰流量。一个病毒式帖子、营销活动,甚至凌晨 3 点和下午 3 点之间的差异都可以造成 10 倍的流量变化。在那时,手动调整容量停止工作。

阶段五:自动扩展 + 无状态设计(100000-500000 用户)

有 100K+ 用户时,流量模式变得不太可预测。你可能有:

  • 每日高峰(美国的早晨,欧盟的晚上)
  • 每周模式(B2B 工作日更高,消费者周末更高)
  • 营销活动高峰(数小时内 10 倍流量)
  • 病毒式时刻(100 倍流量,持续时间不可预测)

在这一点上,手动添加和移除服务器不再可行。你需要自动响应的基础设施。

这个阶段侧重于自动扩展(自动调整容量)和确保你的应用真正无状态(服务器可以自由添加或移除而不会丢失数据或影响用户)。

无状态架构

为了让自动扩展工作,你的应用服务器必须可互换。任何请求都可以到任何服务器。任何服务器都可以终止而不会丢失数据。新服务器可以立即开始处理请求。

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

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

当服务器被移除时:

  1. 负载均衡器停止发送新请求
  2. 在途请求完成(优雅关闭)
  3. 服务器终止

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

自动扩展策略

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

大多数团队从基于 CPU 的扩展开始。它简单,适用于大多数工作负载,并且易于理解。为背景任务工作者添加队列深度扩展。

扩展参数

配置自动扩展时,你将设置这些参数:

1
2
3
4
5
6
7
最小实例数: 2       # 即使在零流量时也始终运行
最大实例数: 20 # 成本上限和资源限制
扩展上限阈值: 70% # 触发扩展的 CPU 百分比
扩展下限阈值: 30% # 触发缩减的 CPU 百分比
扩展冷却时间: 3 分钟 # 扩展后等待时间
缩减冷却时间: 10 分钟 # 缩减后等待时间
实例预热: 2 分钟 # 新实例完全运行的时间

重要考虑因素:

  • 最小实例数:应该至少为 2 以保证冗余。如果一个故障,另一个在替换启动时处理流量。
  • 冷却时间:防止抖动(快速上下扩展)。缩减冷却时间通常更长,因为移除容量比添加更冒险。
  • 实例预热:新服务器需要时间启动、加载代码、预热缓存、建立数据库连接。在它们准备好之前不要将它们的容量计入。
  • 非对称扩展:积极扩展(快速响应负载),保守缩减(不要太快移除容量)。

用于无状态认证的 JWT

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

JWT 有三个部分:

1
2
3
Header.Payload.Signature

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjM0NTZ9.signature_here

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

JWT 的权衡:

  • 优点:真正无状态,每个请求不需要会话存储查找
  • 优点:跨服务工作(微服务、移动应用、第三方 API)
  • 缺点:不能在过期前使单个令牌失效(用户登出,但令牌仍然有效)
  • 缺点:令牌大小增加每个请求(500 字节 vs 32 字节会话 ID)

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

在这一点上,你的应用层弹性扩展。流量激增,更多服务器启动。流量下降,它们关闭。

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

阶段六:分片 + 微服务 + 消息队列(500000-1000000 用户)

有 500K+ 用户时,你将遇到以前的优化无法解决的新上限:

  • 即使读取被卸载到副本,写入仍然压垮单个主数据库。
  • 单体变得痛苦地难以发布。通知的小更改迫使整个应用完全重新部署。
  • 以前快速的操作开始花费数秒,因为请求路径中发生了太多工作。
  • 产品的不同部分需要不同的扩展配置。搜索和信息流可能需要个人资料页面 10 倍的容量。

这就是重型机械进来的地方:数据库分片微服务异步处理(消息队列)。

数据库分片

只读副本解决了读取扩展,但写入仍然都去往一个主数据库。在高容量下,这个主数据库成为瓶颈。你受到一台机器在以下方面的限制:

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

分片根据分片键将你的数据拆分到多个数据库。每个分片保存数据的子集并处理该子集的读取和写入。

分片策略

一致性哈希是基于简单哈希的分片的流行改进。不是 hash(key) % num_shards,而是将键放在环上。当你添加新分片时,只有相邻于其位置的键移动,不是所有键。这意味着添加第四个分片移动约 25% 的数据而不是约 75%。

何时分片

分片是单向门。一旦分片:

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

在分片之前,用尽这些选项:

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

只有当你真正受写入限制并且单个节点物理上无法处理你的吞吐量,或者当你的数据集超过一台机器可以容纳的时候,才分片。

微服务

随着产品和团队的增长,单体变得更难安全地演进。你可能从微服务中受益的常见信号:

  • 一个区域的更改(如通知)需要重新部署整个应用。
  • 团队不能独立发布而不协调每个发布。
  • 应用的不同部分有不同的扩展需求(搜索需要 10 台服务器,个人资料查看需要 2 台)
  • 工程师经常在相同的代码库中冲突。
  • 一个子系统中的错误导致整个应用宕机。

微服务将应用拆分为通过网络通信的独立服务。

每个服务:

  • 拥有自己的数据(只有它直接写入的数据库)
  • 独立部署(发布通知而不触碰结账)
  • 独立扩展(搜索可以与个人资料分开扩展)
  • 使用适合用途的技术(搜索可能使用 Elasticsearch,支付可能需要具有强一致性的 Postgres)
  • 暴露清晰的 API 契约(其他服务通过稳定的端点集成)

权衡是运营复杂性的巨大跳跃。最安全的方法是一次提取一个:选择具有最清晰边界和最清晰独立扩展需求的服务。避免一开始就拆分成几十个服务。

消息队列和异步处理

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

必须同步:

  • 验证支付方式
  • 检查库存
  • 创建订单记录
  • 返回订单确认

可以异步:

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

KafkaRabbitMQSQS 这样的消息队列将生产者与消费者解耦。订单服务发布一个事件如 OrderPlaced,下游系统独立消费它。

异步处理的好处:

  • 弹性:如果邮件服务宕机,消息排队。订单仍然完成。邮件在服务恢复时发送。
  • 可扩展性:消费者基于队列深度独立扩展。节假日高峰?添加更多仓库通知处理器而无需触碰订单服务。
  • 解耦:订单服务不需要知道谁消费事件。你可以添加新消费者(欺诈检测、CRM 同步)而无需更改生产者。
  • 平滑突发:队列吸收尖峰,让下游系统以可持续的速率处理而不是过载。
  • 重试处理:失败的消息可以自动重试。死信队列捕获反复失败的消息以供调查。

一个常见的真实模式是”现在写入,稍后做繁重工作”。

例如,在社交应用中,创建帖子通常是一个快速写入和立即的成功响应。昂贵的工作如扇出、索引、通知和信息流更新异步发生,这就是为什么你有时会看到点赞数或信息流传播的小延迟。

在这一点上,你的架构可以在单个区域内处理大规模。但你的用户不在一个地方,你的基础设施也不应该在一个地方。

一旦你在各大洲都有用户,延迟变得明显,单个数据中心成为整个全球用户群的单点故障。

阶段七:多区域 + 高级模式(1000000-10000000+ 用户)

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

  • 澳大利亚用户访问美国服务器时体验 300 毫秒延迟
  • 数据中心中断(火灾、网络分区、云提供商问题)导致整个服务宕机
  • 你的数据库模式无法有效服务写密集型实时更新和读密集型分析仪表板
  • 不同地区有不同的数据驻留要求(欧盟的 GDPR、数据本地化法律)

这个阶段涵盖多区域部署高级缓存专业模式,如 CQRS。

多区域架构

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

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

有两种主要方法:

主-备(主-从)

一个区域(主)处理所有写入。其他区域服务读取,如果主区域故障可以接管。

优点:

  • 实现更简单
  • 不需要写入冲突解决
  • 写入的强一致性

缺点:

  • 远离主区域的用户写入延迟更高
  • 故障转移不是瞬时的(DNS 传播、副本提升)
  • 主区域仍然是单点故障

主-主

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

优点:

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

缺点:

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

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

全球规模的 CAP 定理

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

  • 一致性:每次读取都收到最近的写入
  • 可用性:每个请求都收到响应(不是错误)
  • 分区容错性:尽管网络分区,系统仍继续运行

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

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

  • 用户的帖子可能需要 1-2 秒才能出现在其他区域的粉丝中
  • 产品评分可能在不同区域短暂显示略有不同的平均值
  • 用户资料更新可能需要一点时间传播

只有不一致性会导致真正问题的操作(支付、库存减量、金融交易)需要强一致性,那些可能路由到主区域。

CQRS 模式

随着系统增长,读取和写入模式显著分化:

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

CQRS(命令查询职责分离) 完全分离这些关注点。

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

真实示例:Twitter 的时间线架构。

  • 写入路径:当你发推时,它被写入具有适当索引、约束和事务的规范化推文表。
  • 事件:触发”推文创建”事件。
  • 投影:扇出服务读取事件并将推文添加到每个粉丝的时间线(一个反规范化的、每用户数据结构,为”显示我的信息流”查询优化)。
  • 读取路径:当你打开 Twitter 时,你从预计算的时间线读取,而不是连接推文、关注和用户的复杂查询。

CQRS 增加复杂性但支持:

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

高级缓存模式

在全球规模,缓存变得更复杂:

多级缓存

缓存预热

当新缓存服务器启动(或缓存在维护后过期)时,第一个请求面临缓存未命中,导致延迟尖峰和源站负载。缓存预热在流量到达之前预填充缓存:

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

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

写后(回写)缓存

对于写密集型工作负载,先写入缓存并异步持久化到数据库:

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

这大大减少了写入延迟但引入了风险:如果缓存在刷新前故障,写入会丢失。仅在以下情况使用:

  • 一些数据丢失可以接受(分析计数器、查看计数)
  • 缓存是高可用的(具有复制和持久化的 Redis)
  • 可以为性能牺牲持久性

你现在已经构建了一个处理全球数百万用户、全球低延迟的分布式系统。但旅程并没有在这里结束。在真正的大规模,即使最好的现成解决方案也开始显示其限制。

超越千万用户

有 1000 万用户及以上时,你进入了现成解决方案并不总是管用的领域。这个规模的公司经常构建针对其特定访问模式的定制基础设施。问题变得对你的工作负载独一无二。

专业数据存储

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

每个数据库都为特定的访问模式优化。用 PostgreSQL 做时序数据可以工作但效率低下。用 Elasticsearch 做事务是可能的但危险的。

大规模定制解决方案

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

  • Facebook 的 TAO: 用于社交图的定制数据系统,当现成选项无法满足时,为满足 Facebook 在大规模下的延迟和吞吐量需求而构建。
  • Google Spanner: 一个全球分布式 SQL 数据库,旨在跨区域提供强一致性,结合了当时难以同时获得的属性。
  • Netflix 的 EVCache: 基于 Memcached 的大规模缓存层,具有额外的复制、可靠性和运营工具,以支持 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 → 响应”,许多请求变成”请求 → 边缘 → 响应”,边缘具有足够的计算能力来处理逻辑。

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

总结

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

要记住的关键原则

  1. 从简单开始:不要为你还没有的问题优化。在不行之前,单服务器是可以的。
  2. 先测量:在添加基础设施之前识别实际的瓶颈。CPU 限制的问题需要与 I/O 限制的问题不同的解决方案。
  3. 无状态服务器是先决条件:在服务器不持有本地状态之前,你无法水平扩展或自动扩展。
  4. 积极缓存:大多数数据的读取频率远高于写入。缓存为读密集型工作负载提供 10-100 倍的性能提升。
  5. 尽可能异步:不是所有事情都需要在请求路径中发生。邮件发送、分析、通知都可以排队。
  6. 谨慎分片:数据库分片是单向门,有显著的复杂性。先用尽其他选项。
  7. 接受权衡:在网络分区期间,完美的一致性和可用性不能共存。知道哪些操作真正需要强一致性。
  8. 复杂性有成本:你添加的每个组件都是一个可能故障的组件,需要监控,需要专业知识来运营。

扩展之路不是关于一次实现所有内容。而是关于了解在每个阶段出现哪些问题,并在正确的时间应用正确的解决方案。

最好的架构是满足你当前需求的最简单的那个,当这些需求变化时有清晰的发展路径。

原文: https://blog.algomaster.io/p/scaling-a-system-from-0-to-10-million-users