前言

在笔者 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 来演示一下如何获取用户登录信息。

@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));
} }
    @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);
}
/**
* 仅部分核心属性
*/
@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 虽然简单但是可以运行,不是伪代码:

    /**
* 活动预览链接 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("生成预览链接失败!");
}
    /**
* 剪切板口令码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 之类的没有写明,大家能意会就好,重要的是思路:

    /**
* 用户未读通知数量计数
*/
@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);
}
}
    /**
* 用户加入购物车的商品计数
*/
@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 可以做到即进即出,而且方便入数据库;
  • 文章系统在编写文章时会选用一些媒体资源如图片、视频等,那么对于媒资系统而言,将这些数据立即同步到媒资系统就十分有必要了;
    /**
* 性能要求高,评论即进即出,而且可以入库
*/
@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:接下来可以入数据库
}
    /**
* 性能要求高,立即同步文章选用的媒体信息到媒资系统
*/
@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 来简单实现。

    /**
* 计算用户成绩排名,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 缓存应用场景其实还有很多,以上只是我做的一些总结。

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

【解决方案】Java 互联网项目中常见的 Redis 缓存应用场景的更多相关文章

  1. 项目中遇到的Redis缓存问题

    1.Redis服务器 can not get resource from pool. 1000个线程并发还能跑,5000个线程的时候出现这种问题,查后台debug日志,发现redis 线程池不够.刚开 ...

  2. 【原创】互联网项目中mysql应该选什么事务隔离级别

    摘要 企业千万家,靠谱没几家. 社招选错家,亲人两行泪. 祝大家金三银四跳槽顺利! 引言 开始我们的内容,相信大家一定遇到过下面的一个面试场景 面试官:"讲讲mysql有几个事务隔离级别?& ...

  3. 互联网项目中mysql推荐(读已提交RC)的事务隔离级别

    [原创]互联网项目中mysql应该选什么事务隔离级别 Mysql为什么不和Oracle一样使用RC,而用RR 使用RC的原因 这个是有历史原因的,当然要从我们的主从复制开始讲起了!主从复制,是基于什么 ...

  4. 互联网项目中mysql应该选什么事务隔离级别

    引言 开始我们的内容,相信大家一定遇到过下面的一个面试场景 面试官:“讲讲mysql有几个事务隔离级别?” 你:“读未提交,读已提交,可重复读,串行化四个!默认是可重复读” 面试官:“为什么mysql ...

  5. 【转】互联网项目中mysql应该选什么事务隔离级别

    作者:孤独烟 转自:https://www.cnblogs.com/rjzheng/p/10510174.html 摘要 企业千万家,靠谱没几家.社招选错家,亲人两行泪. 祝大家金三银四跳槽顺利! 引 ...

  6. JAVA WEB项目中各种路径的获取

    JAVA WEB项目中各种路径的获取 标签: java webpath文件路径 2014-02-14 15:04 1746人阅读 评论(0) 收藏 举报  分类: JAVA开发(41)  1.可以在s ...

  7. iOS项目中常见的文件

    html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,bi ...

  8. linux 下用renameTo方法修改java web项目中文件夹名称问题

    经测试,在Linux环境中安装tomcat,然后启动其中的项目,在项目中使用java.io.File.renameTo(File dest)方法可行. 之前在本地运行代码可以修改,然后传到Linux服 ...

  9. 对Java Web项目中路径的理解

    第一个:文件分隔符 坑比Window.window分隔符 用\;unix采用/.于是用File.separator来跨平台 请注意:这是文件路径.在File f = new File(“c:\\hah ...

  10. Java Web项目中缺少Java EE 6 Libraries怎么添加

    Java Web项目中缺少Java EE 6 Libraries怎么添加 具体步骤如下: 1.项目名称上点击鼠标右键,选择"Build Path-->Configure Build P ...

随机推荐

  1. SQL Server 图解备份(完全备份、差异备份、增量备份)和还原

    常用的数据备份方式有完全备份.差异备份以及增量备份,那么这三种备份方式有什么区别,在具体应用中又该如何选择呢? 1.三种备份方式 完全备份(Full Backup):备份全部选中的文件夹,并不依赖文件 ...

  2. C# 常用类和命名空间

    Array类 用括号声明数组是C#中使用Array类的记号.在后台使用C#语法,会创建一个派生于抽象基类Array的新类.这样,就可以使用Array类为每个C#数组定义的方法和属性了. Array类实 ...

  3. java spring boot 权限认证总结瞎记一通,各种 方案。附近如何运行jar包。和如何读配文件

    在正式笔之 前先来思考如何将java 的包打包成jar 包同,运行时指定配置,这样运行, 以上问题有空在来研究,有空在来补这个文档 首先呢,先来说说Session 怎么使用,这个在sping boot ...

  4. java程序设计期末复习总结&复盘

    java复习 java的特点:简单.面向对象.可移植.跨平台.分布式.多线程.稳定安全.高性能 一个数组可以存放许多不同类型的数值. (F) StringBuffer类是线程安全的,StringBui ...

  5. 近似最优的分层路径搜索(Near Optimal Hierarchical Path-Finding)—— A*算法的变种 —— 分层A*算法(HPA*)

    论文地址: https://webdocs.cs.ualberta.ca/~mmueller/ps/hpastar.pdf Near Optimal Hierarchical Path-Finding

  6. 对于强化学习算法中的AC算法(Actor-Critic算法) 的一些理解

    AC算法(Actor-Critic算法)最早是由<Neuronlike Adaptive Elements That Can Solve Difficult Learning Control P ...

  7. 视频推荐: Linux 的make自动化编译和通用makefile

    1.Linux 的make自动化编译原理 2.makefile编写规则 3.通用makefile的编写 ================================================ ...

  8. 使用django-treebeard实现树类型存储与编辑

    前言 其实之前做很多项目都有遇到跟树相关的功能,以前都是自己实现的,然后前端很多UI组件库都有Tree组件,套上去就可以用. 不过既然用 Django 了,还是得充分发挥一下生态的优势,但是我找了半天 ...

  9. .NET 免费开源工业物联网网关

    前言 IoTClient 是一个针对物联网 (IoT) 领域的开源客户端库,它主要用于实现与各种工业设备之间的通信.这个库是用 C# 编写的,并且基于 .NET Standard 2.0,这意味着可以 ...

  10. Java基础之时间类