达人探店

由于学习的是Redis,所以查看博客和发布博客的功能实现我就不多赘述了,只记录关于Redis方面的内容

使用Redis改进点赞功能

现有的点赞功能没有判断用户,直接操作数据库,导致一个用户可以多次点赞同一篇Blog

1
2
3
4
5
6
7
@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
// 修改点赞数量
blogService.update()
.setSql("liked = liked + 1").eq("id", id).update();
return Result.ok();
}

代码修改:

给Blog新建一个isLike属性,使用@TableField(exist = false)注解说明该属性不存在于数据库中

1
2
3
4
5
6
@TableField(exist = false)
private Boolean isLike; /**
* 是否点赞过了
*/
@TableField(exist = false)
private Boolean isLike;

判断文章是否已经被当前用户点赞stringRedisTemplate.opsForSet().isMember()来查询Set集合中是否有当前用户

1
2
3
4
5
private void isBlogLiked(Blog blog) {
Long userId = UserHolder.getUser().getId();
Boolean isMember = stringRedisTemplate.opsForSet().isMember(RedisConstants.BLOG_LIKED_KEY + blog.getId(), userId.toString());
blog.setIsLike(Boolean.TRUE.equals(isMember));
}

点赞业务逻辑代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public Result isLike(Long id) {
// 1.判断当前用户是否已经点赞
Long userId = UserHolder.getUser().getId();
Boolean isMember = stringRedisTemplate.opsForSet().isMember(RedisConstants.BLOG_LIKED_KEY + id, userId.toString());
// 2.当前用户未点赞
if(Boolean.FALSE.equals(isMember)){
// 2.1数据库点赞数+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
// 2.2保存用户到Redis的Set集合中
if(isSuccess) {
stringRedisTemplate.opsForSet().add(RedisConstants.BLOG_LIKED_KEY + id, userId.toString());
}
}else {
// 3.当前用户已点赞
// 3.1数据库点赞数-1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
// 3.2把用户从Redis的Set集合中删除
if (isSuccess) {
stringRedisTemplate.opsForSet().remove(RedisConstants.BLOG_LIKED_KEY + id, userId.toString());
}
}
return Result.ok();
}

点赞排行榜

在前面我们把点赞的用户放在了Set集合中,但是我们现在需要显示点赞前五的用户,我们都知道Set集合是无序的

1
  • List值不唯一,排除
  • Set无序,排除
  • ZSet满足要求,且底层基于哈希表能快速判断是否存在,查找也更加高效

复习一下ZSet的命令

  • ZSCORE key member : 获取sorted set中的指定元素的score值
  • ZRANGE key min max:按照score排序后,获取指定排名范围内的元素

点赞逻辑代码,修改为使用ZSet集合,根据score查询,score值为当前的时间戳System.currentTimeMillis()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public Result isLike(Long id) {
// 1.判断当前用户是否已经点赞
Long userId = UserHolder.getUser().getId();
Double score = stringRedisTemplate.opsForZSet().score(RedisConstants.BLOG_LIKED_KEY + id, userId.toString());
// 2.当前用户未点赞
if(score == null){
// 2.1数据库点赞数+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
// 2.2保存用户到Redis的ZSet集合中
if(isSuccess) {
stringRedisTemplate.opsForZSet().add(RedisConstants.BLOG_LIKED_KEY + id, userId.toString(),System.currentTimeMillis());
}
}else {
// 3.当前用户已点赞
// 3.1数据库点赞数-1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
// 3.2把用户从Redis的ZSet集合中删除
if (isSuccess) {
stringRedisTemplate.opsForZSet().remove(RedisConstants.BLOG_LIKED_KEY + id, userId.toString());
}
}
return Result.ok();
}

点赞查询列表代码

  • 当返回的数据想要有序时,不能直接使用MybatisPlus中的listByIds()方法,底层用了in无法保证查到结果的顺序
  • Order By Field()来控制数据库按集合中对应顺序查询
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public Result queryBlogLikes(Long id) {
// 1.在Redis中查出前五名,返回的是一个Set集合 zrange key 0 4
Set<String> top5 = stringRedisTemplate.opsForZSet().range(RedisConstants.BLOG_LIKED_KEY + id, 0, 4);
if(top5 == null || top5.isEmpty()){
return Result.ok(Collections.emptyList());
}
// 2.从Set集合中拿出用户ID集合
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
String idStr = StrUtil.join(",", ids);
// 3.根据用户从数据库中查询用户信息 WHERE id IN ( 5 , 1 ) ORDER BY FIELD(id, 5, 1)
List<UserDTO> userDTOList = userService.query().in("id",ids).last("ORDER BY FIELD(id," + idStr +")").list()
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(userDTOList);
}

