redis-附近&签到&UV

1 附近的店铺

1.1 GEO数据结构的基本用法

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

  • GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
  • GEODIST:计算指定的两个点之间的距离并返回
  • GEOHASH:将指定member的坐标转为hash字符串形式并返回
  • GEOPOS:返回指定member的坐标
  • GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.以后已废弃
  • GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
  • GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。 6.2.新功能。

1.2 附近的店铺导入GEO

在这里插入图片描述

分析shop对应的数据库表,发现有一个typeId字段,表明是什么类型的,那么此时可以根据这个字段将店铺进行分组

在这里插入图片描述

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
@Test
void loadShopData() {
// 1.查询店铺信息
List<Shop> list = shopService.list();
// 2.把店铺按照typeId分组,typeId一样的分一组
Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));

// 3.分批完成写入Redis
Set<Map.Entry<Long, List<Shop>>> entries = map.entrySet();
for (Map.Entry<Long, List<Shop>> entry : entries) {
// 3.1获取typeId
Long typeId = entry.getKey();

// 3.2获取同类型的店铺的集合
List<Shop> value = entry.getValue();

String key = RedisConstants.SHOP_GEO_KEY + typeId;
// 3.3 写入Redis

// 方法一:打开shop实体类集合,一条店铺一条店铺添加(比较慢)
/*for (Shop shop : value) {
stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
}*/

// 方法二:locations
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
for (Shop shop : value) {
locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(),
new Point(shop.getX(), shop.getY())));
}
stringRedisTemplate.opsForGeo().add(key, locations);
}
}

1.3 获取附近的店铺

SpringDataRedis的2.3.9版本并不支持Redis 6.2提供的GEOSEARCH命令,因此我们需要提示其版本,修改自己的POM

1
2
3
4
5
6
7
8
9
10
<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>
1
2
3
4
5
6
7
8
9
@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);
}
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
/**
* @param
* @return void
* @description //根据商铺类型分页查询商铺信息(加入坐标)
**/
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 1.判断是否需要根据坐标查询,如果需要再按照坐标
if (null == x || null == y) {
// 根据类型分页查询
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
// GEOSEARCH g1 FROMLONLAT 116.397904 39.909005 BYRADIUS 10 km WITHDIST
String key = RedisConstants.SHOP_GEO_KEY + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
.search(key,
GeoReference.fromCoordinate(x, y),
new Distance(RedisConstants.GEO_DISTANT),
RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().limit(end)
);

// 4.解析shopId
if (null == results) {
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
ArrayList<Long> shopIds = new ArrayList<>(list.size());
Map<String, Distance> map = new HashMap<>(list.size());
// 4.1 截取从from 到 end
list.stream().skip(from).forEach(result -> {
// 4.2获取店铺id
String shopIdStr = result.getContent().getName();
shopIds.add(Long.valueOf(shopIdStr));

// 4.3获取距离
Distance distance = result.getDistance();

map.put(shopIdStr, distance);
});

// 5.根据id查询shop
String joinStr = StrUtil.join(",", shopIds);
List<Shop> shops = query().in("id", shopIds).last("order by field(id," + joinStr + ")").list();
for (Shop shop : shops) {
shop.setDistance(map.get(shop.getId().toString()).getValue());
}

// 6.返回
return Result.ok(shops);
}

但是此时测试的时候,到最后的时候向下拉却报错了

在这里插入图片描述

原因分析:

在这里插入图片描述

最后在截取的时候,跳过之后就没有任何值了

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
/**
* @param
* @return void
* @description //根据商铺类型分页查询商铺信息(加入坐标)
**/
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 1.判断是否需要根据坐标查询,如果需要再按照坐标
if (null == x || null == y) {
// 根据类型分页查询
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
// GEOSEARCH g1 FROMLONLAT 116.397904 39.909005 BYRADIUS 10 km WITHDIST
String key = RedisConstants.SHOP_GEO_KEY + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
.search(key,
GeoReference.fromCoordinate(x, y),
new Distance(RedisConstants.GEO_DISTANT),
RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().limit(end)
);

// 4.解析shopId
if (null == results) {
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
ArrayList<Long> shopIds = new ArrayList<>(list.size());
if(list.size () <= from) {
// 已经没有下一页了
return Result.ok(Collections.emptyList());
}
Map<String, Distance> map = new HashMap<>(list.size());
// 4.1 截取从from 到 end
list.stream().skip(from).forEach(result -> {
// 4.2获取店铺id
String shopIdStr = result.getContent().getName();
shopIds.add(Long.valueOf(shopIdStr));

// 4.3获取距离
Distance distance = result.getDistance();

map.put(shopIdStr, distance);
});

// 5.根据id查询shop
String joinStr = StrUtil.join(",", shopIds);
List<Shop> shops = query().in("id", shopIds).last("order by field(id," + joinStr + ")").list();
for (Shop shop : shops) {
shop.setDistance(map.get(shop.getId().toString()).getValue());
}

// 6.返回
return Result.ok(shops);
}

2 签到

2.1 MySQL实现签到

在这里插入图片描述

缺点:

用户一次签到,就是一条记录,假如有1000万用户,平均每人每年签到次数为10次,则这张表一年的数据量为 1亿条

每签到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共22 字节的内存,一个月则最多需要600多字节

2.2 优化

我们按月来统计用户签到信息,签到记录为1,未签到则记录为0.

把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)。这样我们就用极小的空间,来实现了大量数据的表示。

Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是 232个bit位。

在这里插入图片描述

BitMap的操作命令有:

  • SETBIT:向指定位置(offset)存入一个0或1,从0开始
  • GETBIT :获取指定位置(offset)的bit值
  • BITCOUNT :统计BitMap中值为1的bit位的数量
  • BITFIELD :操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
  • BITFIELD_RO :获取BitMap中bit数组,并以十进制形式返回
  • BITOP :将多个BitMap的结果做位运算(与 、或、异或)
  • BITPOS :查找bit数组中指定范围内第一个0或1出现的位置

那么,如何完成用户签到?

思路:我们可以把年和月作为bitMap的key,然后保存到一个bitMap中,每次签到就到对应的位上把数字从0变成1,只要对应是1,就表明说明这一天已经签到了,反之则没有签到。

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
16
17
18
19
20
21
22
23
24
/**
* @param
* @return com.hmdp.dto.Result
* @description //签到
**/
@Override
public Result sign() {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));

