1. Redis实现点赞
点赞业务可以有多种实现,常见的有两种:
- 在数据库表中增加字段,已经点赞了就设为1,没有点赞就设为0,但是与数据库的频繁交互使得效率不高
- 基于Redis的实现,用户点赞后,将用户存入redis,在用户操作前,先判断redis中是否有该用户,如果没有,可以点赞,并且数据库点赞数+1,如果有,则可以取消点赞,数据库点赞数-1。并且为了方便后续业务的拓展,我们这里采用sorted_set这种数据结构,既不能存重复值,又能够实现排序。
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result isLike(Long id) {
// 1.获取登录用户 (我这里在用户登录之后,将用户存在了ThreadLocal里面,方便后续取出)
Long userId = UserHolder.getUser.getId(); // UserHolder是我编写的一个操作ThreadLocal的工具类,也可以直接使用ThreadLocal操作。
// 2.判断当前用户是否已经点赞
String key = "blog:liked:" + id; // 首先拼接key,在id前面加上业务名称,更有结构性
// 3.由于我们使用的sorted_set,不能直接判断是否存在该元素,只能通过查询它所对应的分数,分数为null,则为不存在
Double score = stringRedsiTemplate.opsForZSet().score(key,userId.toString());
if(score == null) {
// 3.未点赞,可以点赞
// 3.1 数据库点赞数+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id",id).update();
// 3.2 保存用户id到redis中
if(isSuccess) {
stringRedisTemplate.opsForZSet.add(key,userId.toString(),System.currentTimeMillis());// 这里用时间戳来表示分数,就可以实现排序了。
}
}else {
// 4.已经点赞了,可以取消点赞
// 4.1 数据库点赞数-1
boolean isSuccess = update().setSql("liked = liked - 1").eq("id",id).update();
// 4.2 把用户从redis中删除
if(isSuccess) {
stringRedisTemplate.opsForZSet().remove(key,userId.tostring());
}
}
return Result.ok();
}
2. 点赞列表查询
基于上面的点赞业务,我们可以实现点赞列表的查询业务
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryBlogLikes(Long id) {
// 1.查询前五名的点赞用户 zrange key 0 4
String key = "blog:liked:" + id; // 首先还是拼接key
Set<String> top5 = stringReidsTemplate.opsForZSet().range(key,0,4);
if(top5 == null || top5.isEmplty()){
return Result.ok();
}
// 2.从set集合中解析出用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
// 3.根据id查询用户,但是为了保护用户隐私,我们只展示用户一些不重要的信息,比如昵称头像,因此我编写了一个UserDTO,将查询出来的User -> UserDTO
List<UserDTO> userDTOS = userService.listByIds(ids).stream().map(user -> BeanUtil.copyProperties(UserDTO.class)).collect(Collectors.toList());
// 4.返回
return Result.ok(userDTOS);
}
3. 关注和取关
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result follow(Long followUserId,Boolean isFollow) {
// 1.获取登录用户
UserDTO = UserHolder.getUser();
if(user == null) {
return Result.ok();
}
Long userId = user.getId();
// 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 followUserId
String key = "follows:" + userId;
stringRedisTemplate.opsForSet().add(key,followUserId.toString());
}else {
// 4.取关,删除数据 delete from tb_follow where userId = ? and follow_user_id = ?
remove(new QueryWrapper<Follow>().eq("user_id",userId).eq("follow_user_id",followUserId));
// 把关注的用户id移除redis
if (isSuccess) {
String key = "follows:" + userId;
stringRedisTemplate.opsForSet().remove(key,followUserId.toString());
}
}
return Result.ok();
}
// 查询是否关注
@Override
public Result isFollow(Long followUserId) {
// 获取用户id
Long userId = UserHolder.getUser().getId();
// 查询是否存在 select count from tb_follow where user_id = ? and follow_user_id = ?
Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
// 判断count是否 > 0
return Result.ok(count > 0);
}
4. 共同关注
利用redis中的set数据结构,实现共同关注功能,在博主的个人主页展示出当前用户和博主的共同关注
// 共同关注
@Override
public Result followCommons(Long id) {
// 获取当前用户
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
String key2 = "follows:" + id;
// 求交集
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
if (intersect == null || intersect.isEmpty()) {
return Result.ok();
}
// 解析id集合
List<Long> list = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
// 查询用户
List<UserDTO> userDTOS = userService.listByIds(list)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(userDTOS);
}
5. 关注推送
关注推送也叫feed流,直译为投喂。为用户持续提供沉浸式体验,通过无限下拉刷新获取新的信息
Feed流的模式
Feed流产品有两种常见的模式:
-
TimeLine流:不做内筒筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
- 优点:信息全面,不会有缺失,并且实现也相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
-
智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
- 优点:投喂用户感兴趣信息,用户粘度高,容易沉迷
- 缺点:如果算法不精确,可能起到反作用
我们这里实现基于推模式的关注推送功能
Feed流的滚动分页:由于Feed流中的数据会不断更新,因此数据的角标也在不断变化,因此不能采用传统的分页模式 (list和sorted_list都能实现排序,但list不能实现滚动分页,因此我们使用sorted_set)
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1.获取当前用户
Long userId = UserHolder.getUser().getId();
// 2.查询收件箱 zrevrangebyscore key Max Min Limit offset count
String key = "feed:" + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
// 3.非空判断
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok();
}
// 4.解析数据:blogId,minTime(时间戳),offset
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0;
int os = 1;
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
// 4.1 获取id
ids.add(Long.valueOf(tuple.getValue()));
// 4.2 获取分数(时间戳)
long time = tuple.getScore().longValue();
if (time == minTime) {
os++;
}else {
minTime = time;
os = 1;
}
}
// 5.根据id查询blog
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id",ids).last("order by field(id," + idStr + ")").list();
blogs.forEach(blog -> {
// 5.1 查询blog有关的用户
queryBlogUser(blog);
// 5.2 查询blog是否被点赞
isBlogLiked(blog);
});
// 6.封装并返回
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os);
r.setMinTime(minTime);
return Result.ok(r);
}
6. 附近商户
GEO数据结构
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2的版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据,常见的命令有:
- GEOADD:添加一个地理空间信息,包含:经度、维度、值
- GEODIST:计算指定的两个点之间的距离并返回
- GEOHASH:将指定member的坐标转为hash字符串形式并返回
- GEOPOS:返回指定的member坐标
- GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回,6.2以后已废弃
- GEOSEARCH:在指定返回内搜索member,并按照与指定点之间的距离排序后并返回。范围可以是圆形或矩形。6.2新功能
- GEOSERACHSTORE:与GEOSEARCH的功能一直,不过可以把结果存储到一个指定的key,6.2新功能
// 查询坐标
@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,按照距离排序,分页。结果:shopId、distance
String key = "shop:geo:" + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
.search(
key,
GeoReference.fromCoordinate(x, y),
new Distance(5000), // 5000m
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeCoordinates().limit(end)
);
// 4.解析出id
if (results == null) {
return Result.ok();
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
if(list.size() <= from) {
// 没有下一页
return Result.ok();
}
// 截取 from ~ end
List<Long> ids = new ArrayList<>(list.size());
Map<String,Distance> distanceMap = new HashMap<>();
list.stream().skip(from).forEach(result -> {
// 获取店铺id
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
// 获取距离
Distance distance = result.getDistance();
distanceMap.put(shopIdStr,distance);
});
// 5.根据id查询shop
String idStr = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids).last("order by field(id," + idStr + ")").list();
shops.forEach(shop -> {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
});
// 6.返回
return Result.ok(shops);
}
7. 用户签到
BitMap用法
Redis是利用string类型数据结构实现BitMap,因此最大上限是512M
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出现的位置
// 统计连续签到
@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 = "sign:" +userId + keySuffix;
// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5.获取本月截止今天为止的所有签到记录,返回的是一个十进制的数字 BITFIELD key GET u11 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();
}
Long num = result.get(0);
if (num == null || num == 0) {
return Result.ok();
}
// 6.循环遍历
int count = 0;
while (true) {
// 6.1 让这个数字与1做与运算,得到数字的最后一个bit位,判断这个bit位是否为0 (数与1做与运算等于数本身)
if ((num & 1) == 0) {
// 如果0,说明未签到,结束
break;
}else {
// 如果不为0,说明已经签到,计数器+1
count++;
}
// 把数字右移一位,抛弃最后一个bit位,继续下一个bit位
num >>>= 1;
}
return Result.ok(count);
}
8.UV统计
- UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问。浏览这个网页的自然人。一天内同一个用户多次访问该网站,只记录一次
- PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录一次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存在redis中,数据量会非常恐怖。
Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。
Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用率极低!作为代价,其测量结果是概率性的,有小于0.81%的误差,不对这对于UV统计来说,这完全可以忽略。
模拟UV统计的实现
@Test
void testHyperLoglog() {
String[] users = new String[];
int j = 0;
for(int i = 0;i < 1000000;i++) {
j = i % 1000;
users[j] = "user_" + i;
if(j = 999) {
// 发送到redis
stringRedisTemplate.opsForHyperLogLog().add("hll",users);
}
}
// 统计数量
Long count = stringRedisTemplate.opsForHyperLogLog().size("hll");
}