好友关注

这章主要实现用户之间的关注功能

关注和取关

这个业务逻辑特别简单,直接上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 关注或取关
* @param followUserId 被关注者id
* @param isFollow 关注/取关
* @return
*/
@Override
public Result follow(Long followUserId, Boolean isFollow) {
Long userId = UserHolder.getUser().getId();
if(Boolean.TRUE.equals(isFollow)){
Follow follow = new Follow()
.setUserId(userId)
.setFollowUserId(followUserId)
.setCreateTime(LocalDateTime.now());
save(follow);
}else{
remove(new QueryWrapper<Follow>()
.eq("user_id",userId).eq("follow_user_id",followUserId));
}
return Result.ok();
}

/**
* 查询该用户是否被关注
* @param id 用户id
* @return true已关注,false未关注
*/
@Override
public Result isFollow(Long id) {
Long userId = UserHolder.getUser().getId();
Integer count = query().eq("user_id", userId).eq("follow_user_id", id).count();
return Result.ok(count>0);
}

共同关注

要求共同关注,我们可以把用户的关注列表存入Set集合中,使用Set来求交集获得共同关注列表

  • SINTER key1 key2 … :求key1与key2的交集

改造关注代码,将关注人和被关注人存入Redis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public Result follow(Long followUserId, Boolean isFollow) {
Long userId = UserHolder.getUser().getId();
if(Boolean.TRUE.equals(isFollow)){
Follow follow = new Follow()
.setUserId(userId)
.setFollowUserId(followUserId)
.setCreateTime(LocalDateTime.now());
boolean success = save(follow);
if(success){
stringRedisTemplate.opsForSet().add(RedisConstants.FOLLOW + userId,followUserId.toString());
}
}else{
boolean success = remove(new QueryWrapper<Follow>()
.eq("user_id", userId).eq("follow_user_id", followUserId));
if(success){
stringRedisTemplate.opsForSet().remove(RedisConstants.FOLLOW + userId,followUserId.toString());
}
}
return Result.ok();
}

查询共同关注(两个Set集合交集)代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public Result followCommons(Long id) {
Long userId = UserHolder.getUser().getId();
Set<String> intersect = stringRedisTemplate.opsForSet()
.intersect(RedisConstants.FOLLOW + userId, RedisConstants.FOLLOW + id);
if(intersect == null || intersect.isEmpty()){
return Result.ok(Collections.emptyList());
}
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
List<UserDTO> users = userService.listByIds(ids)
.stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(users);
}

关注推送-Feed流方案

当我们关注了用户后,这个用户发了动态,那么我们应该把这些数据推送给用户,这个需求,其实我们又把他叫做Feed流,关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。

对于传统的模式的内容解锁:我们是需要用户去通过搜索引擎或者是其他的方式去解锁想要看的内容

2

对于新型的Feed流的的效果:不需要我们用户再去推送信息,而是系统分析用户到底想要什么,然后直接把内容推送给用户,从而使用户能够更加的节约时间,不用主动去寻找。

3

Feed流的实现有两种模式:

Feed流产品有两种常见模式:
Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈

  • 优点:信息全面,不会有缺失。并且实现也相对简单
  • 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低

智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户

  • 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
  • 缺点:如果算法不精准,可能起到反作用
    本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:

我们本次针对好友的操作,采用的就是Timeline的方式,只需要拿到我们关注用户的信息,然后按照时间排序即可

,因此采用Timeline的模式。该模式的实现方案有三种:

  • 拉模式
  • 推模式
  • 推拉结合

拉模式:也叫做读扩散

该模式的核心含义就是:当张三和李四和王五发了消息后,都会保存在自己的邮箱中,假设赵六要读取信息,那么他会从读取他自己的收件箱,此时系统会从他关注的人群中,把他关注人的信息全部都进行拉取,然后在进行排序

优点:比较节约空间,因为赵六在读信息时,并没有重复读取,而且读取完之后可以把他的收件箱进行清楚。

缺点:比较延迟,当用户读取数据时才去关注的人里边去读取数据,假设用户关注了大量的用户,那么此时就会拉取海量的内容,对服务器压力巨大。

4

推模式:也叫做写扩散。

