本篇博客总结了我在学习redis中做的实战项目,实现了网红探店、好友关注、附近商铺和用户签到功能,其中业务部分代码和注释都是我手敲的,前端代码和图片来自黑马程序员
虽然这个项目已经烂大街,但是对于学习项目开发和redis来说仍是一份不错的教材,感谢黑马程序员
课程链接:https://www.bilibili.com/video/BV1cr4y1671t?p=1&vd_source=40ac0553f204ea9791dc385431e71f1c
网红探店
查看探店笔记
在BlogController
中创建:
1 2 3 4
| @GetMapping("/{id}") public Result queryBlogById(@PathVariable("id") Long id) { return blogService.queryBlogById(id); }
|
在service包下实现queryBlogById
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Override public Result queryBlogById(Long 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()); }
|
点赞功能
需求:
- 同一个用户只能点赞一次,再次点击则取消点赞
- 如果当前用户已经点赞,则点赞按钮高亮显示(前端判断isLike属性)
实现步骤:
- 给Blog类中添加一个isLike字段,标识是否被当前用户点赞
- 修改点赞功能,利用redis的set集合判断是否点赞过,未点赞则点赞数+1,反之-1
- 修改根据id查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
- 修改分页查询Blog业务,判断当前用户是否点赞过,赋值给isLike字段
代码实现
在Blog类中添加isLike字段
1 2 3 4 5
|
@TableField(exist = false) private Boolean isLike;
|
修改点赞功能
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
|
@PutMapping("/like/{id}") public Result likeBlog(@PathVariable("id") Long id) { return blogService.likeBlog(id); }
Result likeBlog(Long id);
@Override public Result likeBlog(Long id) { Long userId = UserHolder.getUser().getId(); String key = BLOG_LIKED_KEY + id; Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString()); if (BooleanUtil.isFalse(isMember)) { boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update(); if (isSuccess) { stringRedisTemplate.opsForSet().add(key, userId.toString()); } } else { boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update(); if (isSuccess) { stringRedisTemplate.opsForSet().remove(key, userId.toString()); } } return Result.ok(); }
|
在queryBlogById
方法第二步下添加第三步:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| isBlogLiked(blog);
private void isBlogLiked(Blog blog) { UserDTO user = UserHolder.getUser(); if(user == null) { return; } Long userId = user.getId(); String key = BLOG_LIKED_KEY + blog.getId(); Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString()); blog.setIsLike(BooleanUtil.isTrue(isMember)); }
|
点赞排行榜
|
List |
Set |
SortedSet |
排序方式 |
按添加顺序排序 |
无序 |
根据score值排序 |
唯一性 |
不唯一 |
唯一 |
唯一 |
查找方式 |
按索引查找或首尾查找 |
按元素查找 |
按元素查找 |
- 代码实现:
修改likeBlog
方法: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
| @Override public Result likeBlog(Long id) { Long userId = UserHolder.getUser().getId(); String key = BLOG_LIKED_KEY + id; Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString()); if (score == null) { boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update(); if (isSuccess) { stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis()); } } else { boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update(); if (isSuccess) { stringRedisTemplate.opsForZSet().remove(key, userId.toString()); } } return Result.ok(); }
|
修改isBlogLiked
方法:
1 2 3 4 5 6
| private void isBlogLiked(Blog blog) { Long userId = UserHolder.getUser().getId(); String key = BLOG_LIKED_KEY + blog.getId(); Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString()); blog.setIsLike(BooleanUtil.isTrue(score != null)); }
|
实现点赞列表查询
在BlogController
中创建:
1 2 3 4
| @GetMapping("/likes/{id}") public Result queryBlogLikes(@PathVariable("id") Long id) { return blogService.queryBlogLikes(id); }
|
在IBlogService
中创建:
Result queryBlogLikes(Long 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
|
@Override public Result queryBlogLikes(Long id) { String key = BLOG_LIKED_KEY + id; 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()); String idStr = StrUtil.join(",", ids); 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()); return Result.ok(userDTOS); }
|
这里有一点需要注意,一开始我这样查询:
1 2 3 4
| List<UserDTO> userDTOS = userService.listByIds(ids) .stream() .map(user -> BeanUtil.copyProperties(user, UserDTO.class)) .collect(Collectors.toList());
|
这样会导致查出来的数据是倒序,原因是数据库调用的查询语句是SELECT ... FROM ... WHERE id IN(5, 1);
,问题就出在IN
,我们可以在后面跟上ORDER BY FIELD(id, 5, 1)
来实现自定义排序。
关注/取关功能
实现关注/取关功能
在FollowController
类中定义接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @Resource private IFollowService iFollowService;
@PutMapping("/{id}/{isFollow}") public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow){ return iFollowService.follow(followUserId, isFollow); }
@GetMapping("/or/not/{id}") public Result isFollow(@PathVariable("id") Long followUserId){ return iFollowService.isFollow(followUserId); }
|
在IFollowService
类下创建:
1 2 3
| Result follow(Long followUserId, Boolean isFollow);
Result isFollow(Long followUserId);
|
- 代码实现:
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
| @Override public Result follow(Long followUserId, Boolean isFollow) { Long userId = UserHolder.getUser().getId(); String key = "follow:" + userId; if (isFollow) { Follow follow = new Follow(); follow.setUserId(userId); follow.setFollowUserId(followUserId); boolean isSuccess = save(follow); if (isSuccess) { stringRedisTemplate.opsForSet().add(key, followUserId.toString()); } } else { boolean isSuccess = remove(new QueryWrapper<Follow>() .eq("user_id", userId) .eq("follow_user_id", followUserId)); if (isSuccess) { stringRedisTemplate.opsForSet().remove(key, followUserId.toString()); } } return Result.ok(); }
@Override public Result isFollow(Long followUserId) { Long userId = UserHolder.getUser().getId(); Integer count = query().eq("user_id", userId) .eq("follow_user_id", followUserId).count(); return Result.ok(count > 0); }
|
共同关注
在UserController
中添加以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
@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); }
|
在BlogController
中添加以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
@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中的SINTER
实现求交集,把关注用户放入redis中
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
| @Override public Result follow(Long followUserId, Boolean isFollow) { Long userId = UserHolder.getUser().getId(); String key = "follows:" + userId; if (isFollow) { Follow follow = new Follow(); follow.setUserId(userId); follow.setFollowUserId(followUserId); boolean isSuccess = save(follow); if (isSuccess) { stringRedisTemplate.opsForSet().add(key, followUserId.toString()); } } else { boolean isSuccess = remove(new QueryWrapper<Follow>() .eq("user_id", userId) .eq("follow_user_id", followUserId)); if (isSuccess) { stringRedisTemplate.opsForSet().remove(key, followUserId.toString()); } } return Result.ok(); }
|
实现共同关注接口,在FollowController
中添加:
1 2 3 4
| @GetMapping("/common/{id}") public Result followCommons(@PathVariable("id") Long id){ return iFollowService.followCommons(id); }
|
在IFollowService
中添加:
Result followCommons(Long id);
在FollowServiceImpl
中添加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| @Override public Result followCommons(Long id) { Long userId = UserHolder.getUser().getId(); String key1 = "follows:" + userId; String key2 = "follows:" + id; Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2); 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流产品有两种常见模式:
- **Timeline:**不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
- 优点:信息全面,不会有缺失。并且实现也相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
- 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
- 优点:投喂用户感兴趣的信息,用户粘性很高,容易沉迷
- 如果算法不精准,可能起到反作用
本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline模式。该模式实现方案有三种:
- 拉模式:也叫读扩散。只有用户在读的时候才会获取一个副本回来
- 推模式:也叫写扩散。消息直接推送到粉丝收件箱
- 推拉结合:也叫读写混合。普通粉丝用推模式,活跃粉丝用拉模式,既节省了内存,又照顾了活跃于用户的感受。
|
拉模式 |
推模式 |
推拉结合 |
写比例 |
低 |
高 |
中 |
读比例 |
高 |
低 |
中 |
用户读取延迟 |
高 |
低 |
低 |
实现难度 |
复杂 |
简单 |
很复杂 |
使用场景 |
很少使用 |
用户量少,没有大V |
过千万的用户量,有大V |
基于推模式实现关注推送功能
需求:
- 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
- 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
- 查询收件箱数据时,可以实现分页查询
改造BlogController
中的saveBlog
:
1 2 3 4 5 6
| @PostMapping public Result saveBlog(@RequestBody Blog blog) { return blogService.saveBlog(blog); }
Result saveBlog(Blog blog);
|
在BlogServiceImpl
中实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @Override public Result saveBlog(Blog blog) { UserDTO user = UserHolder.getUser(); blog.setUserId(user.getId()); boolean isSuccess = save(blog); if (!isSuccess) { return Result.fail("保存博文失败!"); } List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list(); for (Follow follow : follows) { Long userId = follow.getUserId(); String key = FEED_KEY + userId; stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis()); } return Result.ok(blog.getId()); }
|
滚动分页查询
Feed流推送不能使用传统的分页查询,因为数据会实时更新,在redis中有两种数据结构支持分页查询,分别是List
和SortedSet
,这里使用SortedSet
,因为list只能实现角标查询,如果数据更新,会出现重复读取,导致数据混乱。
滚动分页查询参数:
max: 当前时间戳 | 上一次查询的最小时间戳
min: 0
offset: 0 | 在上一次的结果中,与最小值一样的元素的个数
count: 跟前端约定好,固定值
在DTO
中定义ScrollResult
类:
1 2 3 4 5 6 7 8 9
| @Data public class ScrollResult { private List<?> list; private Long minTime; private Integer offset; }
|
在BlogController
中定义接口:
1 2 3 4 5 6 7 8
| @GetMapping("/of/follow") public Result queryByBlogOfFollow( @RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset) { return blogService.queryBlogOfFollow(max, offset); }
Result queryBlogOfFollow(Long max, Integer 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 48 49 50
|
@Override public Result queryBlogOfFollow(Long max, Integer offset) { Long userId = UserHolder.getUser().getId(); 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(); } ArrayList<Long> ids = new ArrayList<>(typedTuples.size()); long minTime = 0; int os = 1; for (ZSetOperations.TypedTuple<String> tuple : typedTuples) { ids.add(Long.valueOf(tuple.getValue())); long time = tuple.getScore().longValue(); if (time == minTime) { os++; } else { minTime = time; os = 1; } } String idStr = StrUtil.join(",", ids); List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list(); for (Blog blog : blogs) { queryBlogUser(blog); isBlogLiked(blog); } ScrollResult r = new ScrollResult(); r.setList(blogs); r.setMinTime(minTime); r.setOffset(os); return Result.ok(r); }
|
附近商户
GEO数据结构
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令者:
GEOADD
:添加一个地理空间信息,包含:经度( longitude)、纬度(latitude).值( member)
GEODIST
:计算指定的两个点之间的距离并返回
GEOHASH
:将指定member的坐标转为hash字符串形式并返回
GEOPOS
:返回指定member的坐标
GEORADIUS
:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.2以后已废弃
GEOSEARCH
:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
GEOSEARCHSTOR
:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。6.2.新功能
附近商户搜索
按照商户类型做分组,类型相同的商户作为一组,以typeId
为key存入同一个GEO集合中即可。
用单元测试导入数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| @Test void loadShopData() { List<Shop> list = shopService.list(); Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy((Shop::getTypeId))); for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) { Object typeId = entry.getKey(); String key = SHOP_GEO_KEY + typeId; List<Shop> value = entry.getValue(); List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(); for (Shop shop : value) { locations.add(new RedisGeoCommands.GeoLocation<>( shop.getId().toString(), new Point(shop.getX(), shop.getY()) )); } stringRedisTemplate.opsForGeo().add(key, locations); } }
|
改造ShopController
中的接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
@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去做:
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
|
@Override public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) { 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()); } int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE; int end = current * SystemConstants.DEFAULT_PAGE_SIZE; String key = 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()); } 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); }); 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()); } return Result.ok(shops); }
|
用户签到
假设平台有1000万用户,每人每年签到10次,这么庞大的数据量如果用数据库存储显然是不现实的,这里我们按月统计用户签到信息,签到记录为1,未签到则记录为0,这样只需要使用31bit就可以保存一个用户当月的签到记录。
把每一个bit位对应当月的每一天,形成映射关系。用0和1表示业务状态,这种思路就称为位图(BitMap)。
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的结果做位运算(与、或、异或)
BITOPS
:查找bit数组中指定范围内第一个0或1出现的位置
实现签到功能
在UserController
中创建接口:
1 2 3 4
| @PostMapping("/sign") public Result sign() { return userService.sign(); }
|
在IUserService
中创建:
Result sign();
在实现类中写具体业务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Override public Result sign() { Long userId = UserHolder.getUser().getId(); LocalDateTime now = LocalDateTime.now(); String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM")); String key = USER_SIGN_KEY + userId + keySuffix; int dayOfMonth = now.getDayOfMonth(); stringRedisTemplate.opsForValue().setBit(key, dayOfMonth, true); return Result.ok(); }
|
签到统计
问题1:什么叫做连续签到天数?
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。
问题2:如何得到本月到今天为止的所有签到数据?
BITFILED key GET u[dayOfMonth] 0
问题3:如何从后往前遍历每个bit位?
与1做与运算,就能得到最后一个bit位,随后右移1位,下一个bit位就成了最后一个bit位。
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
| @GetMapping("/sign/count") public Result signCount(){ return userService.signCount(); }
Result signCount();
@Override public Result signCount() { Long userId = UserHolder.getUser().getId(); LocalDateTime now = LocalDateTime.now(); String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM")); String key = USER_SIGN_KEY + userId + keySuffix; int dayOfMonth = now.getDayOfMonth(); 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); } int count = 0; while (true) { if ((num & 1) == 0) { break; } else { count++; } num >>>= 1; } return Result.ok(count); }
|