站点图标 高效码农

从零到千万用户:手把手教你构建可扩展系统架构的7个实战阶段

 

如何将系统从 0 扩展到 1000 万用户:架构演进实战指南

核心问题:在资源有限的情况下,如何避免过度设计,并通过合理的架构演进阶段将系统从零扩展至千万级用户规模?

系统扩展是一个复杂的话题,但核心原则非常简单:不要在起步阶段就过度设计。无论是在大型科技公司处理数百万次请求,还是从零开始构建自己的初创项目,大多数系统在增长过程中都会经历一系列惊人相似的阶段。关键在于从简单开始,识别瓶颈,然后逐步扩展。

本文将带你走过从零到 1000 万用户乃至更多的 7 个扩展阶段。每个阶段都针对特定的增长瓶颈,你将学到何时添加什么组件、为什么它有帮助,以及其中涉及的权衡。


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

核心问题:当你刚开始时,如何在验证产品想法的同时,以最低的成本和复杂度让系统运行起来?

当你刚刚起步时,首要任务非常简单:发布产品并验证想法。在这个阶段过早优化是浪费时间和金钱,因为你可能永远不会遇到那些你试图解决的问题。

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

实战案例:Instagram 在 2010 年推出第一个版本时,第一天就有 25,000 人注册。他们没有预先过度设计,而是依靠小团队和简单的设置,根据实际需求增加容量,而不是为假设的未来流量构建架构。

架构具体表现

在实践中,单服务器设置意味着:

  • Web 框架:处理 HTTP 请求。
  • 数据库:存储数据。
  • 后台作业处理:用于异步任务。
  • 反向代理:可能用于 SSL 终止。

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

为什么早期阶段适用

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

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

权衡的代价

这种简单性伴随着你明确接受的权衡:

何时进入下一阶段

当你注意到以下迹象时,你就知道是时候进化了:

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

💡 作者反思
很多初创公司死掉不是因为技术不行,而是因为在 0 到 1 的阶段花了太多时间去解决 1 到 100 才会遇到的问题。在这个阶段,你的核心任务是“活下去”和“验证价值”。只要代码能跑,数据库没丢数据,哪怕架构再“土”,也是最好的架构。


第二阶段:数据库分离(100-1K 用户)

核心问题:当 Web 应用与数据库争夺资源导致性能下降时,如何通过物理隔离来提升性能?

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

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

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

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

托管数据库服务

在这个阶段,大多数团队使用托管数据库,如 Amazon RDSGoogle Cloud SQLAzure DatabaseSupabase

托管服务通常处理:

  • 自动备份(每日快照,时间点恢复)
  • 安全补丁和更新
  • 基本监控和警报
  • 可选的只读副本
  • 故障转移到备用实例

考虑到工程时间,自托管和托管之间的成本差异通常很小。托管 PostgreSQL 实例可能比原始 VM 贵 50-100 美元/月,但它每周可以节省数小时的维护时间。这些时间最好用于发布功能。

连接池

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

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

打开一个新连接也很昂贵。在 TCP 握手、SSL 协商和数据库身份验证之间,你可能会为每个请求增加 50-100 毫秒 的开销。

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

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

连接池模式:

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

大多数应用程序最适用于 事务池化,这通常能将连接效率提高 3-5 倍

网络延迟考量

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

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

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

第三阶段:负载均衡 + 水平扩展(1K-10K 用户)

核心问题:当单台应用服务器成为单点故障且无法处理增长的需求时,如何保证系统的高可用性和处理能力?

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

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

负载均衡器位于服务器前面,并在它们之间分配传入请求。如果一台服务器发生故障,负载均衡器会检测到此情况(通过健康检查)并仅将流量路由到健康的服务器。当单台服务器发生故障时,用户不会遇到停机时间。

垂直扩展 vs 水平扩展

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

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

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

会话问题(Session Problem)

这是水平扩展变得棘手的地方。如果用户登录并且他们的会话存在于 Server 1 的内存中,当下一个请求落在 Server 2 上时会发生什么?从应用程序的角度来看,会话丢失了,因此用户看起来已注销。

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

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

1. 粘性会话

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

优点

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

缺点

  • 如果该服务器发生故障,用户将丢失其会话
  • 如果某些用户比其他用户更活跃,则负载分配不均
  • 限制了真正的水平扩展(无法在服务器之间自由移动用户)
  • 新服务器需要时间来“预热”会话

