在开发高并发的系统时, 有很多手段来保护系统, 如缓存, 降级和限流等. 缓存可以提升系统的访问速度, 降级可以暂时屏蔽掉非核心业务, 使得核心业务不受影响. 限流的目的通过对并发访问进行限速, 一旦达到一定的速率就可以拒绝服务(定向到错误页或告知资源没有了), 排队等待(如秒杀, 评论, 下单等), 降级(直接返回兜底数据, 如商品库存默认有货).
常见的限流方式有: 限制总并发数(数据库连接池, 线程池), 限制瞬时并发数(如 Nginx 的 limit_conn 模块), 限制时间窗口的平均速率(如 Guava 的 RateLimiter,Nginx 的 limit_req 模块), 限制远程接口的调用速率, 限制 MQ 的消费速率等. 从应用的层面上来讲, 又可以分为: 接入层限流, 应用层限流和分布式限流等.
限流算法
令牌桶算法
令牌桶算法是一个存放固定容量令牌的容器, 按照固定速率添加令牌, 算法描述如下:
假设限制 2r/s, 则按照 500ms 的固定速率添加令牌.
桶的总容量为 N, 当达到总容量时, 新添加的令牌则被丢弃或拒绝.
当一个 n 个字节大小的数据包到达, 则从桶中删除 n 个令牌, 然后处理数据包.
如果桶中的令牌不足 n 个, 则不会删除令牌, 但是数据包将会被限流.
漏桶算法
漏桶可以用于流量整型和流量控制, 算法描述如下:
一个固定容量的漏桶, 会按照固定的速率流出水滴.
如果桶中无水, 则不需要流出水滴.
可以以任意速率流入水滴.
如果流入的水滴超出了桶容量, 则新添加的则会被丢弃.
综上可以看出, 令牌桶允许一定程度的突发请求(有令牌就可以处理), 漏桶的主要目的是来平滑流入的速率.
应用级限流
限制总并发数 / 连接 / 请求数
对于一个应用来说, 总会有一个 TPS/QPS 的阀值, 如果超过了阀值, 则系统就会变得非常慢跟甚至无法响应. 因此需要对系统进行过载保护, 避免大量请求击垮系统.
如 Tomcat 的 Connector 中的以下几个参数:
acceptCount: 如果 Tomcat 的线程都忙于响应, 新来的连接将会进入队列, 如果超出队列大小, 则会拒绝连接.
maxConnections: 瞬时最大连接数, 超出的会排队等待.
maxThreads:Tomcat 能启动用来处理请求的最大线程数, 如果请求处理量一直远远大于线程数, 则会引起响应变慢甚至会僵死.
类似于 Tomcat 配置最大连接数等参数, Redis 和 MySQL 也有相关的配置.
限制接口的总并发 / 请求数
在 Java 中可以用线程安全的 AtomicLong 或者 Semaphore 进行处理, 如下使用了 AtomicLong 进行简单的统计:
try {
if (atomic.incrementAndSet()> 阀值) {
- // 拒绝请求
- }
- // 处理请求
- } finally {
- atomic.decrementAndGet();
- }
这种方式实现起来比较简单暴力, 没有平滑处理, 这需要根据实际情况选择使用.
限流接口每秒的请求数
限制每秒的请求数, 可以使用 Guava 的 Cache 来存储计数器, 设置过期时间为 2S(保证能记录 1S 内的计数). 下面代码使用当前时间戳的秒数作为 key 进行统计, 这种限流的方式也比较简单.
- LoadingCache<Long, AtomicLong> counter =
- CacheBuilder.newBuilder()
- .expireAfterWrite(2, TimeUnit.SECONDS)
- .build(new CacheLoader<Long, AtomicLong>() {
- @Override
- public AtomicLong load(Long seconds) throws Exception {
- return new AtomicLong(0);
- }
- });
- long limit = 1000;
- while (true) {
- // 得到当前秒
- long currentSeconds = System.currentTimeMillis() / 1000;
- if (counter.get(currentSeconds).incrementAndGet()> limit) {
- System.out.println("限流了:" + currentSeconds);
- continue;
- }
- // 业务处理
- }
上面介绍的 2 中限流方案都是对于单机接口的限流, 当系统进行多机部署时, 就无法实现整体对外功能的限流了. 当然这也看具体的应用场景, 如果平行的应用服务器需要共享限流阀值指标, 可以使用 Redis 作为共享的计数器.
平滑限流接口的请求数
Guava 的 RateLimiter 提供的令牌桶算法可以用于平滑突发限流 (SmoothBursty) 和平滑预热限流 (SmoothWarmingUp) 实现.
平滑突发限流(SmoothBursty)
平滑突发限流顾名思义, 就是允许突发的流量进入, 后面再慢慢的平稳限流. 下面给出几个 Demo
- # 创建了容量为 5 的桶, 并且每秒新增 5 个令牌, 即每 200ms 新增一个令牌
- RateLimiter limiter = RateLimiter.create(5);
- while (true) {
- // 获取令牌(可以指定一次获取的个数), 获取后可以执行后续的业务逻辑
- System.out.println(limiter.acquire());
- }
上面代码执行结果如下所示:
- 0.0
- 0.188216
- 0.191938
- 0.199089
- 0.19724
- 0.19997
上面 while 循环中执行的 limiter.acquire(), 当没有令牌时, 此方法会阻塞. 实际应用当中应当使用 tryAcquire()方法, 如果获取不到就直接执行拒绝服务.
下面在介绍一下中途休眠的场景:
- RateLimiter limiter = RateLimiter.create(2);
- System.out.println(limiter.acquire());
- Thread.sleep(1500L);
- while (true) {
- System.out.println(limiter.acquire());
- }
上面代码执行结果如下:
- 0.0
- 0.0
- 0.0
- 0.0
- 0.499794
- 0.492334
从上面结果可以看出, 当线程休眠时, 会囤积令牌, 以给后续的 acquire()使用. 但是上面的代码只能囤积 1S 的令牌(也就是 2 个), 当睡眠时间超过 1.5S 时, 执行结果还是相同的.
平滑预热限流(SmoothWarmingUp)
平滑突发限流有可能瞬间带来了很大的流量, 如果系统扛不住的话, 很容易造成系统挂掉. 这时候, 平滑预热限流便可以解决这个问题. 创建方式:
- // permitsPerSecond 表示每秒钟新增的令牌数, warmupPeriod 表示从冷启动速率过渡到平均速率所需要的时间间隔
- RateLimiter.create(double permitsPerSecond, long warmupPeriod, TimeUnit unit)
- RateLimiter limiter = RateLimiter.create(5, 1000, TimeUnit.MILLISECONDS);
- for (int i = 1; i < 5; i++) {
- System.out.println(limiter.acquire());
- }
- Thread.sleep(1000L);
- for (int i = 1; i < 50; i++) {
- System.out.println(limiter.acquire());
- }
执行结果如下:
- 0.0
- 0.513566
- 0.353789
- 0.215167
- 0.0
- 0.519854
- 0.359071
- 0.219118
- 0.197874
- 0.197322
- 0.197083
- 0.196838
上面结果可以看出来, 平滑预热限流的耗时是慢慢趋近平均值的.
来源: http://www.jianshu.com/p/2441394e83d2