【解决方案】Java 互联网项目中常见的 Redis 缓存应用场景

目录

前言

在笔者 3 年的 Java 一线开发经历中,尤其是一些移动端、用户量大的互联网项目,经常会使用到 Redis 作为缓存中间件的基本工具来解决一些特定的问题。

下面是笔者总结梳理的一些常见的 Redis 缓存应用场景,例如常见的 String 类型 Key-Value、对时效性要求高的场景、Hash 结构的场景以及对实时性要求高的场景等,基本涵盖了 Redis 中所有的 5 种基本类型。

如果你也在项目中经常使用 Redis 来作为缓存的中间件,那么你一定不会对下面的内容感到陌生。如果你还是刚入行不久,暂时还没接触到 Redis 这样的缓存中间件,那么也没关系,本篇文章对你也会有一定的帮助。

关于缓存的一些基本概念,大家可以看这里再回顾一下:https://www.cnblogs.com/CodeBlogMan/p/18022719

一、常见 key-value

首先介绍的是项目开发中常见的一些String 类型的 key-value 结构场景,如:

  • 使用 jsonStr 结构存储的用户登录信息,包括:手机号、token、唯一 uuid、昵称等;
  • jsonStr 结构某个热门商品的信息,包括:商品名称、商品唯一id、所属商家、价格等;
  • String 类型的、带过期时间的分布式锁,包括:锁的超时时间、随机生成的 value、判断加锁成功、释放锁等。

下面用简单的 demo 来演示一下如何获取用户登录信息。

java 复制代码
@RestController
@RequestMapping("/member")
public class MemberController {

    @Resource
    private MemberService memberService;

    /**
     * 通过 userUuid 获取会员信息
     * @param userUuid
     * @return 会员信息
     */
    @GetMapping("/info")
    public Response<MemberVO> getMemberInfo(@RequestParam(value = "userUuid") String userUuid) {
        return ResponseBuilder.buildSuccess(this.memberService.info(userUuid));
    }

}
java 复制代码
    @Resource
    private RedisTemplate<String, String> redisTemplate;    

    private final String MEMBER_INFO_USER_UUID_KEY = "initial.member.user.uuid.key";
    
    @Override
    public MemberVO info(String userUuid) {
        //先查缓存
        String memberStr = redisTemplate.opsForValue().get(RedisKey.MEMBER_INFO_USER_UUID_KEY.concat(userUuid));
        if (StringUtils.isNotBlank(memberStr)){
            return JSON.parseObject(memberStr, MemberVO.class);
        }
        //缓存没有再查数据库
        LambdaQueryWrapper<Member> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(Member::getMemberUuid, userUuid)
                .eq(Member::getEnableStatus, NumberUtils.INTEGER_ZERO)
                .eq(Member::getDataStatus, NumberUtils.INTEGER_ZERO);
        return this.getOne(wrapper).convertExt(MemberVO.class);
    }
java 复制代码
/**
 * 仅部分核心属性
 */
@EqualsAndHashCode(callSuper = true)
@Data
public class MemberVO extends BaseVO {
    /**
     * 用户唯一uuid
     */
    private String memberUuid;
    /**
     * 登录 token
     */
    private String token;
    /**
     * 用户昵称
     */
    private String nickName;
    /**
     * 电话号码
     */
    private String mobile;
    /**
     * 头像地址
     */
    private String avatarImg;
    /**
     * 性别:1-male,2-female,3-unknown
     */
    private Integer gender;
}

二、时效性强

在开发的时候我们经常会碰到时效性强的一些场景,从业务上对过期时间的要求比较高,比如:

  • 如某个项目或者活动的预览链接,设定该链接在 30 分钟后失效,即过半小时后不允许再访问该预览链接;
  • 从 web 端跳转到 app 客户端访问一些特定的内容,使用剪切板复制的分享口令打开客户端,设定在 60 分钟后过期;
  • 用户在客户端或者小程序领取的优惠券,领取后放入"我的卡券"中,不同类型的卡券设定不同的过期时间,如某积分券在 30 天后过期等。

下面举两个例子,demo 虽然简单但是可以运行,不是伪代码:

java 复制代码
    /**
     * 活动预览链接 30 分钟后过期
     */
    @Test
    public void testPreviewLinkExpire(){
        String baseUrl = "http://localhost:8089/initial/ealbum/preview/detail";
        Long projectId = UUIDUtils.generateUUIDToLong();
        String projectUuid = UUIDUtils.generateUUID();
        //这里是链接的签名,如果签名过期,那么意味着整个链接过期
        String tempLinkSign = DigestUtils.md5Hex(projectUuid);
        String signKey = RedisKey.INITIAL_EALBUM_TEMP_LINK_SIGN_KEY.concat(".").concat(projectId.toString());
        stringRedisTemplate.opsForValue().set(signKey, tempLinkSign, Duration.ofMinutes(30));
        String sign = stringRedisTemplate.opsForValue().get(signKey);
        if (StringUtils.isNotBlank(sign)){
            //拼接临时地址
            StringBuilder tempLinkUrl = new StringBuilder(baseUrl)
                    .append("?projectId=").append(projectId)
                    .append("&sign=").append(sign);
            log.info("打印看下预览地址:{}", tempLinkUrl);
        }
        throw new BusinessException("生成预览链接失败!");
    }
java 复制代码
    /**
     * 剪切板口令码1小时过期
     */
    @Test
    public void testClipboardTextKey(){
        //这里先随机生产一个 uuid 作为例子
        String articleId = UUIDUtils.generateUUID();
        String redisKey = RedisKey.CLIP_BOARD_TEXT_KEY.concat(".").concat(articleId);
        String value = JSON.toJSONString(ClipboardVO.builder()
                .articleId(articleId)
                .articleTitle("测试标题")
                .copyright(NumberUtils.INTEGER_ZERO).build());
        //很简单的一个结构,就是常见的 String 类型的 key-value,但是对时效性有要求,一个小时后直接失效
        stringRedisTemplate.opsForValue().set(redisKey, value, Duration.ofHours(1));
        //这里去拿 value,判断是否过期
        ClipboardVO vo = Optional.ofNullable(stringRedisTemplate.opsForValue().get(redisKey))
                .filter(StringUtils::isNotBlank)
                .map(val -> JSON.parseObject(val, ClipboardVO.class))
                .orElse(null);
        log.info("打印一下返回的vo:{}", vo);
    }

三、计数器相关

关于计数器也是 Redis 的一个常见应用场景了,比如以下几点:

  • 点赞数/收藏数:文章的点赞数,文章的收藏数,可以同步到文章表作为一个属性;

  • 文章评论数:采用 String 结构,redis-key 可以是文章 id 标识,redis-value 则是评论数,也可以同步到文章表作为一个属性;

  • 未读消息数:采用 Hash 结构,常量 redis-key,用户标识为 hash-key,数量为 hash-value,需要同步到通知表;

  • 加入购物车的商品数:采用 BoundHash 结构,redis-key 为用户标识,hash-key 为商品 id 标识,hash-value 为数量,需要同步到购物车的表。

下面举两个例子吧,部分实体、DTO/VO 之类的没有写明,大家能意会就好,重要的是思路:

java 复制代码
    /**
     * 用户未读通知数量计数
     */
    @Test 
    public void testNoticeCountNum() {
        //关于热 Key 这里场景有点不够:因为通知数量只有启动app和点击按钮的时候才会调用,并不是那么地频繁,QPS 几万应该没问题
        //但有大 key 的可能性,那么:1、定期清理缓存,配合数据库解决;2、Hash 底层会压缩数据;3、看占用内存的大小或者元素的数量;4、数据分片
        NoticeAddDTO dto = NoticeAddDTO.builder()
                .targetUserUuid("123qwe456rty789uio")
                .superType(NumberUtils.INTEGER_TWO)
                .subType(5)
                .content("内容内容").build();
        //无论何种业务系统的何种类型消息,先入数据库
        Notification notification = this.notificationService.addNotice(dto);
        //1、按消息的 tab 类型来做,可以为每一种类型都新建一个Redis-Key来单独存,这样是可行的,从某种程度上来说是拆 Key
        //2、思考了一下还是用 Hash 结构,用 List 或者 String 可以解决类型的问题,但是难以按照用户来取值
        HashOperations<String, String, Integer> hashOperations = redisTemplate.opsForHash();
        //由于一个小时会清除全部缓存,当前未读数量需要查数据库来确认
        this.notificationService.checkUnReadCount(notification);
        if (NumberUtils.INTEGER_ONE.equals(notification.getSuperType()) && NumberUtils.INTEGER_ONE.equals(notification.getSubType())) {
            //业务系统产生的消息,未读消息数+1;
            Long increment = hashOperations.increment(RedisKey.INITIAL_NOTICE_COMMENT_NUM_PERFIX, dto.getTargetUserUuid(), 1);
            log.info("新增的评论tab消息数量:{}", increment);
        }
        if (NumberUtils.INTEGER_TWO.equals(dto.getSuperType())) {
            Long increment = hashOperations.increment(RedisKey.INITIAL_NOTICE_NUM_PERFIX, dto.getTargetUserUuid(), 1);
            log.info("新增的通知tab消息数量:{}", increment);
        }
        //业务系统撤回消息,未读数-1
        if (NumberUtils.INTEGER_TWO.equals(dto.getSuperType())) {
            //Redis里不存在去做自减一,得到的就是-1;所以一定有值,不用判空只需判断正负
            Long num = hashOperations.increment(RedisKey.INITIAL_NOTICE_NUM_PERFIX, dto.getTargetUserUuid(), -1);
            log.info("撤回后通知tab消息数量:{}", num);
            int intNUm = num.intValue();
            //为了避免出现负数,要拿0作为界限来比
            int result = intNUm < NumberUtils.INTEGER_ZERO ? NumberUtils.INTEGER_ZERO : intNUm;
            hashOperations.put(RedisKey.INITIAL_NOTICE_NUM_PERFIX, dto.getTargetUserUuid(), result);
        }
    }
java 复制代码
    /**
     * 用户加入购物车的商品计数
     */
    @Resource
    private ShoppingCarService shoppingCarService;
    @Test
    public void testUserShoppingCarInfo(){
        //首选方案:boundHashOps() 在使用上的主要区别就是需要先绑定 Redis-Key,方便后续操作;而 opsForHash() 则是直接操作数据
        String userUuid = "1656698374114156635";
        String gooId = "3523465836543623675";
        Long shopId = 34776547437357643L;
        //1、首先是入参DTO的信息应该至少包含哪些?
        ShoppingCarGoodInfoDTO dto = ShoppingCarGoodInfoDTO.builder()
                .userUuid(userUuid).goodId(gooId)
                    .goodName("商品名称").goodDesc("618专属活动商品")
                .price(BigDecimal.valueOf(98.99D))
                .shopId(shopId).shopName("xx品牌旗舰店")
                .quantities(2).manufactureTime(1720146162L).build();
        ShoppingCar shoppingCar = dto.convertExt(ShoppingCar.class);
        //2、先入数据库
        this.shoppingCarService.save(shoppingCar);
        //3、再入 redis:将用户 uuid 作为 redis-key,goodId 作为 hash-key,商品的具体信息为 hash-value
        BoundHashOperations<String, String, String> operations = redisTemplate.boundHashOps(userUuid);
        //这样就是有多少个用户,就有多少个Redis-Key;虽然一般来说用不完,也不会造成大 Key 问题,但数量多了无疑是对资源的一种巨大消耗,要考虑成本
        String goodInfo = JSON.toJSONString(shoppingCar);
        operations.put(gooId, goodInfo);
        //入 redis 的时候直接设置7天过期时间,这样可以定期删除 Key 保证空间
        operations.expire(Duration.ofDays(7));
        //计数
        Long size = operations.size();
        log.info("打印看下数量:{}", size);
        //todo: 更新购物车时也是先入数据库,再更新 Redis,并设置过期时间;查询时先查 Redis,没有再查数据库,然后重新写入数据库,设置过期时间
    }

四、高实时性

