JAVA系统QPS突然下降的排查:限流、熔断与线程池链路诊断

JAVA系统QPS突然下降的排查:限流、熔断与线程池链路诊断

大家好,今天我们来聊聊Java系统QPS突然下降的排查思路,重点关注限流、熔断以及线程池这三个关键环节。QPS下降是一个常见问题,原因可能很多,但从这三个角度入手,往往能快速定位并解决大部分问题。

一、QPS下降的常见原因与诊断流程

首先,我们需要了解QPS下降可能的原因,然后制定一个清晰的诊断流程。

  • 常见原因:

    • 资源瓶颈: CPU、内存、磁盘IO、网络带宽等资源达到瓶颈。
    • 数据库瓶颈: 数据库查询缓慢、连接数不足等。
    • 代码问题: 死循环、资源泄漏、低效算法等。
    • 外部依赖: 依赖的外部服务响应缓慢或者不可用。
    • 并发问题: 锁竞争激烈、死锁等。
    • 限流、熔断: 系统主动触发了限流或者熔断机制。
    • 缓存失效: 大量缓存同时失效,导致请求直接打到数据库。
    • 垃圾回收: 频繁的Full GC导致系统停顿。
  • 诊断流程:

    1. 监控报警: 查看监控系统,确认QPS下降的程度和持续时间。关注CPU、内存、磁盘IO、网络带宽、数据库连接数、JVM GC等关键指标。
    2. 日志分析: 分析系统日志,查找错误信息、异常堆栈等。
    3. 链路追踪: 使用链路追踪工具(如SkyWalking、Jaeger、Zipkin)分析请求链路,找出耗时较长的环节。
    4. 线程Dump: 获取线程Dump,分析线程状态,查找死锁、锁竞争等问题。
    5. 内存Dump: 获取内存Dump,分析内存占用情况,查找内存泄漏等问题。
    6. 逐步排查: 从最可能的原因入手,逐步排查,缩小范围。

二、限流排查

