第一章:从 0 到 100 万用户的扩展

一、系统设计学习重点

和面试官聊系统设计,最重要的不是背架构图,而是能表现出这几种能力:

1. 能先问清楚需求

  • 面向 C 端还是 B 端?

  • 日活多少?

  • 读多写多还是写多读少?

  • 强一致还是最终一致?

  • 延迟、可用性、成本哪个优先?

2. 能搭一个主链路

  • 用户请求怎么进来?

  • API 服务怎么处理?

  • 数据怎么存?

  • 缓存怎么加?

  • 消息队列放在哪里?

  • 哪些地方会成为瓶颈?

3. 能讲权衡

  • MySQL vs NoSQL

  • 同步 vs 异步

  • 推模式 vs 拉模式

  • 强一致 vs 最终一致

  • 本地缓存 vs 分布式缓存

  • 分库分表 vs 读写分离

4. 能结合自己的项目聊

你做过:RAG Agent 文档解析 压测 Milvus Redis MySQL 并发测试

所以你读这本书时,要主动问:

“这个设计思想,能不能迁移到我的项目里?”


1. 从 0 到 100 万用户的扩展

1.1 单服务器配置

Web 服务的流量有两个:

1. Web 应用端

  • 使用 Java / Python 来处理业务逻辑、数据存储等

  • 使用客户端 HTML 以及 JavaScript 来展示内容

2. 移动应用端

  • JSON 作为 API 响应格式

1.2 数据库

一台服务器用于处理 Web 应用和移动应用的流量,一台服务器用作数据库。

也就是:

把处理 Web 应用 / 移动应用流量的服务器和数据库服务器分开。

  • 用户通过 www.mysite.comapi.mysite.com 访问服务

  • Web 服务器处理请求

  • 数据库单独部署

  • Web 服务器和数据库之间有读 / 写 / 更新交互


数据库类型

关系型数据库使用表和行来存储数据。

非关系型数据库通过下面几种方式存储数据:

  • 键值存储

  • 图存储

  • 列存储

  • 文档存储

数据库的选择:

一般情况下使用关系型数据库。

有如下场景,比较适合非关系型数据库:

1. 应用只能接受非常低的时延
2. 应用中的数据是非结构化的,数据之间没有明确的关系

1.3 纵向扩展与横向扩展

纵向扩展

纵向扩展指的是:

  • 提升服务器的能力

比如:

  • 增加 CPU

  • 增加内存

  • 增加磁盘

  • 提升单机性能

横向扩展

横向扩展指的是:

  • 增加更多的服务器


1.4 负载均衡器

负载均衡器的作用:

进行流量分发,把海量用户的流量访问,均衡地分配给每一台服务器。


1.5 数据库的复制

数据库集群一般是:

  • 一个主数据库

  • 若干从数据库

主数据库处理写请求,并将修改的信息更新到从数据库中。

从数据库处理读请求。

设置多个读数据库可以分担读压力。

整体过程:

  • 建立负载均衡器和用户端的连接

  • HTTP 请求通过负载均衡器转发到其中一个服务器上

  • 服务器在从库中读数据

  • 服务器在主库中修改数据,并同步更新到其他从库上

图中重点体现:

  • 用户请求先到负载均衡器

  • 负载均衡器分发请求到不同服务器

  • 写请求进入主库

  • 读请求进入从库

  • 主库向从库复制数据


1.6 缓存

什么时候应该使用缓存?

当数据满足下面特点时,适合使用缓存:

对数据的读操作非常频繁,而修改不是很频繁。

缓存策略包括:

  1. 过期策略

  2. 一致性

  3. 减轻出错的影响

  4. 驱逐策略


1. 过期策略

缓存中的数据不能永久保存,需要设置过期时间。


2. 一致性

修改数据的事务和缓存数据的事务,不是同一个事务,就会发生不一致现象。


3. 减轻出错的影响

可以采用:

  • 单点缓存服务 → 多点缓存服务

  • 将一部分内存给缓存容量,允许一定的超量


4. 驱逐策略

缓存容量有限,需要有驱逐策略。


