美文网首页Java
一口气讲完了Redis常用的数据结构及应用场景

一口气讲完了Redis常用的数据结构及应用场景

作者: 互联网高级架构师 | 来源:发表于2023-02-07 14:58 被阅读0次

    一、概述

    Redis是互联网技术领域使用最为广泛的存储中间件,它是Remote Dictionary Service(远程字典服务)的首字母缩写,Redis以其超高的性能、活跃的社区、详细的文档以及丰富的客户端库支持在开源中间件领域广受好评,国内外很多大型互联网都在使用Redis,比如:TwitterGithub、新浪微博、阿里巴巴、京东、Stack Overflow等,可以说,深入了解Redis应用和实践,已成为如今中高级后端加法绕不开的必备技能。

    二、Redis常见应用场景

    三、Redis有哪些数据结构

    3.1 String字符串

    字符串典型的使用场景:

    • 单值缓存
    • 对象缓存
    • 计数器
    • 分布式锁

    单值缓存

    127.0.0.1:6379> set num 1
    OK
    127.0.0.1:6379> get num
    "1"
    127.0.0.1:6379>
    

    单值缓存

    SET user:1 value(json格式数据)
    

    计数器

    文章阅读量、点赞量、评论量

    127.0.0.1:6379> incr article:read:id1
    (integer) 1
    127.0.0.1:6379> incr article:read:id1
    (integer) 2
    127.0.0.1:6379> incr article:up:id1
    (integer) 1
    127.0.0.1:6379> incr article:up:id2
    (integer) 1
    127.0.0.1:6379> incr article:comment:id1
    (integer) 1
    127.0.0.1:6379> incr article:comment:id1
    (integer) 2
    127.0.0.1:6379>
    

    分布式锁

    • setnx

    定时任务防止同一时刻重复执行,可以在业务执行代码前使用分布式锁控制。

    127.0.0.1:6379> setnx job GlobalNotifyJob
    (integer) 1
    127.0.0.1:6379> get job
    "GlobalNotifyJob"
    127.0.0.1:6379> ttl job
    (integer) -1
    127.0.0.1:6379>
    

    伪代码如下:

    @Slf4j
    @Component
    public class GlobalNotifyJob {
    
        private static final String LOCK_KEY = "redis_notify_lock";
    
        /**
         * 每小时执行一次
         */
        @Scheduled(cron = "0 0 0/1 * * ?")
        public void notify() {
            if (!lockService.grabLock(LOCK_KEY)) {
                log.info("[GlobalNotifyJob] 没有拿到锁, 停止操作......");
                return;
            }
            // 拿到锁,开始执行业务...
        }
    }
    
    • setex + 过期时间【SETNX KEY_NAME TIMEOUT VALUE】
    127.0.0.1:6379> setex key1 60 value1
    OK
    127.0.0.1:6379> ttl key1
    (integer) 53
    127.0.0.1:6379> get key1
    "value1"
    127.0.0.1:6379>
    

    涉及到 Redis分布式锁 知识点可以参考博主之前发的文章:探讨Redis分布式锁解决优惠券拼抢问题

    hash哈希

    哈希典型应用场景:

    • 缓存对象信息(帖子标题、摘要、作者信息)
    • 记录帖子的点赞数、评论数和点击数
    • 电商购物车
    命令 描述
    HSET key field value 存储一个哈希表key的键值
    HSETNX key field value 存储一个不存储的哈希表key的键值
    HMSET key field value [field value...] 在一个哈希表key中存储多个键值对
    HGET key field value 获取哈希表key对应的field键值
    HMGET key field value 批量获取哈希表key中多个field键值
    HDEL key field [field ...] 删除哈希表key中多个field的键值
    HLEN key 返回哈希表key中field的数量
    HGETALL key 返回哈希表key中所有的键值
    127.0.0.1:6379> hmset user:1 name austin age 25 address guangzhou balance 6888
    OK
    127.0.0.1:6379> hget user:1 name
    "austin"
    127.0.0.1:6379> hget user:1 balance
    "6888"
    127.0.0.1:6379> hmget user:1 age address
    1) "25"
    2) "guangzhou"
    127.0.0.1:6379> hlen user:1
    (integer) 4
    127.0.0.1:6379> hgetall user:1
    1) "name"
    2) "austin"
    3) "age"
    4) "25"
    5) "address"
    6) "guangzhou"
    7) "balance"
    8) "6888"
    127.0.0.1:6379>
    

    list列表

    列表的典型应用场景:

    • 文章列表
    • 微博和微信公众号消息
    Stack(栈FILO) = LPUSH + LPOP 
    Queue(队列FIFO)= LPUSH + RPOP 
    Blocking MQ(阻塞队列)= LPUSH + BRPOP
    
    LPUSH  key  value [value ...]       // 将一个或多个值value插入到key列表的表头(最左边)
    RPUSH  key  value [value ...]       // 将一个或多个值value插入到key列表的表尾(最右边)
    LPOP  key                   // 移除并返回key列表的头元素
    RPOP  key                   // 移除并返回key列表的尾元素
    LRANGE  key  start  stop        // 返回列表key中指定区间内的元素,区间以偏移量start和stop指定
    LINSERT key  BEFORE|AFTER pivot element // 在元素element前后插入pivot
    LREM key count element                  //根据参数 COUNT 的值,移除列表中与参数 VALUE 相等的元素 count > 0 : 从表头开始向表尾搜索,移除与 VALUE 相等的元素,数量为 COUNT 
    BLPOP  key  [key ...]  timeout          //从key列表表头弹出一个元素,若列表中没有元素,阻塞等待 timeout秒,如果timeout=0,一直阻塞等待
    BRPOP  key  [key ...]  timeout          //从key列表表尾弹出一个元素,若列表中没有元素,阻塞等待 timeout秒,如果timeout=0,一直阻塞等待
    

    set集合

    🔥列表的典型应用场景:

    • 抽奖
    • 微博点赞,收藏,标签
    • 共同好友

    抽奖场景:

    1. 用户参与抽奖
    # 将用户10001加入商品a的参与池子中
    SADD luckdraw:product:a 10001
    
    1. 查看参与商品a抽奖的所有用户
    SMEMBERS luckdraw:product:a
    
    1. 抽取1名幸运中奖者
    SPOP luckdraw:product:a 1
    

    点赞场景:

    // 博客点赞功能需求:
    
    1. 同一用户一篇博客只能点赞一次,再次点赞为取消点赞
    2. 如果当前用户已经点赞,则点赞按钮高亮显示(前端实现,根据返回的isLike字段属性做判断)
    

    具体的功能实现步骤:

    1. 给Post帖子信息表新增一个isLike字段,标识是否有被当前用户点赞
    2. 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过的点赞数+1,已点赞过大点赞数-1
    3. 修改根据帖子ID查询帖子信息的业务,判断当前登录用户是否已经点赞过,赋值isLike字段返回给前端
    4. 修改分页查询帖子的业务,判断当前用户是否点赞过,赋值isLike字段返回给前端

    伪代码实现:

    @Override
    public Result likePost(Long id, Long currentUserId) {
        // 1.判断当前用户是否已经点赞
        String key = "post:liked:" + id;
    
        Post post = this.getById(id);
        if (post == null) {
            return Result.fail("post not found!");
        }
    
        Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, currentUserId);
        if (BooleanUtil.isFalse(isMember)) {
            // 如果未点赞,数据库帖子点赞数加1
            post.setLikeCount(post.getLikeCount() + 1);
            boolean success = this.update(post);
            if (success) {
                // 保存用户点赞记录到Redis的set集合中
                stringRedisTemplate.opsForSet().add(key, currentUserId.toString());
            }
        } else {
            // 如果已经点赞,取消点赞,数据库帖子点赞数-1
            post.setLikeCount(post.getLikeCount() - 1);
            boolean success = this.update(post);
            if (success) {
                // 移除set集合中的用户点赞记录
                stringRedisTemplate.opsForSet().remove(key, currentUserId.toString());
            }
        }
        return Result.succ();
    }
    

    在返回帖子详情和列表业务中,需要判断当前用户是否点赞过:

    /**
     * 在返回帖子详情和列表业务中,需要判断当前用户是否点赞过
     */
    private PostVO isPostLiked(PostVO postVO, Long currentUserId) {
        String key = "post:liked:" + postVO.getId();
        Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, currentUserId);
        postVO.setIsLiked(isMember);
        return postVO;
    }
    

    共同好友场景:

    用户1的好友为:3,4,8
    用户2的好友为:4,5,11

    取交集,获取用户1和用户2的共同好友,为用户4。

    127.0.0.1:6379> sadd user_1 2 3 4
    (integer) 3
    127.0.0.1:6379> sadd user_2 4 5 7
    (integer) 3
    127.0.0.1:6379> sinter user_1 user_2
    1) "4"
    127.0.0.1:6379>
    

    sorted set有序集合

    🔥列表的典型应用场景:

    • 微博热搜榜
    • 刷礼物实时排行榜
    • 博客社区本周热议

    Redis有序集合和集合一样也是string类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个 double类型的分数,Redis正是通过分数来为集合中的成员进行从小到大的排序。有序集合的成员是唯一的,但分数score却可以重复。下面使用redis-cli实践Redis有序集合命令:

    zset几个基本命令:

    命令 说明
    zrange key start stop [WITHSCORES] 将集合元素依照顺序值升序排序再输出,startstop限制遍历的限制范围
    zincrby key increment member 有序集key的成员memberscore值加上增量increment
    ZUNIONSTORE destination numkeys key [key ...] [WEIGHTS weight [weight ...]] 计算给定的一个或多个有序集的并集,其中给定key的数量必须以numkeys参数指定,并将该并集 (结果集) 储存到destination
    127.0.0.1:6379[3]> zadd zsetofpost 89 post:1
    (integer) 1
    127.0.0.1:6379[3]> zadd zsetofpost 123 post:2
    (integer) 1
    127.0.0.1:6379[3]> zadd zsetofpost 32 post:3
    (integer) 1
    127.0.0.1:6379[3]> zadd zsetofpost 432 post:4
    (integer) 1
    127.0.0.1:6379[3]> zadd zsetofpost 128 post:5
    (integer) 1
    
    #升序排序
    127.0.0.1:6379[3]> zrange zsetofpost 0 -1 withscores
     1) "post:3"
     2) "32"
     3) "post:1"
     4) "89"
     5) "post:2"
     6) "123"
     7) "post:5"
     8) "128"
     9) "post:4"
    10) "432"
    
    #降序排序
    127.0.0.1:6379[3]> zrevrange zsetofpost 0 -1 withscores
     1) "post:4"
     2) "432"
     3) "post:5"
     4) "128"
     5) "post:2"
     6) "123"
     7) "post:1"
     8) "89"
     9) "post:3"
    10) "32"
    
    #有序集合某个元素的score值加上对应的增量
    127.0.0.1:6379[3]> zincrby zsetofpost 40 post:1
    "129"
    127.0.0.1:6379[3]> zincrby zsetofpost 500 post:3
    "532"
    127.0.0.1:6379[3]> zrange zsetofpost 0 -1 withscores
     1) "post:2"
     2) "123"
     3) "post:5"
     4) "128"
     5) "post:1"
     6) "129"
     7) "post:4"
     8) "432"
     9) "post:3"
    10) "532"
    

    简单认识了Redis有序集合和对应的命令之后,我们来实现本周热议排行榜功能,博客的本周热议主要的实现思路是:

    1. 库获取最近 7 天的所有文章(或者加多一个条件:评论数量大于 0)。
    2. 把文章的评论数量作为有序集合的分数score,文章的ID作为key存储到zset中,当有人发表评论的时候,直接使用命令加一,并重新计算得到排行榜。
    3. 本周热议上有标题和评论数量,因此,我们还需要把文章的基本信息存储到Redis中,这样得到文章的ID之后,我们再从缓存中得到标题等信息,这里我们可以使用hash的结构来存储文章的信息。
    4. 因为是本周热议,如果文章发表超过 7 天了之后就会失效,所以我们可以给文章的有序集合一个有效时间。超过 7 天之后就自动删除缓存。

    画图分析:

    最终实现效果:

    Bitmaps位图

    位图的典型应用场景:

    • 用户连续签到功能

    很多社区、博客平台其实都有每日签到模块,一开始看到这个模块需求的时候,很多人第一反应是利用MySQL来实现,创建一个签到表,记录用户ID和签到时间,然后统计的时候从数据库中取出来然后聚合计算,这样设计其实存在弊端,如我们想要做一些复杂的功能就不是太方便了,或者说不是太高性能了,比如,今天是连续签到的第几天,在一定时间内连续签到了多少天。另外一方面,如果按 100 万用户量级来计算,一个用户每年可以产生 365 条记录,100 万用户的所有签到记录那就有点恐怖了,查询计算速度也会越来越慢。其实RedisBitmaps位图操作非常适合处理每日签到功能场景,因为Bit的值为0或者1,位图的每一位代表一天的签到,1表示已签,0表示未签。 考虑到每月初需要重置连续签到次数,最简单的方式是按用户每月存一条签到数据(也可以每年存一条数据)。Key的格式为u:sign:uid:yyyyMM,Value则采用长度为4个字节(32位)的位图(最大月份只有31天)。

    Redis位图命令基本命令

    命令 说明
    SETBIT key offset value 对key所储存的字符串值,设置或清除指定偏移量上的位(bit)
    BITPOS key bit [start] [end] 查询指定字节区间第一个被设置成1的bit位的位置
    GETBIT key offset 查询指定偏移位置的bit值
    BITCOUNT key [start end] 统计指定字节区间bit为1的数量
    GETBIT key offset 查询指定偏移位置的bit值
    BITFIELD key offset 查询指定偏移位置的bit值

    这里的offset,大家姑且当做用户ID来看就可以了,那么究竟如何去实现用户打卡功能呢,我们可以利用上面的setbit命令来实现,setbit的作用说的直白就是:在你想要的位置操作字节值,比如说u:sign:1000:202302表示ID=1000的用户在2023年2月7号签到记录。

    # 用户1000在2023年2月7号签到
    SETBIT u:sign:1000:202302 6 1 # 偏移量是从0开始,所以要把7减1
    
    # 检查用户1000在2023年2月7号是否签到
    GETBIT u:sign:1000:202302 6   # 偏移量是从0开始,所以要把7减1
    
    # 统计用户1000在2月份签到次数
    BITCOUNT u:sign:1000:202302
    
    # 获取2月份前28天的签到数据
    BITFIELD u:sign:1000:202302 get u28 0
    
    # 获取2月份首次签到日期
    BITPOS u:sign:1000:202302 1  # 返回的首次签到的偏移量,加上1即为当月的某一天
    

    示例代码:

    /**
     * 基于Redis位图的用户签到功能工具实现类
     *
     * @author: austin
     * @since: 2023/2/7 1:50
     */
    public class UserSignKit {
    
        private Jedis jedis = new Jedis();
    
        /**
         * 用户签到
         *
         * @param uid  用户ID
         * @param date 日期
         * @return 之前的签到状态
         */
        public boolean doSign(int uid, LocalDate date) {
            int offset = date.getDayOfMonth() - 1;
            return jedis.setbit(buildSignKey(uid, date), offset, true);
        }
    
        /**
         * 检查用户是否签到
         *
         * @param uid  用户ID
         * @param date 日期
         * @return 当前的签到状态
         */
        public boolean checkSign(int uid, LocalDate date) {
            int offset = date.getDayOfMonth() - 1;
            return jedis.getbit(buildSignKey(uid, date), offset);
        }
    
        /**
         * 获取用户签到次数
         *
         * @param uid  用户ID
         * @param date 日期
         * @return 当前的签到次数
         */
        public long getSignCount(int uid, LocalDate date) {
            return jedis.bitcount(buildSignKey(uid, date));
        }
    
        /**
         * 获取当月连续签到次数
         *
         * @param uid  用户ID
         * @param date 日期
         * @return 当月连续签到次数
         */
        public long getContinuousSignCount(int uid, LocalDate date) {
            int signCount = 0;
            String type = String.format("u%d", date.getDayOfMonth());
            List<Long> list = jedis.bitfield(buildSignKey(uid, date), "GET", type, "0");
            if (list != null && list.size() > 0) {
                // 取低位连续不为0的个数即为连续签到次数,需考虑当天尚未签到的情况
                long v = list.get(0) == null ? 0 : list.get(0);
                for (int i = 0; i < date.getDayOfMonth(); i++) {
                    if (v >> 1 << 1 == v) {
                        // 低位为0且非当天说明连续签到中断了
                        if (i > 0) {
                            break;
                        }
                    } else {
                        signCount += 1;
                    }
                    v >>= 1;
                }
            }
            return signCount;
        }
    
        /**
         * 获取当月首次签到日期
         *
         * @param uid  用户ID
         * @param date 日期
         * @return 首次签到日期
         */
        public LocalDate getFirstSignDate(int uid, LocalDate date) {
            long pos = jedis.bitpos(buildSignKey(uid, date), true);
            return pos < 0 ? null : date.withDayOfMonth((int) (pos + 1));
        }
    
        /**
         * 获取当月签到情况
         *
         * @param uid  用户ID
         * @param date 日期
         * @return Key为签到日期,Value为签到状态的Map
         */
        public Map<String, Boolean> getSignInfo(int uid, LocalDate date) {
            Map<String, Boolean> signMap = new HashMap<>(date.getDayOfMonth());
            String type = String.format("u%d", date.lengthOfMonth());
            List<Long> list = jedis.bitfield(buildSignKey(uid, date), "GET", type, "0");
            if (list != null && list.size() > 0) {
                // 由低位到高位,为0表示未签,为1表示已签
                long v = list.get(0) == null ? 0 : list.get(0);
                for (int i = date.lengthOfMonth(); i > 0; i--) {
                    LocalDate d = date.withDayOfMonth(i);
                    signMap.put(formatDate(d, "yyyy-MM-dd"), v >> 1 << 1 != v);
                    v >>= 1;
                }
            }
            return signMap;
        }
    
        private static String formatDate(LocalDate date) {
            return formatDate(date, "yyyyMM");
        }
    
        private static String formatDate(LocalDate date, String pattern) {
            return date.format(DateTimeFormatter.ofPattern(pattern));
        }
    
        private static String buildSignKey(int uid, LocalDate date) {
            return String.format("u:sign:%d:%s", uid, formatDate(date));
        }
    
        public static void main(String[] args) {
            UserSignKit kit = new UserSignKit();
            LocalDate today = LocalDate.now();
    
            {   // doSign
                boolean signed = kit.doSign(1000, today);
                if (signed) {
                    System.out.println("您已签到:" + formatDate(today, "yyyy-MM-dd"));
                } else {
                    System.out.println("签到完成:" + formatDate(today, "yyyy-MM-dd"));
                }
            }
    
            {   // checkSign
                boolean signed = kit.checkSign(1000, today);
                if (signed) {
                    System.out.println("您已签到:" + formatDate(today, "yyyy-MM-dd"));
                } else {
                    System.out.println("尚未签到:" + formatDate(today, "yyyy-MM-dd"));
                }
            }
    
            {   // getSignCount
                long count = kit.getSignCount(1000, today);
                System.out.println("本月签到次数:" + count);
            }
    
            {   // getContinuousSignCount
                long count = kit.getContinuousSignCount(1000, today);
                System.out.println("连续签到次数:" + count);
            }
    
            {   // getFirstSignDate
                LocalDate date = kit.getFirstSignDate(1000, today);
                System.out.println("本月首次签到:" + formatDate(date, "yyyy-MM-dd"));
            }
    
            {   // getSignInfo
                System.out.println("当月签到情况:");
                Map<String, Boolean> signInfo = new TreeMap<>(kit.getSignInfo(1000, today));
                for (Map.Entry<String, Boolean> entry : signInfo.entrySet()) {
                    System.out.println(entry.getKey() + ": " + (entry.getValue() ? "√" : "-"));
                }
            }
        }
    }
    

    运行结果:

    您已签到:2023-02-07
    您已签到:2023-02-07
    本月签到次数:5
    连续签到次数:3
    本月首次签到:2023-02-02
    当月签到情况:
    2023-02-01: -
    2023-02-02: √
    2023-02-03: √
    2023-02-04: √
    2023-02-05: -
    2023-02-06: √
    2023-02-07: √
    2023-02-08: -
    2023-02-09: -
    2023-02-10: -
    2023-02-11: -
    2023-02-12: -
    2023-02-13: -
    2023-02-14: -
    2023-02-15: -
    2023-02-16: -
    2023-02-17: -
    2023-02-18: -
    2023-02-19: -
    2023-02-20: -
    2023-02-21: -
    2023-02-22: -
    2023-02-23: -
    2023-02-24: -
    2023-02-25: -
    2023-02-26: -
    2023-02-27: -
    2023-02-28: -
    

    Redis发布订阅

    Redis提供了发布订阅功能,可以用于消息的传输,Redis的发布订阅机制包括三个部分:发布者订阅者Channel。发布者和订阅者都是Redis客户端,Channel则为Redis服务器端,发布者将消息发送到某个的频道,订阅了这个频道的订阅者就能接收到这条消息。Redis的这种发布订阅机制与基于主题的发布订阅类似,Channel相当于主题。

    总结

    本文详细介绍了Redis的五种数据结构和应用场景,希望可以帮助大家解决实际工作中遇到的问题

    作者:austin流川枫
    链接:https://juejin.cn/post/7197062234165461029
    来源:稀土掘金

    相关文章

      网友评论

        本文标题:一口气讲完了Redis常用的数据结构及应用场景

        本文链接:https://www.haomeiwen.com/subject/aqptkdtx.html