简介《系统设计面试》PDF面向准备技术面试的程序员与初中高级开发者帮助读者系统梳理大型、模糊架构题的作答思路从需求澄清、容量估算到方案取舍建立可复用的设计框架。内容围绕“从零扩展到数百万用户”展开依次讨论限流器、一致性哈希、键值存储、分布式唯一ID、短链接、网络爬虫、通知系统、新闻推送、聊天系统、搜索自动补全并以YouTube、Google Drive等综合案例串联存储、缓存、消息、负载均衡、水平与垂直扩展等常见考点。资源包共1个PDF文件大小约9.97MB目录清晰便于按章节检索和专题复盘。已有2229人学习读者可借助书中的估算方法、答题模板与案例拆解练习在白板上组织完整回答并积累可迁移的分布式系统知识。1. 系统设计面试的隐藏评分表没有标准答案为什么还能打分很多程序员第一次坐进系统设计面试最不适应的不是题目难而是题目「没有答案」。让你设计一个短链服务你画了负载均衡、缓存、数据库分片面试官却一直追问「为什么是这三个而不是两个」「缓存整体不可用了会怎样」。这种追问很容易让人怀疑自己是不是答错了。真实情况恰好相反这类面试从设计之初就没打算找一个正确架构。它观察的是你在信息不完备、约束互相冲突时能不能把一连串取舍讲清楚——需求怎么收敛、瓶颈在哪、代价由谁承担。搞懂这条底层逻辑后面所有负载均衡、主从复制、一致性哈希才有意义因为它们是「取舍的原材料」而不是一份背诵清单。2. 从单机到百万用户每一步扩展到底在消灭哪个单点2.1 单服务器链路上的四个环节与一次请求的完整路径所有扩展故事都从一台机器开始Web 应用、数据库、缓存全挤在同一台服务器上。这不是偷懒而是先让链路跑通再逐个拆掉瓶颈。理解这条最小链路比背十种架构图都管用因为后面每一次加组件本质都是把这条链路上的某一段单独拎出来。一次请求要经过四个环节。用户输入域名域名系统把域名解析成 IP 地址返回给浏览器或移动端这一步通常由第三方托管不在自己的服务器上。拿到 IP 之后客户端用 HTTP 协议把请求直接发到 Web 服务器服务器处理完返回 HTML 页面或者 JSON 数据给客户端渲染。这条链路上任何一环挂掉用户看到的就是白屏。流量的来源只有两个方向但处理方式差别不小。Web 应用是「服务端语言处理业务逻辑 客户端语言负责呈现」的组合页面和接口混在一起移动应用则几乎完全依赖 HTTP 接口JSON 因为结构简单、解析成本低成了事实上的响应格式。这带来一个容易被忽略的细节接口字段的类型设计会直接影响客户端能不能正确渲染。# 客户端侧域名解析由系统/浏览器完成这里只体现拿到 IP 之后的 HTTP 交互 curl -s https://api.example.com/users/12 \ -H Accept: application/json// 服务端返回的最小结构字段类型决定了客户端解析的正确性 { id: 1284729384756109312, // 用字符串而不是数字 name: user-12, avatar: https://static.example.com/a/12.webp, updatedAt: 1700000000 // 秒级时间戳避免时区歧义 }上面这段返回里id特意写成字符串。原因在于标识符一旦采用「时间戳 机器位 序列号」这类分段方案数值会超出客户端语言能精确表示的整数范围用数字类型接收就会在末几位静默丢精度表现为「同一个用户偶尔查不到」。这不是玄学而是数值表示边界的直接后果后面第 4 章会展开讲它的位分配。avatar指向独立静态域名则是为把图片流量从业务服务器上剥离出去给 CDN 留出位置updatedAt用秒级整数而不是格式化字符串是为了让客户端排序和缓存判断都不用再做字符串解析。把这四点串起来看域名解析决定「找到谁」HTTP 决定「怎么问」服务端语言决定「怎么算」JSON 结构决定「怎么还」。单机阶段这四件事挤在一台机器上任何一个变慢都互相拖累这就是拆分的第一动机。2.2 第一刀砍在 Web 层和数据层之间为什么不是先加缓存用户量涨起来之后最直觉的做法是加缓存。但更稳的第一步是把 Web 服务器和数据库服务器分开部署。理由很朴素这两层的资源画像完全不同。Web 层是 CPU 和连接数敏感、几乎无状态、可以随时加机器数据层是磁盘和内存敏感、有状态、加机器要搬数据。混在一起时你为了扛住流量给机器加 CPU数据库用不上数据库要扩容磁盘Web 层又觉得浪费。拆开之后两层可以按各自的节奏独立伸缩。Web 层从 2 台扩到 20 台改一下负载均衡后面的机器列表就行数据层保持 1 主 2 从先靠复制扛读流量。故障边界也清楚了Web 层某台机器挂掉只影响它自己承载的那部分请求数据层挂了才是真事故。这种「按资源画像和故障域切分」的思路会贯穿后面所有的拆分决策。数据库类型的选择也发生在这一步。关系型数据库把数据组织成表和行支持跨表 join四十多年的工程积累让它在事务和一致性上非常成熟绝大多数业务的首选仍然是它。非关系型数据库通常分成键值、图、宽列、文档四类多数不支持 join但在几个特定场景下更合适需要极低延迟、数据本身没有关系结构、只需要序列化和反序列化、或者要存的数据量级大到关系型数据库的运维成本难以承受。判断标准不是「谁更先进」而是「你的查询模式能不能被它天然支持」。2.3 垂直扩展的天花板加配置解决不了的两件事扩展分两个方向。垂直扩展是给单台机器加 CPU、加内存水平扩展是往资源池里加机器。流量不大的阶段垂直扩展的简单性有巨大优势不用改代码、不用处理分布式问题、不用引入新的故障点把机器配置翻倍往往能顶很长一段时间。但它有两个硬约束。第一是物理上限单机的 CPU 核数和内存容量终究有尽头价格也不是线性的——从 32 核到 64 核的性价比远不如再加一台 32 核。第二更致命垂直扩展不提供故障转移和冗余。机器只有一台它宕机整个站点跟着下线没有任何备份路径。这条约束决定了任何对可用性有要求的系统最终都必须走水平扩展。维度垂直扩展水平扩展操作方式提升单机 CPU/内存/磁盘向资源池增加机器复杂度低无需改代码高要处理分发、状态、一致性上限存在物理与成本硬顶理论可近似线性故障影响单点宕机则全站不可用单机故障可被流量转移吸收适用阶段早期、流量可预测中后期、需要可用性保障表格里「水平扩展复杂度高」这一行是后面所有章节的源头加机器容易让多台机器像一个整体那样工作很难。负载均衡解决「请求发给谁」主从复制解决「数据放几份」缓存解决「重复计算怎么省」一致性哈希解决「加机器后数据怎么搬」。每一个组件都在填补水平扩展带来的新坑。2.4 负载均衡把「一台服务器」变成「一个池子」早期用户直连 Web 服务器这带来两个问题。服务器下线用户就访问不了大量用户同时涌入超过单机承载上限表现是响应变慢或者直接连不上。负载均衡器就是为这两个问题生的它把进来的流量均匀分发给一个服务器池池子里的机器对客户端不可见。部署方式上有个安全细节值得说清。用户只连接负载均衡器的公网 IPWeb 服务器不再直接对外暴露负载均衡器和 Web 服务器之间走私网 IP 通信私网地址只能在同一网络内部到达公网无法路由过去。这一层隔离让攻击面从「N 台机器」收缩到「1 个入口」也避免了有人绕过均衡器直接打某台后端。加入第二台 Web 服务器之后可用性问题迎刃而解一台下线流量全部转到另一台同时补充一台健康机器进池流量暴涨往池子里加机器均衡器自动开始分发。这里有个隐藏前提——Web 层必须是无状态的。如果用户的登录会话存在某台服务器的本地内存里请求被分发到另一台就会「莫名其妙掉登录」这类问题在排查时特别费时间因为现象随机、日志对不上。常见的做法是把会话外移到共享存储或者用签名令牌让服务端不保存会话状态让任何一台机器都能处理任何请求。3. 缓存与 CDN命中率、失效和那条最危险的时间窗3.1 read-through 链路的完整时序一次命中省掉了什么Web 层和数据层理顺之后下一个瓶颈是响应时间和数据库负载。每次页面加载都会触发一次或多次数据库查询重复查询同一份几乎不变的数据是纯粹的浪费。缓存的定位很明确把昂贵响应或高频访问数据的结果放进内存让后续请求直接命中不再落到数据库。最常见的读路径叫 read-through请求进来Web 服务器先查缓存有没有对应结果命中就直接返回未命中就查数据库把结果写回缓存再返回给客户端。这条链路的价值不在于「快」而在于它把数据库的读压力按命中率折算掉了。命中率 95% 意味着 100 次请求只有 5 次真正打到数据库原来需要 20 台数据库才能扛的读流量现在 1 台就够。def get_user(user_id: str, cache, db): key fuser:{user_id} # 缓存键要带业务前缀避免不同对象撞键 cached cache.get(key) # 一次内存查询耗时通常在亚毫秒级 if cached is not None: return cached # 命中直接返回不碰数据库 row db.query_one(SELECT * FROM users WHERE id ?, user_id) if row is None: cache.set(key, __MISS__, ttl60) # 空结果也缓存短 TTL 防穿透 return None cache.set(key, row, ttl3600) # 正常数据给较长 TTL按变更频率调 return row这段代码里三个参数决定成败。ttl是过期时间太长会导致读到过期数据太短会让命中率崩掉经验值是按「数据可容忍的陈旧时间」定而不是按「数据库扛不扛得住」定。空结果的__MISS__缓存是防穿透的关键不加这行一个不存在的 ID 被高频请求时会绕过缓存直击数据库。键前缀则防止不同业务对象用同样的 ID 互相覆盖。3.2 失效策略缓存和数据库之间那条无法消除的时间窗缓存最容易被低估的部分不是读是写。数据更新时是先更新数据库再删缓存还是先删缓存再更新数据库两种顺序都有漏洞先删缓存后写库删除到写库之间的并发读会把旧值重新加载回缓存脏数据一直留到 TTL 到期先写库后删缓存删除动作本身失败缓存就永久是旧值。常见的做法是「先写数据库再删除缓存」并且删除失败要重试——把删除操作丢进消息队列反复投递直到成功。之所以是「删」而不是「更新」是因为更新缓存在并发下会产生乱序覆盖两个写请求几乎同时到达后写库的那个可能先更新缓存最终缓存里留下先写库的旧值。「删除」把冲突收敛成「下次读的时候重新加载」虽然多一次未命中但语义简单得多。这里有一条必须承认的现实缓存和数据库之间永远存在一个时间窗你能做的是把它压缩到业务可接受而不是消灭它。凡是对「读到旧值」零容忍的数据余额、库存扣减正确做法是不要让它走普通缓存而是走强一致的路径或者干脆直接查库。3.3 CDN 与静态内容外移把带宽从业务服务器上卸下来缓存解决的是「重复计算的读」CDN 解决的是「重复传输的字节」。JavaScript、CSS、图片、视频这些静态内容有两个特征体积大、内容不变。让它们和动态接口挤在同一批服务器上带宽会被吃满业务接口的响应时间跟着遭殃。CDN 的工作方式是把静态资源预先分发到离用户更近的边缘节点。用户请求静态资源时DNS 直接把他引导到最近的边缘节点请求根本不进入你的源站。命中边缘缓存就返回未命中才回源取一次并缓存下来。这一层对源站的意义是数量级的一个 2MB 的首屏图片源站只传一次之后由边缘节点承担百万次分发。和缓存一样CDN 的关键参数也是 TTL但判断依据不同静态资源通常可以配很长的 TTL靠文件名带内容哈希来做「版本化失效」——文件内容一变文件名就变天然不会读到旧版本。这个技巧比手动刷新 CDN 缓存可靠得多因为刷新是异步的全球节点全部生效存在延迟靠人记着刷新迟早翻车。4. 一致性哈希与分布式 ID节点增减时数据搬家的代价4.1 取模哈希的崩溃点加一台机器为什么等于全量迁移数据量大到单库放不下时要把数据分片到多台机器上。最直觉的分配方式是hash(key) % NN 是节点数。这个方案在节点数固定时工作得很好分布均匀、定位 O(1)。问题出在 N 变化的那一刻。假设原来 4 台机器加一台变成 5 台。对任意一个 keyhash(key) % 4和hash(key) % 5的结果绝大多数情况下不同——大约 80% 的 key 会被判定到新的节点上。这意味着加一台机器要迁移几乎全量的数据迁移期间缓存大面积失效请求全部回源数据库瞬间被打穿。更糟的是这个代价随节点数变化不可预测运维上基本等于「扩容 事故」。一致性哈希要解决的就是这件事让节点增减只影响相邻的一小段数据而不是全局重排。4.2 哈希环与虚拟节点把「搬多少」变成可计算的量一致性哈希把哈希值空间想象成一个首尾相接的环节点和 key 都映射到环上。一个 key 归属哪个节点取决于从它所在位置顺时针走遇到的第一个节点。增加一个节点时只有落在「新节点到它逆时针前一个节点」这段区间内的 key 需要迁移其余 key 的归属完全不变。在节点数均匀的情况下这段区间平均占比是 1/(N1)也就是说 4 台加 1 台只迁移约 20% 的数据而且迁移量可以事前算出来。这是设计层面的可预期性比「性能提升」更值钱——你可以据此安排迁移窗口、限速、灰度。import hashlib, bisect class ConsistentHash: def __init__(self, nodesNone, replicas150): self.replicas replicas # 每个物理节点对应的虚拟节点数 self.ring [] # 排序后的哈希值列表用于二分查找 self.owner {} # 哈希值 - 物理节点名 for n in (nodes or []): self.add(n) def _hash(self, key: str) - int: return int(hashlib.md5(key.encode()).hexdigest(), 16) def add(self, node: str): for i in range(self.replicas): h self._hash(f{node}#{i}) # 虚拟节点同一物理节点铺满环 self.ring.append(h) self.owner[h] node self.ring.sort() def get(self, key: str) - str: if not self.ring: return None h self._hash(key) idx bisect.bisect_left(self.ring, h) # 顺时针找第一个 h 的位置 if idx len(self.ring): idx 0 # 越过环尾要回到环首 return self.owner[self.ring[idx]]这里两个参数值得说透。replicas是虚拟节点数作用是解决「物理节点少时分布不均」的问题只有 4 个节点直接上环四个点很可能挤在一起导致某一台承载大半数据。给每个物理节点铺 150 个虚拟节点等于用 600 个点均匀撒满环单台机器的负载偏差会小很多。数字取多大取决于节点总数节点越多需要的虚拟节点越少太少会偏斜太多只是多占内存。bisect_left那一步是整段代码的核心它对排序后的环做二分查找把定位复杂度压到 O(log N)同时处理「越过环尾」的边界。这个边界不写会直接抛异常属于血泪经验级别的小坑。4.3 唯一 ID为什么自增主键在分布式里失效分片之后紧接着撞上的问题是主键生成。数据库自增主键依赖单点序列一旦数据分到多台机器、或者业务要求 ID 在生成时就全局唯一自增就彻底不可用了。UUID 能保证唯一但它是完全随机的作为主键会让写入变成随机 IO索引页频繁分裂写入吞吐掉得厉害。工程上更常见的方案是把 ID 拆成几段拼接高位放时间戳中间放机器标识低位放同一毫秒内的自增序列。这样生成的 ID 有两个性质全局唯一机器位保证、整体单调递增时间戳在高位写入近似顺序追加。位数分配是关键取舍——机器位分几位决定最多支持多少台生成节点序列位分几位决定单节点每毫秒能生成多少个 ID两者互相挤占。import time, threading EPOCH 1700000000000 # 自定义纪元缩短时间戳位数延长可用年限 NODE_BITS, SEQ_BITS 10, 12 # 机器位 10 位1024 台序列位 12 位每毫秒 4096 个 NODE_ID 7 _lock threading.Lock() _last_ms, _seq -1, 0 def next_id() - int: global _last_ms, _seq with _lock: # 单机内必须串行否则序列会重复 now int(time.time() * 1000) if now _last_ms: _seq (_seq 1) ((1 SEQ_BITS) - 1) if _seq 0: # 同一毫秒序列用尽自旋到下一毫秒 while now _last_ms: now int(time.time() * 1000) else: _seq 0 _last_ms now return ((now - EPOCH) (NODE_BITS SEQ_BITS)) | (NODE_ID SEQ_BITS) | _seq这段实现有三个必须处理的地方。_lock不是可选项同一进程内多线程并发取 ID不加锁就会拿到重复序列。序列用尽时自旋等待下一毫秒是「宁可短暂阻塞也不重复」的取舍吞吐上限因此被锁死在「单节点每毫秒 4096 个」。EPOCH设成固定值而不是 0是为了把 41 位时间戳的可用年限从 1970 起算拉长到从纪元起算具体能撑多少年由位数决定超过就得换方案。还有一个运行时坑机器时间被回拨。回拨会导致时间戳倒退生成出比历史 ID 更小的值甚至重复。判断逻辑通常是「发现 now 小于上一次时间戳就拒绝服务并告警」而不是硬着头皮继续生成——这类数据一旦写进去事后修复成本极高等于没有后悔药。5. 避坑与排查五个「看起来对、跑起来错」的设计5.1 会话存在本地内存扩容后登录随机失效现象加机器之后用户反馈「一会儿登录一会儿掉线」刷新几次又好了日志里查不到任何异常。原因会话保存在 Web 服务器本地内存负载均衡把同一用户的请求轮询到了不同机器新机器上没有这份会话判为未登录。这个问题的迷惑性在于它随流量分布随机出现单机复现不出来。解决把 Web 层做成无状态会话外移到共享存储或者改用签名令牌由客户端携带。做完之后要验证——随机打上千次请求确认任意两台机器返回的登录态一致。5.2 主从复制下的「写后读不到」现象用户修改昵称刷新页面还是旧名字过几秒再刷就对了。测试同学认为是 bug开发认为是缓存。原因写请求打到主库读请求打到从库主从复制是异步的存在毫秒级到秒级的延迟。写完之后立刻发起的读可能在从库上读到还没同步的旧值。解决对「自己写、自己马上读」的场景做读写绑定写入后的短时间内强制读主库或者用会话级粘性把同一用户一段时间的读都指向主库。这不是缓存问题别去查缓存方向错了会浪费一整天。5.3 缓存同时过期数据库被瞬间打穿现象服务运行平稳每到整点或者某个固定时间点数据库连接数陡增接口大面积超时几分钟后自己恢复。原因一批缓存键设了相同的 TTL同一时刻集体失效原本被缓存挡住的流量在同一秒全部回源。流量不是变多了是被压缩到了同一瞬间。解决给 TTL 加随机抖动比如基准 3600 秒加 0 到 300 秒的随机偏移把失效时间摊开。同时对热点键加互斥锁只允许一个请求回源重建缓存其余请求等待或返回旧值。5.4 一致性哈希加了节点负载反而更不均现象按方案扩容理论上只迁移一小部分数据实际监控显示新节点几乎空闲某台老节点 CPU 打满。原因虚拟节点数太少或者节点名的哈希分布本身有偏导致环上的点位扎堆。物理节点少的时候这个偏差会被放大。解决提高每台物理节点的虚拟节点数重新评估分布上线前用一批真实 key 做模拟统计各节点的承载比例偏差超过阈值就不要上线。这个验证成本很低能省掉一次线上事故。5.5 重试把一次抖动放大成雪崩现象下游某个依赖短暂超时调用方大量重试结果是依赖彻底被打挂故障范围从小接口扩散到全站。原因重试没有退避、没有上限、没有熔断。一次超时变成三次请求三次又各自超时再重试请求量指数上升把本来只是慢的服务彻底压垮。解决重试必须带指数退避和随机抖动限定最大次数并且只对幂等操作重试。同时加熔断错误率超过阈值直接快速失败给下游恢复的时间。限流器也是同一层的保护在入口就把超过承载能力的请求挡掉而不是让它们进来排队互相拖死。6. 背估算与取舍把设计能力变成可复现的推导散落在各章的技术点最终要收敛成一个可复现的推导动作先估数量级再拿数字去选架构。这也是我建议任何人在动手画图之前必须走的一遍——不估就画画出来的东西大概率是在解决一个并不存在的瓶颈。估算的逻辑不复杂麻烦的是把假设说清楚。假设某个内容型产品的日活是 1000 万人均每天发起 20 次读请求、2 次写请求峰值系数取 3单条约 1KB。用一段代码把这些假设变成结论比在纸上口算更不容易错也方便别人质疑你的假设时当场改参数重算。DAU 10_000_000 READ_PER_USER, WRITE_PER_USER 20, 2 PEAK_FACTOR 3 RECORD_KB 1 CACHE_HIT 0.95 reads_per_day DAU * READ_PER_USER avg_read_qps reads_per_day / 86400 peak_read_qps avg_read_qps * PEAK_FACTOR db_read_qps peak_read_qps * (1 - CACHE_HIT) daily_new DAU * WRITE_PER_USER daily_storage_gb daily_new * RECORD_KB / 1024 / 1024 yearly_storage_tb daily_storage_gb * 365 / 1024 bandwidth_mbps peak_read_qps * RECORD_KB * 8 / 1024 # KB - Mb print(f平均读 QPS: {avg_read_qps:.0f}) # 约 2315 print(f峰值读 QPS: {peak_read_qps:.0f}) # 约 6944 print(f落到数据库峰值: {db_read_qps:.0f}) # 约 347 print(f每日新增存储: {daily_storage_gb:.1f} GB) # 约 19.1 GB print(f五年存储: {yearly_storage_tb * 5:.1f} TB) # 约 34 TB print(f峰值带宽: {bandwidth_mbps:.0f} Mbps) # 约 54 Mbps跑出来的数字很有意思也是这套方法最有价值的地方。峰值读接近 7000 QPS看起来必须上集群但套上 95% 的缓存命中率真正打到数据库的只有三百多 QPS一台配置不错的机器加从库就能扛。带宽按 1KB 算只有 50 多 Mbps如果响应里含图片把图片体积累乘进去就是几百 Mbps —— 这时候 CDN 不是「优化项」而是「必需项」判断依据从感觉变成了算式。五年 34TB 的存储规模也直接决定了单库够不够、什么时候该考虑分片。这套推导真正训练的是取舍意识。把缓存命中率从 95% 调到 90%数据库压力立刻翻倍这就是为什么缓存方案里「命中率」比「缓存什么」更值得讨论。把峰值系数从 3 调到 5容量规划立刻变得保守很多这就是为什么要在方案里显式写出「我假设峰值是均值的 3 倍」——假设被质疑、被修改方案才站得住。反过来如果一个候选架构听完你的估算后没有任何一项数字能对应上它很可能只是照着印象抄来的图。我的习惯是每次开口讲架构之前先在纸上写三行核心 QPS、核心数据量、核心瓶颈在哪一层。说不清这三行后面加的每一个组件都是没依据的装饰。从那以后我每次准备方案都强制先走一遍这个估算流程哪怕结论只是「这台单机够用别加缓存」——它替我挡掉过好几次为了显得专业而过度设计的冲动。这份资料把上面这些推导和案例整理成了十六个部分从用户量增长的完整演化路径、信封背面的快速估算方法、面试推进的框架一直到限流器、一致性哈希、键值存储、分布式唯一 ID、短链、爬虫、通知、信息流、聊天、自动补全、视频平台和云盘这些具体系统的设计拆解每一块都沿着「先估数字、再定组件、最后讲清代价」的顺序展开。如果你想把散在脑子里的零碎知识点串成一条能自洽推演的线翻它比刷十篇面经更省时间。希望帮到你。本文还有配套的精品资源点击获取