2. 外部会话存储

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

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


第四阶段:缓存 + 读写分离 + CDN(10K-100K 用户)

核心问题:当数据库因为读取请求过多而成为瓶颈时,如何通过分层缓存和内容分发来减少 90% 以上的数据库负载?

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

读取密集型应用程序(大多数都是这样,读写比率为 10:1 或更高)尤其痛苦。

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

缓存层

大多数 Web 应用程序遵循 80/20 规则:80% 的请求访问 20% 的数据。一个被查看 10,000 次的产品页面不需要 10,000 次数据库查询。每次页面加载时获取的用户配置文件不需要每次都从数据库中新鲜获取。

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

最常见的缓存模式是 Cache-Aside(旁路缓存)(也称为惰性加载):

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

Redis 和 Memcached 是这里的标准选择。Redis 功能更丰富(支持列表、集合、有序集合等数据结构;持久性;发布/订阅;Lua 脚本),而 Memcached 更简单,对于纯键值缓存稍快一些。大多数团队选择 Redis,因为额外的功能很有用。

缓存失效

缓存最难的部分不是添加它,而是保持其准确性。当底层数据发生变化时,缓存的数据就会过时。这 famously 是计算机科学中“两个难题”之一。

常见策略包括:

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

只读副本

即使有缓存,一些请求仍然会命中数据库,尤其是写入缓存未命中。只读副本有助于将读取流量分布在多个数据库副本上。

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

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

内容分发网络(CDN)

静态资源(如图片、CSS、JavaScript 和视频)很少更改,根本不需要命中你的应用服务器。它们也是你提供的最大文件,这使得如果你直接提供它们,带宽和计算成本都很高。

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

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


第五阶段:自动扩缩容 + 无状态设计(100K-500K 用户)

核心问题:当流量模式变得不可预测(如营销活动或突发病毒传播)时,如何让基础设施自动适应并保持弹性?

拥有 10 万+ 用户后,流量模式变得不再那么可预测。你可能会遇到:

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

在这个阶段,手动添加和删除服务器不再可行。你需要能够自动反应的基础设施。

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

无状态架构

为了使自动扩缩容工作,你的应用服务器必须可以互换。任何请求都可以去往任何服务器。任何服务器都可以在不丢失数据的情况下终止。新服务器可以立即开始处理请求。

自动扩缩容策略

大多数团队从基于 CPU 的扩缩容开始。它简单,适用于大多数工作负载,并且很容易推理。

扩缩容参数:

最小实例数:2       # 即使流量为零也始终运行
最大实例数:20      # 成本上限和资源限制
扩容阈值:70%       # 触发扩容的 CPU 百分比
缩容阈值:30%       # 触发缩容的 CPU 百分比
扩容冷却时间:3 分钟   # 扩容后下次操作前的等待时间
缩容冷却时间:10 分钟 # 缩容后的等待时间
实例预热:2 分钟     # 新实例变得完全运行的时间

用于无状态认证的 JWT

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

JWT 的权衡:

  • 优点:真正的无状态,每个请求都没有会话存储查找
  • 优点:跨服务工作(微服务、移动应用程序、第三方 API)
  • 缺点:无法在过期前使单个令牌失效
  • 缺点:令牌大小会增加每个请求(500 字节 vs 32 字节会话 ID)

常见的模式是短期的访问令牌(例如 15 分钟)加上长期的刷新令牌(例如 7 天)。


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

核心问题:当单台数据库无法承受写入压力,且单体应用难以维护时,如何通过架构拆分来解决极限瓶颈?

拥有 50 万+ 用户后,你将遇到以前的优化无法解决的新天花板:

  • 写入使单个主数据库不堪重负,即使读取被分流到副本。
  • 单体应用变得难以发布。对通知的小改动需要重新部署整个应用程序。
  • 以前快速的操作开始需要几秒钟,因为请求路径中同步发生了太多工作。
  • 产品的不同部分需要不同的配置文件。

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

数据库分片

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

  • 写入吞吐量(插入、更新、删除)
  • 存储容量
  • 连接数

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

