JAVA高并发 Redis+Lua限流实战

参考资料

https://dwz.cn/Gvviwswi

https://dwz.cn/pO9mWjhq

简介

采用了redis来作为限流器的实现

  • redis作为高性能缓存系统,性能上能够满足多机之间高并发访问的要求
  • redis有比较好的api来支持限流器令牌桶算法的实现
  • 对于我们的系统来说,通过spring data redis来操作比较简单和常见,避免了引入新的中间件带来的风险

但是我们也知道,限流器在每次请求令牌和放入令牌操作中,存在一个协同的问题,即获取令牌操作要尽可能保证原子性,否则无法保证限流器是否能正常工作。在RateLimiter的实现中使用了mutex作为互斥锁来保证操作的原子性,那么在redis中就需要一个类似于事务的机制来保证获取令牌中多重操作的原子性。 面对这样的需求,我们有几个选择:

  • 用redis实现分布式锁来保证操作的原子性,这个方案实现起来应该比较简单,分布式锁有现成的例子,然后就是把Rate Limiter的代码套用分布式锁就行了,但是这样的话效率会显得不太高,特别是在大量访问的情况下。
  • 用redis的transaction,在我查阅redis官方文档和stackoverflow之后发现redis的transaction官方并不推荐,并且有可能在未来取消事务,因此不可取。
  • 通过redis分布式锁和本地锁组成一个双层结构,每次分布式获取锁之后可以预支一部分令牌量,然后放到本地通过本地的锁来分配这些令牌,消耗完之后再到请求redis。这样的好处是相比第一个方案,网络访问延迟开销会比较好,但是实现难度和复杂程度比较难估量,而且这样的做法如果在多机不能保证均匀分配流量的情况下并不理想
  • 通过将获取锁封装到lua脚本中,提交给redis进行eval和evalsha操作来完成lua脚本的执行,由于lua脚本在redis中天然的原子性,我们的需求能够比较好的满足,问题是将业务逻辑封装在lua中,对于开发人员自身的能力和调试存在一定的问题。

经过权衡,我采用了第四种方式,通过redis和lua来编写令牌桶算法来完成分布式限流的需求。

项目实战

本项目基于SpringBoot 2.1.5,使用到 Redis + Lua 限流脚本

一. 引入依赖 pom.xml文件

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">    <modelVersion>4.0.0</modelVersion>    <parent>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-parent</artifactId>        <version>2.1.5.RELEASE</version>        <relativePath/>    </parent>    <groupId>com.xd</groupId>    <artifactId>redis-lua-limit</artifactId>    <version>0.0.1-SNAPSHOT</version>    <name>redis-lua-limit</name>    <description>基于Spring Boot Redis+Lua高并发限流</description>
    <properties>        <java.version>1.8</java.version>    </properties>
    <dependencies>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-web</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-data-redis</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-aop</artifactId>        </dependency>        <dependency>            <groupId>org.apache.commons</groupId>            <artifactId>commons-lang3</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-test</artifactId>        </dependency>        <dependency>            <groupId>org.projectlombok</groupId>            <artifactId>lombok</artifactId>            <version>1.18.8</version>        </dependency>    </dependencies>
    <build>        <plugins>            <plugin>                <groupId>org.springframework.boot</groupId>                <artifactId>spring-boot-maven-plugin</artifactId>            </plugin>        </plugins>    </build>
</project>

二. application.properties

spring.application.name=spring-boot-redis-lua-limit
# Redis数据库索引 默认为0spring.redis.database=0# Redis地址spring.redis.host=localhost# Redis端口 默认6379spring.redis.port=6379# Redis服务器连接密码(默认为空)spring.redis.password=# 连接池最大连接数(使用负值表示没有限制)spring.redis.jedis.pool.max-active=8# 连接池最大阻塞等待时间(使用负值表示没有限制)spring.redis.jedis.pool.max-wait=-1# 连接池中的最大空闲连接spring.redis.jedis.pool.max-idle=8# 连接池中的最小空闲连接spring.redis.jedis.pool.min-idle=0# 连接超时时间(毫秒)spring.redis.timeout=10000

三. Lua 脚本

