GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
GEOADD g1 116.378248 39.865275 beijingnan
GEOADD g1 116.42803 39.903738 beijingzhan 116.322287 39.893729 beijingxizhan
看一下图形界面,底层是sortedset
计算距离,以km为单位
GEODIST g1 beijingnan beijingxizhan km
GEODIST g1 beijingxizhan beijingzhan km
GEOSEARCH g1 FROMLONLAT 116.397904 39.909005 BYRADIUS 10 km WITHDIST
再看一下其它指令
GEOPOS g1 beijingzhan
GEOHASH g1 beijingzhan
具体场景说明:
当我们点击美食之后,会出现一系列的商家,商家中可以按照多种排序方式,我们此时关注的是距离,这个地方就需要使用到我们的GEO,向后台传入当前app收集的地址(我们此处是写死的) ,以当前坐标作为圆心,同时绑定相同的店家类型type,以及分页信息,把这几个条件传入后台,后台查询出对应的数据再返回。
我们要做的事情是:将数据库表中的数据导入到redis中去,redis中的GEO,GEO在redis中就一个menber和一个经纬度,我们把x和y轴传入到redis做的经纬度位置去,但我们不能把所有的数据都放入到menber中去,毕竟作为redis是一个内存级数据库,如果存海量数据,redis还是力不从心,所以我们在这个地方存储他的id即可。
但是这个时候还有一个问题,就是在redis中并没有存储type,所以我们无法根据type来对数据进行筛选,所以我们可以按照商户类型做分组,类型相同的商户作为同一组,以typeId为key存入同一个GEO集合中即可
代码
HmDianPingApplicationTests
@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);
}
}
先安装插件Maven Helper,管理依赖
管理Maven依赖
移除老版本
SpringDataRedis的2.3.9版本并不支持Redis 6.2提供的GEOSEARCH命令,因此我们需要提示其版本,修改自己的POM
第一步:导入pom.xml
<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>
第二步:
修改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.java
/**
* @param
* @return void
* @description //根据商铺类型分页查询商铺信息(加入坐标)
* @param: typeId
* @param: current
* @param: x
* @param: y
* @date 2023/2/19 1:00
* @author wty
**/
Result queryShopByType(Integer typeId, Integer current, Double x, Double y);
修改实现类ShopServiceImpl
/**
* @param
* @return void
* @description //根据商铺类型分页查询商铺信息(加入坐标)
* @param: typeId
* @param: current
* @param: x
* @param: y
* @date 2023/2/19 1:00
* @author wty
**/
@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);
}
重启应用,发现按照距离由近到远排序了
此时滚动往下拉发现报错了
发现IDEA控制台报错了
在ShopServiceImpl.java中增加逻辑
if (list.size() <= from) {
return Result.ok(Collections.emptyList());
}
我们针对签到功能完全可以通过mysql来完成,比如说以下这张表
用户一次签到,就是一条记录,假如有1000万用户,平均每人每年签到次数为10次,则这张表一年的数据量为 1亿条
每签到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共22 字节的内存,一个月则最多需要600多字节
我们如何能够简化一点呢?其实可以考虑小时候一个挺常见的方案,就是小时候,咱们准备一张小小的卡片,你只要签到就打上一个勾,我最后判断你是否签到,其实只需要到小卡片上看一看就知道了。
我们可以采用类似这样的方案来实现我们的签到需求。
我们按月来统计用户签到信息,签到记录为1,未签到则记录为0.
把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)。这样我们就用极小的空间,来实现了大量数据的表示
Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是 232个bit位。
示例:
SETBIT bm1 0 1
GETBIT bm1 0
SETBIT bm1 1 1
SETBIT bm1 2 1
SETBIT bm1 5 1
SETBIT bm1 6 1
BITCOUNT bm1
从0开始2个bit位,刚好是11,二进制11转换为十进制就是3
BITFIELD bm1 GET u2 0
BITFIELD bm1 GET u3 0
BITPOS bm1 0
思路:我们可以把年和月作为bitMap的key,然后保存到一个bitMap中,每次签到就到对应的位上把数字从0变成1,只要对应是1,就表明说明这一天已经签到了,反之则没有签到。
我们通过接口文档发现,此接口并没有传递任何的参数,没有参数怎么确实是哪一天签到呢?这个很容易,可以通过后台代码直接获取即可,然后到对应的地址上去修改bitMap。
代码
修改UserController
@PostMapping("/sign")
public Result sign(){
return userService.sign();
}
修改接口IUserService.java
/**
* @param
* @return com.hmdp.dto.Result
* @description //签到
* @date 2023/2/19 12:21
* @author wty
**/
Result sign();
修改UserServiceImpl
/**
* @param
* @return com.hmdp.dto.Result
* @description //签到
* @date 2023/2/19 12:22
* @author wty
**/
@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();
}
重启应用测试
打开PostMan,配置路径
http://localhost:8080/api/user/sign
配置token
点击send后的结果
保存在redis中的信息如下:比如今天19号,那就在19位是1
用命令行给前三天补签
SETBIT sign:1010:202302 0 1
SETBIT sign:1010:202302 1 1
SETBIT sign:1010:202302 2 1
**问题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进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。
需求:实现下面接口,统计当前用户截止当前时间在本月的连续签到天数
有用户有时间我们就可以组织出对应的key,此时就能找到这个用户截止这天的所有签到记录,再根据这套算法,就能统计出来他连续签到的次数了。
UserController
@GetMapping("/sign/count")
public Result signCount(){
return userService.signCount();
}
IUserService.java
/**
* @param
* @return com.hmdp.dto.Result
* @description //合计签到总数
* @date 2023/2/19 14:33
* @author wty
**/
Result signCount();
UserServiceImpl
/**
* @param
* @return com.hmdp.dto.Result
* @description //合计签到总数
* @date 2023/2/19 14:33
* @author wty
**/
@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);
}
重新启动应用
用PosrMan测试,注意这里实现的是截至今天为止的连续签到次数,昨天签到,今天没签,连续签到次数也是0
回顾缓存穿透:
发起了一个数据库不存在的,redis里边也不存在的数据,通常你可以把他看成一个攻击
解决方案:
判断id<0
如果数据库是空,那么就可以直接往redis里边把这个空数据缓存起来
第一种解决方案:遇到的问题是如果用户访问的是id不存在的数据,则此时就无法生效
第二种解决方案:遇到的问题是:如果是不同的id那就可以防止下次过来直击数据
所以我们如何解决呢?
我们可以将数据库的数据,所对应的id写入到一个list集合中,当用户过来访问的时候,我们直接去判断list中是否包含当前的要查询的数据,如果说用户要查询的id数据并不在list集合中,则直接返回,如果list中包含对应查询的id数据,则说明不是一次缓存穿透数据,则直接放行。
现在的问题是这个主键其实并没有那么短,而是很长的一个 主键
哪怕你单独去提取这个主键,但是在11年左右,淘宝的商品总量就已经超过10亿个
所以如果采用以上方案,这个list也会很大,所以我们可以使用bitmap来减少list的存储空间
我们可以把list数据抽象成一个非常大的bitmap,我们不再使用list,而是将db中的id数据利用哈希思想,比如:
id % bitmap.size = 算出当前这个id对应应该落在bitmap的哪个索引上,然后将这个值从0变成1,然后当用户来查询数据时,此时已经没有了list,让用户用他查询的id去用相同的哈希算法, 算出来当前这个id应当落在bitmap的哪一位,然后判断这一位是0,还是1,如果是0则表明这一位上的数据一定不存在, 采用这种方式来处理,需要重点考虑一个事情,就是误差率,所谓的误差率就是指当发生哈希冲突的时候,产生的误差。
首先我们搞懂两个概念:
通常来说UV会比PV大很多,所以衡量同一个网站的访问量,我们需要综合考虑很多因素,所以我们只是单纯的把这两个值作为一个参考值。
UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖,那怎么处理呢?
Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:Hyperloglog算法
Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
示例:
添加5个元素
PFADD hl e1 e2 e3 e4 e5
PFCOUNT hl
UV统计,对于相同用户不能统计2次,那么我们插入相同元素看一下。发现计算的个数还是5
查看当前redis内存占用情况:
info memory
测试思路:我们直接利用单元测试,向HyperLogLog中添加100万条数据,看看内存占用和统计效果如何。
@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);
}
目前是2175992
与之前的差值是36736bit
换算成kb是35kb
1百万数据只占了36kb
经过测试:我们会发现它的误差是在允许范围内,并且内存占用极小。
黑马实战篇结束了!