目录

高并发系统设计

高并发不是一种神秘的架构流派,它只是普通系统在流量、扇出和错误假设叠加之后的真实工作状态。

很多“高并发系统设计”文章不好用,是因为它们习惯把主题写成名词表:

  • 负载均衡
  • 缓存
  • 消息队列
  • 分库分表
  • 熔断
  • 自动扩缩容

这些东西都没错,但把名词列全,不等于设计做对。

真实的高并发设计,通常绕不开四个问题:

  1. 先坏的到底是哪一层?
  2. 每一层的容量预算是什么?
  3. 负载上来时,系统怎么优雅变慢,而不是直接垮掉?
  4. 我们怎么在生产教训出现之前,知道自己的判断错了?

这篇文章就按这个角度来讲。

先找瓶颈,不要先堆组件

任何系统都有吞吐上限,只是上限不一定在你以为的地方。

常见的第一批瓶颈包括:

  • 数据库连接池被打满
  • 热行 / 热分区竞争
  • 缓存击穿或缓存雪崩
  • 请求路径里同步调用太多下游
  • CPU 被序列化、压缩、加密、模板渲染吃满
  • 代码内部锁竞争
  • 消费者处理不过来,消息堆积
  • 磁盘 I/O 被日志、索引、落盘拖住

所以,别一上来就问“要不要上 Kafka”,先回答:

在 10 倍流量下,今天的系统最先卡在哪?

一个很实用的方法,是把一个高价值请求完整走一遍:

  • 入口层
  • 鉴权
  • 缓存
  • 数据库
  • 内部 RPC
  • 外部 API
  • 异步副作用
  • 响应序列化

然后看它在哪些地方会:

  • 排队
  • 阻塞
  • 重试
  • 放大负载

高并发设计,本质上就是在这些地方做约束和保护。

容量预算,比“我觉得能扛住”有用得多

很多事故不是因为团队完全没做容量规划,而是因为他们只知道平均流量,不知道真实预算。

至少应该有一批粗但明确的数字:

  • 稳态 RPS
  • 峰值突发能力
  • p95 / p99 延迟目标
  • 可接受的最大队列深度
  • 单请求最多几次 DB 查询
  • 单请求最多扇出几个下游
  • 连接池上限
  • 内存余量
  • 缓存命中率假设

这些数字不需要假装特别精确,但不能没有。

比如一个请求平均会触发:

  • 3 次数据库读
  • 2 次缓存读
  • 1 次下游调用

那 2000 RPS 从来不只是“2000 个请求”,而是:

  • 6000 次数据库读 / 秒
  • 4000 次缓存操作 / 秒
  • 2000 次下游调用 / 秒

一旦重试打开,放大效应会更明显。

队列是缓冲,不是遮羞布

消息队列很好用,但也是最容易被滥用的组件之一。

它适合这些场景:

  • 削峰填谷
  • 把慢任务从同步请求里剥出去
  • 吸收下游的短时抖动
  • 把非实时工作移出关键路径

但队列很容易被误用成一种“问题推迟术”。

下面这些情况,队列并没有真正解决问题:

  • 生产速度长期大于消费速度
  • 没人盯 backlog 和 lag
  • 下游本来就处理不了最终吞吐
  • poison message 一直重试回队列
  • 用户还以为这是个同步 SLA 的能力

真正该问的不是“要不要上队列”,而是:

  • 哪些工作真的可以离开请求路径?
  • 能接受多大的处理延迟?
  • 积压到什么程度算不可接受?
  • 队列爆了以后,是限流、丢弃,还是降级?

没有背压策略的队列,只是延迟失败。

背压不是可选项

如果系统接收工作的速度,长期高于处理工作的速度,那最后只会发生两种事:

  • 你主动做背压
  • 系统自己用超时、OOM、雪崩来替你做背压

主动背压通常包括:

  • 有上限的队列
  • 每租户限流
  • 并发数限制
  • admission control
  • 上游生产者节流
  • 低优先级任务提前拒绝
  • 在压力下减少扇出

这不是“服务变弱了”,而是为了避免整套系统一起死。

一个很重要的设计问题是:
你准备在哪一层开始拒绝工作?

  • 在边缘入口
  • 在内部队列
  • 在某个依赖前
  • 按租户级别
  • 按功能优先级

