Redis项目笔记

达人探店

发布探店笔记

这部分代码已经提供好了,我们来看看对应的数据表

  • tb_blog

  • tb_blog_comments

  • 其他用户对探店笔记的评价

Field Type Collation Null Key Default Extra Comment
id bigint unsigned (NULL) NO PRI (NULL) auto_increment 主键
user_id bigint unsigned (NULL) NO (NULL) 用户id
blog_id bigint unsigned (NULL) NO (NULL) 探店id
parent_id bigint unsigned (NULL) NO (NULL) 关联的1级评论id,如果是一级评论,则值为0
answer_id bigint unsigned (NULL) NO (NULL) 回复的评论id
content varchar(255) utf8mb4_general_ci NO (NULL) 回复的内容
liked int unsigned (NULL) YES (NULL) 点赞数
status tinyint unsigned (NULL) YES (NULL) 状态,0:正常,1:被举报,2:禁止查看
create_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED 创建时间
update_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED on update CURRENT_TIMESTAMP

探店店笔记表,包含笔记中的标题、文字、图片等

Field Type Collation Null Key Default Extra Comment
id bigint unsigned (NULL) NO PRI (NULL) auto_increment 主键
shop_id bigint (NULL) NO (NULL) 商户id
user_id bigint unsigned (NULL) NO (NULL) 用户id
title varchar(255) utf8mb4_unicode_ci NO (NULL) 标题
images varchar(2048) utf8mb4_general_ci NO (NULL) 探店的照片,最多9张,多张以","隔开
content varchar(2048) utf8mb4_unicode_ci NO (NULL) 探店的文字描述
liked int unsigned (NULL) YES 0 点赞数量
comments int unsigned (NULL) YES (NULL) 评论数量
create_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED 创建时间
update_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED on update CURRENT_TIMESTAM
  • 对应的实体类,数据表中并没有用户头像和用户昵称,但是对应的实体类里却有,这是因为使用了@TableField(exist = false) 用来解决实体类中有的属性但是数据表中没有的字段
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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_blog")
public class Blog implements Serializable {

private static final long serialVersionUID = 1L;

/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 商户id
*/
private Long shopId;
/**
* 用户id
*/
private Long userId;
/**
* 用户图标
*/
@TableField(exist = false)
private String icon;
/**
* 用户姓名
*/
@TableField(exist = false)
private String name;
/**
* 是否点赞过了
*/
@TableField(exist = false)
private Boolean isLike;

/**
* 标题
*/
private String title;

/**
* 探店的照片,最多9张,多张以","隔开
*/
private String images;

/**
* 探店的文字描述
*/
private String content;

/**
* 点赞数量
*/
private Integer liked;

/**
* 评论数量
*/
private Integer comments;

/**
* 创建时间
*/
private LocalDateTime createTime;

/**
* 更新时间
*/
private LocalDateTime updateTime;
}
1
2
3
4
5
6
7
8
9
10
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店博文
blogService.save(blog);
// 返回id
return Result.ok(blog.getId());
}
  • 上传图片的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@PostMapping("blog")
public Result uploadImage(@RequestParam("file") MultipartFile image) {
try {
// 获取原始文件名称
String originalFilename = image.getOriginalFilename();
// 生成新文件名
String fileName = createNewFileName(originalFilename);
// 保存文件
image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
// 返回结果
log.debug("文件上传成功,{}", fileName);
return Result.ok(fileName);
} catch (IOException e) {
throw new RuntimeException("文件上传失败", e);
}
}

注意:这里我们需要修改SystemConstants.IMAGE_UPLOAD_DIR 为自己图片所在的地址,在实际开发中图片一般会放在nginx上或者是云存储上。

查看探店笔记

  • 需求:点击首页的探店笔记,会进入详情页面,我们现在需要实现页面的查询接口
  • 随便点击一张图片,查看发送的请求

请求网址: http://localhost:8080/api/blog/6
请求方法: GET

  • 看样子是BlogController下的方法,请求方式为GET,那我们直接来编写对应的方法

业务逻辑我们要写在Service层,Controller层只调用

1
2
3
4
5
java
@GetMapping("/{id}")
public Result queryById(@PathVariable Integer id){
return blogService.queryById(id);
}

在Service类中创建对应方法之后,在Impl类中实现,我们查看用户探店笔记的时候,需要额外设置用户名和其头像,由于设置用户信息这个操作比较通用,所以这里封装成了一个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public Result queryById(Integer id) {
Blog blog = getById(id);
if (blog == null) {
return Result.fail("评价不存在或已被删除");
}
queryBlogUser(blog);
return Result.ok(blog);
}

private void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
  • 我们顺手将queryHotBlog也修改一下,原始代码将业务逻辑写到了Controller中,修改后的完整代码如下

BlogController

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
@RestController
@RequestMapping("/blog")
public class BlogController {

@Resource
private IBlogService blogService;

@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店博文
blogService.save(blog);
// 返回id
return Result.ok(blog.getId());
}

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

@GetMapping("/of/me")
public Result queryMyBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
// 根据用户查询
Page<Blog> page = blogService.query()
.eq("user_id", user.getId()).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
return Result.ok(records);
}

@GetMapping("/hot")
public Result queryHotBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
return blogService.queryHotBlog(current);
}

@GetMapping("/{id}")
public Result queryById(@PathVariable Integer id){
return blogService.queryById(id);
}
}
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
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Resource
private IUserService userService;

@Override
public Result queryHotBlog(Integer current) {
// 根据用户查询
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
// 查询用户
records.forEach(this::queryBlogUser);
return Result.ok(records);
}


@Override
public Result queryById(Integer id) {
Blog blog = getById(id);
if (blog == null) {
return Result.fail("评价不存在或已被删除");
}
queryBlogUser(blog);
return Result.ok(blog);
}

private void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
}

点赞功能

  • 点击点赞按钮,查看发送的请求

请求网址: http://localhost:8080/api/blog/like/4
请求方法: PUT

  • 看样子是BlogController中的like方法,源码如下
1
2
3
4
5
6
@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
// 修改点赞数量
blogService.update().setSql("liked = liked + 1").eq("id", id).update();
return Result.ok();
}
  • 问题分析:这种方式会导致一个用户无限点赞,明显是不合理的
  • 造成这个问题的原因是,我们现在的逻辑,发起请求只是给数据库+1,所以才会出现这个问题
  • 需求
    1. 同一个用户只能对同一篇笔记点赞一次,再次点击则取消点赞
    2. 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)
  • 实现步骤
    1. 修改点赞功能,利用Redis中的set集合来判断是否点赞过,未点赞则点赞数+1,已点赞则点赞数-1
    2. 修改根据id查询的业务,判断当前登录用户是否点赞过,赋值给isLike字段
    3. 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
  • 具体实现

业务逻辑写在Service层

1
2
3
4
@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
return blogService.likeBlog(id);
}
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
@Override
public Result likeBlog(Long id) {
//1. 获取当前用户信息
Long userId = UserHolder.getUser().getId();
//2. 如果当前用户未点赞,则点赞数 +1,同时将用户加入set集合
String key = BLOG_LIKED_KEY + id;
Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
if (BooleanUtil.isFalse(isLiked)) {
//点赞数 +1
boolean success = update().setSql("liked = liked + 1").eq("id", id).update();
//将用户加入set集合
if (success) {
stringRedisTemplate.opsForSet().add(key, userId.toString());
}
//3. 如果当前用户已点赞,则取消点赞,将用户从set集合中移除
}else {
//点赞数 -1
boolean success = update().setSql("liked = liked - 1").eq("id", id).update();
if (success){
//从set集合移除
stringRedisTemplate.opsForSet().remove(key, userId.toString());
}
}
return Result.ok();
}
  • 修改完毕之后,页面上还不能立即显示点赞完毕的后果,我们还需要修改查询Blog业务,判断Blog是否被当前用户点赞过
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
@Override
public Result queryHotBlog(Integer current) {
// 根据用户查询
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
// 查询用户
records.forEach(blog -> {
queryBlogUser(blog);
//追加判断blog是否被当前用户点赞,逻辑封装到isBlogLiked方法中
isBlogLiked(blog);
});
return Result.ok(records);
}

