附近商户

GEO数据结构的基本用法

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

    • GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)

    • 这个member就是相当于value的意思,多了一个值可以存放

    • 命令格式

    1
    GEOADD key longitude latitude member [longitude latitude member …]
    • 返回值:添加到sorted set元素的数目,但不包括已更新score的元素
    • 复杂度:每⼀个元素添加是O(log(N)) ,N是sorted set的元素数量
    • 举例
    1
    GEOADD china 13.361389 38.115556 "shanghai" 15.087269 37.502669 "beijing"
    • GEODIST:计算指定的两个点之间的距离并返回

    • 命令格式

    1
    GEODIST key member1 member2 [m|km|ft|mi]
    • 如果两个位置之间的其中⼀个不存在, 那么命令返回空值。
    • 指定单位的参数 unit 必须是以下单位的其中⼀个:
      • m 表示单位为米。
      • km 表示单位为千米。
      • mi 表示单位为英⾥。
      • ft 表示单位为英尺。
    • 如果用户没有显式地指定单位参数, 那么 GEODIST 默认使用米作为单位。
    • **GEODIST 命令在计算距离时会假设地球为完美的球形, 在极限情况下, 这⼀假设最⼤会造成 0.5% 的误差
    • 返回值:计算出的距离会以双精度浮点数的形式被返回。 如果给定的位置元素不存在, 那么命令返回空值
    • 举例
    1
    GEODIST china beijing shanghai km
    • GEOHASH8:将指定member的坐标转化为hash字符串形式并返回

    • 命令格式

    1
    GEOHASH key member [member …]
    • 通常使用表示位置的元素使用不同的技术,使用Geohash位置52点整数编码。由于编码和解码过程中所使用的初始最小和最大坐标不同,编码的编码也不同于标准。此命令返回一个标准的Geohash,在维基百科和geohash.org网站都有相关描述
    • 返回值:一个数组, 数组的每个项都是一个 geohash 。 命令返回的 geohash 的位置与用户给定的位置元素的位置一一对应
    • 复杂度:O(log(N)) for each member requested, where N is the number of elements in the sorted set
    • 举例
    1
    2
    3
    云服务器:0>GEOHASH china beijing shanghai
    1) "sqdtr74hyu0"
    2) "sqc8b49rny0"
    • GEOPOS:返回指定member的坐标

    • 格式:GEOPOS key member [member …]

    • 给定一个sorted set表示的空间索引,密集使用 geoadd 命令,它以获得指定成员的坐标往往是有益的。当空间索引填充通过 geoadd 的坐标转换成一个52位Geohash,所以返回的坐标可能不完全以添加元素的,但小的错误可能会出台。

    • 因为 GEOPOS 命令接受可变数量的位置元素作为输入, 所以即使用户只给定了一个位置元素, 命令也会返回数组回复

    • 返回值:GEOPOS 命令返回一个数组, 数组中的每个项都由两个元素组成: 第一个元素为给定位置元素的经度, 而第二个元素则为给定位置元素的纬度。当给定的位置元素不存在时, 对应的数组项为空值

    • 复杂度:O(log(N)) for each member requested, where N is the number of elements in the sorted set

    1
    2
    3
    4
    5
    6
    云服务器:0>geopos china beijing shanghai
    1) 1) "15.08726745843887329"
    2) "37.50266842333162032"

    2) 1) "13.36138933897018433"
    2) "38.11555639549629859"
    • GEOGADIUS:指定圆心、半径,找到该园内包含的所有member,并按照与圆心之间的距离排序后返回,6.2之后已废弃

    • 命令格式

    1
    2
    GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] 
    [COUNT count [ANY]] [ASC|DESC] [STORE key] [STOREDIST key]
    • 范围可以使用以下其中一个单位:
      • m 表示单位为米。
      • km 表示单位为千米。
      • mi 表示单位为英里。
      • ft 表示单位为英尺。
    • 在给定以下可选项时, 命令会返回额外的信息:
      • WITHDIST: 在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回。 距离的单位和用户给定的范围单位保持一致。
      • WITHCOORD: 将位置元素的经度和维度也一并返回。
      • WITHHASH: 以 52 位有符号整数的形式, 返回位置元素经过原始 geohash 编码的有序集合分值。 这个选项主要用于底层应用或者调试, 实际中的作用并不大。
    • 命令默认返回未排序的位置元素。 通过以下两个参数, 用户可以指定被返回位置元素的排序方式:
      • ASC: 根据中心的位置, 按照从近到远的方式返回位置元素。
      • DESC: 根据中心的位置, 按照从远到近的方式返回位置元素。
    • 在默认情况下, GEORADIUS 命令会返回所有匹配的位置元素。 虽然用户可以使用 COUNT 选项去获取前 N 个匹配元素, 但是因为命令在内部可能会需要对所有被匹配的元素进行处理, 所以在对一个非常大的区域进行搜索时, 即使只使用 COUNT 选项去获取少量元素, 命令的执行速度也可能会非常慢。 但是从另一方面来说, 使用 COUNT 选项去减少需要返回的元素数量, 对于减少带宽来说仍然是非常有用的
    • 返回值:
      • 在没有给定任何 WITH 选项的情况下, 命令只会返回一个像 [“New York”,”Milan”,”Paris”] 这样的线性(linear)列表。
      • 在指定了 WITHCOORD 、 WITHDIST 、 WITHHASH 等选项的情况下, 命令返回一个二层嵌套数组, 内层的每个子数组就表示一个元素。
      • 在返回嵌套数组时, 子数组的第一个元素总是位置元素的名字。 至于额外的信息, 则会作为子数组的后续元素, 按照以下顺序被返回:
        • 以浮点数格式返回的中心与位置元素之间的距离, 单位与用户指定范围时的单位一致。
        • geohash 整数。
        • 由两个元素组成的坐标,分别为经度和纬度
    • 举例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19


    云服务器:0>GEORADIUS china 15 37 200 km WITHDIST WITHCOORD
    1) 1) "shanghai"
    2) "190.4424"
    3) 1) "13.36138933897018433"
    2) "38.11555639549629859"

    2) 1) "beijing"
    2) "56.4413"
    3) 1) "15.08726745843887329"
    2) "37.50266842333162032"

    云服务器:0>GEORADIUS china 15 37 200 km WITHDIST
    1) 1) "shanghai"
    2) "190.4424"

    2) 1) "beijing"
    2) "56.4413"
    • GEOSEARCH:在指定范围内搜索member,并按照与制定点之间的距离排序后返回,范围可以使圆形或矩形,6.2的新功能

    image-20240316175214030

    命令格式

    1
    2
    3

    GEOSEARCH key [FROMMEMBER member] [FROMLONLAT longitude latitude] [BYRADIUS radius m|km|ft|mi]
    [BYBOX width height m|km|ft|mi] [ASC|DESC] [COUNT count [ANY]] [WITHCOORD] [WITHDIST] [WITHHASH]
    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


    GEOSEARCH key:

    GEOSEARCH 是命令名。
    key 是存储地理空间数据的Redis键。
    [FROMMEMBER member]:

    使用一个已存在的成员(通常是之前通过GEOADD命令添加的)作为搜索的中心点。
    [FROMLONLAT longitude latitude]:

    使用指定的经度和纬度作为搜索的中心点。
    [BYRADIUS radius m|km|ft|mi]:

    BYRADIUS 指定了搜索的半径。
    radius 是搜索半径的值。
    m|km|ft|mi 是半径的单位,可以是米、千米、英尺或英里。
    [BYBOX width height m|km|ft|mi]:

    BYBOX 用于定义一个矩形框进行搜索。
    width 和 height 是矩形框的宽和高。
    同样,m|km|ft|mi 是单位。
    注意:BYRADIUS 和 BYBOX 是互斥的,即你不能同时在一个GEOSEARCH命令中使用它们。

    [ASC|DESC]:

    指定搜索结果的排序方式。ASC 表示按照与中心点的距离从近到远排序,DESC 则相反。
    [COUNT count]:

    限制返回的结果数量。
    [ANY]:

    当与COUNT一起使用时,如果搜索到的结果少于COUNT指定的数量,则返回所有找到的结果。
    [WITHCOORD]:

    在返回的结果中包含每个成员的经度和纬度。
    [WITHDIST]:

    在返回的结果中包含每个成员与中心点的距离。
    [WITHHASH]:

    在返回的结果中包含每个成员的geohash值。
    这个命令非常灵活,可以根据不同的需求进行组合使用。例如,你可以使用BYRADIUS和WITHDIST来查找附近的位置,并返回与中心点的距离。你也可以使用COUNT来限制返回的结果数量。

    注意:GEOSEARCH命令是Redis 6.2及以后版本的功能,所以请确保你的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
    30
    31
    云服务器:0>geosearch china FROMLONLAT 15 37 BYRADIUS 200 km ASC WITHCOORD WITHDIST

    1) 1) "beijing"
    2) "56.4413"
    3) 1) "15.08726745843887329"
    2) "37.50266842333162032"


    2) 1) "shanghai"
    2) "190.4424"
    3) 1) "13.36138933897018433"
    2) "38.11555639549629859"



    云服务器:0>geosearch china FROMLONLAT 15 37 BYBOX 400 400 km DESC WITHCOORD WITHDIST

    1) 1) "shanghai"
    2) "190.4424"
    3) 1) "13.36138933897018433"
    2) "38.11555639549629859"


    2) 1) "beijing"
    2) "56.4413"
    3) S1) "15.08726745843887329"
    2) "37.50266842333162032"




    • GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key,也是6.2的新功能

    • 命令格式

    1
    2
    3
    GEOSEARCHSTORE destination source [FROMMEMBER member] [FROMLONLAT longitude latitude] 
    [BYRADIUS radius m|km|ft|mi] [BYBOX width height m|km|ft|mi]
    [ASC|DESC] [COUNT count [ANY]] [STOREDIST]
    • 这个命令和 GEORADIUS 命令一样, 都可以找出位于指定范围内的元素, 但是 GEORADIUSBYMEMBER 的中心点是由给定的位置元素决定的, 而不是像 GEORADIUS 那样, 使用输入的经度和纬度来决定中心点
    • 指定成员的位置被用作查询的中心。
    • 关于 GEORADIUSBYMEMBER 命令的更多信息, 请参考 GEORADIUS 命令的文档
    • 复杂度:O(N+log(M)) where N is the number of elements inside the bounding box of the circular area delimited by center and radius and M is the number of items inside the index
    1
    2
    3
    4

    云服务器:0>GEORADIUSBYMEMBER china beijing 200 km
    1) "shanghai"
    2) "beijing"

导入店铺数据到GEO

  • 具体场景说明,例如美团/饿了么这种外卖App,你是可以看到商家离你有多远的,那我们现在也要实现这个功能。
  • 我们可以使用GEO来实现该功能,以当前坐标为圆心,同时绑定相同的店家类型type,以及分页信息,把这几个条件插入后台,后台查询出对应的数据再返回
  • 那现在我们要做的就是:将数据库中的数据导入到Redis中去,GEO在Redis中就是一个member和一个经纬度,经纬度对应的就是tb_shop中的x和y,而member,我们用shop_id来存,因为Redis只是一个内存级数据库,如果存海量的数据,还是力不从心,所以我们只存一个id,用的时候再拿id去SQL数据库中查询shop信息
  • 但是此时还有一个问题,我们在redis中没有存储shop_type,无法根据店铺类型来对数据进行筛选,解决办法就是将type_id作为key,存入同一个GEO集合即可

image-20240316180932030

image-20240316180848129

  • 代码如下

image-20240316181639978

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

@Test
public void loadShopData(){
//1. 查询所有店铺信息
List<Shop> shopList = shopService.list();
//2. 按照typeId,将店铺进行分组
Map<Long, List<Shop>> map = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));
//3. 逐个写入Redis
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
//3.1 获取类型id
Long typeId = entry.getKey();
//3.2 获取同类型店铺的集合
List<Shop> shops = entry.getValue();
String key = SHOP_GEO_KEY + typeId;
for (Shop shop : shops) {
//3.3 写入redis GEOADD key 经度 纬度 member
stringRedisTemplate.opsForGeo().add(key,new Point(shop.getX(),shop.getY()),shop.getId().toString());
}
}
}
  • 但是上面的代码不够优雅,是一条一条写入的,效率较低,那我们现在来改进一下,这样只需要写入等同于type_id数量的次数

