目录
五、达人探店
1.发布探店笔记
2.查看探店笔记
3.点赞功能
4.点赞排行榜
六、好友关注
1.关注和取消关注
2.共同关注
3.关注推送
(1)Feed流实现方案分析
(2)推送到粉丝收件箱
(3)实现分页查询收件箱
七、附近商户
1.GEO数据结构的基本用法
2.导入店铺数据到GEO
3.实现附近商户功能
八、用户签到
1.BitMap功能演示
2.实现签到功能
3.签到统计
九、UV统计
1.HyperLogLog用法
2.测试百万数据的统计
五、达人探店
1.发布探店笔记
探店店笔记表,包含笔记中的标题、文字、图片等
点击首页最下方菜单栏中的 + 按钮,即可发布探店图文:
将上传图片的地址改为前端 ngnix 服务器中的地址:
上传图片后,会响应该图片在前端保存的路径:
填写完成相关信息后,点击发布:
此时,个人资料和首页就可以看到新发布的笔记了:
同时,数据库中也新增了一条数据:
2.查看探店笔记
需求:点击首页的探店笔记,会进入详情页面,实现该页面的查询接口
代码实现:
① controller 层(BlogController)
@RestController
@RequestMapping("/blog")
public class BlogController {@Resourceprivate IBlogService blogService;@Resourceprivate IUserService userService;...@GetMapping("/hot")public Result queryHotBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {return blogService.queryHotBlog(current);}@GetMapping("/{id}")public Result queryBlogById(@PathVariable("id") Long id){return blogService.queryBlogById(id);}
}
② Service 层
IBlogService 接口:
public interface IBlogService extends IService<Blog> {Result queryHotBlog(Integer current);Result queryBlogById(Long id);
}
BlogServiceImpl 实现类:
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {@Resourceprivate IUserService userService;@Overridepublic 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);}@Overridepublic Result queryBlogById(Long id) {// 1.查询blogBlog blog = getById(id);if(blog == null){return Result.fail("笔记不存在!");}// 2.查询blog有关的用户信息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());}
}
重启项目,运行结果:
3.点赞功能
在首页的探店笔记排行榜和探店图文详情页面都有点赞排行的功能:
打开代码,我们发现点赞的实现仅仅是通过修改数据库点赞数量进行实现的,所以就会存在一个问题:一个用户可以无限次的点赞,刷点赞数,这是不合理的。
需求:
- 同一个用户只能对同一篇笔记点赞一次,再次点击则取消点赞
- 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)
实现步骤:
① 给 Blog 类中添加一个 isLike 字段,标识是否被当前用户点赞过
② 修改点赞功能,利用 Redis 中的 set 集合来判断是否点赞过,未点赞则点赞数 +1
,已点赞则点赞数 -1
controller层:
@RestController
@RequestMapping("/blog")
public class BlogController {@Resourceprivate IBlogService blogService;...@PutMapping("/like/{id}")public Result likeBlog(@PathVariable("id") Long id) {return blogService.likeBlog(id);}
}
Service 接口:
public interface IBlogService extends IService<Blog> {Result queryHotBlog(Integer current);Result queryBlogById(Long id);Result likeBlog(Long id);
}
Service 实现类:
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {@Resourceprivate IUserService userService;@Resourceprivate StringRedisTemplate stringRedisTemplate;...@Overridepublic Result likeBlog(Long id) {// 1.获取登录用户Long userId = UserHolder.getUser().getId();// 2.判断用户是否已经点赞String key = BLOG_LIKED_KEY + id;Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());if (BooleanUtil.isFalse(isMember)) {// 3.如果未点赞,可以点赞// 3.1.数据库点赞数+1boolean isSuccess = update().setSql("liked = like + 1").eq("id", id).update();// 3.2.保存用户到Redis的set集合if (isSuccess) {stringRedisTemplate.opsForSet().add(key, userId.toString());}}else{// 4.如果已点赞,取消点赞// 4.1.数据库点赞数-1boolean isSuccess = update().setSql("liked = like - 1").eq("id", id).update();// 4.2.把用户从Redis的set集合中移除if (isSuccess) {stringRedisTemplate.opsForSet().remove(key, userId.toString());}}return Result.ok();}
}
③ 修改根据 id 查询的业务,判断当前登录用户是否点赞过,赋值给 isLike 字段
④ 修改分页查询 Blog 业务,判断当前登录用户是否点赞过,赋值给 isLike 字段
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {@Resourceprivate IUserService userService;@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic 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);isBlogLiked(blog);});return Result.ok(records);}@Overridepublic Result queryBlogById(Long id) {// 1.查询blogBlog blog = getById(id);if (blog == null) {return Result.fail("笔记不存在!");}// 2.查询blog有关的用户信息queryBlogUser(blog);// 3.查询blog是否被点赞isBlogLiked(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());}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());blog.setIsLike(BooleanUtil.isTrue(isMember));}...
}
测试:
重启项目,此时进行点赞,就可以成功显示高亮了,redis 中也出现了点赞记录
而且用户只能够点赞一次,再次点击就会取消点赞,redis 中也会删除点赞记录
4.点赞排行榜
当我们点击探店笔记详情页面时,应该按点赞顺序展示点赞用户,比如显示最早点赞的TOP5,形成点赞排行榜。
之前的点赞是放到 Set 集合中,但是 Set 集合又不能排序,所以得换一种数据类型:
List | Set | SortedSet | |
---|---|---|---|
排序方式 | 按添加顺序排序 | 无法排序 | 根据score值排序 |
唯一性 | 不唯一 | 唯一 | 唯一 |
查找方式 | 按索引查找或首尾查找 | 根据元素查找 | 根据元素查找 |
显然,这里最合适的数据结构就是 SortedSet,在 java 中称作为 ZSet,但之前使用的 Set 和 Sorted 在使用方法上有很多不同。
所以,我们需要修改刚才点赞功能中的代码:
修改BlogServiceImpl:
① ZSet 在使用 add 方法时,不仅需要传入 key 和 value,还要传入分数 score 进行排序,这里我们用时间戳作为 score。
② 由于 ZSet 没有 isMember 方法,所以这里只能通过查询 score 来判断集合中是否有该元素,如果有该元素,则返回值是对应的 score,如果没有该元素,则返回值为 null
③ 修改 isBlogLiked 方法,在原有逻辑上,判断用户是否已登录,登录状态下才会继续判断用户是否点赞。未登录时,首页的笔记无需查询是否点赞。
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {@Resourceprivate IUserService userService;@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic 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);isBlogLiked(blog);});return Result.ok(records);}@Overridepublic Result queryBlogById(Long id) {// 1.查询blogBlog blog = getById(id);if (blog == null) {return Result.fail("笔记不存在!");}// 2.查询blog有关的用户信息queryBlogUser(blog);// 3.查询blog是否被点赞isBlogLiked(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());}private void isBlogLiked(Blog blog) {// 1.获取登录用户UserDTO user = UserHolder.getUser();if (user == null) {// 用户未登录,无需查询是否点赞return;}Long userId = user.getId();// 2.判断用户是否已经点赞String key = BLOG_LIKED_KEY + blog.getId();Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());blog.setIsLike(score != null);}@Overridepublic Result likeBlog(Long id) {// 1.获取登录用户Long userId = UserHolder.getUser().getId();// 2.判断用户是否已经点赞String key = BLOG_LIKED_KEY + id;Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());if (score == null) {// 3.如果未点赞,可以点赞// 3.1.数据库点赞数+1boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();// 3.2.保存用户到Redis的set集合if (isSuccess) {stringRedisTemplate.opsForZSet().add(key, userId.toString(),System.currentTimeMillis());}} else {// 4.如果已点赞,取消点赞// 4.1.数据库点赞数-1boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();// 4.2.把用户从Redis的set集合中移除if (isSuccess) {stringRedisTemplate.opsForZSet().remove(key, userId.toString());}}return Result.ok();}
}
重启项目,此时进行点赞后,打开 redis,就可以发现:和原来的 Set 相比,多了一个 score
接下来,我们继续来完善显示点赞列表功能。
④ 查询范围查询前五个用户,zrange
controller 层:
@RestController
@RequestMapping("/blog")
public class BlogController {@Resourceprivate IBlogService blogService;...@GetMapping("/likes/{id}")public Result queryBlogLikes(@PathVariable("id") Long id){return blogService.queryBlogLikes(id);}
}
Service 接口:
public interface IBlogService extends IService<Blog> {Result queryHotBlog(Integer current);Result queryBlogById(Long id);Result likeBlog(Long id);Result queryBlogLikes(Long id);
}
Service 实现类:
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {@Resourceprivate IUserService userService;@Resourceprivate StringRedisTemplate stringRedisTemplate;...@Overridepublic Result queryBlogLikes(Long id) {// 1.查询top5的店点赞用户 zrange key 0 4String key = BLOG_LIKED_KEY + id;Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);if (top5 == null || top5.isEmpty()) {return Result.ok(Collections.emptyList());}// 2.解析出其中的用户idList<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());// 3.根据用户id去查询用户List<UserDTO> userDTOS = userService.listByIds(ids).stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());// 4.返回用户集合return Result.ok(userDTOS);}
}
重启项目,点击笔记详情,这时发现已经可以看见点赞的用户列表了。
但是,这时我们发现,展示的顺序似乎不对,好像反了?
我们发现,在控制台和 redis 中,显示的顺序都是正常的,1010 号在前,1 号在后,这是怎么回事呢?
原因:SQL 语句的问题,在 SQL 中我们使用的是 in,用它查询出的结果,会自动按 id 从小到大进行排序。
解决办法:使用 order by 手动指定返回的顺序
所以,我们需要修改 BlogServiceImpl 中显示点赞列表的代码:
@Overridepublic Result queryBlogLikes(Long id) {// 1.查询top5的店点赞用户 zrange key 0 4String key = BLOG_LIKED_KEY + id;Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);if (top5 == null || top5.isEmpty()) {return Result.ok(Collections.emptyList());}// 2.解析出其中的用户idList<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());// 把id拼成一串用 , 隔开的字符串String idStr = StrUtil.join(",", ids);// 3.根据用户id去查询用户 WHERE id IN (1010, 1) ORDER BY FIELD(id, 1010, 1)List<UserDTO> userDTOS = userService.query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list().stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());// 4.返回用户集合return Result.ok(userDTOS);}
重启项目,查看点赞列表,这时显示的顺序就正常了:
六、好友关注
1.关注和取消关注
在探店图文的详情页面中,可以关注发布笔记的作者:
需求:基于该表结构,实现两个接口:
- 关注和取关接口
- 判断是否关注接口
关注是 User 之间的关系,是博主与粉丝的关系,数据库中有一张 tb_follow 表来标示
代码实现:
controller 层:
@RestController
@RequestMapping("/follow")
public class FollowController {@Resourceprivate IFollowService followService;@PutMapping("/{id}/{isFollow}")public Result follow(@PathVariable("id") Long followUserId,@PathVariable("isFollow")Boolean isFollow){return followService.follow(followUserId,isFollow);}@GetMapping("/or/not/{id}")public Result isFollow(@PathVariable("id") Long followUserId){return followService.isFollow(followUserId);}
}
service 接口:
public interface IFollowService extends IService<Follow> {Result follow(Long followUserId, Boolean isFollow);Result isFollow(Long followUserId);
}
service 实现类:
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {@Overridepublic Result follow(Long followUserId, Boolean isFollow) {// 1.获取登录用户Long userId = UserHolder.getUser().getId();// 2.判断到底是关注还是取关if (isFollow) {// 3.关注,新增数据Follow follow = new Follow();follow.setUserId(userId);follow.setFollowUserId(followUserId);save(follow);}else {// 4.取关,删除数据remove(new QueryWrapper<Follow>().eq("user_id",userId).eq("follow_user_id",followUserId));}return Result.ok();}@Overridepublic Result isFollow(Long followUserId) {// 1.获取登录用户Long userId = UserHolder.getUser().getId();// 2.查询是否关注Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();return Result.ok(count > 0);}
}
重启项目,查看笔记详情页面:
点击关注:
打开数据库,能够发现此时多了一条记录:
2.共同关注
点击用户头像,进入到用户详情页,可以查看用户发布的笔记,和共同关注列表
但现在我们还没写具体的业务逻辑,所以现在暂时看不到数据。
准备工作:
① 编写查询用户信息方法(UserController):
@GetMapping("/{id}")
public Result queryUserById(@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);
}
② 编写查询用户笔记方法(BlogController):
@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 集合中,求有交集并集补集的命令,可以把二者关注的人放入到 set 集合中,然后通过SINTER 查询两个 set 集合的交集。
为此,我们必须要修改我们之前的关注逻辑,在关注博主的同时,需要将数据放到set集合中。当取消关注时,也需要将数据从 set 集合中删除。
FollowServiceImpl:
@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result follow(Long followUserId, Boolean isFollow) {// 1.获取登录用户Long userId = UserHolder.getUser().getId();String key = "follows:" + userId;// 2.判断到底是关注还是取关if (isFollow) {// 3.关注,新增数据Follow follow = new Follow();follow.setUserId(userId);follow.setFollowUserId(followUserId);boolean isSuccess = save(follow);if (isSuccess) {// 把关注用户的id,放入redis的set集合 sadd userId followUserIdstringRedisTemplate.opsForSet().add(key, followUserId.toString());}} else {// 4.取关,删除数据boolean isSuccess = remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));if (isSuccess) {// 把关注用户的id,从redis的set集合中删除 srem userId followUserIdstringRedisTemplate.opsForSet().remove(key, followUserId.toString());}}return Result.ok();}
重启项目,重新关注进行测试,可以看见 redis 中已经新增了关注数据了。
那么接下来,我们实现共同关注代码:
controller 层:
@RestController
@RequestMapping("/follow")
public class FollowController {@Resourceprivate IFollowService followService;...@GetMapping("/common/{id}")public Result followCommons(@PathVariable("id") Long id){return followService.followCommons(id);}
}
service 接口:
public interface IFollowService extends IService<Follow> {Result follow(Long followUserId, Boolean isFollow);Result isFollow(Long followUserId);Result followCommons(Long id);
}
service 实现类:
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Resourceprivate IUserService userService;...@Overridepublic Result followCommons(Long id) {// 1.获取当前用户Long userId = UserHolder.getUser().getId();String key1 = "follows:" + userId;String key2 = "follows:" + id;// 2.求交集Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);if (intersect == null || intersect.isEmpty()) {// 无交集return Result.ok(Collections.emptyList());}// 3.解析id集合List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());// 4.查询用户List<UserDTO> users = userService.listByIds(ids).stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());return Result.ok(users);}
}
重启项目,进行测试:
redis 中显示,1010号(当前用户)和 1号用户(小鱼同学),都关注了 2 号用户。
页面展示的共同关注,确实如此。
3.关注推送
(1)Feed流实现方案分析
当我们关注了用户之后,这个用户发布了动态,那我们应该把这些数据推送给用户,这个需求,我们又称其为 Feed 流。
关注推送也叫作 Feed 流,直译为投喂,为用户提供沉浸式体验,通过无限下拉刷新获取新的信息。
- 对于传统的模式内容检索:用户需要主动通过搜索引擎或者是其他方式去查找想看的内容
- 对于新型 Feed 流的效果:系统分析用户到底想看什么,然后直接把内容推送给用户,从而使用户能更加节约时间,不用去主动搜素
Feed 流的实现有两种模式:
- Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注(例如朋友圈)
- 优点:信息全面,不会有缺失,并且实现也相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
- 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容,推送用户感兴趣的信息来吸引用户
- 优点:投喂用户感兴趣的信息,用户粘度很高,容易沉迷
- 缺点:如果算法不精准,可能会起到反作用(给你推的你都不爱看)
我们这里是基于关注的好友来做 Feed 流,因此采用的是 Timeline 方式,只需要拿到我们关注用户的信息,然后按照时间排序即可。
采用 Timeline 模式,有三种具体的实现方案:
① 拉模式:也叫读扩散
核心含义:当张三和李四、王五发了消息之后,都会保存到自己的发件箱中,如果赵六要读取消息,那么他会读取他自己的收件箱,此时系统会从他关注的人群中,将他关注人的信息全都进行拉取,然后进行排序
优点:比较节约空间,因为赵六在读取信息时,并没有重复读取,并且读取完之后,可以将他的收件箱清除
缺点:有延迟,当用户读取数据时,才会去关注的人的时发件箱中拉取信息,假设该用户关注了海量用户,那么此时就会拉取很多信息,对服务器压力巨大
②
推模式:也叫写扩散
核心含义:推模式是没有发邮箱的,当张三写了一个内容,此时会主动把张三写的内容发送到它粉丝的收件箱中,假设此时李四再来读取,就不用再去临时拉取了
优点:时效快,不用临时拉取
缺点:内存压力大,假设一个大V发了一个动态,很多人关注他,那么就会写很多份数据到粉丝那边去。
③ 推拉结合:页脚读写混合,兼具推和拉两种模式的优点。
核心含义:推拉模式是一个折中的方案,站在发件人这一边,如果是普通人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝收件箱中,因为普通人的粉丝数量较少,所以这样不会产生太大压力。但如果是大V,那么他是直接将数据写入一份到发件箱中去,在直接写一份到活跃粉丝的收件箱中,站在收件人这边来看,如果是活跃粉丝,那么大V和普通人发的都会写到自己的收件箱里,但如果是普通粉丝,由于上线不是很频繁,所以等他们上线的时候,再从发件箱中去拉取信息。
总结:
综上,在本案例中, 由于是点评项目,我们选择第二种:推模式。
(2)推送到粉丝收件箱
需求:
- 修改新增探店笔记的业务,在保存 blog 到数据库的同时,推送到粉丝的收件箱
- 收件箱满足可以根据时间戳排序,必须使用 Redis 的数据结构实现
- 查询收件箱数据时,可以实现分页查询
Feed 流中的数据会不断更新,所以数据的角标也会不断变化,所以我们不能使用传统的分页模式
假设在 t1 时刻,我们取读取第一页,此时 page = 1,size = 5,那么我们拿到的就是 10~6 这 5 条记录,假设 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.
代码实现:
改变原有逻辑,创建博客的同时推送消息给粉丝的收件箱
controller 层:
@RestController
@RequestMapping("/blog")
public class BlogController {@Resourceprivate IBlogService blogService;@PostMappingpublic Result saveBlog(@RequestBody Blog blog) {return blogService.saveBlog(blog);}...
}
Service 接口:
public interface IBlogService extends IService<Blog> {Result queryHotBlog(Integer current);Result queryBlogById(Long id);Result likeBlog(Long id);Result queryBlogLikes(Long id);Result saveBlog(Blog blog);
}
Service 实现类:
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {@Resourceprivate IUserService userService;@Resourceprivate StringRedisTemplate stringRedisTemplate;@Resourceprivate IFollowService followService;...@Overridepublic Result saveBlog(Blog blog) {// 1.查询登录用户UserDTO user = UserHolder.getUser();blog.setUserId(user.getId());// 2.保存探店笔记boolean isSuccess = save(blog);if (!isSuccess) {return Result.fail("新增笔记失败!");}// 3.查询笔记作者的所有粉丝 select * from tb_follow where follow_user_id = ?List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();// 4.推送笔记id给所有粉丝for (Follow follow : follows) {// 4.1.获取粉丝idLong userId = follow.getUserId();// 4.2.推送String key = "feed:" + userId;stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());}//5.返回idreturn Result.ok(blog.getId());}
}
重启项目,登录之前关注的 2 号用户(可可今天不吃肉),发布新的笔记:
此时打开 redis,就可以看见刚才关注的 1号 和 1010 号用户的收件箱中都多了一条记录:
而 25 号记录正是刚才 2 号用户新创建的笔记。
(3)实现分页查询收件箱
需求:在个人主页的 “关注” 中,查询并展示推送的Blog信息
分页查询不能按照正常逻辑记角标查询,应该通过 score 分数来查询,每次查询记住上次查询的最后一个分数
滚动分页查询命令:
zrevrangebyscore key maxscore minscore [withscores] [limit offset count]
每次查询完成之后,我们要分析出查询出的最小时间戳,这个值会作为下一次的查询条件中的maxscore。
我们需要找到与上一次查询中相同 minscore 的查询个数,并作为偏移量 offset,下次查询的时候,跳过这些查询过的数据,拿到我们需要的数据。
例如:时间戳8 6 6 5 5 4,我们每次查询 3 个,第一次是 8 6 6,此时最小时间戳是 6,如果不设置偏移量,会从第一个 6 之后开始查询,那么查询到的就是 6 5 5,而不是 5 5 4。
综上,我们的请求参数中需要携带 lastId 和 offset ,即上一次查询时的最小时间戳和偏移量。这两个参数是我们在后台分析处理完返回的,所以返回值也要包含这两个,以便下次前端发送请求时携带。(第一次查询时,lastId 就是当前时间戳,offset 就是 0)
代码实现:
① 编写一个通用的实体类,不一定只对 blog 进行分页查询,这里用泛型做一个通用的分页查询,list 是封装返回的结果,minTime 是记录的最小时间戳,offset 是记录偏移量。
@Data
public class ScrollResult {private List<?> list;private Long minTime;private Integer offset;
}
② 修改业务方法
controller 层:
@RestController
@RequestMapping("/blog")
public class BlogController {@Resourceprivate IBlogService blogService;...@GetMapping("/of/follow")public Result queryBlogOfFollow(@RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset) {return blogService.queryBlogOfFollow(max, offset);}
}
service 接口:
public interface IBlogService extends IService<Blog> {...Result queryBlogOfFollow(Long max, Integer offset);
}
service 实现类:
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {@Resourceprivate IUserService userService;@Resourceprivate StringRedisTemplate stringRedisTemplate;@Resourceprivate IFollowService followService;...@Overridepublic Result queryBlogOfFollow(Long max, Integer offset) {// 1.查询登录用户Long userId = UserHolder.getUser().getId();// 2.获取收件箱 ZREVRANGEBYSCORE key max min LIMIT offset countString 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(时间戳)、offsetList<Long> ids = new ArrayList<>(typedTuples.size());long minTime = 0;int newOffset = 1;for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {// 4.1.获取idids.add(Long.valueOf(tuple.getValue()));// 4.2.获取分数(时间戳)long time = tuple.getScore().longValue();if (time == minTime) {newOffset++;} else {newOffset = 1;minTime = time;}}// 5.根据id查询blogString 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(newOffset);r.setMinTime(minTime);return Result.ok(r);}
}
细节:
i. 这里和之前查询点赞排行榜一样,不能直接使用 listByIds() 方法批量根据 id 获取 blog,因为其底层 SQL 是 in,会把原有的顺序破坏,按照 id 进行排序。
ii. 这里查询到 blog 信息之后,还要注意:我们每次查询到 blog 后,都应执行 queryBlogUser 和 isBlogLiked 方法,去查询和 blog 有关的用户信息和 blog 是否被点赞,因为前端会进行展示。
重启项目,进行测试:
七、附近商户
1.GEO数据结构的基本用法
GEO就是 Geolocation 的简写形式,代表地理坐标。Redis在 3.2 版本中加入了对 GEO 的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据,常见的命令有:
命令 | 作用 |
---|---|
GEOADD | 添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member) |
GEODIST | 计算指定的两个点之间的距离并返回 |
GEOHASH | 将指定 member 的坐标转化为 hash 字符串形式并返回 |
GEOPOS | 返回指定 member 的坐标 |
GEOGADIUS | 指定圆心、半径,找到该园内包含的所有 member,并按照与圆心之间的距离排序后返回,6.2 之后已废弃 |
GEOSEARCH | 在指定范围内搜索 member,并按照与制定点之间的距离排序后返回,范围可以使圆形或矩形,6.2 的新功能 |
GEOSEARCHSTORE | 与 GEOSEARCH 功能一致,不过可以把结果存储到一个指定的 key,也是 6.2 的新功能 |
需求:
1. 添加下面几条数据:
- 北京南站(116.378248 39.865275)
- 北京站(116.42803 39.903738)
- 北京西站(116.322287 39.893729)
GEOADD g1 116.378248 39.865275 bjn 116.42803 39.903738 bjz 116.322287 39.893729 bjx
查看 redis 客户端, 我们发现 geo 底层采用 ZSET 实现。
2. 计算北京南站到北京西站的距离
# 默认单位是米
GEODIST g1 bjn bjx
3. 搜索天安门(116.397904 39.909005)附近 10km 内的所有火车站,并按照距离升序排序
GEOSEARCH g1 FROMLONLAT 116.397904 39.909005 BYRADIUS 10 km WITHDIST
2.导入店铺数据到GEO
在首页中点击某个频道,即可看到频道下的商户。
具体场景说明,例如美团、饿了么这种外卖App,你是可以看到商家离你有多远的,那我们现在也要实现这个功能。
我们可以使用 GEO 来实现该功能,以当前坐标为圆心,同时绑定相同的店家类型 type,以及分页信息,把这几个条件插入后台,后台查询出对应的数据再返回。
那现在我们要做的就是:将数据库中的数据导入到 Redis中去,GEO 在Redis中就是一个 member和一个经纬度,经纬度对应的就是 tb_shop 中的 x 和 y,而 member,我们用 shop_id 来存。
因为Redis 只是一个内存级数据库,如果存海量的数据,还是力不从心,所以我们只存一个 id,用的时候再拿 id 去 SQL 数据库中查询 shop 信息。
但是此时还有一个问题,我们在 redis 中没有存储 shop_type,无法根据店铺类型来对数据进行筛选。
解决办法:不将所有数据存到同一个 GEO 集合中,而是根据 type_id 作为 key,同 type 的店铺存入同一个 GEO 集合中,在 redis 中存储多个 GEO 集合。
Key | Value | Score |
---|---|---|
shop:geo:美食 | 海底捞 | 40691512240174598 |
吉野家 | 40691519846517915 | |
shop:geo:KTV | KTV 01 | 40691165486458787 |
KTV 02 | 40691514154651657 |
代码实现:
@SpringBootTest
class HmDianPingApplicationTests {@Resourceprivate ShopServiceImpl shopService;@Resourceprivate StringRedisTemplate stringRedisTemplate;@Testpublic void loadShopData() {//1. 查询所有店铺信息List<Shop> shopList = shopService.list();//2. 按照typeId,将店铺进行分组,typeId一致的放到一个集合Map<Long, List<Shop>> map = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));//3. 分批完成写入Redisfor (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {//3.1 获取类型idLong typeId = entry.getKey();//3.2 获取同类型店铺的集合List<Shop> shops = entry.getValue();String key = "shop:geo:" + typeId;//3.3 写入redis GEOADD key 经度 纬度 memberfor (Shop shop : shops) {stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());}}}
}
但是这种写法效率较低,是一条一条写入的,那我们现在来改进一下,一次性将同一 type_id 的数据写到一个 GEO 集合中。
@Testpublic void loadShopData() {//1. 查询所有店铺信息List<Shop> shopList = shopService.list();//2. 按照typeId,将店铺进行分组,typeId一致的放到一个集合Map<Long, List<Shop>> map = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));//3. 分批完成写入Redisfor (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {//3.1 获取类型idLong typeId = entry.getKey();//3.2 获取同类型店铺的集合List<Shop> shops = entry.getValue();String key = "shop:geo:" + typeId;List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(shops.size());//3.3 写入redis GEOADD key 经度 纬度 memberfor (Shop shop : shops) {// 先将当前type的商铺都统一添加到locations集合中locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(), new Point(shop.getX(), shop.getY())));}// 批量写入,将locations集合中所有数据一次性写入stringRedisTemplate.opsForGeo().add(key, locations);}}
运行结果:
3.实现附近商户功能
注意:SpringDataRedis 的 2.3.9 版本并不支持 Redis 6.2 提供的 GEOSEARCH 命令,因此我们需要提升其版本,修改自己的 pom.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>
代码实现:
controller 层:
@RestController
@RequestMapping("/shop")
public class ShopController {@Resourcepublic IShopService shopService;..../*** 根据商铺类型分页查询商铺信息** @param typeId 商铺类型* @param current 页码* @return 商铺列表*/@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);}
}
service 接口:
public interface IShopService extends IService<Shop> {Result queryById(Long id);Result update(Shop shop);Result queryShopByType(Integer typeId, Integer current, Double x, Double y);
}
service 实现类:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic 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 WITHDISTGeoResults<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. 解析出idList<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();if (list.size() < from) {//起始查询位置大于数据总量,则说明没数据了,返回空集合return Result.ok(Collections.emptyList());}//4.1.截取from~end的部分ArrayList<Long> ids = new ArrayList<>(list.size());HashMap<String, Distance> distanceMap = new HashMap<>(list.size());list.stream().skip(from).forEach(result -> {//4.2.获取店铺idString shopIdStr = result.getContent().getName();ids.add(Long.valueOf(shopIdStr));//4.3.获取距离Distance distance = result.getDistance();distanceMap.put(shopIdStr, distance);});//5. 根据id查询shopString 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);}...
}
重启项目,进行测试,发现已经可以正常显示出距离并且排序:
八、用户签到
1.BitMap功能演示
假如我们用一张表来存储用户签到信息,其结构应该如下:
用户签到一次,就是一条记录,假如有 1000W 用户,平均每人每年签到 10 次,那这张表一年的数据量就有 1 亿条。
每签到一次需要使用(8+8+1+1+3+1)共 22 字节的内存,一个月则最多需要 600 多字节。
那有没有方法能简化一点呢?
解决办法:我们可以使用二进制位来记录每个月的签到情况,签到记录为 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 出现的位置 |
2.实现签到功能
需求:实现签到接口,将当前用户当天签到信息保存到 Redis 中
注意:由于 BitMap 底层是基于 String 数据结构,因此其操作也都封装在字符串相关操作中了
代码实现:
controller 层:
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {@Resourceprivate IUserService userService;@Resourceprivate IUserInfoService userInfoService;...@PostMapping("/sign")public Result sign(){return userService.sign();}
}
Service 接口:
public interface IUserService extends IService<User> {Result sendCode(String phone, HttpSession session);Result login(LoginFormDTO loginForm, HttpSession session);Result sign();
}
Service 实现类:
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result sign() {//1. 获取当前用户Long userId = UserHolder.getUser().getId();//2. 获取日期LocalDateTime now = LocalDateTime.now();//3. 拼接keyString 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 1stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);return Result.ok();}...
}
重启项目,使用 PostMan 发送请求测试:
请求地址:http://localhost:8080/api/user/sign
打开 redis,发现的确新增了一条数据,第 23 位(今天 23 号)是 1。
3.签到统计
Question1:什么叫做连续签到天数?
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。
Question2:如何获取本月到今天为止的所有签到数据?
BITFIELD key GET u[dayOfMonth] 0
Question3:如何从后往前遍历每个 bit 位,获取连续签到天数
与 1 做与运算,就能得到最后一个 bit 位。
随后右移 1 位,下一个 bit 位就成为了最后一个 bit 位。
直到与运算的结果为 0 时停止,说明断签了。
需求:实现下面接口,统计当前用户截止当前时间在本月的连续签到天数
代码实现:
controller 层:
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {@Resourceprivate IUserService userService;@Resourceprivate IUserInfoService userInfoService;...@GetMapping("/sign/count")public Result signCount(){return userService.signCount();}
}
Service 接口:
public interface IUserService extends IService<User> {Result sendCode(String phone, HttpSession session);Result login(LoginFormDTO loginForm, HttpSession session);Result sign();Result signCount();
}
Service 实现类:
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result signCount() {//1. 获取当前用户Long userId = UserHolder.getUser().getId();//2. 获取日期LocalDateTime now = LocalDateTime.now();//3. 拼接keyString 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 0List<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是否为0if ((num & 1) == 0) {//6.2.如果为0,说明未签到,结束break;} else//6.3.如果不为0,说明已签到,计数器+1count++;//6.4.数字无符号右移,抛弃最后一个bit位,继续下一个bit位num >>>= 1;}return Result.ok(count);}...
}
重启项目,使用 PostMan 发送请求测试:
请求地址:http://localhost:8080/api/user/sign/count
可以发现,的确返回了正确的连续签到天数。
九、UV统计
1.HyperLogLog用法
UV:全称 Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1 天内同一个用户多次访问该网站,只记录 1 次。
PV:全称 Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录 1 次 PV,用户多次打开页面,则记录多次 PV。往往用来衡量网站的流量。
UV 统计在服务端做会很麻烦,因为要判断该用户是否已经统计过了,需要将统计过的信息保存,但是如果每个访问的用户都保存到 Redis 中,那么数据库会非常恐怖,那么该如何处理呢?
HyperLogLog(HLL)是从 Loglog 算法派生的概率算法,用户确定非常大的集合基数,而不需要存储其所有值,算法相关原理可以参考下面这篇文章:https://juejin.cn/post/6844903785744056333#heading-0
Redis中的 HLL 是基于 string 结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
2.测试百万数据的统计
我们直接使用单元测试,向 HyperLogLog 中添加 100W 条数据,看看内存占用是否真的那么低,以及统计误差如何。
@Testvoid testHyperLogLog(){// 准备数组,装用户数据String[] values = new String[1000];// 数组角标int j = 0;for (int i = 0; i < 1000000; i++) {// 赋值j = i % 1000;values[j] = "user_"+i;// 每1000条发送一次if(j == 999){//发送到redisstringRedisTemplate.opsForHyperLogLog().add("hll1",values);}}// 统计数量Long size = stringRedisTemplate.opsForHyperLogLog().size("hll1");System.out.println("size = " + size);}
插入100W 条数据,得到的 count 为 997593,误差率为 0.002407%。
插入后内存增加:
2242288 - 2227904 = 14384 字节
14384 / 1024 = 14.046875 kb < 16 kb
100W 条数据只占用了 14 kb 的内存,占用率确实是极低!