@Override
public Result queryById(Integer id) {
Blog blog = getById(id);
if (blog == null) {
return Result.fail("评价不存在或已被删除");
}
queryBlogUser(blog);
//追加判断blog是否被当前用户点赞,逻辑封装到isBlogLiked方法中
isBlogLiked(blog);
return Result.ok(blog);
}

private void isBlogLiked(Blog blog) {
//1. 获取当前用户信息
Long userId = UserHolder.getUser().getId();
//2. 判断当前用户是否点赞
String key = BLOG_LIKED_KEY + blog.getId();
Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
//3. 如果点赞了,则将isLike设置为true
blog.setIsLike(BooleanUtil.isTrue(isMember));
}

点赞排行榜

  • 当我们点击探店笔记详情页面时,应该按点赞顺序展示点赞用户,比如显示最早点赞的TOP5,形成点赞排行榜,就跟QQ空间发的说说一样,可以看到有哪些人点了赞
  • 之前的点赞是放到Set集合中,但是Set集合又不能排序,所以这个时候,我们就可以改用SortedSet(Zset)
  • 那我们这里顺便就来对比一下这些集合的区别
List Set SortedSet
排序方式 按添加顺序排序 无法排序 根据score值排序
唯一性 不唯一 唯一 唯一
查找方式 按索引查找或首尾查找 根据元素查找 根据元素查找
  • 修改BlogServiceImpl
    由于ZSet没有isMember方法,所以这里只能通过查询score来判断集合中是否有该元素,如果有该元素,则返回值是对应的score,如果没有该元素,则返回值为null
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
@Override
public Result likeBlog(Long id) {
//1. 获取当前用户信息
Long userId = UserHolder.getUser().getId();
//2. 如果当前用户未点赞,则点赞数 +1,同时将用户加入set集合
String key = BLOG_LIKED_KEY + id;
//尝试获取score
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
//为null,则表示集合中没有该用户
if (score == null) {
//点赞数 +1
boolean success = update().setSql("liked = liked + 1").eq("id", id).update();
//将用户加入set集合
if (success) {
stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
}
//3. 如果当前用户已点赞,则取消点赞,将用户从set集合中移除
} else {
//点赞数 -1
boolean success = update().setSql("liked = liked - 1").eq("id", id).update();
if (success) {
//从set集合移除
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
}
return Result.ok();
}

Redis的SortedSet(有序集合)默认按照score升序排列(从小到大),但可以通过指定参数实现降序排列。以下是关于SortedSet的关键特性和常用API说明,以及代码的解析:

一、SortedSet 核心特性

  1. 唯一性:成员(member)唯一,score可重复
  2. 排序依据:按score数值排序(默认升序)
  3. 时间复杂度:大多数操作是O(log(N))

二、常用 Redis SortedSet API(Java Spring Data Redis)

在Spring Data Redis中,通过opsForZSet()操作SortedSet:

1. 添加/更新成员

1
2
3
JavaBoolean add(String key, V value, double score);
// 示例:添加用户ID为1001,score=当前时间戳
stringRedisTemplate.opsForZSet().add("liked:blog1", "1001", System.currentTimeMillis());

2. 移除成员

1
2
3
JavaLong remove(String key, Object... values);
// 示例:移除用户ID为1001
stringRedisTemplate.opsForZSet().remove("liked:blog1", "1001");

3. 获取成员score

1
2
3
JavaDouble score(String key, Object member);
// 示例:检查用户1001是否已点赞
Double score = stringRedisTemplate.opsForZSet().score("liked:blog1", "1001");

4. 按score范围查询(升序)

1
2
3
JavaSet<V> range(String key, long start, long end);
// 示例:获取前10个点赞用户(按时间戳升序)
Set<String> users = stringRedisTemplate.opsForZSet().range("liked:blog1", 0, 9);

5. 按score范围查询(降序)

1
2
3
JavaSet<V> reverseRange(String key, long start, long end);
// 示例:获取最新10个点赞用户(按时间戳降序)
Set<String> latestUsers = stringRedisTemplate.opsForZSet().reverseRange("liked:blog1", 0, 9);

6. 获取排名(升序)

1
2
3
JavaLong rank(String key, Object member);
// 示例:用户1001的升序排名(从0开始)
Long rank = stringRedisTemplate.opsForZSet().rank("liked:blog1", "1001");

7. 获取排名(降序)

1
Long reverseRank(String key, Object member);
  1. 统计成员数量
1
2
JavaLong size(String key);  // 或 zCard(String key)
Long count(String key, double min, double max);

三、您的代码解析

您的点赞逻辑是正确的,但需要注意以下几点:

1. SortedSet排序方向

  • 代码中System.currentTimeMillis()作为score,表示点赞时间戳
  • 默认按升序排列(从旧到新),若需要展示最新点赞用户,应使用reverseRange()实现降序

2. 潜在优化点

1
2
3
4
5
Java// 建议添加原子性保护(如分布式锁)
boolean success = update().setSql("liked = liked + 1").eq("id", id).update();
if (success) {
stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
}

3. 数据一致性

  • 数据库与Redis操作非原子性,极端情况可能不一致
  • 解决方案:异步补偿机制或事务(需权衡性能)

四、SortedSet 典型场景

  1. 排行榜(按分数排序)
  2. 延迟队列(score=到期时间戳)
  3. 时间轴(score=发布时间戳)

将时间戳作为score的设计非常适合记录用户点赞时间,并通过reverseRange()快速获取最新点赞列表。

  • 同时修改isBlogLiked方法,在原有逻辑上,判断用户是否已登录,登录状态下才会继续判断用户是否点赞
1
2
3
4
5
6
7
8
9
10
11
12
private void isBlogLiked(Blog blog) {
//1. 获取当前用户信息
UserDTO userDTO = UserHolder.getUser();
//当用户未登录时,就不判断了,直接return结束逻辑
if (userDTO == null) {
return;
}
//2. 判断当前用户是否点赞
String key = BLOG_LIKED_KEY + blog.getId();
Double score = stringRedisTemplate.opsForZSet().score(key, userDTO.getId().toString());
blog.setIsLike(score != null);
}
  • 那我们继续来完善显示点赞列表功能,查看浏览器请求,这个请求目前应该是404的,因为我们还没有写,他需要一个list返回值,显示top5点赞的用户

请求网址: http://localhost:8080/api/blog/likes/4
请求方法: GET

  • 在Controller层中编写对应的方法,点赞查询列表,具体逻辑写到BlogServiceImpl中
1
2
3
4
@GetMapping("/likes/{id}")
public Result queryBlogLikes(@PathVariable Integer id){
return blogService.queryBlogLikes(id);
}
  • 具体逻辑如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
public Result queryBlogLikes(Integer id) {
String key = BLOG_LIKED_KEY + id;
//zrange key 0 4 查询zset中前5个元素
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
//如果是空的(可能没人点赞),直接返回一个空集合
if (top5 == null || top5.isEmpty()) {
return Result.ok(Collections.emptyList());
}
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
//将ids使用`,`拼接,SQL语句查询出来的结果并不是按照我们期望的方式进行排
//所以我们需要用order by field来指定排序方式,期望的排序方式就是按照查询出来的id进行排序
String idsStr = StrUtil.join(",", ids);
//select * from tb_user where id in (ids[0], ids[1] ...) order by field(id, ids[0], ids[1] ...)
List<UserDTO> userDTOS = userService.query().in("id", ids)
.last("order by field(id," + idsStr + ")")
.list().stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(userDTOS);
}

在MySQL中,FIELD() 是一个函数,用于指定自定义排序顺序。当你在SQL中使用 ORDER BY FIELD(id, 5, 3, 1) 时,它会按照 (5, 3, 1) 的顺序对结果集进行排序,而不是按默认的升序或降序排列。这就是为什么需要拼接 idsStr 的原因。

为什么要拼接 idsStr

  1. 默认排序问题: SQL的 IN 子句(如 WHERE id IN (5, 3, 1))返回的结果会默认按主键升序排列(如 1, 3, 5),而非传入的 (5, 3, 1) 顺序。这会导致查询结果顺序与Redis中的点赞排名顺序不一致。
  2. 保持顺序一致: Redis的 ZSET 是按分数(点赞数)排序的,查询前5名时得到的ID顺序是排名顺序(如 5, 3, 1)。为了让数据库结果与这个顺序一致,需要通过 ORDER BY FIELD(id, 5, 3, 1) 强制指定排序规则。

这里出问题了

代码中 ZSet 的 score 存储的是用户点赞的时间戳,而 queryBlogLikes 方法却试图将 ZSet 中的前5个元素当作 点赞数的 Top5,这会导致逻辑错误。

1. likeBlog 方法的作用

  • 目的:用户点赞/取消点赞,记录用户行为。
  • ZSet 的用途:
    • Key: BLOG_LIKED_KEY + id,记录某篇博客的点赞用户。
    • Value: 用户ID。
    • Score: 用户点赞的时间戳(System.currentTimeMillis())。
  • 逻辑:
    • 用户首次点赞时,数据库的 liked 字段 +1,并将用户ID和时间戳存入 ZSet。
    • 用户取消点赞时,数据库的 liked 字段 -1,并从 ZSet 中移除用户ID。

2. queryBlogLikes 方法的矛盾

  • 目标:查询某篇博客的点赞数前5的用户。
  • 问题:
    • 如果 ZSet 的 score 是时间戳,range(key, 0, 4) 会取出 时间戳最小的前5个用户(最早点赞的5人),而不是点赞数最多的用户。
    • 数据库的 liked 字段是总点赞数,但 ZSet 中存储的是用户ID和时间戳,二者没有直接关联

3. 根本问题:数据结构设计错误

  • ZSet 的作用:
    • 如果目标是记录 哪些用户点过赞,ZSet 的 score 可以是时间戳,但此时无法统计每个用户的点赞次数。
    • 如果目标是统计 每个用户的点赞次数,ZSet 的 score 应是用户的累计点赞次数。
  • 这里的的代码矛盾点:
    • likeBlog 用 ZSet 记录用户点赞时间,但 queryBlogLikes 却试图用它查询 Top5 用户,逻辑不匹配。

正确设计方案

方案一:用 ZSet 统计用户点赞次数

  • 数据结构

    • Key: BLOG_LIKED_KEY + id
    • Value: 用户ID
    • Score: 该用户对这篇博客的累计点赞次数(每次点赞 +1,取消 -1)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// likeBlog 方法中修改 score 逻辑
if (score == null) {
boolean success = update().setSql("liked = liked + 1").eq("id", id).update();
if (success) {
// 用户首次点赞,score 初始化为 1
stringRedisTemplate.opsForZSet().add(key, userId.toString(), 1);
}
} else {
boolean success = update().setSql("liked = liked - 1").eq("id", id).update();
if (success) {
// 用户取消点赞,score -1
stringRedisTemplate.opsForZSet().incrementScore(key, userId.toString(), -1);
}
}

查询 Top5: 使用 reverseRange 按 score 降序获取前5名:

1
Set<String> top5 = stringRedisTemplate.opsForZSet().reverseRange(key, 0, 4);

方案二:用 ZSet 记录用户点赞时间 + Hash 统计点赞次数

  • 数据结构:
    • ZSet:记录用户点赞时间(Key: BLOG_LIKED_TIME:{id}, Value: 用户ID, Score: 时间戳)。
    • Hash:记录用户累计点赞次数(Key: BLOG_LIKED_COUNT:{id}, Field: 用户ID, Value: 点赞次数)。
  • 代码修改
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 点赞逻辑
if (score == null) {
boolean success = update().setSql("liked = liked + 1").eq("id", id).update();
if (success) {
// 记录时间戳到 ZSet
stringRedisTemplate.opsForZSet().add(timeKey, userId.toString(), System.currentTimeMillis());
// 记录点赞次数到 Hash
stringRedisTemplate.opsForHash().increment(countKey, userId.toString(), 1);
}
} else {
boolean success = update().setSql("liked = liked - 1").eq("id", id).update();
if (success) {
// 移除 ZSet 中的用户
stringRedisTemplate.opsForZSet().remove(timeKey, userId.toString());
// 减少 Hash 中的点赞次数
stringRedisTemplate.opsForHash().increment(countKey, userId.toString(), -1);
}
}
  • 查询 Top5: 从 Hash 中获取所有用户的点赞次数,排序后取前5名。

好友关注

关注和取消关注

  • 当我们进入到笔记详情页面时,会发送一个请求,判断当前登录用户是否关注了笔记博主

请求网址: http://localhost:8080/api/follow/or/not/2
请求方法: GET

  • 当我们点击关注按钮时,会发送一个请求,实现关注/取关

请求网址: http://localhost:8080/api/follow/2/true
请求方法: PUT

  • 关注是User之间的关系,是博主与粉丝的关系,数据库中有一张tb_follow表来标示
Field Type Collation Null Key Default Extra Comment
id bigint (NULL) NO PRI (NULL) auto_increment 主键
user_id bigint unsigned (NULL) NO (NULL) 用户id
follow_user_id bigint unsigned (NULL) NO (NULL) 关联的用户id
create_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED 创建时间
  • 对应的实体类如下
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
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_follow")
public class Follow implements Serializable {

private static final long serialVersionUID = 1L;

/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;

/**
* 用户id
*/
private Long userId;

/**
* 关联的用户id
*/
private Long followUserId;

/**
* 创建时间
*/
private LocalDateTime createTime;
}
  • 那我们现在来Controller层中编写对应的两个方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequestMapping("/follow")
public class FollowController {
@Resource
private IFollowService followService;
//判断当前用户是否关注了该博主
@GetMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId) {
return followService.isFollow(followUserId);
}
//实现取关/关注
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFellow) {
return followService.follow(followUserId,isFellow);
}
}
  • 具体的业务逻辑我们还是放在FellowServiceImpl中来编写
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
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {

@Override
public Result isFollow(Long followUserId) {
//获取当前登录的userId
Long userId = UserHolder.getUser().getId();
LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
//查询当前用户是否关注了该笔记的博主
queryWrapper.eq(Follow::getUserId, userId).eq(Follow::getFollowUserId, followUserId);
//只查询一个count就行了
int count = this.count(queryWrapper);
return Result.ok(count > 0);
}

@Override
public Result follow(Long followUserId, Boolean isFellow) {
//获取当前用户id
Long userId = UserHolder.getUser().getId();
//判断是否关注
if (isFellow) {
//关注,则将信息保存到数据库
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
save(follow);
} else {
//取关,则将数据从数据库中移除
LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Follow::getUserId, userId).eq(Follow::getFollowUserId, followUserId);
remove(queryWrapper);
}
return Result.ok();
}
}