如果你不选,生产会替你选,而且通常选得很难看。

负载丢弃要先保护关键路径

高并发系统里,不是所有请求都该被同等对待。

比较靠谱的系统,一定会区分:

  • 用户核心操作
  • 最终一致性的副作用
  • 分析类、统计类附加工作
  • 昂贵的 enrich 操作
  • 后台低优先级任务

当压力升高时,一个常见且健康的退化顺序应该是:

  1. 先砍可选 enrich
  2. 再延后非关键副作用
  3. 再减少昂贵的跨服务扇出
  4. 再拒绝低优先级流量
  5. 最后尽量保住最小核心路径

这比让所有请求一起慢死、一起超时,要健康得多。

一旦有重试,就必须认真谈幂等

高并发系统里,重试不是偶发事件,而是日常事实:

  • 客户端会重试
  • 代理层会重试
  • worker 会重试
  • 用户自己会刷新、会重复点击

如果一个操作不是幂等的,那高并发压力下,小抖动就很容易放大成:

  • 重复扣款
  • 重复下单
  • 重复发消息
  • 重复状态迁移
  • 数据写坏

应该明确考虑幂等性的场景包括:

  • 支付类操作
  • create 操作
  • 状态流转
  • 消费消息
  • webhook 接收
  • 定时任务

常见手段有:

  • idempotency key
  • 去重窗口
  • 业务唯一键
  • compare-and-swap
  • 接受 at-least-once,然后把 handler 做成安全重入

在高并发系统里,“可安全重试”通常比“理论上完美的一次性执行”更重要。

数据库仍然是最容易说真话的地方

再现代的架构,最后很多问题还是会在数据库层暴露得最直白。

数据库在高并发下最常见的痛点:

  • 连接数打满
  • 热索引页竞争
  • 本该缓存的热点读反复打库
  • 写入集中在单行 / 单 key
  • 事务范围过大
  • 平时不慢、流量一上来就拖死的查询
  • 锁竞争被重试进一步放大

通常更有效的优化方向是:

  • 减少单请求 DB 往返次数
  • 把索引收紧
  • 缩短事务生命周期
  • 不必要时避免 read-after-write
  • 把真正热点读缓存起来
  • 分离分析类和事务类查询路径
  • 把非关键写入异步化

不要一遇到压力就立刻喊分库分表。
很多系统在走到那一步之前,查询形状和访问纪律都还有大量优化空间。

分区 / 分片不是升级勋章,而是复杂度交换

有些规模下,分区和分片确实必要。
但它带来的不是“纯收益”,而是用复杂度换容量。

代价通常包括:

  • key 分布不均
  • 热分区
  • 重平衡困难
  • 跨分区查询变丑
  • 事务更难做
  • 运维工具链要求更高

好的分片键,应该能同时做到两件事:

  • 把负载尽量打散
  • 让常见访问路径尽量局部化

坏的分片键,最后通常会得到:

  • 一个特别热的分片
  • 迁移噩梦
  • 查询奇技淫巧
  • 高峰期被迫重分片

分片不是因为表大了就该做,而是因为工作负载形状确实支持它

缓存能不能扛住压力,比“有没有缓存”更重要

缓存不是天然的高并发解法。
一个行为糟糕的缓存,甚至会制造比“没有缓存”更差的故障模式。

高压下常见问题包括:

  • key 过期时集中击穿
  • 命中率很低,但系统还背着缓存的复杂度
  • 热 key 被打爆
  • stale 语义没人讲清楚
  • miss 后过于激进地回源数据库
  • miss 时重建代价太高

常见的有效做法包括:

  • request coalescing
  • TTL 加 jitter
  • 热 key 后台刷新
  • 本地缓存 + 共享缓存分层
  • 明确 stale-while-revalidate 策略

缓存真正应该做的是削压,不是把压力重新同步成更大的脉冲。

用户真正感受到的是尾延迟,不是平均值

平均延迟这个指标很容易让人放松警惕。

很多高并发问题,真正会暴露在:

  • p95
  • p99
  • 更高尾部

原因通常很现实:

  • 某个 shard 偶尔很慢
  • 某个依赖在抖
  • 某次 GC 卡住了
  • 某个队列开始堆积
  • 某把锁形成 convoy
  • 某个大对象序列化太贵

