目录
前言:
实现达人探店,好友关注,附件商铺,用户签到,UV统计
1.达人探店功能
1.1.点赞功能
分析:实现点赞功能,那么一个用户是不是只能点赞一次,如果能进行多次点赞(这不刷起来了吗)
思路:一个用户只能点赞一次,点亮了,赞图标就高亮显示,再次点亮就是取消点赞
实现方案:
- 数据库:数据库中创建一张表,每次用户点赞时,先访问数据库,数据库有对应数据,代表已经点赞,因此实现取消点赞功能(删除数据库对应数据),没有数据,则代表没有点赞,添加对应数据到数据库中
- Redis:一人一赞,那么我们使用set集合(不可重复性),key为发笔记人的id,value为点赞用户id
- 同时采用数据库和Redis:Redis做缓存实现快速查询,数据库做数据持久化
**优化:**由于前端需要后端返回一个布尔值,来判断是否点赞(是否显示高亮),而老师采用的是每次查询操作是先查询数据再判断是否点赞,但是由于我们笔记实体类中已经有了这个属性(布尔),我们可以直接在调用点赞业务代码时(你都在这设置了是否点赞了,那你直接将值赋值给属性即可)
**问题:**如果我们使用老师的方案,在每次查询业务时都需要查询数据库,根据登录用户id查,如果用户没有登录呢?你的查询操作就进行不下去了(要判断id是否为空),没有登录的用户连界面都访问不了
java
@Override
public Result likeBlog(Long id) {
//设置Redis的key
String key = RedisConstants.BLOG_LIKED_KEY + id;
//查询Redis
Long userId = UserHolder.getUser().getId();
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
//判断是否存在
if (score != null) {
//Redis中已经存在
//更新数据库
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
if (isSuccess) {
//数据库更新成功后,才能删除Redis
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
} else {
//不存在
//更新数据库
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
if (isSuccess) {
//数据库更新成功后,才能更新Redis
stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
}
}
Blog blog = getById(id);
blog.setIsLike(score == null);
return Result.ok();
}
1.2.排行榜
**分析:**由于我们是要对点赞时间用户的排序(统计前五名先点赞的用户),因为是点赞用户肯定不止一个,因此我们需要使用集合的方式,并且是一个查询操作,可以采取Redis
集合分析:
set集合:由于我们是要统计排名,需要对数据进行排序,而set集合是无序的(不能使用)
list集合:一个用户只能点赞一次,那么就代表数据不能重复对吧,list集合是按照添加顺序来排序,但是它的原理是基于链表(可以重复),并且由于是链表代表它的查询速度慢(索引查询,首尾查),如果有大量数据需要查询,等待时间太长了
sortedset集合:该集合不允许重复(保证唯一性),并且它可以根据你给定的score(分数)进行排序(默认从小到大)
**思路:**使用sortedset集合,我们需要统计前五名点赞的用户,是不是就是统计先点赞的用户,那么在用户点赞时会调用点赞业务,我们在业务中将原先的使用的存入Redis采用的set集合代码转变成sortedset集合代码即可(我们的score可以设置为时间戳,直接根据时间戳来排序)
查询排行榜:
Redis查询数据
==》判断数据是否存在
==》不存在(没人点赞),直接返回空
==》存在
==》将数据过滤,留下用户id
==》根据用户id查询数据库
==》将数据再次转变成前端要的数据
==》返回数据

java
@Override
public Result queryLikes(Long id) {
//查询Redis
String key = RedisConstants.BLOG_LIKED_KEY + id;
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
//判断是否存在
if (top5 == null || top5.isEmpty()){
return Result.ok();
}
//转换格式
List<Long> ids = top5.stream().map(Long::valueOf).toList();
//根据ids查询
List<UserDTO> userDTOS = userService
.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.toList();
String join = StrUtil.join(",", ids);
return Result.ok(userDTOS);
}
**问题:**虽然我们从Redis中取出的数据是按照时间顺序排序的,但是由于我们使用的是mp的list查询方式,它会默认根据id的顺序查询(从小到大),并不会按照我们指定的顺序查询,最终我们查询出来的数据依然是排序错误(数据库原理:in()查询默认使用id正序查询)
**解决:**不使用这个查询方式,自己写sql语句即可
java
@Override
public Result queryLikes(Long id) {
//查询Redis
String key = RedisConstants.BLOG_LIKED_KEY + id;
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
//判断是否存在
if (top5 == null || top5.isEmpty()){
return Result.ok();
}
//转换格式
List<Long> ids = top5.stream().map(Long::valueOf).toList();
//根据ids查询
String join = StrUtil.join(",", ids);
List<UserDTO> userDTOS = userService
.query()
.in("id", ids)
.last("ORDER BY FIELD(id," + join + ")").list()
.stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).toList();
return Result.ok(userDTOS);
}
**解释:**因为我们设置score时设置的是每次点赞的时间戳,因此我们可以直接根据时间戳来查询排行榜(默认正序),所以我们只需要根据索引查询5个就行(索引默认从0开始:0到4即可)
2.关注
2.1.共同关注
分析:共同关注代表的是不是就是两个人关注的交集,并且我们依然是查询判断操作,使用Redis,使用哪个数据结构呢?求交集,使用set集合
java
@Override
public Result followCommon(Long followId) {
//1.获取用户id
Long userId = UserHolder.getUser().getId();
//2.设置key
String key1 = "follows:" + userId;
String key2 = "follows:" + followId;
//3.Redis中查询交集
Set<String> set = stringRedisTemplate.opsForSet().intersect(key1, key2);
//4.转换格式
if (set == null || set.isEmpty()){
return Result.ok(Collections.emptyList());
}
List<Long> ids = set.stream().map(Long::valueOf).toList();
//5.查询数据库
List<UserDTO> userDTOS = userService.listByIds(ids)
.stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).toList();
return Result.ok(userDTOS);
}
2.2.关注推送
**介绍:**关注推送也叫Feed流(投喂),通过无限下拉刷新数据,获取信息(刷视频)
模式:
- 传统模式:用户需要自己去寻找信息,自己鉴别这个信息是不是自己想要的(用户寻找内容)
- Feed流:自动根据用户的行为来匹配数据,直接推送你想要的数据(大数据)(刷视频根据你的停留时间来给你推送该类型的视频)(内容匹配用户)
Feed流的模式:
Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
优点:信息全面,不会有缺失。并且实现也相对简单
缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
缺点:如果算法不精准,可能起到反作用
总结:由于我们是个人空间的笔记展示,使用第一种即可
Feed流的实现方案:
- 拉模式(读扩散)
- 推模式(写扩散)
- 推拉结合
拉模式:
介绍:只有当用户读这篇笔记时才会加载(发件箱--收件箱)
例子:每个发信的用户(作者)都有对应的发件箱(存储笔记的空间),当张三发布了一篇笔记,李四发布了两篇笔记,他们各自的笔记会发送到自己的发件箱中(并且按照时间戳排序(互通,共享)从小到大不会重复),而王五关注了张三,但是王五平时不会访问张三,所以王五的收件箱是空的,只有当王五去访问这篇笔记时(收件箱才会加载),才会拉取张三的消息(笔记)(一个个拉),拉取完成后会进行时间排序(从小到大)
- 优点:节省内存空间(因为一般是空的)
- 缺点:每次都需要重新拉取并且进行排序(耗时长,延迟长)
总结:拉模式(只有当你读的时候才会加载)(读扩散)
拉模式:
介绍:用户发了消息直接就传递到收件箱中(写扩散)
例子:没有发件箱了,只有收件箱,当张三发送消息,会直接将消息推送到他的所以粉丝的收件箱中,然后进行排序
- 优点:延迟低
- 缺点:有多少粉丝就需要发送多少份(内存占用高)
推拉结合:
介绍:读写混合
例子:有两个作者,一个作者张三是一个拥有千万粉丝的大V博主,另一个作者李四是一个粉丝量少的普通博主,当李四发消息时,采用推模式(粉丝少,直接发),大V发消息时,根据粉丝的活跃度做处理,由于活跃的粉丝相较于是少的,那么活跃的粉丝采用推模式,普通粉丝采用拉模式(不经常访问)
- 优点:节省内存空间,延迟低
- 缺点:实现复杂
分析:由于我们用户少,所以采用推模式,我们要展示的是笔记,肯定是先展示最新发布的笔记,然后依次展示,所以我们需要进行根据发布时间进行排序,我们还是进行查找操作,依然选择Redis来进行缓存,使用哪个集合呢?感觉list和sortedset都可以
**思考:**我们使用的是Feed流,我们的数据是不是在不断更新,因此索引也一直在变化(首尾一样),而List不支持流动处理,因此排除,并且由于我们数据不断变化的,我们能才用线性分页查询吗?
**例子:**如果采用线性查询,每次查询五条数据,第一次查询完后,更新了两条数据(按时间发布排序,他们会挤掉先前的第一第二),所以当我们第二次查询时会查询到重复的数据
实现:使用滚动分页查询,每次记录查询的最后一个数据的score值即可,下次就从这查询(要保证有序)
**问题:**那么第一次查询呢?我们没有记录数据啊
解决:由于我们是按从大到小排序,我们直接使用一个无限大的值即可,并且我们不能使用sortedset的按排名查(排名会变),只能使用范围查询(score值不变)
**细节:**如果我们记录查询的最后一个数据的score值时,它有重复值(多个),那么你怎么知道你是从哪个查询,因此我们需要记录最后一个值的重复值有几个(跳过即可)
java
@Override
public Result queryBlogFollow(Long max, Integer offset) {
//1.获取用户id
Long userId = UserHolder.getUser().getId();
//2.根据id获取Redis中的数据
String key = "feed:" + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0F, max, offset, 3);
//3.判断是否为空
if (typedTuples == null || typedTuples.isEmpty()){
return Result.ok();
}
//4.获取查询博客的id
List<Long> ids = new ArrayList<>(typedTuples.size());
//5.设置时间初始值
int os = 1;
long minTime = 0;
for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
ids.add(Long.valueOf(typedTuple.getValue()));
long time = typedTuple.getScore().longValue();
if(time == minTime){
os++;
}else {
minTime = time;
os = 1;
}
}
//6.查询数据库
String join = StrUtil.join(",", ids);
List<Blog> blogs = query()
.in("id", ids)
.last("ORDER BY FIELD(id," + join + ")").list();
BlogData blogData = new BlogData();
blogData.setList(blogs);
blogData.setOffset(os);
blogData.setMinTime(minTime);
return Result.ok(blogData);
}
3.附近商铺
3.1.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.新功能
- GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key
3.2.获取附近商铺
分析:还是查询,使用Redis
注意:由于我们用户可以选择不同的分类来进行查询店铺,不一定就是距离查询,因此我们需要判断
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.DEFAULT_PAGE_SIZE;
int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
//3.查询Redis,按照距离排序,分页
String key = RedisConstants.SHOP_GEO_KEY + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo().search(key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end));
//4.解析出id
if (results == null) {
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
//4.1截取from - end
if (list.size() <= from) {
return Result.ok(Collections.emptyList());
}
List<Long> ids = new ArrayList<>(list.size());
Map<String,Distance> distanceMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(result -> {
//4.2.获取店铺id
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
//4.3.获取距离
Distance distance = result.getDistance();
distanceMap.put(shopIdStr,distance);
});
//5.根据id查询店铺信息
String idsStr = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idsStr + ")").list();
for (Shop shop : shops) {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
return Result.ok(shops);
}
4.签到
4.1.BitMap
分析:如果我们使用数据库将每天用户签到的数据一一记录下来,那么我们将要存储大量数据,成本大,因此我们需要使用一个简单的方式来实现签到记录数据的实现
**简单方法:**签到只有两个选择:签到还是没有签到,我们二进制是不是只有0和1,那么我们可以使用1代表签到的,0代表未签到的,怎么统计每一月,每一年的数据呢,使用Redis key为时间前缀,value为签到值
实现:一般是统计一个月的签到情况,因此我们key前缀具体到月,value的二进制位数为32位即可,因为类型简单,使用Redis的String类型(BitMap)即可
BitMap用法:
介绍:Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是2A32个bit位。
BitMap的操作命令有:
- SETBIT:向指定位置(offset)存入一个0或1
- GETBIT:获取指定位置(offset)的bit值
- BITCOUNT:统计BitMap中值为1的bit位的数量
- BITFIELD:操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
- BITFIELD_RQ:获取BitMap中bit数组,并以十进制形式返回
- BITOP:将多个BitMap的结果做位运算(与、或、异或)
- BITPOS:查找bit数组中指定范围内第一个0或1出现的位置
4.2.实现签到
java
@Override
public Result sign() {
//1.获取用户id
Long userId = UserHolder.getUser().getId();
//2.获取当前时间
LocalDateTime now = LocalDateTime.now();
//3.设置时间
String keyStrTime = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keyStrTime;
//4.获取天数
int dayOfMonth = now.getDayOfMonth();
//5.写入Redis
stringRedisTemplate.opsForValue().setBit(key,dayOfMonth-1,true);
return Result.ok();
}
4.3.统计连续签到
java
@Override
public Result signCount() {
//1.获取用户id
Long userId = UserHolder.getUser().getId();
//2.获取当前时间
LocalDateTime now = LocalDateTime.now();
//3.设置时间
String keyStrTime = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keyStrTime;
//4.获取天数
int dayOfMonth = now.getDayOfMonth();
//5.查询Redis,查询本月数据
List<Long> list = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));
if(list == null || list.isEmpty()){
return Result.ok(0);
}
Long aLong = list.get(0);
if(aLong == null || aLong == 0){
return Result.ok(0);
}
//6.循环遍历
int count = 0;
while(true){
//做与运算
if((aLong & 1) == 0){
//跳出循环
break;
}else {
//计数器加一
count++;
}
//向右移动一位
aLong >>>= 1;
}
return Result.ok(count);
}
5.UV统计
UV: 全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
**分析:**UV统计在服务端·实现很困难,因为要判断该用户是否已经统计过了,那么就需要查询数据,因此我们的已经访问的用户信息都需要保存到Redis中,如果用户量大,需要考虑内存
解决:
