基于Redis的窗口计数场景

本文涉及的产品
云数据库 Redis 版,社区版 2GB
推荐场景:
搭建游戏排行榜
简介: 基于Redis的窗口计数场景

场景


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


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


  • 场景一: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次,不符合要求。


1.png


线程安全


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应该设置过期时间

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
目录
相关文章
|
19天前
|
缓存 监控 NoSQL
Redis - 在电商购物车场景下的实战分析
Redis - 在电商购物车场景下的实战分析
211 0
|
19天前
|
缓存 NoSQL 架构师
Redis 三种批量查询技巧,高并发场景下的利器
在高并发场景下,巧妙地利用缓存批量查询技巧能够显著提高系统性能。 在笔者看来,熟练掌握细粒度的缓存使用是每位架构师必备的技能。因此,在本文中,我们将深入探讨 Redis 中批量查询的一些技巧,希望能够给你带来一些启发。
Redis 三种批量查询技巧,高并发场景下的利器
|
9月前
|
缓存 NoSQL Redis
Redis高并发场景下秒杀超卖解决
Redis高并发场景下秒杀超卖解决
303 0
|
9月前
|
消息中间件 存储 NoSQL
【Redis从头学-5】Redis中的List数据类型实战场景之天猫热销榜单
【Redis从头学-5】Redis中的List数据类型实战场景之天猫热销榜单
191 0
|
19天前
|
存储 NoSQL 算法
Redis HyperLogLog 是什么?这些场景使用它,让我枪出如龙,一笑破苍穹
Redis HyperLogLog 是什么?这些场景使用它,让我枪出如龙,一笑破苍穹
73 0
|
19天前
|
存储 消息中间件 缓存
Redis的高性能使得它非常适合用于实时分析场景
【5月更文挑战第15天】Redis在Python Web开发中扮演关键角色,常用于缓存系统,提高数据读取速度;会话管理,存储用户信息;分布式锁,确保数据一致性;排行榜和计数,利用有序集合和哈希结构;消息队列,基于列表结构实现异步处理;实时分析,高效处理实时数据。其丰富的数据结构和高性能使其在多种场景下应用广泛。
296 3
|
10天前
|
存储 NoSQL 关系型数据库
Redis -- String 字符串, 计数命令,字符串操作
Redis -- String 字符串, 计数命令,字符串操作
18 0
|
9月前
|
存储 缓存 NoSQL
【Redis从头学-6】Redis中的Hash数据类型实战场景之购物车
【Redis从头学-6】Redis中的Hash数据类型实战场景之购物车
261 0
|
19天前
|
缓存 NoSQL 关系型数据库
【中间件】Redis与MySQL双写一致性如何保证?--缓存和数据库在双写场景下一致性是如何保证的
【中间件】Redis与MySQL双写一致性如何保证?--缓存和数据库在双写场景下一致性是如何保证的
149 0
【中间件】Redis与MySQL双写一致性如何保证?--缓存和数据库在双写场景下一致性是如何保证的
|
8月前
|
缓存 监控 NoSQL
Redis事务失效的三种场景
Redis事务失效的三种场景