Springboot整合Redis以及Lua腳本的使用


一、Maven依賴

<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.2.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
</parent>

<dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

二、application.properties中加入redis相關配置

# Redis數據庫索引(默認為0)  
spring.redis.database=0  
# Redis服務器地址  
spring.redis.host=192.168.0.24  
# Redis服務器連接端口  
spring.redis.port=6379  
# Redis服務器連接密碼(默認為空)  
spring.redis.password=  
# 連接池最大連接數(使用負值表示沒有限制)  
spring.redis.pool.max-active=200  
# 連接池最大阻塞等待時間(使用負值表示沒有限制)  
spring.redis.pool.max-wait=-1  
# 連接池中的最大空閑連接  
spring.redis.pool.max-idle=10 
# 連接池中的最小空閑連接  
spring.redis.pool.min-idle=0  
# 連接超時時間(毫秒)  
spring.redis.timeout=1000 

三、寫一個redis配置類

其實現在就可以在代碼中注入RedisTemplate,為啥可以直接注入呢?先看下源碼吧。下圖為 RedisAutoConfiguration類中的截圖和代碼:

@Configuration
@ConditionalOnClass(RedisOperations.class)
@EnableConfigurationProperties(RedisProperties.class)
@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class })
public class RedisAutoConfiguration {
    @Bean
    @ConditionalOnMissingBean(name = "redisTemplate")
    public RedisTemplate<Object, Object> redisTemplate(
            RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }
    @Bean
    @ConditionalOnMissingBean
    public StringRedisTemplate stringRedisTemplate(
            RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
        StringRedisTemplate template = new StringRedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }
}

 

       通過源碼可以看出,SpringBoot自動幫我們在容器中生成了一個RedisTemplate和一個StringRedisTemplate。
  但是,這個 RedisTemplate的泛型是<Object,Object>,寫代碼不方便,需要寫好多類型轉換的代碼;我們需要一個泛型為<String,Object>形式的RedisTemplate。並且,這個RedisTemplate沒有設置數據存在Redis時,key及value的序列化方式。
        看到這個 @ConditionalOnMissingBean注解后,就知道 如果Spring容器中有了RedisTemplate對象了,這個自動配置的RedisTemplate不會實例化。因此我們可以直接自己寫個配置類,配置 RedisTemplate。

重新配置一個RedisTemplate

package com.zxy.demo.redis;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
 * redis配置類
 * @author ZENG.XIAO.YAN
 * @date   2018年6月6日
 * 
 */
@Configuration
public class RedisConfig {
    
    @Bean
    @SuppressWarnings("all")
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<String, Object>();
        template.setConnectionFactory(factory);
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        // key采用String的序列化方式
        template.setKeySerializer(stringRedisSerializer);
        // hash的key也采用String的序列化方式
        template.setHashKeySerializer(stringRedisSerializer);
        // value序列化方式采用jackson
        template.setValueSerializer(jackson2JsonRedisSerializer);
        // hash的value序列化方式采用jackson
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet();
        return template;
    }
}

四、寫一個Redis工具類

直接用RedisTemplate操作Redis,需要很多行代碼,因此直接封裝好一個RedisUtils,這樣寫代碼更方便點。這個RedisUtils交給Spring容器實例化,使用時直接注解注入。
工具類代碼如下:
package com.zxy.demo.redis;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
/**
 * Redis工具類
 * @author ZENG.XIAO.YAN
 * @date   2018年6月7日
 */
