2022黑马Redis跟学笔记.实战篇 七

  • 4.11.附近的店铺功能
  • 4.11.1. GEO数据结构的基本用法
  • 1. 附近商户-导入店铺数据到GEO
  • 4.11.2. 获取附近的店铺
  • 1. 附近商户-实现附近商户功能
  • 4.9. 签到功能
  • 4.9.1.BitMap原理
  • 1. 用户签到-BitMap功能演示
  • 4.9.2.实现签到功能
  • 4.9.3.实现补签功能
  • 4.9.4.统计连续签到天数
  • 1. 用户签到-签到统计
  • 2. 额外加餐-关于使用bitmap来解决缓存穿透的方案
  • 4.10.UV统计
  • 4.10.1.UV统计的基本思路
  • 4.10.2.HypeLogLog实现统计


国产可以装redis redis国产化替代_数据


这里视频中先讲了4.11

4.11.附近的店铺功能

4.11.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.新功能。

国产可以装redis redis国产化替代_缓存_02

GEOADD g1 116.378248 39.865275 beijingnan
GEOADD g1 116.42803 39.903738 beijingzhan 116.322287 39.893729 beijingxizhan

国产可以装redis redis国产化替代_redis_03


看一下图形界面,底层是sortedset

国产可以装redis redis国产化替代_国产可以装redis_04


计算距离,以km为单位

GEODIST g1 beijingnan beijingxizhan km

国产可以装redis redis国产化替代_国产可以装redis_05

GEODIST g1 beijingxizhan beijingzhan km

国产可以装redis redis国产化替代_缓存_06


天安门附近火车站

GEOSEARCH g1 FROMLONLAT 116.397904 39.909005 BYRADIUS 10 km WITHDIST

国产可以装redis redis国产化替代_缓存_07

再看一下其它指令

  • GEOPOS 返回指定memeber的坐标
GEOPOS g1 beijingzhan

国产可以装redis redis国产化替代_缓存_08

  • GEOHASH 将指定member的坐标转为hash字符串形式并返回
GEOHASH g1 beijingzhan

国产可以装redis redis国产化替代_redis_09

1. 附近商户-导入店铺数据到GEO

具体场景说明:

国产可以装redis redis国产化替代_数据库_10


当我们点击美食之后,会出现一系列的商家,商家中可以按照多种排序方式,我们此时关注的是距离,这个地方就需要使用到我们的GEO,向后台传入当前app收集的地址(我们此处是写死的) ,以当前坐标作为圆心,同时绑定相同的店家类型type,以及分页信息,把这几个条件传入后台,后台查询出对应的数据再返回。

国产可以装redis redis国产化替代_缓存_11

我们要做的事情是:将数据库表中的数据导入到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);
        }
    }

运行单元测试

国产可以装redis redis国产化替代_国产可以装redis_12

4.11.2. 获取附近的店铺

1. 附近商户-实现附近商户功能

先安装插件Maven Helper,管理依赖

国产可以装redis redis国产化替代_缓存_13


管理Maven依赖

国产可以装redis redis国产化替代_redis_14


移除老版本

国产可以装redis redis国产化替代_数据库_15

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);
    }

重启应用,发现按照距离由近到远排序了

国产可以装redis redis国产化替代_缓存_16


此时滚动往下拉发现报错了

国产可以装redis redis国产化替代_redis_17


发现IDEA控制台报错了

国产可以装redis redis国产化替代_数据库_18


在ShopServiceImpl.java中增加逻辑

if (list.size() <= from) {
            return Result.ok(Collections.emptyList());
        }

国产可以装redis redis国产化替代_缓存_19


再次重启,查看下拉就正常了

国产可以装redis redis国产化替代_redis_20


国产可以装redis redis国产化替代_缓存_21

4.9. 签到功能

4.9.1.BitMap原理

1. 用户签到-BitMap功能演示

我们针对签到功能完全可以通过mysql来完成,比如说以下这张表

国产可以装redis redis国产化替代_数据库_22

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

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

我们如何能够简化一点呢?其实可以考虑小时候一个挺常见的方案,就是小时候,咱们准备一张小小的卡片,你只要签到就打上一个勾,我最后判断你是否签到,其实只需要到小卡片上看一看就知道了。

国产可以装redis redis国产化替代_国产可以装redis_23


我们可以采用类似这样的方案来实现我们的签到需求。

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

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

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

国产可以装redis redis国产化替代_数据库_24


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出现的位置
    需求:实现签到接口,将当前用户当天签到信息保存到Redis中

示例:

SETBIT bm1 0 1

国产可以装redis redis国产化替代_数据_25

GETBIT bm1 0

国产可以装redis redis国产化替代_数据_26

SETBIT bm1 1 1
SETBIT bm1 2 1
SETBIT bm1 5 1
SETBIT bm1 6 1

国产可以装redis redis国产化替代_缓存_27


查看redis图形界面中存取的数据,注意勾选二进制

国产可以装redis redis国产化替代_数据_28

BITCOUNT bm1

国产可以装redis redis国产化替代_缓存_29


从0开始2个bit位,刚好是11,二进制11转换为十进制就是3

BITFIELD bm1 GET u2 0

国产可以装redis redis国产化替代_redis_30


