1. 这不是笔记,是系统设计能力的“肌肉记忆”训练手册
“system-design-notes”这个标题乍看平平无奇,像极了某位工程师随手建的GitHub仓库名,或是面试前熬夜整理的OneNote文档。但如果你真把它当成普通笔记来抄、来背、来收藏吃灰,那它就真的只是个名字——而你,大概率会在下一轮系统设计面试里卡在第一道题:如何设计一个短链接服务?不是因为不会画UML图,而是因为你根本没想清楚“短链接”背后要对抗的,是每秒数万次的高并发写入、全球分布的缓存穿透、以及URL哈希碰撞后如何保证唯一性。我带过37位准备系统设计面试的候选人,其中29人栽在同一个地方:把“notes”理解成知识罗列,却忽略了它本质是决策日志——记录的是“为什么选Redis而不是Memcached做缓存层”、“为什么分库分表要按用户ID哈希而非时间戳”、“为什么Rate Limiter必须放在API网关而非业务代码里”。这些决策背后没有标准答案,只有权衡:一致性 vs 可用性、延迟 vs 成本、开发速度 vs 长期可维护性。这本notes的核心价值,从来不是告诉你“该怎么做”,而是逼你直面“为什么不能那样做”。它适合三类人:正在冲刺FAANG级别后端岗的应届生(别信“刷够100道题就能过”的鬼话)、工作三年以上却总在架构评审会上插不上话的中级工程师(你缺的不是经验,是结构化表达能力)、以及带团队却说不清“我们为什么用Kafka不用RabbitMQ”的技术负责人(你的决策需要被复现、被质疑、被传承)。它不教你怎么写Hello World,它教你如何在资源有限、需求模糊、时间紧迫的真实战场里,用最朴素的工程原则,把“看起来不可能”的需求,拆解成一张张可落地、可验证、可迭代的草图。
2. 内容整体设计与思路拆解:从“记知识点”到“建决策树”
2.1 为什么拒绝“分类目录式”笔记?——真实面试场景的倒逼逻辑
市面上90%的系统设计资料,都按“消息队列”、“缓存”、“数据库”、“负载均衡”等模块分类,像一本技术词典。我试过用这种方式准备面试,结果在模拟面试中被面试官一句话问懵:“如果现在要给一个日活500万的电商App加购物车功能,你会怎么设计?先说核心瓶颈在哪。”——我脑子里瞬间跳出“Redis缓存”、“分库分表”、“读写分离”一堆名词,但说不出“为什么购物车数据必须用Redis Hash结构存,而不是String”、“为什么加购请求要先走本地缓存再打Redis,而不是直接穿透”、“为什么库存扣减和购物车更新要拆成两个独立服务”。问题出在哪?分类目录式笔记,训练的是名词检索能力;而系统设计面试考察的是因果推演能力。所以这本notes的骨架,完全抛弃了技术栈分类,转而以典型业务场景为根节点,向上生长出决策分支。比如“短链接服务”这个场景,它的第一层分支不是“用什么数据库”,而是“核心指标是什么?”——QPS峰值、平均延迟、URL生成成功率、存储成本。第二层分支才是“如何达成这些指标?”,这时才引出“生成算法选Base62还是UUID?”、“缓存策略是预热还是懒加载?”、“DB写入是同步落盘还是异步批量?”每一个分支点,都强制标注决策依据:比如选Base62是因为它比UUID短30%,能减少URL长度对CDN缓存命中率的影响;选异步批量写入是因为短链接生成是幂等操作,允许少量丢失,而DB写入延迟会直接拖慢前端响应。这种结构,本质上是在模拟真实架构师的思考路径:先定义成功标准,再评估约束条件,最后选择技术方案。它不告诉你“Redis好”,而是逼你算一笔账:假设QPS 10万,单机Redis集群吞吐量约8万,那是否需要多集群?多集群的跨机房同步延迟是否会导致短链接生成失败?失败后是重试还是降级?——所有答案,都藏在决策树的叶子节点里。
2.2 “Notes”二字的深层含义:不是记录,是留痕与复盘
很多人误以为notes就是摘抄概念,比如“Rate Limiter有令牌桶、漏桶、滑动窗口三种算法”。但这本notes里,关于Rate Limiter的一页,只有一张手绘草图:横轴是时间,纵轴是请求数,上面画着三个不同形状的“桶”,旁边密密麻麻写着小字:“漏桶:平滑输出,但突发流量会被直接拒绝,适合支付接口;令牌桶:允许突发,但需预分配令牌,适合API网关;滑动窗口:精度高,但内存开销大,适合用户行为分析”。更关键的是,在页脚,有一行红笔批注:“上周线上事故复盘:登录接口被爬虫打爆,原用滑动窗口限流,窗口粒度设为1秒,但爬虫用分布式IP轮询,实际每秒请求数未超限,却在100ms内集中爆发。改用令牌桶+IP+User-Agent双维度限流后,拦截率提升至99.2%”。看到这里你就懂了,“notes”在这里是事故日志、是AB测试报告、是压测数据截图、是架构评审会议纪要的摘要。它存在的意义,不是让你记住“令牌桶是什么”,而是让你记住“在什么条件下,令牌桶会失效,以及我们是怎么发现并修复它的”。我坚持要求团队新人每做完一个功能,必须提交三样东西:PR代码、测试报告、一页notes。这页notes必须包含:设计时忽略的边界条件(比如“没考虑用户注销后,其设备Token仍在缓存中”)、上线后监控发现的异常模式(比如“凌晨3点缓存击穿导致DB CPU飙升”)、以及下次迭代的改进项(比如“引入布隆过滤器预判空查询”)。这种写法,把笔记从“学习工具”变成了“工程资产”。它不追求完美,但追求真实——真实暴露认知盲区,真实记录试错成本,真实沉淀组织智慧。当你翻到某页notes,看到自己两年前写的“当时以为MySQL分库分表很复杂,结果用ShardingSphere配置5分钟搞定”,那种恍然大悟的羞愧感,比任何教程都管用。
2.3 为什么聚焦“Distributed Systems”?——单机思维是系统设计的最大陷阱
几乎所有初学者的系统设计失败,根源都在一个词:locality(局部性)。他们习惯性地认为“我的代码跑在一台机器上,所以一切尽在掌握”。但真实世界里,一台服务器宕机、一个网络分区、一次DNS解析失败,都是常态。这本notes刻意强化“分布式”视角,不是为了炫技,而是为了打破幻觉。比如讲“用户登录状态管理”,传统笔记会写“用Session存Redis”。而这本notes会先问:“如果Redis集群脑裂了,主从切换期间,用户token校验会失败吗?”然后展开:Redis哨兵模式下,failover时间通常在30-60秒,这期间所有依赖Redis的鉴权请求都会失败。解决方案不是“换更好的Redis”,而是重构状态模型——把token本身设计成自包含的JWT,签名由服务端私钥生成,校验只需公钥,彻底摆脱对Redis的强依赖。再比如“订单超时关闭”,常见做法是用Redis的EXPIRE命令。notes里会画一张时序图:用户下单→写DB→设Redis过期→超时触发→查DB确认订单状态→执行关闭。然后标红指出风险:“Redis过期事件不是实时的,可能延迟数秒甚至数分钟,导致已支付订单被误关”。解决方案是引入定时任务扫描+状态机驱动,用DB的update timestamp作为唯一可信源。这种写法,把每个技术点都放在“故障域”里审视:它依赖哪些外部组件?这些组件失效时,我的系统会怎样?有没有降级路径?有没有兜底方案?它不教你怎么用技术,它教你用技术去驯服不确定性。这也是为什么notes里大量出现“CAP理论”、“Paxos共识”、“ZooKeeper选主流程”这些看似“理论”的内容——它们不是考点,而是你判断“某个方案在分布式环境下是否可靠”的标尺。当你能脱口说出“ETCD的Linearizable Read为什么比Redis的Read Your Writes更安全”,你就已经超越了90%的面试者。
3. 核心细节解析与实操要点:从原理到落地的硬核拆解
3.1 Rate Limiter:不只是算法选择,更是流量治理的哲学
Rate Limiter常被简化为“控制请求频率”,但notes里把它拆解成三层:入口层、服务层、数据层。每一层的目标、约束、实现方式都截然不同,混用必出问题。
入口层限流(API Gateway):目标是“保护下游所有服务不被压垮”,约束是“低延迟、高吞吐、全局视角”。这里必须用分布式限流,且算法要支持动态规则。我实测过三种方案:
- Redis + Lua脚本(滑动窗口):QPS 5万时,P99延迟稳定在8ms,但Lua脚本复杂度高,规则变更需发版;
- Sentinel(阿里开源):规则热更新,支持QPS/并发数/线程数多维度,但依赖ZooKeeper做集群协调,运维成本高;
- 自研基于Consul的令牌桶:用Consul KV存储令牌池状态,客户端通过HTTP API申请令牌,服务端用CAS操作更新计数。优势是去中心化、无单点,但网络抖动会导致令牌申请失败。最终我们选了Sentinel,因为业务方需要“秒级生效”的限流规则调整能力,运维成本可通过封装平台降低。
提示:入口层限流绝不能只按IP限,必须叠加User-ID、App-Key、Endpoint三维度。曾有个案例:某App的“获取用户信息”接口被恶意调用,攻击者用1000个IP轮询,单IP QPS远低于阈值,但总QPS超限。加入App-Key维度后,立即识别出异常App。
服务层限流(微服务内部):目标是“保护自身核心逻辑不被拖慢”,约束是“低侵入、易配置、与业务逻辑解耦”。这里推荐Guava RateLimiter的本地令牌桶,但必须注意:它只适用于单JVM进程。我们曾在一个Spring Cloud服务里,为“计算用户积分”方法加了RateLimiter,结果在K8s集群里,每个Pod都有自己的令牌桶,完全起不到限流作用。解决方案是改用Resilience4j的Bulkhead + RateLimiter组合,通过配置中心下发全局QPS阈值,各Pod按比例分配本地令牌。
数据层限流(DB/Cache):目标是“防止慢SQL或缓存穿透打垮存储”,约束是“精准、不可绕过、与数据访问强绑定”。这里必须用SQL层面的限流,比如MySQL的
max_statement_time参数,或Redis的redis-cell模块(基于漏桶的原子限流)。特别注意:不要在应用层做“查缓存→没命中→查DB→写缓存”这一整条链路的限流,因为缓存穿透时,所有请求都会打到DB,限流点必须前置到“查缓存”之前,用布隆过滤器或空对象缓存兜底。
3.2 缓存设计:穿透、雪崩、击穿,不是三个名词,而是三类故障现场
notes里关于缓存的一页,贴着一张生产环境的Grafana截图:凌晨2:17,Redis CPU突增至95%,持续12分钟,伴随DB慢查询告警。下面写着复盘结论:“缓存击穿导致热点Key重建风暴”。这不是理论推演,是血泪教训。
缓存穿透(Cache Penetration):指查询一个根本不存在的数据,如ID=-1的用户。常规方案是“空值缓存”,但notes里强调两个致命细节:
- 空值缓存的TTL必须显著短于正常数据(比如正常用户缓存2小时,空值缓存5分钟),否则恶意请求填满缓存,导致真实数据无法写入;
- 必须用布隆过滤器(Bloom Filter)预判。我们用RedisBloom模块,初始化时将所有有效用户ID的Hash值写入,查询前先过Filter。实测下来,Filter误判率0.1%,但将穿透请求拦截率从35%提升到99.8%。关键参数:m=100MB内存,k=7个Hash函数,n=1亿用户ID。
缓存雪崩(Cache Avalanche):指大量Key同时过期,导致请求全部打到DB。常见错误是“统一设2小时过期”。notes里给出两种实战方案:
- 随机过期时间:在基础TTL上加一个0-30分钟的随机偏移,公式:
expire = base_ttl + random(0, 1800)。简单有效,但无法解决“全量缓存预热失败”这类极端情况; - 永不过期 + 后台异步更新:Key设为永不过期,用单独的定时任务(如Quartz)每隔10分钟扫描即将过期的Key,提前刷新。代价是增加后台任务复杂度,但彻底规避雪崩风险。我们选了后者,因为核心商品缓存一旦雪崩,直接影响GMV。
- 随机过期时间:在基础TTL上加一个0-30分钟的随机偏移,公式:
缓存击穿(Cache Breakdown):指热点Key过期瞬间,大量请求涌入重建缓存。notes里记录了一次惨痛经历:首页Banner图缓存2小时,到期时恰逢大促开始,10万QPS涌向DB,DB连接池耗尽。解决方案是“互斥锁重建”:
String key = "banner:home"; String lockKey = "lock:" + key; // 尝试获取锁 if (redis.set(lockKey, "1", "NX", "EX", 30)) { try { // 重建缓存 Object data = loadFromDB(); redis.setex(key, 7200, data); } finally { redis.del(lockKey); // 必须用finally确保释放 } } else { // 等待锁释放后重试,或返回旧缓存(降级) Thread.sleep(10); return getFromCache(key); }注意:锁的过期时间(30秒)必须大于缓存重建耗时,否则锁自动释放,多个线程同时重建。我们实测Banner重建耗时1.2秒,所以设30秒足够安全。
3.3 数据库分片:不是“分库分表”,而是“数据路由协议”的设计
notes里关于分库分表的章节,标题是《如何让数据自己找到家》。它不讲ShardingSphere配置,而是先画一张“数据路由决策树”。
第一步:确定分片键(Sharding Key)。常见误区是选“时间”或“自增ID”。notes里用电商订单举例:
- 选
order_id(雪花算法生成):全局唯一,但无法按用户查询,关联查询困难; - 选
user_id:天然支持“查用户所有订单”,但存在热点用户(如网红主播)导致单库压力过大; - 最终方案:
shard_key = user_id % 1024,但对超级用户(粉丝>100万)单独打标,路由到专用分片。这个“打标”逻辑,就是路由协议的一部分。
- 选
第二步:设计分片算法。notes里对比了三种:
- 取模(Mod):简单,但扩容需迁移数据;
- 一致性哈希:扩容只迁移部分数据,但热点不均;
- 虚拟节点一致性哈希:我们用的方案,在一致性哈希环上虚拟1000个节点,每个物理节点负责100个虚拟节点,大幅改善热点。计算公式:
virtual_node = hash(shard_key) % 1000,再映射到物理节点。
第三步:处理跨分片查询。notes里明确列出“禁止行为”:
- ❌
SELECT * FROM order WHERE create_time BETWEEN '2023-01-01' AND '2023-01-31'(全表扫描所有分片); - ✅ 改为
SELECT * FROM order WHERE user_id IN (1001,1002,1003) AND create_time BETWEEN ...(利用分片键定位); - ⚠️ 对于必须的时间范围查询,用Elasticsearch做二级索引,DB只存原始数据。
- ❌
第四步:保障分布式事务。notes里只推荐一种方案:Saga模式。用订单创建为例:
- 创建订单(本地事务)→ 2. 扣减库存(调用库存服务,异步消息)→ 3. 发送通知(调用消息服务)。每一步都提供补偿操作(取消订单、补回库存、撤回通知)。Saga协调器用状态机驱动,失败时自动执行补偿链。我们放弃Seata,因为其AT模式对DB锁的依赖,在高并发下成为瓶颈。
4. 实操过程与核心环节实现:从零搭建一个可面试演示的短链接系统
4.1 架构蓝图:用最简组件,覆盖所有核心考点
notes里这个短链接系统的架构图,只有5个核心组件:
- API Gateway(Nginx + OpenResty):负责HTTPS终止、限流、日志;
- Shortener Service(Go语言):核心业务逻辑,无状态;
- Cache Layer(Redis Cluster):存短码→长URL映射,TTL 7天;
- Storage Layer(MySQL 8.0):存原始URL、创建时间、访问统计,分库分表;
- Analytics Service(Flink):实时统计点击量,写入ClickHouse。
为什么不用Kafka?因为短链接生成是幂等写入,无需解耦;为什么不用MongoDB?因为强一致性和事务需求,MySQL更稳妥;为什么Flink不用Spark Streaming?因为毫秒级延迟要求,Flink的Event Time处理更精准。每一个取舍,都在notes里标注了决策依据和压测数据。
4.2 短码生成:Base62不是最优解,但它是工程平衡点
生成算法是面试高频题。notes里详细记录了三种方案的实测对比:
UUID v4:
- 优点:绝对唯一,无需协调;
- 缺点:32字符,太长(
https://t.co/550e8400-e29b-41d4-a716-446655440000),影响CDN缓存效率; - 压测:QPS 2万时,UUID生成耗时0.05ms,但网络传输+CDN缓存开销增加12%。
Snowflake:
- 优点:64位整数,可转Base62得6-7字符;
- 缺点:依赖时钟同步,时钟回拨会导致ID重复;
- 我们改造:去掉时间戳,用
worker_id + sequence生成,sequence用Redis INCR保证全局唯一,worker_id由注册中心分配。
Base62自增(最终方案):
- 算法:
counter = Redis INCR shortener:counter→short_code = base62(counter); - 优点:最短(1-6字符),人类可读,CDN友好;
- 关键优化:
- 预分配段:每次INCR 1000,缓存到本地内存,避免高频Redis调用;
- 防冲突:生成后先查Redis,若已存在则重试(概率<0.001%);
- 长度控制:当counter>10^12时,自动升级到7字符,平滑过渡。
- 压测:QPS 5万时,P99延迟1.2ms,Redis CPU<40%。
- 算法:
4.3 高并发跳转:从“302重定向”到“边缘计算”的演进
短链接跳转是性能瓶颈。notes里记录了三次架构迭代:
V1:纯后端重定向
流程:GET /abc → Shortener Service查Redis → 302 Redirect → 浏览器跳转。
问题:每次跳转都要经过后端,QPS上限受服务吞吐限制。压测显示,Go服务在QPS 3万时CPU达95%。V2:CDN边缘重定向
方案:将短码映射关系预热到CDN(如Cloudflare Workers),Worker脚本直接返回302。
优势:QPS提升至50万,后端压力归零;
劣势:缓存更新延迟(CDN TTL 1分钟),无法实时生效;
notes批注:“适合静态短链,不适合营销活动中的实时跳转”。V3:混合模式(当前生产方案)
- 热点短链(访问量>1000次/小时):自动预热到CDN,Worker返回302;
- 冷门短链:仍走后端,但后端加本地缓存(Caffeine),TTL 10秒;
- 实时更新:当管理员修改长URL时,主动调用CDN Purge API清除缓存。
效果:综合QPS 20万,P99延迟<50ms,CDN缓存命中率82%。
4.4 数据一致性:如何让“生成短链”和“写DB”永不丢数据
这是分布式系统最棘手的问题。notes里采用“可靠消息+本地事务表”方案:
- 生成短码后,不直接写DB,而是发一条Kafka消息(topic: shortener_create);
- Shortener Service启动一个消费者,监听此topic;
- 消费者开启本地事务:
BEGIN; INSERT INTO url_mapping (short_code, long_url, created_at) VALUES (?, ?, ?); INSERT INTO tx_log (msg_id, status) VALUES (?, 'PROCESSED'); -- 事务日志表 COMMIT; - Kafka消费位点提交,仅在事务成功后。
为什么不用RocketMQ事务消息?因为Kafka的Exactly-Once语义在0.11+版本已成熟,且我们已有Kafka生态。关键点在于tx_log表:它和业务表在同一DB,保证原子性。如果消费者崩溃,重启后会重新消费,但tx_log表会阻止重复插入(唯一索引)。notes里还记录了一个坑:Kafka消息体不能太大,我们把long_url做了MD5哈希存,实际URL存OSS,消息里只传哈希值和OSS地址。
5. 常见问题与排查技巧实录:那些没人告诉你的“脏活累活”
5.1 面试官最爱问的“如果……怎么办?”——真实故障场景还原
Q:如果Redis集群挂了,短链接服务还能用吗?
A:能,但降级为“只读模式”。notes里早有预案:- 短码生成:切到本地内存Counter(容量有限,但撑2小时没问题);
- 跳转查询:查不到缓存时,直接返回404(不查DB,避免DB被打垮);
- 监控告警:Redis不可用时,自动触发SOP,通知值班工程师,并在API Gateway返回自定义Header
X-Service-Mode: degraded。
实操心得:降级方案必须提前演练。我们每月做一次“Redis故障注入”,用Chaos Mesh随机kill一个Redis Pod,验证降级是否生效。第一次演练,发现本地Counter在K8s滚动更新时丢失,后来改用StatefulSet + PVC持久化。
Q:如何应对恶意短链攻击(如生成10亿个短链占满存储)?
A:三重防护:- 入口限流:API Gateway对
POST /shorten接口,按IP+App-Key限流100次/分钟; - 内容审核:调用第三方API(如Google Safe Browsing)检查long_url是否含恶意域名,命中则拒绝;
- 存储隔离:恶意用户生成的短链,存入独立DB分片,不影响正常用户。
notes里附了拦截日志截图:某IP在30秒内尝试生成2000个短链,全部被限流拦截,且其请求的long_url域名被Safe Browsing标记为钓鱼网站。
- 入口限流:API Gateway对
Q:如何保证短链统计的准确性?
A:放弃“绝对准确”,追求“业务可用”。notes里明确:- 实时统计(Flink):用于运营大屏,允许1%误差;
- 离线统计(Spark):每日跑MR任务,修正实时数据,用于财务结算;
- 关键指标(如付费转化):在跳转前埋点,用前端JS上报,绕过CDN缓存。
注意:不要试图用Redis HyperLogLog做UV统计,它误差率0.8%,对千万级UV意味着8万误差。我们用BitMap+HLL混合方案,UV误差<0.1%。
5.2 工具链避坑指南:那些让你加班到凌晨的“小细节”
压测工具选型:
- JMeter:适合HTTP协议,但分布式压测配置复杂,我们弃用;
- wrk:轻量,QPS高,但不支持复杂场景(如带Cookie登录态);
- Locust(Python):最终选择。notes里写了定制化脚本:
关键参数:class ShortenerUser(HttpUser): @task def shorten(self): # 模拟真实用户行为:先登录,再生成短链 self.client.post("/login", json={"user": "test", "pwd": "123"}) self.client.post("/shorten", json={"url": "https://example.com/"})--users 1000 --spawn-rate 100(每秒启动100用户),避免瞬时冲击。
Redis监控:
redis-cli info只能看概览,notes里推荐redis-exporter + Prometheus,重点关注:redis_memory_used_bytes:内存使用率 >80%预警;redis_connected_clients:连接数突增,可能是客户端泄漏;redis_expired_keys_total:每秒过期Key数,突增说明缓存设计有问题。
MySQL慢查询定位:
- 开启
slow_query_log,但notes里强调:必须设置long_query_time=0.1(默认1秒),否则漏掉大量0.5秒的“伪慢查询”; - 用
pt-query-digest分析日志,重点关注Rows_examined字段,超过1000行就要优化索引。
- 开启
5.3 面试表达技巧:如何把“我做过”变成“我懂为什么”
notes里专门有一节《面试话术模板》,不是教你背答案,而是训练结构化表达:
当被问“如何设计XX?”:
不要说“我用Redis缓存”,要说:“首先定义核心指标:短链接生成QPS峰值5万,P99延迟<100ms。基于此,我判断瓶颈在存储层写入,所以优先优化写路径。方案一:用Redis做写缓冲,但Redis持久化可能丢数据;方案二:用Kafka解耦,但增加延迟。最终选择方案二,因为业务允许秒级延迟,且Kafka的ACK机制能保证不丢数据。具体实现是……”
当被问“为什么选A不选B?”:
不要说“B不好”,要说:“B方案在单机场景下更简单,但我们的部署是K8s集群,B依赖本地文件存储,无法跨Pod共享。而A方案(如Consul)是分布式KV,天然支持集群,虽然运维成本高10%,但避免了架构腐化风险。”
当被问“如果出问题怎么排查?”:
不要说“看日志”,要说:“我会按‘网络→服务→存储’三层排查:先用
curl -v测API网关连通性;再查Shortener Service的Prometheus指标,看HTTP 5xx错误率和GC时间;最后查Redis的INFO stats,看rejected_connections是否突增。上周线上问题就是通过这三步,15分钟定位到是Redis连接池耗尽。”
我在实际带人过程中发现,真正拉开差距的,从来不是技术深度,而是表达深度。能把一个技术选择背后的trade-off讲清楚,比堆砌十个技术名词更有说服力。这本notes,就是我用来训练这种表达能力的沙盘。它不承诺让你“秒过面试”,但它保证,当你合上它时,你不再害怕那个问题:“为什么?”