springboot+redis+lua實現(xiàn)簡單的限流馁启、黑名單

簡單了解下redis嵌入lua腳本(隨便百度扒的):
Redis支持的LUA腳本與其優(yōu)勢
redis嵌入lua官方文檔
Redis悲觀鎖芍秆、樂觀鎖和調(diào)用Lua腳本的優(yōu)缺點

序言

本教程本著人和代碼其中一個能跑就行的原則螟碎。人菜勿噴迹栓,只接受技術(shù)性建議克伊。
近期看到了一些關(guān)于redis的文章說:哇redis牛皮愿吹、redis怎么這么快犁跪、哦喲坷衍!redis6竟然支持多線程了枫耳。迁杨。铅协。

然后問題來了:多線程狐史?線程安全問題?哦喲侈玄,百度百度了解了下序仙,原來如此潘悼,此多線程非彼多線程(自己百度去治唤,這里不說這個)宾添。然后就看到了嵌入lua腳本這個騷操作,在繼續(xù)百度了下疙挺,哎喲铐然,不錯喲搀暑!拿來吧你。

蹭個熱度:吳簽和某時間管理大師參與多人運動(多線程)時已出現(xiàn)一個很細小(線程安全)的針眼冈绊,我的有點大埠啃,請你忍耐一下碴开,一夜之間讓花季程序猿痛苦流淚博秫。

正文

通過redis嵌入lua腳本,實現(xiàn)簡單的限流即寒、黑名單功能召噩。別說這也沒有那也沒有凹嘲,個性化功能自行開發(fā)周蹭。
其中有點小坑谷醉,最后再說。
說那么多萎攒,不如直接丟代碼耍休。Talk is cheap. Show me the code.

測試環(huán)境

win11
jdk8
Redis server v=5.0.9
springboot 2.4.7

Show me the code.

先來看看lua腳本
lua腳本存放在項目的resource目錄下的lua文件夾下面(路徑可以自己改,下面SelfRedisScript .java里面改成對應(yīng)的就行)

--- lua腳本:限流喧锦、黑名單專用燃少,慎改
--- 用于高并發(fā)情況下保證redis線程安全
--- 注意:
--- 1阵具、redis反序列化問題
--- 2阳液、完成lua腳本后帘皿,請在本地測試無誤后再提交代碼
--- 3、若lua腳本執(zhí)行報錯越庇,redis不會回滾已經(jīng)執(zhí)行的命令

-- 獲取傳遞進來的參數(shù)
local countKey = KEYS[1]
if countKey == nil then
    return true
end
-- 獲取傳遞進來的閾值
local requestCount = KEYS[2]
-- 獲取傳遞進來的過期時間ttl
local requestTtl = KEYS[3]
-- 獲取redis參數(shù)
local countVal = redis.call('GET', countKey)
-- 如果不是第一次請求
if countVal then
    -- 由于lua腳本接收到參數(shù)都會轉(zhuǎn)為String奉狈,所以要轉(zhuǎn)成數(shù)字類型才能比較
    local numCountVal = tonumber(countVal)
    -- 如果超過指定閾值桑驱,則返回true
    if numCountVal >= tonumber(requestCount) then
        return true
    else
        numCountVal = numCountVal + 1
        redis.call('SETEX', countKey, requestTtl, numCountVal)
    end
else
    redis.call('SETEX', countKey, requestTtl, 1)
end
return false

Java代碼(SelfRedisScript .java)注入RedisScript

@Component
public class SelfRedisScript {

    @Bean("redisScriptBoolean")
    public DefaultRedisScript<Boolean> redisScriptBoolean() {
        DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/limit_blacklisted.lua")));
        redisScript.setResultType(Boolean.class);
        return redisScript;
    }
}

Java代碼(RedisTemplateConfig.java)簡單配置RedisTemplate

@EnableCaching
@Configuration
@AutoConfigureBefore(RedisAutoConfiguration.class)
public class RedisTemplateConfig {

    @Bean
    @Primary
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        return redisTemplate;
    }
}

準備工作做完后,開始實現(xiàn)簡單的限流押框、黑名單

在過濾器里面實現(xiàn)功能
CommonConstants類中的常量、ApplicationConfig從application.yml從獲取值的代碼就不貼出來了兑徘,
WebUtils.returnResponse單獨列出來了,自行修改補充崭闲。
RedisConstants常量:

   /**
    * 限流機制key前綴 REQUEST_LIMIT:127.0.0.1:/api/test
    */
   String REQUEST_LIMIT = "REQUEST_LIMIT:%s:%s";

   /**
    * 黑名單機制key前綴 REQUEST_LIMIT:127.0.0.1:
    */
   String REQUEST_BLACKLISTED = "REQUEST_BLACKLISTED:%s";
@Slf4j
public class RequestLimitFilter implements Filter {

