深入浅出 Guava RateLimiter

在高并发系统中,如何优雅地保护你的服务不被流量冲垮?限流是必不可少的防御手段之一。

一、为什么需要限流

想象一下这样的场景:

  • 秒杀活动:上万用户在同一时刻点击“立即购买”,后端服务如果毫无防护,瞬间的巨量请求会导致数据库崩溃,服务雪崩。
  • API开放平台:你需要为不同付费等级的客户提供 API 服务,免费用户可能限制每秒 10 次调用,而企业用户则允许每秒 1000 次。
  • 日志采集:应用疯狂打印日志,如果不加控制地写入磁盘或网络,可能会耗尽所有资源。

这些场景的共同痛点就是:对资源访问的速率需要进行控制。限流的核心目的就是通过限制速率,保证系统在承受范围内平稳运行,防止因突发流量导致服务不可用。

而 Guava RateLimiter 就是一个运行在应用层面的、单机的、简单易用的限流工具。

二、Guava RateLimiter 初探

Guava RateLimiter 提供了令牌桶算法的实现,他它有两种主要模式:

  1. 平滑突发限流(SmoothBursty):默认模式,允许一定程度的突发流量
  2. 平滑预热限流(SmoothWarmingUp):在系统冷启动时,有一个预热期,逐步将速率提升到设定值,避免冷系统被压垮

2.1 快速开始

首先,确保你的项目引入了 Guava 依赖:

<dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>31.1-jre</version>
</dependency>

创建一个限流器非常简单:

// 创建一个每秒产生 2 个令牌的限流器(即每秒允许处理 2 个操作)
RateLimiter rateLimiter = RateLimiter.create(2.0);

// 在代码中,在需要限流的地方“获取”令牌
void submitOrder() {
    // 阻塞当前线程,直到获取到一个令牌
    double acquireTime = rateLimiter.acquire();
    // 执行你的核心业务逻辑
    System.out.println("处理订单 at: " + System.currentTimeMillis());
}

运行上面的代码,你会发现打印间隔大约是 500 毫秒。

  • acquire():阻塞式获取 1 个令牌。如果当前没有可用令牌,它会进行睡眠,直到有令牌可用。返回值代表了该线程的“等待时间”,通常用于监控。
  • acquire(int permits):一次性获取多个令牌。

2.2 非阻塞尝试 - tryAcquire

在实际生产中,我们通常不希望线程无限制地等待。tryAcquire方法提供了非阻塞的选择。

if (rateLimiter.tryAcquire()) {
     // 成功获取令牌执行任务
     System.out.println("执行任务");
} else {
     // 未获取到令牌,执行其他操作
     System.out.println("未获取到令牌");
}

tryAcquire 还可以设置超时时间:

if (rateLimiter.tryAcquire(500, TimeUnit.MILLISECONDS)) {
     // 成功获取令牌执行任务
     System.out.println("执行任务");
} else {
     // 未获取到令牌,执行其他操作
     System.out.println("未获取到令牌");
}

三、深入原理:令牌桶算法

RateLimiter 的核心是令牌桶算法。你可以想象有一个桶,系统以固定的速率向这个桶里放入令牌。当请求到达时,需要从桶中获取一个(或多个)令牌,只有拿到令牌的请求才被放行。

优点:

  • 允许突发:如果桶中有积攒的令牌,瞬间的突发请求可以被立即处理。例如桶容量是 10,速率是 1/s,如果 10 秒内没有请求,桶里就有 10 个令牌,这时可以瞬间处理 10 个请求。这是 SmoothBursty 模式的特点。
  • 平滑输出:对于持续到来的请求,输出流是被平滑处理的,避免了间隔性的流量尖峰。

3.1 Guava RateLimiter 的实现

Guava 的 RateLimiter 在令牌桶算法的基础上做了一些优化,并没有真的用一个线程去定时投放令牌,而是通过一种“滞后支付”的方法。

它记录了下一个令牌可用的时间点。当有请求来获取令牌时,它通过当前时间和下一个令牌可用时间计算出需要等待的时间,然后让线程睡眠相应的时间。这种方式非常高效,不需要额外的线程。