分片策略:

  • 基于哈希hash(key) % num_shards。简单,但重新分片很难。
  • 范围:按 ID 范围(0-9999 在分片 A,10000-19999 在分片 B)。有利于范围查询,但会导致热点。
  • 目录/映射:查找服务告诉你哪个分片拥有数据。灵活,但增加了查找服务的复杂性。

💡 作者反思
分片是一扇“单向门”。一旦你分片了,跨分片查询变得昂贵或不可能,跨分片事务变得复杂。在分片之前,你必须先用尽其他所有选项:优化查询、垂直扩展、只读副本、缓存、归档旧数据。只有当你真正受限于写入且单节点在物理上无法处理你的吞吐量时,才分片。

微服务

随着产品和团队的成长,单体应用变得更难安全地演进。微服务将应用程序拆分为通过网络通信的独立服务。

每个服务:

  • 拥有自己的数据(只有它直接写入的数据库)
  • 独立部署(发布通知而不触及结账)
  • 独立扩展(搜索可以独立于配置文件扩展)
  • 使用适合的技术(搜索可能使用 Elasticsearch,支付可能需要具有强一致性的 Postgres)

消息队列和异步处理

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

必须同步: 验证支付方式、检查库存、创建订单记录。
可以异步: 发送确认电子邮件、更新分析仪表板、通知仓库履行。

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

异步处理的好处:

  • 弹性:如果电子邮件服务宕机,消息会排队。订单仍然完成。
  • 可扩展性:消费者根据队列深度独立扩展。
  • 解耦:订单服务不需要知道谁消费该事件。

第七阶段:多区域部署 + 高级模式(1M-10M+ 用户)

核心问题:当用户遍布全球时,如何解决跨地域的延迟问题,并确保在数据中心发生灾难性故障时服务依然可用?

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

  • 澳大利亚用户在访问美国服务器时遇到 300ms 延迟
  • 数据中心中断(火灾、网络分区、云提供商问题)导致整个服务宕机
  • 你的数据库架构无法有效地服务写入繁重的实时更新和读取繁重的分析仪表板

此阶段涵盖多区域部署高级缓存CQRS 等专业模式。

多区域架构

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

  1. 更低延迟:用户连接到附近的服务器。
  2. 灾难恢复:如果一个区域发生故障,其他区域继续服务流量。

有两种主要方法:

  • 主备:一个区域处理所有写入。其他区域服务读取,如果主节点发生故障可以接管。
  • 双活:所有区域处理读取和写入。这需要解决冲突解决的难题。

CQRS 模式

随着系统的增长,读写模式显著分歧:

  • 写入需要事务、验证、规范化数据。
  • 读取需要非规范化数据、快速聚合、全文搜索。

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

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

现实世界示例:Twitter 的时间线架构。

  • 写入路径:当你发推文时,它被写入一个带有适当索引的规范化推文表中。
  • 事件:触发“推文已创建”事件。
  • 投影:扇出服务读取该事件,并将推文添加到每个关注者的时间线中(一个为每个用户的“向我展示我的订阅”查询优化的非规范化数据结构)。
  • 读取路径:当你打开 Twitter 时,你从预计算的时间线中读取。

边缘计算

下一个前沿是将计算推得更接近用户。边缘计算在 CDN 边缘位置全球运行代码(例如 Cloudflare Workers, AWS Lambda@Edge)。

边缘计算代表了一个根本性的转变:许多请求变成“请求 → 边缘 → 响应”,而不是“请求 → CDN → 源站 → CDN → 响应”。


超越 1000 万用户

在 1000 万用户及更多时,你进入了现成解决方案并不总是有效的领域。这个规模的公司经常构建针对其特定访问模式定制的自定义基础设施。

多语言持久化

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

  • PostgreSQL/MySQL:用于核心交易数据(用户、订单)。
  • Redis:用于缓存和会话。
  • Elasticsearch:用于全文搜索(产品搜索、日志)。
  • Cassandra/ScyllaDB:用于写入繁重的时间序列数据或用户活动流。
  • TimescaleDB:用于指标和监控数据。

规模化定制解决方案

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

  • Facebook 的 TAO:为社交图定制的数据系统。
  • Google Spanner:旨在跨区域提供强一致性的全球分布式 SQL 数据库。
  • Netflix 的 EVCache:构建在 Memcached 上的大规模缓存层。
  • Discord 的存储之旅:MongoDB → Cassandra → ScyllaDB。