推模式是没有写邮箱的,当张三写了一个内容,此时会主动的把张三写的内容发送到他的粉丝收件箱中去,假设此时李四再来读取,就不用再去临时拉取了

优点:时效快,不用临时拉取

缺点:内存压力大,假设一个大V写信息,很多人关注他, 就会写很多分数据到粉丝那边去

5

推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点。

推拉模式是一个折中的方案,站在发件人这一段,如果是个普通的人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝中去,因为普通的人他的粉丝关注量比较小,所以这样做没有压力,如果是大V,那么他是直接将数据先写入到一份到发件箱里边去,然后再直接写一份到活跃粉丝收件箱里边去,现在站在收件人这端来看,如果是活跃粉丝,那么大V和普通的人发的都会直接写入到自己收件箱里边来,而如果是普通的粉丝,由于他们上线不是很频繁,所以等他们上线时,再从发件箱里边去拉信息。

6

关注推送-Feed推模式实现

需求:

  • 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
  • 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
  • 查询收件箱数据时,可以实现分页查询

Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。

传统了分页在feed流是不适用的,因为我们的数据会随时发生变化:

假设在t1 时刻,我们去读取第一页,此时page = 1 ,size = 5 ,那么我们拿到的就是106 这几条记录,假设现在t2时候又发布了一条记录,此时t3 时刻,我们来读取第二页,读取第二页传入的参数是page=2 ,size=5 ,那么此时读取到的第二页实际上是从6 开始,然后是62 ,那么我们就读取到了重复的数据,所以feed流的分页,不能采用原始方案来做。

1653813047671

Feed流的滚动分页:

我们需要记录每次操作的最后一条,然后从这个位置开始去读取数据

举个例子:我们从t1时刻开始,拿第一页数据,拿到了10~6,然后记录下当前最后一次拿取的记录,就是6,t2时刻发布了新的记录,此时这个11放到最顶上,但是不会影响我们之前记录的6,此时t3时刻来拿第二页,第二页这个时候拿数据,还是从6后一点的5去拿,就拿到了5-1的记录。

我们这个地方可以采用sortedSet来做,可以进行范围查询,并且还可以记录当前获取数据时间戳最小值,就可以实现滚动分页了

1653813462834

核心的意思:就是我们在保存完探店笔记后,获得到当前笔记的粉丝,然后把数据推送到粉丝的redis中去。

修改保存博客的代码,从原先的直接写入数据库改为写入数据库后再写入Redis中Set集合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public Result saveBlog(Blog blog) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店博文
boolean isSuccess = save(blog);
if (!isSuccess) {
return Result.fail("博客发布失败");
}else{
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
for (Follow follow : follows) {
Long userId = follow.getUserId();
stringRedisTemplate.opsForZSet().add(RedisConstants.FEED_KEY + userId,blog.getId().toString(),System.currentTimeMillis());
}
}
return Result.ok();
}

9

关注推送-实现分页查询推送

这个要实现的效果就是每次查询几条Blogs,只有用户向下滑动才查询接下来的几条Blogs

具体操作如下:

1、每次查询完成后,我们要分析出查询出数据的最小时间戳,这个值会作为下一次查询的条件

2、我们需要找到与上一次查询相同的查询个数作为偏移量,下次查询时,跳过这些查询过的数据,拿到我们需要的数据

综上:我们的请求参数中就需要携带 lastId:上一次查询的最小时间戳 和偏移量这两个参数。

这两个参数第一次会由前端来指定,以后的查询就根据后台结果作为条件,再次传递到后台。

为什么要判断重复的次数来确定offset:

因为他是根据这个<=score来查询,如果offset指定为1的话,那么只会排除=score的第一个值,而其他相同的值还是算了进去,就会重复出现把已经出现过的数据一样算了进来传给前端。

  • 定义出来具体的返回值实体类
1
2
3
4
5
6
@Data
public class ScrollResult {
private List<?> list;
private Long minTime;
private Integer offset;
}
  • BlogController

注意:RequestParam 表示接受url地址栏传参的注解,当方法上参数的名称和url地址栏不相同时,可以通过RequestParam 来进行指定

1
2
3
4
5
@GetMapping("/of/follow")
public Result queryBlogOfFollow(
@RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset){
return blogService.queryBlogOfFollow(max, offset);
}
  • BlogServiceImpl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1.获取当前用户
