跳转到内容

Module 1: 规模估算与API设计

📖 深度参考手册 — 本模块属于理论参考,非主线必读。 主线学习路径见 README.md。 当你在项目实战中遇到相关问题时,回来查阅。

来源:Acing the System Design Interview Ch2-3


1.1 回信封估算 (Back-of-the-envelope estimation)

Section titled “1.1 回信封估算 (Back-of-the-envelope estimation)”

定义:回信封估算是一种用简单数学和合理假设快速得出系统关键数字(QPS、存储量、带宽)的技术。名字来源于”在信封背面随手算算”,不追求精确,而是追求数量级正确。在面试和实际架构决策中,它帮助你在几分钟内判断方案是否可行。

为什么重要:如果你不先算清楚数字就开始设计,可能会选择完全不匹配规模的技术方案。比如一个日写入量只有1万的系统和日写入量10亿的系统,架构可能完全不同。估算让你在设计初期就建立”数字直觉”,避免过度设计或设计不足。

案例:以 URL Shortener 为例。假设系统每天创建1亿个短链接(写操作),读写比为1:100(每创建一个短链接,平均被访问100次)。那么:

  • 写QPS = 1亿 / 86400 ≈ 1160 QPS
  • 读QPS = 1160 × 100 ≈ 116,000 QPS
  • 5年存储量 = 1亿/天 × 365 × 5 × (每条记录约100字节) ≈ 18.25TB

这些数字立刻告诉你:写入压力不大,读取压力很大,需要重点优化读取路径(缓存是必须的)。

先想一想 🤔 如果 URL Shortener 的读写比从1:100变成1:10,架构上需要做什么调整?

点击查看解析 读QPS从116K降到11.6K,缓存的重要性降低但仍然有用。更大的变化是写入占比提高了——需要考虑写入路径的优化,比如是否需要写缓冲、异步写入等。同时,缓存命中率可能下降(每个URL被访问的次数少了),缓存策略需要重新评估。

定义:系统设计中有一组被广泛引用的延迟和容量数字,它们帮助你快速判断不同存储和通信方式的性能差异。这些数字不需要精确记忆,但必须知道数量级关系。

为什么重要:当你在面试中说”我们用Redis缓存”时,面试官期望你知道为什么——因为内存访问比磁盘快10万倍。如果你不知道这些数量级差异,你的架构选择就缺乏数据支撑。

参考表

操作延迟数量级
L1缓存引用0.5ns纳秒级
L2缓存引用7ns纳秒级
内存引用100ns纳秒级
SSD随机读取100μs微秒级
内存顺序读1MB250μs微秒级
同数据中心网络往返500μs微秒级
SSD顺序读1MB1ms毫秒级
磁盘寻址10ms毫秒级
磁盘顺序读1MB20ms毫秒级
跨地域网络往返150ms毫秒级

容量参考

单位大小
1KB1,000字节
1MB1,000,000字节
1GB10亿字节
1TB1万亿字节
1PB1000TB

案例:Gaming Leaderboard 为什么用 Redis(内存)而不是 MySQL(磁盘)?排行榜需要实时更新和查询Top N玩家。Redis的内存访问延迟约100ns,而MySQL即使有索引,磁盘寻址也要10ms——差了10万倍。当每秒有数万次分数更新和排行查询时,MySQL的磁盘I/O会成为严重瓶颈,而Redis的有序集合(Sorted Set)可以在O(log N)时间内完成插入和范围查询,且全部在内存中操作。

先想一想 🤔 为什么同数据中心的网络往返(500μs)比SSD随机读取(100μs)还慢,但我们仍然经常选择远程缓存(如Redis集群)而非本地SSD?

点击查看解析 因为远程缓存(500μs)虽然比本地SSD(100μs)慢5倍,但比本地磁盘寻址(10ms)快20倍。更关键的是,远程缓存提供了:1)共享状态——多个服务器看到相同的数据;2)内存容量水平扩展——一台机器的内存有限,但Redis集群可以扩展到TB级;3)数据一致性——避免每台机器本地缓存不一致的问题。性能差异在5倍以内时,架构优势往往更重要。

定义:QPS(Queries Per Second)是衡量系统每秒处理请求数的核心指标。基本计算公式为:QPS = 日活用户数(DAU)× 每用户每日平均操作次数 / 86400(一天的秒数)。峰值QPS通常是平均QPS的2-5倍。