限流是为了保护系统免受过载的攻击,防止雪崩效应。但如果限流策略配置不当,或者系统误判,也可能导致QPS下降。

  • 常见限流算法:

    • 计数器法: 在单位时间内,记录请求数量,超过阈值则拒绝请求。

      public class CounterRateLimiter {
          private final int limit; // 单位时间内允许的请求数量
          private final long period; // 单位时间,单位毫秒
          private long counter; // 当前计数器
          private long lastRefillTimestamp; // 上次重置计数器的时间戳
      
          public CounterRateLimiter(int limit, long period) {
              this.limit = limit;
              this.period = period;
              this.counter = 0;
              this.lastRefillTimestamp = System.currentTimeMillis();
          }
      
          public synchronized boolean tryAcquire() {
              long now = System.currentTimeMillis();
              if (now - lastRefillTimestamp > period) {
                  // 重置计数器
                  counter = 0;
                  lastRefillTimestamp = now;
              }
      
              if (counter < limit) {
                  counter++;
                  return true; // 允许请求
              } else {
                  return false; // 拒绝请求
              }
          }
      }
    • 令牌桶算法: 系统以恒定速率向桶中放入令牌,每个请求需要从桶中获取一个令牌,如果桶中没有令牌,则拒绝请求。

      public class TokenBucketRateLimiter {
          private final int capacity; // 令牌桶容量
          private final double rate; // 令牌生成速率,单位:令牌/毫秒
          private double tokens; // 当前令牌数量
          private long lastRefillTimestamp; // 上次填充令牌的时间戳
      
          public TokenBucketRateLimiter(int capacity, double rate) {
              this.capacity = capacity;
              this.rate = rate;
              this.tokens = capacity; // 初始令牌数量为桶容量
              this.lastRefillTimestamp = System.currentTimeMillis();
          }
      
          public synchronized boolean tryAcquire() {
              refill(); // 先填充令牌
      
              if (tokens >= 1) {
                  tokens--;
                  return true; // 允许请求
              } else {
                  return false; // 拒绝请求
              }
          }
      
          private void refill() {
              long now = System.currentTimeMillis();
              double elapsedTime = (now - lastRefillTimestamp) / 1000.0; // 毫秒转秒
              double newTokens = elapsedTime * rate;
              tokens = Math.min(capacity, tokens + newTokens);
              lastRefillTimestamp = now;
          }
      }
    • 漏桶算法: 请求进入漏桶,漏桶以恒定速率流出请求,如果请求速度超过漏桶流出速度,则请求溢出,被拒绝。

      // 简易实现,实际生产中更复杂,需要考虑并发等问题
      public class LeakyBucketRateLimiter {
          private final int capacity; // 漏桶容量
          private final double rate; // 漏水速率,单位:请求/毫秒
          private int water; // 当前水量
          private long lastOutflowTimestamp; // 上次漏水的时间戳
      
          public LeakyBucketRateLimiter(int capacity, double rate) {
              this.capacity = capacity;
              this.rate = rate;
              this.water = 0;
              this.lastOutflowTimestamp = System.currentTimeMillis();
          }
      
          public synchronized boolean tryAcquire() {
              long now = System.currentTimeMillis();
              double elapsedTime = (now - lastOutflowTimestamp) / 1000.0;
              double outflow = elapsedTime * rate; // 计算流出的水量
              water = Math.max(0, water - (int)outflow);
              lastOutflowTimestamp = now;
      
              if (water < capacity) {
                  water++;
                  return true;
              } else {
                  return false;
              }
          }
      }
      
  • 排查步骤:

    1. 检查限流配置: 确认限流策略是否过于严格,例如QPS阈值设置过低。
    2. 查看限流日志: 分析限流日志,确认是否有大量请求被限流。
    3. 监控限流指标: 监控限流器的状态,例如计数器值、令牌桶剩余令牌数等。
    4. 动态调整限流策略: 根据实际情况,动态调整限流策略,例如增加QPS阈值、调整令牌生成速率等。
  • 代码示例:

    假设我们使用Guava RateLimiter进行限流,排查代码如下:

    import com.google.common.util.concurrent.RateLimiter;
    
    public class RateLimiterExample {
        private static final RateLimiter rateLimiter = RateLimiter.create(100); // 每秒允许100个请求
    
        public static void main(String[] args) {
            for (int i = 0; i < 200; i++) {
                if (rateLimiter.tryAcquire()) {
                    // 处理请求
                    System.out.println("处理请求:" + i);
                } else {
                    // 请求被限流
                    System.out.println("请求被限流:" + i);
                }
            }
        }
    }

    在排查QPS下降时,需要关注rateLimiter.tryAcquire()的返回值,如果大量请求返回false,则说明系统正在进行限流。可以通过调整RateLimiter.create(100)的参数来调整限流策略。

三、熔断排查