共同关注

  • 点击用户头像,进入到用户详情页,可以查看用户发布的笔记,和共同关注列表

  • 但现在我们还没写具体的业务逻辑,所以现在暂时看不到数据

  • 检测NetWork选项卡,查看发送的请求

    • 查询用户信息

    请求网址: http://localhost:8080/api/user/2
    请求方法: GET

    • 查看共同关注

    请求网址: http://localhost:8080/api/follow/common/undefined
    请求方法: GET

  • 编写查询用户信息方法

1
2
3
4
5
6
7
8
9
10
11
12
@GetMapping("/{id}")
public Result queryById(@PathVariable("id") Long userId) {
// 查询详情
User user = userService.getById(userId);
if (user == null) {
// 没有详情,应该是第一次查看详情
return Result.ok();
}
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 返回
return Result.ok(userDTO);
}
  • 编写查询用户笔记方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    @GetMapping("/of/user")
public Result queryBlogByUserId(@RequestParam(value = "current", defaultValue = "1") Integer current, @RequestParam("id") Long id) {
LambdaQueryWrapper<Blog> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Blog::getUserId, id);
Page<Blog> pageInfo = new Page<>(current, SystemConstants.MAX_PAGE_SIZE);
blogService.page(pageInfo, queryWrapper);
List<Blog> records = pageInfo.getRecords();
return Result.ok(records);
}