为什么重要:QPS直接决定了你需要多少台服务器、是否需要负载均衡、数据库能否承受压力。一个1000 QPS和100万QPS的系统,架构方案可能完全不同。面试中如果你跳过QPS估算直接画架构图,说明你在”凭感觉设计”而不是”基于数据设计”。

案例:以 News Feed 为例:

  • 日活用户:5亿
  • 每人每天平均刷新Feed 20次
  • 平均QPS = 5亿 × 20 / 86400 ≈ 115,740 QPS ≈ 116K QPS
  • 峰值QPS(假设3倍)≈ 350K QPS

这意味着:

  • 单台服务器假设能处理1000 QPS,至少需要350台应用服务器
  • 数据库层面需要大量读副本或缓存层来分担压力
  • 这还只是读取,写入(发帖)的QPS要单独算

先想一想 🤔 News Feed的116K QPS是读操作,假设写操作(发帖)QPS是5K,为什么面试中通常更关注读QPS而不是写QPS?

点击查看解析 读QPS是写QPS的23倍,读操作是系统的主要瓶颈。但更深层的原因是:每次"读"Feed时,服务端需要从多个关注者那里聚合内容、排序、过滤——这是一个扇出(fan-out)操作,计算量远比简单的写入大。所以读虽然单次比写"轻",但数量大且逻辑复杂,是News Feed系统设计的核心挑战。

定义:存储量计算用于估算系统在一定时间内需要的总存储空间。基本公式为:总存储量 = 每条数据平均大小 × 每天新增数据条数 × 数据保留天数。需要分别计算不同类型的数据(元数据、媒体文件、日志等)。

为什么重要:存储量决定了你选择什么存储系统、需要多少台机器、数据是否需要分片、归档策略如何设计。如果你估算出5年需要500PB的存储,显然需要分布式存储系统和数据生命周期管理。

案例:以 YouTube 为例:

  • 每天上传50万个新视频
  • 平均每个视频原始文件500MB
  • 每天原始视频存储:50万 × 500MB = 250TB/天
  • 每个视频转码为5种分辨率,平均压缩后总大小约为原始文件的3倍
  • 转码后每天存储:250TB × 3 = 750TB/天
  • 一年存储:750TB × 365 ≈ 274PB/年

再算元数据:

  • 每条视频的元数据(标题、描述、标签、统计等)约2KB
  • 50万 × 2KB = 1GB/天——和视频文件比几乎可以忽略

这个估算告诉你:YouTube的核心存储挑战是视频文件,而不是元数据。架构上需要CDN分发、冷热分层存储(热门视频放SSD、冷门视频放HDD或对象存储)。

先想一想 🤔 如果YouTube把所有视频的保留期从”永久”改为”5年后自动删除不活跃视频”,架构上需要做哪些改变?

点击查看解析 需要:1)定义"不活跃"的指标(如最近6个月无播放);2)添加视频活跃度追踪系统;3)实现后台清理任务(数据量巨大,需要批量处理);4)冷热分层存储更加重要——即将过期的视频应迁移到最便宜的存储层;5)删除不只是删视频文件,还需删除所有转码版本、缩略图、CDN缓存、评论等关联数据;6)提供用户通知和下载/续期机制。这本质上是一个数据生命周期管理系统。

定义:带宽计算用于估算系统在网络传输层面的压力。基本公式为:所需带宽 = 峰值QPS × 每次请求/响应的平均数据大小。通常分别计算入站带宽(上传)和出站带宽(下载),因为它们的数量级可能差异巨大。

为什么重要:带宽瓶颈是很多大规模系统的实际限制因素。即使你的服务器CPU和内存充足,如果网络带宽不够,用户的请求也无法及时送达或响应。带宽估算帮助你决定是否需要CDN、是否需要数据压缩、是否需要多区域部署。

案例:以 Google Maps 的地图瓦片传输为例:

  • 假设峰值有100万用户同时在浏览/拖动地图
  • 每次拖动加载大约9个瓦片(3×3网格)
  • 每秒平均每个用户触发1次拖动
  • 每个瓦片平均大小:矢量瓦片约20KB,光栅瓦片约50KB(取30KB平均)
  • 峰值出站带宽 = 100万 × 9 × 30KB = 270GB/s ≈ 2.16Tbps

这个数字告诉你:

  • 单个数据中心无法支撑这样的出站带宽,必须使用全球CDN
  • 瓦片需要激进的缓存策略(地图数据不经常变化)
  • 客户端预加载和缓存也很关键——减少重复请求