熔断是为了防止系统因外部依赖故障而导致雪崩效应。当外部依赖出现故障时,熔断器会切断对该依赖的调用,避免故障蔓延。但如果熔断器误判,或者熔断策略配置不当,也可能导致QPS下降。

  • 常见熔断器实现:

    • Hystrix: Netflix开源的熔断器,功能强大,但已停止维护。
    • Resilience4j: 轻量级的熔断器,功能完善,且仍在积极维护。
    • Spring Cloud CircuitBreaker: Spring Cloud提供的熔断器,与Spring Cloud生态集成良好。
  • 排查步骤:

    1. 检查熔断器状态: 确认熔断器是否处于打开状态。
    2. 查看熔断器指标: 监控熔断器的状态指标,例如错误率、请求数量、熔断时间等。
    3. 分析熔断原因: 分析熔断日志,确认熔断的原因,例如外部依赖超时、异常等。
    4. 调整熔断策略: 根据实际情况,调整熔断策略,例如调整错误率阈值、熔断时间等。
    5. 检查依赖服务: 确认被熔断的依赖服务是否正常运行。
  • 代码示例:

    假设我们使用Resilience4j进行熔断,排查代码如下:

    import io.github.resilience4j.circuitbreaker.CircuitBreaker;
    import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
    import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
    import java.time.Duration;
    import java.util.function.Supplier;
    
    public class CircuitBreakerExample {
    
        public static void main(String[] args) {
            // 配置熔断器
            CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
                    .failureRateThreshold(50) // 错误率超过50%则熔断
                    .waitDurationInOpenState(Duration.ofSeconds(10)) // 熔断10秒后尝试恢复
                    .slidingWindowSize(10) // 统计最近10次请求
                    .build();
    
            CircuitBreakerRegistry circuitBreakerRegistry = CircuitBreakerRegistry.of(circuitBreakerConfig);
            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("myCircuitBreaker");
    
            // 定义需要熔断保护的方法
            Supplier<String> backendService = () -> {
                // 模拟调用外部服务
                if (Math.random() < 0.6) {
                    throw new RuntimeException("调用外部服务失败");
                }
                return "调用外部服务成功";
            };
    
            // 使用熔断器包装方法
            Supplier<String> decoratedSupplier = CircuitBreaker.decorateSupplier(circuitBreaker, backendService);
    
            // 调用方法
            for (int i = 0; i < 20; i++) {
                try {
                    String result = decoratedSupplier.get();
                    System.out.println("调用结果:" + result + ", 状态:" + circuitBreaker.getState());
                } catch (Exception e) {
                    System.out.println("调用失败:" + e.getMessage() + ", 状态:" + circuitBreaker.getState());
                }
            }
        }
    }

    在排查QPS下降时,需要关注circuitBreaker.getState()的状态,如果状态为OPEN,则说明熔断器处于打开状态。可以通过调整CircuitBreakerConfig的参数来调整熔断策略,或者检查被熔断的依赖服务是否正常运行。

四、线程池排查