//下面这是老师的代码,个人感觉我的可读性更高[doge]
// BlogController 根据id查询博主的探店笔记
@GetMapping("/of/user")
public Result queryBlogByUserId(
@RequestParam(value = "current", defaultValue = "1") Integer current,
@RequestParam("id") Long id) {
// 根据用户查询
Page<Blog> page = blogService.query()
.eq("user_id", id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
return Result.ok(records);
}
  • 接下来我们来看看怎么实现共同关注

需求:利用Redis中恰当的数据结构,实现共同关注功能,在博主个人页面展示出当前用户与博主的共同关注

  • 实现方式当然是我们之前学过的set集合,在set集合中,有交集并集补集的api,可以把二者关注的人放入到set集合中,然后通过api查询两个set集合的交集
  • 那我们就得先修改我们之前的关注逻辑,在关注博主的同时,需要将数据放到set集合中,方便后期我们实现共同关注,当取消关注时,也需要将数据从set集合中删除
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
@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Result follow(Long followUserId, Boolean isFellow) {
//获取当前用户id
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
//判断是否关注
if (isFellow) {
//关注,则将信息保存到数据库
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
//如果保存成功
boolean success = save(follow);
//则将数据也写入Redis
if (success) {
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
} else {
//取关,则将数据从数据库中移除
LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Follow::getUserId, userId).eq(Follow::getFollowUserId, followUserId);
//如果取关成功
boolean success = remove(queryWrapper);
//则将数据也从Redis中移除
if (success){
stringRedisTemplate.opsForSet().remove(key,followUserId.toString());
}
}
return Result.ok();
}
  • 那么接下来,我们实现共同关注代码
1
2
3
4
@GetMapping("/common/{id}")
public Result followCommons(@PathVariable Long id){
return followService.followCommons(id);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public Result followCommons(Long id) {
//获取当前用户id
Long userId = UserHolder.getUser().getId();
String key1 = "follows:" + id;
String key2 = "follows:" + userId;
//对当前用户和博主用户的关注列表取交集
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);
if (intersect == null || intersect.isEmpty()) {
//无交集就返回个空集合
return Result.ok(Collections.emptyList());
}
//将结果转为list
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
//之后根据ids去查询共同关注的用户,封装成UserDto再返回
List<UserDTO> userDTOS = userService.listByIds(ids).stream().map(user ->
BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());
return Result.ok(userDTOS);
}

Feed流实现方案

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

  • 对于传统的模式内容检索:用户需要主动通过搜索引擎或者是其他方式去查找想看的内容

  • 对于新型Feed流的效果:系统分析用户到底想看什么,然后直接把内容推送给用户,从而使用户能更加节约时间,不用去主动搜素

  • Feed流的实现有两种模式

    1. Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注(B站关注的up,朋友圈等)
      • 优点:信息全面,不会有缺失,并且实现也相对简单
      • 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
    2. 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容,推送用户感兴趣的信息来吸引用户
      • 优点:投喂用户感兴趣的信息,用户粘度很高,容易沉迷
      • 缺点:如果算法不精准,可能会起到反作用(给你推的你都不爱看)
  • 那我们这里针对好友的操作,采用的是Timeline方式,只需要拿到我们关注用户的信息,然后按照时间排序即可

  • 采用Timeline模式,有三种具体的实现方案

    1. 拉模式
    2. 推模式
    3. 推拉结合
  • 拉模式:也叫读扩散
    
    1
    2
    3
    4
    5
    6
    7
    8

    - 该模式的核心含义是:当张三和李四、王五发了消息之后,都会保存到自己的发件箱中,如果赵六要读取消息,那么他会读取他自己的收件箱,此时系统会从他关注的人群中,将他关注人的信息全都进行拉取,然后进行排序
    - 优点:比较节约空间,因为赵六在读取信息时,并没有重复读取,并且读取完之后,可以将他的收件箱清除
    - 缺点:有延迟,当用户读取数据时,才会去关注的人的时发件箱中拉取信息,假设该用户关注了海量用户,那么此时就会拉取很多信息,对服务器压力巨大
    [![img](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)](https://pic1.imgdb.cn/item/6363826516f2c2beb1992291.jpg)

    - ```
    推模式:也叫写扩散
    - 推模式是没有写邮箱的,当张三写了一个内容,此时会主动把张三写的内容发送到它粉丝的收件箱中,假设此时李四再来读取,就不用再去临时拉取了 - 优点:时效快,不用临时拉取 - 缺点:内存压力大,假设一个大V发了一个动态,很多人关注他,那么就会写很多份数据到粉丝那边去 [![img](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)](https://pic1.imgdb.cn/item/636383e816f2c2beb1a0ab75.jpg)
  • 推拉结合:页脚读写混合,兼具推和拉两种模式的优点
    
    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

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

    ### 推送到粉丝收件箱

    - 需求:
    1. 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
    2. 收件箱满足可以根据时间戳排序,必须使用Redis的数据结构实现
    3. 查询收件箱数据时,课实现分页查询
    - Feed流中的数据会不断更新,所以数据的角标也会不断变化,所以我们不能使用传统的分页模式
    - 假设在t1时刻,我们取读取第一页,此时page = 1,size = 5,那么我们拿到的就是`10~6`这几条记录,假设t2时刻有发布了一条新纪录,那么在t3时刻,我们来读取第二页,此时page = 2,size = 5,那么此时读取的数据是从6开始的,读到的是`6~2`,那么我们就读到了重复的数据,所以我们要使用Feed流的分页,不能使用传统的分页

    - Feed流的滚动分页
    - 我们需要记录每次操作的最后一条,然后从这个位置去开始读数据
    - 举个例子:我们从t1时刻开始,拿到第一页数据,拿到了`10~6`,然后记录下当前最后一次读取的记录,就是6,t2时刻发布了新纪录,此时这个11在最上面,但不会影响我们之前拿到的6,此时t3时刻来读取第二页,第二页读数据的时候,从`6-1=5`开始读,这样就拿到了`5~1`的记录。我们在这个地方可以使用SortedSet来做,使用时间戳来充当表中的`1~10`

    - 核心思路:我们保存完探店笔记后,获取当前用户的粉丝列表,然后将数据推送给粉丝
    - 那现在我们就需要修改保存笔记的方法

    ```java
    @Override
    public Result saveBlog(Blog blog) {
    // 获取登录用户
    UserDTO user = UserHolder.getUser();
    blog.setUserId(user.getId());
    // 保存探店博文
    save(blog);
    // 条件构造器
    LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
    // 从follow表最中,查找当前用户的粉丝 select * from follow where follow_user_id = user_id
    queryWrapper.eq(Follow::getFollowUserId, user.getId());
    //获取当前用户的粉丝
    List<Follow> follows = followService.list(queryWrapper);
    for (Follow follow : follows) {
    Long userId = follow.getUserId();
    String key = FEED_KEY + userId;
    //推送数据
    stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
    }
    // 返回id
    return Result.ok(blog.getId());
    }

实现分页查询收件箱

  • 需求:在个人主页的关注栏中,查询并展示推送的Blog信息
  • 具体步骤如下
    1. 每次查询完成之后,我们要分析出查询出的最小时间戳,这个值会作为下一次的查询条件
    2. 我们需要找到与上一次查询相同的查询个数,并作为偏移量,下次查询的时候,跳过这些查询过的数据,拿到我们需要的数据(例如时间戳8 6 6 5 5 4,我们每次查询3个,第一次是8 6 6,此时最小时间戳是6,如果不设置偏移量,会从第一个6之后开始查询,那么查询到的就是6 5 5,而不是5 5 4,如果这里说的不清楚,那就看后续的代码)
  • 综上:我们的请求参数中需要携带lastId和offset,即上一次查询时的最小时间戳和偏移量,这两个参数
  • 编写一个通用的实体类,不一定只对blog进行分页查询,这里用泛型做一个通用的分页查询,list是封装返回的结果,minTime是记录的最小时间戳,offset是记录偏移量
1
2
3
4
5
6
@Data
public class ScrollResult {
private List<?> list;
private Long minTime;
private Integer offset;
}
  • 点击个人主页中的关注栏,查看发送的请求

请求网址: http://localhost:8080/api/blog/of/follow?&lastId=1667472294526
请求方法: GET

  • 在BlogController中创建对应的方法,具体实现去ServiceImpl中完成
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);
}

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
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
//1. 获取当前用户
Long userId = UserHolder.getUser().getId();
//2. 查询该用户收件箱(之前我们存的key是固定前缀 + 粉丝id),所以根据当前用户id就可以查询是否有关注的人发了笔记
String key = FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> typeTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
//3. 非空判断
if (typeTuples == null || typeTuples.isEmpty()){
return Result.ok(Collections.emptyList());
}
//4. 解析数据,blogId、minTime(时间戳)、offset,这里指定创建的list大小,可以略微提高效率,因为我们知道这个list就得是这么大
ArrayList<Long> ids = new ArrayList<>(typeTuples.size());
long minTime = 0;
int os = 1;
for (ZSetOperations.TypedTuple<String> typeTuple : typeTuples) {
//4.1 获取id
String id = typeTuple.getValue();
ids.add(Long.valueOf(id));
//4.2 获取score(时间戳)
long time = typeTuple.getScore().longValue();
if (time == minTime){
os++;
}else {
minTime = time;
os = 1;
}
}
//解决SQL的in不能排序问题,手动指定排序为传入的ids
String idsStr = StrUtil.join(",");
//5. 根据id查询blog
List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idsStr + ")").list()
for (Blog blog : blogs) {
//5.1 查询发布该blog的用户信息
queryBlogUser(blog);
//5.2 查询当前用户是否给该blog点过赞
isBlogLiked(blog);
}
//6. 封装结果并返回
ScrollResult scrollResult = new ScrollResult();
scrollResult.setList(blogs);
scrollResult.setOffset(os);
scrollResult.setMinTime(minTime);
return Result.ok(scrollResult);
}