使用RedisGeoCommands.GeoLocation实现批量导入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

@Test
public void loadShopData() {
List<Shop> shopList = shopService.list();
Map<Long, List<Shop>> map = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
Long typeId = entry.getKey();
List<Shop> shops = entry.getValue();
String key = SHOP_GEO_KEY + typeId;
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(shops.size());
for (Shop shop : shops) {
//将当前type的商铺都添加到locations集合中
locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(), new Point(shop.getX(), shop.getY())));
}
//批量写入
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
  • 代码编写完毕,我们启动测试方法,然后去Redis图形化界面中查看是否有对应的数据

实现附近商户功能

  • SpringDataRedis的2.3.9版本并不支持Redis 6.2提供的GEOSEARCH命令,因此我们需要提示其版本,修改自己的pom.xml文件
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


<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>
  • 点击距离分类,查看发送的请求

请求网址: http://localhost:8080/api/shop/of/type?&typeId=1&current=1&x=120.149993&y=30.334229
请求方法: GET

  • 看样子是ShopController中的方法,那我们现在来修改其代码,除了typeId,分页码,我们还需要其坐标
1
2
3
4
5
6
7
8
9
10

@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);
}
  • 具体业务逻辑依旧是写在ShopServiceImpl中
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 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 WITHDIST
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());
}
//4. 解析出id
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);
});
//5. 根据id查询shop
String 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);
}
  • 最终效果如下,可以显示出距离
    image-20240316181231304

