基于 Redis 实现分布式应用限流

原文链接:http://xiaoqiangge.com/aritcle/1513004492550.html

限流的目的是通过对并发访问/请求进行限速或者一个时间窗口内的的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务。

image

前几天在DD的公众号,看了一篇关于使用 瓜娃 实现单应用限流的方案,参考《redis in action》 实现了一个jedis版本的,都属于业务层次限制。 实际场景中常用的限流策略:

  • Nginx接入层限流按照一定的规则如帐号、IP、系统调用逻辑等在Nginx层面做限流
  • 业务应用系统限流通过业务代码控制流量这个流量可以被称为信号量,可以理解成是一种锁,它可以限制一项资源最多能同时被多少进程访问。

代码实现

import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;
import redis.clients.jedis.ZParams;

import java.util.List;
import java.util.UUID;

/**
 * email [email protected]
 * @data 2017-08
 */
public class RedisRateLimiter {
    private static final String BUCKET = "BUCKET";
    private static final String BUCKET_COUNT = "BUCKET_COUNT";
    private static final String BUCKET_MONITOR = "BUCKET_MONITOR";

    static String acquireTokenFromBucket(
            Jedis jedis, int limit, long timeout) {
        String identifier = UUID.randomUUID().toString();
        long now = System.currentTimeMillis();
        Transaction transaction = jedis.multi();

        //删除信号量
        transaction.zremrangeByScore(BUCKET_MONITOR.getBytes(), "-inf".getBytes(), String.valueOf(now - timeout).getBytes());
        ZParams params = new ZParams();
        params.weightsByDouble(1.0,0.0);
        transaction.zinterstore(BUCKET, params, BUCKET, BUCKET_MONITOR);

        //计数器自增
        transaction.incr(BUCKET_COUNT);
        List<Object> results = transaction.exec();
        long counter = (Long) results.get(results.size() - 1);

        transaction = jedis.multi();
        transaction.zadd(BUCKET_MONITOR, now, identifier);
        transaction.zadd(BUCKET, counter, identifier);
        transaction.zrank(BUCKET, identifier);
        results = transaction.exec();
        //获取排名,判断请求是否取得了信号量
        long rank = (Long) results.get(results.size() - 1);
        if (rank < limit) {
            return identifier;
        } else {//没有获取到信号量,清理之前放入redis 中垃圾数据
            transaction = jedis.multi();
            transaction.zrem(BUCKET_MONITOR, identifier);
            transaction.zrem(BUCKET, identifier);
            transaction.exec();
        }
        return null;
    }
}

调用

测试接口调用
@GetMapping("/")
public void index(HttpServletResponse response) throws IOException {
    Jedis jedis = jedisPool.getResource();
    String token = RedisRateLimiter.acquireTokenFromBucket(jedis, LIMIT, TIMEOUT);
    if (token == null) {
        response.sendError(500);
    }else{
        //TODO 你的业务逻辑
    }
    jedisPool.returnResource(jedis);
}

优化

使用拦截器 + 注解优化代码

@Configuration
static class WebMvcConfigurer extends WebMvcConfigurerAdapter {
    private Logger logger = LoggerFactory.getLogger(WebMvcConfigurer.class);
    @Autowired
    private JedisPool jedisPool;

    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new HandlerInterceptorAdapter() {
            public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                                     Object handler) throws Exception {
                HandlerMethod handlerMethod = (HandlerMethod) handler;
                Method method = handlerMethod.getMethod();
                RateLimiter rateLimiter = method.getAnnotation(RateLimiter.class);

                if (rateLimiter != null){
                    int limit = rateLimiter.limit();
                    int timeout = rateLimiter.timeout();
                    Jedis jedis = jedisPool.getResource();
                    String token = RedisRateLimiter.acquireTokenFromBucket(jedis, limit, timeout);
                    if (token == null) {
                        response.sendError(500);
                        return false;
                    }
                    logger.debug("token -> {}",token);
                    jedis.close();
                }
                return true;
            }
        }).addPathPatterns("/*");
    }
}

定义注解

/**
 * email [email protected]
 * @data 2017-08
 * 限流注解
 */

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {
    int limit() default 5;
    int timeout() default 1000;
}

使用

@RateLimiter(limit = 2, timeout = 5000)
@GetMapping("/test")
public void test() {
}

并发测试

工具:apache-jmeter-3.2
说明: 没有获取到信号量的接口返回500,status是红色,获取到信号量的接口返回200,status是绿色。
当限制请求信号量为2,并发5个线程:

image

image