这里看还不太懂 滚动分页查询是如何实现的

滚动查询:每一次查询都记住上一次查询的最小值是谁,将最小值作为下一次的最大值,这样就能避免重复查询

因为新发的文章肯定时间戳就大 我们是逆序查的,这样确保能查到最新的 ,那么每次分页查完之后我们都把其中最小的时间戳记住,然后让最小的时间戳作为下一次查询的最大值

从有序集合中按分数从高到低返回指定范围内的成员

1
ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]
1
2
3
4
5
ZREVRANGEBYSCORE z1 1000 0 WITHSCORES LIMIT 0 3
z1: 有序集合的键名。
1000 0: 分数范围,表示 max=1000, min=0(从高分到低分筛选分数在 0 ≤ score ≤ 1000 的成员)。
WITHSCORES: 返回结果包含分数。
LIMIT 0 3: 分页参数,表示从第 0 个位置开始,返回 3 个成员。

此命令会执行以下操作:

  1. 从有序集合 z1 中筛选分数在 01000 之间的成员。
  2. 按分数从高到低排序。
  3. 返回前 3 个成员及其分数。

示例输出(假设 z1 中的数据):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Redis
1) "memberA" # 分数 1000
2) "900" # memberA 的分数
3) "memberB" # 分数 800
4) "800"
5) "memberC" # 分数 700
6) "700"
> 这个时候输入: ZADD z1 member910 910
> ZREVRANGEBYSCORE z1 700 0 WITHSCORES LIMIT 1 3
会输出:
1) "memberD" #
2) "600" #
3) "memberE" #
4) "500"
5) "memberF" #
6) "400"
  • Redis 版本兼容性:

    1
    ZREVRANGEBYSCORE

    在 Redis 6.2.0 后已废弃,建议改用新语法:

    1
    ZRANGE z1 0 1000 BYSCORE REV WITHSCORES LIMIT 0 3
  • 分数顺序ZREVRANGEBYSCORE 的参数是 max 在前,min 在后,这与升序命令 ZRANGEBYSCORE 相反。

常见用途

  • 获取排行榜中前 N 名(如游戏高分榜)。
  • 按分数倒序分页查询数据。

规律:分数最小值和查的数量固定不变。最大值为上一次查询的最小值、偏移量第1次给0,第1次后给在上一次的结果中,与最小值一样的元素的个数。

当分数一致出现问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1) "memberA"  # 分数 1000
2) "900" # memberA 的分数
3) "memberB" # 分数 800
4) "800"
5) "memberC" # 分数 700
6) "700"
> 这个时候输入: ZADD z1 member910 910
> ZREVRANGEBYSCORE z1 700 0 WITHSCORES LIMIT 1 3
会输出:
1) "memberC" #
2) "700" #
3) "memberE" #
4) "500"
5) "memberF" #
6) "400"

解决: score范围进行查询,记住最小的时间戳,下次找比这个更小的时间戳。

滚动分页查询参数:

max:第一次:当前时间戳 接下来的:上一次查询的最小值 min:永远是0 offset偏移量:第一次:0 在上一次的结果中,与最小值一样的元素个数 count:3

分数最小值和查的数量固定不变。最大值为上一次查询的最小值、偏移量第1次给0,第1次后给在上一次的结果中,与最小值一样的元素的个数。

假设我们有一个**「时光胶囊」存储库,每个胶囊上贴着时间戳标签**(比如2023年、2024年),胶囊里装着你的笔记。所有胶囊按时间从新到旧排列:

1
2
3
4
5
6
7
8
 时间戳(分数) | 胶囊内容
-------------------------
2024年 → 胶囊A
2023年 → 胶囊B
2023年 → 胶囊C
2022年 → 胶囊D
2022年 → 胶囊E
2021年 → 胶囊F

现在你需要按从新到旧的顺序,每次取2个胶囊,但遇到同一年份的胶囊要一次性跳过。

2. 第一次查询

命令ZREVRANGEBYSCORE 时光胶囊 9999 0 WITHSCORES LIMIT 0 2 (相当于:从最新时间到最旧时间,取前2个)

结果

1
2
3
 1) 胶囊A (2024年)
2) 胶囊B (2023年)
3) 胶囊C (2023年) ← 这里发现2023年有2个胶囊!

分析

  • minTime(本次最小时间戳):2023年(因为最后一个是2023年的胶囊C)
  • offset(同时间戳数量):2023年有2个胶囊(B和C),所以offset=2

3. 第二次查询

目标:跳过所有2023年的胶囊,继续往后取 命令ZREVRANGEBYSCORE 时光胶囊 2023年 0 WITHSCORES LIMIT 2 2 (解释:时间≤2023年,跳过前2个,取接下来2个)

结果

1
2
 1) 胶囊D (2022年)
2) 胶囊E (2022年) ← 发现2022年也有2个!

分析

  • minTime:2022年
  • offset:2022年有2个胶囊(D和E),offset=2