先想一想 🤔 Google Maps 如何在不牺牲用户体验的情况下减少50%的带宽消耗?

点击查看解析 几个关键策略:1)矢量瓦片替代光栅瓦片——矢量瓦片只传输几何数据和样式,客户端渲染,体积可减少60-80%;2)增量更新——用户拖动时只加载新进入视口的瓦片,而不是重新加载所有瓦片;3)客户端本地缓存——用户经常查看的区域不需要重复下载;4)不同缩放级别使用不同精度的数据——远处的建筑不需要传输详细轮廓;5)WebP/AVIF等现代图片格式替代PNG。实际上Google Maps在2010年代中期正是通过从光栅切换到矢量瓦片大幅降低了带宽。

定义:REST API设计是系统对外暴露功能的接口规范。核心原则包括:以资源(名词)而非动作(动词)命名URL;用HTTP方法(GET/POST/PUT/DELETE)表达操作语义;版本控制(/v1/)防止破坏性变更;分页和过滤控制响应大小;统一的错误响应格式。

为什么重要:API是系统最重要的”合同”——一旦发布很难更改。好的API设计降低沟通成本、减少bug、让前端和第三方开发者更容易接入。在面试中,API设计展示你是否有工程实践经验,而不仅仅是画框图。

案例:以 URL Shortener 的API设计为例:

# 创建短链接
POST /api/v1/urls
Request Body: { "long_url": "https://example.com/very/long/path", "custom_alias": "my-link" }
Response 201: { "short_url": "https://tinyurl.com/my-link", "long_url": "...", "created_at": "..." }
# 重定向(核心读接口)
GET /:shortCode
Response 301/302: Location: https://example.com/very/long/path
# 查询短链接详情
GET /api/v1/urls/:shortCode
Response 200: { "short_url": "...", "long_url": "...", "clicks": 12345, "created_at": "..." }
# 删除短链接
DELETE /api/v1/urls/:shortCode
Response 204: No Content

设计要点:

  • 重定向接口用最短路径 /:shortCode(不加 /api/v1/),因为这是用户直接访问的URL
  • 返回301(永久重定向)还是302(临时重定向)取决于业务需求——301对SEO友好但不利于点击统计,302每次都会经过服务器利于计数
  • 创建接口放在 /api/v1/ 下,方便后续版本演进

先想一想 🤔 URL Shortener的重定向应该返回301还是302?这个选择会影响系统的哪些方面?

点击查看解析 301(永久重定向):浏览器会缓存,下次直接跳转不经过服务器。优点是减少服务器压力,缺点是无法统计点击次数,也无法在短链接失效后更新目标URL。302(临时重定向):浏览器每次都请求服务器。优点是可以精确统计点击、随时更改目标URL、实现A/B测试等。实际上大部分短链接服务(如bit.ly)选择302,因为点击分析是核心商业功能。只有纯粹追求性能的场景才用301。

定义:API限流是控制客户端在给定时间窗口内发送请求数量的机制,防止系统被过度使用或恶意攻击。常见算法有三种:令牌桶(Token Bucket)——以固定速率向桶中添加令牌,每个请求消耗一个令牌;漏桶(Leaky Bucket)——请求进入固定大小的队列,以固定速率处理;滑动窗口(Sliding Window)——在滑动的时间窗口内计数请求。

为什么重要:没有限流的API就像没有保险丝的电路——一个异常客户端就能拖垮整个系统。限流保护后端资源、确保公平使用、防止DDoS攻击、控制成本(尤其是调用第三方付费API时)。

案例:以 Web Crawler 的礼貌爬取频率控制为例。爬虫本质上是一个客户端,它对目标网站发起大量请求。如果不限制频率:

  • 可能触发目标网站的防爬机制被封IP
  • 可能对小网站造成实际的服务中断
  • 违反robots.txt中的Crawl-delay指令

Web Crawler中的限流实现:

  • 令牌桶算法:为每个域名维护一个令牌桶。例如对 example.com 设置每秒2个令牌(即最多2 QPS)。桶容量设为5,允许短暂突发。
  • 自适应限流:监控目标网站的响应时间。如果响应变慢(如从100ms变为500ms),自动降低爬取频率——说明对方可能扛不住了。
  • 全局 vs 单域名:全局限流控制爬虫总QPS(保护自己的出站带宽),单域名限流保护目标网站。
