三个熟悉的场景
先说三个场景。它们由多个项目里的常见问题拼合而成,不对应任何一家公司,也不指向任何具体的个人。
第一个场景:系统在白天运行平稳,晚高峰一到,监控上的 P99 从 180ms 跳到 2.4s。团队的第一反应是加机器,加完发现数据库连接先打满;于是调大连接池,应用内存开始上涨;再加缓存,缓存命中率只有 40%,因为 key 设计把用户维度带了进去。折腾两周,P99 回到 600ms,没人说得清剩下的 420ms 花在哪,也没人知道下次高峰会不会再次恶化。
第二个场景:大促前做容量评估。产品给出“预计订单量翻三倍”,运维按去年峰值乘以三申请机器,开发说“应该够了”。没有人把日订单量换算成峰值 QPS,没有人测过单实例在目标 P99 下的容量,也没有人算过失去一个可用区之后还剩多少余量。大促当天流量只翻了两倍,系统却在某一个热点 SKU 上被打穿。
第三个场景:主库告警,值班同学按手册切换备库。切换脚本执行成功,健康检查也通过了,但五分钟后旧主恢复,两个节点同时接受写入。等发现时,订单表已经出现两条不同状态的主键记录。事后复盘,结论是“切换流程需要更谨慎”,没有人回答:为什么健康检查没有拦住旧主,为什么写入没有 fencing token,为什么“切换成功”没有被定义成可验证的条件。
这三个场景看起来分别属于性能、容量和高可用,根因却是同一个:团队在用感觉回答代价问题。
- 感觉“加机器就能解决”,但没有定位瓶颈;
- 感觉“乘以三就够”,但没有容量模型;
- 感觉“切过去就好了”,但没有故障域和接管条件的定义。
卷一解决的是“边界在哪里、决策怎么记、约束怎么验证”。卷二要解决的是另一个问题:在这些边界之内,系统的代价是多少,代价失控时会发生什么,怎样用可复现的方式把代价测出来、把故障关进笼子。
卷一没有回答的那一半
卷一把订单系统 arch-lab 收敛成一个模块化单体:三个核心聚合、五个 API、一套状态机、端口与适配器、明确的部署单元。这套基线解决了“什么该放在一起、什么必须分开”,但它刻意没有回答下面这些问题:
| 卷一留下的问题 | 卷二必须给出的答案 |
|---|---|
| 下单 P99 目标 400ms,实际是多少 | 端到端延迟怎么测、尾延迟怎么读、预算怎么拆 |
| 单实例能扛多少订单 | 业务指标怎么换算成 QPS 和并发,压测怎么设计 |
| 数据库慢查询从哪里来 | 索引、分页、批处理和读写分离的代价 |
| 缓存能带来多少收益 | 命中率、回源、一致性和故障时的行为 |
| 同步调用链会不会拖死整个系统 | 超时、重试、舱壁、限流和熔断的边界 |
| 一个实例挂了会怎样 | 冗余、故障域、接管条件与流量摘除 |
| 新版本怎么发、怎么回 | 金丝雀判定、兼容窗口和回滚点 |
| 出故障时怎么知道、怎么复盘 | 观测契约、告警闭环和改进项验证 |
这些问题的共同点是:它们都不能只靠画图回答。必须跑起来、测出来、在故障下观察,才能得到可信的结论。卷二的每一节都要求至少交付一项可运行产物、一次真实测量、一份带代价的决策记录,或一组跨语言对照。
本书的四个立场
立场一:先测量,再优化
没有基线的优化是猜测。看到一个接口慢,先要回答三个问题:慢在客户端、网络、服务端还是下游;慢的是平均值还是尾部;这个慢是否稳定复现。没有这三个答案,任何“优化”都可能只是把瓶颈从一个地方挪到另一个地方。
卷二不会给出“某框架比某框架快三倍”的结论。它给出的是测量方法:怎样构造负载、怎样记录分位数、怎样合并跨实例数据、怎样判断样本量是否足够。方法比结论寿命长。
立场二:看尾部,不看平均
平均值会掩盖最需要处理的那部分请求。1000 个请求里 990 个 3ms、9 个 25ms、1 个 120ms,平均值约 3.4ms,看起来很好;但那 1 个 120ms 的请求可能触发客户端超时、用户重试,甚至让整条同步链路失败。
卷二反复使用 P50、P99、P999 和最大值四个读数,并且在扇出场景里说明:十个下游各自 P99 达标,不代表端到端 P99 达标。尾延迟不是统计学细节,它直接决定超时预算、重试策略和用户体验。
立场三:故障是常态,不是例外
分布式系统里,网络会抖、磁盘会满、依赖会超时、实例会挂。把故障当成“不应该发生”的异常,系统就会在第一次真实故障时暴露出一连串未定义行为:重试放大、缓存雪崩、连接池耗尽、告警风暴。
卷二把故障当作设计输入:舱壁、限流、熔断、超时、重试、幂等、健康检查、流量摘除,每一项都不是“最佳实践清单”,而是一组需要写清触发条件、退出条件和副作用的机制。机制越多不等于越可靠;没有退出条件的机制,最后都会变成新的故障源。
立场四:每个机制都要有退出条件
缓存什么时候该撤、CQRS 什么时候不该上、熔断阈值什么时候该调、灰度什么时候该停,这些问题在方案里经常缺席。卷二要求每个机制同时写出:它解决什么问题、引入什么新失败模式、用什么指标判断它有效、什么条件下应该关闭或回退。
一条没有退出条件的架构决策,等于把系统的未来抵押给当时的假设。
arch-lab 在卷二怎么继续
卷二沿用卷一的项目,不另起炉灶。arch-lab 仍然是那个 Go 订单系统:订单、库存、支付三个聚合,同一套状态机,同一组错误码。卷二在它上面增加性能与故障治理组件:
cmd/loadgen/ 开环与闭环压测器
internal/platform/metrics/ 延迟直方图与分桶
internal/platform/cache/ TTL/LRU、singleflight、逻辑过期
internal/platform/ratelimit/ 令牌桶、滑动窗口、租约
internal/platform/circuitbreaker/ 三态熔断
internal/platform/retry/ 退避、抖动、重试预算
internal/platform/bulkhead/ 信号量舱壁
internal/platform/health/ 存活/就绪与摘流
internal/order/ 订单编排
internal/ports/ 下游接口
这些组件都遵循卷一确定的边界:internal/platform 不依赖具体业务,internal/order 通过端口调用下游,适配器可以替换。卷二的代码块仍带 file、tier、verify 三个标记,抽取后每篇独立成模块,能单独运行 gofmt、go vet 和 go test。
卷二的大部分结论是 L1(纯逻辑或单包)和 L2(进程内端到端)。原因不是偷懒,而是可复现性:一个依赖真实数据库、消息队列和网络分区的实验,很难让每个读者在相同环境下复现;而一个纯 Go 的进程内实验,至少能保证“代码逻辑和测试结论”是可验证的。真实依赖的场景会明确标注 L3,并写清环境、版本和未执行的部分。
Go 主线与 Java 21 对照
卷二继续以 Go 1.26.0 为主线。选 Go 不是因为它是“性能语言”,而是因为它的并发模型、标准库和工具链足够小,能把注意力放在架构问题上,而不是框架配置上。
Java 21 对照只在与本章主题直接相关时出现,例如线程池与 goroutine 池的扩展倍率、虚拟线程与阻塞式写法的边界。所有 Java 结论都必须真跑:本机使用 Temurin 21.0.13,javac --release 21 编译,输出保存到 .local/reports/be-arch/。没有跑过的 Java 行为,正文写成“预期行为”,不写成实测。
跨语言对照的目的不是评出胜负。两种语言的调度模型、内存模型和运行时不同,绝对吞吐不可直接比较;可比的是扩展倍率、失败模式和工程约束。例如 5.1 会说明:Go 的 goroutine 很便宜,但“每个请求一个 goroutine”仍然不能替代对下游并发的限制;Java 的虚拟线程降低了创建成本,但线程池的队列与拒绝语义仍然存在。共同结论是:池只能限制本进程,不能限制下游。
这本卷二适合谁
适合:
- 已经能写业务代码,但说不清系统在高峰时会发生什么的工程师;
- 正在做容量规划、稳定性治理或发布体系建设的后端负责人;
- 读过卷一,想把“边界决策”推进到“代价与故障”的读者;
- 需要一套可运行示例来给团队做性能与高可用培训的人。
不适合:
- 想找“某个中间件参数怎么调”的读者;本书不替代产品文档;
- 想找“百万 QPS 生产架构”的读者;本书的测量在本机完成,不承诺生产容量;
- 只想看结论、不想跑代码的读者;卷二的核心证据在测试与输出里;
- 期待覆盖 Kubernetes、服务网格、混沌平台完整操作的读者;这些属于卷四。
关于数字的承诺
卷二会出现三类数字,读的时候要分清:
| 类型 | 特征 | 例子 |
|---|---|---|
| 实测值 | 附命令、环境、样本量、运行次数 | “本机 darwin/arm64,1000 次请求,P99=20ms” |
| 目标值 | 设计目标或 SLO | “下单 P99 目标 400ms” |
| 示例值 | 用于说明公式或配置形状 | “单实例容量按 50 请求/秒计算” |
目标值和示例值不是实测值,不能拿来证明系统性能;实测值也只代表当次环境,不代表生产容量。卷二会明确区分三者,并且在只有单次运行时写“本机单次运行”,不包装成稳定结论。
四卷本里的位置
《后端架构设计》共四卷:
- 卷一《架构基础与边界》:边界、决策、契约、数据归属、演进与治理的最小闭环;
- 卷二《高性能与高可用》:测量、容量、缓存、异步、隔离、限流、超时重试、故障域、发布与观测;
- 卷三《分布式与数据一致性》:复制、共识、分区、一致性模型、分布式事务与多活;
- 卷四《演进与治理》:微服务拆分、平台工程、SLO、混沌、发布工程与成本治理。
卷二处在“单体内功”与“分布式难题”之间。它假设系统仍然是一个可部署单元,或者只有少量服务;它不讨论跨机房复制、共识算法和多活写冲突,这些留给卷三。把卷二的问题解决好,卷三的分布式复杂度才有讨论的基础;否则,分布式只会把未测量的性能问题和未定义的故障行为放大到更多节点上。
怎么读
三条路径:
路径 A:顺序读。 第 1–2 章建立测量与容量语言,第 3–7 章逐个处理缓存、异步、隔离、限流、超时重试,第 8–9 章讨论高可用拓扑与发布,第 10–11 章补观测与数据层,第 12–13 章做贯穿改造与综合规划。
路径 B:带着故障来。 先读第 1 章建立读数口径,再按症状跳转:慢读第 2、11 章,缓存问题读第 3 章,消息积压读第 4 章,线程/连接耗尽读第 5 章,雪崩读第 6–7 章,切换失败读第 8 章,发布回滚读第 9 章,告警与复盘读第 10 章。
路径 C:只做改造。 直接读第 12 章,跟着贯穿项目跑一遍基线与改造;遇到不理解的读数回查第 1–2 章,遇到机制选择回查第 3–9 章。
每一节末尾都有“小结”和“常见错误与边界”。前者是可带走的结论,后者是这套结论不适用的地方。两者同样重要。
一个判断示例
用一个具体问题走一遍卷二的判断方式。
问题是:订单查询接口 P99 超标,应该先加缓存还是先查数据库?
先测量:
- 记录成功、失败、超时三本账,确认 P99 是服务端处理时间还是端到端时间;
- 用 profile 看 CPU、内存、锁和下游等待,确认时间花在哪里;
- 用慢查询日志和索引命中率确认数据库是否是瓶颈;
- 用开环压测确认瓶颈是否随并发线性恶化。
再列备选:
- A:加本地缓存,按商品维度缓存快照,设置短 TTL;
- B:加分布式缓存,多个实例共享,接受一次网络往返;
- C:优化查询与索引,减少扫描行数;
- D:读写分离,把查询路由到副本,接受复制延迟。
然后写代价:
- A 的代价是实例间不一致和内存占用,失效逻辑必须可观测;
- B 的代价是多一次网络调用和缓存一致性窗口,缓存故障时要有回源保护;
- C 的代价是写放大和迁移窗口,索引不是越多越好;
- D 的代价是复制延迟和 read-your-writes 问题,刚写完立即读必须回主库。
最后写触发条件:
- 如果 profile 显示数据库扫描是主因,先做 C;
- 如果 C 之后 P99 仍受数据库连接数限制,再评估 D;
- 如果读多写少且能接受短暂陈旧,A 或 B 才有收益;
- 任何缓存方案都必须同时实现穿透保护、失效可观测和故障回退,否则不上线。
这套推导不保证结论永远正确。它保证的是:每个选择都有证据、有代价、有退出条件。卷二每一章想提供的,就是这种可以复查的判断粒度。
与站内文章的关系
站内 architecture/ 与 distributed-systems/ 两个专题已经覆盖了缓存策略、限流熔断、高可用、发布、链路追踪等题目。卷二不重复这些文章的定义和清单,只在必要处用一段话概括,并用站内链接指过去。卷二新增的是:arch-lab 的可运行产物、真实测量、故障注入结果,以及带退出条件的决策记录。
如果你已经读过那些文章,卷二可以当作“把它们放进同一个订单系统,真正跑一遍”的实践册;如果你没读过,也不影响阅读,正文会在第一次出现概念时给出最小定义和延伸阅读入口。
一个容量问题的算例
把“加机器”还原成一组可检查的输入,才能看出问题在哪。
假设产品给出三个业务指标:
| 输入 | 数值 | 类型 | 来源 |
|---|---|---|---|
| 日订单量 | 20 万 | 目标值 | 产品计划 |
| 峰值小时占比 | 12% | 目标值 | 历史分布假设 |
| 每单请求数 | 8 | 示例值 | 创建、查询、支付、重试等合计 |
| 创建订单占比 | 25% | 示例值 | 业务结构假设 |
| 下单 P99 目标 | 400ms | 目标值 | SLO 草案 |
先算平均和峰值:
- 日请求数 = 20 万 × 8 = 160 万;
- 平均 QPS = 160 万 ÷ 86400 ≈ 18.5;
- 峰值小时请求数 = 160 万 × 12% = 19.2 万;
- 峰值小时平均 QPS = 19.2 万 ÷ 3600 ≈ 53.3;
- 如果峰值小时内还有 1.5 倍的分钟级尖峰,设计峰值约为 80 QPS。
再算并发。按 Little’s Law,并发 ≈ QPS × 平均延迟。若创建订单的平均延迟是 80ms、P99 是 400ms:
- 按平均延迟估算的稳态并发 ≈ 80 × 0.08 ≈ 6.4;
- 按 P99 估算的安全并发 ≈ 80 × 0.4 = 32;
- 这两个数不是“谁对谁错”,它们回答不同问题:前者是平均资源占用,后者是尾部同时占用。
最后算实例数。假设单实例在目标 P99 下能承载 50 QPS(示例值,必须用压测替换),目标利用率 60%,并且要求失去一个实例后仍能承载峰值:
- 无冗余实例数 = ceil(80 ÷ (50 × 0.6)) = ceil(2.67) = 3;
- N+1 后 = 4;
- 失去一台后,剩余 3 台的总能力 = 3 × 50 = 150 QPS,利用率 = 80 ÷ 150 ≈ 53%,仍低于 60%;
- 如果单实例实际只能承载 40 QPS,则剩余能力 = 120 QPS,利用率 ≈ 67%,已经越过目标线,需要 5 台。
这个算例里,只有“20 万订单”是业务输入,其余都是假设或示例。容量规划的价值不在于算出 4 台,而在于把每个假设标出来:哪个数字一改,结论就会翻。把示例值写成实测值,规划就从“可审计”变成“看起来精确”。
性能治理的三个反模式
反模式一:把平均值当目标
“接口平均响应 50ms”听起来很好,但如果 P99 是 2s,仍然有大量用户在等待。平均值适合看趋势,不适合做承诺。承诺必须绑定分位数、统计窗口和样本量,否则同一个系统可以报出任意好看的平均值。
更隐蔽的问题是:失败请求很快返回,会把平均值拉低。一次 500 错误只花 1ms,它不改善用户体验,却改善了平均值。所以延迟统计必须先按成功、失败、超时分开,再谈分位数。
反模式二:把机制当结论
“我们上了熔断”“我们用了缓存”“我们做了限流”都不是结论,只是手段。真正要回答的是:
- 熔断在什么条件下打开,打开后用户看到什么;
- 缓存命中率是多少,失效失败时回源会不会打穿数据库;
- 限流阈值依据是什么,被限流的请求是排队、降级还是拒绝;
- 这些机制的指标在哪里看,谁来判断它们有效。
没有这些答案,机制只是增加了系统的部件数量,没有增加可预测性。
反模式三:把单次压测当容量
一次 10 分钟的压测,能说明“在这个数据、这个并发、这个机器上,系统大致是什么表现”;它不能说明“生产环境能扛多少”。生产的数据分布、依赖延迟、网络质量和流量结构都不同,压测结论必须带上边界:数据规模、并发模型、运行时长、失败率、是否预热、机器规格。
卷二会反复区分“本机单次运行”和“生产容量结论”。前者是证据,后者需要生产环境的测量和持续校准。
一次测量是否可信
拿到一个性能数字,可以用下面这张表快速判断它的可信度。
| 检查项 | 可信的做法 | 不可信的做法 |
|---|---|---|
| 命令 | 给出完整命令和参数 | 只写“压测了一下” |
| 环境 | 写明机器、OS、语言/运行时版本 | 不写环境,直接给绝对值 |
| 负载 | 写明并发模型、到达率、数据规模 | 只写“1000 并发” |
| 样本 | 写明请求数、运行次数、失败数 | 用 100 个请求谈 P999 |
| 统计 | 分位数、窗口、是否合并直方图 | 把各实例 P99 直接平均 |
| 结论 | 限定在当次环境和负载内 | 外推成生产容量承诺 |
| 失败 | 记录超时、错误、重试放大 | 只统计成功请求 |
这张表也适用于读书里的数字。如果正文只给结论不给命令,读者应该保留怀疑;如果给了命令和原始输出,至少可以复现或证伪。
还有一个容易被忽略的检查项:基线是否相同。改造前后的对比必须使用同一份数据、同一个负载模型、同一台机器和同一个统计窗口,否则“提升 30%”可能只是换了一个更轻的请求。
具体来说,基线对比至少要固定:
- 同一份输入数据与随机种子;
- 同一个到达率或并发模型;
- 同一个预热时间和测量窗口;
- 同一套成功/失败/超时判定;
- 同一种直方图桶和分位数算法。
少固定一项,数字就失去可比性。卷二第 12 章的改造对照会把这些条件写进测试,而不是只给一张“改造前/改造后”的表。
本书不做什么
- 不教 Go 语法、标准库和运行时细节;
- 不教 PostgreSQL 安装、参数调优、备份恢复;
- 不教 Kubernetes、服务网格、CI/CD 平台的具体配置;
- 不推导共识算法、复制协议和一致性模型;
- 不承诺生产容量,不给出“照着配就能扛多少”的结论;
- 不把单机测量包装成线上性能;
- 不覆盖组织绩效、招聘和预算审批流程。
这些内容分别在语言书、数据库手册、卷三、卷四中处理。卷二只在必要处提到它们,并说明边界。
验证环境与复现方式
卷二的本机验证环境如下:
| 组件 | 版本 | 用途 |
|---|---|---|
| Go | 1.26.0 | 全书主线代码 |
| Java | Temurin 21.0.13 | 线程池与虚拟线程对照 |
| 操作系统 | macOS darwin/arm64 | 本机测量环境 |
| Hugo | 0.163.3+extended | 书稿构建 |
几条复现约定:
- 每个 Go 代码块带
// file:标记,抽取到独立模块后执行gofmt -l .、go vet ./...、go test ./...; - 并发相关的测试额外执行
go test -race ./...; - Java 对照使用
javac --release 21,运行输出保存到.local/reports/be-arch/; - 真实依赖(PostgreSQL、Redis、消息队列)如果没有实际启动,正文明确写“未实测”,不写成结论;
- 所有性能数字附命令、并发、数据规模和运行次数;单次运行不外推。
如果读者环境不同,绝对数字会变,这是正常的。卷二希望传递的是测量方法和判断链条,而不是一组可以照抄的常数。
最后
性能和高可用不是两个独立的“非功能需求”,它们是同一件事的两面:系统在正常负载下要快,在异常负载下要活着。只谈快不谈故障,会得到一套在第一次依赖超时时崩溃的优化;只谈故障不谈代价,会得到一套成本失控的冗余。
卷二的目标不是让你记住更多机制,而是让你在面对一个具体系统时,能回答四个问题:现在的代价是多少、瓶颈在哪里、故障时会发生什么、什么时候应该回退。能回答这四个问题,比记住任何“最佳实践”都更有用。
从 1.1 开始。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。