【解决方案】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 来演示一下如何获取用户登录信息。
@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 缓存应用场景的更多相关文章
- 项目中遇到的Redis缓存问题
1.Redis服务器 can not get resource from pool. 1000个线程并发还能跑,5000个线程的时候出现这种问题,查后台debug日志,发现redis 线程池不够.刚开 ...
- 【原创】互联网项目中mysql应该选什么事务隔离级别
摘要 企业千万家,靠谱没几家. 社招选错家,亲人两行泪. 祝大家金三银四跳槽顺利! 引言 开始我们的内容,相信大家一定遇到过下面的一个面试场景 面试官:"讲讲mysql有几个事务隔离级别?& ...
- 互联网项目中mysql推荐(读已提交RC)的事务隔离级别
[原创]互联网项目中mysql应该选什么事务隔离级别 Mysql为什么不和Oracle一样使用RC,而用RR 使用RC的原因 这个是有历史原因的,当然要从我们的主从复制开始讲起了!主从复制,是基于什么 ...
- 互联网项目中mysql应该选什么事务隔离级别
引言 开始我们的内容,相信大家一定遇到过下面的一个面试场景 面试官:“讲讲mysql有几个事务隔离级别?” 你:“读未提交,读已提交,可重复读,串行化四个!默认是可重复读” 面试官:“为什么mysql ...
- 【转】互联网项目中mysql应该选什么事务隔离级别
作者:孤独烟 转自:https://www.cnblogs.com/rjzheng/p/10510174.html 摘要 企业千万家,靠谱没几家.社招选错家,亲人两行泪. 祝大家金三银四跳槽顺利! 引 ...
- JAVA WEB项目中各种路径的获取
JAVA WEB项目中各种路径的获取 标签: java webpath文件路径 2014-02-14 15:04 1746人阅读 评论(0) 收藏 举报 分类: JAVA开发(41) 1.可以在s ...
- iOS项目中常见的文件
html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,bi ...
- linux 下用renameTo方法修改java web项目中文件夹名称问题
经测试,在Linux环境中安装tomcat,然后启动其中的项目,在项目中使用java.io.File.renameTo(File dest)方法可行. 之前在本地运行代码可以修改,然后传到Linux服 ...
- 对Java Web项目中路径的理解
第一个:文件分隔符 坑比Window.window分隔符 用\;unix采用/.于是用File.separator来跨平台 请注意:这是文件路径.在File f = new File(“c:\\hah ...
- Java Web项目中缺少Java EE 6 Libraries怎么添加
Java Web项目中缺少Java EE 6 Libraries怎么添加 具体步骤如下: 1.项目名称上点击鼠标右键,选择"Build Path-->Configure Build P ...
随机推荐
- SQL Server 图解备份(完全备份、差异备份、增量备份)和还原
常用的数据备份方式有完全备份.差异备份以及增量备份,那么这三种备份方式有什么区别,在具体应用中又该如何选择呢? 1.三种备份方式 完全备份(Full Backup):备份全部选中的文件夹,并不依赖文件 ...
- C# 常用类和命名空间
Array类 用括号声明数组是C#中使用Array类的记号.在后台使用C#语法,会创建一个派生于抽象基类Array的新类.这样,就可以使用Array类为每个C#数组定义的方法和属性了. Array类实 ...
- java spring boot 权限认证总结瞎记一通,各种 方案。附近如何运行jar包。和如何读配文件
在正式笔之 前先来思考如何将java 的包打包成jar 包同,运行时指定配置,这样运行, 以上问题有空在来研究,有空在来补这个文档 首先呢,先来说说Session 怎么使用,这个在sping boot ...
- java程序设计期末复习总结&复盘
java复习 java的特点:简单.面向对象.可移植.跨平台.分布式.多线程.稳定安全.高性能 一个数组可以存放许多不同类型的数值. (F) StringBuffer类是线程安全的,StringBui ...
- 近似最优的分层路径搜索(Near Optimal Hierarchical Path-Finding)—— A*算法的变种 —— 分层A*算法(HPA*)
论文地址: https://webdocs.cs.ualberta.ca/~mmueller/ps/hpastar.pdf Near Optimal Hierarchical Path-Finding
- 对于强化学习算法中的AC算法(Actor-Critic算法) 的一些理解
AC算法(Actor-Critic算法)最早是由<Neuronlike Adaptive Elements That Can Solve Difficult Learning Control P ...
- 视频推荐: Linux 的make自动化编译和通用makefile
1.Linux 的make自动化编译原理 2.makefile编写规则 3.通用makefile的编写 ================================================ ...
- 使用django-treebeard实现树类型存储与编辑
前言 其实之前做很多项目都有遇到跟树相关的功能,以前都是自己实现的,然后前端很多UI组件库都有Tree组件,套上去就可以用. 不过既然用 Django 了,还是得充分发挥一下生态的优势,但是我找了半天 ...
- .NET 免费开源工业物联网网关
前言 IoTClient 是一个针对物联网 (IoT) 领域的开源客户端库,它主要用于实现与各种工业设备之间的通信.这个库是用 C# 编写的,并且基于 .NET Standard 2.0,这意味着可以 ...
- Java基础之时间类