实时性要求高的场景,一般指的是:用户在使用某个功能时,服务能够近乎实时地提供结果。且并发量高时,如果每次都去请求数据库,那么所花费的开销对系统来说无疑是种挑战和压力。如果将数据存储在 Redis 中进行取用,那么其响应速度将会是极快的。

下面举 2 个例子:

  • 用户在 app 客户端发表的评论,需要实时地展示在评论区,这个场景对性能有较高的要求,用 Redis 可以做到即进即出,而且方便入数据库;
  • 文章系统在编写文章时会选用一些媒体资源如图片、视频等,那么对于媒资系统而言,将这些数据立即同步到媒资系统就十分有必要了;
java 复制代码
    /**
     * 性能要求高,评论即进即出,而且可以入库
     */
    @Test
    public void testCommentList(){
        TestCommentAddDTO dto = TestCommentAddDTO.builder()
                .parentId(NumberUtils.INTEGER_ZERO)
                .articleType(NumberUtils.INTEGER_ONE)
                .articleTitle("新闻06").articleId(12)
                .content("评论一下看看").creatorName("用户375368")
                .creatorUuid("abc123def789UUID").createTime(new Date())
                .build();
        //评论队列先入 Redis 缓存,从队列左边进入,值得注意的是,List 结构只是表示队列的形式,具体的数据结构是 String 类型的
        Long num = stringRedisTemplate.opsForList().leftPush(RedisKey.COMMENT_IMPORT_LIST, JSON.toJSONString(dto));
        //这里返回的数量是该队列的大小
        log.info("评论队列先入 Redis 缓存,数量为:{}", num);
        //经验证,这里的方法是根据Redis-Key 弹出(即删除)全部的 Value,并且设置超时时间为 5 秒;leftPull 配合 rightPop 就是先进先出
        String str = stringRedisTemplate.opsForList().rightPop(RedisKey.COMMENT_IMPORT_LIST,5, TimeUnit.SECONDS);
        log.info("从右边弹出全部 Redis 队列缓存的内容,内容为:{}", str);
        //反序列化解析
        TestCommentAddDTO result = Optional.ofNullable(str)
                .filter(StringUtils::isNotBlank)
                .map(val -> JSON.parseObject(val, TestCommentAddDTO.class))
                .orElse(null);
        log.info("打印一下:{}", result);
        //todo:接下来可以入数据库
    }
java 复制代码
    /**
     * 性能要求高,立即同步文章选用的媒体信息到媒资系统
     */
    @Test
    public void testMediaSet(){
        ArrayList<String> imageList = new ArrayList<>();
        imageList.add("20240702165612_image_xxx_filename_Media.png");
        imageList.add("20240702164556_image_xxx_filename_Media.png");
        ArrayList<String> videoList = new ArrayList<>();
        videoList.add("20240702152319_video_xxx_filename_Media.mp4");
        ArticleMediaDTO dto = ArticleMediaDTO.builder()
                .articleId(123L)
                .articleTitle("测文章")
                .articleType(NumberUtils.INTEGER_TWO)
                .imageMediaId(imageList)
                .videoMediaId(videoList).build();
        //这里每次只添加一条,但是需要保证整个队列没有重复的元素,故选择Set
        stringRedisTemplate.opsForSet().add(RedisKey.INITIAL_ARTICLE_MEDIA_KEY_SET, JSON.toJSONString(dto));
        //这里pop()是随机弹出一个元素,由于每次都是及时弹出的,所以队列里有的话只会有一个,否则为空
        String str = stringRedisTemplate.opsForSet().pop(RedisKey.INITIAL_ARTICLE_MEDIA_KEY_SET);
        ArticleMediaDTO result = Optional.ofNullable(str)
                .filter(StringUtils::isNotBlank)
                .map(val -> JSON.parseObject(val, ArticleMediaDTO.class))
                .orElse(null);
        log.info("打印一下弹出的内容:{}", result);
        //todo: 接下来还可以与 MQ 配合进行通知操作
    }

五、排行榜系列

顾名思义,排行的场景很好理解,无论是 web 网页应用还是 app 客户端,都有很多需要排行的场景,如:

  • 用户参与活动的成绩排名
  • 用户参与某个抽奖游戏的积分排名
  • 用户在 app 内的活跃度排名