用户签到

BitMap功能演示

  • 我们针对签到功能完全可以通过MySQL来完成,例如下面这张表
Field Type Collation Null Key Default Extra Comment
id bigint unsigned (NULL) NO PRI (NULL) auto_increment 主键
user_id bigint unsigned (NULL) NO (NULL) 用户id
year year (NULL) NO (NULL) 签到的年
month tinyint (NULL) NO (NULL) 签到的月
date date (NULL) NO (NULL) 签到的日期
is_backup tinyint unsigned (NULL) YES (NULL) 是否补签
  • 用户签到一次,就是一条记录,假如有1000W用户,平均每人每年签到10次,那这张表一年的数据量就有1亿条
  • 那有没有方法能简化一点呢?我们可以使用二进制位来记录每个月的签到情况,签到记录为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出现的位置

实现签到功能

  • 需求:实现签到接口,将当前用户当天签到信息保存到Redis中
说明
请求方式 Post
请求路径 /user/sign
请求参数
返回值
  • 思路:我们可以把年和月作为BitMap的key,然后保存到一个BitMap中,每次签到就把对应位上的0变成1,只要是1就说明这一天已经签到了,反之则没有签到
  • 由于BitMap底层是基于String数据结构,因此其操作也都封装在字符串相关操作中了
  • 在UserController中编写对应的方法
1
2
3
4
5

@PostMapping("/sign")
public Result sign(){
return userService.sign();
}
  • 具体实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