@Component
public final class RedisUtil {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    // =============================common============================
    /**
     * 指定緩存失效時間
     * @param key 鍵
     * @param time 時間(秒)
     * @return
     */
    public boolean expire(String key, long time) {
        try {
            if (time > 0) {
                redisTemplate.expire(key, time, TimeUnit.SECONDS);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * 根據key 獲取過期時間
     * @param key 鍵 不能為null
     * @return 時間(秒) 返回0代表為永久有效
     */
    public long getExpire(String key) {
        return redisTemplate.getExpire(key, TimeUnit.SECONDS);
    }
    /**
     * 判斷key是否存在
     * @param key 鍵
     * @return true 存在 false不存在
     */
    public boolean hasKey(String key) {
        try {
            return redisTemplate.hasKey(key);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * 刪除緩存
     * @param key 可以傳一個值 或多個
     */
    @SuppressWarnings("unchecked")
    public void del(String... key) {
        if (key != null && key.length > 0) {
            if (key.length == 1) {
                redisTemplate.delete(key[0]);
            } else {
                redisTemplate.delete(CollectionUtils.arrayToList(key));
            }
        }
    }
    // ============================String=============================
    /**
     * 普通緩存獲取
     * @param key 鍵
     * @return*/
    public Object get(String key) {
        return key == null ? null : redisTemplate.opsForValue().get(key);
    }
    /**
     * 普通緩存放入
     * @param key 鍵
     * @param value 值
     * @return true成功 false失敗
     */
    public boolean set(String key, Object value) {
        try {
            redisTemplate.opsForValue().set(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * 普通緩存放入並設置時間
     * @param key 鍵
     * @param value 值
     * @param time 時間(秒) time要大於0 如果time小於等於0 將設置無限期
     * @return true成功 false 失敗
     */
    public boolean set(String key, Object value, long time) {
        try {
            if (time > 0) {
                redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
            } else {
                set(key, value);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * 遞增
     * @param key 鍵
     * @param delta 要增加幾(大於0)
     * @return
     */
    public long incr(String key, long delta) {
        if (delta < 0) {
            throw new RuntimeException("遞增因子必須大於0");
        }
        return redisTemplate.opsForValue().increment(key, delta);
    }
    /**
     * 遞減
     * @param key 鍵
     * @param delta 要減少幾(小於0)
     * @return
     */
    public long decr(String key, long delta) {
        if (delta < 0) {
            throw new RuntimeException("遞減因子必須大於0");
        }
        return redisTemplate.opsForValue().increment(key, -delta);
    }
    // ================================Map=================================
    /**
     * HashGet
     * @param key 鍵 不能為null
     * @param item 項 不能為null
     * @return*/
    public Object hget(String key, String item) {
        return redisTemplate.opsForHash().get(key, item);
    }
    /**
     * 獲取hashKey對應的所有鍵值
     * @param key 鍵
     * @return 對應的多個鍵值
     */
    public Map<Object, Object> hmget(String key) {
        return redisTemplate.opsForHash().entries(key);
    }
    /**
     * HashSet
     * @param key 鍵
     * @param map 對應多個鍵值
     * @return true 成功 false 失敗
     */
    public boolean hmset(String key, Map<String, Object> map) {
        try {
            redisTemplate.opsForHash().putAll(key, map);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * HashSet 並設置時間
     * @param key 鍵
     * @param map 對應多個鍵值
     * @param time 時間(秒)
     * @return true成功 false失敗
     */
    public boolean hmset(String key, Map<String, Object> map, long time) {
        try {
            redisTemplate.opsForHash().putAll(key, map);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * 向一張hash表中放入數據,如果不存在將創建
     * @param key 鍵
     * @param item 項
     * @param value 值
     * @return true 成功 false失敗
     */
    public boolean hset(String key, String item, Object value) {
        try {
            redisTemplate.opsForHash().put(key, item, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * 向一張hash表中放入數據,如果不存在將創建
     * @param key 鍵
     * @param item 項
     * @param value 值
     * @param time 時間(秒) 注意:如果已存在的hash表有時間,這里將會替換原有的時間
     * @return true 成功 false失敗
     */
    public boolean hset(String key, String item, Object value, long time) {
        try {
            redisTemplate.opsForHash().put(key, item, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * 刪除hash表中的值
     * @param key 鍵 不能為null
     * @param item 項 可以使多個 不能為null
     */
    public void hdel(String key, Object... item) {
        redisTemplate.opsForHash().delete(key, item);
    }
    /**
     * 判斷hash表中是否有該項的值
     * @param key 鍵 不能為null
     * @param item 項 不能為null
     * @return true 存在 false不存在
     */
    public boolean hHasKey(String key, String item) {
        return redisTemplate.opsForHash().hasKey(key, item);
    }
    /**
     * hash遞增 如果不存在,就會創建一個 並把新增后的值返回
     * @param key 鍵
     * @param item 項
     * @param by 要增加幾(大於0)
     * @return
     */
    public double hincr(String key, String item, double by) {
        return redisTemplate.opsForHash().increment(key, item, by);
    }
    /**
     * hash遞減
     * @param key 鍵
     * @param item 項
     * @param by 要減少記(小於0)
     * @return
     */
    public double hdecr(String key, String item, double by) {
        return redisTemplate.opsForHash().increment(key, item, -by);
    }
    // ============================set=============================
    /**
     * 根據key獲取Set中的所有值
     * @param key 鍵
     * @return
     */
    public Set<Object> sGet(String key) {
        try {
            return redisTemplate.opsForSet().members(key);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
    /**
     * 根據value從一個set中查詢,是否存在
     * @param key 鍵
     * @param value 值
     * @return true 存在 false不存在
     */
    public boolean sHasKey(String key, Object value) {
        try {
            return redisTemplate.opsForSet().isMember(key, value);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * 將數據放入set緩存
     * @param key 鍵
     * @param values 值 可以是多個
     * @return 成功個數
     */
    public long sSet(String key, Object... values) {
        try {
            return redisTemplate.opsForSet().add(key, values);
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }
    /**
     * 將set數據放入緩存
     * @param key 鍵
     * @param time 時間(秒)
     * @param values 值 可以是多個
     * @return 成功個數
     */
    public long sSetAndTime(String key, long time, Object... values) {
        try {
            Long count = redisTemplate.opsForSet().add(key, values);
            if (time > 0)
                expire(key, time);
            return count;
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }
    /**
     * 獲取set緩存的長度
     * @param key 鍵
     * @return
     */
    public long sGetSetSize(String key) {
        try {
            return redisTemplate.opsForSet().size(key);
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }
    /**
     * 移除值為value的
     * @param key 鍵
     * @param values 值 可以是多個
     * @return 移除的個數
     */
    public long setRemove(String key, Object... values) {
        try {
            Long count = redisTemplate.opsForSet().remove(key, values);
            return count;
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }
    // ===============================list=================================
    /**
     * 獲取list緩存的內容
     * @param key 鍵
     * @param start 開始
     * @param end 結束 0 到 -1代表所有值
     * @return
     */
    public List<Object> lGet(String key, long start, long end) {
        try {
            return redisTemplate.opsForList().range(key, start, end);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
    /**
     * 獲取list緩存的長度
     * @param key 鍵
     * @return
     */
    public long lGetListSize(String key) {
        try {
            return redisTemplate.opsForList().size(key);
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }
    /**
     * 通過索引 獲取list中的值
     * @param key 鍵
     * @param index 索引 index>=0時, 0 表頭,1 第二個元素,依次類推;index<0時,-1,表尾,-2倒數第二個元素,依次類推
     * @return
     */
    public Object lGetIndex(String key, long index) {
        try {
            return redisTemplate.opsForList().index(key, index);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
    /**
     * 將list放入緩存
     * @param key 鍵
     * @param value 值
     * @param time 時間(秒)
     * @return
     */
    public boolean lSet(String key, Object value) {
        try {
            redisTemplate.opsForList().rightPush(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * 將list放入緩存
     * @param key 鍵
     * @param value 值
     * @param time 時間(秒)
     * @return
     */
    public boolean lSet(String key, Object value, long time) {
        try {
            redisTemplate.opsForList().rightPush(key, value);
            if (time > 0)
                expire(key, time);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * 將list放入緩存
     * @param key 鍵
     * @param value 值
     * @param time 時間(秒)
     * @return
     */
    public boolean lSet(String key, List<Object> value) {
        try {
            redisTemplate.opsForList().rightPushAll(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * 將list放入緩存
     * 
     * @param key 鍵
     * @param value 值
     * @param time 時間(秒)
     * @return
     */
    public boolean lSet(String key, List<Object> value, long time) {
        try {
            redisTemplate.opsForList().rightPushAll(key, value);
            if (time > 0)
                expire(key, time);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * 根據索引修改list中的某條數據
     * @param key 鍵
     * @param index 索引
     * @param value 值
     * @return
     */
    public boolean lUpdateIndex(String key, long index, Object value) {
        try {
            redisTemplate.opsForList().set(key, index, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * 移除N個值為value
     * @param key 鍵
     * @param count 移除多少個
     * @param value 值
     * @return 移除的個數
     */
    public long lRemove(String key, long count, Object value) {
        try {
            Long remove = redisTemplate.opsForList().remove(key, count, value);
            return remove;
        } catch (Exception e) {
            e.printStackTrace();
            return 0;
        }
    }
}
View Code

注意 : 設置下key和value的序列化方式,不然存到Redis的中數據看起來像亂碼一下。


五、通過redisTemplate調用lua腳本 並打印調試信息到redis log

第一次寫完lua時,想到的就是如何在應用調用腳本的時候,去調試腳本。在網上海搜了一把,能找到的有點相關的寥寥無幾。

有一種方法是通過執行redis命令,調用redis客戶端,加載lua腳本,然后出現基於命令行調試的交互界面,輸入調試命令去調試腳本。如下:

在終端輸入命令:redis-cli.exe --ldb --eval LimitLoadTimes.lua 1 mykey , myargv

--ldb:redis-cli.exe進行命令調試的必要參數

--eval:告訴redis客戶端去加載Lua腳本,后面跟着的就是 lua腳本的路徑(我是直接放在redis目錄下),

1:傳給Lua腳本的key的數量,我測試的時候是1

--mykey:自己傳的一個key值,和前面的數量1對應

--myargv:自己傳的除key外的參數,可以是多個

注,命令中的逗號不能忽略,並且前后要有一個空格

  回車,如上圖,本來以為可以進入調試,結果等了半天,一直沒有出現交互的命令行界面,找了好久,還是沒找到辦法,結果只好先暫停(如果有大神遇到這種情況,跪求解~~)。換一種調試方式,把調試信息打在redis日志上。

下面是我自己調用腳本時,打印調試信息的方式,如果有更好的方式,請不吝賜教。

1、選擇redisTemplate序列化方式

首先,創建一個redisTemplate,具體代碼就不說了,這個比較簡單。要注意的是,需要設置redisTemplate的序列化方式,springBoot默認是基於java jdk的序列化。通過這種序列化后的參數傳到Lua腳本是,是無法正常打印到redis日志的,會出現亂碼,而且參數如果傳的是一個Map或List的話,不方便解析。並且這種序列化占用的字節比較大。所以改成JSON序列化,用FastJson實現。

下面貼上redis序列化代碼:

public class FastJsonRedisSerializer<T> implements RedisSerializer<T> {
    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
    private Class<T> clazz;
 
    public FastJsonRedisSerializer(Class<T> clazz){
        super();
        this.clazz = clazz;
    }
 
    @Override
    public byte[] serialize(T t) throws SerializationException {
        return ofNullable(t)
                .map(r -> JSON.toJSONString(r, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET))
                .orElseGet(() -> new byte[0]);
    }
 
    @Override
    public T deserialize(byte[] bytes) throws SerializationException {
        return Optional.ofNullable(bytes)
                .map(t -> JSON.parseObject(new String(t, DEFAULT_CHARSET), clazz))
                .orElseGet(() -> null);
    }
 
}

2、應用端加載腳本,並設置傳遞參數

  在springboot中,是用 DefaultRedisScript 類來加載腳本的,並設置相應的數據類型來接收lua腳本返回的數據,這個泛型類在使用時設置泛型是什么類型,腳本返回的結果就是用什么類型接收。注意,該類只接收4種類型的返回類型,之前沒注意,還納悶為什么出錯,看源碼才曉得,截圖,如下:

在lua腳本中,有兩個全局的變量,是用來接收redis應用端傳遞的鍵值和其它參數的,分別為KEYS、ARGV。

在應用端傳遞給KEYS時是一個數組列表,在lua腳本中通過索引方式獲取數組內的值。

在應用端,傳遞給ARGV的參數比較靈活,可以是多個獨立的參數,但對應到Lua腳本中是,統一用ARGV這個數組接收,獲取方式也是通過數組下標獲取。

下面貼上應用端的測試代碼:

 
@Service("luaScriptService")
public class LuaScriptServiceImpl implements LuaScriptService{
    @Autowired
    private RedisTemplate<String,Object> redisTemplate1;
 
    private DefaultRedisScript<List> getRedisScript;
 
    @PostConstruct
    public void init(){
        getRedisScript = new DefaultRedisScript<List>();
        getRedisScript.setResultType(List.class);
        getRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("luascript/LimitLoadTimes.lua")));
    }
 
    @Override
    public void redisAddScriptExec(){
        /**
         * List設置lua的KEYS
         */
        List<String> keyList = new ArrayList();
        keyList.add("count");
        keyList.add("rate.limiting:127.0.0.1");
 
        /**
         * 用Mpa設置Lua的ARGV[1]
         */
        Map<String,Object> argvMap = new HashMap<String,Object>();
        argvMap.put("expire",10000);
        argvMap.put("times",10);
 
        /**
         * 調用腳本並執行
         */
        List result = redisTemplate1.execute(getRedisScript,keyList, argvMap);
        System.out.println(result);
 
    }
}

  代碼中發送了兩個key,還有一個map包裝的argv,傳遞到Lua腳本中時,KEYS和ARGV接收到的是對象字符串,所以得用lua的庫做相關的解碼,我們發送的時候是用json序列化的,用Lua的庫cjson可以轉成json對象。下面貼上Lua腳本代碼:

--獲取KEY
local key1 = KEYS[1]
local key2 = KEYS[2]
 
-- 獲取ARGV[1],這里對應到應用端是一個List<Map>.
--  注意,這里接收到是的字符串,所以需要用csjon庫解碼成table類型
local receive_arg_json =  cjson.decode(ARGV[1])
 
--返回的變量
local result = {}
 
--打印日志到reids
--注意,這里的打印日志級別,需要和redis.conf配置文件中的日志設置級別一致才行
redis.log(redis.LOG_DEBUG,key1)
redis.log(redis.LOG_DEBUG,key2)
redis.log(redis.LOG_DEBUG, ARGV[1],#ARGV[1])
 
--獲取ARGV內的參數並打印
local expire = receive_arg_json.expire
local times = receive_arg_json.times
redis.log(redis.LOG_DEBUG,tostring(times))
redis.log(redis.LOG_DEBUG,tostring(expire))
 
--往redis設置值
redis.call("set",key1,times)
redis.call("incr",key2)
redis.call("expire",key2,expire)
 
--用一個臨時變量來存放json,json是要放入要返回的數組中的
local jsonRedisTemp={}
jsonRedisTemp[key1] = redis.call("get",key1)
jsonRedisTemp[key2] = redis.call("get", key2)
jsonRedisTemp["ttl"] = redis.call("ttl",key2)
redis.log(redis.LOG_DEBUG, cjson.encode(jsonRedisTemp))
 
 
result[1] = cjson.encode(jsonRedisTemp) --springboot redistemplate接收的是List,如果返回的數組內容是json對象,需要將json對象轉成字符串,客戶端才能接收
result[2] = ARGV[1] --將源參數內容一起返回
redis.log(redis.LOG_DEBUG,cjson.encode(result)) --打印返回的數組結果,這里返回需要以字符返回
 
return result

3、設置日志級別

代碼中,redis.log()函數向運日志中輸出信息,這里要注意一下,函數里面設置的日志級別要和redis.conf配置文件中設置的日志級別一樣才能正常打印到文件,這里我是設置成了deubg級別。這里可設置的級別有4種,分別如下:

  • redis.LOG_DEBUG
  • redis.LOG_VERBOSE
  • redis.LOG_NOTICE
  • redis.LOG_WARNING

在應用端,我們設置接收返回的數據類型是List,所以在Lua腳本中,返回的類型用table與之對應,並且放到table變量中的內容,得是字符串,應用端才能通過反序列化,正常解析。下圖是輸出lua返回值的打印信息:

 


public class LuaDemo {

    private final static String lua = "local num=redis.call('incr',KEYS[1])\n" +
            "if tonumber(num)==1 then\n" +
            "\tredis.call('expire',KEYS[1],ARGV[1])\n" +
            "\treturn 1\n" +
            "elseif tonumber(num)>tonumber(ARGV[2]) then\n" +
            "\treturn 0\n" +
            "else \n" +
            "\treturn 1\n" +
            "end";
    /**
     * 這是將腳本提取到外面為常量,用 jedis.evalsha()加載
     */
    public static void main(String[] args) {
        Jedis jedis = RedisManager.getJedis();
        List<String> keys = new ArrayList<>();
        keys.add("ip:limit:127.0.0.1");
        List<String> arggs = new ArrayList<>();
        arggs.add("6000");
        arggs.add("10");
        String luaLoad = jedis.scriptLoad(lua);
        System.out.println(luaLoad);
        Object obj = jedis.evalsha(luaLoad,keys,arggs);
        System.out.println(obj);
    }

    /**
     * 這是直接將腳本寫死在代碼中,用 jedis.eval()
     */
    public static void method(){
        Jedis jedis = RedisManager.getJedis();
        String lua = "local num=redis.call('incr',KEYS[1])\n" +
                "if tonumber(num)==1 then\n" +
                "\tredis.call('expire',KEYS[1],ARGV[1])\n" +
                "\treturn 1\n" +
                "elseif tonumber(num)>tonumber(ARGV[2]) then\n" +
                "\treturn 0\n" +
                "else \n" +
                "\treturn 1\n" +
                "end";
        List<String> keys = new ArrayList<>();
        keys.add("ip:limit:127.0.0.1");
        List<String> arggs = new ArrayList<>();
        arggs.add("6000");
        arggs.add("10");
        Object obj = jedis.eval(lua,keys,arggs);
        System.out.println(obj);
    }
}
View Code

 


 

六、Redis + Lua 限流

基於Redis的限流系統的設計,主要會談及限流系統中限流策略這個功能的設計;在實現方面,算法使用的是令牌桶算法來,訪問Redis使用lua腳本。

Lua 嵌入 Redis 優勢: 

    1. 減少網絡開銷: 不使用 Lua 的代碼需要向 Redis 發送多次請求, 而腳本只需一次即可, 減少網絡傳輸;
    2. 原子操作: Redis 將整個腳本作為一個原子執行, 無需擔心並發, 也就無需事務;
    3. 復用: 腳本會永久保存 Redis 中, 其他客戶端可繼續使用.

1、概念

In computer networks, rate limiting is used to control the rate of traffic sent or received by a network interface controller and is used to prevent DoS attacks

用我的理解翻譯一下:限流是對系統的出入流量進行控制,防止大流量出入,導致資源不足,系統不穩定。

  限流系統是對資源訪問的控制組件,控制主要的兩個功能:限流策略熔斷策略,對於熔斷策略,不同的系統有不同的熔斷策略訴求,有的系統希望直接拒絕、有的系統希望排隊等待、有的系統希望服務降級、有的系統會定制自己的熔斷策略,很難一一列舉,所以本文只針對限流策略這個功能做詳細的設計。

針對限流策略這個功能,限流系統中有兩個基礎概念:資源和策略。

  • 資源 :或者叫稀缺資源,被流量控制的對象;比如寫接口、外部商戶接口、大流量下的讀接口

  • 策略 :限流策略由限流算法和可調節的參數兩部分組成

熔斷策略:超出速率閾值的請求處理策略,是我自己理解的一個叫法,不是業界主流的說法。

2、限流算法

  • 限制瞬時並發數

  • 限制時間窗最大請求數

  • 令牌桶

2.1、限制瞬時並發數

定義:瞬時並發數,系統同時處理的請求/事務數量

優點:這個算法能夠實現控制並發數的效果

缺點:使用場景比較單一,一般用來對入流量進行控制

java偽代碼實現

AtomicInteger atomic = new AtomicInteger(1)
try {    
    if(atomic.incrementAndGet() > 限流數) {   
        //熔斷邏輯
    } else {
        //處理邏輯
    } 
} finally {
    atomic.decrementAndGet();
}

2.2、限制時間窗最大請求數

定義:時間窗最大請求數,指定的時間范圍內允許的最大請求數

優點:這個算法能夠滿足絕大多數的流控需求,通過時間窗最大請求數可以直接換算出最大的QPS(QPS = 請求數/時間窗)

缺點:這種方式可能會出現流量不平滑的情況,時間窗內一小段流量占比特別大

lua代碼實現

--- 資源唯一標識
local key = KEYS[1]
--- 時間窗最大並發數
local max_window_concurrency = tonumber(ARGV[1])  
--- 時間窗
local window = tonumber(ARGV[2])   
--- 時間窗內當前並發數
local curr_window_concurrency = tonumber(redis.call('get', key) or 0)  
if current + 1 > limit then
    return false
else
    redis.call("INCRBY", key,1)    
    if window > -1 then
        redis.call("expire", key,window)    
    end
    return true
end



---也可以這樣編寫:
local key = KEYS[1] --限流KEY(一秒一個)
local limit = tonumber(ARGV[1]) --限流大小
local current = tonumber(redis.call(‘get‘, key) or "0")
if current + 1 > limit then --如果超出限流大小
    return 0
else --請求數+1,並設置2秒過期
    redis.call("INCRBY", key,"1")
    redis.call("expire", key,"1")
    return 1
end

 

2.3、令牌桶

 

 

算法描述

  • 假如用戶配置的平均發送速率為r,則每隔1/r秒一個令牌被加入到桶中

  • 假設桶中最多可以存放b個令牌。如果令牌到達時令牌桶已經滿了,那么這個令牌會被丟棄

  • 當流量以速率v進入,從桶中以速率v取令牌,拿到令牌的流量通過,拿不到令牌流量不通過,執行熔斷邏輯

屬性

  • 長期來看,符合流量的速率是受到令牌添加速率的影響,被穩定為:r

  • 因為令牌桶有一定的存儲量,可以抵擋一定的流量突發情況

    • M是以字節/秒為單位的最大可能傳輸速率:M>r

    • T max = b/(M-r)    承受最大傳輸速率的時間

    • B max = T max * M   承受最大傳輸速率的時間內傳輸的流量

優點:流量比較平滑,並且可以抵擋一定的流量突發情況

因為我們限流系統的實現就是基於令牌桶這個算法,具體的代碼實現參考下文。

3、工程實現

3.1、技術選型

  • mysql:存儲限流策略的參數等元數據

  • redis+lua:令牌桶算法實現

說明:因為我們把redis 定位為:緩存、計算媒介,所以元數據都是存在db中

3.2、架構圖

 

 

3.3、 數據結構

字段 描述
name 令牌桶的唯一標示
apps 能夠使用令牌桶的應用列表
max_permits 令牌桶的最大令牌數
rate 向令牌桶中添加令牌的速率
created_by 創建人
updated_by 更新人

限流系統的實現是基於redis的,本可以和應用無關,但是為了做限流元數據配置的統一管理,按應用維度管理和使用,在數據結構中加入了apps這個字段,出現問題,排查起來也比較方便。

3.4、代碼實現

3.4.1、代碼實現遇到的問題

參考令牌桶的算法描述,一般思路是在RateLimiter-client放一個重復執行的線程,線程根據配置往令牌桶里添加令牌,這樣的實現由如下缺點:

  • 需要為每個令牌桶配置添加一個重復執行的線程

  • 重復的間隔精度不夠精確:線程需要每1/r秒向桶里添加一個令牌,當r>1000 時間線程執行的時間間隔根本沒辦法設置(從后面性能測試的變現來看RateLimiter-client 是可以承擔 QPS > 5000 的請求速率)

3.4.2、解決方案

基於上面的缺點,參考了google的guava中RateLimiter中的實現,我們使用了觸發式添加令牌的方式。

 

 

算法描述

  • 基於上述的令牌桶算法

  • 將添加令牌改成觸發式的方式,取令牌的是做添加令牌的動作

  • 在去令牌的時候,通過計算上一次添加令牌和當前的時間差,計算出這段間應該添加的令牌數,然后往桶里添加

    • curr_mill_second = 當前毫秒數

    • last_mill_second = 上一次添加令牌的毫秒數

    • r = 添加令牌的速率

    • reserve_permits = (curr_mill_second-last_mill_second)/1000 * r

  • 添加完令牌之后再執行取令牌邏輯

3.4.3、 lua代碼實現

--- 獲取令牌
--- 返回碼
--- 0 沒有令牌桶配置
--- -1 表示取令牌失敗,也就是桶里沒有令牌
--- 1 表示取令牌成功
--- @param key 令牌(資源)的唯一標識
--- @param permits  請求令牌數量
--- @param curr_mill_second 當前毫秒數
--- @param context 使用令牌的應用標識
local function acquire(key, permits, curr_mill_second, context)
    local rate_limit_info = redis.pcall("HMGET", key, "last_mill_second", "curr_permits", "max_permits", "rate", "apps")    
    local last_mill_second = rate_limit_info[1]    
    local curr_permits = tonumber(rate_limit_info[2])    
    local max_permits = tonumber(rate_limit_info[3])    
    local rate = rate_limit_info[4]    
    local apps = rate_limit_info[5]    
    --- 標識沒有配置令牌桶
    if type(apps) == 'boolean' or apps == nil or not contains(apps, context) then
        return 0
    end
    local local_curr_permits = max_permits;    
    --- 令牌桶剛剛創建,上一次獲取令牌的毫秒數為空
    --- 根據和上一次向桶里添加令牌的時間和當前時間差,觸發式往桶里添加令牌
    --- 並且更新上一次向桶里添加令牌的時間
    --- 如果向桶里添加的令牌數不足一個,則不更新上一次向桶里添加令牌的時間
    if (type(last_mill_second) ~= 'boolean' and last_mill_second ~= false and last_mill_second ~= nil) then
        local reverse_permits = math.floor(((curr_mill_second - last_mill_second) / 1000) * rate)        
        local expect_curr_permits = reverse_permits + curr_permits;
        local_curr_permits = math.min(expect_curr_permits, max_permits);       
         --- 大於0表示不是第一次獲取令牌,也沒有向桶里添加令牌
        if (reverse_permits > 0) then
            redis.pcall("HSET", key, "last_mill_second", curr_mill_second)       
      end
    else
        redis.pcall("HSET", key, "last_mill_second", curr_mill_second)   
    end
    local result = -1
    if (local_curr_permits - permits >= 0) then
        result = 1
        redis.pcall("HSET", key, "curr_permits", local_curr_permits - permits)    
    else
        redis.pcall("HSET", key, "curr_permits", local_curr_permits)    
    end
    return result
end

關於限流系統的所有實現細節,我都已經放到github上,gitbub地址:https://github.com/wukq/rate-limiter,有興趣的同學可以前往查看,由於筆者經驗與知識有限,代碼中如有錯誤或偏頗,歡迎探討和指正。

3.5、性能測試

配置:aws-elasticcache-redis 2核4g

因為Ratelimiter-client的功能比較簡單,基本上是redis的性能打個折扣。

  • 單線程取令牌:Ratelimiter-client的 QPS = 250/s

  • 10個線程取令牌:Ratelimiter-client的 QPS = 2000/s

  • 100個線程取令牌:Ratelimiter-client的 QPS = 5000/s

4、總結

限流系統從設計到實現都比較簡單,但是確實很實用,用四個字來形容就是:短小強悍,其中比較重要的是結合公司的權限體系和系統結構,設計出符合自己公司規范的限流系統。

不足

  • redis 我們用的是單點redis,只做了主從,沒有使用redis高可用集群(可能使用redis高可用集群,會帶來新的問題)

  • 限流系統目前只做了應用層面的實現,沒有做接口網關上的實現

  • 熔斷策略需要自己定制,如果實現的好一點,可以給一些常用的熔斷策略模板

 

參考文章:

https://blog.csdn.net/fsw4848438/article/details/81540495

https://www.cnblogs.com/zeng1994/p/03303c805731afc9aa9c60dbbd32a323.html

https://mp.weixin.qq.com/s?__biz=MzI0MTk0NTY5MA==&mid=2247483711&idx=1&sn=28780c8b26f24ac6314ff5c599bb622c&chksm=e9029c0ade75151c353cd6b720ce438b4342afd8ef3a7d03c61712554c6a000ac3646bbc3124&scene=38#wechat_redirect


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM