场景
每一个月用户只能申请三次出校,这个需要该咋做呢?这个需求等价于每一个小时只允许发三次短信验证码,真的等价吗???
每一个小时只允许发三种短信有两种场景
- 场景一: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 ,在判断结果
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; } } }
上面的代码是线程不安全的,高并发下容易出现问题,下面是更完善
@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,说明该时间场控内允许访问,否则就是不允许访问,达到上限,返回
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脚本
--根据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代码
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应该设置过期时间