@Override
public Result sign() {
//1. 获取当前用户
Long userId = UserHolder.getUser().getId();
//2. 获取日期
LocalDateTime now = LocalDateTime.now();
//3. 拼接key
String 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 1 从0开始计数的
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}
  • 使用PostMan发送请求测试,注意请求头中需携带登录用户的token,否则无效(又浪费我五分钟找这个问题)
  • 发送成功之后,在Redis图形化界面中是可以看到的

签到统计

  • 如何获取本月到今天为止的所有签到数据?

    BITFIELD key GET u[dayOfMonth] 0(u是一个关键字)

  • 如何从后往前遍历每个bit位,获取连续签到天数

    • 连续签到天数,就是从末尾往前数,看有多少个1
    • 因为bitMap返回的是十进制的数,而不是二进制的,所以要采用让返回的数与1做与运算的方法,获取最后一位数字。然后再让该数右移一位,在做与运算
    • image-20240316203712795
    • 简单的位运算算法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10

    int count = 0;
    while(true) {
    if((num & 1) == 0)
    break;
    else
    count++;
    num >>>= 1;
    }
    return count;
  • 需求:实现下面接口,统计当前用户截止当前时间在本月的连续签到天数

说明
请求方式 GET
请求路径 /user/sign/count
请求参数
返回值 连续签到天数
  • 在UserController中创建对应的方法
1
2
3
4
5

@GetMapping("/sign/count")
public Result signCount(){
return userService.signCount();
}
  • 在UserServiceImpl中实现方法
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


@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 = USER_SIGN_KEY + userId + keySuffix;
//4. 获取今天是当月第几天(1~31)
int dayOfMonth = now.getDayOfMonth();
//5. 获取截止至今日的签到记录 BITFIELD key GET uDay 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(0);
}
//6. 循环遍历
int count = 0;
Long num = result.get(0);
while (true) {
if ((num & 1) == 0) {
break;
} else
count++;
//数字右移,抛弃最后一位
num >>>= 1;
}
return Result.ok(count);
}
  • 使用PostMan发送请求,可以手动修改redis中的签到数据多次测试,发请求的时候还是要注意携带登录用户的token

UV统计

HyperLogLog

  • UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次
  • PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
  • 本博客的首页侧边栏就有本站访客量和本站总访问量,对应的就是UV和PV
  • 通常来说PV会比UV大很多,所以衡量同一个网站的访问量,我们需要综合考虑很多因素。
  • UV统计在服务端做会很麻烦,因为要判断该用户是否已经统计过了,需要将统计过的信息保存,但是如果每个访问的用户都保存到Redis中,那么数据库会非常恐怖,那么该如何处理呢?
  • HyperLogLog(HLL)是从Loglog算法派生的概率算法,用户确定非常大的集合基数,而不需要存储其所有值,算法相关原理可以参考下面这篇文章:https://juejin.cn/post/6844903785744056333#heading-0
  • Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
  • 常用的三个方法

image-20240316204849695

1
2
3
4
5
6
7
8
9
10

PFADD key element [element...]
summary: Adds the specified elements to the specified HyperLogLog

PFCOUNT key [key ...]
Return the approximated cardinality of the set(s) observed by the HyperLogLog at key(s).

//计算一个月或者一年的统计量,merge
PFMERGE destkey sourcekey [sourcekey ...]
lnternal commands for debugging HyperLogLog values

测试百万数据的统计

  • 使用单元测试,向HyperLogLog中添加100万条数据,看看内存占用是否真的那么低,以及统计误差如何
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

@Test
public void testHyperLogLog() {
String[] users = new String[1000];
int j = 0;
for (int i = 0; i < 1000000; i++) {
j = i % 1000;
users[j] = "user_" + i;
if (j == 999) {
stringRedisTemplate.opsForHyperLogLog().add("HLL", users);
}
}
Long count = stringRedisTemplate.opsForHyperLogLog().size("HLL");
System.out.println("count = " + count);
}
  • 插入100W条数据,得到的count为997593,误差率为0.002407%
  • 去Redis图形化界面中查看占用情况为:12.3K字节