4. 第三次查询

命令ZREVRANGEBYSCORE 时光胶囊 2022年 0 WITHSCORES LIMIT 2 2 (跳过前2个2022年的胶囊,继续往后)

结果

1
1) 胶囊F (2021年)  ← 只剩最后一个

分析

  • minTime:2021年
  • offset:2021年只有1个胶囊(F),offset=1

5. 总结规律

  • minTime:每次查询结果的最后一条时间戳(不是物理最小,而是逻辑末尾)
  • offset:这个时间戳对应的总条目数(跳过所有重复项)
  • 下一次查询:用minTime作为新上限,offset作为跳过量,防止重复

类比:就像翻一本按年份分组的相册,每次翻页必须跳过整组同一年份的照片。

6. 回到你的Redis例子

假设数据如下(按分数从高到低):

1
2
3
4
5
6
 m88  
g7
m76
m66
m55
m44

第一次查询(LIMIT 0 3):

1
m8(8), g(7), m7(6)  ← 最小分数6,同分有2个(m7和m6)

第二次查询(LIMIT 2 3): 跳过前2个(m7和m6),接着取:

1
m5(5), m4(4), ...   ← 新的最小分数

7. 为什么需要这样设计?

  • 避免重复:如果直接用页码,可能重复返回同分条目
  • 精确分页:确保每次查询都是全新的数据区间
  • 动态适应:即使时间戳分布不均匀(比如某天发100条笔记),也能准确跳过

image-20250325210725483.png

元组(tuple)是关系数据库中的基本概念,是数据库中的一种数据结构。 在关系数据库中,数据以表格的形式存储,每个表格称为一个关系(relation),而表格中的每一行称为一个元组(tuple)。 元组是关系数据库中的基本单位,它代表了数据库中的一条记录。

ZSetOperations 操作接口 www.hxstrive.com/subject/spr…

在 Spring Data Redis 中,可以通过 RedisTemplate 的 opsForZSet() 方法获取,如下:

1
ZSetOperations<String,String> ops = redisTemplate.opsForZSet();

实现滚动分页查询逻辑

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
49
50
51
52
53
54
55
56
57
58
59
60
@Override
    public Result queryBlogOfFollow(Long max, Integer offset) {
        //获取当前用户 之后得到它的id
        Long userId = UserHolder.getUser().getId();
        //然后查询收件箱 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);
        //非空判断
        if (typedTuples == null ||typedTuples.isEmpty()){
            return Result.ok();
        }
        //3.解析数据:blogId、minTime(时间戳)、offset
        ArrayList<Long> ids = new ArrayList<>(typedTuples.size());
        /*
        遍历ZSetOperations集合先拿到第一个,然后把它赋值给minTime,然后遍历第二个,又赋值,最后一个一定是这个最小时间戳
         */
        long minTime = 0;
        /*
          offset怎么求 涉及到算法
         */
        int os = 1;
        for (ZSetOperations.TypedTuple<String> tuple : typedTuples){
            //
            //4.1.获取id
            String idStr = tuple.getValue();
            ids.add(Long.valueOf(idStr));
            //4.2.获取分数(时间戳)
            long time = tuple.getScore().longValue();
            if (time == minTime){
                os++;
            }else {
                minTime = time;
                os = 1;
            }
        }

        //根据id查询blog  和上面  根据用户id查询用户差不多
        String idStr = StrUtil.join(",", ids);
        List<Blog> blogs = query()
                .in("id",ids).in("id", ids)
                .last("ORDER BY FIELD(id," + idStr + ")").list();

        //再补充上一点才完整
        for (Blog blog : blogs) {
            isBlogLiked(blog);
            User user = userService.getById(blog.getUserId());
            blog.setName(user.getNickName());
            blog.setIcon(user.getIcon());
        }

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

附近商户

GEO数据结构的基本用法

  • GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据,常见的命令有

    • GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)

    • 命令格式

    1
    GEOADD key longitude latitude member [longitude latitude member …]
    • 返回值:添加到sorted set元素的数目,但不包括已更新score的元素
    • 复杂度:每⼀个元素添加是O(log(N)) ,N是sorted set的元素数量
    • 举例
    1
    2
    bash
    GEOADD china 13.361389 38.115556 "shanghai" 15.087269 37.502669 "beijing"
    • GEODIST:计算指定的两个点之间的距离并返回

    • 命令格式

    1
    2
    bash
    GEODIST key member1 member2 [m|km|ft|mi]
    • 如果两个位置之间的其中⼀个不存在, 那么命令返回空值。
    • 指定单位的参数 unit 必须是以下单位的其中⼀个:
      • m 表示单位为米。
      • km 表示单位为千米。
      • mi 表示单位为英⾥。
      • ft 表示单位为英尺。
    • 如果用户没有显式地指定单位参数, 那么 GEODIST 默认使用米作为单位。
    • GEODIST 命令在计算距离时会假设地球为完美的球形, 在极限情况下, 这⼀假设最⼤会造成 0.5% 的误差
    • 返回值:计算出的距离会以双精度浮点数的形式被返回。 如果给定的位置元素不存在, 那么命令返回空值
    • 举例
    1
    2
    bash
    GEODIST china beijing shanghai km
    • GEOHASH:将指定member的坐标转化为hash字符串形式并返回

    • 命令格式

    1
    2
    bash
    GEOHASH key member [member …]
    • 通常使用表示位置的元素使用不同的技术,使用Geohash位置52点整数编码。由于编码和解码过程中所使用的初始最小和最大坐标不同,编码的编码也不同于标准。此命令返回一个标准的Geohash,在维基百科和geohash.org网站都有相关描述
    • 返回值:一个数组, 数组的每个项都是一个 geohash 。 命令返回的 geohash 的位置与用户给定的位置元素的位置一一对应
    • 复杂度:O(log(N)) for each member requested, where N is the number of elements in the sorted set
    • 举例
    1
    2
    3
    4
    bash
    云服务器:0>GEOHASH china beijing shanghai
    1) "sqdtr74hyu0"
    2) "sqc8b49rny0"
    • GEOPOS:返回指定member的坐标

    • 格式:GEOPOS key member [member …]

    • 给定一个sorted set表示的空间索引,密集使用 geoadd 命令,它以获得指定成员的坐标往往是有益的。当空间索引填充通过 geoadd 的坐标转换成一个52位Geohash,所以返回的坐标可能不完全以添加元素的,但小的错误可能会出台。

    • 因为 GEOPOS 命令接受可变数量的位置元素作为输入, 所以即使用户只给定了一个位置元素, 命令也会返回数组回复

    • 返回值:GEOPOS 命令返回一个数组, 数组中的每个项都由两个元素组成: 第一个元素为给定位置元素的经度, 而第二个元素则为给定位置元素的纬度。当给定的位置元素不存在时, 对应的数组项为空值

    • 复杂度:O(log(N)) for each member requested, where N is the number of elements in the sorted set

    1
    2
    3
    4
    5
    6
    7
    bash
    云服务器:0>geopos china beijing shanghai
    1) 1) "15.08726745843887329"
    2) "37.50266842333162032"

    2) 1) "13.36138933897018433"
    2) "38.11555639549629859"
    • GEOGADIUS:指定圆心、半径,找到该园内包含的所有member,并按照与圆心之间的距离排序后返回,6.2之后已废弃

    • 命令格式

    1
    2
    3
    bash
    GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH]
    [COUNT count [ANY]] [ASC|DESC] [STORE key] [STOREDIST key]
    • 范围可以使用以下其中一个单位:
      • m 表示单位为米。
      • km 表示单位为千米。
      • mi 表示单位为英里。
      • ft 表示单位为英尺。
    • 在给定以下可选项时, 命令会返回额外的信息:
      • WITHDIST: 在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回。 距离的单位和用户给定的范围单位保持一致。
      • WITHCOORD: 将位置元素的经度和维度也一并返回。
      • WITHHASH: 以 52 位有符号整数的形式, 返回位置元素经过原始 geohash 编码的有序集合分值。 这个选项主要用于底层应用或者调试, 实际中的作用并不大。
    • 命令默认返回未排序的位置元素。 通过以下两个参数, 用户可以指定被返回位置元素的排序方式:
      • ASC: 根据中心的位置, 按照从近到远的方式返回位置元素。
      • DESC: 根据中心的位置, 按照从远到近的方式返回位置元素。
    • 在默认情况下, GEORADIUS 命令会返回所有匹配的位置元素。 虽然用户可以使用 COUNT 选项去获取前 N 个匹配元素, 但是因为命令在内部可能会需要对所有被匹配的元素进行处理, 所以在对一个非常大的区域进行搜索时, 即使只使用 COUNT 选项去获取少量元素, 命令的执行速度也可能会非常慢。 但是从另一方面来说, 使用 COUNT 选项去减少需要返回的元素数量, 对于减少带宽来说仍然是非常有用的
    • 返回值:
      • 在没有给定任何 WITH 选项的情况下, 命令只会返回一个像 [“New York”,”Milan”,”Paris”] 这样的线性(linear)列表。
      • 在指定了 WITHCOORD 、 WITHDIST 、 WITHHASH 等选项的情况下, 命令返回一个二层嵌套数组, 内层的每个子数组就表示一个元素。
      • 在返回嵌套数组时, 子数组的第一个元素总是位置元素的名字。 至于额外的信息, 则会作为子数组的后续元素, 按照以下顺序被返回:
        • 以浮点数格式返回的中心与位置元素之间的距离, 单位与用户指定范围时的单位一致。
        • geohash 整数。
        • 由两个元素组成的坐标,分别为经度和纬度
    • 举例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    bash

    云服务器:0>GEORADIUS china 15 37 200 km WITHDIST WITHCOORD
    1) 1) "shanghai"
    2) "190.4424"
    3) 1) "13.36138933897018433"
    2) "38.11555639549629859"

    2) 1) "beijing"
    2) "56.4413"
    3) 1) "15.08726745843887329"
    2) "37.50266842333162032"

    云服务器:0>GEORADIUS china 15 37 200 km WITHDIST
    1) 1) "shanghai"
    2) "190.4424"

    2) 1) "beijing"
    2) "56.4413"
    • GEOSEARCH:在指定范围内搜索member,并按照与制定点之间的距离排序后返回,范围可以使圆形或矩形,6.2的新功能

    命令格式

    1
    2
    3
    bash
    GEOSEARCH key [FROMMEMBER member] [FROMLONLAT longitude latitude] [BYRADIUS radius m|km|ft|mi]
    [BYBOX width height m|km|ft|mi] [ASC|DESC] [COUNT count [ANY]] [WITHCOORD] [WITHDIST] [WITHHASH]
    • 举例
    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
    bash

    云服务器:0>geosearch china FROMLONLAT 15 37 BYRADIUS 200 km ASC WITHCOORD WITHDIST
    1) 1) "beijing"
    2) "56.4413"
    3) 1) "15.08726745843887329"
    2) "37.50266842333162032"


    2) 1) "shanghai"
    2) "190.4424"
    3) 1) "13.36138933897018433"
    2) "38.11555639549629859"



    云服务器:0>geosearch china FROMLONLAT 15 37 BYBOX 400 400 km DESC WITHCOORD WITHDIST
    1) 1) "shanghai"
    2) "190.4424"
    3) 1) "13.36138933897018433"
    2) "38.11555639549629859"


    2) 1) "beijing"
    2) "56.4413"
    3) S1) "15.08726745843887329"
    2) "37.50266842333162032"
    • GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key,也是6.2的新功能

    • 命令格式

    1
    2
    3
    4
    bash
    GEOSEARCHSTORE destination source [FROMMEMBER member] [FROMLONLAT longitude latitude]
    [BYRADIUS radius m|km|ft|mi] [BYBOX width height m|km|ft|mi]
    [ASC|DESC] [COUNT count [ANY]] [STOREDIST]
    • 这个命令和 GEORADIUS 命令一样, 都可以找出位于指定范围内的元素, 但是 GEORADIUSBYMEMBER 的中心点是由给定的位置元素决定的, 而不是像 GEORADIUS 那样, 使用输入的经度和纬度来决定中心点
    • 指定成员的位置被用作查询的中心。
    • 关于 GEORADIUSBYMEMBER 命令的更多信息, 请参考 GEORADIUS 命令的文档
    • 复杂度:O(N+log(M)) where N is the number of elements inside the bounding box of the circular area delimited by center and radius and M is the number of items inside the index
    1
    2
    3
    4
    bash
    云服务器:0>GEORADIUSBYMEMBER china beijing 200 km
    1) "shanghai"
    2) "beijing"

