存储笔记点赞:
这个需求也很简单,就是将每一篇笔记或者文章的点赞用户存储到redis中
我觉得我们需要有一个概念:
就是我们的key是什么,这个key肯定是这个笔记的id,所以我们需要有一个想法,就是一个笔记在redis中对应一个集合,然后那个集合来存储用户的id
我们还需要注意选用什么样的redis的数据结构
我们首先需要想,一个用户点赞了之后肯定不能重复点赞,所以,先想到redis的set集合
我们后面还有一个需求就是要按照时间的顺序查出点赞用户,所以,我们需要一个redis中的一个有序集合,也就是sortedSet
ZSet的命令:
添加元素
ZADD key score member [score member ...]
- 将一个或多个成员元素及其分数添加到有序集合中。
- 示例:
ZADD myzset 1 "one" 2 "two"
删除元素
ZREM key member [member ...]
- 移除有序集合中的一个或多个成员。
- 示例:
ZREM myzset "one"
获取按分数范围的元素
ZRANGEBYSCORE key min max [WITHSCORES]
- 返回分数在指定范围内的成员。
- 示例:
ZRANGEBYSCORE myzset 1 2 WITHSCORES
项目中的代码:(用户点赞的逻辑)
@Override
public Result likeBlog(Long id) {
//1:获取当前用户
final Long userId = UserHolder.getUser().getId();
//2:判断当前用户是否已经点赞
String key = BLOG_LIKED_KEY + id;
final Double score = redisTemplate.opsForZSet().score(key, userId.toString());
LambdaUpdateWrapper<Blog> updateWrapper = new LambdaUpdateWrapper<>();
if (score == null) {
//2.1 如果没点过,更新数据库的liked+1,并且存入redis中
redisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
final boolean isSuccess = this.update().setSql("liked = liked + 1").eq("id", id).update();
if (!isSuccess) {
return Result.ok("点赞失败");
}
} else {
//2.2 如果点过,就将isliked设置成false,并且从redis中删除
redisTemplate.opsForZSet().remove(key, userId.toString());
final boolean isSuccess = this.update().setSql("liked = liked + 1").eq("id", id).update();
if (!isSuccess) {
return Result.ok("取消点赞失败");
}
}
return Result.ok();
}
整体的思路很简单,就是先去redis中查是否有数据,有数据说明你已经点赞过了,这个时候你需要取消点赞,没有说明没点赞过,存进去即可
这里因为用的是sortedSet,确保存进去的元素不会重复,这就不会一个用户重复点赞博文的情况
项目中的代码:(查询排行榜的逻辑)
@Override
public Result queryBlogLikes(Long id) {
//从redis的ZSet中查出前5名
String key = BLOG_LIKED_KEY + id;
Set<String> top5 = redisTemplate.opsForZSet().range(key, 0, 4);
if (top5 == null || CollUtil.isEmpty(top5)) {
return Result.ok(Collections.emptyList());
}
//解析出用户id
final List<Long> userIds = top5.stream().map(Long::valueOf).toList();
//查询用户
final List<UserDTO> userDTOList = userService.query()
.in("id", userIds)
.last("ORDER BY FIELD(id," + StrUtil.join(",", userIds) + ")").list()
.stream()
.map(this::getUserDTO)
.toList();
return Result.ok(userDTOList);
}
整体的逻辑就是查询前五个(0-4)
这里有个注意点:
就是,我们查出来之后,我们还要查出对应的用户信息并且按照我们的顺序返回,但是我们直接调用mybatis-plus提供的this.listByIds(),然后你传入一个id的集合,但是顺序会被打乱:
下面贴一个GPT的解释:
为什么返回的顺序可能会打乱
无序返回:
- SQL 的
IN
子句并不保证返回结果的顺序。数据库在执行IN
查询时,返回的结果集顺序是未定义的,尤其是在没有使用ORDER BY
的情况下。因此,即使你传入的 ID 集合是有序的,数据库返回的结果可能会以不同的顺序呈现。数据库实现:
- 不同的数据库在处理
IN
查询时,可能会根据内部优化算法返回结果。比如,某些数据库可能会根据索引或其他因素决定返回的顺序,而不是按照IN
列表中的顺序。
解决办法就是使用 ORDER BY FIELD这个字段:
SELECT * FROM table_name WHERE id IN (id1, id2, id3) ORDER BY FIELD(id, id1, id2, id3);
在上面的代码中就先用StrUtil.join(",", userIds)拼接一个这个id1,id2这样的字符串,然后用last拼在这个sql语句的结尾,最后传给数据库查询。
好友关注:
关注这个功能实现,需要先从数据库表的设计入手:
一个用户可以关注多个用户,一个用户也可以被多个用户关注
所以这里采用了这个关联表来记录关注的关系
@Override
public Result followUser(Long id, Boolean isFollow) {
// 1.判断是否关注
final Long userId = UserHolder.getUser().getId();
if(Boolean.TRUE.equals(isFollow)){
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(id);
this.save(follow);
}else {
//取关
QueryWrapper<Follow> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("follow_user_id",id);
queryWrapper.eq("user_id",userId);
this.remove(queryWrapper);
}
return Result.ok();
}
整体的逻辑比较简单,就是前端会传一个是否关注,如果关注就取关(删除数据库数据),没关注就关注(新增一条数据)
共同关注:
这个接口黑马点评的老师是写入了redis中,但是我感觉这个写道redis中不是很好,我就自己实现了一下,总体的思路就是:
我们先分析一下这个共同关注,比如A关注了B,C也关注了B,这个时候,A如果点到C的主页,点击查看共同关注的按钮,就会看到B。
这个其实整体的逻辑还蛮绕,那个时候我还想了一会
代码:
@Override
public Result followCommon(Long id) {
//整体的思路就是先查出当前用户的关注列表,再查出和你另一个用户的关注列表,最后过滤取交集即可
//查询当前用户的关注列表
final Long userId = UserHolder.getUser().getId();
final List<Long> usersFollowList = this
.list(new QueryWrapper<Follow>()
.eq("user_id", userId))
.stream().map(Follow::getFollowUserId).toList();
//查询目标用户的关注列表
final List<Long> targetFollowList = this
.list(new QueryWrapper<Follow>()
.eq("user_id", id)).stream()
.map(Follow::getFollowUserId).toList();
//求交集 -> 共同关注的列表
final List<Long> resultList = usersFollowList.stream().filter(targetFollowList::contains).toList();
final List<UserDTO> userDTOList = userService
.listByIds(resultList)
.stream().map(user -> BeanUtil.copyProperties(user,UserDTO.class)).toList();
return Result.ok(userDTOList);
}
整体的代码逻辑就是:
先查出当前用户(A)的所有关注用户,再查出当前用户在查看哪一个用户(C)的所有关注用户
两个列表取交集,用filter过滤
这里的filter(targetFollowList::contains)就是:括号中可以写成:
item ->{
return targetFollowList.contains(item);
}
这样就可以简化成上面的代码
关注用户的笔记推送:
这里的笔记推送采用的是Feed流的 Timeline中的推模式
就是比如A关注了B,C也关注了B,B发了一篇文章,那么A和C都会收到,也就是B的所有粉丝都会收到
这个的代码逻辑就是在保持笔记的时候将消息推送到每个用户在redis中的 ”收件箱“,
这里的概念就是每个用户在redis中的一个KV,上面的存储笔记是每个笔记的KV
@Override
public Result saveBlog(Blog blog) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店博文
this.save(blog);
// 获取当前关注当前用户的用户id
QueryWrapper<Follow> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("follow_user_id", user.getId());
final List<Follow> followList = followService.list(queryWrapper);
for (Follow follow : followList) {
// 推送博文的id到粉丝的Feeds
redisTemplate.opsForZSet()
.add(FEED_KEY + follow.getUserId(), blog.getId().toString(), System.currentTimeMillis());
}
return Result.ok();
}
实现分页查询邮箱:
这个接口的实现就有点难了,就是上面有些过Set<String> top5 = redisTemplate.opsForZSet().range(key, 0, 4),这一段也是返回ZSet中的元素,默认是升序的
我们的点赞的Top5就默认是升序的
但是我们这里查询邮箱,我们回想我们接收别人的消息,都是早发出来的笔记在下面,刚发的在上面,所以这里还用:ZREVRANGEBYSCORE key lastId 0 WITHSCORES LIMIT offset 2
这个Redis中操作ZSet的命令
参数解释
ZREVRANGEBYSCORE
:这是 Redis 命令,用于按分数降序返回有序集合中的成员。key
:这是你在 Java 代码中指定的有序集合的键。lastId
和0
:这两个参数指定了分数范围。在这个命令中,lastId
是上限,0
是下限。注意,Redis 中的ZREVRANGEBYSCORE
命令的参数顺序是从高到低。WITHSCORES
:这个选项表示返回结果时包含每个成员的分数。LIMIT offset 2
:这个选项用于限制返回的结果数量,从指定的offset
开始,最多返回2
个成员。
这里的offset是什么意思呢:
比如说这个ZSet集合中有m1 1,m2 2,m3 3,m4,4
我们第一次查,就查出m3和m4,我们下一次查的时候,我们希望查出m1和m2,我们的这个lastId需要传什么呢?
根据上面的参数解释,我们需要传m3的分数,但是,如果你不知道offset,就会查出m3和m2,所以需要调整一下这个偏移量,设置为1,就可以查出m1和m2
还有一个注意点:
就是比如m1 1,m2 2,m3 3,m4,3
在这个集合中有两个分数相同的用户(我们往这个ZSet集合中存的分数是时间戳,大概率不会重复,不过为了保证这个代码的健壮性,还是需要考虑的)
分数相同的用户按照我们上面的逻辑,第一次查出3和4
第二次查就算我们按照之前的逻辑将这个偏移量设置成1,我们会查出0~3范围的成员,那这个成员是什么是m4,m3,m2,m1,然后我们的offset设置成1,那就会怎么样
就会从m3开始返回结果,那这个结果就重复了
我们的解决办法就是计算有多少个和这个上一次查询的结果最小的分数相等的个数,比如这个案例最小的值是3,然后算出3有2个,那这个offset就设置成这个2,这样就可以了
经过上面分析之后就可以看代码了:
@Override
public Result queryBlogOfFollow(Long lastId, Integer offset) {
//1:获取当前用户 为了 获取对应用户的id
final Long userId = UserHolder.getUser().getId();
//2:获取对应用户的ZSet
final String key = FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples =
redisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, lastId, offset, 2);
if (CollUtil.isEmpty(typedTuples)) {
return Result.ok(Collections.emptyList());
}
//3: 解析出blogId,当前最小的score(时间戳)和 offset
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0;
int count = 1;//计算offset
int i = 1;
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
ids.add(Long.valueOf(Objects.requireNonNull(tuple.getValue())));
long time = Objects.requireNonNull(tuple.getScore()).longValue();
if(time == minTime){
count++;
}else{
minTime = time;
count = 1;
}
}
count = minTime == lastId ? count : count + offset;
//4:根据ids集合查找对应的blog列表
final String join = StrUtil.join(",", ids);
final List<Blog> blogList = this.query().in("id", ids).last("ORDER BY FIELD(id," + join + ")").list();
//4.1 查询blog对应的作者
//4.2 查询blog是否被点赞了
blogList.forEach(blog -> {
final Long blogUserId = blog.getUserId();
User user = userService.getById(blogUserId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
isBlogLike(blog);
});
//5:封装结果返回
ScrollResult result = new ScrollResult();
result.setList(blogList);
result.setOffset(count);
System.out.println(minTime);
result.setMinTime(minTime);
return Result.ok(result);
}
整体的代码逻辑可以看注释,这里说一个redisTemplate.opsForZSet().reverseRangeByScoreWithScores的返回值是一个Set<ZSetOperations.TypedTuple<String>>,之前的range方法的返回值就是一个Set<String>,为什么这个这么复杂
我们直接点进去
这个就是TypedTuple里面的东西,一个value就是member,还有一个score就是分数,因为我们在这边查的时候是带分数了,所以应该是封装了一个类进行返回,看着复杂,其实吧最后的值取出来就行了。
然后这个也涉及到了之前说的,直接用listByIds会乱序的问题,解决办法和上面是一样的
接着封装这个blogList
最后还要把这个最小的score和offset传给前端,前端下次发请求的时候才能带上。
GEO搜索附件商户:
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据
具体的业务需求:
当我们点击美食之后,会出现一系列的商家,商家中可以按照多种排序方式,我们此时关注的是距离,这个地方就需要使用到我们的GEO,向后台传入当前app收集的地址(我们此处是写死的) ,以当前坐标作为圆心,同时绑定相同的店家类型type,以及分页信息,把这几个条件传入后台,后台查询出对应的数据再返回。
我们要做的事情是:
将数据库表中的数据导入到redis中去,redis中的GEO,GEO在redis中就一个menber和一个经纬度,我们把x和y轴传入到redis做的经纬度位置去,但我们不能把所有的数据都放入到menber中去,毕竟作为redis是一个内存级数据库,如果存海量数据,redis还是力不从心,所以我们在这个地方存储他的id即可。
但是这个时候还有一个问题,就是在redis中并没有存储type,所以我们无法根据type来对数据进行筛选,所以我们可以按照商户类型做分组,类型相同的商户作为同一组,以typeId为key存入同一个GEO集合中即可
导入数据的代码:
@SpringBootTest
public class LoadDataShop {
@Resource
private IShopService shopService;
@Resource
private RedisTemplate redisTemplate;
@Test
public void loadData() {
//1:查询所以商铺信息
final List<Shop> shopList = shopService.list();
//2:根据商品的类型进行分组
Map<Long, List<Shop>> collect = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));
//3:将分组后的数据写入到redis中
for (Map.Entry<Long, List<Shop>> entry : collect.entrySet()) {
//3.1:获取map的key
final Long typeId = entry.getKey();
//3.2:获取map的value
final List<Shop> shops = entry.getValue();
//3.3:将key和value写入GEO
shops.forEach(shop -> {
final Double x = shop.getX();
final Double y = shop.getY();
redisTemplate.opsForGeo()
.add(SHOP_GEO_KEY + typeId,
new Point(x, y),
shop.getId().toString());
});
}
}
}
这里还需要导个maven坐标:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<artifactId>spring-data-redis</artifactId>
<groupId>org.springframework.data</groupId>
</exclusion>
<exclusion>
<artifactId>lettuce-core</artifactId>
<groupId>io.lettuce</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.6.2</version>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.1.6.RELEASE</version>
</dependency>
Service代码:
贴一个GEO的redis代码:GEORADIUS key x y 5000 WITHDISTANCE COUNT end
这个命令将返回在指定半径内的地理位置,并包含每个位置与中心点的距离,同时限制返回的结果数量。
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
//1:是否要根据经纬度进行搜索,如果不是,就走数据库搜索
if(x==null||y==null){
// 根据类型分页查询
Page<Shop> page = this.query()
.eq("type_id", typeId)
.page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
// 返回数据
return Result.ok(page.getRecords());
}
//2:计算分页参数
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
//3:从redis中查询店铺数据
String key = SHOP_GEO_KEY + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo() // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE
.search(
key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
);
if(results==null){
return Result.ok(Collections.emptyList());
}
//4:解析查询结果
final List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
if (list.size() <= from) {
// 没有下一页了,结束
return Result.ok(Collections.emptyList());
}
List<Long> shopIds = new ArrayList<>(list.size());
Map<Long,Double> shopIdDistancesMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(result -> {
final String member = result.getContent().getName();
final Long shopId = Long.valueOf(member);
shopIds.add(shopId);
final Double distance = result.getDistance().getValue();
shopIdDistancesMap.put(shopId,distance);
});
//5:根据id查询Shop
final List<Shop> shopList = this.query().in("id", shopIds)
.last("ORDER BY FIELD(id," + CharSequenceUtil.join(",", shopIds) + ")").list();
final List<Shop> shops = shopList.stream().map(shop -> {
return shop.setDistance(shopIdDistancesMap.get(shop.getId()));
}).toList();
return Result.ok(shops);
}
整体的代码逻辑:
首先先判断前端是否有传xy,没传说明是普通的数据查询,直接走数据库即可
如果有传xy
就开始处理分页参数,
开始,当前的页码-1×每页的数量
结束,当前的页码×每页的数量
接着从redis中查询店铺数据:
查完之后的返回值也很吓人GeoResults,我们直接点进去
这个GeoResults里面还有一个GeoResult,我们再点进去
这里的这个content包括商铺的id和xy坐标(我们存什么这里就会有什么),然后这个距离
随后我们就需要对这个返回值进行解析:
这里我们还需要一个shopIdDistancesMap,用来记录商品的id和对应商品的距离的映射
最后根据id查询商品的时候把答案拼进去就好了,这里商品有一个冗余字段是distance就是为了用来拼这个距离
用户签到:
我们可以采用类似这样的方案来实现我们的签到需求。
我们按月来统计用户签到信息,签到记录为1,未签到则记录为0.
把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)。这样我们就用极小的空间,来实现了大量数据的表示
Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是 2^32个bit位。
其实这个很好理解:
一个月最多31天,然后我来一个这个长度为32的(为什么是32呢?在 Redis 中,BitMap 的存储确实是以字节为单位进行的,32是八的倍数,所以会开到32)字符串,每一天如果签到了就设置成1,没签到就不需要去操作,因为默认也是0.
代码逻辑:
@Override
public Result userSign() {
//1:获取当前用户
final Long userId = UserHolder.getUser().getId();
//2:获取当前时间
LocalDateTime now = LocalDateTime.now();
final String format = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
//3:拼接key
final String key = USER_SIGN_KEY + ":" + userId + format;
//4:计算今天是本月的多少天
final long dayOfMonth = now.getDayOfMonth();
//5:写入redis的bitMap
redisTemplate.opsForValue().setBit(key, (dayOfMonth - 1), true);
return Result.ok();
}
用户签到统计:
这个签到统计就是计算连续签到了多少天:
什么叫做连续签到天数? 从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。
这里的思路是什么呢?
将这个月的bitMap取出之后,注意这里是一个list,因为bitMap返回的时候允许返回多个值
但是我们这个项目就只需要一个值,所以我们待会get(0)就好了
下一个问题就来了,这个返回值是一个Long,我们知道这个bitMap就是一串的01字符串,比如1110,但是返回的时候会转化成10进制。
那我们如果去知道用户连续签到了多少天呢?
比如有一段:1111000111
然后最后一位1,表示用户今天签到了,那我们要知道连续签到了多少天,就要往回数有多少个1,计数,碰到0就结束。
这里就需要涉及到一个位运算了,将这个1111000111的低位与1进行与运算,如果是1,count++
并且呢,这个1111000111向低位移一位就变成111100011,然后再与1进行与运算即可
讲完了代码逻辑看代码:
@Override
public Result signCount() {
//1:获取当前用户
final Long userId = UserHolder.getUser().getId();
//2:获取当前时间
LocalDateTime now = LocalDateTime.now();
final String format = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
//3:拼接key
final String key = USER_SIGN_KEY + ":" + userId + format;
//4:计算今天是本月的多少天
final long dayOfMonth = now.getDayOfMonth();
//5:获取本月截止今天为止的bitMap
final List bitField = redisTemplate.opsForValue()
.bitField(key,
BitFieldSubCommands.create().
get(BitFieldSubCommands.BitFieldType
.unsigned((int) dayOfMonth)).valueAt(0));
if (Optional.ofNullable(bitField).map(Collection::isEmpty).orElse(true)) {
return Result.ok(0);
}
Long num = (Long) bitField.get(0);
if (Optional.ofNullable(num).map(n -> n == 0).orElse(true)) {
return Result.ok(0);
}
//6:统计bitMap中1的个数
int count = 0;
while (true) {
//判断最低位是否为0,为0就说明没签到,就可以返回现在的count。
if ((num & 1) == 0) {
break;
}else {
count++;
}
//将num的高位往右移一位
num >>>= 1;
}
return Result.ok(count);
}
UV统计:
首先我们搞懂两个概念:
-
UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
-
PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
通常来说UV会比PV大很多,所以衡量同一个网站的访问量,我们需要综合考虑很多因素,所以我们只是单纯的把这两个值作为一个参考值
UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖,那怎么处理呢?
Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:https://juejin.cn/post/6844903785744056333#heading-0 Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
UV统计-测试百万数据的统计
测试思路:我们直接利用单元测试,向HyperLogLog中添加100万条数据,看看内存占用和统计效果如何
@SpringBootTest
public class HLLTest {
@Resource
private RedisTemplate redisTemplate;
@Test
public void test(){
String[] strs = new String[1000];
for (int i = 0; i < 1000000; i++) {
strs[i%1000] = "user"+i;
if(i%1000==999){
redisTemplate.opsForHyperLogLog().add("hl1",strs);
}
}
System.out.println(redisTemplate.opsForHyperLogLog().size("hl1"));
}
//2915496
//2979256
}
我们往里插了1000000,这里显示1001788,误差不大
这个方法没有用在这个项目中先记录一下