--Lua脚本
--- 限流KEY资源唯一标识local key = "rate.limit:" .. KEYS[1]--- 时间窗最大并发数local limit = tonumber(ARGV[1])--- 时间窗内当前并发数local current = tonumber(redis.call('get', key) or "0")--如果超出限流大小if current + 1 > limit then  return 0else  --请求数+1,并设置2秒过期  redis.call("INCRBY", key,"1")   redis.call("expire", key,"2")   return current + 1end


--IP限流Lua脚本
--local key = "rate.limit:" .. KEYS[1]--local limit = tonumber(ARGV[1])--local expire_time = ARGV[2]----local is_exists = redis.call("EXISTS", key)--if is_exists == 1 then--    if redis.call("INCR", key) > limit then--        return 0--    else--        return 1--    end--else--    redis.call("SET", key, 1)--    redis.call("EXPIRE", key, expire_time)--    return 1--end

1、我们通过KEYS[1] 获取传入的key参数 2、通过ARGV[1]获取传入的limit参数 3、redis.call方法,从缓存中get和key相关的值,如果为nil那么就返回0 4、接着判断缓存中记录的数值是否会大于限制大小,如果超出表示该被限流,返回0 5、如果未超过,那么该key的缓存值+1,并设置过期时间为1秒钟以后,并返回缓存值+1

三. 注解

自定义注解的目的,是在需要限流的方法上使用

package com.xd.redislualimit.annotation;

import java.lang.annotation.*;
/** * @Author 李号东 * @Description 限流注解 * @Date 17:49 2019-05-25 **/@Target({ElementType.TYPE, ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface RateLimit {
    /**     * 限流唯一标示     *     * @return     */    String key() default "";
    /**     * 限流时间     *     * @return     */    int time();
    /**     * 限流次数     *     * @return     */    int count();}

四. 配置

package com.xd.redislualimit.config;
import org.springframework.context.annotation.Bean;import org.springframework.core.io.ClassPathResource;import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.data.redis.core.script.DefaultRedisScript;import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;import org.springframework.data.redis.serializer.StringRedisSerializer;import org.springframework.scripting.support.ResourceScriptSource;import org.springframework.stereotype.Component;
import java.io.Serializable;
/** * @Classname commons * @Description 配置 * @Author 李号东 lihaodongmail@163.com * @Date 2019-05-25 20:13 * @Version 1.0 */@Componentpublic class commons {
    /**     * 读取限流脚本     *     * @return     */    @Bean    public DefaultRedisScript<Number> redisluaScript() {        DefaultRedisScript<Number> redisScript = new DefaultRedisScript<>();        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("redisLimit.lua")));        //返回类型        redisScript.setResultType(Number.class);        return redisScript;    }
    /**     * RedisTemplate     *     * @return     */    @Bean    public RedisTemplate<String, Serializable> limitRedisTemplate(LettuceConnectionFactory redisConnectionFactory) {        RedisTemplate<String, Serializable> template = new RedisTemplate<>();        template.setKeySerializer(new StringRedisSerializer());        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());        template.setConnectionFactory(redisConnectionFactory);        return template;    }}

五. 限流注解拦截器

package com.xd.redislualimit.config;
import com.xd.redislualimit.annotation.RateLimit;import com.xd.redislualimit.utils.IPUtil;import lombok.extern.slf4j.Slf4j;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.aspectj.lang.reflect.MethodSignature;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Configuration;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.data.redis.core.script.DefaultRedisScript;import org.springframework.web.context.request.RequestContextHolder;import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;import java.io.Serializable;import java.lang.reflect.Method;import java.util.Collections;import java.util.List;import java.util.Objects;
/** * @Classname LimitAspect * @Description 注解拦截 * @Author 李号东 lihaodongmail@163.com * @Date 2019-05-25 20:15 * @Version 1.0 */@Slf4j@Aspect@Configurationpublic class LimitAspect {

    @Autowired    private RedisTemplate<String, Serializable> redisTemplate;
    @Autowired    private DefaultRedisScript<Number> redisluaScript;
    //执行redis的具体方法,限制method,保证没有其他的东西进来    @Around("execution(* com.xd.redislualimit.controller ..*(..) )")    public Object interceptor(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();        Method method = signature.getMethod();        Class<?> targetClass = method.getDeclaringClass();
        RateLimit rateLimit = method.getAnnotation(RateLimit.class);
        if (rateLimit != null) {            HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();            String ipAddress = IPUtil.getIp(request);
            String string = ipAddress + "-" + targetClass.getName() + "- " + method.getName() + "-" + rateLimit.key();            List<String> keys = Collections.singletonList(string);            Number number = redisTemplate.execute(redisluaScript, keys, rateLimit.count(), rateLimit.time());
            if (number != null && number.intValue() != 0 && number.intValue() <= rateLimit.count()) {                log.info("限流时间段内访问第:{} 次", number.toString());                return joinPoint.proceed();            }
        } else {            return joinPoint.proceed();        }        log.error("已经到设置限流次数");        throw new RuntimeException("已经到设置限流次数");    }
}

