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

来源:互联网 发布:淘宝买什么实木家具 编辑:程序博客网 时间:2024/06/04 19:24

原文链接: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 wangiegie@gmail.com * @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);}

优化

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

@Configurationstatic 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 wangiegie@gmail.com * @data 2017-08 * 限流注解 */@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @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-d841974c36d622:34:33.680 [main] INFO com.eju.ess.MyTest - >> e85155f4-07a5-4be4-8db5-60043b9ae6af22:34:34.682 [main] INFO com.eju.ess.MyTest - >> 5cf9aa87-9e90-4a3f-8ffa-09217159e2d122:34:35.683 [main] INFO com.eju.ess.MyTest - >> ce9c7106-f291-4e0a-8a19-bd20c492aa2c22:34:36.685 [main] INFO com.eju.ess.MyTest - >> 325add3a-ba66-42a7-a741-320f48e9052522:34:37.687 [main] INFO com.eju.ess.MyTest - >> null22:34:38.689 [main] INFO com.eju.ess.MyTest - >> null22:34:39.690 [main] INFO com.eju.ess.MyTest - >> null22:34:40.691 [main] INFO com.eju.ess.MyTest - >> null22:34:41.693 [main] INFO com.eju.ess.MyTest - >> null22:34:42.694 [main] INFO com.eju.ess.MyTest - >> b456493d-81d5-4a4d-82c9-bb08300cc9c122:34:43.695 [main] INFO com.eju.ess.MyTest - >> f2078bd6-dfcb-4ab1-bc69-fdfbfb17437b22:34:44.696 [main] INFO com.eju.ess.MyTest - >> 88c305fe-a888-4979-a3ca-a90c5129090522:34:45.698 [main] INFO com.eju.ess.MyTest - >> d7d59be2-01a8-4023-9e13-14bb855b761a22:34:46.699 [main] INFO com.eju.ess.MyTest - >> 13010e9a-6cbb-44dc-8c20-7e220104b9b922:34:47.700 [main] INFO com.eju.ess.MyTest - >> null22:34:48.702 [main] INFO com.eju.ess.MyTest - >> null22:34:49.703 [main] INFO com.eju.ess.MyTest - >> null22:34:50.704 [main] INFO com.eju.ess.MyTest - >> null22:34:51.706 [main] INFO com.eju.ess.MyTest - >> null22:34:52.707 [main] INFO com.eju.ess.MyTest - >> 0b2c9cee-e8f6-4cab-b571-6628ebce10a522:34:53.709 [main] INFO com.eju.ess.MyTest - >> f93da7e3-f48f-4321-b573-d3f58677572822:34:54.710 [main] INFO com.eju.ess.MyTest - >> 0e5f9b4a-a8b0-482e-89fb-5efb7b6fd45822:34:55.711 [main] INFO com.eju.ess.MyTest - >> be271748-44bd-4850-9f2a-06b18986f53622:34:56.713 [main] INFO com.eju.ess.MyTest - >> a645c321-35a2-4738-93f4-dbbf5224f00022:34:57.714 [main] INFO com.eju.ess.MyTest - >> null22:34:58.715 [main] INFO com.eju.ess.MyTest - >> null22:34:59.717 [main] INFO com.eju.ess.MyTest - >> null22: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条之中有一条到期了,整个都会到期。

总结

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