Long userId = UserHolder.getUser().getId();
// 2.查询收件箱 ZREVRANGEBYSCORE key Max Min LIMIT offset count
String key = FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
// 3.非空判断
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok();
}
// 4.解析数据:blogId、minTime(时间戳)、offset
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0; // 上次查询最小的时间戳
int os = 1; // 记录出现了几次重复的
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
// 4.1.获取id
ids.add(Long.valueOf(tuple.getValue()));
// 4.2.获取分数(时间戳)
long time = tuple.getScore().longValue();
if(time == minTime){
os++;//重复则++
}else{
minTime = time;
os = 1;//不重复归位
}
}
// 5.根据id查询blog
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();

for (Blog blog : blogs) {
// 5.1.查询blog有关的用户
queryBlogUser(blog);
// 5.2.查询blog是否被点赞
isBlogLiked(blog);
}

// 6.封装并返回
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os);
r.setMinTime(minTime);

return Result.ok(r);
}

附近商户(GEO)

Redis在3.2版本中加入了对GEO(Geolocation)的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。

GEO数据结构用法

  • GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
  • GEODIST:计算指定的两个点之间的距离并返回
  • GEOHASH:将指定member的坐标转为hash字符串形式并返回
  • GEOPOS:返回指定member的坐标
  • GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
  • GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。 6.2.新功能

导入店铺数据到Redis

我们需要手动将地理坐标按店铺类型存入Redis中方便查询附近商户,信息只存x,y,shopId即可

创建单元测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Test
void GeosImport() {
// 1.查询店铺信息
List<Shop> list = service.list();
// 2.把店铺分组,按照typeId分组,typeId一致的放到一个集合
Map<Long,List<Shop>> listMap = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
// 3.分批完成写入Redis
for (Map.Entry<Long,List<Shop>> entry : listMap.entrySet()) {
// 3.1.获取类型id
Long type = entry.getKey();
String key = RedisConstants.SHOP_GEO_KEY + type;
// 3.2.获取同类型的店铺的集合
List<Shop> value = entry.getValue();
// stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());这个是直接添加多次Redis,改进后通过GeoLocation只操作一次
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
// 3.3.写入redis GEOADD key 经度 纬度 member
for (Shop shop : value) {
locations.add(new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(),shop.getY())
));
}
stringRedisTemplate.opsForGeo().add(key,locations);
}
}

通过创建GeoLocation可以实现只操作一次Redis,让Redis根据GeoLocation里面元素添加,而不是每循环一个操作一次Redis

1
2
3
4
5
6
7
@Data
@RequiredArgsConstructor
class GeoLocation<T> {

private final T name;
private final Point point;
}
  • 外层循环解释for (Map.Entry<Long,List<Shop>> entry : listMap.entrySet()):

-在代码中,listMap 是一个 Map<Long, List<Shop>> 类型的对象,这意味着它是一个键为 Long 类型,值为 List<Shop> 类型的哈希表(也就是键值对集合)。这里每一个键(Long)对应一个 typeId(店铺类型),而每一个值(List<Shop>)对应该类型的所有店铺。

使用 listMap.entrySet() 方法时,它会返回这个哈希表中所有键值对的集合,每个键值对用一个 Map.Entry 对象表示。

具体来说,Map.Entry<Long, List<Shop>> entry 代表哈希表中的一个具体的键值对。entry.getKey() 会返回这个键值对的键(即店铺类型 typeId),entry.getValue() 会返回这个键值对的值(即该类型的所有店铺的列表)。

for (Map.Entry<Long, List<Shop>> entry : listMap.entrySet()) 这段代码的意思是遍历 listMap 中的每一个键值对,并将每一个键值对分别赋值给 entry 变量。这样你就可以在循环体内使用 entry.getKey() 获取店铺类型,使用 entry.getValue() 获取该类型的店铺列表。

到Redis中查看,添加成功

10

实现附近商户功能

实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 1.判断是否需要根据坐标查询
if(x ==null || y == null){
// 根据类型分页查询
Page<Shop> page = 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、按照距离排序、分页。结果:shopId、distance
String key = RedisConstants.SHOP_GEO_KEY + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
.search(key, GeoReference.fromCoordinate(x, y), new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end));
if (results == null) {
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
if (list.size() <= from) {
// 没有下一页了,结束
return Result.ok(Collections.emptyList());
}
// 4.1.截取 from ~ end的部分
// 记录id的List集合
List<Long> ids = new ArrayList<>(list.size());
// 记录id及其对应的距离的Map集合
Map<String,Distance> distanceMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(result -> {
// 4.2.获取店铺id
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
// 4.3.获取距离
Distance distance = result.getDistance();
distanceMap.put(shopIdStr,distance);
});
// 5.根据id查询Shop
String idStr = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
for (Shop shop : shops) {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
// 6.返回
return Result.ok(shops);
}

用户签到(BitMap)

我们按月来统计用户签到信息,签到记录为1,未签到则记录为0.

把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)。这样我们就用极小的空间,来实现了大量数据的表示

Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是 2^32个bit位,而签到31天才需要31bit