六. 控制层

package com.xd.redislualimit.controller;
import com.xd.redislualimit.annotation.RateLimit;import org.apache.commons.lang3.time.DateFormatUtils;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.data.redis.support.atomic.RedisAtomicInteger;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
/** * @Classname LimiterController * @Description 测试控制层 * @Author 李号东 lihaodongmail@163.com * @Date 2019-05-25 20:16 * @Version 1.0 */@RestControllerpublic class LimiterController {
    @Autowired    private RedisTemplate redisTemplate;
    // 10 秒中,可以访问5次    @RateLimit(key = "test", time = 10, count = 5)    @GetMapping("/test")    public String luaLimiter() {        // 简单测试方法        RedisAtomicInteger entityIdCounter = new RedisAtomicInteger("counter", redisTemplate.getConnectionFactory());        String date = DateFormatUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss.SSS");        return date + " 累计访问次数:" + entityIdCounter.getAndIncrement();    }}

七. 测试

启动程序 打开浏览器访问 http://localhost:8080/test

连续访问5次 控制台打印

限流成功!

项目地址: https://github.com/LiHaodong888/SpringBootLearn/

点好看,送你小花花~

本文分享自微信公众号 - 李浩东的博客(lihaodong_blog)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-05-25

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏linda

【Redis】Redis Sentinel

IP:192.168.225.128、192.168.225.129 环境:centos7 版本:redis-3.2.10

12430
来自专栏weixuqin 的专栏

redis 学习(11)-- redis pipeline

所以可以看到,如果执行 n 次的话(比如 n 次 set 操作),时间开销是非常大的。

20020
来自专栏Devops专栏

Centos7 安装 redis

1.修改/root/redis-stable/redis.conf: daemonize no 将值改为yes 保存退出

28840
来自专栏weixuqin 的专栏

redis 学习(9)-- redis 客户端 -- redis-py

关于 redis 的各种客户端,我们可以在官网上寻找并使用,比如我这里的 python 客户端,可以在官网上找到:redis-client 。

14330
来自专栏linda

Redis-脚本-获取某个大key的值

在redis中,对于一个很大的key,例如hash类型,直接查看其值会非常慢,于是想到写个脚本通过增量迭代来获取

15810
来自专栏青青天空树

你知道如何在springboot中使用redis吗

特别说明:本文针对的是新版 spring boot 2.1.3,其 spring data 依赖为 spring-boot-starter-data-redis...

15620
来自专栏魔王卷子的专栏

Redis 的 GEO 特性

今天看文档,无意中发现了 Redis 的一个新功能。 Redis 在 3.2 版本实现了一个地理位置计算的特性。

33520
来自专栏weixuqin 的专栏

redis 学习(10)-- redis 慢查询

MySQL会记录下查询超过指定时间的语句,我们将超过指定时间的SQL语句查询称为慢查询,都记在慢查询日志里。

16940
来自专栏Node开发

简单理解Token机制

互联网发展到现在已经到了一个非常成熟的时代,所以不再是一个你写一个静态网站就可以进行疯狂盈利的时代了。现在对产品有着很多的要求,健壮性,安全性这...

46010
来自专栏linda

【Redis】Redis安装+主从部署

(一个服务器上启动两个redis,端口为6379和6380, 192.168.225.128:6379主,192.168.225.128:6380从

14320

扫码关注云+社区

领取腾讯云代金券

年度创作总结 领取年终奖励