高并发架构设计(三大利器:缓存、限流和降级)
The following article is from 阿里云开发者 Author 骆天
引言
高并发背景
高并发对系统的挑战
什么是高并发
高并发的定义
高并发的特点
高并发场景和应用
高并发的影响
系统性能的下降和延迟增加 资源竞争和资源耗尽 系统稳定性和可用性的挑战
高并发应对策略
缓存:缓解系统负载压力,提高系统响应速度 限流:控制并发访问量,保护系统免受过载影响 降级:保证核心功能的稳定性,舍弃非关键业务或简化处理
缓存
简介
工作原理
常用技术
浏览器缓存
简介
适用场景
常见用法
注意事项
客户端缓存
简介
适用场景
CDN缓存
简介
适用场景
常用工具以及用法
反向代理缓存
简介
适用场景
常用工具以及用法
本地缓存
简介
适用场景
常用工具以及用法
分布式缓存
简介
适用场景
常用工具以及用法
缓存问题
缓存穿透
应对策略
缓存击穿
应对策略
缓存雪崩
应对策略
缓存一致性
应对策略
在数据库层面,可以使用事务来确保数据的一致性。通过将读写操作放在同一个事务中,可以保证数据的更新和查询是一致的。
使用数据库的触发器或者存储过程,在数据更新的同时,主动触发缓存的更新操作,确保缓存与数据库的数据保持一致。
在缓存层面,可以使用缓存更新策略,通过定时任务、异步消息队列等方式,定期或者在数据更新时异步地更新缓存,保持缓存与数据库的数据一致性。
使用互斥锁或者分布式锁来保证对缓存的读写操作的原子性,避免数据冲突。
设置合适的缓存过期时间,避免缓存数据长时间过期而导致数据不一致的问题。
在应用层面,可以采用读写分离策略,将读请求和写请求分发到不同的节点,读请求直接从缓存中获取数据,写请求则更新数据库并更新缓存,保持数据的一致性。 使用缓存中间件或者缓存组件,提供自动更新缓存的功能,减少手动维护缓存的复杂性。
建立监控和报警机制,通过监控缓存层和数据库层的状态、数据一致性等指标,及时发现异常情况,并触发报警,以便及时处理问题。
其他
小结
限流
简介
作用
限流算法
固定窗口算法(计数器)
简介
原理
适用场景
保护后端服务免受大流量冲击,避免服务崩溃; 对 API 调用进行限制,保证公平使用; 防止恶意用户对服务进行洪水攻击;
实现方式
public class FixedWindowRateLimiter {
private static int counter = 0; // 统计请求数
private static long lastAcquireTime = 0L;
private static final long windowUnit = 1000L; // 假设固定时间窗口是1000ms
private static final int threshold = 10; // 窗口阀值是10
public synchronized boolean tryAcquire() {
long currentTime = System.currentTimeMillis(); // 获取系统当前时间
if (currentTime - lastAcquireTime > windowUnit) { // 检查是否在时间窗口内
counter = 0; // 计数器清零
lastAcquireTime = currentTime; // 开启新的时间窗口
}
if (counter < threshold) { // 小于阀值
counter++; // 计数器加1
return true; // 获取请求成功
}
return false; // 超过阀值,无法获取请求
}
}
优劣分析
优点
固定窗口算法非常简单,易于实现和理解。 性能高
缺点
存在明显的临界问题 eg: 比如: 假设限流阀值为5个请求,单位时间窗口是1s,如果我们在单位时间内的前0.8-1s和1-1.2s,分别并发5个请求。虽然都没有超过阀值,但是如果算0.8-1.2s内的,则并发数高达10,已经超过单位时间1s不超过5阀值的定义了。
简介
原理
实现方式
import java.util.LinkedList;
import java.util.Queue;
public class SlidingWindowRateLimiter {
private Queue<Long> timestamps; // 存储请求的时间戳队列
private int windowSize; // 窗口大小,即时间窗口内允许的请求数量
private long windowDuration; // 窗口持续时间,单位:毫秒
public SlidingWindowRateLimiter(int windowSize, long windowDuration) {
this.windowSize = windowSize;
this.windowDuration = windowDuration;
this.timestamps = new LinkedList<>();
}
public synchronized boolean tryAcquire() {
long currentTime = System.currentTimeMillis(); // 获取当前时间戳
// 删除超过窗口持续时间的时间戳
while (!timestamps.isEmpty() && currentTime - timestamps.peek() > windowDuration) {
timestamps.poll();
}
if (timestamps.size() < windowSize) { // 判断当前窗口内请求数是否小于窗口大小
timestamps.offer(currentTime); // 将当前时间戳加入队列
return true; // 获取请求成功
}
return false; // 超过窗口大小,无法获取请求
}
}
如果小于窗口大小,将当前时间戳加入队列,并返回true表示获取请求成功。 如果已经达到或超过窗口大小,表示请求数已满,返回false表示无法获取请求。
适用场景
优劣分析
优势
简单易懂 精度高(通过调整时间窗口的大小来实现不同的限流效果) 可扩展性强(可以非常容易地与其他限流算法结合使用)
劣质 突发流量无法处理(无法应对短时间内的大量请求,但是一旦到达限流后,请求都会直接暴力被拒绝。这样我们会损失一部分请求,这其实对于产品来说,并不太友好),需要合理调整时间窗口大小。
漏桶算法
简介
特点
可以以任意速率流入水滴到漏桶(流入请求) 漏桶具有固定容量,出水速率是固定常量(流出请求) 如果流入水滴超出了桶的容量,则流入的水滴溢出(新请求被拒绝)
原理
思想
工作原理
代码实现
public class LeakyBucketRateLimiter {
private long capacity; // 漏桶容量,即最大允许的请求数量
private long rate; // 漏水速率,即每秒允许通过的请求数量
private long water; // 漏桶当前水量
private long lastTime; // 上一次请求通过的时间戳
public LeakyBucketRateLimiter(long capacity, long rate) {
this.capacity = capacity;
this.rate = rate;
this.water = 0;
this.lastTime = System.currentTimeMillis();
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
long elapsedTime = now - lastTime;
// 计算漏桶中的水量
water = Math.max(0, water - elapsedTime * rate / 1000);
if (water < capacity) { // 判断漏桶中的水量是否小于容量
water++; // 漏桶中的水量加1
lastTime = now; // 更新上一次请求通过的时间戳
return true; // 获取请求成功
}
return false; // 漏桶已满,无法获取请求
}
}
1.获取当前系统时间戳 now。
2.计算从上一次请求通过到当前的时间间隔 elapsedTime。
3.根据漏水速率和时间间隔,计算漏桶中的水量。
如果小于容量,漏桶中的水量加1,更新上一次请求通过的时间戳,并返回true表示获取请求成功。
如果已经达到或超过容量,漏桶已满,返回false表示无法获取请求。
适用场景
优劣分析
优势
可以平滑限制请求的处理速度,避免瞬间请求过多导致系统崩溃或者雪崩。 可以控制请求的处理速度,使得系统可以适应不同的流量需求,避免过载或者过度闲置。 可以通过调整桶的大小和漏出速率来满足不同的限流需求,可以灵活地适应不同的场景。
劣质
需要对请求进行缓存,会增加服务器的内存消耗。 对于流量波动比较大的场景,需要较为灵活的参数配置才能达到较好的效果。 但是面对突发流量的时候,漏桶算法还是循规蹈矩地处理请求,这不是我们想看到的啦。流量变突发时,我们肯定希望系统尽量快点处理请求,提升用户体验嘛。
令牌桶算法
简介
原理
实现方式
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class TokenBucketRateLimiter {
private long capacity; // 令牌桶容量,即最大允许的请求数量
private long rate; // 令牌产生速率,即每秒产生的令牌数量
private long tokens; // 当前令牌数量
private ScheduledExecutorService scheduler; // 调度器
public TokenBucketRateLimiter(long capacity, long rate) {
this.capacity = capacity;
this.rate = rate;
this.tokens = capacity;
this.scheduler = new ScheduledThreadPoolExecutor(1);
scheduleRefill(); // 启动令牌补充任务
}
private void scheduleRefill() {
scheduler.scheduleAtFixedRate(() -> {
synchronized (this) {
tokens = Math.min(capacity, tokens + rate); // 补充令牌,但不超过容量
}
}, 1, 1, TimeUnit.SECONDS); // 每秒产生一次令牌
}
public synchronized boolean tryAcquire() {
if (tokens > 0) { // 判断令牌数量是否大于0
tokens--; // 消耗一个令牌
return true; // 获取请求成功
}
return false; // 令牌不足,无法获取请求
}
}
代码解读
capacity表示令牌桶的容量,即最大允许的请求数量;rate表示令牌产生速率,即每秒产生的令牌数量。tokens表示当前令牌数量,scheduler是用于调度令牌补充任务的线程池。
在构造方法中,初始化令牌桶的容量和当前令牌数量,并启动令牌补充任务scheduleRefill()。
scheduleRefill()方法使用调度器定期执行令牌补充任务,每秒补充一次令牌。在补充任务中,通过加锁的方式更新令牌数量,确保线程安全。补充的令牌数量为当前令牌数量加上产生速率,但不超过令牌桶的容量。
tryAcquire()方法使用synchronized关键字来实现线程安全,在方法中进行以下操作:
1.判断令牌数量是否大于0。
如果令牌数量大于0,表示令牌足够,消耗一个令牌,并返回true表示获取请求成功。
如果令牌数量为0,表示令牌不足,返回false表示无法获取请求。
Guava的RateLimiter限流组件,就是基于令牌桶算法实现的。
适用场景
一般用于保护自身的系统,对调用者进行限流,保护自身的系统不被突发的流量打垮。如果自身的系统实际的处理能力强于配置的流量限制时,可以允许一定程度的流量突发,使得实际的处理速率高于配置的速率,充分利用系统资源。
优劣分析
优势
稳定性高:令牌桶算法可以控制请求的处理速度,可以使系统的负载变得稳定。
精度高:令牌桶算法可以根据实际情况动态调整生成令牌的速率,可以实现较高精度的限流。
弹性好:令牌桶算法可以处理突发流量,可以在短时间内提供更多的处理能力,以处理突发流量。
劣质
实现复杂:相对于固定窗口算法等其他限流算法,令牌桶算法的实现较为复杂。对短时请求难以处理:在短时间内有大量请求到来时,可能会导致令牌桶中的令牌被快速消耗完,从而限流。这种情况下,可以考虑使用漏桶算法。
时间精度要求高:令牌桶算法需要在固定的时间间隔内生成令牌,因此要求时间精度较高,如果系统时间不准确,可能会导致限流效果不理想。
滑动日志算法(比较冷门)
简介
滑动日志限速算法需要记录请求的时间戳,通常使用有序集合来存储,我们可以在单个有序集合中跟踪用户在一个时间段内所有的请求。
原理
滑动日志算法可以用于实现限流功能,即控制系统在单位时间内处理请求的数量,以保护系统免受过载的影响。以下是滑动日志算法用于限流的原理:
1.划分时间窗口:将时间划分为固定的时间窗口,例如每秒、每分钟或每小时等。
2.维护滑动窗口:使用一个滑动窗口来记录每个时间窗口内的请求次数。这个滑动窗口可以是一个固定长度的队列或数组。
3.请求计数:当一个请求到达时,将其计数加一并放入当前时间窗口中。
4.滑动:随着时间的流逝,滑动窗口会根据当前时间窗口的长度,移除最旧的请求计数,并将新的请求计数添加到最新的时间窗口中。
5.限流判断:在每个时间窗口结束时,统计滑动窗口中的请求计数总和,并与预设的阈值进行比较。如果总请求数超过阈值,则触发限流处理。
6.限流处理:一旦触发限流,可以采取不同的处理策略,如拒绝请求、延迟处理、返回错误信息等。具体的限流策略可以根据实际情况进行选择。
通过滑动日志算法进行限流,可以实现对单位时间内的请求进行精确控制。它基于实时统计的方式,能够动态地适应请求流量的变化,并且在内存使用上比较高效。同时,通过调整时间窗口的长度和阈值的设置,可以灵活地控制限流的精度和灵敏度。
实现方式
import java.util.LinkedList;
import java.util.List;
public class SlidingLogRateLimiter {
private int requests; // 请求总数
private List<Long> timestamps; // 存储请求的时间戳列表
private long windowDuration; // 窗口持续时间,单位:毫秒
private int threshold; // 窗口内的请求数阀值
public SlidingLogRateLimiter(int threshold, long windowDuration) {
this.requests = 0;
this.timestamps = new LinkedList<>();
this.windowDuration = windowDuration;
this.threshold = threshold;
}
public synchronized boolean tryAcquire() {
long currentTime = System.currentTimeMillis(); // 获取当前时间戳
// 删除超过窗口持续时间的时间戳
while (!timestamps.isEmpty() && currentTime - timestamps.get(0) > windowDuration) {
timestamps.remove(0);
requests--;
}
if (requests < threshold) { // 判断当前窗口内请求数是否小于阀值
timestamps.add(currentTime); // 将当前时间戳添加到列表
requests++; // 请求总数增加
return true; // 获取请求成功
}
return false; // 超过阀值,无法获取请求
}
}
在以上代码中,requests表示请求总数,timestamps用于存储请求的时间戳列表,windowDuration表示窗口持续时间,threshold表示窗口内的请求数阀值。
在构造函数中传入窗口内的请求数阀值和窗口持续时间。
tryAcquire()方法使用了synchronized关键字来实现线程安全,在方法中进行以下操作:
1.获取当前系统时间戳 currentTime。
2.删除超过窗口持续时间的时间戳,同时更新请求总数。
3.判断当前窗口内请求数是否小于阀值。
如果小于阀值,将当前时间戳添加到列表,请求总数增加,并返回true表示获取请求成功。
如果已经达到或超过阀值,表示请求数已满,返回false表示无法获取请求。
使用这个滑动日志限流算法,可以限制在一定时间窗口内的请求频率,超过阀值的请求会被限制。您可以根据实际需求和业务场景进行调整和使用。
适用场景
对实时性要求高,且需要精确控制请求速率的高级限流场景。
优劣分析
优势
滑动日志能够避免突发流量,实现较为精准的限流;
更加灵活,能够支持更加复杂的限流策略 如多级限流,每分钟不超过100次,每小时不超过300次,每天不超过1000次,我们只需要保存最近24小时所有的请求日志即可实现。
劣质
占用存储空间要高于其他限流算法。
几种算法小结
算法 | 简介 | 核心思想 | 优点 | 缺点 | 开源工具/中间件 | 适用业务场景 |
固定窗口限流 | 在固定的时间窗口内计数请求,达到阈值则限流。 | 将时间分割成固定大小的窗口,每个窗口内独立计数。 | 实现简单,性能较好。 | 可能会有时间窗口切换时的突发流量。 | Nginx、Apache、RateLimiter (Guava) | 需要简单限流,对流量突增不敏感的场景。 eg: 电商平台在每日定时秒杀活动开始时,用于防止瞬时高流量冲垮系统。 |
滑动窗口限流 | 在滑动的时间窗口内计数请求,达到阈值则限流。 | 将时间分割为多个小窗口,统计近期内的总请求数。 | 平滑请求,避免固定窗口算法中的突发流量。 | 实现比固定窗口复杂,消耗资源较多。 | Redis、Sentinel | 对流量平滑性有较高要求的场景。 eg: 社交媒体平台的消息发送功能,用于平滑处理高峰期的消息发送请求,避免服务短暂的超负荷。 |
令牌桶限流 | 以恒定速率向桶中添加令牌,请求消耗令牌,无令牌时限流。 | 以一定速率生成令牌,请求必须拥有令牌才能执行。 | 允许一定程度的突发流量,平滑处理请求。 | 对突发流量的容忍可能导致短时间内资源过载。 | Guava、Nginx、Apache、 Sentinel | 对突发流量有一定要求,且需要一定程度的平滑处理的场景。 eg: 视频流媒体服务,允许用户在网络状况良好时快速缓冲视频,同时在网络拥堵时平滑地降低请求速率。 |
漏桶算法 | 漏桶以固定速率出水,请求以任意速率流入桶内,桶满则溢出(限流)。 | 以恒定的速率处理请求,超过该速率的请求被限制。 | 输出流量稳定,能够限制流量的最大速率。 | 无法应对突发流量,可能导致请求等待时间过长。 | Apache、Nginx | 适用于需要严格控制处理速率,对请求响应时间要求不高的场景。 eg: API网关对外提供服务的接口,需要确保后端服务的调用速率不超过其最大处理能力,防止服务崩溃 |
滑动日志限流 | 使用滑动时间窗口记录请求日志,通过日志来判断是否超出速率限制。 | 记录最近一段时间内的请求日志,实时判断请求是否超限。 | 能够更细粒度地控制请求速率,比固定窗口更公平。 | 实现复杂,存储和计算请求日志成本较高。 | - | 对实时性要求高,且需要精确控制请求速率的高级限流场景。 eg: 高频交易系统,需要根据实时交易数据精确控制交易请求速率,防止因超负荷而影响整体市场的稳定性。 |
常用工具
RateLimiter(单机)
简介
基于令牌桶算法实现的一个多线程限流器,它可以将请求均匀的进行处理,当然他并不是一个分布式限流器,只是对单机进行限流。它可以应用在定时拉取接口数。通过aop、filter、Interceptor 等都可以达到限流效果。
用法
以下是一个基本的 RateLimiter 用法示例:
import com.google.common.util.concurrent.RateLimiter;
public class RateLimiterDemo {
public static void main(String[] args) {
// 创建一个每秒允许2个请求的RateLimiter
RateLimiter rateLimiter = RateLimiter.create(2.0);
while (true) {
// 请求RateLimiter一个令牌
rateLimiter.acquire();
// 执行操作
doSomeLimitedOperation();
}
}
private static void doSomeLimitedOperation() {
// 模拟一些操作
System.out.println("Operation executed at: " + System.currentTimeMillis());
}
}
在这个例子中,RateLimiter.create(2.0) 创建了一个每秒钟只允许2个操作的限速器。rateLimiter.acquire() 方法会阻塞当前线程直到获取到许可,确保调用 doSomeLimitedOperation() 操作的频率不会超过限制。
RateLimiter 还提供了其他的方法,例如tryAcquire(),它会尝试获取许可而不会阻塞,立即返回获取成功或失败的结果。还可以设置等待时间上限,比如 tryAcquire(long timeout, TimeUnit unit) 可以设置最大等待时间。
Guava的RateLimiter非常灵活,它支持平滑突发限制(SmoothBursty)和平滑预热限制(SmoothWarmingUp)等多种模式,可以根据特定的应用场景来选择合适的限流策略。
sentinel(单机或者分布式)
简介
Sentinel是阿里巴巴开源的一款面向分布式系统的流量控制和熔断降级组件。它提供了实时的流量控制、熔断降级、系统负载保护和实时监控等功能,可以帮助开发者保护系统的稳定性和可靠性。
单机模式
DefaultController:是一个非常典型的滑动窗口计数器算法实现,将当前统计的qps和请求进来的qps进行求和,小于限流值则通过,大于则计算一个等待时间,稍后再试; ThrottlingController:是漏斗算法的实现,实现思路已经在源码片段中加了备注; WarmUpController:实现参考了Guava的带预热的RateLimiter,区别是Guava侧重于请求间隔,类似前面提到的令牌桶,而Sentinel更关注于请求数,和令牌桶算法有点类似; WarmUpRateLimiterController:低水位使用预热算法,高水位使用滑动窗口计数器算法排队。
集群模式
Sentinel 集群限流服务端有两种启动方式:
嵌入模式(Embedded)适合应用级别的限流,部署简单,但对应用性能有影响
独立模式(Alone)适合全局限流,需要独立部署
用法
Sentinel的用法主要包括以下几个方面:
1.引入依赖:在项目中引入Sentinel的相关依赖,可以使用Maven或Gradle进行依赖管理。例如,在Maven项目的pom.xml文件中添加以下依赖:
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-core</artifactId>
<version>1.8.2</version>
</dependency>
@SentinelResource(value = "demo", blockHandler = "handleBlock")
public String demo() {
// ...
}
public static void main(String[] args) {
System.setProperty("csp.sentinel.dashboard.server", "localhost:8080"); // 设置控制台地址
System.setProperty("project.name", "your-project-name"); // 设置应用名称
com.alibaba.csp.sentinel.init.InitExecutor.doInit();
SpringApplication.run(YourApplication.class, args);
}
Nginx(分布式)
简介
Nginx从网关这一层面考虑,可以作为最前置的网关,抵挡大部分的网络流量,因此使用Nginx进行限流也是一个很好的选择,在Nginx中,也提供了常用的基于限流相关的策略配置。
用法
Nginx 提供了两种限流方法:一种是控制速率,另一种是控制并发连接数。
控制速率
我们需要使用 limit_req_zone 用来限制单位时间内的请求数,即速率限制,
因为Nginx的限流统计是基于毫秒的,我们设置的速度是 2r/s,转换一下就是500毫秒内单个IP只允许通过1个请求,从501ms开始才允许通过第2个请求。
控制速率优化版
上面的速率控制虽然很精准但是在生产环境未免太苛刻了,实际情况下我们应该控制一个IP单位总时间内的总访问次数,而不是像上面那样精确到毫秒,我们可以使用 burst 关键字开启此设置。
burst=4意思是每个IP最多允许4个突发请求
控制并发数
利用 limit_conn_zone 和 limit_conn 两个指令即可控制并发数
其中 limit_conn perip 10 表示限制单个 IP 同时最多能持有 10 个连接;limit_conn perserver 100 表示 server 同时能处理并发连接的总数为 100 个。
注意:只有当 request header 被后端处理后,这个连接才进行计数。
降级
简介
降级是在高并发或异常情况下舍弃非关键业务或简化处理的一种技术手段。
按类型可分为有感降级,无感降级。
有感降级
主要是通过一定的监控感知到异常出现或即将出现,对调用服务进行快速失败返回或者进行切换,在指标回正的时候恢复服务调用,这个也可以称为熔断。
无感降级
系统不作感知,在调用服务出现异常则自动忽略,进行空返回或无操作。降级的本质为作为服务调用方去规避提供方带来的风险。
原理
在限流中,服务调用方为每一个调用的服务维护一个有限状态机,在这个状态机会有三种状态:关闭(调用远程服务)、半打开(尝试调用远程服务)和打开(返回错误)。这三种状态之间切换的过程如下:
当调用失败的次数累积到一定的阈值时,熔断机制从关闭态切换到打开态。一般在实现时,如果调用成功一次,就会重置调用失败次数。
当熔断处于打开状态时,我们会启动一个计时器,当计时器超时后,状态切换到半打开态。也可以通过设置一个定时器,定期的探测服务是否恢复。
当熔断处于半打开状态时,请求可以达到后端服务,如果累计一定的成功次数后,状态切换到关闭态;如果出现调用失败的情况,则切换到打开态。
常用工具
1.降级开源组件:sentinel和Hystrix(不展开)
2.手动降级:可采用系统配置开关来控制
其他
熔断
简介
熔断在程序中,表示“断开”的意思。如发生了某事件,程序为了整体的稳定性,所以暂时(断开)停止服务一段时间,以保证程序可用时再被使用。
熔断和降级的区别
概念不同
熔断程序为了整体的稳定性,所以暂时(断开)停止服务一段时间;降级(Degradation)降低级别的意思,它是指程序在出现问题时,仍能保证有限功能可用的一种机制;
触发条件不同
不同框架的熔断和降级的触发条件是不同,以Hystrix为例:
Hystrix 熔断触发条件
默认情况 hystrix 如果检测到 10 秒内请求的失败率超过 50%,就触发熔断机制。之后每隔 5 秒重新尝试请求微服务,如果微服务不能响应,继续走熔断机制。如果微服务可达,则关闭熔断机制,恢复正常请求。
Hystrix 降级触发条件
1.方法抛出 HystrixBadRequestException
2.方法调用超时
3.熔断器开启拦截调用
归属关系不同
熔断时可能会调用降级机制,而降级时通常不会调用熔断机制。因为熔断是从全局出发,为了保证系统稳定性而停用服务,而降级是退而求其次,提供一种保底的解决方案,所以它们的归属关系是不同(熔断 > 降级)。
小结
缓存、限流和降级是应对高并发的三大利器,能够提升系统性能、保护资源和保证核心功能。
组合使用缓存、限流和降级策略,根据具体场景灵活调整和优化。
在高并发环境下,综合使用三大利器是应对挑战的有效策略。
参考阅读:
本文由高可用架构转载。技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