💡 作者反思
这些不是你最初会选择的选择,但它们说明了扩展是一个持续的旅程,而不是终点。适用于 100 万用户的架构很少是你希望在 1 亿用户时拥有的架构。始终让架构适应用户规模,而不是反过来。


实用摘要与操作清单

一页速览(One-page Summary)

阶段 用户规模 核心架构组件 解决的主要瓶颈 关键权衡
1 0-100 单服务器 开发速度与成本 单点故障,资源竞争
2 100-1K 数据库分离 CPU/IO 争用 增加了网络延迟
3 1K-10K 负载均衡器 + 多应用服务器 单点故障,可用性 必须处理会话状态
4 10K-100K 缓存 + 读写分离 + CDN 数据库读压力 数据一致性与复制延迟
5 100K-500K 自动扩缩容 + JWT (无状态) 流量波动,运维成本 基础设施配置复杂性
6 500K-1M 分片 + 微服务 + 消息队列 数据库写压力,代码库耦合 系统复杂性急剧增加
7 1M-10M+ 多区域 + CQRS 全球延迟,灾难恢复 实现难度极高,一致性挑战

核心原则回顾

  1. 从简单开始:不要为你还没有的问题优化。单台服务器直到它不行之前都是没问题的。
  2. 先测量:在添加基础设施之前确定实际的瓶颈。
  3. 无状态服务器是先决条件:在你的服务器不持有本地状态之前,你无法水平扩展或自动扩展。
  4. 积极缓存:大多数数据的读取频率远高于写入频率。
  5. 尽可能异步:并非所有事情都需要在请求路径中发生。
  6. 不情愿地分片:数据库分片是一个带有显著复杂性的单向门。
  7. 接受权衡:在网络分区期间,完美的一致性和可用性并不共存。
  8. 复杂性有代价:你添加的每个组件都是一个可能发生故障、需要监控、需要专业知识来操作的组件。

常见问题(FAQ)

Q1: 我应该在什么时候开始考虑使用微服务?
A: 不要在第一天就开始。通常,当你的单体代码库变得难以维护,不同部分的扩展需求差异巨大(例如搜索服务需要 10 台服务器,而用户资料只需要 2 台),或者团队之间频繁发生代码冲突时,才是考虑拆分微服务的时机。

Q2: 缓存和数据库之间数据不一致怎么办?
A: 这是缓存固有的问题。你可以采用“先更新数据库,再删除缓存”的策略,并配合过期时间(TTL)作为兜底。对于强一致性要求极高的业务(如金融交易),可能需要暂时不使用缓存或使用更复杂的缓存一致性协议。

Q3: 什么是“复制延迟”,它会影响我的业务吗?
A: 复制延迟是指主数据库写入数据后,数据同步到只读副本所需的时间。如果你的业务允许用户在更新数据后几毫秒到几秒内看到旧数据(例如社交媒体点赞数),那么影响不大。但如果用户刚修改了个人资料,刷新后必须看到最新数据,你就需要强制从主数据库读取或实现会话一致性。

Q4: 自动扩缩容会节省成本吗?
A: 是的,特别是在流量波动明显的场景下。通过在低谷期减少实例数量并在高峰期自动增加,你可以避免为了应对偶尔的流量峰值而长期闲置大量服务器资源。

Q5: JWT 和 Session 哪个更好?
A: 没有绝对的“更好”,只有“更适合”。Session 管理简单,服务端可控,适合传统的 Web 应用。JWT 是无状态的,适合分布式系统和移动端,但难以主动失效且令牌较大。通常在微服务架构中更倾向于 JWT。

Q6: 我可以直接跳到多区域部署吗?
A: 技术上可以,但这通常是巨大的资源浪费。多区域部署的运维复杂度和成本都很高。除非你的用户从一开始就遍布全球,且有严格的合规或低延迟要求,否则建议先在一个区域内做到极致,再考虑跨区域。

Q7: 如何判断是否需要分片?
A: 只有当你已经优化了查询、使用了最大可能的硬件(垂直扩展)、实施了缓存和读写分离,但数据库仍然因为写入量过大或数据量过大而成为不可逾越的瓶颈时,才应该考虑分片。分片是最后的手段。

退出移动版