后端程序员,不管是出去面试,还是当面试官,Redis几乎是100%会问到的技术点;究其原因,主要是因为他实在过于强大、使用率太高了;导致项目中几乎无处不在。
那Redis部分,不出意外,第一个问题就是:你做的项目,用Redis干了些啥?大部分人的回答都会是:缓存;当问到是否还有其他场景中使用时,部分用得少的朋友就会微微摇头;
其实也没错,redis绝不部分使用场景就是用来做缓存;但是,由于Redis 支持比较丰富的数据结构,因此他能实现的功能并不仅限于缓存,而是可以运用到各种业务场景中,开发出既简洁、又高效的系统;
下面整理了20种 Redis 的妙用场景,每个方案都用一个实际的业务需求并结合数据结构的API来讲解,希望大家能够理解其底层的实现方式,学会举一反三,并运用到项目的方方面面:
本文稍微有点点长,如果时间不够,建议看一下目录,收藏起来,用的时候再翻出来看看。
测试源码:https://github.com/vehang/ehang-spring-boot/tree/main/spring-boot-011-redis
本文假定你已经了解过Redis,并知晓Redis最基础的一些使用,如果你对Redis的基础API还不了解,可以先看一下菜鸟教程:https://www.runoob.com/redis,那么缓存部分及基础API的演示,就不过多来讲解了;
但是,基本的数据结构,在这里再列举一下,方便后续方案的理解:
结构类型结构存储的值结构的读写能力String字符串可以是字符串、整数或浮点数对整个字符串或字符串的一部分进行操作;对整数或浮点数进行自增或自减操作;List列表一个链表,链表上的每个节点都包含一个字符串对链表的两端进行push和pop操作,读取单个或多个元素;根据值查找或删除元素;
Set集合包含字符串的无序集合字符串的集合,包含基础的方法有:看是否存在、添加、获取、删除;还包含计算交集、并集、差集等Hash散列包含键值对的无序散列表包含方法有:添加、获取、删除单个元素Zset有序集合和散列一样,用于存储键值对字符串成员与浮点数分数之间的有序映射;元素的排列顺序由分数的大小决定;包含方法有:添加、获取、删除单个元素以及根据分值范围或成员来获取元素
曾几何时,抽奖是互联网APP热衷的一种推广、拉新的方式,节假日没有好的策划,那就抽个奖吧!一堆用户参与进来,然后随机抽取几个幸运用户给予实物/虚拟的奖品;此时,开发人员就需要写上一个抽奖的算法,来实现幸运用户的抽取;其实我们完全可以利用Redis的集合(Set),就能轻松实现抽奖的功能;
功能实现需要的API
SRANDMEMBER 和 SPOP 主要用于两种不同的抽奖模式,SRANDMEMBER 适用于一个用户可中奖多次的场景(就是中奖之后,不从用户池中移除,继续参与其他奖项的抽取);而 SPOP 就适用于仅能中一次的场景(一旦中奖,就将用户从用户池中移除,后续的抽奖,就不可能再抽到该用户); 通常 SPOP 会用的会比较多。
127.0.0.1:6379> SADD raffle user1(Integer) 1127.0.0.1:6379> SADD raffle user2 user3 user4 user5 user6 user7 user8 user9 user10(integer) 9127.0.0.1:6379> SRANDMEMBER raffle 21) "user5"2) "user2"127.0.0.1:6379> SPOP raffle 21) "user3"2) "user4"127.0.0.1:6379> SPOP raffle 21) "user10"2) "user9"
import lombok.extern.slf4j.Slf4j;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.data.redis.core.RedisTemplate;import Java.util.List;/** * @author 一行Java * @title: RaffleMain * @projectName ehang-spring-boot * @description: TODO * @date 2022/7/18 15:17 */@Slf4j@SpringBootTestpublic class RaffleMain { private final String KEY_RAFFLE_PROFIX = "raffle:"; @Autowired RedisTemplate redisTemplate; @Test void test() { Integer raffleId = 1; join(raffleId, 1000, 1001, 2233, 7890, 44556, 74512); List lucky = lucky(raffleId, 2); log.info("活动:{} 的幸运中奖用户是:{}", raffleId, lucky); } public void join(Integer raffleId, Integer... userIds) { String key = KEY_RAFFLE_PROFIX + raffleId; redisTemplate.opsForSet().add(key, userIds); } public List lucky(Integer raffleId, long num) { String key = KEY_RAFFLE_PROFIX + raffleId; // 随机抽取 抽完之后将用户移除奖池 List list = redisTemplate.opsForSet().pop(key, num); // 随机抽取 抽完之后用户保留在池子里 //List list = redisTemplate.opsForSet().randomMembers(key, num); return list; }}
有互动属性APP一般都会有点赞/收藏/喜欢等功能,来提升用户之间的互动。
传统的实现:用户点赞之后,在数据库中记录一条数据,同时一般都会在主题库中记录一个点赞/收藏汇总数,来方便显示;
Redis方案:基于Redis的集合(Set),记录每个帖子/文章对应的收藏、点赞的用户数据,同时set还提供了检查集合中是否存在指定用户,用户快速判断用户是否已经点赞过
功能实现需要的API
127.0.0.1:6379> sadd like:article:1 user1(integer) 1127.0.0.1:6379> sadd like:article:1 user2(integer) 1# 获取成员数量(点赞数量)127.0.0.1:6379> SCARD like:article:1(integer) 2# 判断成员是否存在(是否点在)127.0.0.1:6379> SISMEMBER like:article:1 user1(integer) 1127.0.0.1:6379> SISMEMBER like:article:1 user3(integer) 0# 移除一个或者多个成员(取消点赞)127.0.0.1:6379> SREM like:article:1 user1(integer) 1127.0.0.1:6379> SCARD like:article:1(integer) 1
import lombok.extern.slf4j.Slf4j;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.data.redis.core.RedisTemplate;/** * @author 一行Java * @title: LikeMain * @projectName ehang-spring-boot * @description: TODO * @date 2022/7/18 15:38 */@Slf4j@SpringBootTestpublic class LikeMain { private final String KEY_LIKE_ARTICLE_PROFIX = "like:article:"; @Autowired RedisTemplate redisTemplate; @Test void test() { Long articleId = 100; Long likeNum = like(articleId, 1001, 1002, 2001, 3005, 4003); unLike(articleId, 2001); likeNum = likeNum(articleId); boolean b2001 = isLike(articleId, 2001); boolean b3005 = isLike(articleId, 3005); log.info("文章:{} 点赞数量:{} 用户2001的点赞状态:{} 用户3005的点赞状态:{}", articleId, likeNum, b2001, b3005); } /** * 点赞 * * @param articleId 文章ID * @return 点赞数量 */ public Long like(Long articleId, Integer... userIds) { String key = KEY_LIKE_ARTICLE_PROFIX + articleId; Long add = redisTemplate.opsForSet().add(key, userIds); return add; } public Long unLike(Long articleId, Integer... userIds) { String key = KEY_LIKE_ARTICLE_PROFIX + articleId; Long remove = redisTemplate.opsForSet().remove(key, userIds); return remove; } public Long likeNum(Long articleId) { String key = KEY_LIKE_ARTICLE_PROFIX + articleId; Long size = redisTemplate.opsForSet().size(key); return size; } public Boolean isLike(Long articleId, Integer userId) { String key = KEY_LIKE_ARTICLE_PROFIX + articleId; return redisTemplate.opsForSet().isMember(key, userId); }}
排名、排行榜、热搜榜是很多APP、游戏都有的功能,常用于用户活动推广、竞技排名、热门信息展示等功能;
比如上面的热搜榜,热度数据来源于全网用户的贡献,但用户只关心热度最高的前50条。
常规的做法:就是将用户的名次、分数等用于排名的数据更新到数据库,然后查询的时候通过Order by + limit 取出前50名显示,如果是参与用户不多,更新不频繁的数据,采用数据库的方式也没有啥问题,但是一旦出现爆炸性热点资讯(比如:大陆收复湾湾,xxx某些绿了等等),短时间会出现爆炸式的流量,瞬间的压力可能让数据库扛不住;
Redis方案:将热点资讯全页缓存,采用Redis的有序队列(Sorted Set)来缓存热度(SCORES),即可瞬间缓解数据库的压力,同时轻松筛选出热度最高的50条;
功能实现需要的命令
# 单个插入127.0.0.1:6379> ZADD ranking 1 user1 (integer) 1# 批量插入127.0.0.1:6379> ZADD ranking 10 user2 50 user3 3 user4 25 user5(integer) 4# 降序排列 不带SCORES127.0.0.1:6379> ZREVRANGE ranking 0 -1 1) "user3"2) "user5"3) "user2"4) "user4"5) "user1"# 降序排列 带SCORES127.0.0.1:6379> ZREVRANGE ranking 0 -1 WITHSCORES 1) "user3" 2) "50" 3) "user5" 4) "25" 5) "user2" 6) "10" 7) "user4" 8) "3" 9) "user1"10) "1"# 升序127.0.0.1:6379> ZRANGE ranking 0 -1 WITHSCORES 1) "user1" 2) "1" 3) "user4" 4) "3" 5) "user2" 6) "10" 7) "user5" 8) "25" 9) "user3"10) "50"
import lombok.extern.slf4j.Slf4j;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.data.redis.core.DefaultTypedTuple;import org.springframework.data.redis.core.RedisTemplate;import java.util.Set;/** * @author 一行Java * @title: RankingTest * @projectName ehang-spring-boot * @description: TODO * @date 2022/7/18 15:54 */@SpringBootTest@Slf4jpublic class RankingTest { private final String KEY_RANKING = "ranking"; @Autowired RedisTemplate redisTemplate; @Test void test() { add(1001, (double) 60); add(1002, (double) 80); add(1003, (double) 100); add(1004, (double) 90); add(1005, (double) 70); // 取所有 Set<DefaultTypedTuple> range = range(0, -1); log.info("所有用户排序:{}", range); // 前三名 range = range(0, 2); log.info("前三名排序:{}", range); } public Boolean add(Integer userId, Double score) { Boolean add = redisTemplate.opsForZSet().add(KEY_RANKING, userId, score); return add; } public Set<DefaultTypedTuple> range(long min, long max) { // 降序 Set<DefaultTypedTuple> set = redisTemplate.opsForZSet().reverseRangeWithScores(KEY_RANKING, min, max); // 升序 //Set<DefaultTypedTuple> set = redisTemplate.opsForZSet().rangeWithScores(KEY_RANKING, min, max); return set; }}
输出
所有用户排序:[DefaultTypedTuple [score=100.0, value=1003], DefaultTypedTuple [score=90.0, value=1004], DefaultTypedTuple [score=80.0, value=1002], DefaultTypedTuple [score=70.0, value=1005], DefaultTypedTuple [score=60.0, value=1001]]前三名排序:[DefaultTypedTuple [score=100.0, value=1003], DefaultTypedTuple [score=90.0, value=1004], DefaultTypedTuple [score=80.0, value=1002]]
Page View(PV)指的是页面浏览量,是用来衡量流量的一个重要标准,也是数据分析很重要的一个依据;通常统计规则是页面被展示一次,就加一
功能所需命令
127.0.0.1:6379> INCR pv:article:1(integer) 1127.0.0.1:6379> INCR pv:article:1(integer) 2
前面,介绍了通过(INCR)方式来实现页面的PV;除了PV之外,UV(独立访客)也是一个很重要的统计数据;
但是如果要想通过计数(INCR)的方式来实现UV计数,就非常的麻烦,增加之前,需要判断这个用户是否访问过;那判断依据就需要额外的方式再进行记录。
你可能会说,不是还有Set嘛!一个页面弄个集合,来一个用户塞(SADD)一个用户进去,要统计UV的时候,再通过SCARD汇总一下数量,就能轻松搞定了;此方案确实能实现UV的统计效果,但是忽略了成本;如果是普通页面,几百、几千的访问,可能造成的影响微乎其微,如果一旦遇到爆款页面,动辄上千万、上亿用户访问时,就一个页面UV将会带来非常大的内存开销,对于如此珍贵的内存来说,这显然是不划算的。
此时,HeyperLogLog数据结构,就能完美的解决这一问题,它提供了一种不精准的去重计数方案,注意!这里强调一下,是不精准的,会存在误差,不过误差也不会很大,**标准的误差率是0.81%**,这个误差率对于统计UV计数,是能够容忍的;所以,不要将这个数据结构拿去做精准的去重计数。
另外,HeyperLogLog 是会占用12KB的存储空间,虽然说,Redis 对 HeyperLogLog 进行了优化,在存储数据比较少的时候,采用了稀疏矩阵存储,只有在数据量变大,稀疏矩阵空间占用超过阈值时,才会转为空间为12KB的稠密矩阵;相比于成千、上亿的数据量,这小小的12KB,简直是太划算了;但是还是建议,不要将其用于数据量少,且频繁创建 HeyperLogLog 的场景,避免使用不当,造成资源消耗没减反增的不良效果。
功能所需命令:
# 添加三个用户的访问127.0.0.1:6379> PFADD uv:page:1 user1 user2 user3(integer) 1# 获取UV数量127.0.0.1:6379> PFCOUNT uv:page:1(integer) 3# 再添加三个用户的访问 user3是重复用户127.0.0.1:6379> PFADD uv:page:1 user3 user4 user5(integer) 1# 获取UV数量 user3是重复用户 所以这里返回的是5127.0.0.1:6379> PFCOUNT uv:page:1(integer) 5
模拟测试10000个用户访问id为2的页面
import lombok.extern.slf4j.Slf4j;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.data.redis.core.RedisTemplate;/** * @author 一行Java * @title: HeyperLogLog 统计UV * @projectName ehang-spring-boot * @description: TODO * @date 2022/7/19 16:13 */@SpringBootTest@Slf4jpublic class UVTest { private final String KEY_UV_PAGE_PROFIX = "uv:page:"; @Autowired RedisTemplate redisTemplate; @Test public void uvTest() { Integer pageId = 2; for (int i = 0; i < 10000; i++) { uv(pageId, i); } for (int i = 0; i < 10000; i++) { uv(pageId, i); } Long uv = getUv(pageId); log.info("pageId:{} uv:{}", pageId, uv); } /** * 用户访问页面 * @param pageId * @param userId * @return */ private Long uv(Integer pageId, Integer userId) { String key = KEY_UV_PAGE_PROFIX + pageId; return redisTemplate.opsForHyperLogLog().add(key, userId); } /** * 统计页面的UV * @param pageId * @return */ private Long getUv(Integer pageId) { String key = KEY_UV_PAGE_PROFIX + pageId; return redisTemplate.opsForHyperLogLog().size(key); }}
日志输出
pageId:2 uv:10023
由于存在误差,这里访问的实际访问的数量是1万,统计出来的多了23个,在标准的误差(0.81%)范围内,加上UV数据不是必须要求准确,因此这个误差是可以接受的。
通过上面HeyperLogLog的学习,我们掌握了一种不精准的去重计数方案,但是有没有发现,他没办法获取某个用户是否访问过;理想中,我们是希望有一个PFEXISTS的命令,来判断某个key是否存在,然而HeyperLogLog并没有;要想实现这一需求,就得 BloomFiler 上场了。
举个例子:假如你写了一个爬虫,用于爬取网络中的所有页面,当你拿到一个新的页面时,如何判断这个页面是否爬取过?
普通做法:每爬取一个页面,往数据库插入一行数据,记录一下URL,每次拿到一个新的页面,就去数据库里面查询一下,存在就说明爬取过;
普通做法的缺点:少量数据,用传统方案没啥问题,如果是海量数据,每次爬取前的检索,将会越来越慢;如果你的爬虫只关心内容,对来源数据不太关心的话,这部分数据的存储,也将消耗你很大的物理资源;
此时通过 BloomFiler 就能以很小的内存空间作为代价,即可轻松判断某个值是否存在。
同样,BloomFiler 也不那么精准,在默认参数情况下,是存在1%左右的误差;但是 BloomFiler 是允许通过error_rate(误差率)以及initial_size(预计大小)来设置他的误差比例
为了方便测试,这里使用 Docker 快速安装
docker run -d -p 6379:6379 redislabs/rebloom
功能所需的命令
127.0.0.1:6379> bf.add web:crawler baidu(integer) 1127.0.0.1:6379> bf.madd web:crawler tencent bing1) (integer) 12) (integer) 1127.0.0.1:6379> bf.exists web:crawler baidu(integer) 1127.0.0.1:6379> bf.mexists web:crawler baidu 1631) (integer) 12) (integer) 0
很多APP为了拉动用户活跃度,往往都会做一些活动,比如连续签到领积分/礼包等等
传统做法:用户每次签到时,往是数据库插入一条签到数据,展示的时候,把本月(或者指定周期)的签到数据获取出来,用于判断用户是否签到、以及连续签到情况;此方式,简单,理解容易;
Redis做法:由于签到数据的关注点就2个:是否签到(0/1)、连续性,因此就完全可以利用BitMap(位图)来实现;
一个月的签到情况,4个字节就记录了(图源:网络)
如上图所示,将一个月的31天,用31个位(4个字节)来表示,偏移量(offset)代表当前是第几天,0/1表示当前是否签到,连续签到只需从右往左校验连续为1的位数;
由于String类型的最大上限是512M,转换为bit则是2^32个bit位。
所需命令:
需求:假如当前为8月4号,检测本月的签到情况,用户分别于1、3、4号签到过
# 8月1号的签到127.0.0.1:6379> SETBIT RangeId:Sign:1:8899 0 1(integer) 1# 8月3号的签到127.0.0.1:6379> SETBIT RangeId:Sign:1:8899 2 1(integer) 1# 8月4号的签到127.0.0.1:6379> SETBIT RangeId:Sign:1:8899 3 1(integer) 1# 查询各天的签到情况# 查询1号127.0.0.1:6379> GETBIT RangeId:Sign:1:8899 0(integer) 1# 查询2号127.0.0.1:6379> GETBIT RangeId:Sign:1:8899 1(integer) 0# 查询3号127.0.0.1:6379> GETBIT RangeId:Sign:1:8899 2(integer) 1# 查询4号127.0.0.1:6379> GETBIT RangeId:Sign:1:8899 3(integer) 1# 查询指定区间的签到情况127.0.0.1:6379> BITFIELD RangeId:Sign:1:8899 get u4 01) (integer) 11
1-4号的签到情况为:1011(二进制) ==> 11(十进制)
签到功能中,最不好理解的就是是否签到、连续签到的判断,在下面SpringBoot代码中,就是通过这样的:signFlag >> 1 << 1 != signFlag来判断的,稍微有一点不好理解,在这里提前讲述一下;
上面测试了1-4号的签到情况,通过BITFIELD获取出来signFlag = 11(十进制) = 1011(二进制);
连续签到的判断依据就是:从右往左计算连续为1的BIT个数,二进制 1011 表示连续签到的天数就是2天,2天的计算过程如下:
理解上面的逻辑之后,再来看下面的SpringBoot代码,就会容易很多了;
签到的方式一般就两种,按月(周)/ 自定义周期,下面将两种方式的签到全部列举出来,以供大家参考:
签到工具类:
import lombok.extern.slf4j.Slf4j;import org.joda.time.DateTime;import org.joda.time.format.DateTimeFormat;import org.joda.time.format.DateTimeFormatter;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.data.redis.connection.BitFieldSubCommands;import org.springframework.data.redis.core.RedisCallback;import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.stereotype.Service;import org.springframework.util.CollectionUtils;import java.util.HashMap;import java.util.List;import java.util.Map;/** * @author 一行Java * @title: 按月签到 * @projectName ehang-spring-boot * @description: TODO * @date 2022/7/18 18:28 */@Slf4j@Servicepublic class SignByMonthServiceImpl { @Autowired StringRedisTemplate stringRedisTemplate; private int dayOfMonth() { DateTime dateTime = new DateTime(); return dateTime.dayOfMonth().get(); } /** * 按照月份和用户ID生成用户签到标识 UserId:Sign:560:2021-08 * * @param userId 用户id * @return */ private String signKeyWitMouth(String userId) { DateTime dateTime = new DateTime(); DateTimeFormatter fmt = DateTimeFormat.forPattern("yyyy-MM"); StringBuilder builder = new StringBuilder("UserId:Sign:"); builder.append(userId).append(":") .append(dateTime.toString(fmt)); return builder.toString(); } /** * 设置标记位 * 标记是否签到 * * @param key * @param offset * @param tag * @return */ public Boolean sign(String key, long offset, boolean tag) { return this.stringRedisTemplate.opsForValue().setBit(key, offset, tag); } /** * 统计计数 * * @param key 用户标识 * @return */ public long bitCount(String key) { return stringRedisTemplate.execute((RedisCallback<Long>) redisConnection -> redisConnection.bitCount(key.getBytes())); } /** * 获取多字节位域 */ public List<Long> bitfield(String buildSignKey, int limit, long offset) { return this.stringRedisTemplate .opsForValue() .bitField(buildSignKey, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(limit)).valueAt(offset)); } /** * 判断是否被标记 * * @param key * @param offest * @return */ public Boolean container(String key, long offest) { return this.stringRedisTemplate.opsForValue().getBit(key, offest); } /** * 用户今天是否签到 * * @param userId * @return */ public int checkSign(String userId) { DateTime dateTime = new DateTime(); String signKey = this.signKeyWitMouth(userId); int offset = dateTime.getDayOfMonth() - 1; int value = this.container(signKey, offset) ? 1 : 0; return value; } /** * 查询用户当月签到日历 * * @param userId * @return */ public Map<String, Boolean> querySignedInMonth(String userId) { DateTime dateTime = new DateTime(); int lengthOfMonth = dateTime.dayOfMonth().getMaximumValue(); Map<String, Boolean> signedInMap = new HashMap<>(dateTime.getDayOfMonth()); String signKey = this.signKeyWitMouth(userId); List<Long> bitfield = this.bitfield(signKey, lengthOfMonth, 0); if (!CollectionUtils.isEmpty(bitfield)) { long signFlag = bitfield.get(0) == null ? 0 : bitfield.get(0); DateTimeFormatter fmt = DateTimeFormat.forPattern("yyyy-MM-dd"); for (int i = lengthOfMonth; i > 0; i--) { DateTime dateTime1 = dateTime.withDayOfMonth(i); signedInMap.put(dateTime1.toString(fmt), signFlag >> 1 << 1 != signFlag); signFlag >>= 1; } } return signedInMap; } /** * 用户签到 * * @param userId * @return */ public boolean signWithUserId(String userId) { int dayOfMonth = this.dayOfMonth(); String signKey = this.signKeyWitMouth(userId); long offset = (long) dayOfMonth - 1; boolean re = false; if (Boolean.TRUE.equals(this.sign(signKey, offset, Boolean.TRUE))) { re = true; } // 查询用户连续签到次数,最大连续次数为7天 long continuousSignCount = this.queryContinuousSignCount(userId, 7); return re; } /** * 统计当前月份一共签到天数 * * @param userId */ public long countSignedInDayOfMonth(String userId) { String signKey = this.signKeyWitMouth(userId); return this.bitCount(signKey); } /** * 查询用户当月连续签到次数 * * @param userId * @return */ public long queryContinuousSignCountOfMonth(String userId) { int signCount = 0; String signKey = this.signKeyWitMouth(userId); int dayOfMonth = this.dayOfMonth(); List<Long> bitfield = this.bitfield(signKey, dayOfMonth, 0); if (!CollectionUtils.isEmpty(bitfield)) { long signFlag = bitfield.get(0) == null ? 0 : bitfield.get(0); DateTime dateTime = new DateTime(); // 连续不为0即为连续签到次数,当天未签到情况下 for (int i = 0; i < dateTime.getDayOfMonth(); i++) { if (signFlag >> 1 << 1 == signFlag) { if (i > 0) break; } else { signCount += 1; } signFlag >>= 1; } } return signCount; } /** * 以7天一个周期连续签到次数 * * @param period 周期 * @return */ public long queryContinuousSignCount(String userId, Integer period) { //查询目前连续签到次数 long count = this.queryContinuousSignCountOfMonth(userId); //按最大连续签到取余 if (period != null && period < count) { long num = count % period; if (num == 0) { count = period; } else { count = num; } } return count; }}
测试类:
import lombok.extern.slf4j.Slf4j;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.data.redis.core.StringRedisTemplate;import java.util.Map;/** * @author 一行Java * @title: SignTest2 * @projectName ehang-spring-boot * @description: TODO * @date 2022/7/19 12:06 */@SpringBootTest@Slf4jpublic class SignTest2 { @Autowired private SignByMonthServiceImpl signByMonthService; @Autowired private StringRedisTemplate redisTemplate; /** * 测试用户按月签到 */ @Test public void querySignDay() { //模拟用户签到 //for(int i=5;i<19;i++){ redisTemplate.opsForValue().setBit("UserId:Sign:560:2022-08", 0, true); //} System.out.println("560用户今日是否已签到:" + this.signByMonthService.checkSign("560")); Map<String, Boolean> stringBooleanMap = this.signByMonthService.querySignedInMonth("560"); System.out.println("本月签到情况:"); for (Map.Entry<String, Boolean> entry : stringBooleanMap.entrySet()) { System.out.println(entry.getKey() + ": " + (entry.getValue() ? "√" : "-")); } long countSignedInDayOfMonth = this.signByMonthService.countSignedInDayOfMonth("560"); System.out.println("本月一共签到:" + countSignedInDayOfMonth + "天"); System.out.println("目前连续签到:" + this.signByMonthService.queryContinuousSignCount("560", 7) + "天"); }}
执行日志:
c.e.r.bitmap_sign_by_month.SignTest2 : 560用户今日是否已签到:0c.e.r.bitmap_sign_by_month.SignTest2 : 本月签到情况:c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-12: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-11: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-10: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-31: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-30: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-19: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-18: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-17: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-16: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-15: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-14: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-13: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-23: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-01: √c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-22: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-21: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-20: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-09: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-08: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-29: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-07: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-28: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-06: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-27: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-05: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-26: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-04: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-25: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-03: √c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-24: -c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-02: -c.e.r.bitmap_sign_by_month.SignTest2 : 本月一共签到:2天c.e.r.bitmap_sign_by_month.SignTest2 : 目前连续签到:1天
签到工具类:
package com.ehang.redis.bitmap_sign_by_range;import lombok.extern.slf4j.Slf4j;import org.joda.time.DateTime;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.data.redis.connection.BitFieldSubCommands;import org.springframework.data.redis.core.RedisCallback;import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.stereotype.Service;import org.springframework.util.CollectionUtils;import java.time.LocalDateTime;import java.time.format.DateTimeFormatter;import java.util.HashMap;import java.util.List;import java.util.Map;/** * @author 一行Java * @title: SignByRangeServiceImpl * @projectName ehang-spring-boot * @description: TODO * @date 2022/7/19 12:27 */@Slf4j@Servicepublic class SignByRangeServiceImpl { @Autowired StringRedisTemplate stringRedisTemplate; /** * 根据区间的id 以及用户id 拼接key * * @param rangeId 区间ID 一般是指定活动的ID等 * @param userId 用户的ID * @return */ private String signKey(Integer rangeId, Integer userId) { StringBuilder builder = new StringBuilder("RangeId:Sign:"); builder.append(rangeId).append(":") .append(userId); return builder.toString(); } /** * 获取当前时间与起始时间的间隔天数 * * @param start 起始时间 * @return */ private int intervalTime(LocalDateTime start) { return (int) (LocalDateTime.now().toLocalDate().toEpochDay() - start.toLocalDate().toEpochDay()); } /** * 设置标记位 * 标记是否签到 * * @param key 签到的key * @param offset 偏移量 一般是指当前时间离起始时间(活动开始)的天数 * @param tag 是否签到 true:签到 false:未签到 * @return */ private Boolean setBit(String key, long offset, boolean tag) { return this.stringRedisTemplate.opsForValue().setBit(key, offset, tag); } /** * 统计计数 * * @param key 统计的key * @return */ private long bitCount(String key) { return stringRedisTemplate.execute((RedisCallback<Long>) redisConnection -> redisConnection.bitCount(key.getBytes())); } /** * 获取多字节位域 * * @param key 缓存的key * @param limit 获取多少 * @param offset 偏移量是多少 * @return */ private List<Long> bitfield(String key, int limit, long offset) { return this.stringRedisTemplate .opsForValue() .bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(limit)).valueAt(offset)); } /** * 判断是否签到 * * @param key 缓存的key * @param offest 偏移量 指当前时间距离起始时间的天数 * @return */ private Boolean container(String key, long offest) { return this.stringRedisTemplate.opsForValue().getBit(key, offest); } /** * 根据起始时间进行签到 * * @param rangeId * @param userId * @param start * @return */ public Boolean sign(Integer rangeId, Integer userId, LocalDateTime start) { int offset = intervalTime(start); String key = signKey(rangeId, userId); return setBit(key, offset, true); } /** * 根据偏移量签到 * * @param rangeId * @param userId * @param offset * @return */ public Boolean sign(Integer rangeId, Integer userId, long offset) { String key = signKey(rangeId, userId); return setBit(key, offset, true); } /** * 用户今天是否签到 * * @param userId * @return */ public Boolean checkSign(Integer rangeId, Integer userId, LocalDateTime start) { long offset = intervalTime(start); String key = this.signKey(rangeId, userId); return this.container(key, offset); } /** * 统计当前月份一共签到天数 * * @param userId */ public long countSigned(Integer rangeId, Integer userId) { String signKey = this.signKey(rangeId, userId); return this.bitCount(signKey); } public Map<String, Boolean> querySigned(Integer rangeId, Integer userId, LocalDateTime start) { int days = intervalTime(start); Map<String, Boolean> signedInMap = new HashMap<>(days); String signKey = this.signKey(rangeId, userId); List<Long> bitfield = this.bitfield(signKey, days + 1, 0); if (!CollectionUtils.isEmpty(bitfield)) { long signFlag = bitfield.get(0) == null ? 0 : bitfield.get(0); DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); for (int i = days; i >= 0; i--) { LocalDateTime localDateTime = start.plusDays(i); signedInMap.put(localDateTime.format(fmt), signFlag >> 1 << 1 != signFlag); signFlag >>= 1; } } return signedInMap; } /** * 查询用户当月连续签到次数 * * @param userId * @return */ public long queryContinuousSignCount(Integer rangeId, Integer userId, LocalDateTime start) { int signCount = 0; String signKey = this.signKey(rangeId, userId); int days = this.intervalTime(start); List<Long> bitfield = this.bitfield(signKey, days + 1, 0); if (!CollectionUtils.isEmpty(bitfield)) { long signFlag = bitfield.get(0) == null ? 0 : bitfield.get(0); DateTime dateTime = new DateTime(); // 连续不为0即为连续签到次数,当天未签到情况下 for (int i = 0; i < dateTime.getDayOfMonth(); i++) { if (signFlag >> 1 << 1 == signFlag) { if (i > 0) break; } else { signCount += 1; } signFlag >>= 1; } } return signCount; }}
测试工具类:
package com.ehang.redis.bitmap_sign_by_range;import lombok.extern.slf4j.Slf4j;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import java.time.LocalDateTime;import java.time.format.DateTimeFormatter;import java.util.Map;/** * @author 一行Java * @title: SignTest * @projectName ehang-spring-boot * @description: TODO * @date 2022/7/18 16:11 */@SpringBootTest@Slf4jpublic class SignTest { @Autowired SignByRangeServiceImpl signByRangeService; @Test void test() { DateTimeFormatter isoDateTime = DateTimeFormatter.ISO_DATE_TIME; // 活动开始时间 LocalDateTime start = LocalDateTime.of(2022, 8, 1, 1, 0, 0); Integer rangeId = 1; Integer userId = 8899; log.info("签到开始时间: {}", start.format(isoDateTime)); log.info("活动ID: {} 用户ID: {}", rangeId, userId); // 手动指定偏移量签到 signByRangeService.sign(rangeId, userId, 0); // 判断是否签到 Boolean signed = signByRangeService.checkSign(rangeId, userId, start); log.info("今日是否签到: {}", signed ? "√" : "-"); // 签到 Boolean sign = signByRangeService.sign(rangeId, userId, start); log.info("签到操作之前的签到状态:{} (-:表示今日第一次签到,√:表示今天已经签到过了)", sign ? "√" : "-"); // 签到总数 long countSigned = signByRangeService.countSigned(rangeId, userId); log.info("总共签到: {} 天", countSigned); // 连续签到的次数 long continuousSignCount = signByRangeService.queryContinuousSignCount(rangeId, userId, start); log.info("连续签到: {} 天", continuousSignCount); // 签到的详情 Map<String, Boolean> stringBooleanMap = signByRangeService.querySigned(rangeId, userId, start); for (Map.Entry<String, Boolean> entry : stringBooleanMap.entrySet()) { log.info("签到详情> {} : {}", entry.getKey(), (entry.getValue() ? "√" : "-")); } }}
输出日志:
签到开始时间: 2022-08-01T01:00:00活动ID: 1 用户ID: 8899今日是否签到: √签到操作之前的签到状态:√ (-:表示今日第一次签到,√:表示今天已经签到过了)总共签到: 3 天连续签到: 2 天签到详情> 2022-08-01 : √签到详情> 2022-08-04 : √签到详情> 2022-08-03 : √签到详情> 2022-08-02 : -
很多生活类的APP都具备一个搜索附近的功能,比如美团搜索附近的商家;
网图
如果自己想要根据经纬度来实现一个搜索附近的功能,是非常麻烦的;但是Redis 在3.2的版本新增了Redis GEO,用于存储地址位置信息,并对支持范围搜索;基于GEO就能轻松且快速的开发一个搜索附近的功能;
通过SpringBoot操作GEO的示例如下
import lombok.extern.slf4j.Slf4j;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.data.geo.*;import org.springframework.data.redis.connection.RedisGeoCommands;import org.springframework.data.redis.core.RedisTemplate;import java.util.List;/** * @author 一行Java * @title: GEOTest * @projectName ehang-spring-boot * @description: TODO * @date 2022/7/28 17:29 */@SpringBootTest@Slf4jpublic class GEOTest { private final String KEY = "geo:drinks"; @Autowired RedisTemplate redisTemplate; @Test public void test() { add("starbucks", new Point(116.62445, 39.86206)); add("yidiandian", new Point(117.3514785, 38.7501247)); add("xicha", new Point(116.538542, 39.75412)); get("starbucks", "yidiandian", "xicha"); GeoResults nearByXY = getNearByXY(new Point(116, 39), new Distance(120, Metrics.KILOMETERS)); List<GeoResult> content = nearByXY.getContent(); for (GeoResult geoResult : content) { log.info("{}", geoResult.getContent()); } GeoResults nearByPlace = getNearByPlace("starbucks", new Distance(120, Metrics.KILOMETERS)); content = nearByPlace.getContent(); for (GeoResult geoResult : content) { log.info("{}", geoResult.getContent()); } getGeoHash("starbucks", "yidiandian", "xicha"); del("yidiandian", "xicha"); } private void add(String name, Point point) { Long add = redisTemplate.opsForGeo().add(KEY, point, name); log.info("成功添加名称:{} 的坐标信息信息:{}", name, point); } private void get(String... names) { List<Point> position = redisTemplate.opsForGeo().position(KEY, names); log.info("获取名称为:{} 的坐标信息:{}", names, position); } private void del(String... names) { Long remove = redisTemplate.opsForGeo().remove(KEY, names); log.info("删除名称为:{} 的坐标信息数量:{}", names, remove); } /** * 根据坐标 获取指定范围的位置 * * @param point * @param distance * @return */ private GeoResults getNearByXY(Point point, Distance distance) { Circle circle = new Circle(point, distance); RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs. newGeoRadiusArgs(). includeDistance(). // 包含距离 includeCoordinates(). // 包含坐标 sortAscending(). // 排序 还可选sortDescending() limit(5); // 获取前多少个 GeoResults geoResults = redisTemplate.opsForGeo().radius(KEY, circle, args); log.info("根据坐标获取:{} {} 范围的数据:{}", point, distance, geoResults); return geoResults; } /** * 根据一个位置,获取指定范围内的其他位置 * * @param name * @param distance * @return */ private GeoResults getNearByPlace(String name, Distance distance) { RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs. newGeoRadiusArgs(). includeDistance(). // 包含距离 includeCoordinates(). // 包含坐标 sortAscending(). // 排序 还可选sortDescending() limit(5); // 获取前多少个 GeoResults geoResults = redisTemplate.opsForGeo() .radius(KEY, name, distance, args); log.info("根据位置:{} 获取: {} 范围的数据:{}", name, distance, geoResults); return geoResults; } /** * 获取GEO HASH * * @param names * @return */ private List<String> getGeoHash(String... names) { List<String> hash = redisTemplate.opsForGeo().hash(KEY, names); log.info("names:{} 对应的hash:{}", names, hash); return hash; }}
执行日志:
成功添加名称:starbucks 的坐标信息信息:Point [x=116.624450, y=39.862060]成功添加名称:yidiandian 的坐标信息信息:Point [x=117.351479, y=38.750125]成功添加名称:xicha 的坐标信息信息:Point [x=116.538542, y=39.754120]获取名称为:[starbucks, yidiandian, xicha] 的坐标信息:[Point [x=116.624452, y=39.862060], Point [x=117.351480, y=38.750124], Point [x=116.538540, y=39.754119]]根据坐标获取:Point [x=116.000000, y=39.000000] 120.0 KILOMETERS 范围的数据:GeoResults: [averageDistance: 102.8394 KILOMETERS, results: GeoResult [content: RedisGeoCommands.GeoLocation(name=xicha, point=Point [x=116.538540, y=39.754119]), distance: 95.8085 KILOMETERS, ],GeoResult [content: RedisGeoCommands.GeoLocation(name=starbucks, point=Point [x=116.624452, y=39.862060]), distance: 109.8703 KILOMETERS, ]]RedisGeoCommands.GeoLocation(name=xicha, point=Point [x=116.538540, y=39.754119])RedisGeoCommands.GeoLocation(name=starbucks, point=Point [x=116.624452, y=39.862060])根据位置:starbucks 获取: 120.0 KILOMETERS 范围的数据:GeoResults: [averageDistance: 7.03605 KILOMETERS, results: GeoResult [content: RedisGeoCommands.GeoLocation(name=starbucks, point=Point [x=116.624452, y=39.862060]), distance: 0.0 KILOMETERS, ],GeoResult [content: RedisGeoCommands.GeoLocation(name=xicha, point=Point [x=116.538540, y=39.754119]), distance: 14.0721 KILOMETERS, ]]RedisGeoCommands.GeoLocation(name=starbucks, point=Point [x=116.624452, y=39.862060])RedisGeoCommands.GeoLocation(name=xicha, point=Point [x=116.538540, y=39.754119])names:[starbucks, yidiandian, xicha] 对应的hash:[wx4fvbem6d0, wwgkqqhxzd0, wx4f5vhb8b0]删除名称为:[yidiandian, xicha] 的坐标信息数量:2
为了保证项目的安全稳定运行,防止被恶意的用户或者异常的流量打垮整个系统,一般都会加上限流,比如常见的sential、hystrix,都是实现限流控制;如果项目用到了Redis,也可以利用Redis,来实现一个简单的限流功能;
功能所需命令
127.0.0.1:6379> INCR r:f:user1(integer) 1# 第一次 设置一个过期时间127.0.0.1:6379> EXPIRE r:f:user1 5(integer) 1127.0.0.1:6379> INCR r:f:user1(integer) 2# 等待5s 再次增加 发现已经重置了127.0.0.1:6379> INCR r:f:user1(integer) 1
import lombok.extern.slf4j.Slf4j;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.data.redis.core.RedisTemplate;import java.util.concurrent.TimeUnit;/** * @author 一行Java * @title: 基于Redis的简单限流 * @projectName ehang-spring-boot * @description: TODO * @date 2022/8/2 9:43 */@SpringBootTest@Slf4jpublic class FreqTest { // 单位时间(秒) private static final Integer TIME = 5; // 允许访问上限次数 private static final Integer MAX = 100; @Autowired RedisTemplate redisTemplate; @Test public void test() throws Exception { String userName = "user1"; int tag = 1; boolean frequency = frequency(userName); log.info("第{}次是否放行:{}", tag, frequency); for (int i = 0; i < 100; i++) { tag += 1; frequency(userName); } frequency = frequency(userName); log.info("第{}次是否放行:{}", tag, frequency); Thread.sleep(5000); frequency = frequency(userName); log.info("模拟等待5s后,第{}次是否放行:{}", tag, frequency); } /** * 校验访问频率 * * @param uniqueId 用于限流的唯一ID 可以是用户ID、或者客户端IP等 * @return true:放行 false:拦截 */ private boolean frequency(String uniqueId) { String key = "r:q:" + uniqueId; Long increment = redisTemplate.opsForValue().increment(key); if (increment == 1) { redisTemplate.expire(key, TIME, TimeUnit.SECONDS); } if (increment <= MAX) { return true; } return false; }}
运行日志:
user1 第1次请求是否放行:trueuser1 第101次请求是否放行:false模拟等待5s后,user1 第101次请求是否放行:true
在分布式系统中,很多场景下需要全局的唯一ID,由于Redis是独立于业务服务的其他应用,就可以利用Incr的原子性操作来生成全局的唯一递增ID
功能所需命令
127.0.0.1:6379> incr g:uid(integer) 1127.0.0.1:6379> incr g:uid(integer) 2127.0.0.1:6379> incr g:uid(integer) 3
在分布式系统中,很多操作是需要用到分布式锁,防止并发操作带来一些问题;因为redis是独立于分布式系统外的其他服务,因此就可以利用redis,来实现一个简单的不完美分布式锁;
功能所需命令
不完美的锁
上面的方案,虽然解决了死锁的问题,但是又带来了一个新的问题,执行时间如果长于自动释放的时间(比如自动释放是5秒,但是业务执行耗时了8秒),那么在第5秒的时候,锁就自动释放了,此时其他的线程就能正常拿到锁,简单流程如下:
此时相同颜色部分的时间区间是由多线程同时在执行。而且此问题在此方案下并没有完美的解决方案,只能做到尽可能的避免:
在支付宝、抖音、QQ等应用中,都会看到好友推荐;
好友推荐往往都是基于你的好友关系网来推荐,将你可能认识的人推荐给你,让你去添加好友,如果随意在系统找个人推荐给你,那你认识的可能性是非常小的,此时就失去了推荐的目的;
比如,A和B是好友,B和C是好友,此时A和C认识的概率是比较大的,就可以在A和C之间的好友推荐;
基于这个逻辑,就可以利用 Redis 的 Set 集合,缓存各个用户的好友列表,然后以差集的方式,来实现好友推荐;
功能所需的命令
# 记录 用户1 的好友列表127.0.0.1:6379> SADD fl:user1 user2 user3(integer) 2# 记录 用户2 的好友列表127.0.0.1:6379> SADD fl:user2 user1 user4(integer) 2# 用户1 可能认识的人 ,把自己(user1)去掉,user4是可能认识的人127.0.0.1:6379> SDIFF fl:user2 fl:user11) "user1"2) "user4"# 用户2 可能认识的人 ,把自己(user2)去掉,user3是可能认识的人127.0.0.1:6379> SDIFF fl:user1 fl:user21) "user3"2) "user2"
不过这只是推荐机制中的一种因素,可以借助其他条件,来增强推荐的准确度;
发布/订阅是比较常用的一种模式;在分布式系统中,如果需要实时感知到一些变化,比如:某些配置发生变化需要实时同步,就可以用到发布,订阅功能
常用API
如上图所示,左侧多个客户端订阅了频道,当右侧客户端往频道发送消息的时候,左侧客户端都能收到对应的消息。
说到消息队列,常用的就是Kafka、RabbitMQ等等,其实 Redis 利用 List 也能实现一个消息队列;
功能所需的指令
依赖调整:
Spring Boot 从 2.0版本开始,将默认的Redis客户端Jedis替换为Lettuce,在测试这块阻塞的时候,会出现一个超时的异常io.lettuce.core.RedisCommandTimeoutException: Command timed out after 1 minute(s);没有找到一个好的解决方式,所以这里将 Lettuce 换回成 Jedis ,就没有问题了,pom.xml 的配置如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <exclusions> <exclusion> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </exclusion> <exclusion> <artifactId>lettuce-core</artifactId> <groupId>io.lettuce</groupId> </exclusion> </exclusions></dependency><!-- jedis客户端 --><dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId></dependency><!-- spring2.X集成redis所需common-pool2,使用jedis必须依赖它--><dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId></dependency>
测试代码:
import lombok.extern.slf4j.Slf4j;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.data.redis.core.StringRedisTemplate;import java.util.concurrent.TimeUnit;/** * @author 一行Java * @title: QueueTest * @projectName ehang-spring-boot * @description: TODO * @date 2022/8/5 14:27 */@SpringBootTest@Slf4jpublic class QueueTest { private static final String REDIS_LP_QUEUE = "redis:lp:queue"; private static final String REDIS_RP_QUEUE = "redis:rp:queue"; @Autowired StringRedisTemplate stringRedisTemplate; /** * 先进后出队列 */ @Test public void rightMonitor() { while (true) { Object o = stringRedisTemplate.opsForList().rightPop(REDIS_LP_QUEUE, 0, TimeUnit.SECONDS); log.info("先进后出队列 接收到数据:{}", o); } } /** * 先进先出队列 */ @Test public void leftMonitor() { while (true) { Object o = stringRedisTemplate.opsForList().leftPop(REDIS_RP_QUEUE, 0, TimeUnit.SECONDS); log.info("先进先出队列 接收到数据:{}", o); } }}
不过,对消息的可靠性要求比较高的场景,建议还是使用专业的消息队列框架,当值被弹出之后,List 中就已经不存在对应的值了,假如此时程序崩溃,就会出现消息的丢失,无法保证可靠性;虽然说也有策略能够保证消息的可靠性,比如,在弹出的同时,将其保存到另外一个队列(BRPOPLPUSH),成功之后,再从另外的队列中移除,当消息处理失败或者异常,再重新进入队列执行,只是这样做就得不偿失了。
既然Redis能持久化数据,就可以用它来实现模块间的数据共享;SpringBoot Session 利用的这个机制来实现 Session 共享;
商城类的应用,都会有类似于下图的一个商品筛选的功能,来帮用户快速搜索理想的商品;
假如现在iphone 100 、华为mate 5000 已发布,在各大商城上线;下面就通过 Redis 的 set 来实现上述的商品筛选功能;
功能所需命令
# 将iphone100 添加到品牌为苹果的集合127.0.0.1:6379> sadd brand:apple iphone100(integer) 1# 将meta5000 添加到品牌为苹果的集合127.0.0.1:6379> sadd brand:huawei meta5000(integer) 1# 将 meta5000 iphone100 添加到支持5T内存的集合127.0.0.1:6379> sadd ram:5t iphone100 meta5000(integer) 2# 将 meta5000 添加到支持10T内存的集合127.0.0.1:6379> sadd ram:10t meta5000(integer) 1# 将 iphone100 添加到操作系统是iOS的集合127.0.0.1:6379> sadd os:ios iphone100(integer) 1# 将 meta5000 添加到操作系统是Android的集合127.0.0.1:6379> sadd os:android meta5000(integer) 1# 将 iphone100 meta5000 添加到屏幕为6.0-6.29的集合中127.0.0.1:6379> sadd screensize:6.0-6.29 iphone100 meta5000(integer) 2# 筛选内存5T、屏幕在6.0-6.29的机型127.0.0.1:6379> sinter ram:5t screensize:6.0-6.291) "meta5000"2) "iphone100"# 筛选内存10T、屏幕在6.0-6.29的机型127.0.0.1:6379> sinter ram:10t screensize:6.0-6.291) "meta5000"# 筛选内存5T、系统为iOS的机型127.0.0.1:6379> sinter ram:5t screensize:6.0-6.29 os:ios1) "iphone100"# 筛选内存5T、屏幕在6.0-6.29、品牌是华为的机型127.0.0.1:6379> sinter ram:5t screensize:6.0-6.29 brand:huawei1) "meta5000"
商品缓存
电商项目中,商品消息,都会做缓存处理,特别是热门商品,访问用户比较多,由于商品的结果比较复杂,店铺信息,产品信息,标题、描述、详情图,封面图;为了方便管理和操作,一般都会采用 Hash 的方式来存储(key为商品ID,field用来保存各项参数,value保存对于的值)
购物车
当商品信息做了缓存,购物车需要做的,就是通过Hash记录商品ID,以及需要购买的数量(其中key为用户信息,field为商品ID,value用来记录购买的数量) ;
功能所需命令
# 购物车添加单个商品127.0.0.1:6379> HSET sc:u1 c001 1(integer) 1# 购物车添加多个商品127.0.0.1:6379> HMSET sc:u1 c002 1 coo3 2OK# 添加商品购买数量127.0.0.1:6379> HINCRBY sc:u1 c002 1(integer) 2# 减少商品的购买数量127.0.0.1:6379> HINCRBY sc:u1 c003 -1(integer) 1# 获取单个的购买数量127.0.0.1:6379> HGET sc:u1 c002"2"# 获取购物车的商品数量127.0.0.1:6379> HLEN sc:u1(integer) 3# 购物车详情127.0.0.1:6379> HGETALL sc:u11) "c001"2) "1"3) "c002"4) "2"5) "coo3"6) "2"
电商类的业务,一般都会有订单30分钟不支付,自动取消的功能,此时就需要用到定时任务框架,Quartz、xxl-job、elastic-job 是比较常用的 Java 定时任务;我们也可以通过 Redis 的定时过期、以及过期key的监听,来实现订单的取消功能;
寄快递、网购的时候,查询物流信息,都会给我们展示xxx时候,快递到达什么地方了,这就是一个典型的时间线列表;
数据库的做法,就是每次变更就插入一条带时间的信息记录,然后根据时间和ID(ID是必须的,如果出现两个相同的时间,单纯时间排序,会造成顺序不对),来排序生成时间线;
我们也可以通过 Redis 的 List 来实现时间线功能,由于 List 采用的是双向链表,因此升序,降序的时间线都能正常满足;
Redis-cli 客户端测试
好了,关于Redis 的妙用,就介绍到这里;有了这些个场景的运用,下次再有面试官问你,Redis除了缓存还做过什么,相信聊上个1把小时,应该不成问题了。