目录
- 一、开篇:两个极端
- 二、判断依据:不要拿用户数当触发条件
- 三、阶段 1:单服务器(0–100 用户)
- 四、阶段 2:把数据库拆出去(100–1K 用户)
- 五、阶段 3:负载均衡与水平扩展(1K–10K 用户)
- 六、阶段 4:缓存与 CDN(10K–100K 用户)
- 七、阶段 5:读扩展、分片与异步(100K–1M 用户)
- 八、阶段 6:微服务与可观测性(1M–10M 用户)
- 九、阶段 7:多区域与单元化(10M+ 用户)
- 十、贯穿全篇的底层原则
- 十一、常见误区与反例
- 十二、面试怎么答这类题
- 十三、自检清单
- 附:延伸阅读
一、开篇:两个极端
在讲任何架构模式之前,我想先摆两个真实案例。它们走的是完全相反的两条路,但都成功了。理解了这一点,你就不会把后面的七个阶段当成必须打卡的流程图。
Instagram:14 台机器,1400 万用户
2010 年 10 月,Instagram v1 发布前夜,两位创始人 Kevin Systrom 和 Mike Krieger 打了个赌——预测第一天会有多少人下载。Kevin 猜 2,500,Mike 猜 25,000。
实际结果正好落在 Mike 的猜测上。第一天的流量大到那台小服务器直接崩溃,凌晨三点的服务器告警变成了常态而非例外。此后一路狂飙:第一周约 10 万用户,三个月 100 万。
起步时他们用的是一台位于洛杉矶的单台服务器,算力还不如一台 MacBook Pro。第一天之后,创始人打电话给主机商要加机器,对方报价四天交付,加急也要 48 小时。面对不可预测的增长曲线,这个周期完全无法接受,于是他们做了一个关键决定:整体迁到 AWS。
到 1400 万用户时,整个后端只有 14 台 Amazon EC2 实例,工程师三个人左右。完整的技术选型是这样的:
- 负载均衡:Amazon ELB 前置 3 台 Nginx
- 应用层:Django + Gunicorn,跑在 25 台以上 High-CPU Extra Large 实例上,且无状态——所以可以随时按需加机器
- 数据库:PostgreSQL(选它部分是因为需要 PostGIS 的地理数据支持),配 PgBouncer 做连接池
- 图片:存 Amazon S3,走 CloudFront 分发
- 缓存:Redis 存了 3 亿个「照片 ID → 用户 ID」的映射,总大小不到 5GB;另有 6 个 Memcached 实例做通用缓存
- 任务队列:Gearman 配约 200 个 Python worker,做 fan-out,比如把新照片推给粉丝
- 监控:Sentry 收错误、Munin 收系统指标、Pingdom 做外部探测、PagerDuty 管告警
值得单独拎出来说的是他们的数据库演进路径,因为它几乎完美对应了本文后面要讲的几个阶段:
graph LR
A["单机<br/>App + DB 同机"] -->|"照片数据超过 68GB<br/>(当时最大 EC2 的内存上限)"| B["DB 独立成机"]
B -->|"Django DB Router"| C["垂直分区<br/>按业务拆表"]
C -->|"继续增长"| D["水平分片"]
style D fill:#e6ffe6
最后一步是精髓所在。他们没有等到「必须扩容」时才去迁移数据,而是一开始就切成上千个逻辑分片,用 PostgreSQL schema 实现,再把这些逻辑分片映射到较少的物理机上。负载涨了,只需要把一部分逻辑分片搬到新机器——迁移单位很小,风险可控。
还有一个细节值得记下来,因为它是分布式 ID 生成的经典解法。Instagram 的照片 ID 是这样构造的,总共 64 位:
| 41 位时间戳(毫秒) | 13 位逻辑分片 ID | 10 位自增序列(mod 1024) |
这本质上就是雪花算法的变体。Twitter Snowflake 用的是「1 位符号 + 41 位毫秒时间戳 + 10 位机器 ID + 12 位序列」,Instagram 把机器位扩到 13 位来容纳它那上千个逻辑分片,序列位压缩到 10 位——意味着每毫秒每分片最多生成 1024 个 ID,对照片上传这个场景绰绰有余。
这个设计的三个好处,面试和实战都用得上:ID 天然按时间递增,所以主键索引的写入是顺序的,不会造成 B+ 树页分裂(这一点可以和 笔记的笔记Mysql.md 里 B+ 树的结构对照着理解);不需要中心化的发号器服务,每个分片自己就能生成;ID 里带着分片信息,从 ID 就能反推出数据在哪个分片,查询时不必广播。
Instagram 团队的信条就一句话:「Do the simple thing first」。当时只有两三个工程师,他们面对每个新问题都选择最快、最简单的修复方案,而不是试图为未来做全面防御。两位创始人也谈不上有深厚的基础设施经验,知识来自 QCon 和 Velocity 大会的视频,以及 Facebook、Netflix、Twitter 公开的技术文章。
Stack Overflow:9 台服务器,20 亿请求/月
现在换一条完全相反的路。
Stack Overflow 至今跑在自己的机房里,不上云。一个 .NET 单体应用,9 台 Web 服务器,承载整个 Stack Exchange 网络 175 个以上的站点。吞吐量约每秒 6,000 请求,每月 20 亿请求。服务器负载只有 5%–10%——理论上整个应用能跑在 1 台 Web 服务器上。数据库是 SQL Server,内存 1.5TB,没有缓存层;问答数据集约 4.5TB,其中三分之一常驻内存。报道时他们已经有两年没买过新机器。
(更早的架构数据是 25 台服务器:11 台 IIS、2 台 HAProxy、4 台 MSSQL、3 台标签引擎、3 台 Elasticsearch、2 台 Redis,承载月页面浏览 5.6 亿。)
他们的技术哲学可以浓缩成一句话:硬件比程序员便宜。
具体表现是:垂直扩展而非水平扩展;反复评估过云,多次拒绝,理由是成本、延迟,以及无法复制他们单跳的 10Gb 内网;拒绝 Kubernetes 和微服务,理由朴素得近乎挑衅——「我们没有遇到它们要解决的问题」,而且部署速度从来不是他们的痛点。如果改用标准的水平扩展方案,这个流量需要 100 到 300 台服务器。
他们公开过的一个事故复盘特别值得学。某次 9 台 Web 服务器中的两台(ny-web01、ny-web04)把数据库连接池耗尽了好几个数量级,根因链条是这样的:
- 每台服务器当时处理 200–500 req/s,所有 Q&A 请求共用一个应用池
- IIS 线程池饥饿,导致异步 continuation 停止,形成类死锁
- 负载均衡器 HAProxy 却没有摘除这两台故障机——因为 ASP.NET 的错误会 302 重定向到
/error,而 HAProxy 把 302 当成了成功响应 - 直到严重超时之后,HAProxy 才把它们摘掉
修复方式是配置 HAProxy,用正则显式声明只接受哪些状态码。
这个事故是「健康检查该怎么做」的完美反面教材。常见的错误做法有三种:只探测 TCP 端口通不通,探测不出「进程活着但已经卡死」;只看是不是 HTTP 200,于是像 Stack Overflow 这样错误页返回 302 也被当成健康;健康检查路径走了完整业务逻辑,让探测本身变成了负载。正确的做法是——显式声明成功状态码的集合,而不是「非 5xx 就算好」;检查内容要覆盖关键依赖(数据库连接池是否可用、缓存是否可达),而不只是「进程还在」;区分 liveness(要不要重启)和 readiness(要不要接流量),连接池耗尽应该让 readiness 失败、摘掉流量,而不是让 liveness 失败、去重启;再加上连续失败 N 次才摘除的防抖。
两个案例的共同点
Instagram 和 Stack Overflow 的架构选择几乎处处相反,但底层逻辑完全一致:
他们都只解决自己实际遇到的问题。
Instagram 的瓶颈是「流量涨得太快,单机扛不住」,所以他们上云、做无状态、加缓存。Stack Overflow 的瓶颈从来不是「机器不够」,而是「如何让 .NET 应用少分配内存、避免 GC 停顿」,所以他们把精力花在了代码层面的极致优化上,而不是架构层面的拆分上。
Stack Overflow 的存在恰好证明了:那七个阶段不是物理定律,而是一组「当你遇到对应瓶颈时的标准解法」。如果你压根没遇到那个瓶颈,就不需要那个解法。
但这里有个必须说清的认知偏差
Instagram 的案例常被用来论证「不用过度设计」,这没错。但幸存者偏差在于:我们只看到了成功的那个。同期同样选择单机起步、随需扩展的其他几千个应用,很多在流量暴涨时直接挂了,然后就没有然后了。
Instagram 能这么干成功,有几个隐含条件:照片分享是读多写少、静态资源为主的业务,天然是缓存的甜点区;迁移 AWS 的决定做得足够早——第一天发现单机扛不住就立刻上云,而不是硬撑;代码留了缝,Django 的 DB Router 机制让后来的垂直分区和水平分片成为可能;再加上一点运气,增长曲线虽然陡,但给了他们几天时间做反应。
所以正确的结论不是「不用设计」,而是「设计那些让你能快速改的东西」。留缝,不留组件。
二、判断依据:不要拿用户数当触发条件
后面的章节会用「0–100 用户」「100–1K 用户」这样的区间来组织。但这些数字只是方便叙述的锚点,不是触发条件。
用户数是个非常粗糙的代理指标。真正决定架构形态的是下面这些量:
| 真正的驱动指标 | 为什么比用户数重要 |
|---|---|
| QPS / 峰值 QPS | 1 万个沉睡用户和 1 万个每分钟都在刷的用户,负载差 100 倍 |
| 读写比 | 读多写少,缓存和读副本收益极高;写多读少,只能靠分片 |
| 数据量级 | 10GB 数据可以整个塞进内存,10TB 就不行,分水岭完全不同 |
| 延迟要求 | P99 < 100ms 和 P99 < 2s 是两套架构 |
| 一致性要求 | 能接受最终一致,缓存和多区域随便上;要强一致,处处掣肘 |
| 单条数据大小 | 存文本和存 4K 视频,存储层设计毫无可比性 |
把用户数当成「该考虑下一阶段了」的提示音,而不是触发条件。真正的触发条件应该是你在监控上实际观测到的瓶颈指标——这一点后面每一章都会具体展开。
理解了这个前提,我们开始。
三、阶段 1:单服务器(0–100 用户)
这个阶段在解决什么
刚起步时,你的首要任务只有一个:把东西做出来,验证你的想法。这个阶段做优化,是把时间和钱浪费在你可能永远不会遇到的问题上。
最简单的架构就是把所有东西放在一台机器上——Web 应用、数据库、后台任务,全部同机。
graph TB
U["用户"] -->|HTTPS| N["Nginx<br/>(SSL 终止 / 反向代理)"]
N --> W["Web 应用<br/>Django / Rails / Express / Spring Boot"]
W --> DB[("数据库<br/>PostgreSQL / MySQL")]
W --> Q["后台任务<br/>Sidekiq / Celery"]
Q --> DB
subgraph S["单台 VPS($20–50/月)"]
N
W
DB
Q
end
style S fill:#f5f5f5,stroke-dasharray: 5 5
具体来说就是:一个 Web 框架处理 HTTP 请求,一个数据库存数据,一个后台任务处理器跑异步任务,前面可能挂一个 Nginx 做 SSL 终止。全部跑在一台虚拟机上,账单每月 20 到 50 美元。
这里有个容易被忽略的观察:这个组合里的每一件,都是「可以删掉但暂时不用删」的。
反向代理没有,应用框架自己也能监听 443、自己做证书;加它只是因为证书续期、静态文件、gzip 这些杂活不该由业务代码干。后台任务队列没有,发邮件直接在请求里同步做也能跑通,只是慢 200 毫秒;加它是因为任务一多请求链路会被拖死。
换句话说,阶段 1 的架构不是「因为简单所以好」,而是「每个组件都留着被拆出去的缝」。
这才是这个阶段真正该做对的事。如果一开始就把数据库逻辑和业务逻辑搅在一起、把 session 存在本地文件、把状态写在进程内存里,后面拆的时候就要重写。留缝,不留组件。
简单带来的五件事
在这个阶段,简单就是你最大的优势,具体体现在五个方面。部署快——一台服务器意味着只有一个地方要部署、监控和排查。成本低——每月二三十美元的 VPS 就能扛住前 100 个用户。迭代快——没有分布式系统的复杂度拖慢开发。便于排查——所有日志都在一个地方,组件之间不存在网络问题。全链路可见——每个请求都能端到端追踪,因为只有一条执行路径。
最后一条被严重低估了。在分布式系统里,查一个「用户下单失败」的问题,你需要关联八个服务的日志(靠 traceId)、确认是哪个环节超时、排查是不是网络抖动、还得考虑是不是某个服务的某个实例的问题。这个过程的耗时,在单体系统里是 5 分钟,在微服务里可能是 5 小时。
早期团队最稀缺的资源不是服务器,是注意力和时间。任何让「发现问题 → 定位问题 → 修复问题」这条链路变长的架构决策,在早期都是负收益。
你付出的代价
这些代价不是「没想到」,而是「知道但仍然接受」:
| 权衡 | 具体代价 | 什么时候会咬你 |
|---|---|---|
| 单点故障 | 机器挂了 = 服务全挂 | 从第一天起就存在,只是早期用户少,影响面小 |
| 无法水平扩展 | 只能垂直加配置,有物理上限 | 数据库先撞墙(内存装不下索引、磁盘 IOPS 打满) |
| 部署即停机 | 重启服务 = 服务中断 | 用户开始觉得「这网站怎么老在维护」 |
| 资源争抢 | App 和 DB 抢 CPU/内存/IO | 一条后台大查询能让全站 API 变慢 |
| 无隔离 | 后台任务崩溃拖垮 Web 进程 | 一次内存泄漏 = 全站 502 |
该往前走的五个信号
这是这个阶段最实用的部分。什么时候说明单机撑不住了?有五个信号。
信号一,高峰期数据库查询变慢。 根因是应用和数据库在抢同一份 CPU 和内存,一条重查询就能把所有人的 API 延迟一起拖垮。怎么确认?看数据库的慢查询日志加上服务器的 load average——如果每次慢查询出现时,Web 应用的 P99 延迟也同步抬头,那就是资源争抢实锤了。
信号二,服务器 CPU 或内存持续超过 70%–80%。 你正在接近单台机器可靠承载的极限。为什么要留 20%–30% 的余量?因为机器满载时的行为是非线性的。CPU 到 90% 之后,请求队列开始堆积,延迟会从「慢一点」变成「雪崩式恶化」;内存用到 swap 时,性能瞬间掉一个数量级。70%–80% 是「还有反应时间」的警戒线,不是极限。
信号三,部署需要重启并造成停机。 哪怕很短的中断都开始被用户察觉,抱怨随之而来。
信号四,后台任务崩溃把 Web 服务器一起拖死。 没有隔离,非面向用户的工作会影响到用户体验。这条特别典型:一个导出报表的任务吃了 8GB 内存,OOM Killer 出手,它挑了个占内存最大的进程杀掉,恰好是你的 Web 进程,全站 502——而用户完全没在跑报表。
信号五,你连短暂停机都承受不起。 产品已经重要到连维护窗口都变得不可接受。
注意第五条的性质和前四条不同。前四条是技术指标,这条是业务指标。它提醒你:架构决策从来不只是技术问题。「凌晨三点发版」这种方案,在 B 端企业内部系统可能完全可接受,在 C 端社交产品可能就是灾难。先问业务能不能忍,再问技术怎么改。
四、阶段 2:把数据库拆出去(100–1K 用户)
瓶颈从哪来
上一章列的五个信号里,前两个的根因是同一个:应用和数据库挤在一台机器上,争抢同一份 CPU、内存和磁盘 IO。
一条重查询足以让全站的 API 延迟集体抬头——这不是猜测,而是资源争抢的必然结果。数据库要做大范围的磁盘扫描,会吃掉大量 IO 和内存带宽,而同一台机器上的 Web 进程正等着这些资源来响应请求。
于是第一个架构拆分就很朴素了:让数据库搬出去。
拆出去之后,两侧的扩展曲线就分开了
这是拆分最深层的理由,比「官方推荐这么做」重要得多。
应用服务器的扩展基本是线性的:无状态之后,流量翻一倍就多加一倍机器,成本线性增长。数据库则是非线性的:数据量翻倍,索引深度加一,B+ 树查询可能从 3 次 IO 变成 4 次;更关键的是,当索引体积超过可用内存,性能会断崖式下跌——从内存命中变成磁盘随机读,延迟差几个数量级。
结果就是:应用服务器一直是「多而小」,很便宜;数据库一旦要升级就是「少而贵」,一台顶配机器的价格可能超过几十台应用服务器。
把它们捆在同一台机器上,等于强迫两者共用一条扩展曲线。
拆开之后的具体收益有五项:资源隔离,应用和数据库不再抢 CPU 和内存,各自能用满自己那份;独立扩展,升级数据库不必动应用服务器;更好的安全性,数据库可以放进私有网络,不对公网暴露;针对性调优,应用服务器偏重 CPU 而数据库偏重 IO,各自按需配置;备份也更省心,数据库备份不再影响应用性能,因为跑在不同机器上。
用托管还是自建
这个阶段的绝大多数团队会用托管数据库——Amazon RDS、Google Cloud SQL、Azure Database,或者 Supabase 这类。
托管服务替你处理掉的事情包括:自动备份(每日快照、时间点恢复)、安全补丁和更新、基础的监控告警、可选的只读副本、以及故障时切换到备用实例。
一旦把工程时间算进去,自建和托管之间的成本差异通常很小。 一个托管 PostgreSQL 实例可能比裸虚拟机每月贵 50 到 100 美元,但它每周能省下好几小时运维——那些时间拿去发版更有价值。
真正该自建的理由只有四个:超大规模下的成本优化;托管服务不支持的特定配置;合规要求禁止使用托管;以及你自己就在做数据库产品。
把这个判断换算成更直观的判据:
| 你的情况 | 建议 |
|---|---|
| 没有专职 DBA / SRE | 无条件选托管,自建省的那点钱抵不上一次误删数据的事故 |
| 有 1 个兼职运维的工程师 | 托管,除非账单超过每月 3,000 美元 |
| 有专职 DBA 团队且账单超过每月 10,000 美元 | 可以开始算自建的账 |
| 核心业务且停机成本极高 | 托管 + 多可用区,别省这个钱 |
反向的判断更简单:如果你问「要不要自建数据库」的时候还需要犹豫,答案就是托管。
连接池:这个阶段最容易被忽视的一环
数据库拆出去之后,有个问题会立刻浮现,而且很多团队会跳过它——连接本身是有成本的。
PostgreSQL 里每个连接要吃掉 5 到 10MB 内存,还要占应用服务器和数据库服务器两边的文件描述符,以及连接管理的 CPU 开销。而且有个关键事实:PostgreSQL 的每个连接对应一个独立的操作系统进程,不是线程。 这意味着 500 个连接大约消耗 5GB 内存,只是为了维持连接本身——这笔开销不取决于连接是否活跃,空闲连接照样占着。
新建连接同样昂贵。算上 TCP 握手、SSL 协商和数据库认证,每次请求可能多出 50 到 100 毫秒的开销。
把这 50 到 100 毫秒放进具体语境里看:如果你用短连接,每个请求的用户感知延迟里有 50 到 100 毫秒纯粹花在「建立连接」这件和业务无关的事上。对一个 P99 目标 200 毫秒的服务来说,这是灾难性的。
解决办法是用连接池。PgBouncer 这样的池化工具会保持一小组数据库连接常开,并在请求之间复用它们。
graph TB
subgraph App["应用层:100 个并发连接"]
C1["请求 1"]
C2["请求 2"]
C3["..."]
C4["请求 100"]
end
P["连接池<br/>PgBouncer"]
subgraph DB["数据库:仅 20–30 个真实连接"]
D1["连接 1"]
D2["连接 2"]
D3["..."]
D4["连接 30"]
end
C1 --> P
C2 --> P
C3 --> P
C4 --> P
P --> D1
P --> D2
P --> D3
P --> D4
style P fill:#ffe6e6
有 1000 个用户时,你的 API 可能有 100 个并发连接。没有连接池,那就是 100 个真实数据库连接在消耗资源;有了池化,20 到 30 个真实连接就能通过复用高效地服务那 100 个应用连接。
三种池化模式,以及它们的代价
| 模式 | 归还时机 | 特点 | 适用场景 |
|---|---|---|---|
| Session pooling | 客户端连接关闭时 | 兼容性最好,效率最低——客户端独占一个后端连接 | 迁移脚本、管理工具、需要会话状态的场景 |
| Transaction pooling | 每个事务结束后 | 最佳平衡 | 绝大多数 Web 应用(默认推荐) |
| Statement pooling | 每条语句后 | 效率最高,但一个事务里的多条语句可能落到不同后端 | 几乎不用,仅限明确不支持事务的场景 |
多数应用最适合 transaction pooling,它通常能把连接效率提升 3 到 5 倍。
但这里有个必须提前知道的坑,因为它会在你上线之后才咬你。Transaction 模式的本质约束是:任何活在事务边界之外的东西都不会保留。 具体会失效的特性包括:
| 特性 | 在事务模式下的行为 |
|---|---|
会话级 SET | 在事务之间丢失,search_path、statement_timeout、自定义 GUC 会静默还原 |
SQL 级 PREPARE / EXECUTE | 直接报错 prepared statement X does not exist |
| 协议级预处理语句 | PgBouncer 1.21+ 配合 max_prepared_statements > 0 可用 |
| 事务外创建的临时表 | 丢失,报 relation does not exist |
会话级咨询锁 pg_advisory_lock | 变成孤儿锁——锁在某个后端上,永远不会释放 |
LISTEN | 通知无法可靠投递 |
WITH HOLD 游标 | 提交时游标状态丢失 |
最危险的地方在于,这些故障是间歇性的,只在并发下出现。 因为失败与否取决于下一个事务恰好落到哪个后端连接上——单用户测试全部通过,PgBouncer 自身的指标也完全健康,报错出现在 SQL 层而非连接池层。这是典型的「测试环境查不出来、一上生产就炸」。
预处理语句是这里面最大的坑。PgBouncer 1.21 之前,事务模式下任何预处理语句在并发下都会失败,因为它们绑定在服务端会话上而不是事务上。1.21 引入了 max_prepared_statements 来追踪并重放协议级 prepare,1.24 起默认开启、默认值 200。但仍有两个陷阱:SQL 级的 PREPARE foo AS ... PgBouncer 看不见,依然会坏;部分驱动也不兼容(比如 PHP/PDO,除非 PHP 8.4+ 配 libpq 17)。
对应的绕法分几层:
- 应用层:用
SET LOCAL替代会话级SET(事务作用域,能正确存活,注意server_reset_query救不了你);用pg_advisory_xact_lock替代pg_advisory_lock;临时表的创建和删除放在同一个事务内;LISTEN/NOTIFY走专用非池化连接 - 驱动层:直接关掉预处理语句走简单查询协议(如 JDBC
prepareThreshold=0加preparedStatementCacheQueries=0),代价是 Postgres CPU 增加约 3% 到 7%,但对多数团队是最务实的选择;或者在连接取出时执行DEALLOCATE ALL,每事务大约增加 0.5 毫秒 - 配置层:按库或按用户覆盖
pool_mode,全局默认 transaction,把迁移工具和运维库单独设成 session - 架构层:会话相关的负载(迁移、schema 探查、
pg_dump)走直连,不经过池
池该开多大:一个反直觉的答案
这里有个反直觉的点,也是面试常被问到的:连接池不是越大越好。
PostgreSQL 官方 Wiki 给的公式是 最优连接数 = (CPU 核数 × 2) + 有效磁盘数。比如 8 核配 SSD 的实例,算出来大约是 17 个连接。经验法则是不超过 4 倍 CPU 核数。
为什么多了反而慢?因为 PostgreSQL 每个连接是一个进程,连接数一多,操作系统调度开销剧增(上下文切换),锁竞争加剧;最要命的是每个连接都有自己的查询计划缓存和工作内存,总连接数越多,单个连接能分到的缓存越少,命中率就下降,磁盘 IO 随之上升,整体更慢。
所以正确的方向是减少连接数、提高单连接的利用率,而不是「数据库慢就把池调大」。
还有两个生产环境特有的陷阱值得记住。第一个是 idle in transaction——应用开着事务去干非数据库的活(调外部 API、做计算),整个期间占着池槽不放。这是事务模式的头号隐形杀手,而且 PgBouncer 没有直接告警;检测方法是看 avg_xact_time 是否远大于 avg_query_time,或者直接查 PostgreSQL 里处于 idle in transaction 状态的后端,并给数据库设置 idle_in_transaction_session_timeout。第二个是会话粘连——事务块外的一个 SET 就能把连接粘住整个客户端会话的生命周期,而且没有任何警告,有报告称生产环境因此损失高达 60% 的有效池容量。
⤷ 连接池解决的是「连接建立成本」,而慢查询本身要靠 SQL 层面解决。定位手段见 笔记的笔记Mysql.md 的「慢查询定位」一节——开启慢查询日志设
long_query_time阈值,用EXPLAIN看type、rows、Extra字段。索引层面避免回表、用覆盖索引,能显著减少单次查询的成本。
网络延迟:拆库之后才暴露的老问题
拆分数据库会引入网络延迟。应用和数据库同机时,「网络」延迟基本是零,因为走的是 loopback 回环接口;拆开之后,每次查询会多出 0.1 到 1 毫秒的网络往返时间。
对多数应用来说这可以忽略。但如果你的代码每个请求要打几百次数据库查询——这是反模式,但很常见——这个延迟就会累积。
解法不是把它们搬回同一台机器,而是优化查询模式:能批量就批量,用 JOIN 替代 N+1,缓存频繁访问的数据,以及用连接池避免反复建连接。
算一笔账,感受一下 N+1 的杀伤力。假设同机部署时单次查询 0.2 毫秒(纯计算加本地 IO),拆机后单次查询增加 0.5 毫秒网络往返:
| 场景 | 同机 | 拆机后 | 变化 |
|---|---|---|---|
| 1 次 JOIN 查询 | 0.5ms | 1.0ms | +0.5ms(可忽略) |
| 100 次 N+1 查询 | 20ms | 70ms | +50ms(3.5 倍) |
| 500 次 N+1 查询 | 100ms | 350ms | +250ms |
N+1 问题在单机时代可能被本地 IO 的高速掩盖了,一拆库就原形毕露。 很多团队「拆完数据库发现变慢了」,根因不是架构错了,而是 N+1 一直存在,只是以前不疼。
所以拆库之前,先跑一遍慢查询日志和 ORM 的查询计数。这一步做完,可能你就不需要拆了。
新制造出来的问题
数据库独立成服务器后,你为自己争取到了成长空间,但也制造了一个新的单点故障:应用服务器现在是薄弱环节。它宕机会怎样?或者它单纯扛不住流量了又会怎样?
这就是「瓶颈会移动」的第一次体现。上一阶段的瓶颈是资源争抢,拆完数据库后,瓶颈变成了「应用服务器是单点,且有单机吞吐上限」。
五、阶段 3:负载均衡与水平扩展(1K–10K 用户)
先做无状态化,再谈加机器
很多人一上来就去装 Nginx 配负载均衡,然后发现用户一刷新就掉登录态。根因是:应用还有状态。
「有状态」有几种常见形态,每一种都是水平扩展的敌人:
| 状态类型 | 存在哪 | 会出什么问题 | 处理方案 |
|---|---|---|---|
| 用户会话 | 应用进程内存 | 请求被分到别的机器就掉登录 | 外置到 Redis、改用 JWT,或做会话粘性 |
| 本地文件上传 | 应用服务器磁盘 | A 机器上传的文件 B 机器读不到 | 换成对象存储 |
| 本地缓存 | 应用进程内存 | 各机器缓存不一致,命中率随实例数下降 | 换成集中式缓存 |
| 定时任务 | 每台机器都跑 | 任务被重复执行 N 次 | 分布式锁、独立调度服务、选主 |
| WebSocket 连接 | 应用进程 | 连接绑在特定实例上 | 粘性路由 + 消息总线 |
会话外置有三种常见方案,各有取舍:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 会话粘性 | 改动最小,不用改代码 | 治标不治本——该实例挂了上面的用户全掉线;扩容时分布不均;本质上还是有状态 |
| Redis Session | 真正无状态,可主动踢人(删 key 即可) | 多一次 Redis 调用;Redis 成了关键依赖 |
| JWT | 完全无状态,不依赖任何存储 | 无法主动失效(只能等过期);token 体积大;需要额外的刷新机制 |
实践中的主流是 Redis Session 存核心会话,配合短期 JWT 做无状态鉴权。
⤷ Redis 作为会话存储的高可用问题(主从、哨兵脑裂、分片集群)见 笔记的笔记redis.md 的「Redis 集群」一节。特别注意:会话存储是「Redis 挂了全站掉登录」的关键依赖,生产上至少要用哨兵模式,最好是分片集群。
做完无状态化,加机器才是线性的。
架构变成什么样
graph TB
U["用户"] --> DNS["DNS / Anycast"]
DNS --> LB["负载均衡器"]
LB --> A1["应用实例 1<br/>(无状态)"]
LB --> A2["应用实例 2<br/>(无状态)"]
LB --> A3["应用实例 3<br/>(无状态)"]
A1 --> R[("Redis<br/>会话 / 缓存")]
A2 --> R
A3 --> R
A1 --> DB[("数据库")]
A2 --> DB
A3 --> DB
LB -.->|健康检查| A1
LB -.->|健康检查| A2
LB -.->|健康检查| A3
style LB fill:#ffe6e6
style R fill:#fff0e6
负载均衡器怎么选
四层和七层的区别,本质是「能看多深」。
| 维度 | L4(传输层) | L7(应用层) |
|---|---|---|
| 看得到 | IP + 端口 | HTTP 路径、Header、Cookie、Body |
| 典型实现 | LVS、AWS NLB、HAProxy(TCP 模式) | Nginx、HAProxy(HTTP 模式)、AWS ALB |
| 性能 | 极高,只改包头,可内核态转发 | 较高,需要解析 HTTP |
| 能力 | 无法做内容路由 | 可按 /api、/static、Host 分流,可改写 Header、做限流 |
| 适用 | 极高性能需求、非 HTTP 协议 | 绝大多数 Web 应用 |
均衡算法的选择上,「最少连接」通常比「轮询」好,原因值得说清楚。轮询假设所有请求耗时相同,但现实中一个导出报表的请求可能跑 30 秒,一个查询接口只需 10 毫秒。轮询会把长请求均匀地摊到所有机器上——听起来公平,实际是让已经背着慢请求的机器继续接新请求,延迟长尾随之恶化。最少连接会把新请求导向「手上活最少」的机器,显著改善 P99。
⤷ 如果服务之间是 Spring Cloud 体系内的调用,负载均衡由 Spring Cloud LoadBalancer 在客户端完成(开箱只有轮询和随机,加权、灰度要自定义选择器),见 Java - 微服务篇 的「服务间调用与负载均衡」一节。注意区分:这里是服务间的内部调用,而 Nginx/ALB 面对的是外部流量入口。
健康检查的做法值得单独强调,因为 Stack Overflow 那次事故就是栽在这上面。三条原则:显式声明成功状态码的集合,而不是「非 5xx 就算好」;检查内容要覆盖关键依赖,比如数据库连接池是否可用、缓存是否可达;区分 liveness(要不要重启)和 readiness(要不要接流量),连接池耗尽应该让 readiness 失败去摘流量,而不是让 liveness 失败去重启。
垂直扩展还是水平扩展
| 维度 | 垂直扩展 | 水平扩展 |
|---|---|---|
| 做法 | 换更好的机器 | 加更多机器 |
| 上限 | 有物理上限,且成本非线性上涨 | 理论无上限 |
| 复杂度 | 低,通常不用改代码 | 高,需要无状态和分布式协调 |
| 可用性 | 单点故障依然存在 | 天然容错 |
| 成本曲线 | 高端机型性价比急剧下降 | 线性 |
| 典型代表 | Stack Overflow | Instagram 和大多数互联网应用 |
这里不该无脑说「水平扩展更好」。在中小规模下,把 4 核换成 16 核的成本,可能远低于引入分布式协调、服务发现、分布式追踪的工程成本。
判据是:如果单机还能翻倍承载(当前负载低于峰值能力的 50%),且业务增长可预测,先垂直升。 等到垂直扩展的成本开始非线性——比如从 32 核升到 64 核价格翻三倍——再考虑水平。
自动伸缩的真实边界
自动伸缩听起来是万能药,但它有个必须知道的前提:它救不了正在雪崩的系统。
三个原因。启动延迟——实例从启动到能接流量通常要 30 秒到几分钟,而雪崩往往是秒级的。冷启动尖峰——新实例启动时要建连接池、加载缓存,一瞬间对下游的冲击比稳态还大。下游是瓶颈时扩容无效——如果是数据库被打满,加再多应用实例只会把数据库打得更死。
正确的定位是:自动伸缩是稳态调节工具,不是事故救援工具。 事故救援要靠限流、降级、熔断,以及一批提前准备好的「热备用」实例——不接流量但已启动完毕,扩容时秒级加入。
另外扩容快、缩容慢是必须遵守的节奏。缩容慢是为了避免抖动:指标刚好在阈值附近波动时会反复加减机器。
这个阶段最隐蔽的坑:连接数爆炸
有一件事必须在这里说清楚,因为它把两个阶段串了起来。
假设每台应用开 20 个数据库连接,从 1 台扩展到 20 台,数据库要承受的就是 400 个连接。按 PostgreSQL 每个连接 5 到 10MB 计算,光连接状态就吃掉 2 到 4GB 内存。
水平扩展会把连接数问题放大 N 倍。 这就是为什么连接池不是阶段 2 的可选项,而是阶段 3 的前置条件——没做连接池就做水平扩展,等于把数据库往死里打。
除此之外,这个阶段还制造了几个新问题:数据库成了新瓶颈,N 台应用实例一起打同一个库,压力反而更集中;负载均衡器自身成了单点,需要做主备或集群;以及配置与发布的复杂性——N 台机器怎么同时更新配置、怎么做灰度发布。
六、阶段 4:缓存与 CDN(10K–100K 用户)
到了这个阶段,瓶颈换成了「读」
前面几步解决的是「应用扛不住」和「应用是单点」。当应用能水平扩展之后,压力会集中到一个地方:数据库的读请求。
这个阶段的解法是缓存。但缓存有个前提条件必须先想清楚:它只对读多写少的场景有效。 如果你的业务是写密集型,缓存不仅帮不上忙,还会引入一堆一致性难题。
缓存不是一个东西,是五个层次
很多人说「加缓存」时脑子里只有 Redis,但实际上从用户到数据库之间有五层可以缓存:
graph TB
U["用户"] --> L1["① 浏览器缓存<br/>Cache-Control / ETag"]
L1 --> L2["② CDN 边缘节点<br/>Cloudflare / CloudFront"]
L2 --> L3["③ 反向代理缓存<br/>Nginx proxy_cache"]
L3 --> L4["④ 应用层缓存<br/>Redis / Memcached"]
L4 --> L5["⑤ 数据库缓存<br/>InnoDB Buffer Pool"]
style L2 fill:#e6f5ff
style L4 fill:#ffe6e6
| 层级 | 存储位置 | 命中收益 | 典型 TTL |
|---|---|---|---|
| 浏览器缓存 | 用户设备 | 完全不发请求,收益最大 | 静态资源:天到月 |
| CDN | 边缘节点,离用户近 | 省去回源,同时降延迟和源站压力 | 静态资源:小时到天 |
| 反向代理 | 离应用最近的一层 | 在应用之前拦截,应用都用不到 | 秒到分钟 |
| 应用层缓存 | Redis / Memcached | 挡住绝大多数数据库读 | 秒到小时 |
| 数据库缓存 | 数据库自身内存 | 最后一道防线 | 由数据库管理 |
这里的性价比排序是 CDN > 应用层缓存 > 反向代理缓存。 CDN 排第一是个常被忽略的判断,原因有三:静态资源(JS、CSS、图片、视频)往往占流量的 80% 以上,但只占业务的 0%;CDN 是纯配置工作,零业务代码改动;收益是双份的,源站压力下降的同时用户延迟也下降。
如果你的应用还没上 CDN,先上 CDN,再考虑 Redis。 顺序反了。
缓存的四种写入策略
| 策略 | 读操作 | 写操作 | 一致性 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside(旁路缓存) | 先查缓存,未命中则查数据库并回填 | 更新数据库,然后删除缓存 | 最终一致 | 绝大多数场景的首选 |
| Read-Through(读穿透) | 应用只读缓存,缓存自己负责回源 | — | 最终一致 | 缓存层封装了回源逻辑 |
| Write-Through(写穿透) | 正常读缓存 | 同时写缓存和数据库,同步 | 较强 | 写少且写完马上要读 |
| Write-Behind(写回) | 正常读缓存 | 只写缓存,异步批量刷数据库 | 弱,可能丢数据 | 写入极高且能容忍丢失 |
这里有个高频问题值得展开:Cache-Aside 为什么是「删除缓存」而不是「更新缓存」?
核心原因是并发写时「更新缓存」会产生永久性脏数据。设想两个请求 A 和 B 同时更新同一条数据,A 写数据库为 1,B 写数据库为 2:
- 走更新缓存:A 写库(1)→ B 写库(2)→ B 更新缓存(2)→ A 更新缓存(1),结果是数据库为 2、缓存为 1,永久不一致
- 走删除缓存:A 写库(1)→ B 写库(2)→ B 删缓存 → A 删缓存,缓存为空,下次读取会从数据库加载到正确的 2
换句话说,删除缓存把「并发写导致的永久脏数据」转化成了「缓存穿透一次」的小代价。这就是它更优的原因。
进一步追问顺序——答案是先写数据库、再删缓存。反过来会变成:删缓存 → 读请求回填旧值 → 写数据库,结果缓存里留下永久脏数据。
缓存的三个经典问题
这三兄弟在 笔记的笔记redis.md 的「缓存三大问题」一节有完整整理,这里只从架构角度补充它们什么时候会真正要命。
缓存穿透是查询一个根本不存在的数据,缓存和数据库都没有,每次请求都打到数据库。它在架构上的危险之处在于:如果被人用不存在的 ID 高频请求,这就成了一个不需要认证的 DoS 入口。解决方案是缓存空值(配短 TTL)或用布隆过滤器拦截。
缓存击穿是某个热点 key 在过期瞬间,大量并发同时打到数据库。它的杀伤力和 key 的热度成正比——一个秒杀商品的缓存失效,可能瞬间打满数据库。解决方案是互斥锁重建,或者热点 key 永不过期配合后台异步刷新。笔记里提到的「逻辑过期 + 互斥锁组合使用」是实践中兼顾性能与一致性的做法。
缓存雪崩是大批 key 同时过期,或者 Redis 服务整体宕机。前者的解法是 TTL 加随机抖动;后者才是真正致命的——Redis 挂了等于全站不可用,所以必须做降级:拿不到缓存就直查数据库,但必须配合限流,否则降级本身会把数据库直接压垮。
⤷ 高可用方面,Redis 有三种部署形态:主从复制(读写分离的基础方案,但写仍集中在单主,存在写瓶颈)、哨兵模式(自动故障转移,但要注意脑裂问题——网络分区时可能出现两个 Master)、分片集群(16384 个哈希槽分给多个主节点,多主并行写入,打破单机写瓶颈)。生产环境最小规划是 3 主 3 从共 6 个节点。详见 笔记的笔记redis.md 的「Redis 集群」一节。
缓存一致性:最难的部分
缓存加进来之后,一致性就成了新的难题。数据库更新成功但删缓存失败怎么办?并发读写时会不会互相污染?
这个问题在 笔记的笔记redis.md 的「双写一致性」一节有详细展开,包括延迟双删(删除缓存 → 更新数据库 → 等待一小段时间 → 再次删除,用来清掉那个极短窗口里被读请求写回来的脏数据)、Redisson 分布式读写锁(强一致,但读写串行化、牺牲性能)、以及异步通知方案(MQ 发消息,或 Canal 伪装成 MySQL 从节点监听 binlog,业务代码零改动)。
值得补充的是这三者的选型逻辑:
| 你的业务特征 | 该选什么 |
|---|---|
| 强一致要求(如资金、库存) | Redisson 分布式读写锁,接受性能损失 |
| 允许短暂延迟(如商品详情、用户资料) | 异步通知(MQ / Canal),对业务透明 |
| 任何场景的兜底 | 延迟双删,轻量级、高性价比 |
关键认知是:缓存一致性和分布式事务一样,不存在「完美的强一致方案」,只有「在你的业务能接受的不一致窗口内工作的方案」。 先问业务能接受多长的不一致窗口,再选技术方案。
Redis 还是 Memcached
| 维度 | Redis | Memcached |
|---|---|---|
| 数据结构 | String / Hash / List / Set / ZSet 等 | 仅 key-value |
| 持久化 | 支持 RDB / AOF | 不支持,重启即丢 |
| 高可用 | 主从 + 哨兵 + Cluster | 客户端分片,无原生集群 |
| 线程模型 | 单线程执行命令(6.0 后网络 IO 多线程) | 多线程 |
| 内存效率 | 略低,有元数据开销 | 略高,纯 KV |
| 适用 | 绝大多数场景 | 纯 KV 缓存、超大规模、能接受丢失 |
现在选型基本无脑选 Redis,Memcached 的剩余价值主要在「纯 KV + 要极致内存效率 + 重启丢了也无所谓」的场景。Instagram 当年同时用了两者——6 个 Memcached 实例做通用缓存,Redis 存 3 亿条照片 ID 映射,用 Redis 正是因为它需要数据结构能力。
关于内存管理还有两个点值得知道。Redis 的过期 key 用的是惰性删除 + 定期删除的组合策略——惰性对 CPU 友好但对内存不友好,定期是折中。内存满了但 key 还没过期时,靠淘汰策略决定淘汰谁,实战中常用 allkeys-lru 只保留热点数据。LRU 和 LFU 的区别在于前者看「最后访问时间」、后者看「访问频率」——如果一个 key 曾经很热但现在已经冷了,LRU 会较快淘汰它,LFU 则可能因为它历史频率高而一直留着。
⤷ 持久化方面的取舍(RDB 恢复快但可能丢最后一次快照后的数据,AOF 更安全但文件大、恢复慢)见 笔记的笔记redis.md 的「持久化」一节。从架构角度关注一点:缓存重启后的冷启动——如果 Redis 挂了重启,缓存全空,所有请求会瞬间涌向数据库。RDB/AOF 能缩短这个窗口,但真正的解法还是在应用层做限流降级。
CDN 的关键机制
| 机制 | 说明 |
|---|---|
| 边缘节点 | 全球部署,用户从最近的节点取资源 |
| 回源 | 边缘节点没有缓存时,回源站取并缓存 |
| 缓存键 | 默认是 URL,可加入 Header 或 Query 定制 |
| TTL 控制 | 通过 Cache-Control 响应头控制 |
| 缓存失效 | 通常通过 URL 指纹而非主动清除 |
| 动态加速 | 通过优化回源链路(专线、协议优化)加速动态请求 |
前端静态资源的标准做法是「内容哈希文件名 + 长 TTL」:
/static/app.a3f8c2d1.js ← Cache-Control: max-age=31536000, immutable
/index.html ← Cache-Control: no-cache
带哈希的文件名内容变了名字就变,所以可以设一年 TTL 永远不失效;index.html 不缓存,保证用户能拿到最新的资源引用。这样就完全不需要「主动清除 CDN 缓存」这个操作,发布零风险。
反过来,如果你的 JS 文件名是固定的 app.js,每次发版都要手动刷 CDN,一旦漏刷就会出现「新 HTML 配旧 JS」的诡异 bug——这类问题排查起来非常痛苦,因为代码看起来完全正确。
新制造出来的问题
缓存这一层加进来之后,你会同时获得几个新的故障模式:缓存一致性问题(数据库更新成功但缓存删除失败);缓存冷启动(缓存全部失效的瞬间数据库会被打爆);Redis 成为关键依赖(它挂了等于全站不可用);以及内存成本失控——当命中率下降时,为了维持性能你只能不断加内存,而内存的单位成本远高于磁盘。
七、阶段 5:读扩展、分片与异步(100K–1M 用户)
缓存之后,瓶颈回来了
缓存挡住了绝大多数读请求,但它挡不住两类流量:缓存没命中的那部分读(哪怕命中率 95%,剩下的 5% 在百万级 QPS 下依然是巨大数字),以及所有的写。
于是这个阶段要解决两个问题,顺序很重要:先解决读,再解决写。
决策顺序:分片应该是最后手段
这里必须先立一条规矩,因为太多团队搞反了:
graph TB
A["数据库有压力"] --> B["① 优化 SQL / 加索引"]
B --> C["② 加应用层缓存 / CDN"]
C --> D["③ 加物化视图"]
D --> E["④ 加读副本 + 读写分离"]
E --> F{"写还是瓶颈?"}
F -->|否| G["结束,够用了"]
F -->|是| H["⑤ 分区 → 分片"]
style B fill:#e6ffe6
style C fill:#e6ffe6
style D fill:#e6ffe6
style E fill:#e6ffe6
style H fill:#ffe6e6
分片之前该穷尽的手段有三个层面。调优数据库——把 innodb_buffer_pool_size 或 shared_buffers 设成内存的 70% 到 80%,从 HDD 换到 NVMe。加读副本——一个主库配三个读副本就能扛住非常大的读吞吐。修查询和索引——缺失索引、坏 JOIN、N+1,这些应该在加任何基础设施之前解决。
⤷ 索引层面的优化手段(避免回索引用覆盖索引、超大分页用子查询优化、注意最左前缀法则和索引失效的五个场景)见 笔记的笔记Mysql.md 的「索引」和「SQL 优化」两节。这份笔记里「整体优化方向」那一节给的顺序(表设计 → 索引 → SQL → 主从读写分离 → 分库分表)和这里的决策顺序是一致的,可以对照着记。
那么什么时候才真的需要分片?答案是:当写是真正的瓶颈时。 具体信号包括:主库已经是读优化过的配置,但仍然撞上了写锁天花板;迁移和备份的时间超过了维护窗口;数据集极度庞大(比如 8TB 以上)。
核心认知是:读副本解决不了写吞吐问题。 副本只分摊读,要分摊写只能靠分区或分片——这一点经常被搞混。
读副本与复制延迟
读副本的本质
读副本最好理解成「一个近似一致的缓存」。
复制延迟指的是写入在主库提交,到这个变更出现在副本上的时间差。它导致最终一致性和读到旧数据。
延迟的量级跨度很大,值得记住这几个数:
| 场景 | 典型延迟 |
|---|---|
| 空闲 | < 5ms |
| 中等写入 | 10–50ms |
| 高流量 | 50–500ms |
| 写入突发 | 1–5s |
| 副本压力大时 | 5–10s |
注意延迟在各副本之间是不均匀的。 一个副本在跑大扫描查询时会严重滞后,而另一个可能很新鲜——所以「读副本集群」的健康度不能只看平均值。
延迟的常见成因包括:主库写入突增或 TPS 过高;副本配置过弱或负载过重(应用变更的速度跟不上);网络延迟抖动(跨区域 RTT 尤其明显);以及大事务、批量更新和长时间运行的 DDL。
⤷ 主从复制的工作流程(写请求到 Master、binlog 同步到 Slave、读请求路由到 Slave、中间件如 MyCat 或 ShardingSphere 负责路由)见 笔记的笔记Mysql.md 的「主从复制与读写分离」一节。
读到旧数据怎么办
最典型的症状是:用户改了昵称,刷新后没变,过几秒又变了。
这是复制延迟导致的读后写不一致。四类解法:
第一类是读写一致性路由。 记录用户最后一次写的时间戳,在接下来几秒到几分钟内,该用户的读请求强制走主库;或者用会话粘性,让同一会话固定走同一节点;事务内天然需要强一致,所以全部走主库。
第二类是把关键接口标记为「仅主库」。 具体包括:购物车和结算总额、钱包余额、库存、支付状态,以及后台运维工具。这些接口永久走主库,不做读写分离。
第三类是基于 LSN 的延迟感知路由。 主库上取 pg_current_wal_lsn(),副本上取 pg_last_wal_replay_lsn(),算出延迟字节数或秒数。安全副本读模式是「延迟小于阈值(比如 1 秒或 1MB)才用副本,否则回退主库」;强一致模式则是等待副本 LSN 追平提交 LSN——Aurora、CockroachDB、MongoDB 的因果一致性都是这个思路。
第四类是监控和降级。 监控指标上,RDS PostgreSQL 看 ReplicaLag(秒),Aurora 看 AuroraReplicaLag(毫秒),MySQL 看 Seconds_Behind_Source、GTID 集合比对或 pt-heartbeat。告警阈值建议设在延迟超过容忍度持续数分钟(而不是单次尖峰),参考值约 10 秒;另外非活跃复制槽超过 1 小时也要告警,因为它会导致 WAL 堆积、磁盘打满。降级手段则包括显示「数据延迟」提示条、禁用重型图表、回退到缓存结果、关键读走主库,以及自动把滞后副本从轮转中摘除。
分片:策略与代价
四种分片策略
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 哈希分片 | 对 user_id 或 tenant_id 哈希 | 分布均匀,SaaS 最常用 | 扩容极痛苦;范围查询要广播 |
| 范围分片 | 按时间、ID 区间或地理划分 | 范围查询高效;便于数据隔离(如 GDPR) | 容易产生热点分片——自增 ID 会把写全压到最后一个分片 |
| 目录分片 | 用一张映射表记录 key 到分片的对应 | 灵活,可动态调整 | 映射表本身成了单点和瓶颈 |
| 地理分片 | 按用户所在区域 | 合规友好,延迟低 | 跨区域用户难处理 |
一致性哈希能缓解但不能消除 resharding 的痛苦。 它把「加节点时全量重分布」变成「只迁移相邻节点的部分数据」,这是巨大改进,但它解决的是分布问题而不是数据迁移问题——该搬的数据还得搬。
真正的生产方案通常是预分片:一开始就切出远多于当前需要的分片数(比如 1024 个逻辑分片),映射到少量物理机上。扩容时只需要搬逻辑分片。这正是 Instagram 的做法,好处是数据量小的时候也能用大分片数(每片很小、迁移快),避免「早期分片数定少了,后期迁移要停机」。
⤷ 有意思的是,Redis 分片集群的哈希槽(16384 个)就是预分片思路的工业化实现——槽位数固定不变,节点增减只是重新分配槽的归属,不需要改变 key 到槽的映射。对照 笔记的笔记redis.md 的「分片集群」一节理解,会发现两者是同一个思想在不同存储上的应用。
路由层方面,ProxySQL 是 MySQL 分片路由的主流方案,也可以用应用侧路由。监控重点是数据倾斜——建议最大分片超过最小分片 1.2 倍时就告警。
分片的代价清单
| 代价 | 说明 |
|---|---|
| 跨分片 JOIN 消失 | 只能应用层拼装,或在冗余字段上做反范式 |
| 分布式事务困难 | 需要 2PC / Saga / TCC,复杂度和性能损失都大 |
| 聚合查询变重 | 需要 scatter-gather,取所有分片再合并 |
| 全局唯一 ID | 自增 ID 失效,需要雪花算法或号段模式 |
| 扩容是硬仗 | 数据迁移、双写、校验、切换 |
| 运维复杂度 | 备份、监控、DDL 都要按分片做 |
这张表应该打印出来贴在显示器上——每次有人提议「我们分库分表吧」的时候,先对照着读一遍。
消息队列:削峰、解耦、异步
到这里为止,所有讨论都围绕「如何让同步请求更快」。但有些事本来就不该同步做。
graph LR
U["用户请求"] --> API["应用"]
API -->|"同步"| DB[("数据库")]
API -->|"异步投递"| MQ["消息队列<br/>Kafka / RabbitMQ / SQS"]
MQ --> W1["发邮件 worker"]
MQ --> W2["更新推荐 worker"]
MQ --> W3["写日志 worker"]
MQ --> W4["生成报表 worker"]
style MQ fill:#ffe6e6
消息队列提供三样东西。削峰——突发流量先入队,后端按自己节奏消费,秒杀和抢购场景的标配。解耦——下单之后要发邮件、更新推荐、写日志、加积分,全塞给队列,生产者不需要知道消费者是谁。异步——把慢操作踢出请求链路,下单接口不用等发邮件就能返回。
⤷ RabbitMQ 与 Kafka 的选型区分:业务逻辑(订单流转、发通知、削峰)用 RabbitMQ,数据管道(海量日志、大数据实时分析、数据同步)用 Kafka。见 Java - 消息中间件篇 的「Kafka 顺序性 / 高可用 / 存储 / 高性能」一节。
引入队列后的四个新问题
这是必须提前知道的代价——你用四个新问题换掉了原来的一个性能问题。
| 问题 | 说明 | 应对 |
|---|---|---|
| 消息重复 | 网络重试导致同一消息被消费两次 | 消费者幂等 |
| 消息顺序 | 分区后同一 key 的消息可能乱序 | 同一业务 key 路由到同一分区(Kafka 按 key 分区) |
| 消息丢失 | 生产端、Broker、消费端都可能丢 | 三端都要保证 |
| 消息积压 | 消费速度跟不上生产速度 | 监控 lag,扩容消费者,死信队列兜底 |
「如何保证消息不丢失」是三段式回答,对应三个环节:生产端用确认机制(RabbitMQ 的 ACK 回执、Kafka 的 acks=all)确保消息到达 Broker;Broker 端靠消息持久化和镜像队列、仲裁队列保证不因宕机丢失;消费端用手动 ACK,处理完业务逻辑再确认,而不是自动提交 offset。
⤷ 完整的可靠性机制、死信交换机(消息被拒绝、TTL 过期、重试超限、队列满四种产生原因)、消息堆积的三种解法(增加消费者、消费者内开线程池、扩大队列容积)、以及普通集群/镜像集群/仲裁队列的对比,见 Java - 消息中间件篇 的「RabbitMQ 消息可靠性」与「RabbitMQ 重复消费 / 死信队列 / 消息堆积」两节。
「如何保证消息不重复」的答案要先纠正一个误区:消息队列本身做不到「恰好一次」(exactly-once 更多是营销词),实践中是**「至少一次 + 消费端幂等」**。
幂等的实现方式有三种:唯一键去重,消息带业务唯一 ID(如订单号),消费前先查去重表或 Redis,已处理则跳过;数据库唯一约束,直接让唯一索引兜底,重复插入会失败;状态机,只允许特定状态流转(如「待支付 → 已支付」),重复的已支付事件会被忽略。
⤷ 接口层面的幂等方案(Token + Redis、分布式锁、去重表、状态机)见 Java - 微服务篇 的「接口幂等性」一节。两者的区别:接口幂等防的是「用户重复点击提交」,消息幂等防的是「MQ 重复投递」,但底层思路是相通的——都是用一个唯一标识 + 存储来记录「这件事做过了」。
新制造出来的问题
这个阶段之后,系统的复杂度会陡增:数据分散在主库、副本、缓存、队列四个地方,一致性问题成倍增加;一个请求的副作用散布在多个异步链路里,调试变难;消息积压成了新的故障模式;而分片之后,扩容依然是一场硬仗。
这也是很多团队真正开始「还技术债」的阶段。
八、阶段 6:微服务与可观测性(1M–10M 用户)
这个阶段的瓶颈不是技术问题
前面五个阶段,瓶颈都是技术性的:CPU 不够、连接不够、读扛不住、写扛不住。到了百万用户这个量级,瓶颈会变成组织性质的:
几百人改同一个代码库,发布互相阻塞;某个模块的一次内存泄漏拖垮整个服务。
这个认知很重要,因为它决定了你该不该做微服务。微服务解决的主要是「组织扩展」问题,不是「性能」问题。 如果你的团队只有十个人,拆微服务几乎必然是负收益——你付出了分布式系统的全部复杂度,却没换来任何组织收益。这是面试和实战中最容易搞错的一点。
怎么拆
拆分的核心原则是按业务域拆(DDD 的限界上下文),而不是按技术层拆。拆成「Controller 服务」「DAO 服务」是典型的错误示范。
其余原则包括:每个服务独立数据库——共享数据库是最糟糕的微服务反模式,等于没拆;服务边界应该等于事务边界,如果一个业务操作需要跨服务强一致,说明拆错了;避免循环依赖;改动一个功能不应该牵动多个服务。
必备基础设施
没有下面这些就上微服务,等于自杀。
graph TB
C["客户端"] --> GW["API Gateway<br/>认证 / 限流 / 路由"]
GW --> S1["用户服务"]
GW --> S2["订单服务"]
GW --> S3["商品服务"]
S1 --> D1[("用户库")]
S2 --> D2[("订单库")]
S3 --> D3[("商品库")]
S2 -->|"事件"| MQ["消息队列"]
MQ --> S3
SD["注册中心<br/>Nacos / Consul / K8s"] -.-> S1
SD -.-> S2
SD -.-> S3
S1 --> TR["分布式追踪<br/>SkyWalking / Jaeger"]
S2 --> TR
S3 --> TR
style GW fill:#ffe6e6
style TR fill:#e6f5ff
这张图里的每个组件,你在 Spring Cloud 里其实都见过对应实现:
| 组件 | 作用 | Spring Cloud 实现 |
|---|---|---|
| API 网关 | 统一入口,认证、限流、路由 | Spring Cloud Gateway |
| 注册与配置中心 | 服务实例动态上下线、配置热更新 | Nacos |
| 客户端负载均衡 | 服务间调用时的实例选择 | Spring Cloud LoadBalancer |
| 声明式调用 | 像调本地方法一样调远程服务 | OpenFeign |
| 熔断降级 | 防止故障扩散 | Sentinel |
| 全链路追踪 | 串联跨服务的调用链 | SkyWalking |
⤷ 这套组件的具体用法(Nacos 的心跳与主动探测差异、声明式调用
@HttpExchange或 OpenFeign 的@FeignClient、Sentinel 的降级写法、Gateway 的lb://与stripPrefix路由配置)见 Java - 微服务篇 的「Spring Cloud 组件全景」一节——那份笔记是从怎么写代码的角度整理的,这里是从为什么需要它的角度,可以互为补充。
可观测性:微服务的生命线
单体应用出问题时,你看一眼日志就知道哪里错了。微服务出问题时,一个请求穿过八个服务,每个服务各有多个实例——没有可观测性体系,你连「哪里慢」都不知道。
可观测性有三根支柱,各自回答不同的问题:
| 支柱 | 回答的问题 | 数据特点 |
|---|---|---|
| Logs 日志 | 发生了什么? | 离散事件,信息量大,成本高 |
| Metrics 指标 | 系统整体健康吗? | 可聚合,成本低,适合告警 |
| Traces 链路追踪 | 这个请求慢在哪? | 请求级上下文,串联全链路 |
一个典型的排查流程正好走完这三步:Prometheus 告警显示订单服务 P99 超过 1 秒,你知道出事了;打开 SkyWalking 或 Jaeger 查链路,发现耗时都集中在「调用库存服务」那一段,你知道慢在哪了;再看库存服务的日志,显示「数据库连接池等待超时」,你找到根因了。
没有链路追踪的话,第二步就卡住了——你只知道订单服务慢,不知道是它自己慢还是下游慢。这就是微服务最大的调试痛点。
指标层面有三套经典框架:RED(面向服务:Rate 请求速率、Errors 错误率、Duration 延迟)、USE(面向资源:Utilization 使用率、Saturation 饱和度、Errors 错误数),以及 Google SRE 的四个黄金信号(延迟、流量、错误、饱和度)。
⤷ Spring Cloud 体系里,全链路追踪通常用 SkyWalking 或 Micrometer Tracing + OpenTelemetry(Boot 3 之后 Sleuth 已被取代)。注意 Java - 微服务篇 里提到的整体链路是「Nginx 处理静态文件 → API 请求分发到网关 → 网关路由到各微服务」——Nginx 和网关都要接入追踪,否则链路会在入口处断掉,这是实践中很容易漏的一环。
分布式的固有代价
这是必须向团队和老板讲清楚的部分,因为它们不是「实现问题」,而是分布式系统的物理属性:
| 代价 | 具体表现 |
|---|---|
| 网络故障成为常态 | 函数调用不会失败,网络调用随时会超时、丢包、抖动 |
| 分布式事务困难 | 需要 Saga / TCC 补偿,没有简单的强一致方案 |
| 调试成本剧增 | 一个跨 8 个服务的 bug 可能耗掉一整天 |
| 本地开发变重 | 起 10 个容器才能跑起来,新人上手周期从 1 天变成 1 周 |
| 运维复杂度爆炸 | 部署、监控、扩缩容、版本兼容都要按服务做 |
| 数据一致性 | 跨服务查询要么冗余数据,要么多次调用 |
| 延迟增加 | 原本一次函数调用,现在是一次网络往返加序列化 |
分布式事务怎么做
回答这个问题之前,先反问业务需求。
如果是资金类的强一致场景,用 TCC 或者直接让这块保持单体;如果是订单、积分、优惠券这类可以最终一致的场景,用 Saga 或者本地消息表加定时补偿就够了。
⤷ Seata 的三种模式——XA(CP,各分支互相等待保证强一致,性能差)、AT(AP,基于 undo log,性能好)、TCC(AP,需人工编码 Try/Confirm/Cancel,性能较好)——以及 TC / TM / RM 三大角色的协作流程,见 Java - 微服务篇 的「分布式事务(Seata / Saga / Outbox)」一节。笔记里也提到 Transactional Outbox 是”发消息”这件事的标准做法——直接
mq.send()无法和数据库事务绑定,所以要把事件先写进 outbox 表、由后台进程或 CDC 投递。
但比选型更重要的是一个认知:跨服务的强一致会引入锁和长事务,可用性显著下降。更好的做法通常是把需要强一致的部分收敛到同一个服务内——同一个库、本地事务——而不是试图做分布式强一致。微服务的边界应该按事务边界来划,而不是按组织架构来划。
⤷ 理论层面的 CAP 与 BASE(基本可用、软状态、最终一致性)见 Java - 微服务篇 的「CAP 与 BASE」一节。注意一个常见误述:P 在分布式系统里是必须的,真正的选择是分区发生时要 C 还是要 A。
一个典型的失败案例
有个反例值得记住:一个日活几千的应用上了 K8s + 微服务 + Kafka,结果团队 80% 的时间在维护基础设施,而不是做产品。
具体表现是:发一个 bug 修复要改 3 个服务、走 3 次 CI/CD;本地跑不起来,必须连开发环境;加一个字段要改 4 个服务的 DTO;一次网络抖动导致 5 个服务级联超时;为了排查问题又引入了 5 个可观测性组件,又多了 5 个要维护的东西。
判断标准很简单:如果你们团队没有遇到「多团队改同一份代码互相阻塞」这个问题,就不该拆微服务。
九、阶段 7:多区域与单元化(10M+ 用户)
这个阶段解决的是物理问题
前面所有优化都在跟「机器不够快」斗争。到了千万用户、全球用户的量级,你会撞上一堵物理定律砌成的墙:光速。
跨太平洋一次网络往返 100 到 150 毫秒,这是物理约束,没有任何技术能绕过。再加上单区域故障域的问题——一个机房、一个区域出问题,全球用户一起受影响。
所以这个阶段的两件事是:把数据和服务推到离用户更近的地方,以及把故障域切小。
多区域的基础设施
| 技术 | 说明 |
|---|---|
| Geo-DNS | DNS 根据用户来源返回最近的区域 IP |
| Anycast | 同一 IP 在全球多点宣告,由 BGP 路由到最近节点 |
| 多区域数据复制 | 跨区域同步数据,延迟是物理约束 |
| 数据驻留 | 合规要求(如 GDPR)可能强制某些数据留在特定区域 |
一致性 vs 延迟:必须做的取舍
| 模式 | 做法 | 延迟 | 代价 |
|---|---|---|---|
| 强一致 | 跨区域写入需要等待多数派确认 | 高,用户能感知 | 可用性下降,区域故障时可能无法写入 |
| 最终一致 | 本地先写,异步复制 | 低 | 需要处理冲突(CRDT 或 last-write-wins) |
| 分区容忍优先 | 每个区域独立可写,事后合并 | 最低 | 冲突解决复杂,可能丢失更新 |
CAP 定理在这里的实战含义,不是「三选二」,而是按业务分而治之:
| 选择 | 牺牲 | 典型场景 |
|---|---|---|
| CP | 可用性 | 金融交易、库存扣减、分布式锁 |
| AP | 一致性 | 社交动态、点赞数、商品浏览、评论 |
同一个系统里,库存扣减走 CP(宁可不可用也不能超卖),点赞数走 AP(少一个赞没人发现)。 不要全局一刀切——这是 CAP 在工程上唯一正确的用法。
单元化架构(Cell-Based Architecture)
这是超大规模系统的标准答案,也是 AWS Well-Architected 框架明确推荐的模式(隔板架构 / Bulkhead)。
核心思想一句话:把用户切成独立的单元,每个单元自成闭环,一个单元炸了不影响其他单元。
AWS 给的量化说法是:如果负载使用 10 个 cell 服务 100 个请求,一个 cell 故障时 90% 的请求不受影响。
graph TB
U["全部用户"] --> R["Cell Router<br/>(唯一的共享组件)"]
R --> C1["Cell 1"]
R --> C2["Cell 2"]
R --> C3["Cell 3"]
R --> C4["Cell 4"]
R --> C5["Cell 5"]
R --> C6["..."]
R --> C10["Cell 10"]
style R fill:#ffe6e6
style C1 fill:#e6ffe6
关键设计原则有这么几条。分区键要贴合服务粒度——用 customer ID 或 resource ID,不要随机分。Cell 之间不共享状态——唯一可以共享的组件是路由器层。Cell 有最大尺寸上限——通过压测找出崩溃点、留出安全边际后禁止超过,扩容靠加 cell 而不是放大 cell。部署要隔离——不要同时把所有 cell 一起发布。路由器必须保持极简——不要在路由层加复杂业务逻辑,它必须能水平扩展且计算量恒定。跨 cell 交互要最小化——每一次跨 cell 调用都在削弱隔离性。
对应的五个反模式是:允许 cell 无限制增长;同时向所有 cell 部署代码更新;cell 之间共享状态或组件(路由器除外);在路由层加入复杂业务逻辑;不最小化跨 cell 交互。
Cell 的规模怎么算
举个实战的例子。已知峰值 360 万 req/s,单个 cell 的认证能力是 18 万 req/s,现有 cell 封顶在认证能力的 85%:
3,600,000 ÷ (180,000 × 0.85) ≈ 24 个服务 cell
加上空余预留和滚动发布的重叠,总共 26 个 cell。
注意这里有个容易被忽略的点:留一个空的「机动 cell」只有在持续打补丁、预热、配额齐备、安全认证通过、并且用真实流量测试过的前提下才有用。否则它只是个占了名额的僵尸。
Cell 天生就是部署环
Cells 天然构成部署环(deployment ring):先在服务流量之外验证制品;再部署到金丝雀或非关键 cell;观察生产形态的流量并注入局部故障;按有界的批次推进;一旦出现 cell 局部的 SLO、完整性或成本回归就立即停止;保留旧版本和 schema 兼容性,直到回滚完成。
Cell 的生命周期有三个阶段。Provision & Certify——验证网络、身份、密钥、配额、存储持久性、备份、可观测性、合成事务、容量、声明式故障注入,记住「资源创建完成」不等于「就绪」。Fill——先用小的金丝雀单元,维护一个放置水位线。Drain & Retire——围栏源权威、排空连接、验证没有残留的路由和数据和密钥,然后才删除。
有一个警告必须说清楚:一个共享的全局依赖会摧毁整个隔离设计。
比如所有 cell 共用同一个「权益服务」的连接池或配额。这个服务一挂,爆炸半径依然是全球性的。应对方式是:要么把它也分区或缓存,要么明确承认爆炸半径就是全球的——诚实比假装安全好。
Shuffle Sharding:用 20 台机器买到 1140 种隔离
这是 cell 架构的一个重要变体,AWS 在 2014 年为 Route 53 设计。
它和普通分片的区别在于:普通分片的划分是互斥的、不重叠的,而 shuffle sharding 的子集之间可以重叠。
数学原理是组合数。从 N 个资源中选 k 个组成一个子集,可能的子集数量是 C(N, k)。
以 N=20、k=3 为例:C(20,3) = 1,140 个不同的子集。两个租户恰好分到完全相同子集的概率是 1/1,140;期望重叠的资源数是 k²/N = 3²/20 = 0.45 个。
也就是说,只用 20 台机器,就获得了 1,140 种隔离组合。 一个出故障的租户只能影响和它重叠的那 0.45 台机器上的其他租户。
有个约束必须守住:重试必须限制在分配的子集内。 如果失败后回退到整个资源池重试,隔离性瞬间归零。
k 的选择是个权衡:k 越大负载越分散,但重叠越多、爆炸半径越大;k 越小隔离性越好,但冗余不足。另外,有状态资源不适合 shuffle sharding——用 cell 或显式的分区放置来管理权威数据,shuffle sharding 用于每个 cell 内部的无状态前端。
混沌工程
到了这个规模,故障是必然而非偶然。
所以要做故障注入:主动杀掉实例、模拟网络分区、注入延迟。Netflix 的 Chaos Monkey 是最经典的实践,随机终止生产实例。还可以定期组织 Game Day,让团队演练故障响应。
为什么要主动注入故障?因为没演练过的故障恢复路径,等于不存在。
你以为主从切换是自动的,真到切换时才发现有个配置项写错了;你以为告警会响,真出事时才发现告警被静音规则吞了。混沌工程的价值不在于发现 bug,而在于验证恢复机制真的有效。
成本控制成为一等工程问题
在这个规模,成本本身就是架构决策的约束条件:跨区域数据传输费用极高,能本地处理就本地处理;存储做冷热分层(S3 Standard → IA → Glacier);计算用预留实例、Spot 实例和自动伸缩的组合;不是所有服务都需要多区域多活;而可观测性本身的存储成本可能超过业务本身——这是个经常被忽略的账单黑洞。
十、贯穿全篇的底层原则
七个阶段讲完了,但如果只记住七个阶段的名称,等于什么都没学到。真正有价值的是下面这几条原则。
瓶颈会移动
这是最重要的一条。
graph LR
A["应用 + DB 同机<br/>瓶颈:CPU/内存争抢"] -->|拆分| B["独立 DB<br/>瓶颈:应用服务器成单点"]
B -->|加负载均衡| C["多台无状态应用<br/>瓶颈:数据库读压力"]
C -->|加缓存/CDN| D["读压力缓解<br/>瓶颈:缓存一致性"]
D -->|加读副本| E["读扩展了<br/>瓶颈:写吞吐"]
E -->|分片| F["写扩展了<br/>瓶颈:跨片查询/运维复杂度"]
F -->|拆微服务| G["团队解耦了<br/>瓶颈:分布式调试/网络故障"]
G -->|多区域| H["全球覆盖<br/>瓶颈:一致性与成本"]
style A fill:#ffe6e6
style B fill:#fff0e6
style C fill:#fffbe6
style D fill:#e6ffe6
style E fill:#e6f5ff
style F fill:#e6e6ff
style G fill:#f0e6ff
style H fill:#ffe6f5
每一次解决瓶颈的动作,都会把瓶颈推到下一个地方。 这不是设计失败,这是系统演化的常态。
由此推出三条结论。不存在「一步到位的完美架构」——你现在设计的所有东西,都会在下个阶段变成需要改的东西。过早引入的每一层复杂度,都是在给还没发生的问题交利息,而且这个利息是持续支付的(运维、调试、新人上手)。架构演进是响应式的,不是预测式的——监控先行,瓶颈驱动。
顺序比技术选型重要
在错误的时机用正确的技术,等于错误。
| 错误顺序 | 后果 |
|---|---|
| 先拆微服务,后做可观测性 | 出问题查不出来,只能靠猜 |
| 先分库分表,后加缓存 | 白白承担分片的全部复杂度 |
| 先上 K8s,后无状态化 | Pod 重启就掉 session |
| 先水平扩展,后做连接池 | 连接数指数爆炸,把数据库打挂 |
| 先上消息队列,后做幂等 | 消息重复导致业务数据错乱 |
这张表里的每一条,都是真实团队踩过的坑。它们的共同点是:技术本身没错,错的是时机。
无状态化是水平扩展的前提
任何「状态」都是水平扩展的敌人。会话状态外置到 Redis 或改用 JWT;文件放对象存储;本地缓存换成集中式缓存;定时任务用分布式锁或独立调度服务;WebSocket 用粘性路由加消息总线。
注意这里的逻辑关系:不是「水平扩展之后要处理状态问题」,而是「先解决状态问题,水平扩展才成立」。
两把最通用的锤子
几乎所有性能问题,最后都归结到两招:
| 手段 | 本质 | 适用 |
|---|---|---|
| 缓存 | 用空间换时间,减少重复计算和查询 | 读多写少 |
| 异步 | 用排队换时间,把慢操作移出关键路径 | 允许延迟的副作用 |
有趣的是,这两招在每一层都出现过——CDN 是缓存,Redis 是缓存,数据库 buffer pool 是缓存;消息队列是异步,后台任务队列是异步,写回策略也是异步。
复杂度是真实的成本
| 复杂度来源 | 隐藏成本 |
|---|---|
| 多一个组件 | 多一套监控、告警、升级、故障排查、文档 |
| 多一层网络 | 多一次超时可能、多一段延迟、多一个故障点 |
| 多一个团队边界 | 多一轮沟通、多一个 API 契约要维护 |
判断一个新组件该不该加,问三个问题:
- 不加它,现在会在什么场景下出问题?(必须是已经观测到的,不是想象的)
- 加了它,谁会维护?出问题时谁排查?
- 如果 6 个月后要拆掉它,成本有多高?
三个问题有一个答不上来,就不该加。
十一、常见误区与反例
五个高频误区
| 误区 | 为什么错 | 正确做法 |
|---|---|---|
| 「用户数到了 X 就该上技术 Y」 | 用户数是粗糙代理指标,真正决定的是 QPS、读写比、数据量、延迟要求 | 用监控指标触发架构演进 |
| 「微服务比单体先进」 | 微服务解决的是组织扩展问题,小团队拆微服务是纯负收益 | 团队小于 20 人且无人力维护基础设施时,保持单体 |
| 「分库分表是必经之路」 | 分片是最后手段,绝大多数应用终身不需要 | 先穷尽:优化查询 → 加索引 → 缓存 → 读副本 |
| 「连接数越多吞吐越高」 | 连接多了上下文切换、锁竞争、缓存颠簸加剧,反而更慢 | (核数×2) + 磁盘数,上限 4×核数 |
| 「上云一定比自建便宜」 | 大规模下云成本可能远超自建 | 按规模和团队构成算总账 |
反例汇总
| 反例 | 教训 |
|---|---|
| Stack Overflow | 9 台服务器撑起 20 亿请求/月。没有遇到瓶颈,就不需要那个解法 |
| 日活几千就上 K8s + 微服务 + Kafka | 团队 80% 时间在维护基础设施,而不是做产品 |
| 自增 ID 顺序写入分片 | 所有写压到最后一个分片,产生热点 |
事务模式下用会话级 SET | 连接被粘连整个会话,池容量损失高达 60% |
| HAProxy 把 302 当健康 | 故障机没被摘除,事故被放大 |
| N+1 查询 + 拆库 | 同机时被本地 IO 掩盖,一拆库延迟暴涨 3.5 倍 |
| 全局共享的权益服务 | Cell 隔离设计被一个共享依赖彻底摧毁 |
十二、面试怎么答这类题
四要素答题模板
面试官问「设计一个 XX 系统」时,按这个顺序走:
| 步骤 | 内容 | 时长占比 |
|---|---|---|
| ① 澄清需求与量级 | 多少用户?QPS?读写比?数据量?延迟要求?一致性要求? | 30% |
| ② 给出量级估算 | 存储、带宽、QPS 的具体数字 | 10% |
| ③ 从最简架构起步 | 画出单机或三层架构 | 10% |
| ④ 逐层演进 | 指出瓶颈 → 加组件 → 说代价,循环 | 40% |
| ⑤ 总结权衡 | 说明在当前量级下,哪些故意没做以及为什么 | 10% |
最容易被忽略但最加分的是第 ⑤ 步。
面试官最想听到的不是「我知道多少技术」,而是**「你知道在什么情况下不该用它们」**。比如:
「在当前 10 万用户的量级下,我故意没有做分库分表。因为按 3 年增长预估数据量约 200GB,单机加上读副本完全可以承载;而分片会引入跨片 JOIN 和分布式事务的复杂度,当前团队规模下是负收益。如果写入 QPS 超过单机上限,我再启动分片方案。」
平庸的回答是直接报技术栈(「用 Redis 做缓存,用 Kafka 做队列」);优秀的回答是先讲清量级假设,再讲为什么在这个量级下需要这个组件,以及它的代价。
各阶段高频问题速查
| 阶段 | 高频问题 | 关键得分点 |
|---|---|---|
| 阶段 1 | 单体架构的优缺点?什么时候该拆? | 说清「留缝不留组件」 |
| 阶段 2 | 为什么拆数据库?连接池原理?池该开多大? | 池不是越大越好(缓存颠簸) |
| 阶段 3 | 无状态化怎么做?L4 vs L7?健康检查怎么做才对? | 健康检查要显式声明成功码集合 |
| 阶段 4 | 缓存三大问题?为什么删缓存不更新缓存?CDN 原理? | 删缓存把并发问题转成穿透一次 |
| 阶段 5 | 复制延迟怎么解?什么时候该分片?消息如何不丢不重? | 副本解决不了写吞吐 |
| 阶段 6 | 微服务怎么拆?分布式事务怎么做?可观测性三支柱? | 微服务是组织问题不是性能问题 |
| 阶段 7 | CAP 怎么权衡?单元化架构是什么?混沌工程的价值? | 按业务分而治之,不要全局一刀切 |
万能收尾句
「我的设计原则是:每一步都只解决当前观测到的瓶颈。 在 X 量级下,我选择方案 A 而不是方案 B,因为 B 的复杂度收益在当前规模下不成立。当 A 的瓶颈指标 Y 超过阈值 Z 时,我会启动 B。」
这句话同时展示了三件事:工程判断力、成本意识、演进思维。
十三、自检清单
学完这份笔记,你应该能回答:
- 为什么「不要过度设计」是对的,但「不用设计」是错的?
- Instagram 和 Stack Overflow 代表了哪两种截然不同的扩展哲学?
- 单服务器架构的 5 个升级信号分别是什么?
- 拆分数据库后,为什么接口可能反而变慢?(N+1)
- PostgreSQL 每个连接占多少内存?为什么池不是越大越好?
- Transaction pooling 模式下哪些特性会失效?为什么故障是间歇性的?
- 无状态化的 5 种状态分别怎么处理?会话外置的三种方案怎么选?
- HAProxy 把 302 当健康响应,为什么是严重问题?
- Cache-Aside 为什么「删缓存」而不是「更新缓存」?
- 复制延迟的典型量级是多少?有哪四类解法?
- 什么时候才真正需要分片?分片之前该穷尽哪些手段?
- 消息队列「不丢」和「不重」分别怎么保证?
- 为什么微服务主要解决组织问题而非性能问题?
- Cell-Based Architecture 的核心原则和 5 个反模式是什么?
- Shuffle Sharding 用 20 台机器能提供多少种隔离组合?
- CAP 在实践中怎么「按业务分而治之」?
附:延伸阅读
关联笔记
这份笔记和下面几份是互补关系——它们是「怎么写」和「细节是什么」,这份是「什么时候用」和「为什么」:
- 笔记的笔记redis.md —— 缓存三大问题、双写一致性、分布式锁、集群、I/O 模型
- 笔记的笔记Mysql.md —— 慢查询定位、索引、SQL 优化、主从复制与读写分离、事务
- Java - 微服务篇 —— 组件全景、Nacos 3.x、负载均衡与服务间调用、熔断降级、限流、Seata 2.x、幂等、XXL-JOB 3.x、可观测性、服务网格
- 笔记的笔记多线程.md —— 线程池、AQS、锁(理解「连接池为什么不是越大越好」的线程池类比在这里)
推荐学习路径
| 优先级 | 资源 | 说明 |
|---|---|---|
| 必读 | 《Designing Data-Intensive Applications》(DDIA) | 分布式系统的圣经,中文版《数据密集型应用系统设计》。 |
| 必读 | System Design Primer | GitHub 免费,体系完整 |
| 推荐 | ByteByteGo | Alex Xu 的图解系统设计,有免费内容 |
| 推荐 | High Scalability | 真实公司架构案例合集 |
| 推荐 | AWS Well-Architected Framework | 官方最佳实践,尤其可靠性支柱 |
| 参考 | 各公司工程博客(Netflix / Uber / Discord / Figma) | 一手实战经验 |
文中案例与数据来源
- Five Years of Building Instagram — Wired
- What Powers Instagram: Hundreds of Instances, Dozens of Technologies
- Stack Overflow: Still on prem, runs Q&A platform off just nine servers — DatacenterDynamics
- PostgreSQL Connection Pooling Under Pressure — MVP Factory
- PgBouncer transaction vs session pooling — Netdata
- REL10-BP04 Use bulkhead architectures — AWS Well-Architected
- Cell-Based Architecture and Shuffle Sharding on AWS
- Why Read Replicas Exist and When They Actually Help
- 主题框架参考:Ashish Pratap Singh,《How to Scale a System from 0 to 10 million+ Users》,AlgoMaster Newsletter
最后更新:2026-09-15