同理取3位,就是111,转换为10进制就是7

BITFIELD bm1 GET u3 0

国产可以装redis redis国产化替代_redis_31


查找bit数组中指定范围内第一个0出现的位置

BITPOS bm1 0

国产可以装redis redis国产化替代_数据_32


国产可以装redis redis国产化替代_数据_33

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

我们通过接口文档发现,此接口并没有传递任何的参数,没有参数怎么确实是哪一天签到呢?这个很容易,可以通过后台代码直接获取即可,然后到对应的地址上去修改bitMap。

国产可以装redis redis国产化替代_数据_34

4.9.2.实现签到功能

代码

修改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

国产可以装redis redis国产化替代_数据_35


点击send后的结果

国产可以装redis redis国产化替代_数据_36


保存在redis中的信息如下:比如今天19号,那就在19位是1

国产可以装redis redis国产化替代_数据库_37

4.9.3.实现补签功能

用命令行给前三天补签

SETBIT sign:1010:202302 0 1
SETBIT sign:1010:202302 1 1
SETBIT sign:1010:202302 2 1

国产可以装redis redis国产化替代_数据_38


前3天签到

国产可以装redis redis国产化替代_数据_39

4.9.4.统计连续签到天数

1. 用户签到-签到统计

**问题1:**什么叫做连续签到天数?

从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。

国产可以装redis redis国产化替代_缓存_40


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,此时就能找到这个用户截止这天的所有签到记录,再根据这套算法,就能统计出来他连续签到的次数了。

国产可以装redis redis国产化替代_数据库_41


代码

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 redis国产化替代_国产可以装redis_42


国产可以装redis redis国产化替代_redis_43

2. 额外加餐-关于使用bitmap来解决缓存穿透的方案

回顾缓存穿透

发起了一个数据库不存在的,redis里边也不存在的数据,通常你可以把他看成一个攻击

解决方案:

  • 判断id<0
  • 如果数据库是空,那么就可以直接往redis里边把这个空数据缓存起来

第一种解决方案:遇到的问题是如果用户访问的是id不存在的数据,则此时就无法生效

第二种解决方案:遇到的问题是:如果是不同的id那就可以防止下次过来直击数据

所以我们如何解决呢?

我们可以将数据库的数据,所对应的id写入到一个list集合中,当用户过来访问的时候,我们直接去判断list中是否包含当前的要查询的数据,如果说用户要查询的id数据并不在list集合中,则直接返回,如果list中包含对应查询的id数据,则说明不是一次缓存穿透数据,则直接放行。

国产可以装redis redis国产化替代_数据_44

现在的问题是这个主键其实并没有那么短,而是很长的一个 主键

哪怕你单独去提取这个主键,但是在11年左右,淘宝的商品总量就已经超过10亿个

所以如果采用以上方案,这个list也会很大,所以我们可以使用bitmap来减少list的存储空间

我们可以把list数据抽象成一个非常大的bitmap,我们不再使用list,而是将db中的id数据利用哈希思想,比如:

id % bitmap.size = 算出当前这个id对应应该落在bitmap的哪个索引上,然后将这个值从0变成1,然后当用户来查询数据时,此时已经没有了list,让用户用他查询的id去用相同的哈希算法, 算出来当前这个id应当落在bitmap的哪一位,然后判断这一位是0,还是1,如果是0则表明这一位上的数据一定不存在, 采用这种方式来处理,需要重点考虑一个事情,就是误差率,所谓的误差率就是指当发生哈希冲突的时候,产生的误差。

国产可以装redis redis国产化替代_redis_45

国产可以装redis redis国产化替代_缓存_46

4.10.UV统计

4.10.1.UV统计的基本思路

首先我们搞懂两个概念:

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

通常来说UV会比PV大很多,所以衡量同一个网站的访问量,我们需要综合考虑很多因素,所以我们只是单纯的把这两个值作为一个参考值。

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

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

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

国产可以装redis redis国产化替代_缓存_47

示例:
添加5个元素

PFADD hl e1 e2 e3 e4 e5

国产可以装redis redis国产化替代_数据库_48


查询元素的个数

PFCOUNT  hl

国产可以装redis redis国产化替代_缓存_49


UV统计,对于相同用户不能统计2次,那么我们插入相同元素看一下。发现计算的个数还是5

国产可以装redis redis国产化替代_数据_50

4.10.2.HypeLogLog实现统计

查看当前redis内存占用情况:

info memory

国产可以装redis redis国产化替代_缓存_51


当前内存占用情况:2139256

测试思路:我们直接利用单元测试,向HyperLogLog中添加100万条数据,看看内存占用和统计效果如何。

国产可以装redis redis国产化替代_国产可以装redis_52


代码如下:

@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);

    }

测试结果:

国产可以装redis redis国产化替代_国产可以装redis_53


看一下误差

国产可以装redis redis国产化替代_缓存_54


再看一下内存占用:

国产可以装redis redis国产化替代_redis_55


目前是2175992

与之前的差值是36736bit

国产可以装redis redis国产化替代_国产可以装redis_56


换算成kb是35kb

国产可以装redis redis国产化替代_数据库_57

1百万数据只占了36kb

经过测试:我们会发现它的误差是在允许范围内,并且内存占用极小。

实战篇结束了!