系统扩展:从 0 到 1000 万用户

目录


一、开篇:两个极端

在讲任何架构模式之前,我想先摆两个真实案例。它们走的是完全相反的两条路,但都成功了。理解了这一点,你就不会把后面的七个阶段当成必须打卡的流程图。

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)把数据库连接池耗尽了好几个数量级,根因链条是这样的:

  1. 每台服务器当时处理 200–500 req/s,所有 Q&A 请求共用一个应用池
  2. IIS 线程池饥饿,导致异步 continuation 停止,形成类死锁
  3. 负载均衡器 HAProxy 却没有摘除这两台故障机——因为 ASP.NET 的错误会 302 重定向到 /error,而 HAProxy 把 302 当成了成功响应
  4. 直到严重超时之后,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 / 峰值 QPS1 万个沉睡用户和 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.5ms1.0ms+0.5ms(可忽略)
100 次 N+1 查询20ms70ms+50ms(3.5 倍)
500 次 N+1 查询100ms350ms+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 OverflowInstagram 和大多数互联网应用

这里不该无脑说「水平扩展更好」。在中小规模下,把 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

维度RedisMemcached
数据结构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-DNSDNS 根据用户来源返回最近的区域 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 契约要维护

判断一个新组件该不该加,问三个问题:

  1. 不加它,现在会在什么场景下出问题?(必须是已经观测到的,不是想象的)
  2. 加了它,谁会维护?出问题时谁排查?
  3. 如果 6 个月后要拆掉它,成本有多高?

三个问题有一个答不上来,就不该加。


十一、常见误区与反例

五个高频误区

误区为什么错正确做法
「用户数到了 X 就该上技术 Y」用户数是粗糙代理指标,真正决定的是 QPS、读写比、数据量、延迟要求用监控指标触发架构演进
「微服务比单体先进」微服务解决的是组织扩展问题,小团队拆微服务是纯负收益团队小于 20 人且无人力维护基础设施时,保持单体
「分库分表是必经之路」分片是最后手段,绝大多数应用终身不需要先穷尽:优化查询 → 加索引 → 缓存 → 读副本
「连接数越多吞吐越高」连接多了上下文切换、锁竞争、缓存颠簸加剧,反而更慢(核数×2) + 磁盘数,上限 4×核数
「上云一定比自建便宜」大规模下云成本可能远超自建按规模和团队构成算总账

反例汇总

反例教训
Stack Overflow9 台服务器撑起 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微服务怎么拆?分布式事务怎么做?可观测性三支柱?微服务是组织问题不是性能问题
阶段 7CAP 怎么权衡?单元化架构是什么?混沌工程的价值?按业务分而治之,不要全局一刀切

万能收尾句

「我的设计原则是:每一步都只解决当前观测到的瓶颈。 在 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 PrimerGitHub 免费,体系完整
推荐ByteByteGoAlex Xu 的图解系统设计,有免费内容
推荐High Scalability真实公司架构案例合集
推荐AWS Well-Architected Framework官方最佳实践,尤其可靠性支柱
参考各公司工程博客(Netflix / Uber / Discord / Figma)一手实战经验

文中案例与数据来源


最后更新:2026-09-15