只要一个请求要扇出多个下游,那么“至少有一个慢”的概率就会迅速上升。
这就是很多平时看着挺健康的系统,一上量就尾延迟难看的原因。

改善尾延迟,通常靠这些动作:

  • 减少同步扇出
  • 收紧 timeout
  • 在允许的场景下做部分响应
  • 谨慎使用 hedging
  • 控制队列纪律
  • 防止重试风暴打爆热点依赖
  • 避免超大 payload 和超大查询结果

用户一般不在乎你的 p50 多漂亮。
他们在乎的是:这个系统会不会经常“偶尔像坏了一样”。

异步工作流是把工作移出去,不是把责任甩出去

一个很常见的设计套路是:

  • 请求进来
  • 先记个事件
  • 重活后面慢慢做

有时候这是对的。
有时候只是把 SLA 偷偷藏起来了。

异步工作流适合这些情况:

  • 用户不需要立刻完成
  • 工作本身很慢或很突发
  • 下游不够稳定
  • 允许最终一致
  • 结果可以轮询或回调获取

但异步并不等于“后面再说”,它仍然需要明确这些东西:

  • 状态机
  • 重试策略
  • 去重策略
  • poison message 处理
  • lag 可观测性
  • 过期与补偿规则

你只是把工作移到了请求路径外,不是把它从系统里删掉。

可观测性不是为了好看,而是为了看懂压力怎么传导

高并发系统里的可观测性,不是“我们有 dashboard 就行”。
真正目标应该是:

当延迟、错误率、积压突然变差时,我们能不能快速看懂压力从哪一层开始传导?

至少该监控这些:

  • 请求速率
  • 成功 / 错误 / 超时比例
  • 队列深度和消息年龄
  • 数据库连接池使用率与等待时间
  • 缓存命中 / miss / 热 key 分布
  • 下游延迟和失败率
  • worker 并发数
  • p50 / p95 / p99 延迟
  • 重试率
  • 被拒绝 / 被丢弃 / 被降级的请求数

同时日志和 trace 最好能回答:

  • 哪个依赖先慢了?
  • 队列是从哪开始积压的?
  • 错误是 overload、timeout 还是 bad input?
  • 这次重试是不是把问题放大了?

面板很容易做,能解释问题的观测体系才重要。

韧性测试要打你的假设,不要只打 happy path

很多团队做压测,其实只是把正常流量放大一下。
这类测试有用,但远远不够。

更有价值的测试包括:

  • 下游变慢,而不是直接挂掉
  • 缓存失效或命中率大幅下降
  • 消费者 lag 拉长
  • 热 key / 热租户集中打
  • 重复消息投递
  • 部分网络分区
  • 服务恢复后瞬时回流形成 herd
  • 高峰期发一个坏配置

这类测试真正打的是你的架构假设。

比如:

  • “缓存会扛住”
  • “队列迟早能追上”
  • “数据库池子够大”
  • “重试不会形成风暴”
  • “单个租户不可能把整个 shard 打热”

这些话都该在生产前被认真怀疑一次。

常见反模式

请求路径里同步扇出太多,还没预算

平时好像没事,一上量就满屏 timeout。

队列无限长

这不是容量增加,只是把痛苦存起来以后再一起爆。

每一层都在重试

慢请求很快就会被你们自己打成重试风暴。

一上来就喊分库分表

很多时候是在跳过更基础的查询和访问纪律优化。

状态变更操作不做幂等

平时看不出来,第一波重试风暴就会教做人。

把缓存当信仰

没人知道命中率、失效策略、stampede 风险,那它就不是方案,只是希望。

不理解瓶颈就盲目扩容

如果瓶颈在数据库,你多起应用实例,可能只会更快把数据库打死。

最后一句

高并发设计不是拼谁会背更多架构名词,而是看谁能更早承认系统承受的是真实负载形状,并围绕这个形状做约束。

一个比较靠谱的顺序通常是:

  1. 先找真实瓶颈
  2. 再做容量预算
  3. 把非关键工作移出热路径
  4. 用有上限的队列和背压保护系统
  5. 保护数据库和缓存不被放大效应拖死
  6. 用幂等保证重试可控
  7. 盯尾延迟,不只盯平均值
  8. 用压测和故障演练去打自己的假设

做到这些以后,“高并发”就不再是一个很玄的架构标签,而是很朴素的系统工程基本功。