服务治理:限流、熔断、降级

面对高流量出现故障的原因

  • 由于依赖的资源或者服务不可用,最终导致整体服务宕机。举例来说,在你的电商系统中就可能由于数据库访问缓慢,导致整体服务不可用。

  • 乐观地预估了可能到来的流量,当有超过系统承载能力的流量到来时,系统不堪重负,从而出现拒绝服务的情况。

雪崩效应

  • 系统运行需要消耗资源,包括 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 的响应时间变长,就可以导致整体服务宕机,这就是我们经常遇到的一种服务雪崩情况。

  • ==》在分布式环境下,系统最怕的反而不是某一个服务或者组件宕机(影响部分功能),而是最怕它响应缓慢(雪崩拖垮整个系统)

解决思路:检测到某一个服务的响应时间出现异常时,切断调用它的服务与它之间的联系,让服务的调用快速返回失败,从而释放这次请求持有的资源

限流机制

定义:限流是通过限制并发请求数量,保证系统能够正常响应部分请求,对于超过限制的流量则拒绝服务。限流策略通常部署在服务的入口层,如API网关。

为什么要限流:

  1. 防止系统过载

  2. 增加安全性,防止暴力破解

  3. 保证服务质量:公平,避免某些用户占用过多资源

  4. 控制运营成本

限流机制需要考虑什么?

  1. 公平:无需多言

  2. 灵活:考虑不同的业务场景,如IP、设备ID、接口、设备、时间......适应不同的流量模式和业务需求,例如在高流量期间放宽限制。

  3. 解耦:将限流服务与具体业务逻辑分

  4. 可观测:要求限流规则和当前状态对用户可见,使用户能够了解他们被限流的原因和情况。

针对什么限流?

从限流对象来看:

  1. 单机:窗口类算法如固定窗口、滑动窗口

  2. 集群:借助redis之类的中间件记录流量和阙值,来实现前面的限流算法

  3. 业务对象限流:IP、用户ID、业务ID

  4. VIP不限流普通用户限流等等

常见限流算法:

  • 固定窗口算法:

    • 统计固定时间窗口内的请求数量,超过限制则触发限流。

    • 优点:实现简单,满足限流

    • 缺点:无法应对短时间内的突发流量,不够平滑

    • 应用场景:适用于请求分布相对均匀的场景,例如统计 PV/UV 请求等

  • 滑动窗口算法(TCP协议):

    • 将时间窗口划分为多个小窗口,统计滑动时间窗口内的请求数量,解决了固定窗口算法的缺陷,

    • 但还是无法限制短时间之内的集中流量,也就是说无法控制流量让它们更加平滑

  • 漏桶算法:通过漏桶机制平滑流量,对突发流量进行缓冲处理,常用于消息队列。【缺点是流量缓存在漏桶中,响应时间增长】

  • 【最推荐】令牌桶算法:在桶中按固定速率(1/限制访问次数)加入令牌,请求需要消耗令牌才能被处理。适用于应对突发流量的情况,如Guava库提供了RateLimiter类。【缺点是要存储并获取令牌数量,在分布式中用redis存储,每次请求redis都会有延迟,解决办法是使用Lua脚本每次获取一批令牌而不是一个,减少请求redis次数】

熔断机制

  • 类似电路中的保险丝保护机制,服务调用失败次数达到阈值时,停止调用并返回错误。

  • 三种状态:关闭(正常调用)、半打开(尝试调用)、打开(返回错误)。 不仅仅微服务之间调用需要熔断的机制,我们在调用 Redis、Memcached 等资源的时候也可以引入这套机制

实现:使用定时器定期检测服务是否恢复。

  1. 当熔断器处于 Open 状态时,定期地检测 Redis 组件是否可用

  2. 在通过 Redis 客户端操作 Redis 中的数据时,在其中加入熔断器的逻辑。比如,当节点处于熔断状态时,直接返回空值以及熔断器三种状态之间的转换

  3. 这样当某一个 Redis 节点出现问题,Redis 客户端中的熔断器就会实时监测到,并且不再请求有问题的 Redis 节点,避免单个节点的故障导致整体系统的雪崩

降级机制

将有限的资源效益最大化:通过开关控制非核心服务,保证核心服务可用。

常用策略:

  • 牺牲时效性

    • 返回降级数据:数据库压力大时只考虑读取缓存数据,非核心接口出现问题直接返回服务繁忙或固定的降级数据

    • 降频:对于轮询查询数据场景,增加轮询间隔

    • 同步写转异步写:通过牺牲数据一致性来保证系统可用性

  • 牺牲功能完整性

    • 关闭风控

    • 取消条件判断

  • 牺牲用户体验

    1. 为了减少对「冷数据」的获取,禁用列表的翻页功能。

    2. 为了放缓流量进入的速率,增加验证码机制。

    3. 为了减少“大查询”浪费过多的资源,提高筛选条件要求(禁用模糊查询、部分条件必选等)。

    4. 用通用的静态化数据代替「千人千面」的动态数据。

    5. 甚至更简单粗暴的,直接挂一个页面显示「XX 功能在 XX 时间内暂时关闭」。

三者区别

image-20250212004712977

熔断是降级的一种实现方式

限流代码 —— 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());
}

熔断代码 —— 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();
     }
}

最后更新于