前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
社区首页 >专栏 >基于Redis的窗口计数场景

基于Redis的窗口计数场景

作者头像
CBeann
发布2023-12-25 18:51:22
发布2023-12-25 18:51:22
28000
代码可运行
举报
文章被收录于专栏:CBeann的博客CBeann的博客
运行总次数:0
代码可运行

场景

每一个月用户只能申请三次出校,这个需要该咋做呢?这个需求等价于每一个小时只允许发三次短信验证码,真的等价吗???

每一个小时只允许发三种短信有两种场景

  • 场景一:1:59分发3条,2:01分发3条成立
  • 场景二:1:59分发3条,2:01分发3条不成立,因为在1:50到2:10这个窗口时间段里发送了6条

代码下载

https://github.com/cbeann/Demooo/tree/master/springboot-demo/src/main/java/com/example/windowlimit

场景一

场景1的处理其实比较简单,就是把时间拼接到key里,然后加1 ,在判断结果

代码语言:javascript
代码运行次数:0
运行
复制
package com.example.windowlimit;

import java.text.DateFormat;
import java.util.Date;
import java.util.concurrent.TimeUnit;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;


@RestController
public class WindowLimitDemo1Controller {


    private static final String USER_PREFIX = "user:";

    private static final Integer LIMIT_NUM = 3;

    //1小时的毫秒数
    private static final Integer PERIOD = 1 * 60 * 60 * 1000;
    //1分钟
    private static final Integer PERIOD_WINDOW = 10 * 1000;


    @Autowired
    private StringRedisTemplate stringRedisTemplate;


    @Autowired
    private RedisTemplate<String, Object> redisTemplate;


    /**
     * 场景一:1:59分发3条,2:01分发3条成立
     */
    @GetMapping("/emailLimit")
    public Object emailLimit(String userName) {


        userName = "zhangsan";

        DateFormat dateTimeInstance = DateFormat.getDateInstance();
        Date date = new Date();
        String format = dateTimeInstance.format(date);

        //拼接字符串
        String key = USER_PREFIX + format + userName;

        String s = stringRedisTemplate.opsForValue().get(key);


        Integer num = 0;

        if (null != s) {
            num = Integer.parseInt(s);
        }


        if (num < LIMIT_NUM) {
            System.out.println("发送短信");
            //设置超时
            stringRedisTemplate.opsForValue().set(key, String.valueOf(num + 1), 1, TimeUnit.HOURS);
            return 1;
        } else {
            return 0;
        }


    }


    
}

上面的代码是线程不安全的,高并发下容易出现问题,下面是更完善

代码语言:javascript
代码运行次数:0
运行
复制
 @GetMapping("/emailLimitV2")
    public Object emailLimitV2(String userName) {


        userName = "zhangsan";

        DateFormat dateTimeInstance = DateFormat.getDateInstance();
        Date date = new Date();
        String format = dateTimeInstance.format(date);

        //拼接字符串
        String key = USER_PREFIX + format + userName;

        //给key+1,因为redis是单线程的,所以redis那边是线程安全的,这边把结果获取并判断是否大于阈值,也是线程安全的
        Long num = stringRedisTemplate.opsForValue().increment(key, 1);
        //设置过期时间 一天
        stringRedisTemplate.expire(key, 1 * 24 * 60 * 60 * 1000, TimeUnit.MILLISECONDS);


        if (num < LIMIT_NUM) {
            System.out.println("发送短信");
            //设置超时
            return 1;
        } else {
            return 0;
        }


    }

场景二

线程不安全(初始逻辑)

代码

场景二就需要使用到zset结构了,假设我的场景是10秒窗口内最多允许3次

第20秒请求进入,先从key中删除0秒到10秒的数据(20秒-时间窗口10秒),然后判断key的个数为多少个,如果小于3,说明该时间场控内允许访问,否则就是不允许访问,达到上限,返回

代码语言:javascript
代码运行次数:0
运行
复制
package com.example.windowlimit;

import java.text.DateFormat;
import java.util.Date;
import java.util.concurrent.TimeUnit;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;


@RestController
public class WindowLimitDemo1Controller {


    private static final String USER_PREFIX = "user:";

    private static final Integer LIMIT_NUM = 3;

    //1小时的毫秒数
    private static final Integer PERIOD = 1 * 60 * 60 * 1000;
    //1分钟
    private static final Integer PERIOD_WINDOW = 10 * 1000;


    @Autowired
    private StringRedisTemplate stringRedisTemplate;


    @Autowired
    private RedisTemplate<String, Object> redisTemplate;


    

    /** 1:59分发3条,2:01分发3条不成立,因为在1:50到2:10这个窗口时间段里发送了6条 下面按照1分钟3条写demo 线不与安全 */
  @GetMapping("/emailWindowLimit")
  public Object emailWindowLimit(String userName) {

    userName = "lisi";
    // 拼接字符串
    String key = USER_PREFIX + userName;
    long current = System.currentTimeMillis();

    // 移除时间窗口之前的行为记录,剩下的都是时间窗口内的
    redisTemplate.opsForZSet().removeRangeByScore(key, 0, current - PERIOD_WINDOW);
    // 获取窗口内的行为数量
    Long zCard = redisTemplate.opsForZSet().zCard(key);

    if (zCard < LIMIT_NUM) {
      System.out.println("send email");

      // 记录行为
      redisTemplate.opsForZSet().add(key, current, current);
      // 设置zset过期时间,避免冷用户持续占用内存
      // 过期时间应该等于时间窗口长度,再多宽限1单位,此处是1毫秒,其实多一秒也行
      redisTemplate.expire(key, PERIOD_WINDOW + 1, TimeUnit.MILLISECONDS);
      return 1;
    }

    return 0;
  }
}
线程不安全分析

前提:此时此刻时间为10,窗口范围为10,窗口范围内允许最大数量为3,并且在第9秒有2次成功请求,在第11秒,此时该接口被同一用户(lisi)两个线程访问,就出现了线程不安全问题。

如下图所示,线程并发执行,判断后发现还有一次机会,结果这两个请求都成功发送email,此时在窗口(8,12)范围内就发送了4次,不符合要求。

线程安全

lua脚本

代码语言:javascript
代码运行次数:0
运行
复制
--根据score范围删除数据
redis.call("zremrangebyscore",KEYS[1],ARGV[1],ARGV[2])

--获取个数
local zSetLen = redis.call("zcard", KEYS[1])
--如果大于某个数
if tonumber(zSetLen) > tonumber(ARGV[4]) then
    return 0
end
--zadd添加数据
local res = redis.call("zadd",KEYS[1], ARGV[5], ARGV[6])
redis.call("expire",KEYS[1],ARGV[3])
return res

java代码

代码语言:javascript
代码运行次数:0
运行
复制
   private static final String USER_PREFIX = "user:";

  private static final Integer LIMIT_NUM = 3;

  // 1小时的毫秒数
  private static final Integer PERIOD = 1 * 60 * 60 * 1000;
  // 1分钟
  private static final Integer PERIOD_WINDOW = 60 * 1000;

  @Autowired private StringRedisTemplate stringRedisTemplate;

  @Autowired private RedisTemplate<String, Object> redisTemplate;

 /** 1:59分发3条,2:01分发3条不成立,因为在1:50到2:10这个窗口时间段里发送了6条 下面按照1分钟3条写demo 线不与安全 */
  @GetMapping("/emailWindowLimitV2")
  @Deprecated
  public Object emailWindowLimitV2(String userName) {

    userName = "zhangsan";
    // 拼接字符串
    String key = userName;
    //获取当前的时间
    long current = System.currentTimeMillis();

    // 执行一个lua脚本
    String scriptLua = "";

    DefaultRedisScript<Object> defaultRedisScript = new DefaultRedisScript<>();
    defaultRedisScript.setResultType(Object.class);
    defaultRedisScript.setScriptText("--根据score删除数据\n"
            + "redis.call(\"zremrangebyscore\",KEYS[1],ARGV[1],ARGV[2])\n"
            + "\n"
            + "--获取个数\n"
            + "local zSetLen = redis.call(\"zcard\", KEYS[1])\n"
            + "\n"
            + "\n"
            + "\n"
            + "if tonumber(zSetLen) > tonumber(ARGV[4]) then\n"
            + "    return 0\n"
            + "end\n"
            + "--zadd添加数据\n"
            + "local res = redis.call(\"zadd\",KEYS[1], ARGV[5], ARGV[6])\n"
            + "redis.call(\"expire\",KEYS[1],ARGV[3])\n"
            + "return res\n"
            + "\n"
            + "\n");
    // defaultRedisScript.setScriptSource(new ResourceScriptSource(new
    // ClassPathResource("redis/demo.lua")));

    List<String> keys = new ArrayList<>();
    keys.add(key);
    Object[] args = new Object[6];
    args[0] = 0;//删除的窗口开始
    args[1] = current-PERIOD_WINDOW;//删除的窗口结束
    args[2] = 60;//设置key的过期时间
    args[3] = LIMIT_NUM;//设置limit
    args[4] = new Date().getTime();//zadd 的元组
    args[5] = new Date().getTime();//zadd 的元组

    Object execute = redisTemplate.execute(defaultRedisScript, keys, args);
    System.out.println(execute);

    return execute;
  }

注意

考虑并发问题

key应该设置过期时间

参考

Redis中使用Lua脚本(一) - 知乎

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2022-03-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 场景
  • 代码下载
  • 场景一
  • 场景二
    • 线程不安全(初始逻辑)
      • 代码
      • 线程不安全分析
    • 线程安全
  • 注意
  • 参考
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档