高并发
开篇:什么是"高并发"?
想象一下双 11 零点的淘宝 -- 几亿用户同时点击"立即购买",每一秒涌入的请求比平时多出几十上百倍。服务器就像一家只有 5 个服务员的餐厅,突然涌进来 500 位客人。如果没有任何应对措施,这家餐厅只会陷入混乱,谁也吃不上饭。
高并发的本质,是在有限的资源下,尽可能多地处理请求,同时保证系统不崩溃。
要做到这一点,我们有三大武器,也有三道防线。接下来一个一个聊。
一、高并发的三大武器:缓存、异步、集群
在深入限流、降级、熔断之前,先了解最基础的三板斧。
1.1 缓存:把常用的东西放在手边
去图书馆查资料,每次都跑到书库去翻,效率很低。但如果你把常看的几本书放在桌上,查起来就快多了。缓存做的就是这件事 -- 把高频访问的数据放到内存中,避免每次都去数据库查询。
浏览器缓存 → CDN → 本地缓存(Caffeine) → 分布式缓存(Redis) → 数据库越靠近用户的缓存,效果越好,但数据一致性越难保证。这就像你桌上的书可能不是最新版的 -- 你需要在"快"和"准"之间做权衡。
1.2 异步:不用排队等结果
去银行办业务,柜员说"您先坐,办好了叫您"。你不用一直站在窗口等着,可以先去办别的事。这就是异步 -- 把不需要立刻完成的操作放到消息队列中,后台慢慢处理。
异步的好处不仅是快,还能削峰填谷 -- 上游可以瞬间写入大量消息,下游按自己的节奏消费。
1.3 集群:人多力量大
一个服务员忙不过来,那就多雇几个。集群部署的核心思想就是水平扩展 -- 把同一个服务部署到多台机器上,通过负载均衡分配请求。
但是集群并不是万能的。10 个人能搬 10 倍的砖,却不能让一个孕妇在一个月内生出孩子。有些问题(比如数据库热点行更新)并不能通过简单加机器解决。这时候就需要更精细的设计,比如分库分表、热点数据隔离等。
二、限流:给系统装一个水龙头
2.1 为什么要限流?
假设你的系统最多能处理 1000 QPS,突然来了 5000 QPS 的流量。如果全部放进去,系统会被压垮,所有人都用不了。但如果只放进 1000 个请求,剩下的排队或者拒绝,至少能保证这 1000 个请求正常服务。
限流的本质:宁可拒绝一部分请求,也不能让系统整体崩溃。
2.2 四种经典限流算法
计数器(固定窗口)
最简单的方案:每秒最多处理 N 个请求,超过就拒绝。
问题在于临界突变 -- 比如限制每秒 100 个请求,第 0.9 秒来了 100 个,第 1.1 秒又来了 100 个。虽然每个 1 秒窗口内都没超限,但在 0.9 到 1.1 秒这 0.2 秒内,系统承受了 200 个请求的冲击。
滑动窗口
把时间窗口切成更细的小格子(比如把 1 秒切成 10 个 100ms 的格子),窗口随时间滑动,统计最近 1 秒内所有小格子的总请求数。
窗口每前进一个格子,就把最老的格子的计数去掉,加上最新格子的计数。这样能更平滑地控制流量,避免临界突变问题。
实现上常用 Redis 的 sorted set 按时间戳打分来统计。也可以用环形数组在本地实现,Sentinel 的滑动窗口就是这么做的。
漏桶算法
漏桶就像一个底部有小孔的水桶 -- 不管水龙头开多大(请求来多快),水只会以固定速率从小孔流出。
输入:不定速率的请求
↓
[ 漏 桶 ] ← 固定容量,满了就溢出(拒绝)
↓
输出:固定速率处理打个比方:漏桶每秒漏 1 滴水,那么 1 秒钟只能处理 1 个请求。前 4 秒没有请求,第 5 秒同时来了 5 个请求 -- 漏桶也只能处理 1 个,其余 4 个要么排队、要么被拒绝。
优点是输出绝对匀速,适合对下游有严格速率要求的场景。缺点是无法应对突发流量 -- 即使系统此刻很空闲,也只能按固定速率处理。
令牌桶算法
令牌桶换了一个思路 -- 以固定速率往桶里放令牌,请求来了取一个令牌才能执行。桶满了令牌就溢出不再添加。
[令牌生成器] → 每秒放入 N 个令牌
↓
[ 令牌桶 ] ← 固定容量 M
↓
请求到达 → 取令牌 → 有令牌则执行,无令牌则拒绝还是刚才的例子:每秒生成 1 个令牌,前 4 秒没有请求,桶里攒了 4 个令牌(加上第 5 秒新生成的 1 个,共 5 个)。第 5 秒来了 5 个请求,一次全部取走 5 个令牌,5 个请求都能立刻执行。令牌桶天然支持突发流量。
这也是 Guava RateLimiter 的底层实现原理。在 Java 中使用非常简单:
// 创建一个每秒生成 100 个令牌的限流器
RateLimiter rateLimiter = RateLimiter.create(100);
public void handleRequest(Request request) {
if (rateLimiter.tryAcquire()) {
process(request); // 获取到令牌,处理请求
} else {
reject(request); // 没有令牌,拒绝请求
}
}四种算法对比
| 算法 | 突发流量 | 匀速输出 | 实现复杂度 | 典型实现 |
|---|---|---|---|---|
| 固定窗口 | 有临界问题 | 否 | 低 | 简单计数器 |
| 滑动窗口 | 较好 | 否 | 中 | Redis sorted set |
| 漏桶 | 不支持 | 是 | 中 | 消息队列 |
| 令牌桶 | 支持 | 否 | 中 | Guava RateLimiter |
一句话总结:漏桶适合严格匀速的场景,令牌桶适合允许一定突发的场景。大多数互联网系统选择令牌桶。
2.3 单机限流 vs 集群限流
单机限流用 Guava 的 RateLimiter 就能搞定,保护的是单台机器不被打垮。
集群限流则需要借助 Redis 或 Sentinel 等分布式组件,控制整个集群的总 QPS。
两者缺一不可,这里有两个容易踩的坑:
只有集群限流,没有单机限流 -- 集群限制 1000 QPS,10 台机器理想情况下平均每台 100。但"平均"二字是最不可靠的。受同机房优先、同单元优先等路由策略影响,流量很容易倾斜。某台机器可能承担了 300 QPS,直接被打挂。
还有一个更常见的场景:应用发布时部分机器下线,在线机器的平均 QPS 被动上升。10 台变 5 台,每台从 100 QPS 变成 200 QPS,如果单机只能扛 150,就出问题了。
只有单机限流,没有集群限流 -- 单机能扛 500 QPS,10 台机器总共能扛 5000 QPS。但下游数据库未必能扛 5000。如果不断扩容而不设集群限流,下游可能先倒下。
正确做法:两者结合使用。
2.4 自适应限流
固定阈值的限流有一个问题:系统的实际承载力是动态变化的。服务器状态好的时候能抗 2000 QPS,GC 的时候可能只能抗 500。
自适应限流的思路是根据系统的实时指标动态调整限流阈值。给系统设定一条"水位线",水位以下正常运行,水位以上自动限流保护。
Sentinel 框架支持自适应限流,支持以下阈值类型:
| 指标 | 说明 | 参考值 |
|---|---|---|
| Load | 系统 load1 (仅 Linux) | CPU cores x 2.5 |
| CPU usage | CPU 使用率 | 0.6 (60%) |
| RT | 所有入口流量的平均响应时间 | 根据业务定 |
| 线程数 | 所有入口流量的并发线程数 | 根据压测定 |
| 入口 QPS | 所有入口流量的总 QPS | 根据压测定 |
核心配置示例:
SystemRule rule = new SystemRule();
rule.setHighestCpuUsage(0.6); // CPU 使用率超 60% 触发
rule.setHighestSystemLoad(3.0); // 系统 Load 超 3 触发
rule.setAvgRt(10); // 平均 RT 超 10ms 触发
rule.setQps(20); // 总 QPS 超 20 触发
rule.setMaxThread(10); // 并发线程数超 10 触发
SystemRuleManager.loadRules(Collections.singletonList(rule));三、服务降级:丢车保帅
3.1 从饭店说起
去饭店吃饭,不忙的时候服务员会拿问卷让你填用户反馈。但到了饭点高峰期,服务员连上菜都来不及,谁还有空发问卷?这时候就会把"问卷调查"这个功能降级掉 -- 不是永远不做了,而是等不忙的时候再做。
降级和彻底关闭不同。彻底关闭是"这个功能没了",降级更像是"这个功能换了一种更省资源的方式运行"。比如把问卷调查从"当面填写"降级为"离店后发短信",功能还在,只是换了一种更轻量的方式。
3.2 淘宝详情页的降级预案
一个淘宝商品详情页至少有 15 个功能模块:图片、标题、价格、库存、推荐、评价、物流、收藏、下单......
这些模块分布在十几个不同的应用中。详情页渲染一次,就要和十几个应用做网络交互。双 11 当天,不可能所有模块都保持 100% 可用,必须有所取舍:
识别哪些是核心功能、哪些是非核心功能,然后对非核心功能制定降级方案 -- 这个过程叫做降级预案。这是每次大促前的必修课。
3.3 降级的常见方式
| 降级方式 | 说明 | 举例 |
|---|---|---|
| 延迟服务 | 操作仍执行,但延后处理 | 发表评论后延迟给用户加积分 |
| 关闭功能 | 直接关掉非核心功能 | 隐藏"相关推荐"模块 |
| 读降级 | 降级为只读缓存 | 库存查询降级为读缓存中的近似值 |
| 写降级 | 降级为写缓存 | 秒杀只更新 Cache,异步同步到 DB |
| 页面降级 | 异步请求降级或页面跳转 | 推荐信息加载超时则不展示 |
双 11 零点到次日零点期间无法退款,其实就是"关闭功能"这种降级方式。
3.4 降级的触发方式
自动降级 -- 系统根据指标自动触发:
- 超时降级:下游服务 RT 超过阈值,自动降级。比如推荐服务超过 200ms 没返回,直接展示空白或默认推荐。
- 失败次数降级:下游服务连续失败 N 次后触发降级。注意:如果只降级单次失败的请求,其他请求还会继续调用已经出问题的下游,雪上加霜。所以通常是设一个阈值,整体失败率达标后统一降级一段时间。
- 限流降级:QPS 超过阈值,后续请求直接降级返回。比如秒杀超量后提示"已售罄"或让用户输入验证码重试。
- 故障降级:下游返回固定错误码或 RPC 抛异常,直接认定故障,对其降级。
人工降级 -- 运维人员手动操作:
- 大促前提前降级非核心功能,释放资源给核心链路
- 系统异常时紧急关闭某个功能止血
- 通过定时任务在特定时间段自动触发(比如零点切换)
实际项目中,两者结合使用。关键场景配自动降级规则,同时保留人工介入的能力。
3.5 降级工具
目前主流的两个选择:
- Hystrix (Netflix) -- 以隔离和熔断为主的容错机制,2018 年停更但最新版 1.5.18 仍可使用
- Sentinel (Alibaba) -- 侧重多样化的流量控制、熔断降级、系统保护,提供实时监控控制台
对于新项目,推荐 Sentinel,功能更全面,社区更活跃。
四、熔断:给调用链装一个保险丝
4.1 服务雪崩
在一个微服务系统中,服务 A 调用服务 B,服务 B 调用服务 C。如果服务 C 挂了,服务 B 的请求会堆积超时,拖慢服务 B;服务 A 调用服务 B 也会超时,最终整条链路全部崩溃。这就是服务雪崩。
更要命的是重试机制。服务 C 本来就快撑不住了,上游还在不断重试 -- 框架级的自动重试、代码里的重试逻辑、用户的手动刷新,三重暴击把下游彻底打趴。
大多数服务雪崩,归根结底都是不断重试导致的。但完全不设计重试机制也不行,那是因噎废食。正确的做法是引入熔断器。
4.2 熔断器的三种状态
熔断器的灵感来自电路中的保险丝 -- 电流过大时自动断开,保护电路不被烧毁。
三种状态的详细说明:
- 关闭状态(正常):所有请求正常通过。内部有计数器统计错误次数,一旦达到阈值(比如连续 5 次失败),切换到开启状态,同时启动一个计时器。
- 开启状态(熔断中):所有请求直接拒绝,快速返回错误或降级结果。避免继续给已经出问题的下游增加压力。计时器到期后(比如 10 秒后),切换到半开状态。
- 半开状态(试探中):放行少量请求作为试探。如果这些请求都成功了,说明下游恢复了,切回关闭状态,重置计数器;如果有任何一个请求失败,立刻切回开启状态,重新计时,给系统多一些恢复时间。
熔断带来两个核心能力:
- 快速失败 -- 下游出问题时不再傻等超时,直接返回错误或降级结果,保护上游不被拖垮
- 无缝恢复 -- 通过半开状态自动探测下游是否恢复,不需要人工干预
4.3 熔断工具选型
| 工具 | 状态 | 特点 |
|---|---|---|
| Hystrix (Netflix) | 已停更 (2018) | 基于线程池/信号量隔离,最经典的熔断框架 |
| resilience4j | 活跃维护 | 轻量级,函数式 API,Hystrix 官方推荐替代品 |
| Sentinel (Alibaba) | 活跃维护 | 流量控制 + 熔断降级 + 系统保护一体化,功能最全 |
resilience4j 和 Hystrix 的几个关键差异:
- Hystrix 需要把调用封装到
HystrixCommand里;resilience4j 用装饰器模式,可以简洁地组合多种高可用机制 - Hystrix 用滑动窗口统计频次;resilience4j 用环状缓冲区,内存效率更高
- 半开状态的转换:Hystrix 只用一次请求判断;resilience4j 支持配置执行次数和阈值,稳定性更好
- 隔离机制:Hystrix 提供线程池和信号量两种隔离;resilience4j 只提供信号量隔离
对于新项目,建议使用 Sentinel -- 它不仅支持熔断,还集成了限流、降级、系统保护等能力,并且提供实时监控控制台。
五、异步化设计:削峰填谷
5.1 消息队列解耦
同步调用就像打电话 -- 对方不接你就得一直等着。异步调用像发微信 -- 发完就干别的事,对方什么时候回复都行。
消息队列是异步化最常用的手段。它的核心作用是削峰填谷:
上游可以瞬间写入大量消息,下游按自己的处理能力匀速消费。这样即使瞬间流量是平时的 5 倍,下游也不会被压垮 -- 只是处理会有一定延迟而已。
5.2 线程池隔离
假设你的服务同时对接了服务 A(正常,RT 10ms)和服务 B(很慢,RT 3000ms)。如果共用一个线程池(比如 20 个线程),服务 B 的慢请求会迅速把 20 个线程全部占满,导致服务 A 的请求也没有线程可用。
这就像一个餐厅只有一个取号窗口,VIP 客人和普通客人混在一起排队。一旦普通客人排得太慢,VIP 也得等着。
解决方案是线程池隔离 -- 给不同的下游服务分配独立的线程池:
// 给服务A分配独立线程池(核心10,最大20)
ExecutorService poolA = new ThreadPoolExecutor(10, 20, 60L,
TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));
// 给服务B分配独立线程池(少一些,因为它本来就慢)
ExecutorService poolB = new ThreadPoolExecutor(5, 10, 60L,
TimeUnit.SECONDS, new LinkedBlockingQueue<>(50));这样即使服务 B 的线程池被占满,服务 A 的线程池完全不受影响。这就是 Hystrix 的核心设计理念之一 -- 舱壁隔离(Bulkhead Isolation),灵感来自轮船的水密舱设计。
六、实战:如何设计一个高并发系统
把前面所有知识串起来,设计一个高并发系统需要从这几个层面思考:
核心原则总结为 12 个字:缓存加速、异步解耦、弹性扩缩、柔性可用。
展开来说,一个高并发系统至少需要关注以下 10 个方面:
- 分布式架构 -- 将系统拆分成多个服务,降低单点故障风险,每个服务可以独立扩缩容
- 集群 + 负载均衡 -- 水平扩展,多台机器分担流量。负载均衡策略的选择(轮询、权重、最小连接数)直接影响流量分配的均匀度
- 多级缓存 -- 浏览器 → CDN → 本地缓存 → Redis → DB,层层拦截,让大部分请求在到达数据库之前就被消化掉
- 缓存预热 -- 大促前提前加载热点数据到缓存中,避免冷启动导致的缓存穿透
- 异步处理 -- 消息队列削峰填谷,把瞬时峰值流量摊平到更长的时间段内处理
- 数据库优化 -- 合理的索引设计、读写分离分散压力、分库分表降低单库单表数据量
- 限流降级熔断 -- 三道防线保障系统在极端流量下不崩溃
- 代码优化 -- 减少锁竞争(锁粒度最小化)、避免长事务、批量操作代替逐条操作
- 监控体系 -- 实时观察 QPS、RT、CPU、内存等水位指标,发现问题秒级告警
- 压力测试 -- 上线前全链路压测,提前发现瓶颈,验证容量规划是否靠谱
七、高并发场景下的锁选择
在高并发的写操作场景中,乐观锁和悲观锁怎么选?这是一个实战中非常常见的决策。
乐观锁假设冲突很少 -- 先做业务逻辑(参数处理、模型组装、内存计算),最后用版本号或 CAS 检查是否有并发修改。适合读多写少的场景。
悲观锁假设冲突频繁 -- 先获取锁,再做业务逻辑。适合写操作密集的场景。
换句话说:乐观锁是"先干活,后检查";悲观锁是"先加锁,再干活"。
在高并发写场景下,推荐悲观锁。原因很直觉 -- 你辛辛苦苦组装好了数据模型、做完了内存计算,结果去数据库更新时发现版本号变了,白干了。这不是大冤种吗?
不如一开始就尝试加锁,拿不到锁直接快速失败(fail-fast),避免做无用功:
// 悲观锁的典型用法:先加分布式锁,再做业务操作
if (redisLock.tryLock("order:" + orderId, 3, TimeUnit.SECONDS)) {
try {
processOrder(orderId); // 拿到锁才开始干活
} finally {
redisLock.unlock("order:" + orderId);
}
} else {
throw new BusinessException("系统繁忙,请稍后重试"); // 快速失败
}注意:乐观锁并非"无锁"。数据库层面的乐观锁(CAS + 版本号)在执行 UPDATE 时仍然会申请行级锁,只是持有锁的时间更短而已。
八、缓存预热:别让冷启动拖后腿
系统刚启动或重启后,缓存是空的(冷启动),所有请求都会穿透到数据库。如果此时正好是流量高峰,数据库可能直接被打垮。
缓存预热就是在流量到来之前,提前把热点数据加载到缓存中。主要有三种做法:
- 启动时加载 -- 系统启动时自动加载核心数据到本地缓存或 Redis。对于 Guava/Caffeine 本地缓存,可以在 Spring 的
@PostConstruct中完成。 - 定时任务加载 -- 周期性地刷新缓存中的热点数据,保证时效性。特别适合数据更新频率可预测的场景。
- 大促前手动加载 -- 秒杀商品、活动页面数据提前预热。对于可预知的热点 key,这是最靠谱的方式。
对于 Redis 的批量预热,可以使用 Redis Bulk Loading 工具高效导入大量数据,避免逐条 SET 带来的性能开销。
附录:限流、降级、熔断的关系
这三个概念经常被放在一起讨论,但它们解决的问题不同:
| 机制 | 触发条件 | 保护对象 | 动作 |
|---|---|---|---|
| 限流 | 流量超过阈值 | 当前系统 | 拒绝多余的请求 |
| 降级 | 系统压力大或非核心服务异常 | 核心功能 | 关闭或简化非核心功能 |
| 熔断 | 下游服务连续失败 | 上游调用方 | 停止调用下游,快速失败 |
一个形象的比喻:
- 限流 -- 像景区限制每日入园人数,超了就不卖票。保护的是景区(当前系统)。
- 降级 -- 像景区在客流高峰时关闭部分游乐设施,把电力和人力集中在核心设施上。保护的是核心体验。
- 熔断 -- 像发现某条道路塌方后,交警直接封路禁止通行,引导车辆走其他路线。保护的是即将驶入的车辆(上游调用方)。
在实际系统中,三者往往协同工作:限流在最外层拦截过量流量,降级在内部释放资源给核心链路,熔断在调用链中隔离故障节点。一个成熟的高并发系统,三道防线缺一不可。
小结
高并发系统设计没有银弹,每一种技术都有它适用的场景和代价。但万变不离其宗,核心思路可以浓缩为四句话:
- 能缓存的就缓存 -- 用空间换时间
- 能异步的就异步 -- 用消息队列削峰填谷
- 能限流的就限流 -- 宁可拒绝也不能崩溃
- 能降级的就降级 -- 保核心功能,牺牲非核心功能
记住:高并发的核心不是让系统处理所有请求,而是在资源有限的情况下,让系统做到优雅地拒绝和合理地取舍。