附近商铺
附近商户即基于地理坐标进行搜索。支持这种地理坐标搜索的技术有很多,而Redis就是其中之一。接下来学习如何基于Redis来实现这种地理坐标的搜索功能。
本章内容会分成两个小节来学习。
第一节:了解一下Redis中Geo数据的结构及其基本用法。
第二节:实现业务当中的商户搜索功能。
GEO数据结构
先来看Redis中的Geo数据结构。Geo是Geolocation的缩写,代表的是地理坐标,也就是经纬度。Redis在3.2版本加入了对于Geo的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。
Redis支持的Geo相关命令:
- GEOADD:添加一个地理空间信息,包含三个主要参数:经度(longitude)、纬度(latitude)、值(member)。值可以是任何东西,包括但不限于地名、店铺名、店铺id。一个Geo类型的key可以添加多个点,形成这些点的集合。
- GEODIST:计算指定的两个地理点之间的距离并返回。
- GEOHASH:该命令将指定member的地理坐标转换为一个哈希字符串形式,并返回该字符串。
- GEOHASH:将指定的member的坐标转为hash字符串并返回。其是一种地理编码方式,能够将二维的经纬度坐标编码为一维的字符串,便于存储和快速检索。
- GEOPOS:返回指定member的地理坐标,即经度和纬度。
- GEORADIUS(6.2后已废弃):指定圆心和半径,找到该圆形范围内包含的所有member,并按照与圆心的距离进行排序后返回。
- GEOSEARCH(6.2新功能):在指定范围内搜索member,范围可以是圆形或矩形,并按照它们与指定点之间的距离进行排序后返回。
- GEOSEARCHSTORE(6.2新功能):与GEOSEARCH功能一致,但其会将搜索结果存储到一个指定的SortedSet中。
接下来到Redis中练习这些命令。与之前所学的五大类型不同,我们无法通过类似于help @GEO来查看所有的相关命令,只能通过类似于help GEOADD的格式依次查看每个命令。
通过GEO实现以下需求:
- 添加北京南站、北京站、北京西站,三个火车站的经纬度到Redis的同一个key中:
- 北京南站(116.378248 39.865275)
- 北京站(116.42803 39.903738)
- 北京西站(116.322287 39.893729)
- 计算北京西站到北京站的距离
- 搜索天安门(116.397904 39.909005)附近10km内的所有火车站,并按照距离升序排序
添加三个站点信息可以通过GEOADD命令实现,key指定为testKey,然后按照经度、纬度、值,的顺序添加数据,可一次添加多个。返回(integer)3,表示成功添加进去三个点。
192.168.88.111:6379> GEOADD testKey 116.378248 39.865275 BeijingSouth 116.42803 39.903738 Beijing 116.322287 39.893729 BeijingWest
(integer) 3
在Redis图形化工具中可以看到,添加的值的类型为SortedSet,所以GEO底层用的是sorted set。member存入SortedSet的value中就,经纬度则被转换成个数字,然后作为score存入。之后还可以把数字再转换成对应的坐标,还可以根据这些地理坐标的信息去计算点与点之间的距离,或者是进行搜索。
再来计算北京西站到北京站的距离。计算两个点的距离我可以使用GEODIST命令,DIST是distance的缩写。指定key为刚刚添加的testKey,再指定member,即刚刚添加的Beijing和BeijingWest,其默认返回的距离单位为m,也可手动指定m/km/ft/mi来使单位为米/千米/英尺/英里。然后得到两站之间相距9km多一点。
192.168.88.111:6379> GEODIST testKey Beijing BeijingWest
"9091.5648"
192.168.88.111:6379> GEODIST testKey Beijing BeijingWest km
"9.0916"
然后是搜索天安门附近10km内的所有火车站。
这个就要用到搜索功能了,搜索功能有两个,分别是GEORADIUS,和GEOSEARCH。先来看GEORADIUS(虽然它已经废弃了,但我们还是看一下),需要先指定一个key,然后指定longitude和latitude经纬度,由经纬度确定一个点作为圆心,然后指定radius即半径,它就会以该点为圆心,以该值为半径值作圆,然后搜索该圆内所有的已存坐标,可以加参数WITHDIST来选择是否带上距离,还有ASC或DESC表示做升序还是降序。其会按照这些规则排序并且返回结果。
然后是GEOSEARCH,其与GEORADIUS比较像。首先同样需要指定一个key,然后其可以选择FROMMEMBER参数,后跟member,以key中的一个成员作为圆心,也可以选择 FROMLONLAT参数,后跟经度、纬度作为圆心。所以它涵盖了前者的功能。参数BYRADIUS就是按照圆半径来搜索,后面指定半径;BYBOX是按矩形来搜索,同时指定宽和高;m/km/ft/mi指定距离长度,ASC/DESC正序倒序,不再赘述。还可以用COUNT指定查多少条,COUNT参数后跟数量即可。WITHDISTWITHDIST就是带上距离。
192.168.88.111:6379> help georadius
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count [ANY]] [ASC|DESC] [STORE key] [STOREDIST key]
summary: Query a sorted set representing a geospatial index to fetch members matching a given maximum distance from a point
since: 3.2.0
group: geo
192.168.88.111:6379> help geosearch
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]
summary: Query a sorted set representing a geospatial index to fetch members inside an area of a box or a circle.
since: 6.2
group: geo
接下来使用GEOSEARCH完成需求。先指定key,再指定搜索方式是FROMLONLAT,其实就是经纬度的缩写LON和LAT,后面跟天安门的经度和纬度。后面选择BYRADIUS根据半径搜索,指定为10 km,排序方式和查询数量不指定,加上WITHDIST使结果带上距离:
192.168.88.111:6379> GEOSEARCH testKey FROMLONLAT 116.397904 39.909005 BYRADIUS 10 km WITHDIST
1) 1) "Beijing"
2) "2.6361"
2) 1) "BeijingSouth"
2) "5.1452"
3) 1) "BeijingWest"
2) "6.6723"
这样就搜到了距离北京天安门10km内的所有的火车站。
还有几个没讲到的,我们简单介绍一下:
- GEOPOS返回坐标,指定key后再指定member,其就会返回该member的经纬度。
- GEOHASH,使用方法相同,坐标转为hash值再返回。其可读性和内存占用更佳。
192.168.88.111:6379> GEOPOS testKey Beijing
1) 1) "116.42802804708480835"
2) "39.90373880538094653"
192.168.88.111:6379> GEOHASH testKey Beijing
1) "wx4g12k21s0"
附近商铺搜索
接下来将利用GEO数据结构实现附近商家搜索的功能。在首页顶部,有很多不同的按钮,每个按钮代表一种类型的商家,比如与美食相关的、与KTV相关的等。当我们点击某个按钮时,它会展示出该类型的所有商家列表,并且这个列表还会根据评分、人气或距离进行排序。
我们需要使其按距离排序,因为这样可以搜索当前用户周围或附近的商家,越近的商家排名越靠前。如果一屏显示不下,可以再进行分页查询。目前该功能已实现了搜索和分页,但还未实现排序。
进入这个页面时,请求已经发出,并且与排序有关的参数也都传递了。请求的方式是get,路径叫shop/of/type,即根据类型搜索某种类型的商家。因此,第一个参数为typeId,代表商户的类型;第二个参数叫current,代表页码。这里要做滚动查询,注意,这个滚动查询不是滚动分页,而是说如果一页展示不完,滚动一次就多查一页。在分页时页码表示当前为第几页,所以其是一个传统分页,而非之前讲的滚动分页,实现起来也不复杂。然后传入x、y表示经纬度,以此作为圆心,也就是当前登录用户的坐标,我们要搜索的就是它附近的商家,并按照距离排序。这个用户的坐标在真实案例中是由APP从后台获取当前手机所在的位置,这里为方便就直接在前台写死了。
拿到这些信息后,后台就可以根据typeId搜索该类型的商家,再利用当前页码去做分页,经纬度去做排序,最后返回的就是查询到的所有商家形成的集合。
目前所有的的商家信息都保存在数据库的tb_shop表中,但数据库难以实现按照地理坐标去搜索附近的商家,并按距离排序。要想实现必须把店铺中的经纬度坐标等信息导入到Redis的GEO类型数据结构当中。
需要注意的是,Redis的GEO类型在存储时,主要参数只有member、经度和纬度。经纬度对应数据库表里的x、y即可。但如果把整个店铺的信息都塞到member里去显然不合适,因为Redis毕竟是内存存储,空间占用太多也不好。所以可以使member只存店铺的id。将来搜索附近店铺的时候,根据经纬度坐标进行筛选,筛选以后再根据id来查询数据库即可。
同时为区分不同类型的店铺,可以把商户按照type_id去做分组,类型相同的商户作为同一组,将其type_id作为key存到同一个集合里边去。这样在该key存储的都为对应类型的店铺。这样就不用再去考虑根据类型过滤的问题了。
导入数据到Redis
为方便数据导入,可以选择直接写个单元测试来保存数据到Redis。首先需要查询店铺信息,如果需要查询的信息过多,可以循环分批去查。因为本项目中的数据较少,所以直接查所有的数据即可。
然后要把店铺按照type_id来分组,id一致的放到一个集合,id不一样的放到不同集合里去。然后以type_id为key,集合为value,放到一个map中。可以遍历查询到的list,按照type_id存入不同的List中,再以type_id、List的形式存入Map中。但这样比较复杂,也可以直接使用stream流来分组。stream流的收集功能很强大,可以调用collectors.groupingBy()把集合中的店铺分组,同时需要传入分组的依据,即店铺的类型。
有了分组后的map,就可以分批的去完成写入了。可以遍历该map的entry。获取entry里的key和value,即type_id和同类型的店铺的集合。然后写入Redis,要使用的命令是GEOAdd,对应的java方法为opsForGeo().add(),传入的key为拼接的固定字符串+店铺类型,然后是经纬度,经纬度在Redis里被称作point,所以此处可以new point()内部传入x和y。最后的member就是shop的id,调用toString()转为字符串即可。
不过这种写法的话效率比较低,因为每一个店铺你都要发一个请求。还可以在调用stringRedisTemplate的GEOAdd时,不再一个点一个点的传入,而是使用Iterable,即可迭代的,就像之前学习的List或者Set,都是可迭代的。也就是说传入一个集合,集合里为GeoLocation,同时GeoLocation有一个member的泛型,因为本项目中的member为shop的id的字符串,所以指定member的泛型为String。
也就是说需要将个店铺的集合转变成GeoLocation的集合,然后再上传。那么这个GeoLocation内部就是一个point和传入的泛型类。我们需要将店铺封装成GeoLocation对象。因此GeoLocation集合的大小就是店铺集合的大小。这样在遍历的时就不需要再一个个添加,而是直接把shop转成GeoLocation即可。等循环结束了,每个shop都转换成GeoLocation,形成GeoLocation集合,最后批量的往Redis里写效率就会高一些。
@Test
public void loadShop2Redis() {
// 查询所有店铺信息
List<Shop> list = shopService.list();
// 根据店铺的类型ID进行分组
Map<Long,List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
// 遍历分组后的店铺信息,并将它们分批存入Redis
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
// 获取当前分组的类型ID
Long typeId = entry.getKey();
// 构造Redis中的key,用于存储同一类型ID的店铺地理位置信息
String key = SHOP_GEO_KEY + typeId;
// 获取当前类型ID对应的所有店铺列表
List<Shop> value = entry.getValue();
// 初始化一个列表,用于存储店铺的地理位置信息,大小初始化为店铺列表的大小以优化性能
List<RedisGeoCommands.GeoLocation<String>> locations=new ArrayList<>(list.size());
// 遍历店铺列表,将每个店铺的地理位置信息添加到locations列表中
value.forEach(shop -> {
/* 老方法
// 将单个店铺的地理位置信息添加到Redis中
// key: Redis中存储地理位置信息的键,由SHOP_GEO_KEY和店铺类型ID组成
// new Point(shop.getX(), shop.getY()): 店铺的经纬度坐标点
// shop.getId().toString(): 店铺ID转换为字符串,作为地理位置的成员标识
stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
*/
// 创建一个地理位置对象,包含店铺ID和坐标点(经度、纬度)
locations.add(new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(), // 店铺ID转换为字符串作为成员标识
new Point(shop.getX(), shop.getY()) // 店铺的经纬度坐标
));
});
// 将当前类型ID的所有店铺地理位置信息批量添加到Redis中
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
执行该单元测试,在Redis客户端找到shop:geo,可以发现有两个key,这是因为在数据库里店铺类型就两种,一种是1(美食),另外一种是2(KTV)。所以刚好有两个key。可以看到两个key中店铺的id和对应的经纬度全部都存入进去了。
搜索店铺排序
接下来实现商户的搜索功能。但在此之前需要注意一个问题:当前项目中使用的Spring Boot版本并不是最新的,因此对应的Spring Data Redis版本也不是最新的,而是2.3.9版本。该版本不支持Redis 6.2提供的GeoSearch命令,这是我们需要用来实现搜索功能的命令。
老版本虽然也能用,但既然使用了新版本的Redis,最好也使用最新的命令。所以需要修改pom文件,排除掉当前Spring Boot提供的Spring Data Redis和Lettuce的版本,然后手动引入最新的2.6.2和6.1.6版本。完成这些操作后,我们就可以开始实现搜索功能了。
<!--修改前————————————————————————————————————————————————-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--修改后————————————————————————————————————————————————-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
</exclusion>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<!--使用2.6.2也可以-->
<version>2.7.11</version>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.1.10.RELEASE</version>
</dependency>
接下来改造ShopController中的queryShopByType()方法,首先增加两个参数x和y,分别代表经纬度。这两个参数不一定每次都会传入,前端也可能按照人气或评分排序,所以我们需要指定参数require==false,表示这两个参数是可选的。如果没有传入,我们就按照数据库查询;如果传入了,我们就按照Redis中的Geo数据进行查询。
现在统一将逻辑放到ServiceImpl中去实现。首先要判断x和y是否为空,即是否需要根据坐标查询。如果需要,我们就按照坐标查询;如果不需要,我们就按照旧的数据库查询方式。同时,我们还需要处理分页问题。分页需要两个参数:页码和每页的大小。根据这两个参数,我们可以计算出从哪开始查到哪结束。
在查询Redis时,使用stringRedisTemplate.opsForGeo() .search()命令。这个命令需要指定key、圆心、半径等参数。key不再赘述,圆心不再使用Point,而是调用静态方法GeoReference.fromCoordinate()并指定经纬度[fromMember()使用GEO中的某个点作为圆心,fromCircle()使用圆]。半径传入new distance,其默认单位为m,也可手动指定单位,将来返回的结果的距离单位也相同,本项目指定为5000。
还有withdistance,可以传入RedisGeoCommands.GeoSearchCommandArgs即搜索参数,然后调用他的静态方法.newGeoSearchArgs()去构造,该Args可以传入很多信息,比如说.includeDistance()就是withdistance参数,这样返回值就会携带距离。还有分页,继续指定limit(),需要指定从哪开始到哪结束,但在limit只指定了一个count,即到此处结束,这意味着查询总是从第一条记录开始,直到达到指定的结束点。由于开始点(from)不能指定,这可能会带来一些不便。为了解决这个问题,我们需要在查询结果出来后,手动截取从所需开始点(from)到结束点(end)的部分。
得到查询结果后,先进行非空判断。不为空则取出结果中的Content,它才是我们所需的部分。然后截取从begin到end的部分。截取的方法有很多种,可以使用集合的subList()方法或者Stream流的skip()方法,传入的参数表示从何处开始截取所需的记录。使用Stream流的skip()方法更为高效,因为它不需要真正拷贝集合,只是跳过不需要的记录,从而节省内存。当跳过的元素个数大于或等于集合的大小时,应直接退出避免出现空指针异常,也就是说需要进行非空判断。
在截取完所需记录后获取相关信息,结果的Content中的name就是店铺的id,Content中的Distance就是距离,需要店铺ID转换为long类型进行收集,以便后续根据ID查询店铺信息。同时,我们还需要将店铺ID和对应的距离存储在一个Map中,以便在后续将距离与店铺信息对应起来。
在根据ID批量查询店铺信息时,需要保证查询结果的有序性。这可以通过拼接查询语句实现(之前写过,在此不再赘述)。
最后需要将查询到的店铺信息与对应的距离合并在一起,并返回给前端,Shop类中已准备好Distance字段,直接赋值即可。在合并过程中,可以直接遍历店铺信息集合,以店铺ID为key从Map中获取对应的距离,并将其设置到店铺对象的distance属性中。
// ShopController———————————————————
@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);
}
// IShopService———————————————————————
Result queryShopByType(Integer typeId, Integer current, Double x, Double y);
// ShopServiceImpl———————————————————
@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 判断是否需要根据坐标查询
if (x == null || y == null) {
// 如果坐标为空,则不进行坐标查询,直接按类型ID查询数据库
Page<Shop> page = query()
.eq("type_id", typeId) // 等于类型ID
.page(new Page<>(current, DEFAULT_PAGE_SIZE)); // 分页查询
return Result.ok(page); // 返回查询结果
}
// 计算分页的开始和结束索引
int begin = (current - 1) * DEFAULT_PAGE_SIZE; // 开始索引
int end = current * DEFAULT_PAGE_SIZE; // 结束索引
// 从Redis中查询,根据距离排序并分页,结果包含店铺ID和距离
String key = SHOP_GEO_KEY + typeId; // Redis的键
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
.search(
key, // 使用键
fromCoordinate(x, y), // 根据给定坐标
new Distance(5000), // 搜索半径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() <= begin) {
// 如果元素不足,表示没有下一页,直接返回空列表
return Result.ok(Collections.emptyList());
}
// 截取当前页的元素,并解析店铺ID和距离
List<Long> ids = new ArrayList<>(list.size()); // 存储店铺ID
Map<String, Distance> distanceMap = new HashMap<>(list.size()); // 存储店铺ID和对应距离
list.stream().skip(begin).forEach(result -> {
// 遍历结果,获取店铺ID
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
// 获取并存储距离
Distance distance = result.getDistance();
distanceMap.put(shopIdStr, distance);
});
// 根据店铺ID查询店铺详细信息
String idStr = StrUtil.join(",", ids); // 将ID列表转换为字符串,用于SQL查询
List<Shop> shops = query().in("id", ids) //whereIn查询
.last("ORDER BY FIELD(id," + idStr + ")") // 按ID列表顺序排序
.list(); // 执行查询并获取结果列表
// 设置每个店铺的距离
for (Shop shop : shops) {
Distance distance = distanceMap.get(shop.getId().toString()); // 获取对应店铺的距离
if (distance != null) {
shop.setDistance(distance.getValue()); // 设置店铺的距离
}
}
// 返回查询结果
return Result.ok(shops);
}
重试按分类搜索店铺功能,现在店铺都会按照距离来排序,向下滚动还会再次发起请求查询更多数据(第一次进入默认直接从数据库中查询五条数据,且无法滚动继续发起查询请求,需点击距离/人气/评分重新发起请求,才会从Redis中查询并具备滚动继续发起请求的功能,点击距离会传x和y导致重新查询我能理解,但点击人气/评分也会重新查询?不知道前端的逻辑是什么):
用户签到
接下来实现用户签到功能。一些社交APP也会有签到送积分或金币的功能。其核心作用是通过一些小奖励吸引用户每天访问APP,从而提升APP的活跃度。
本章将分成三个小节。第一节,我们会介绍Redis中Bitmap的用。第二节将利用Bitmap去实现签到功能。最后实现签到统计,统计用户每个月的签到、连续签到的情况等。
BitMap
为了更好地理解Bitmap的功能和作用,我们先来分析一下签到功能的实现思路。我们可以借助数据库表来实现签到。数据表tb_sign包含字段:id(主键)、user_id(签到的用户)、year和month(签到的年份和月份,用于统计)、data(真正的签到日期),以及is_backup(是否是补签,有些APP允许补签,但正常签到和补签是有差异的)。这些字段就是签到功能所需要的最基本字段,也可以根据业务需求继续添加。
然而,如果我们的用户达到了一定的规模,比如上千万,那么一年下来需要记录的数据量将非常庞大。因此,这种设计因为占用空间过大,不适合直接使用。
可以学习签到卡的实现方式:一张卡片上包含一个月的所有日期,签到则在该日打上对勾,未签到则直接空着。这样一来只用一张卡片就可以把一个月的签到情况全部罗列出来。因为用户的签到状态无非只有两种:签了或者没签。我们可以直接在程序中使用零或一来表示签到和未签到。这样签到状况便可一目了然。
因为程序里并没有类似于签到卡这样的数据,所有我们要把这一个月的签到情况从第一天开始,依次地把这些二进制的值记录下来。第一位代表第一天,最后一位代表这个月的最后一天。一个月最多31天,也就是说用31bit就能表示某一个用户一个月的签到情况了。一个用户一个月的签到记录只用两字节,相比于之前的方法,内存占用相差了几百倍。
这种做法的核心思想就是把每一个bit对应了每月的某一天,形成映射关系,然后再用零和一来表示该业务的状态。这样一种思路就叫做位图(Bitmap)。在本案例中的映射关系就是跟签到状态做的映射。这种实现既简单又节省内存空间,所以说在大多数做数据统计的时候经常会用到Bitmap,包括我们以前说过的布隆过滤器,其底层同样是利用Bitmap来实现的。
Redis也是支持Bitmap的。在Redis的底层,是用String类型结构来实现Bitmap的,因为String类型底层存储数据也是字节,字节就是八个比特位。因此使用String类型完全可以实现Bitmap。而String类型它的最大存储上限是512MB,转换成Bit就是2^32个比特位。按照这种存储方法签到一个月才需要31个比特位,是绰绰有余的。所以说用Redis中的Bitmap实现签到功能非常合适。可以将每一个用户某一个月的日期连在一起作为key,用于存储该月的签到情况,一个月只需要31个bit,既节省了内存的空间,还有利于按月去统计用户的签到情况,非常方便。
接下来来看操作Redis当中的Bitmap的相关命令:
- SETBIT: 向Bitmap中指定位置(offset)存入一个0或1(offset相当于角标)
- GETBIT:获取指定位置(offset)的bit值
- BITCOUNT:统计BitMap中值为1的bit位的数量
- BITFIELD:操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
- BITFIELD_RO:获取BitMap中bit数组,并以十进制形式返回
- BITOP:将多个BitMap的结果做位运算(与、或、异或)
- BITPOS:查找bit数组中指定范围内第一个0或1出现的位置
接下来演示一遍。先来模拟签到,调用SETBIT命令,指定一个key叫为BitMapTest,offset角标从0开始,给1模拟签到。后续模拟只在1、2、6、7、8天签到,其余不给值保持默认。
192.168.88.111:6379> SETBIT BitMapTest 0 1
(integer) 0
192.168.88.111:6379> SETBIT BitMapTest 1 1
(integer) 0
192.168.88.111:6379> SETBIT BitMapTest 5 1
(integer) 0
192.168.88.111:6379> SETBIT BitMapTest 6 1
(integer) 0
192.168.88.111:6379> SETBIT BitMapTest 7 1
(integer) 0
回到Redis的客户端,其对应的二进制值为1100011。有些可视化客户端不会自动转为二进制显示,需要手动选择Binary,将结果改为二进制。(BitMap只会每8位显示一次,如果不足八位则显示0,比如设定的值位101,但实际显示为10100000)
接下来是查询。使用GETBIT命令查看某一天的值,指定key为上文设置的值,然后指定offset来表示查哪一天。分别查询第6天和第5天,其分别返回1和0代表已签和未签。
BITCOUNT可以统计指定Key中值为1的bit位的数量,即查询总共签到了几次。上文总计指定了5bit为1,所以返回值为5。
192.168.88.111:6379> GETBIT BitMapTest 5
(integer) 1
192.168.88.111:6379> GETBIT BitMapTest 4
(integer) 0
192.168.88.111:6379> BITCOUNT BitMapTest
(integer) 5
BITFIELD较为复杂,因为其同时兼具了查询、修改、自增等各种功能。GET type offset代表查询、SET type offset value代表赋值、INCRBY type offset increment代表自增。 因为SET相关命令和SETBIT功能很像,所以使用较少,主要还是使用GET组的相关命令。
做查询时首先同样需要指定key,然后跟上GET,然后是TYPE和OFFSET参数。TYPE表示读取几个bit,OFFSET表示偏移量,即从第几位开始读。其之所以叫TYPE,而非COUNT,是因为他还要指定返回的结果是有符号的还是无符号的。因为最终返回的结果不是以二进制返回,是以十进制返回,而十进制中有正数和负数,二进制则以符号位1或者0来去标记。所以返回十进制时,必须先指定是否携带符号位,携带的话第一个bit就会被当成符号位。其中u来代表无符号,i代表有符号。这里一般都是使用代表无符号的u。比如u2,代表只获取两个无符号的bit:
192.168.88.111:6379> BITFIELD BitMapTest GET u2 0
1) (integer) 3
本行命令表示从第零位开始获取,获取2位的数据,获取到的二进制值为11,转为无符号的十进制则为3。同理可得,参数换为u3,二进制为110,十进制为6;换为u4,二进制为1100,十进制为12。
BITOP做位运算位,因为用不到所以不再演示。
BITPOS查找某个数字第一次出现的位置。后跟指定的key名,然后是1或0来指定查哪个。还可以添加可选参数start和end来指定查询范围,不指定则查询全部:
192.168.88.111:6379> BITPOS BitMapTest 1
(integer) 0
192.168.88.111:6379> BITPOS BitMapTest 0
(integer) 2
签到功能
接下来利用bitmap实现签到接口,将当前用户当天的签到信息保存到Redis中。请求方式为POST,请求路径是user/sign,无请求参数。因为后端可以直接获取当前用户和当前时间,并编写相关数据,所以前端无需传递任何参数没有,也无返回值。
同时因为BitMap底层是基于String存储的,所以其相关的操作也被封装到了字符串操作里,即opsForValue()。其提供了getBit、setBit、bitField三个常用函数。setBit函数可以实现签到功能,需传参key、offset和值(true或false,代表0和1)。
接下来去实现签到接口。首先在UserController中定义一个接口用于签到。然后调用UserService的方法来实现具体的签到逻辑。
先获取当前登录的用户ID和当前日期,然后拼接成key。如何获取当前用户id和当前时间不再赘述。获取到时间后调用format(DateTimeFormatter.ofPattern(":yyyyMM"))将其转为字符串,然后拼接即可。
接着通过LocalDate的getDayOfMonth方法得到今天是这个月的第几天,以确定签到的比特位(offset)。
最后调用opsForValue().setBit()传入三个参数即可。因为现实中没有第0天,而Redis的位序号从0开始,所以偏移量(第几天)还需减一才可存入。值指定为true,代表已签到。
// UserController———————————————————
@PostMapping("/sign")
public Result sign(){
return userService.sign();
}
// IUserService———————————————————————
Result sign();
// UserServiceImpl———————————————————
@Override
public Result sign() {
// 获取当前登录的用户ID
Long userId = UserHolder.getUser().getId();
// 获取当前时间
LocalDateTime now = LocalDateTime.now();
// 构造用于存储签到信息的Redis键,格式为 "sign:用户ID:年月"
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = "sign:" + userId + keySuffix;
// 获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 在Redis中设置对应天数的签到状态,offset为天序号减1(因为Redis的位序号从0开始)
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
// 返回签到成功的结果
return Result.ok();
}
因为前端并无相关按钮,所以直接使用Postman发送一个相关请求:
在Redis中找到对应的键值对,因为写文章时是11号,所以第11位为1,其他位均为0,所以值为0000000000100000。
签到统计
签到统计有多种方式,比如统计本月的总签到次数或截至今天的连续签到次数。统计总次数相对简单,但统计连续签到次数则较为复杂。
首先要明确什么是连续签到天数:从最后一次签到(今日签到)开始向前统计,直到遇到第一次未签到为止,这段时间内的签到次数即为连续签到次数。
可以用遍历来模拟该过程,每遍历一次都检查值是否为1(已签到),是则继续遍历,不是则结束遍历并返回已遍历(已签到)个数。
要获取本月截止今天为止的所有签到数据,即一次获取多个bit位,可以通过使用bitfield命令来获取。bitfield命令有两个关键参数:type和offset,即起始角标和要查的bit位的个数。要查本月第一天开始至今为止的所有的记录,则起始角标一定是0。今天是几号,就要查多少天,也就需要查该数量的bit位。
拿到签到数据后,我们需要从后向前逐个比特位进行遍历。在遍历过程中,可以将得到的十进制数字与1做与运算来获取每一个比特位的值()。与运算的特性是,只有两个数字都是1时,结果才是1,否则结果是0。所以当该bit位与1做与运算时,如果它是1,那么结果就是1;它是0,结果就是0。由于1是最小的二进制数,位于最后一个比特位,因此这个操作只会影响到结果的最后一个比特位。接下来可以把数字向右移动一位,使原来的倒数第二位变成了最后一位,原来的最后一位被抛弃。然后再次对新的最后一位进行与1的与运算,就能得到原来的倒数第二个比特位的值。
通过不断重复这个过程,就可以逐个遍历数字的所有比特位,当bit位为1时计数器加一,不为1时返回当前计数器。
接下来新建接口用于统计当前用户截至今天在本月的连续签到天数。请求方式为GET,请求路径为user/sign/count。由于要统计的是当前用户的连续签到天数,所以我们可以通过用户ID和当前日期来组装出对应的key,从而获取该用户本月的签到记录。
然后调用stringRedisTemplate.opsForValue().bitField方法来获取签到记录。上文介绍过,该方法功能较多,所以该方法传入key后,还需传入子命令来告诉系统该命令需要执行什么操作。该子命令可以通过静态方法BitFieldSubCommands.create(). get()来创建。内部需要指定有无符号、 查询个数、偏移量,可以通过BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)来指定。因为该命令可以执行多种操作,为兼容不同操作,返回类型为List。本操作只会返回一个十进制值,所以第0个就是我们需要的值。
拿到结果后先进行非空判断,然后对该十进制数字进行遍历,使用与运算来判断其是否为0。如果不为0,则计数器加1;如果为0,则结束循环,返回计数器的值。在遍历过程中,我们还需要将数字右移一位,以便判断下一个比特位。
// UserController———————————————————
@GetMapping("/sign/count")
public Result signCount(){
return userService.signCount();
}
// IUserService———————————————————————
Result signCount();
// UserServiceImpl———————————————————
@Override
public Result signCount() {
// 获取当前登录的用户ID
Long userId = UserHolder.getUser().getId();
// 获取当前时间
LocalDateTime now = LocalDateTime.now();
// 构造用于存储签到信息的Redis键,格式为 "sign:用户ID:年月"
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = "sign:" + userId + keySuffix;
// 获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 使用Redis的BITFIELD命令获取截至今天为止的所有签到记录,结果以十进制数字形式返回
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create().
get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));
// 检查结果是否为空或null,如果是则表示没有签到记录
if (result.isEmpty() || result == null) {
// 返回签到次数为0
return Result.ok(0);
}
// 获取签到记录的十进制数字
Long num = result.get(0);
// 初始化签到次数计数器
int count = 0;
// 循环遍历所有位,统计签到次数
while (true) {
// 将数字与1进行按位与操作,获取最后一个bit位,判断是否为0
if ((num & 1) == 0) {
// 如果bit位为0,表示未签到,结束循环
break;
} else {
// 如果bit位为1,表示已签到,签到次数计数器加1
count++;
}
// 将数字无符号右移一位,抛弃已经检查过的最后一个bit位
num >>>= 1;
}
// 返回累计签到次数
return Result.ok(count);
}
同样在Postman里测试,测试前我将签到的数据改为0011001111110000,系统能够正确识别连续签到天数:
UV统计
接下来学习UV统计功能。这一章分为两个小节:第一小节,认识一下HyperLogLog(简称HLL)这种数据结构的一个用法;第二节就会利用它来实现UV的统计。
HyperLogLog用法
在认识HyperLogLog之前,需要先搞懂UV和PV的概念。
- UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
- PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
PV往往会比UV要大很多,因为它有很多重复值。因此,我们去衡量一个网站的情况的时候,需要结合多个值一起来分析。通过UV能看出用户访问量,通过PV则能看出整个网站的整体流量。而PV与UV的比值,则可以看出这个网站的用户粘度如何。
统计一个网站的UV,即独立访客量,就必须要在用户访问的时,先判断其是否已被记录。如果没被记录过,计数器加一;如果已记录则无视。UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖。因此,把信息直接写入Redis显然不是一个理想的方案。这就要用到HyperLogLog了。
HyperLogLog简称为HLL,是从Loglog算法派生的一种概率算法,用于确定非常大的集合的一种基数。可以理解为:基于一些基本数据的统计,来推算出一个总数量。
Redis的HyperLogLog底层也是基于String接口来实现的,且单个HLL的内存永远小于16KB。无论有多少用户,其内存占用永远不会高于该值。内存占用低的代价则是,测量结果不是百分之百的精准,它也有一定的误差,大概在0.81%。但因为误差较小,所以完全可以忽略。
HLL相关的主要命令有三个:
- PFADD key element [element ...]:向指定的HyperLogLog数据结构中添加一个或多个元素。如果元素已经存在于结构中,则不会重复添加。返回1表示添加成功,0则表示添加失败。
- PFCOUNT key [key ...]:返回给定HyperLogLog结构的基数估计值。可以同时输入多个key进行统计。返回整数类型(并不完全准确),如果key不存在,则返回0。
- PFMERGE destkey sourcekey [sourcekey ...]:将一个或多个源HyperLogLog结构合并到一个目标HyperLogLog结构中。返回值为简单字符串回复,通常是OK。
因为只有第一个命令是常用命令,所以这里只演示第一个命令,其余命令为ai生成,仅供参考。先添加一个u1,返回1表示添加成功,随后一次添加多个:u2、u3、u4,也添加成功,但添加重复值u1和u2,则返回0表示添加失败:
192.168.88.111:6379> PFADD HLLTest u1
(integer) 1
192.168.88.111:6379> PFADD HLLTest u2 u3 u4
(integer) 1
192.168.88.111:6379> PFADD HLLTest u1 u2
(integer) 0
后两个命令示例:
PFCOUNT myhll
(integer) 1000
PFCOUNT myhll1 myhll2 myhll3
PFADD hll1 "element1" "element2" "element3"
(integer) 1
PFADD hll2 "element3" "element4" "element5"
(integer) 1
PFADD hll3 "element5" "element6" "element7"
(integer) 1
PFMERGE merged_hll hll1 hll2 hll3
OK
PFCOUNT merged_hll
(integer) 7
注意:
HyperLogLog提供的基数估计并非精确值,而是一个接近真实值的近似值,误差率通常在0.81%左右。PFCOUNT命令在执行时,可能会对HLL结构进行内部优化或调整,因此可以视为一个写命令。
PFMERGE命令会将多个HLL合并为一个HLL,合并后的HLL将包含所有源HLL中的唯一元素。合并操作不会改变源HLL的内容。
这三个命令使得HyperLogLog在处理大规模数据时,能够高效地估算集合中的唯一元素数量,而无需消耗大量的内存资源。
实现UV统计
原本我们应该实现UV(独立用户访问量)统计,但由于没有那么多真实用户,所以仅进行模拟统计,而非实际的UV统计。
为了测试Haplog的性能,可以使用一个for循环向HyperLogLog里插入100万条数据,测试其内存占用情况。但在开始测试之前,可以先通过info memory命令查看当前Redis的内存占用情况,第一个值为字节数,第二个为换算成MB的大小:
然后编写测试代码。在测试方法中,先定义一个字符串数组values,用于存储要插入的元素。由于要插入100万条数据,但不可能一次性插入,所以每次插入1000条,循环1000次来达到目标。为了模拟用户数据,我们在循环中使用user_+i的形式生成元素。
由于数组长度为1000,我们需要确保在插入数据时不会超出数组范围。因此,我们使用一个额外的变量index来作为数组的索引,每次插入数据时都使用index,并在index达到999时重置为0。这样,我们就能确保在循环过程中不会超出数组范围。
插入完数据后,每隔1000条就执行一次PFADD命令,将数据发送到Redis。插入完100万条数据后,我们使用PFCOUNT命令统计数量,并打印结果。
/**
* 测试 HyperLogLog 数据结构的使用方法。
*/
@Test
void testHyperLogLog() {
// 创建一个长度为 1000 的字符串数组来存储用户标识符
String[] values = new String[1000];
// 初始化索引变量
int index = 0
// 循环遍历 0 到 999999 的数字
for (int i = 0; i < 1000000; i++) {
// 计算当前索引值,确保它在 0 到 999 之间循环
index = i % 1000;
// 将格式化的用户标识符存储到数组中
values[index] = "user_" + i;
// 当索引达到 999 时,表示数组已满
if (index == 999) {
// 使用 Redis 的 HyperLogLog 数据结构添加这些唯一值
stringRedisTemplate.opsForHyperLogLog().add("hl2", values);
// 重置索引以便重新开始填充数组
index = -1;
}
}
// 从 Redis 中获取 HyperLogLog 的估计基数(即唯一值的数量)
Long count = stringRedisTemplate.opsForHyperLogLog().size("hl2");
// 打印出估计的唯一用户数
System.out.println("count = " + count);
}
运行测试后,统计的结果为997593,接近100万条,误差在可接受范围内。这说明HyperLogLog在海量数据下的统计还是比较准确的。
再查看Redis的内存占用情况,并与之前的值进行对比。结果发现,插入了100万条数据后,Redis的内存占用只增加了14KB左右。这验证了HyperLogLog在内存占用方面的优势,确实非常适合用于UV统计等需要处理海量数据的场景。
虽然并未实现真正的UV统计,但通过这个测试,我们可以相信HyperLogLog绝对能够满足我们对UV统计的需求。它不仅能够过滤重复元素,还能在海量数据下保持极低的内存占用,是一个完美的统计方案。将来如果真的需要去做UV统计,只需要将用户的信息不断往HyperLogLog里添加即可。