高并发系统设计

高并发不是一种神秘的架构流派,它只是普通系统在流量、扇出和错误假设叠加之后的真实工作状态。
很多“高并发系统设计”文章不好用,是因为它们习惯把主题写成名词表:
- 负载均衡
- 缓存
- 消息队列
- 分库分表
- 熔断
- 自动扩缩容
这些东西都没错,但把名词列全,不等于设计做对。
真实的高并发设计,通常绕不开四个问题:
- 先坏的到底是哪一层?
- 每一层的容量预算是什么?
- 负载上来时,系统怎么优雅变慢,而不是直接垮掉?
- 我们怎么在生产教训出现之前,知道自己的判断错了?
这篇文章就按这个角度来讲。
先找瓶颈,不要先堆组件
任何系统都有吞吐上限,只是上限不一定在你以为的地方。
常见的第一批瓶颈包括:
- 数据库连接池被打满
- 热行 / 热分区竞争
- 缓存击穿或缓存雪崩
- 请求路径里同步调用太多下游
- 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 操作
- 后台低优先级任务
当压力升高时,一个常见且健康的退化顺序应该是:
- 先砍可选 enrich
- 再延后非关键副作用
- 再减少昂贵的跨服务扇出
- 再拒绝低优先级流量
- 最后尽量保住最小核心路径
这比让所有请求一起慢死、一起超时,要健康得多。
一旦有重试,就必须认真谈幂等
高并发系统里,重试不是偶发事件,而是日常事实:
- 客户端会重试
- 代理层会重试
- 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 风险,那它就不是方案,只是希望。
不理解瓶颈就盲目扩容
如果瓶颈在数据库,你多起应用实例,可能只会更快把数据库打死。
最后一句
高并发设计不是拼谁会背更多架构名词,而是看谁能更早承认系统承受的是真实负载形状,并围绕这个形状做约束。
一个比较靠谱的顺序通常是:
- 先找真实瓶颈
- 再做容量预算
- 把非关键工作移出热路径
- 用有上限的队列和背压保护系统
- 保护数据库和缓存不被放大效应拖死
- 用幂等保证重试可控
- 盯尾延迟,不只盯平均值
- 用压测和故障演练去打自己的假设
做到这些以后,“高并发”就不再是一个很玄的架构标签,而是很朴素的系统工程基本功。