单点故障

单点故障指的是:

系统中的某一部分,如果它出现故障,整个系统就不能工作。

图片描述:缓存读取流程

具体流程为:

1. 如果数据在缓存中,直接从缓存中读取
2. 如果数据不在缓存中,到数据库中读取
3. 读取后将数据写入缓存

1.7 内容分发网络

CDN 是一个大型的缓存系统。

它用来缓存用户高频访问的静态资源,让用户从离自己更近的节点获取资源,而不是每次都去源站服务器拉取。

图片描述:CDN 访问流程

  • 客户端请求静态资源

  • 如果 CDN 有缓存,直接返回

  • 如果 CDN 没有缓存,则回源站拉取

  • 拉取后缓存到 CDN,再返回给用户


1.8 无状态网络层


1.8.1 有状态架构

有状态的服务器会处理客户端发来的一个个请求,并记下客户端的状态数据。

无状态架构的服务器,不会记录客户端的状态数据。

有状态架构的问题

用户 A 的会话数据和个人资料会被存储到服务器 1 上。

为了能让用户 A 访问到自己的资料和信息,必须要求用户 A 发送的 HTTP 请求精准地打到服务器 1 上。

如何将来自同一客户端的所有请求都发送给同一个服务器?

需要使用负载均衡器的粘性会话功能。

但是这样会:

  • 增加成本

  • 添加服务器变困难

  • 移除服务器变困难

  • 服务器故障更难处理

图片描述:有状态架构

每个服务器内部保存对应用户的数据,例如:

服务器1:用户A的会话数据、用户A的个人资料、图片
服务器2:用户B的会话数据、用户B的个人资料、图片
服务器3:用户C的会话数据、用户C的个人资料、图片

1.8.2 无状态架构

无状态架构中,服务器不保存用户状态。

用户请求可以发送到任意 Web 服务器。

Web 服务器需要状态数据时,从共享数据存储中获取。

  • 多个用户请求可以打到任意 Web 服务器

  • Web 服务器不保存用户状态

  • 用户状态统一放到共享数据存储中


图片描述:加入无状态网络层后的系统设计

  • CDN 用于静态资源

  • 负载均衡器分发请求

  • Web 层可以自动扩展

  • 数据层包括数据库、缓存、NoSQL

  • Web 层保持无状态


1.9 数据中心

数据中心是什么?

数据中心可以理解为:

机房。

为什么要用数据中心?

主要有两个原因:

  1. 就近访问,降低延迟

  2. 容灾


单数据中心

单数据中心指的是:

系统部署在一个机房中。

流程如下:

用户
↓
DNS / CDN
↓
动态请求进入数据中心
↓
负载均衡
↓
Web 服务
↓
缓存 / 数据库 / 消息队列
↓
返回结果

多数据中心

当业务非常大,用户分布在多个地区,就需要部署多个数据中心。

多数据中心的好处:

  1. 延迟更低

  2. 容灾性更好

难点:

  1. 数据同步复杂

  2. 一致性更难

  3. 成本更高


1.10 消息队列:重点

消息队列是一个任务缓冲。

Web 服务器把暂时不需要立刻完成的任务放进去,后面的 Worker 慢慢处理。


消息队列的作用

1. 异步处理

例如:

  • 用户注册成功

  • 发送问候邮件或者短信

这些任务可以异步处理,不需要阻塞主流程。


2. 削峰填谷

假设突然来了 100 万个请求。

如果直接把所有任务打到下游服务或者数据库,会把系统打崩。

这时可以先放到消息队列中,再慢慢消费。


3. 解耦系统

无消息队列时:

下单系统 和 库存系统两个系统强绑定

有消息队列时:

下单系统 → 消息队列 → 库存系统

下单系统只管发消息,其他系统自己消费。


1.11 记录日志、收集指标与自动化


指标

用户的指标包括:

1. 主机级别的指标

例如:

  • CPU

  • 内存

  • 磁盘 IO 等

2. 聚合级别指标

例如:

  • 整个数据库层的性能

  • 整个缓存层的性能

3. 关键业务指标