// 3.拼接key
String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;

// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();

// 5.写入Redis SETBIT key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);

return Result.ok();
}

2.3 统计连续签到

问题1:什么叫做连续签到天数?
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。

Java逻辑代码:获得当前这个月的最后一次签到数据,定义一个计数器,然后不停的向前统计,直到获得第一个非0的数字即可,每得到一个非0的数字计数器+1,直到遍历完所有的数据,就可以获得当前月的签到总天数了

问题2:如何得到本月到今天为止的所有签到数据?

BITFIELD key GET u[dayOfMonth] 0

假设今天是10号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是10号,那么就是10位,去拿这段时间的数据,就能拿到所有的数据了,那么这10天里边签到了多少次呢?统计有多少个1即可。

问题3:如何从后向前遍历每个bit位?

注意:bitMap返回的数据是10进制,哪假如说返回一个数字8,那么我哪儿知道到底哪些是0,哪些是1呢?我们只需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。

1
2
3
4
@GetMapping("/sign/count")
public Result signCount(){
return userService.signCount();
}
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
/**
* @param
* @return com.hmdp.dto.Result
* @description //合计签到总数
**/
@Override
public Result signCount() {
// 1.获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2.获取日期
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));

// 3.拼接key
String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;

// 4.获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();

// 5.获取本月截至今天为止所有的签到记录,返回的是一个十进制的数字
// BITFIELD bm1 GET u2 0
List<Long> results = stringRedisTemplate.opsForValue().bitField(
key, BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
.valueAt(0)
);

if (null == results || results.isEmpty()) {
// 没有任何签到结果
return Result.ok(0);
}
Long num = results.get(0);
if (null == num || 0 == num) {
return Result.ok(0);
}

// 6.循环遍历
// 计数器
int calCount = 0;
while (true) {
// 7.让这个数字与1做与运算,得到数字的最后一个bit位
if ((num & 1) == 0) {
// 判断这个bit位是否为0,如果为0说明未签到,结束
break;

} else {
// 如果不为0,说明已签到,计数器 + 1
calCount++;
}


// 把数字(无符号)右移一位,抛弃最后一个bit位,继续下一个bit位
num = (num >>> 1);

}


return Result.ok(calCount);
}

3 UV统计

3.1 UV统计的基本思路

  • UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
  • PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。

UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖,那怎么处理呢?

Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:Hyperloglog算法

Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。

3.2 HypeLogLog实现统计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void testHyperLog() {
String[] values = new String[1000];
int j = 0;
for (int i = 0; i < 1000000; i++) {
j = i % 1000;
values[j] = "user_" + i;
if (j == 999) {
// 发送到Redis
stringRedisTemplate.opsForHyperLogLog().add("hl", values);

}
}

// 统计数量
Long count = stringRedisTemplate.opsForHyperLogLog().size("hl");
System.out.println("统计的总数是:" + count);

}

  • 计算出来统计的总数是997593

redis-附近&签到&UV
https://baijianglai.cn/redis-附近-签到-UV/91f9a5d4aa12/
作者
Lai Baijiang
发布于
2024年3月10日
许可协议