    private final ApplicationConfig applicationConfig;
    private Long limitTimeSeconds;
    private Integer limitCount;
    private Long blacklistedTimeSeconds;
    private Integer blacklistedCount;
    private List<String> limitIgnores;
    private RedisTemplate<String, Object> redisTemplate;
    private DefaultRedisScript<Boolean> script;

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        String requestURI = request.getRequestURI();
        if (!WebUtils.uriMatch(this.limitIgnores, requestURI)) {
            // 獲取ip
            String realIp = WebUtils.getIP(request);
            // 黑名單限制
            String blacklistedKey = String.format(RedisConstants.REQUEST_BLACKLISTED, realIp);
            // key為空返回true,超過指定閾值返回true忘伞,其他返回false
            Boolean blackPass = getPass(blacklistedKey, blacklistedCount, blacklistedTimeSeconds);
            if (blackPass) {
                WebUtils.returnResponse(response, JSONUtil.toJsonStr(R.failed(StatusCode.BLACKLISTED)));
                return;
            }
            // 限流限制
            String limitKey = String.format(RedisConstants.REQUEST_LIMIT, realIp, requestURI);
            Boolean limitPass = getPass(limitKey, limitCount, limitTimeSeconds);
            if (limitPass) {
                WebUtils.returnResponse(response, JSONUtil.toJsonStr(R.failed(StatusCode.LIMITED)));
                return;
            }
        }
        filterChain.doFilter(servletRequest, servletResponse);
    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        // 限流
        this.limitCount = ObjectUtil.isNull(applicationConfig.getLimitCount())
                ? CommonConstants.REQUEST_LIMIT_COUNT : applicationConfig.getLimitCount();
        this.limitTimeSeconds = ObjectUtil.isNull(applicationConfig.getLimitTimeSeconds())
                ? CommonConstants.REQUEST_LIMIT_TIME_SECONDS : applicationConfig.getLimitTimeSeconds();
        // 黑名單
        this.blacklistedCount = ObjectUtil.isNull(applicationConfig.getBlacklistedCount())
                ? CommonConstants.REQUEST_BLACKLISTED_COUNT : applicationConfig.getBlacklistedCount();
        this.blacklistedTimeSeconds = ObjectUtil.isNull(applicationConfig.getBlacklistedTimeSeconds())
                ? CommonConstants.REQUEST_BLACKLISTED_TIME_SECONDS : applicationConfig.getBlacklistedTimeSeconds();
        // 過濾請求薄翅,從application.yml從獲取值
        this.limitIgnores = IterUtil.isEmpty(applicationConfig.getLimitIgnores())
                ? Collections.emptyList() : applicationConfig.getLimitIgnores();
        // lua
        this.redisTemplate = SpringContextHolder.getBean(RedisTemplate.class);
        this.script = SpringContextHolder.getBean("redisScriptBoolean");
        Filter.super.init(filterConfig);
    }

    public RequestLimitFilter(ApplicationConfig applicationConfig) {
        this.applicationConfig = applicationConfig;
    }

    /**
     *  調(diào)用lua腳本沙兰,獲取執(zhí)行結(jié)果
     * @param key 緩存key
     * @param count 請求閾值
     * @param timeSeconds  攔截時間
     * @return 執(zhí)行結(jié)果
     */
    private Boolean getPass(String key, Integer count, Long timeSeconds) {
        Boolean execute = redisTemplate.execute(script, Arrays.asList(key, String.valueOf(count), String.valueOf(timeSeconds)));
        return execute == null ? true : execute;
    }
}