每个域名的限流配置示例:
default: 2 req/s, burst=5
news sites: 5 req/s, burst=10 (新闻站更新快,需要更频繁爬取)
small sites: 0.5 req/s, burst=2 (小站容量有限,需要更温和)

先想一想 🤔 令牌桶和漏桶的最大区别是什么?在Web Crawler场景下哪个更合适?

点击查看解析 最大区别在于对突发流量的处理。令牌桶允许突发——如果桶里积累了令牌,可以一次性发出多个请求;漏桶严格以固定速率处理,即使桶里有排队请求也不会加速。对Web Crawler来说,令牌桶更合适。原因是:爬虫在解析一个页面时不发请求(令牌积累),解析完后需要快速获取页面中的多个链接(消耗积累的令牌)。这种"停顿-突发"模式正是令牌桶擅长的。如果用漏桶,爬虫在解析期间浪费了请求配额,获取新链接时又被迫等待,整体效率更低。

定义:API分页是将大量数据分批返回给客户端的技术。两种主要方式:Offset/Limit分页(?offset=20&limit=10)通过偏移量定位;Cursor-based分页(?cursor=abc123&limit=10)通过上次结果的最后一条记录的标识符定位下一页。

为什么重要:不分页的API在数据量大时会返回巨大的响应——浪费带宽、撑爆客户端内存、查询慢。但选错分页方式也有问题:Offset分页在数据频繁插入时会出现数据重复或遗漏;Cursor分页不支持”跳到第50页”。选择哪种方式取决于使用场景。

两种方式对比

特性Offset/LimitCursor-based
实现复杂度低(SQL OFFSET)中(需要编码cursor)
跳页能力支持(直接算offset)不支持
性能(深翻页)差(OFFSET 10000很慢)好(始终从上次位置开始)
数据一致性差(新数据插入导致重复/遗漏)好(基于位置标记)
适用场景管理后台、搜索结果无限滚动、实时流

案例:以 News Feed 的无限滚动为例。用户在Feed中不断下拉看更多内容,这是典型的cursor-based分页场景:

# 第一次请求
GET /api/v1/feed?limit=20
Response: {
"posts": [...20条],
"next_cursor": "eyJ0IjoxNjk4MDAwMDAwfQ==" // base64编码的时间戳
}
# 下拉加载更多
GET /api/v1/feed?cursor=eyJ0IjoxNjk4MDAwMDAwfQ==&limit=20
Response: {
"posts": [...20条],
"next_cursor": "eyJ0IjoxNjk3OTk5MDAwfQ=="
}

为什么News Feed不用Offset?因为Feed数据在用户浏览过程中不断有新帖子插入。假设用户看完第1页(第1-20条),在加载第2页之前,有3条新帖子插入到了最前面。如果用 offset=20,实际上跳过了原来的第18-20条(现在变成了第21-23条),用户永远看不到这3条。Cursor-based用时间戳定位”上次看到的最后一条”,不受新插入数据的影响。

先想一想 🤔 如果用cursor-based分页的News Feed需要支持”回到顶部刷新最新内容”的功能,cursor机制需要做什么调整?

点击查看解析 "回到顶部刷新"本质上是一次全新的请求——不需要传cursor,直接请求 `GET /api/v1/feed?limit=20`,获取最新的20条。这不需要对cursor机制做任何调整。但有一个优化点:刷新后,客户端可能已经缓存了之前看过的帖子。可以在请求中带上一个"since_cursor"参数,只获取比该cursor更新的帖子,和本地缓存合并显示,减少传输量。Twitter/X的API就是这样设计的——`since_id`参数只返回比指定ID更新的推文。

练习1:Hotel Reservation 完整规模估算

Section titled “练习1:Hotel Reservation 完整规模估算”

给定假设:

  • 10万家酒店,每家平均50间房
  • 日均入住率70%
  • 每天100万次搜索请求
  • 每天35万次预订(10万×50×70%)

请估算以下数字:

  1. 搜索QPS:100万 / 86400 ≈ ?(平均和峰值)
  2. 预订写QPS:35万 / 86400 ≈ ?
  3. 数据存储量(1年):
    • 酒店数据:10万家 × 每家约5KB(名称、地址、描述等)= ?
    • 房型数据:假设每家5种房型 = 50万种 × 1KB = ?
    • 库存数据:10万 × 50间 × 365天 × 每条50字节 = ?
    • 订单数据:35万/天 × 365 × 每条200字节 = ?
  4. 搜索带宽:峰值搜索QPS × 每次返回20个结果 × 每个结果500字节 = ?