而 Redis 提供的 ZSet 集合数据类型结构能很好地实现各种复杂的排行榜需求,下面举一个 demo 来简单实现。

java 复制代码
    /**
     * 计算用户成绩排名,ZSet 的 Score 需要根据一个权重来生成,最终 Redis 就会根据 Score 来排序
     */
    @Test 
    public void testUserScoreRanking(){
        //1、首先看分数(平均分、总分、最高分),总之按照配置会得到有一个分数;如果是整数那么就直接看用时,如果是小数会取小数点后两位
        //2、如果分数相同,那么比谁的用时少(平均用时、总用时、最短用时),总之会得到一个用时,时间统一都精确到毫秒
        //3、如果分数和用时都完全一样,那么就看交卷时间,谁的的交卷时间早谁就排前面,这里的时间也精确到毫秒
        ScoreData scoreData = ScoreData.builder()
                .finalScore(82.36D)
                .spendTime(368956L)
                .submitTime(1719915961902L).build();
        Long activityId = UUIDUtils.generateUUIDToLong();
        String userUuid = UUIDUtils.generateUUID();
        //注意:分数取大,用时取小,这样的组合无法组成权重;如果将用时取反,即剩余时间,那么剩余时间取大,两者都成正比就能形成一个权重了
        //具体:1、分数右移两位,组成权重的整数部分;
        BigDecimal scoreBigDecimal = BigDecimal.valueOf(scoreData.getFinalScore()).movePointRight(NumberUtils.INTEGER_TWO);
        //2、用一天的毫秒数 - 用时毫秒树 = 剩余时间,剩余时间小数点左移8位,组成权重的小数部分;
        long time = Constants.MAX_DAY_TIME - scoreData.getSpendTime();
        BigDecimal spendTimeBig = BigDecimal.valueOf(time).movePointLeft(8);
        //3、整数部分+小数部分,即为 ZSet 的权重
        BigDecimal weight = scoreBigDecimal.add(spendTimeBig);
        //Java 的 BigDecimal 类提供了任意精度的计算,能完全满足对当代数学算术运算结果的精度要求,广泛运用于金融、科学计算等领域。
        String rankingKey = RedisKey.INITIAL_ACTIVITY_PLAYER_SCORE_RANKING_KET.concat(".").concat(activityId.toString());
        //最后到这里就可以写进 ZSet 了
        stringRedisTemplate.opsForZSet().add(rankingKey, userUuid, weight.doubleValue());
    }

六、文章小结

到这里本篇文章就结束了,关于在实际 Java 互联网项目开发中常见的 Redis 缓存应用场景其实还有很多,以上只是我做的一些总结。

今天的分享就到这里,如有不足和错误,还请大家指正。或者你有其它想说的,也欢迎大家在评论区交流!

相关推荐
西岭千秋雪_几秒前
Redis性能优化
数据库·redis·笔记·学习·缓存·性能优化
chuanauc4 分钟前
Kubernets K8s 学习
java·学习·kubernetes
一头生产的驴20 分钟前
java整合itext pdf实现自定义PDF文件格式导出
java·spring boot·pdf·itextpdf
YuTaoShao27 分钟前
【LeetCode 热题 100】73. 矩阵置零——(解法二)空间复杂度 O(1)
java·算法·leetcode·矩阵
zzywxc78730 分钟前
AI 正在深度重构软件开发的底层逻辑和全生命周期,从技术演进、流程重构和未来趋势三个维度进行系统性分析
java·大数据·开发语言·人工智能·spring
Uluoyu40 分钟前
redisSearch docker安装
运维·redis·docker·容器
YuTaoShao3 小时前
【LeetCode 热题 100】56. 合并区间——排序+遍历
java·算法·leetcode·职场和发展
程序员张33 小时前
SpringBoot计时一次请求耗时
java·spring boot·后端
llwszx6 小时前
深入理解Java锁原理(一):偏向锁的设计原理与性能优化
java·spring··偏向锁
云泽野6 小时前
【Java|集合类】list遍历的6种方式
java·python·list