导入店铺数据到GEO

  • 具体场景说明,例如美团/饿了么这种外卖App,你是可以看到商家离你有多远的,那我们现在也要实现这个功能。
  • 我们可以使用GEO来实现该功能,以当前坐标为圆心,同时绑定相同的店家类型type,以及分页信息,把这几个条件插入后台,后台查询出对应的数据再返回
  • 那现在我们要做的就是:将数据库中的数据导入到Redis中去,GEO在Redis中就是一个member和一个经纬度,经纬度对应的就是tb_shop中的x和y,而member,我们用shop_id来存,因为Redis只是一个内存级数据库,如果存海量的数据,还是力不从心,所以我们只存一个id,用的时候再拿id去SQL数据库中查询shop信息
  • 但是此时还有一个问题,我们在redis中没有存储shop_type,无法根据店铺类型来对数据进行筛选,解决办法就是将type_id作为key,存入同一个GEO集合即可
Key Value Score
shop:geo:美食 海底捞 40691512240174598
吉野家 40691519846517915
shop:geo:KTV KTV 01 40691165486458787
KTV 02 40691514154651657
  • 代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java

@Test
public void loadShopData(){
//1. 查询所有店铺信息
List<Shop> shopList = shopService.list();
//2. 按照typeId,将店铺进行分组
Map<Long, List<Shop>> map = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));
//3. 逐个写入Redis
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
//3.1 获取类型id
Long typeId = entry.getKey();
//3.2 获取同类型店铺的集合
List<Shop> shops = entry.getValue();
String key = SHOP_GEO_KEY + typeId;
for (Shop shop : shops) {
//3.3 写入redis GEOADD key 经度 纬度 member
stringRedisTemplate.opsForGeo().add(key,new Point(shop.getX(),shop.getY()),shop.getId().toString());
}
}
}
  • 但是上面的代码不够优雅,是一条一条写入的,效率较低,那我们现在来改进一下,这样只需要写入等同于type_id数量的次数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java

@Test
public void loadShopData() {
List<Shop> shopList = shopService.list();
Map<Long, List<Shop>> map = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
Long typeId = entry.getKey();
List<Shop> shops = entry.getValue();
String key = SHOP_GEO_KEY + typeId;
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(shops.size());
for (Shop shop : shops) {
//将当前type的商铺都添加到locations集合中
locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(), new Point(shop.getX(), shop.getY())));
}
//批量写入
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
  • 代码编写完毕,我们启动测试方法,然后去Redis图形化界面中查看是否有对应的数据

实现附近商户功能

  • SpringDataRedis的2.3.9版本并不支持Redis 6.2提供的GEOSEARCH命令,因此我们需要提示其版本,修改自己的pom.xml文件
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
xml

<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>
  • 点击距离分类,查看发送的请求

请求网址: http://localhost:8080/api/shop/of/type?&typeId=1&current=1&x=120.149993&y=30.334229
请求方法: GET

  • 看样子是ShopController中的方法,那我们现在来修改其代码,除了typeId,分页码,我们还需要其坐标