点击查看参考答案
  1. 搜索QPS

    • 平均 = 1,000,000 / 86,400 ≈ 11.6 QPS
    • 峰值(3倍)≈ 35 QPS
    • 这个QPS不高!单台服务器就能搞定读取。但搜索涉及复杂查询(日期范围、地理位置、价格过滤),实际压力在数据库。
  2. 预订写QPS

    • 350,000 / 86,400 ≈ 4 QPS
    • 写QPS非常低,但每次写涉及事务(检查库存→扣减→创建订单),对一致性要求极高。
  3. 数据存储量

    • 酒店数据:10万 × 5KB = 500MB(很小)
    • 房型数据:50万 × 1KB = 500MB
    • 库存数据:10万 × 50 × 365 × 50B ≈ 91GB/年
    • 订单数据:35万 × 365 × 200B ≈ 25.5GB/年
    • 总计约117GB/年——单机完全可以存放
  4. 搜索带宽

    • 35 QPS × 20 × 500B = 350KB/s ≈ 2.8Mbps
    • 带宽压力几乎可以忽略

关键洞察:Hotel Reservation不是一个高QPS或大数据量的系统。它的核心挑战不在”规模”,而在”正确性”——如何在并发预订时避免超卖(double booking)。这就是为什么面试中这个题的重点是并发控制和事务设计,而不是分布式存储。


练习2:Chat System RESTful API + WebSocket接口设计

Section titled “练习2:Chat System RESTful API + WebSocket接口设计”

为一个类似微信/WhatsApp的聊天系统设计完整的API接口:

要求覆盖:

  • 用户管理(注册/登录)
  • 联系人/好友
  • 一对一聊天
  • 群聊
  • 消息已读状态
  • 在线状态

请思考:

  1. 哪些功能用REST API?哪些用WebSocket?
  2. 消息如何分页?
  3. 群聊和一对一聊天的消息发送接口是合并还是分开?
点击查看参考答案

REST API(请求-响应式操作)

# 用户
POST /api/v1/auth/register 注册
POST /api/v1/auth/login 登录
# 联系人
GET /api/v1/contacts 获取联系人列表
POST /api/v1/contacts 添加联系人
DELETE /api/v1/contacts/:userId 删除联系人
# 会话
GET /api/v1/conversations 获取会话列表(含最近消息预览)
POST /api/v1/conversations 创建群聊
GET /api/v1/conversations/:convId/messages?cursor=xxx&limit=50 历史消息
PUT /api/v1/conversations/:convId/read 标记已读(也可用WebSocket)
# 群管理
POST /api/v1/conversations/:convId/members 添加群成员
DELETE /api/v1/conversations/:convId/members/:userId 移除群成员
PUT /api/v1/conversations/:convId 修改群信息

WebSocket(实时双向通信)

# 连接
ws://api.chat.com/ws?token=jwt_token
# 客户端→服务端
{ "type": "message", "conv_id": "xxx", "content": "你好", "msg_id": "client-uuid" }
{ "type": "typing", "conv_id": "xxx" }
{ "type": "read", "conv_id": "xxx", "last_msg_id": "msg-123" }
# 服务端→客户端
{ "type": "message", "conv_id": "xxx", "from": "user-456", "content": "你好", "msg_id": "server-uuid", "timestamp": 1698000000 }
{ "type": "typing", "conv_id": "xxx", "user_id": "user-456" }
{ "type": "read_receipt", "conv_id": "xxx", "user_id": "user-456", "last_msg_id": "msg-123" }
{ "type": "presence", "user_id": "user-456", "status": "online" }
{ "type": "ack", "client_msg_id": "client-uuid", "server_msg_id": "server-uuid" }

设计决策

  1. 消息发送用WebSocket:因为需要实时推送,用HTTP轮询延迟高且浪费资源。
  2. 历史消息用REST + cursor分页:用户上滑加载历史消息是典型的分页场景。Cursor用消息的timestamp或递增ID。
  3. 群聊和一对一合并为”会话”概念:一对一也是一种会话,只是成员数为2。发消息的接口统一用 conv_id 标识,不区分群聊还是单聊。
  4. 消息ID双重保障:客户端生成 client_msg_id(用于去重和ACK匹配),服务端生成 server_msg_id(用于全局排序和cursor分页)。
  5. 在线状态通过WebSocket连接自动管理:连接上就是在线,断开就是离线,用心跳包维持。