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被访问的次数少了),缓存策略需要重新评估。
1.2 常用数量级参考
Section titled “1.2 常用数量级参考”定义:系统设计中有一组被广泛引用的延迟和容量数字,它们帮助你快速判断不同存储和通信方式的性能差异。这些数字不需要精确记忆,但必须知道数量级关系。
为什么重要:当你在面试中说”我们用Redis缓存”时,面试官期望你知道为什么——因为内存访问比磁盘快10万倍。如果你不知道这些数量级差异,你的架构选择就缺乏数据支撑。
参考表:
| 操作 | 延迟 | 数量级 |
|---|---|---|
| L1缓存引用 | 0.5ns | 纳秒级 |
| L2缓存引用 | 7ns | 纳秒级 |
| 内存引用 | 100ns | 纳秒级 |
| SSD随机读取 | 100μs | 微秒级 |
| 内存顺序读1MB | 250μs | 微秒级 |
| 同数据中心网络往返 | 500μs | 微秒级 |
| SSD顺序读1MB | 1ms | 毫秒级 |
| 磁盘寻址 | 10ms | 毫秒级 |
| 磁盘顺序读1MB | 20ms | 毫秒级 |
| 跨地域网络往返 | 150ms | 毫秒级 |
容量参考:
| 单位 | 大小 |
|---|---|
| 1KB | 1,000字节 |
| 1MB | 1,000,000字节 |
| 1GB | 10亿字节 |
| 1TB | 1万亿字节 |
| 1PB | 1000TB |
案例: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倍以内时,架构优势往往更重要。
1.3 QPS计算
Section titled “1.3 QPS计算”定义: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系统设计的核心挑战。
1.4 存储量计算
Section titled “1.4 存储量计算”定义:存储量计算用于估算系统在一定时间内需要的总存储空间。基本公式为:总存储量 = 每条数据平均大小 × 每天新增数据条数 × 数据保留天数。需要分别计算不同类型的数据(元数据、媒体文件、日志等)。
为什么重要:存储量决定了你选择什么存储系统、需要多少台机器、数据是否需要分片、归档策略如何设计。如果你估算出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)提供用户通知和下载/续期机制。这本质上是一个数据生命周期管理系统。
1.5 带宽计算
Section titled “1.5 带宽计算”定义:带宽计算用于估算系统在网络传输层面的压力。基本公式为:所需带宽 = 峰值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年代中期正是通过从光栅切换到矢量瓦片大幅降低了带宽。
1.6 REST API设计原则
Section titled “1.6 REST API设计原则”定义:REST API设计是系统对外暴露功能的接口规范。核心原则包括:以资源(名词)而非动作(动词)命名URL;用HTTP方法(GET/POST/PUT/DELETE)表达操作语义;版本控制(/v1/)防止破坏性变更;分页和过滤控制响应大小;统一的错误响应格式。
为什么重要:API是系统最重要的”合同”——一旦发布很难更改。好的API设计降低沟通成本、减少bug、让前端和第三方开发者更容易接入。在面试中,API设计展示你是否有工程实践经验,而不仅仅是画框图。
案例:以 URL Shortener 的API设计为例:
# 创建短链接POST /api/v1/urlsRequest 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 /:shortCodeResponse 301/302: Location: https://example.com/very/long/path
# 查询短链接详情GET /api/v1/urls/:shortCodeResponse 200: { "short_url": "...", "long_url": "...", "clicks": 12345, "created_at": "..." }
# 删除短链接DELETE /api/v1/urls/:shortCodeResponse 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。
1.7 API限流 (Rate Limiting)
Section titled “1.7 API限流 (Rate Limiting)”定义: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=5news sites: 5 req/s, burst=10 (新闻站更新快,需要更频繁爬取)small sites: 0.5 req/s, burst=2 (小站容量有限,需要更温和)先想一想 🤔 令牌桶和漏桶的最大区别是什么?在Web Crawler场景下哪个更合适?
点击查看解析
最大区别在于对突发流量的处理。令牌桶允许突发——如果桶里积累了令牌,可以一次性发出多个请求;漏桶严格以固定速率处理,即使桶里有排队请求也不会加速。对Web Crawler来说,令牌桶更合适。原因是:爬虫在解析一个页面时不发请求(令牌积累),解析完后需要快速获取页面中的多个链接(消耗积累的令牌)。这种"停顿-突发"模式正是令牌桶擅长的。如果用漏桶,爬虫在解析期间浪费了请求配额,获取新链接时又被迫等待,整体效率更低。
1.8 API分页
Section titled “1.8 API分页”定义:API分页是将大量数据分批返回给客户端的技术。两种主要方式:Offset/Limit分页(?offset=20&limit=10)通过偏移量定位;Cursor-based分页(?cursor=abc123&limit=10)通过上次结果的最后一条记录的标识符定位下一页。
为什么重要:不分页的API在数据量大时会返回巨大的响应——浪费带宽、撑爆客户端内存、查询慢。但选错分页方式也有问题:Offset分页在数据频繁插入时会出现数据重复或遗漏;Cursor分页不支持”跳到第50页”。选择哪种方式取决于使用场景。
两种方式对比:
| 特性 | Offset/Limit | Cursor-based |
|---|---|---|
| 实现复杂度 | 低(SQL OFFSET) | 中(需要编码cursor) |
| 跳页能力 | 支持(直接算offset) | 不支持 |
| 性能(深翻页) | 差(OFFSET 10000很慢) | 好(始终从上次位置开始) |
| 数据一致性 | 差(新数据插入导致重复/遗漏) | 好(基于位置标记) |
| 适用场景 | 管理后台、搜索结果 | 无限滚动、实时流 |
案例:以 News Feed 的无限滚动为例。用户在Feed中不断下拉看更多内容,这是典型的cursor-based分页场景:
# 第一次请求GET /api/v1/feed?limit=20Response: { "posts": [...20条], "next_cursor": "eyJ0IjoxNjk4MDAwMDAwfQ==" // base64编码的时间戳}
# 下拉加载更多GET /api/v1/feed?cursor=eyJ0IjoxNjk4MDAwMDAwfQ==&limit=20Response: { "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%)
请估算以下数字:
- 搜索QPS:100万 / 86400 ≈ ?(平均和峰值)
- 预订写QPS:35万 / 86400 ≈ ?
- 数据存储量(1年):
- 酒店数据:10万家 × 每家约5KB(名称、地址、描述等)= ?
- 房型数据:假设每家5种房型 = 50万种 × 1KB = ?
- 库存数据:10万 × 50间 × 365天 × 每条50字节 = ?
- 订单数据:35万/天 × 365 × 每条200字节 = ?
- 搜索带宽:峰值搜索QPS × 每次返回20个结果 × 每个结果500字节 = ?
点击查看参考答案
-
搜索QPS:
- 平均 = 1,000,000 / 86,400 ≈ 11.6 QPS
- 峰值(3倍)≈ 35 QPS
- 这个QPS不高!单台服务器就能搞定读取。但搜索涉及复杂查询(日期范围、地理位置、价格过滤),实际压力在数据库。
-
预订写QPS:
- 350,000 / 86,400 ≈ 4 QPS
- 写QPS非常低,但每次写涉及事务(检查库存→扣减→创建订单),对一致性要求极高。
-
数据存储量:
- 酒店数据:10万 × 5KB = 500MB(很小)
- 房型数据:50万 × 1KB = 500MB
- 库存数据:10万 × 50 × 365 × 50B ≈ 91GB/年
- 订单数据:35万 × 365 × 200B ≈ 25.5GB/年
- 总计约117GB/年——单机完全可以存放
-
搜索带宽:
- 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接口:
要求覆盖:
- 用户管理(注册/登录)
- 联系人/好友
- 一对一聊天
- 群聊
- 消息已读状态
- 在线状态
请思考:
- 哪些功能用REST API?哪些用WebSocket?
- 消息如何分页?
- 群聊和一对一聊天的消息发送接口是合并还是分开?
点击查看参考答案
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" }设计决策:
- 消息发送用WebSocket:因为需要实时推送,用HTTP轮询延迟高且浪费资源。
- 历史消息用REST + cursor分页:用户上滑加载历史消息是典型的分页场景。Cursor用消息的timestamp或递增ID。
- 群聊和一对一合并为”会话”概念:一对一也是一种会话,只是成员数为2。发消息的接口统一用
conv_id标识,不区分群聊还是单聊。 - 消息ID双重保障:客户端生成
client_msg_id(用于去重和ACK匹配),服务端生成server_msg_id(用于全局排序和cursor分页)。 - 在线状态通过WebSocket连接自动管理:连接上就是在线,断开就是离线,用心跳包维持。