例如:

  • 每日活跃用户数

  • 留存率

  • 收益等


自动化

一个比较大的系统,使用自动化工具提高生产力十分重要。

持续集成

每次代码 check in 的时候,需要自动化工具进行审核。

构建、测试和部署等流程也需要自动化。


整体访问流程

用户
↓
DNS
↓
CDN / 负载均衡器
↓
Web 服务器
↓
缓存 / 数据库 / NoSQL / 消息队列

图片描述:百万用户系统整体架构


访问静态资源

用户请求静态资源
↓
DNS 把请求导向 CDN
↓
CDN 判断自己有没有缓存
↓
有:直接返回
↓
没有:回源站拉取,再缓存,再返回

访问动态接口

例如:用户查看商品详情。

读数据流程:

用户请求 api.mysite.com
↓
DNS 解析域名
↓
请求到负载均衡器
↓
负载均衡器选择一台 Web 服务器
↓
Web 服务器处理业务逻辑
↓
访问缓存 / 数据库 / NoSQL
↓
返回结果给用户

写数据

例如:用户下单。

写数据流程:

用户提交订单
↓
负载均衡器
↓
Web 服务器
↓
写入数据库
↓
发送一条消息到消息队列
↓
立即返回:下单成功

Worker 队列慢慢处理:

消息队列
↓
Worker 消费消息
↓
发送短信
↓
发邮件
↓
扣库存
↓
生成报表
↓
推送通知

1.11 数据库扩展


1. 数据库为什么要扩展?

随着用户数量和数据量增长,单个数据库会遇到瓶颈。

常见瓶颈包括:

  • CPU

  • 内存

  • 磁盘

  • IO

  • 单表数据量

所以需要扩展数据库层。


2. 数据库扩展的两种方式


1. 纵向扩展

定义:

给数据库机器增加 CPU、内存、SSD 等硬件。

优点:

  1. 简单

  2. 改动小

缺点:

  1. 单点故障风险仍然存在

  2. 硬件有上限


2. 横向扩展

定义:

把数据库拆到多台机器上。

优点:

  1. 容量变大

  2. 单表更小

  3. 写压力得到分散

缺点:

  1. 跨库 Join 困难

  2. 跨库事务复杂


3. 怎么做到分片?

选取一个字段,叫做:

shard key

中文叫:

分片键


4. 分片键为什么非常重要?

分片键决定一个数据应该落到哪个数据库。

例如:

用户系统按照 user_id 分片
订单系统按照 user_id 或者 order_id 分片

5. 分片带来的新问题


1. 分片之后,跨库连接查询困难

业务场景:

想通过 order_id 查询订单,同时还想拿到这个订单对应的用户信息。

这时候,订单数据和用户数据不在同一个数据库中,查询就比较困难。


2. 分片之后,跨库事务复杂

业务场景:

分库之后:

订单表在数据库1
库存表在数据库2
支付表在数据库3

创建订单这个事务需要:

1. 在数据库1中插入订单
2. 在数据库2中扣减库存
3. 在数据库3中插入支付记录

如果前两个操作成功,第三个操作失败,系统就进入了不一致的状态。

单库可以保证原子性,但多库中各个数据库是独立的,很难保证原子性。

解决方案:

  1. 尽量让事务落在同一个分片
    比如订单、支付记录,按照 order_id 或者 user_id 分到同一个库中。

  2. 最终一致性
    通过消息队列来异步处理,只做最后的校验,允许短时间不一致。


3. 扩容迁移复杂

原来 4 个分片,现在变成 8 个分片。

取模数量一变,大量数据的归属都会发生变化。

这会导致:

海量的数据迁移,而且不能停机。


1.13 百万用户量系统设计

扩展系统以支持百万量级用户,需要下面几个技术点:

  1. 让网络层无状态

  2. 让每一层都有冗余

  3. 尽量多缓存数据

  4. 支持多个数据中心

  5. 用 CDN 来静态加载资源

  6. 通过分片来扩展数据层

  7. 把不同的架构分成不同的服务

  8. 监控系统并使用自动化工具