11

BitMap的操作命令有:

  • SETBIT:向指定位置(offset)存入一个0或1
  • GETBIT :获取指定位置(offset)的bit值
  • BITCOUNT :统计BitMap中值为1的bit位的数量
  • BITFIELD :操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
  • BITFIELD_RO :获取BitMap中bit数组,并以十进制形式返回
  • BITOP :将多个BitMap的结果做位运算(与 、或、异或)
  • BITPOS :查找bit数组中指定范围内第一个0或1出现的位置

实现签到功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public Result sign() {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
// 3.拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;
// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5.写入Redis SETBIT key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}

实现连续登录日期统计

问题1:什么叫做连续签到天数?
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。

1653834455899

Java逻辑代码:获得当前这个月的最后一次签到数据,定义一个计数器,然后不停的向前统计,直到获得第一个非0的数字即可,每得到一个非0的数字计数器+1,直到遍历完所有的数据,就可以获得当前月的签到总天数了

问题2:如何得到本月到今天为止的所有签到数据?

BITFIELD key GET u[dayOfMonth] 0

假设今天是10号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是10号,那么就是10位,去拿这段时间的数据,就能拿到所有的数据了,那么这10天里边签到了多少次呢?统计有多少个1即可。

问题3:如何从后向前遍历每个bit位?

注意:bitMap返回的数据是10进制,哪假如说返回一个数字8,那么我哪儿知道到底哪些是0,哪些是1呢?我们只需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。

需求:实现下面接口,统计当前用户截止当前时间在本月的连续签到天数

有用户有时间我们就可以组织出对应的key,此时就能找到这个用户截止这天的所有签到记录,再根据这套算法,就能统计出来他连续签到的次数了

12

UserController

1
2
3
4
@GetMapping("/sign/count")
public Result signCount(){
return userService.signCount();
}

在Java中>>>表示无符号右移,对bit的操作封装在opsForValue()中了,底层是字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Override
public Result signCount() {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
// 3.拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5.获取本月截止今天为止的所有的签到记录,返回的是一个十进制的数字 BITFIELD sign:5:202203 GET u14 0
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
);
if (result == null || result.isEmpty()) {
// 没有任何签到结果
return Result.ok(0);
}
Long num = result.get(0);
if (num == null || num == 0) {
return Result.ok(0);
}
// 6.循环遍历
int count = 0;
while (true) {
// 6.1.让这个数字与1做与运算,得到数字的最后一个bit位 // 判断这个bit位是否为0
if ((num & 1) == 0) {
// 如果为0,说明未签到,结束
break;
}else {
// 如果不为0,说明已签到,计数器+1
count++;
}
// 把数字右移一位,抛弃最后一个bit位,继续下一个bit位
num >>>= 1;
}
return Result.ok(count);
}

UV统计(HyperLogLog)

两个概念:

  • UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
  • PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。

通常来说UV会比PV大很多,所以衡量同一个网站的访问量,我们需要综合考虑很多因素,所以我们只是单纯的把这两个值作为一个参考值

UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖

HyperLogLog数据类型

Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。

相关算法原理大家可以参考:https://juejin.cn/post/6844903785744056333#heading-0

Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。

13

测试百万数据的统计

测试思路:我们直接利用单元测试,向HyperLogLog中添加100万条数据,看看内存占用和统计效果如何

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
void testHyperLogLog() {
// 准备数组, 装用户数据
String[] users = new String[1000];
// 数组角标
int index = 0;
for (int i = 1; i <= 1000000; i++) {
// 赋值
users[index++] = "user_" + i;
// 每1000条发送一次
if (i % 1000 == 0) {
index = 0;
stringRedisTemplate.opsForHyperLogLog().add("hll1", users);
}
}
// 统计数量
Long size = stringRedisTemplate.opsForHyperLogLog().size("hll1");
System.out.println("size = " + size);
}

经过测试:我们会发生他的误差是在允许范围内,100万少400次左右,并且内存占用极小,仅14KB

14

15

完结撒花

到这里Redis实战篇就学完了,感谢坚持的自己!