// -------------------------------------------WebUtils工具類---------------------------------------------
    public void returnResponse(HttpServletResponse response, String data) {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/html; charset=utf-8");
        try (PrintWriter writer = response.getWriter()) {
            // 通過 PrintWriter 將 data 數(shù)據(jù)直接 print 回去      
            writer.print(data);
        } catch (IOException ignored) {
        }
    }

    public String getIP(HttpServletRequest request) {
        Assert.notNull(request, "HttpServletRequest is null");
        String ip = request.getHeader(HEADER_X_REQUESTED_FOR);
        if (StrUtil.isBlank(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getHeader(HEADER_X_FORWARDED_FOR);
        }
        if (StrUtil.isBlank(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getHeader(HEADER_PROXY_CLIENT_IP);
        }
        if (StrUtil.isBlank(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getHeader(HEADER_WL_PROXY_CLIENT_IP);
        }
        if (StrUtil.isBlank(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getHeader(HEADER_HTTP_CLIENT_IP);
        }
        if (StrUtil.isBlank(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getHeader(HEADER_HTTP_X_FORWARDED_FOR);
        }
        if (StrUtil.isBlank(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return StrUtil.isBlank(ip) ? null : ip.split(",")[0];
    }

最后注冊下RequestLimitFilter.java這個過濾器

@Component
@AllArgsConstructor
public class FilterRegistration {

    private final ApplicationConfig applicationConfig;

    @Bean
    public FilterRegistrationBean<RequestLimitFilter> requestLimitFilter() {
        FilterRegistrationBean<RequestLimitFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(new RequestLimitFilter(applicationConfig));
        registration.addUrlPatterns("/*");
        registration.setName("RequestLimitFilter");
        registration.setOrder(1);
        return registration;
    }
}

展示下成果(計算規(guī)則自行調(diào)整

請求即記錄


請求即記錄

時間段內(nèi)多次請求達到限流指定的請求閾值


達到限流指定的請求閾值

時間段內(nèi)多次請求已被限流后,繼續(xù)請求達到黑名單指定的請求閾值


達到黑名單指定的請求閾值

注意事項

  • RedisTemplate配置的序列化問題

如果配置的是JdkSerializationRedisSerializer翘魄,就需要改成StringRedisSerializer鼎天,如果需要兩者兼容,那
就再給spring丟一個名為jdkRedisSerializer的Bean暑竟,然后在 @Autowired時但荤,添加@Qualifier("jdkRedisSerializer")指定注入Bean

  @Bean("jdkRedisSerializer")
  public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
      RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
      redisTemplate.setKeySerializer(new StringRedisSerializer());
      redisTemplate.setHashKeySerializer(new StringRedisSerializer());
      redisTemplate.setValueSerializer(new JdkSerializationRedisSerializer());
      redisTemplate.setHashValueSerializer(new JdkSerializationRedisSerializer());
      redisTemplate.setConnectionFactory(redisConnectionFactory);
      return redisTemplate;
  }
  • lua腳本執(zhí)行報錯問題

若lua腳本執(zhí)行報錯哑了,redis不會回滾已經(jīng)執(zhí)行的命令炕淮,所以在完成lua腳本后,請在本地測試無誤后再提交代碼

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市九妈,隨后出現(xiàn)的幾起案子策菜,更是在濱河造成了極大的恐慌,老刑警劉巖零如,帶你破解...
    沈念sama閱讀 219,366評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,521評論 3 395
  • 文/潘曉璐 我一進店門敞临,熙熙樓的掌柜王于貴愁眉苦臉地迎上來编矾,“玉大人凹蜈,你說我怎么就攤上這事吊骤⊙及停” “怎么了?”我有些...
    開封第一講書人閱讀 165,689評論 0 356
  • 文/不壞的土叔 我叫張陵养铸,是天一觀的道長谎碍。 經(jīng)常有香客問我,道長笋敞,這世上最難降的妖魔是什么喷兼? 我笑而不...
    開封第一講書人閱讀 58,925評論 1 295
  • 正文 為了忘掉前任候学,我火速辦了婚禮掰茶,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘啊胶。我一直安慰自己趣倾,他們只是感情好,可當我...
    茶點故事閱讀 67,942評論 6 392
  • 文/花漫 我一把揭開白布曹洽。 她就那樣靜靜地躺著,像睡著了一般惕澎。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上唧喉,一...
    開封第一講書人閱讀 51,727評論 1 305
  • 那天捣卤,我揣著相機與錄音,去河邊找鬼八孝。 笑死董朝,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的干跛。 我是一名探鬼主播子姜,決...
    沈念sama閱讀 40,447評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼楼入!你這毒婦竟也來了哥捕?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,349評論 0 276
  • 序言:老撾萬榮一對情侶失蹤嘉熊,失蹤者是張志新(化名)和其女友劉穎遥赚,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體阐肤,經(jīng)...
    沈念sama閱讀 45,820評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡鸽捻,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,990評論 3 337
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片御蒲。...
    茶點故事閱讀 40,127評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖诊赊,靈堂內(nèi)的尸體忽然破棺而出厚满,到底是詐尸還是另有隱情,我是刑警寧澤碧磅,帶...
    沈念sama閱讀 35,812評論 5 346
  • 正文 年R本政府宣布碘箍,位于F島的核電站,受9級特大地震影響鲸郊,放射性物質(zhì)發(fā)生泄漏丰榴。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,471評論 3 331
  • 文/蒙蒙 一秆撮、第九天 我趴在偏房一處隱蔽的房頂上張望四濒。 院中可真熱鬧,春花似錦职辨、人聲如沸盗蟆。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,017評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽喳资。三九已至,卻和暖如春腾供,著一層夾襖步出監(jiān)牢的瞬間仆邓,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,142評論 1 272
  • 我被黑心中介騙來泰國打工伴鳖, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留节值,地道東北人。 一個月前我還...
    沈念sama閱讀 48,388評論 3 373
  • 正文 我出身青樓黎侈,卻偏偏與公主長得像察署,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子峻汉,可洞房花燭夜當晚...
    茶點故事閱讀 45,066評論 2 355

推薦閱讀更多精彩內(nèi)容