1. 面对高流量出现故障的原因
-
由于依赖的资源或者服务不可用,最终导致整体服务宕机。在电商系统中就可能由于数据库访问缓慢,导致整体服务不可用。
-
乐观地预估了可能到来的流量,当有超过系统承载能力的流量到来时,系统不堪重负,从而出现拒绝服务的情况。
2. 雪崩效应
-
系统运行需要消耗资源,包括 CPU、内存等系统资源,也包括执行业务逻辑需要的线程资源。比如说Tomcat 定义了线程池来处理 HTTP 请求。这些线程池中的线程资源是有限的,如果这些线程资源被耗尽,服务无法处理新请求,服务提供方也就宕机了。
【举例】
-
A 调用 B,B 调用 C 和 D。其中ABD 服务是系统的核心服务(像电商系统中的订单服务、支付服务),C 是非核心服务(像反垃圾服务、审核服务)。
-
一旦作为入口的 A 流量增加,你可能会考虑把 ABD 服务扩容,忽略 C。那么 C 就有可能因为无法承担这么大的流量,导致请求处理缓慢,进一步会让 B 在调用 C 的时候,B 中的请求被阻塞,等待 C 返回响应结果。这样一来,B 服务中被占用的线程资源就不能释放。
-
久而久之,B 就会因为线程资源被占满,无法处理后续的请求。那么从 A 发往 B 的请求,就会被放入 B 服务线程池的队列中,然后 A 调用 B 响应时间变长,进而拖垮 A 服务。你看,仅仅因为非核心服务 C 的响应时间变长,就可以导致整体服务宕机,这就是我们经常遇到的一种服务雪崩情况。
==》在分布式环境下,系统最怕的反而不是某一个服务或者组件宕机(影响部分功能),而是最怕它响应缓慢(雪崩拖垮整个系统)
解决思路:检测到某一个服务的响应时间出现异常时,切断调用它的服务与它之间的联系,让服务的调用快速返回失败,从而释放这次请求持有的资源 ==》熔断
-
3. 熔断机制
-
类似电路中的保险丝保护机制,服务调用失败次数达到阈值时,停止调用并返回错误。
-
三种状态:关闭(正常调用)、半打开(尝试调用)、打开(返回错误)。
不仅仅微服务之间调用需要熔断的机制,我们在调用 Redis、Memcached 等资源的时候也可以引入这套机制
实现:使用定时器定期检测服务是否恢复。
-
当熔断器处于 Open 状态时,定期地检测 Redis 组件是否可用
-
在通过 Redis 客户端操作 Redis 中的数据时,在其中加入熔断器的逻辑。比如,当节点处于熔断状态时,直接返回空值以及熔断器三种状态之间的转换
-
这样当某一个 Redis 节点出现问题,Redis 客户端中的熔断器就会实时监测到,并且不再请求有问题的 Redis 节点,避免单个节点的故障导致整体系统的雪崩
4. 降级机制
将有限的资源效益最大化:通过开关控制非核心服务,保证核心服务可用。
常用策略:
-
牺牲时效性
-
返回降级数据:数据库压力大时只考虑读取缓存数据,非核心接口出现问题直接返回服务繁忙或固定的降级数据
-
降频:对于轮询查询数据场景,增加轮询间隔
-
同步写转异步写:通过牺牲数据一致性来保证系统可用性
-
-
牺牲功能完整性
-
关闭风控等功能
-
取消条件判断
-
-
牺牲用户体验
-
为了减少对「冷数据」的获取,禁用列表的翻页功能。
-
为了放缓流量进入的速率,增加验证码机制。
-
为了减少“大查询”浪费过多的资源,提高筛选条件要求(禁用模糊查询、部分条件必选等)。
-
用通用的静态化数据代替「千人千面」的动态数据。
-
甚至更简单粗暴的,直接挂一个页面显示「XX 功能在 XX 时间内暂时关闭」。
-
5. 限流机制
定义:限流是通过限制并发请求数量,保证系统能够正常响应部分请求,对于超过限制的流量则拒绝服务。限流策略通常部署在服务的入口层,如API网关。
常见限流算法:
-
固定窗口算法:统计固定时间窗口内的请求数量,超过限制则触发限流。缺点是无法应对短时间内的突发流量。
-
滑动窗口算法(TCP协议):将时间窗口划分为多个小窗口,统计滑动时间窗口内的请求数量,解决了固定窗口算法的缺陷,但还是无法限制短时间之内的集中流量,也就是说无法控制流量让它们更加平滑
-
漏桶算法:通过漏桶机制平滑流量,对突发流量进行缓冲处理,常用于消息队列。【缺点是流量缓存在漏桶中,响应时间增长】
-
【最推荐】令牌桶算法:在桶中按固定速率(1/限制访问次数)加入令牌,请求需要消耗令牌才能被处理。适用于应对突发流量的情况,如Guava库提供了RateLimiter类。【缺点是要存储并获取令牌数量,在分布式中用redis存储,每次请求redis都会有延迟,解决办法是使用Lua脚本每次获取一批令牌而不是一个,减少请求redis次数】
限流代码 —— 令牌桶限流
实现令牌桶限流算法,需要反复调用 Redis 查询与计算,一次限流判断需要多次请求较为耗时。因此我们采用编写 Lua 脚本运行的方式,将运算过程放在 Redis 端,使得对 Redis 进行一次请求就能完成限流的判断。
令牌桶算法需要在 Redis 中存储桶的大小、当前令牌数量,并且实现每隔一段时间添加新的令牌。最简单的办法当然是每隔一段时间请求一次 Redis,将存储的令牌数量递增。
但实际上我们可以通过对限流两次请求之间的时间和令牌添加速度来计算得出上次请求之后到本次请求时,令牌桶应添加的令牌数量。因此我们在 Redis 中只需要存储上次请求的时间和令牌桶中的令牌数量,而桶的大小和令牌的添加速度可以通过参数传入实现动态修改。
由于第一次运行脚本时默认令牌桶是满的,因此可以将数据的过期时间设置为令牌桶恢复到满所需的时间,及时释放资源。
编写Lua脚本如下:
local ratelimit_info = redis.pcall('HMGET',KEYS[1],'last_time','current_token')
local last_time = ratelimit_info[1]
local current_token = tonumber(ratelimit_info[2])
local max_token = tonumber(ARGV[1])
local token_rate = tonumber(ARGV[2])
local current_time = tonumber(ARGV[3])
local reverse_time = 1000/token_rate
if current_token == nil then
current_token = max_token
last_time = current_time
else
local past_time = current_time-last_time
local reverse_token = math.floor(past_time/reverse_time)
current_token = current_token+reverse_token
last_time = reverse_time*reverse_token+last_time
if current_token>max_token then
current_token = max_token
end
end
local result = 0
if(current_token>0) then
result = 1
current_token = current_token-1
end
redis.call('HMSET',KEYS[1],'last_time',last_time,'current_token',current_token)
redis.call('pexpire',KEYS[1],math.ceil(reverse_time*(max_token-current_token)+(current_time-last_time)))
return result
使用 SpringDataRedis 来进行 Redis 脚本的调用,执行限流
public class RedisReteLimitScript implements RedisScript<String> {
private static final String SCRIPT =".Lua";
@Override public String getSha1() {
return DigestUtils.sha1Hex(SCRIPT);
}
@Override public Class<String> getResultType() {
return String.class;
}
@Override public String getScriptAsString() {
return SCRIPT;
}
}
// 执行脚本
public boolean rateLimit(String key, int max, int rate) {
List<String> keyList = new ArrayList<>(1);
keyList.add(key);
return "1".equals(stringRedisTemplate.execute(new RedisReteLimitScript(),
keyList, Integer.toString(max), Integer.toString(rate),
Long.toString(System.currentTimeMillis())));
}
编写测试类:rateLimit 方法传入的 key 为限流接口的 ID,max 为令牌桶的最大大小,rate 为每秒钟恢复的令牌数量,返回的 boolean 即为此次请求是否通过了限流。为了测试 Redis 脚本限流是否可以正常工作,我们编写一个单元测试进行测试看看
@Autowired
private RedisManager redisManager;
@Test
public void rateLimitTest() throws InterruptedException {
String key = "test_rateLimit_key";
int max = 10; //令牌桶大小
int rate = 10; //令牌每秒恢复速度
AtomicInteger successCount = new AtomicInteger(0);
Executor executor = Executors.newFixedThreadPool(10);
CountDownLatch countDownLatch = new CountDownLatch(30);
for (int i = 0; i < 30; i++) {
executor.execute(() -> {
boolean isAllow = redisManager.rateLimit(key, max, rate);
if (isAllow) {
successCount.addAndGet(1);
}
log.info(Boolean.toString(isAllow));
countDownLatch.countDown();
});
}
countDownLatch.await();
log.info("请求成功{}次", successCount.get());
}
日志输出:
[19:12:50,283]true
[19:12:50,284]true
[19:12:50,284]true
[19:12:50,291]true
[19:12:50,291]true
[19:12:50,291]true
[19:12:50,297]true
[19:12:50,297]true
[19:12:50,298]true
[19:12:50,305]true
[19:12:50,305]false
[19:12:50,305]true
[19:12:50,312]false
[19:12:50,312]false
[19:12:50,312]false
[19:12:50,319]false
[19:12:50,319]false
[19:12:50,319]false
[19:12:50,325]false
[19:12:50,325]false
[19:12:50,326]false
[19:12:50,380]false
[19:12:50,380]false
[19:12:50,380]false
[19:12:50,387]false
[19:12:50,387]false
[19:12:50,387]false
[19:12:50,392]false
[19:12:50,392]false
[19:12:50,392]false
[19:12:50,393]请求成功11次
熔断代码 —— Redis 开关
当熔断器处于 Open 状态时,定期地检测 Redis 组件是否可用
new Timer("RedisPort-Recover", true).scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
if (breaker.isOpen()) {
Jedis jedis = null;
try {
jedis = connPool.getResource();
jedis.ping(); // 验证 redis 是否可用
successCount.set(0); // 重置连续成功的计数
breaker.setHalfOpen(); // 设置为半打开态
} catch (Exception ignored) {
} finally {
if (jedis != null) {
jedis.close();
}
}
}
}
}, 0, recoverInterval); // 初始化定时器定期检测 redis 是否可用
在通过 Redis 客户端操作 Redis 中的数据时,我们会在其中加入熔断器的逻辑。比如,当节点处于熔断状态时,直接返回空值以及熔断器三种状态之间的转换。
if (breaker.isOpen()) {
return null; // 断路器打开则直接返回空值
}
K value = null;
Jedis jedis = null;
try {
jedis = connPool.getResource();
value = callback.call(jedis);
if(breaker.isHalfOpen()) { // 如果是半打开状态
if(successCount.incrementAndGet() >= SUCCESS_THRESHOLD) {// 成功次数超过阈值
failCount.set(0); // 清空失败数
breaker.setClose(); // 设置为关闭态
}
}
return value;
} catch (JedisException je) {
if(breaker.isClose()){ // 如果是关闭态
if(failCount.incrementAndGet() >= FAILS_THRESHOLD){ // 失败次数超过阈值
breaker.setOpen(); // 设置为打开态
}
} else if(breaker.isHalfOpen()) { // 如果是半打开态
breaker.setOpen(); // 直接设置为打开态
}
throw je;
} finally {
if (jedis != null) {
jedis.close();
}
}