点评

这种方式可以实现限流功能,但是有一个很严重的问题,窗口中数据过期时间不均匀。所谓时间均匀就是确保每条数据都能遵守过期时间合约,但是上面这个代码不能完全遵守过期时间合约,如下测试,

@Test
    public void test01(){
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxIdle(20);
        config.setMaxTotal(40);
        config.setMinIdle(10);
        JedisPool jedisPool = new JedisPool(config, "127.0.0.1", 32768, 1000);
        while(true){
            String value =   RedisRateLimiter.acquireTokenFromBucket(jedisPool.getResource(),5,10000);
            log.info(">> {}",value);
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

代码中设置的限流条数是5条,限流时是10妙,意思就是说10秒内,最多流量为5条,也就是没一条都得遵守10秒过期的合约。输出的日志如下,

22:34:32.667 [main] INFO com.eju.ess.MyTest - >> b7a6da80-6e8c-489a-8892-d841974c36d6
22:34:33.680 [main] INFO com.eju.ess.MyTest - >> e85155f4-07a5-4be4-8db5-60043b9ae6af
22:34:34.682 [main] INFO com.eju.ess.MyTest - >> 5cf9aa87-9e90-4a3f-8ffa-09217159e2d1
22:34:35.683 [main] INFO com.eju.ess.MyTest - >> ce9c7106-f291-4e0a-8a19-bd20c492aa2c
22:34:36.685 [main] INFO com.eju.ess.MyTest - >> 325add3a-ba66-42a7-a741-320f48e90525
22:34:37.687 [main] INFO com.eju.ess.MyTest - >> null
22:34:38.689 [main] INFO com.eju.ess.MyTest - >> null
22:34:39.690 [main] INFO com.eju.ess.MyTest - >> null
22:34:40.691 [main] INFO com.eju.ess.MyTest - >> null
22:34:41.693 [main] INFO com.eju.ess.MyTest - >> null
22:34:42.694 [main] INFO com.eju.ess.MyTest - >> b456493d-81d5-4a4d-82c9-bb08300cc9c1
22:34:43.695 [main] INFO com.eju.ess.MyTest - >> f2078bd6-dfcb-4ab1-bc69-fdfbfb17437b
22:34:44.696 [main] INFO com.eju.ess.MyTest - >> 88c305fe-a888-4979-a3ca-a90c51290905
22:34:45.698 [main] INFO com.eju.ess.MyTest - >> d7d59be2-01a8-4023-9e13-14bb855b761a
22:34:46.699 [main] INFO com.eju.ess.MyTest - >> 13010e9a-6cbb-44dc-8c20-7e220104b9b9
22:34:47.700 [main] INFO com.eju.ess.MyTest - >> null
22:34:48.702 [main] INFO com.eju.ess.MyTest - >> null
22:34:49.703 [main] INFO com.eju.ess.MyTest - >> null
22:34:50.704 [main] INFO com.eju.ess.MyTest - >> null
22:34:51.706 [main] INFO com.eju.ess.MyTest - >> null
22:34:52.707 [main] INFO com.eju.ess.MyTest - >> 0b2c9cee-e8f6-4cab-b571-6628ebce10a5
22:34:53.709 [main] INFO com.eju.ess.MyTest - >> f93da7e3-f48f-4321-b573-d3f586775728
22:34:54.710 [main] INFO com.eju.ess.MyTest - >> 0e5f9b4a-a8b0-482e-89fb-5efb7b6fd458
22:34:55.711 [main] INFO com.eju.ess.MyTest - >> be271748-44bd-4850-9f2a-06b18986f536
22:34:56.713 [main] INFO com.eju.ess.MyTest - >> a645c321-35a2-4738-93f4-dbbf5224f000
22:34:57.714 [main] INFO com.eju.ess.MyTest - >> null
22:34:58.715 [main] INFO com.eju.ess.MyTest - >> null
22:34:59.717 [main] INFO com.eju.ess.MyTest - >> null
22:35:00.718 [main] INFO com.eju.ess.MyTest - >> null

从上面日志看到,只有b7a6da80-6e8c-489a-8892-d841974c36d6``b456493d-81d5-4a4d-82c9-bb08300cc9c1``0b2c9cee-e8f6-4cab-b571-6628ebce10a5这三条记录遵守了过期合约,其余的没有遵守,也就是5条之中有一条到期了,整个都会到期。

总结

上面的代码可以对分布式限流实现了部分,但是不够完美。

猜你喜欢

转载自blog.csdn.net/minicto/article/details/78779402