下面以 acquire 方法为例:

  public double acquire() {
    // 获取一个令牌
    return acquire(1);
  }
  
  public double acquire(int permits) {
    // 计算休眠时间
    long microsToWait = reserve(permits);
    // 休眠
    stopwatch.sleepMicrosUninterruptibly(microsToWait);
    // 等待时间转换为秒
    return 1.0 * microsToWait / SECONDS.toMicros(1L);
  }

  final long reserve(int permits) {
    // permits校验,小于0抛出异常
    checkPermits(permits);
    synchronized (mutex()) {
      return reserveAndGetWaitLength(permits, stopwatch.readMicros());
    }
  }

  // 使用双重检查锁定模式初始化
  private Object mutex() {
    Object mutex = mutexDoNotUseDirectly;
    if (mutex == null) {
      synchronized (this) {
        mutex = mutexDoNotUseDirectly;
        if (mutex == null) {
          mutexDoNotUseDirectly = mutex = new Object();
        }
      }
    }
    return mutex;
  }

  // 预留并获取等待时长
  final long reserveAndGetWaitLength(int permits, long nowMicros) {
    long momentAvailable = reserveEarliestAvailable(permits, nowMicros);
    return max(momentAvailable - nowMicros, 0);
  }
  
  // 预留制定数量的许可,返回最早可用的时间戳
  // @param requiredPermits: 需要的许可数量
  // @param nowMicros: 当前时间(微秒)
  final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
    // 限流器状态同步,根据当前时间更新存储的许可数量
    resync(nowMicros);
    // 保存当前“下次通行时间”作为返回值
    long returnValue = nextFreeTicketMicros;
    double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
    double freshPermits = requiredPermits - storedPermitsToSpend;
    // 计算等待时间
    long waitMicros =
        storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
            + (long) (freshPermits * stableIntervalMicros);
    // 更新下次可用时间
    this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
    // 减少存储的许可数
    this.storedPermits -= storedPermitsToSpend;
    return returnValue;
  }
  
  // 限流器状态同步,更新存储的许可数量
  void resync(long nowMicros) {
    // 只有当前时间超过了记录的下次可用时间时才需要同步
    if (nowMicros > nextFreeTicketMicros) {
      // 计算生成的许可数量
      double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
      storedPermits = min(maxPermits, storedPermits + newPermits);
      nextFreeTicketMicros = nowMicros;
    }
  }

举个栗子:

    public static void main(String[] args) {
        RateLimiter rateLimiter = RateLimiter.create(1);
        System.out.println(LocalTime.now());
        System.out.println(rateLimiter.acquire(10));
        System.out.println(rateLimiter.acquire(20));
        System.out.println(LocalTime.now());
    }

如果完全按照令牌桶算法,上面的代码需要 30 个令牌,每秒产生 1 个令牌,总共需要等待 30s。

实际上 Guava RateLimiter 通过滞后支付的方法,只需要等待 10s。rateLimiter.acquire(10) 时立即执行,随后等待10s,调用 rateLimiter.acquire(20),程序结束(rateLimiter 倒欠 20 个令牌)。

四、高级特性

4.1 预热模式(Warming Up)

对于某些资源(如数据库连接池、缓存),在冷启动时直接处理大量请求会导致性能不佳。SmoothWarmingUp模式可以让限流器从较低速率开始,在设定的预热时间内,逐渐提升到设定的最高速率。

// 创建一个限流器,每秒 5 个令牌,预热时间为 3 秒
RateLimiter rateLimiter = RateLimiter.create(5.0, 3, TimeUnit.SECONDS);

// 在预热期内,即使有令牌,前几次调用 acquire() 的间隔也会逐渐变短,直到稳定在 200ms 一次。

这个特性对于需要“热身”的系统组件非常友好。

五、总结

Guava RateLimiter 是一个设计精良、易于使用的单机限流库。它通过令牌桶算法,在保证平均速率的同时,兼顾了系统的突发处理能力。

  • 核心方法:acquire() 和 tryAcquire()
  • 两种模式:SmoothBursty(应对突发)和 SmoothWarmingUp(系统预热)
  • 核心设计思想:滞后支付
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值