线程池是管理和复用线程的机制,可以提高系统性能。但如果线程池配置不当,例如线程池过小、任务队列过长等,也可能导致QPS下降。

  • 常见线程池参数:

    • corePoolSize: 核心线程数,即使线程空闲也会保持的线程数量。
    • maximumPoolSize: 最大线程数,线程池允许的最大线程数量。
    • keepAliveTime: 线程空闲时间,超过该时间的空闲线程会被回收。
    • workQueue: 任务队列,用于存放等待执行的任务。
    • RejectedExecutionHandler: 拒绝策略,当任务队列已满且线程池已达到最大线程数时,用于处理新提交的任务。
  • 排查步骤:

    1. 监控线程池状态: 监控线程池的活跃线程数、任务队列长度、已完成任务数等指标。
    2. 分析线程Dump: 获取线程Dump,分析线程状态,查找线程是否处于阻塞状态。
    3. 检查线程池配置: 确认线程池的配置是否合理,例如线程池大小是否足够、任务队列是否过长等。
    4. 调整线程池配置: 根据实际情况,调整线程池配置,例如增加线程池大小、缩短线程空闲时间等。
    5. 分析任务执行时间: 分析任务的执行时间,找出耗时较长的任务,并进行优化。
  • 代码示例:

    假设我们使用ThreadPoolExecutor创建线程池,排查代码如下:

    import java.util.concurrent.ArrayBlockingQueue;
    import java.util.concurrent.ThreadPoolExecutor;
    import java.util.concurrent.TimeUnit;
    
    public class ThreadPoolExample {
        private static final int CORE_POOL_SIZE = 5;
        private static final int MAX_POOL_SIZE = 10;
        private static final long KEEP_ALIVE_TIME = 60L;
        private static final int QUEUE_CAPACITY = 100;
    
        private static final ThreadPoolExecutor executor = new ThreadPoolExecutor(
                CORE_POOL_SIZE,
                MAX_POOL_SIZE,
                KEEP_ALIVE_TIME,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(QUEUE_CAPACITY),
                new ThreadPoolExecutor.CallerRunsPolicy()); // 使用CallerRunsPolicy拒绝策略
    
        public static void main(String[] args) {
            for (int i = 0; i < 200; i++) {
                final int taskId = i;
                executor.execute(() -> {
                    try {
                        // 模拟执行任务
                        Thread.sleep((long) (Math.random() * 100)); // 模拟任务执行时间
                        System.out.println("执行任务:" + taskId + ", 线程:" + Thread.currentThread().getName());
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                });
            }
    
            // 关闭线程池
            executor.shutdown();
            try {
                executor.awaitTermination(10, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    在排查QPS下降时,需要关注线程池的活跃线程数、任务队列长度等指标。可以使用jconsole或者jvisualvm等工具来监控线程池的状态。如果任务队列过长,或者活跃线程数接近最大线程数,则说明线程池可能存在瓶颈。可以尝试增加线程池大小或者优化任务执行时间来解决问题。同时,关注拒绝策略的执行情况,如果大量任务被拒绝,则说明线程池无法处理所有的请求。

五、链路诊断工具的使用

链路追踪工具,如 SkyWalking, Zipkin, Jaeger 等,可以帮助我们分析请求在各个服务之间的调用链,找出耗时较长的环节,从而快速定位问题。

  • 使用步骤:

    1. 引入依赖: 在项目中引入链路追踪工具的依赖。
    2. 配置参数: 配置链路追踪工具的参数,例如采样率、上报地址等。
    3. 代码埋点: 在关键代码处进行埋点,例如在Controller层、Service层、DAO层等。
    4. 查看追踪信息: 通过链路追踪工具的UI界面,查看请求的调用链、耗时信息等。
  • 示例(以SkyWalking为例):

    1. 引入依赖 (Maven):
    <dependency>
        <groupId>org.apache.skywalking</groupId>
        <artifactId>apm-toolkit-trace</artifactId>
        <version>8.9.0</version>
    </dependency>
    1. 配置 Agent: 下载 SkyWalking Agent, 并配置 agent.config 文件. 主要配置 collector.servers 指向 SkyWalking OAP 服务器.
    2. 启动 Agent: 在 JVM 启动参数中添加 -javaagent:/path/to/skywalking-agent/skywalking-agent.jar.
    3. 查看链路信息: 访问 SkyWalking UI, 查看 trace 信息.

链路追踪工具能够提供清晰的调用链,帮助我们快速定位瓶颈点,例如数据库查询缓慢、外部服务响应慢等。

六、实际案例分析

假设一个电商系统在双十一期间QPS突然下降,经过初步排查,发现CPU利用率不高,数据库连接数也正常。

  1. 查看限流日志: 发现大量请求被限流,原因是秒杀接口的限流策略过于严格。
  2. 调整限流策略: 临时调整秒杀接口的限流阈值,增加允许通过的请求数量。
  3. 查看熔断器状态: 发现支付服务被熔断,原因是支付服务在高峰期出现了一些故障。
  4. 检查支付服务: 检查支付服务,发现数据库连接池耗尽,导致服务响应缓慢。
  5. 调整数据库连接池: 增加支付服务的数据库连接池大小,解决数据库连接耗尽的问题。
  6. 恢复熔断器: 等待支付服务恢复正常后,手动恢复熔断器。
  7. 监控线程池: 发现订单服务的线程池任务队列过长,导致订单处理速度缓慢。
  8. 调整线程池: 增加订单服务的线程池大小,提高订单处理速度。

通过以上排查和调整,电商系统的QPS逐渐恢复正常。

七、总结与建议

QPS下降的排查是一个复杂的过程,需要综合考虑各种因素。限流、熔断和线程池是三个关键环节,需要重点关注。

  • 监控是关键: 完善的监控系统可以帮助我们及时发现问题。
  • 日志是线索: 详细的日志可以帮助我们定位问题。
  • 链路追踪是利器: 链路追踪工具可以帮助我们分析请求链路,找出耗时较长的环节。
  • 逐步排查: 从最可能的原因入手,逐步排查,缩小范围。

希望今天的分享能帮助大家更好地排查Java系统QPS下降的问题,谢谢大家。

核心思路的回顾

本文阐述了QPS下降的排查思路,重点介绍了限流、熔断和线程池的排查方法,并结合代码示例和实际案例进行了说明,强调了监控、日志和链路追踪的重要性。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注