1
2
3
4
5
6
7
8
9
10
java
@GetMapping("/of/type")
public Result queryShopByType(
@RequestParam("typeId") Integer typeId,
@RequestParam(value = "current", defaultValue = "1") Integer current,
@RequestParam(value = "x", required = false) Double x,
@RequestParam(value = "y", required = false) Double y
) {
return shopService.queryShopByType(typeId,current,x,y);
}
  • 具体业务逻辑依旧是写在ShopServiceImpl中
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
49
50
java

@Override
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.MAX_PAGE_SIZE;
int end = current * SystemConstants.MAX_PAGE_SIZE;
String key = SHOP_GEO_KEY + typeId;
//3. 查询redis、按照距离排序、分页; 结果:shopId、distance
//GEOSEARCH key FROMLONLAT x y BYRADIUS 5000 m WITHDIST
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());
}
//4. 解析出id
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
if (list.size() < from) {
//起始查询位置大于数据总量,则说明没数据了,返回空集合
return Result.ok(Collections.emptyList());
}
ArrayList<Long> ids = new ArrayList<>(list.size());
HashMap<String, Distance> distanceMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(result -> {
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
Distance distance = result.getDistance();
distanceMap.put(shopIdStr, distance);
});
//5. 根据id查询shop
String idsStr = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD( id," + idsStr + ")").list();
for (Shop shop : shops) {
//设置shop的举例属性,从distanceMap中根据shopId查询
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
//6. 返回
return Result.ok(shops);
}
  • 最终效果如下,可以显示出距离
    img

用户签到

BitMap功能演示

  • 我们针对签到功能完全可以通过MySQL来完成,例如下面这张表
Field Type Collation Null Key Default Extra Comment
id bigint unsigned (NULL) NO PRI (NULL) auto_increment 主键
user_id bigint unsigned (NULL) NO (NULL) 用户id
year year (NULL) NO (NULL) 签到的年
month tinyint (NULL) NO (NULL) 签到的月
date date (NULL) NO (NULL) 签到的日期
is_backup tinyint unsigned (NULL) YES (NULL) 是否补签
  • 用户签到一次,就是一条记录,假如有1000W用户,平均没人每年签到10次,那这张表一年的数据量就有1亿条
  • 那有没有方法能简化一点呢?我们可以使用二进制位来记录每个月的签到情况,签到记录为1,未签到记录为0
  • 把每一个bit位对应当月的每一天,形成映射关系,用0和1标识业务状态,这种思路就成为位图(BitMap)。这样我们就能用极小的空间,来实现大量数据的表示
  • Redis中是利用String类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是2^32个bit位
  • 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出现的位置

实现签到功能

  • 需求:实现签到接口,将当前用户当天签到信息保存到Redis中
说明
请求方式 Post
请求路径 /user/sign
请求参数
返回值
  • 思路:我们可以把年和月作为BitMap的key,然后保存到一个BitMap中,每次签到就把对应位上的0变成1,只要是1就说明这一天已经签到了,反之则没有签到
  • 由于BitMap底层是基于String数据结构,因此其操作也都封装在字符串相关操作中了
  • 在UserController中编写对应的方法
1
2
3
4
@PostMapping("/sign")
public Result sign(){
return userService.sign();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
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 = USER_SIGN_KEY + userId + keySuffix;
//4. 获取今天是当月第几天(1~31)
int dayOfMonth = now.getDayOfMonth();
//5. 写入Redis BITSET key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}

连续签到统计

  • 如何获取本月到今天为止的所有签到数据?
    • BITFIELD key GET u[dayOfMonth] 0
  • 如何从后往前遍历每个bit位,获取连续签到天数
    • 连续签到天数,就是从末尾往前数,看有多少个1
    • 简单的位运算算法
1
2
3
4
5
6
7
8
9
int count = 0;
while(true) {
if((num & 1) == 0)
break;
else
count++;
num >>>= 1;
}
return count;

这段代码是统计整数二进制表示中,从最低位(右侧)开始连续出现 1 的次数。就像用探针从右往左扫描二进制位,遇到第一个 0 就停止。

逐帧拆解(以 num=15 为例)

1
2
3
4
5
6
7
8
9
num = 15(二进制 1111)
循环次数 | 操作 | 二进制状态 | count
---------------------------------------------------------
初始值 | | 1111 | 0
第1次 | 检查最后一位是1 → count+1 | 1111 → 右移成 111 | 1
第2次 | 检查最后一位是1 → count+1 | 111 → 右移成 11 | 2
第3次 | 检查最后一位是1 → count+1 | 11 → 右移成 1 | 3
第4次 | 检查最后一位是1 → count+1 | 1 → 右移成 0 | 4
第5次 | 最后一位是0 → 终止 | | 最终结果4
  1. num & 1
    • 二进制末位探测器:结果为 1 表示末位是 10 表示末位是 0
    • 示例:0b1011 & 1 = 10b1010 & 1 = 0
  2. num >>>= 1
    • 无符号右移:将二进制整体向右移动一位,左侧补 0
    • 对比 >>(带符号右移):负数时左侧补 1
  • 需求:实现下面接口,统计当前用户截止当前时间在本月的连续签到天数
说明
请求方式 GET
请求路径 /user/sign/count
请求参数
返回值 连续签到天数
  • 在UserController中创建对应的方法
1
2
3
4
5
java
@GetMapping("/sign/count")
public Result signCount(){
return userService.signCount();
}
  • 在UserServiceImpl中实现方法
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
java

@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. 获取今天是当月第几天(1~31)
int dayOfMonth = now.getDayOfMonth();
//5. 获取截止至今日的签到记录 BITFIELD key GET uDay 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);
}
//6. 循环遍历
int count = 0;
Long num = result.get(0);
while (true) {
if ((num & 1) == 0) {
break;
} else
count++;
//数字右移,抛弃最后一位
num >>>= 1;
}
return Result.ok(count);
}
  • 使用PostMan发送请求,可以手动修改redis中的签到数据多次测试,发请求的时候还是要注意携带登录用户的token

UV统计

HyperLogLog

  • UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
  • PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
  • 本博客的首页侧边栏就有本站访客量和本站总访问量,对应的就是UV和PV
  • 通常来说PV会比UV大很多,所以衡量同一个网站的访问量,我们需要综合考虑很多因素。
  • UV统计在服务端做会很麻烦,因为要判断该用户是否已经统计过了,需要将统计过的信息保存,但是如果每个访问的用户都保存到Redis中,那么数据库会非常恐怖,那么该如何处理呢?
  • HyperLogLog(HLL)是从Loglog算法派生的概率算法,用户确定非常大的集合基数,而不需要存储其所有值,算法相关原理可以参考下面这篇文章:https://juejin.cn/post/6844903785744056333#heading-0
  • Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
  • 常用的三个方法
1
2
3
4
5
6
7
8
9
bash
PFADD key element [element...]
summary: Adds the specified elements to the specified HyperLogLog

PFCOUNT key [key ...]
Return the approximated cardinality of the set(s) observed by the HyperLogLog at key(s).

PFMERGE destkey sourcekey [sourcekey ...]
lnternal commands for debugging HyperLogLog values

测试百万数据的统计

  • 使用单元测试,向HyperLogLog中添加100万条数据,看看内存占用是否真的那么低,以及统计误差如何
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java

@Test
public void testHyperLogLog() {
String[] users = new String[1000];
int j = 0;
for (int i = 0; i < 1000000; i++) {
j = i % 1000;
users[j] = "user_" + i;
if (j == 999) {
stringRedisTemplate.opsForHyperLogLog().add("HLL", users);
}
}
Long count = stringRedisTemplate.opsForHyperLogLog().size("HLL");
System.out.println("count = " + count);
}
  • 插入100W条数据,得到的count为997593,误差率为0.002407%
  • 去Redis图形化界面中查看占用情况为:12.3K字节