likes
comments
collection
share

Redis 进阶学习

作者站长头像
站长
· 阅读数 6

Zset排序

有一个分数,所以是三个字段,key,value,score,score用于排序。

添加元素,add

stringRedisTemplate.opsForZSet().add(key, value, score);

移除元素

stringRedisTemplate.opsForZSet().remove(key, value);

判断元素不存在的方式,以及获取分数

stringRedisTemplate.opsForZSet().score(key, value);

查询前五条

// 查询前五条
Set<String> range = stringRedisTemplate.opsForZSet().range(key, 0, 4);

注意事项

因为sql语句的,in并不是按照id排序的,需要手动指定排序,使用 order by field

SELECT * FROM user_table
WHERE id IN (1, 2, 3, 4, 5)
ORDER BY FIELD(id, 1, 2, 3, 4, 5);

Set API

交集,共同好友等使用

Set<String> strings = stringRedisTemplate.opsForSet().intersect(key, key2);

Feed 流实现方案

关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。

Redis 进阶学习

Feed流产品有两种常见模式:

1. Timeline

不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。

例如朋友圈

  • 优点:信息全面,不会有缺失。并且实现也相对简单
  • 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低

2. 智能排序

利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户

  • 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
  • 缺点:如果算法不精准,可能起到反作用

本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:

拉模式

也叫做,读扩散,每个人都有一个发件箱,携带一个时间戳,因为按照时间排序。

而(粉丝,例子)的人,有一个收件箱,用于接收关注的好友的信息。

只在每次查看的时候,去拉取信息,所以叫做,拉模式。

优点:不占用内存,缺点:关注过多,耗时,临时拉取,速度慢

Redis 进阶学习

推模式

也叫做,写扩散,直接写到粉丝用户里。

缺点:内存占用过高。

优点:延迟低

Redis 进阶学习

推拉结合

推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点。

活跃用户采用推模式,而普通用户,采用临时拉取信息,僵尸粉直接不拉取信息。

Redis 进阶学习

总结

拉模式推模式推拉结合
写比例
读比例
用户读取延迟
实现难度复杂简单很复杂
使用场景很少使用用户量少、没有大V过千万的用户量,有大V

Feed 流分页

Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。

第一次从10开始,0-5,所以是,10,9,8,7,6

新加了11,元素,继续读取,就变成了6~~,读取到重复元素了。

Redis 进阶学习

所以在数据变化的情况下,需要使用Feed流的滚动分页

基于最小值,下次从小最小值往下继续分页。

Redis 进阶学习

思路

我们使用 zrevrange 降序排列,去掉rev是升序。查询 0 - 2的数据。withscores 表示查看分数。

我们新增元素后,继续查询发现,数据错乱了。

zrevrange z1 0 2 withscores
zadd z1 7 m7
zrevrange z1 3 5 withscores

我们试一下滚动分页的方式

zrevrangebyscore 滚动查询

其中 z1 表示是哪个key,1000表示最大值,这个根据情况定,0表示最小值, 最大值表示上一次的最小值。

其中我们先设置,最大值,最小值,跳过几个,每页的数量。 变化的是最大值,和跳过几个。

# 第一次查询,1000 表示最大值,0 表示最小值
zrevrangebyscore z1 1000 0 withscores limit 0 3
# 第二次查询,最大值就是上一次的最小值
zrevrangebyscore z1 5 0 withscores limit 1 3

如果出现两个相同的score 值,那么查询会出现重复查询。

假设 m7 的值也是 6。我们看看第二页的查询结果。

Redis 进阶学习

可以看到这一页中,有出现了 6,按正常逻辑,6已经出现过了,我不想它在出现了。

Redis 进阶学习

这就是 跳过几个 的作用了。在命令中limit 1 3 其中 1 表示跳过几个,是根据,上一页中最小值出现的次数判断的。上一条命令应该写2才对。

代码中使用

// 使用了 Redis 中的有序集合(Sorted Set)进行范围查询,并按照分数(Score)进行降序排列,并返回成员。
stringRedisTemplate.opsForZSet().reverseRangeByScore(key, 0, max, offset, 2);

实战,滚动分页

@Override
    public Result queryBlogOfFollow(Long max, Integer offset) {
        // 1. 第一步,找到收件箱---需要当前用户。
        Long id = UserHolder.getUser().getId(); // 获取当前用户的 ID
        String key = RedisConstants.FEED_KEY + id; // 构建当前用户收件箱的 key

        // 使用了 Redis 中的有序集合(Sorted Set)进行范围查询,并按照分数(Score)进行降序排列。
        Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
                .reverseRangeByScoreWithScores(key, 0, max, offset, 2);

        // 非空校验
        if (typedTuples == null || typedTuples.isEmpty()) {
            // 若查询结果为空,则直接返回空列表
            return Result.ok();
        }

        // 2. 解析数据(blodId、score、offset)
        ArrayList<Long> list = new ArrayList<>();
        // 最小时间初始化为 0
        long minTime = 0;
        // 偏移量初始化为 1
        int os = 1; 
        for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
            // 解析出博客 ID,并添加到列表中
            list.add(Long.valueOf(typedTuple.getValue()));
            // 获取博客的时间戳
            long time = typedTuple.getScore().longValue(); 
            if (time == minTime) {
                // 如果时间戳相同,则偏移量加 1
                os++; 
            } else {
                // 更新最小时间
                minTime = time;
                // 重置偏移量为 1
                os = 1; 
            }
        }

        // 构建 ID 列表的字符串表示,用于 SQL 查询排序
        String str = StrUtil.join(",", list);
        // 查询博客列表,并按 ID 排序
        List<Blog> blogs = query().in("id", list).last("order by field(id," + str + ")").list();

        // 3. 根据ID,封装数据并返回。
        ScrollResult scrollResult = new ScrollResult();
        // 设置博客列表
        scrollResult.setList(blogs);
        // 设置偏移量
        scrollResult.setOffset(os); 
        // 设置最小时间
        scrollResult.setMinTime(minTime); 
        // 返回结果
        return Result.ok(scrollResult); 
    }

附近商户功能实现 GEO 数据结构

GEO就是Geolocation的简写形式,代表地理坐标。

Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。

常见的命令有:

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

地理位置集合创建并添加数据

创建地理位置集合 g1,它会自动把地理位置转换成score,并以zset 的方式存入。

  • 116.378248 经度
  • 39.865275 维度
  • bjn 是该数据的标识
geoadd g1 116.378248 39.865275 bjn

使用代码进行添加,单添加

单添加,需要用 Point 添加 x 和 y。

add 添加 参数一 是 point表示坐标,参数2 是 id。

stringRedisTemplate.opsForGeo()
                .add(key.toString(), new Point(shop.getX(), shop.getY()), shop.getId().toString())));

计算两个坐标有多远

计算 北京西到北京南站的位置。单位是km。可选 m、km、ft、mi

geodist g1 bjn bjx km

查找当前集合中距离某个位置10km的位置

# 查找当前集合中 指定位置 10 km内的所有位置。默认升序。
geosearch g1 fromlonlat 116.397904 39.909005 byradius 10 km withdist

查看集合中指定位置的hash值

# 查看 g1 集合中 bjz 的 hash值
geohash g1 bjz

查看集合中指定位置的坐标

# 查看 g1 集合中 bjz 的坐标
geopos g1 bjz

实战

将数据存入 Redis

一次插入一个

@Test
public void ssw2() {
    // 查询店铺信息
    List<Shop> shopList = shopService.list();
    // 把店铺分组,按照 TypeId 分
    Map<Long, List<Shop>> listMap = shopList.stream()
            .collect(Collectors.groupingBy(Shop::getTypeId));
    // 分批写入 Redis
    listMap.forEach((key, value) -> {
        value.forEach(shop ->
                // 一次插入一个用 new Point
                stringRedisTemplate.opsForGeo()
                        .add(key.toString(), new Point(shop.getX(), shop.getY()), shop.getId().toString()));
    });

}

一次插入一个集合

@Test
public void ssw2() {
    // 查询店铺信息
    List<Shop> shopList = shopService.list();
    // 把店铺分组,按照 TypeId 分
    Map<Long, List<Shop>> listMap = shopList.stream()
            .collect(Collectors.groupingBy(Shop::getTypeId));
    // 分批写入 Redis
    listMap.forEach((key, value) -> {
        // 创建 RedisGeoCommands.GeoLocation 集合,里面存入name 和 point,
        // org.springframework.data.redis.connection
        List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
        value.forEach(shop -> {
            locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(), new Point(shop.getX(), shop.getY())));
        });
        stringRedisTemplate.opsForGeo().add(key.toString(), locations);
    });
}

在以下版本可以使用 redis 6.2 的功能

<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>2.6.2</version>
</dependency>
<dependency>
    <artifactId>lettuce-core</artifactId>
    <groupId>io.lettuce</groupId>
    <version>6.1.6.RELEASE</version>
</dependency>

代码实现距离查询

@Override
public Object queryShopByType(Integer typeId, Integer current, Double x, Double y) {
    // 1. 判断是否需要根据坐标查询
    if (x == null || y == null) {
        // 根据类型分页查询
        Page<Shop> page = shopService.query()
                .eq("type_id", typeId)
                .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
        // 返回数据
        return page.getRecords();
    }
    // 2. 计算分页参数
    int from = (current - 1) * 5; // 每次查询 5 条
    int end = current * 5;
    // 3. 查询 redis 按照距离排序,分页。
    String key = RedisConstants.SHOP_GEO_KEY + typeId;

    // 为什么 from 没有使用呢,因为不支持分页,需要自己截取
    GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() // geosearch key bylonlat x y byradius 10 withdistance
            // GeoReference.fromCoordinate() 根据坐标, = bylonlat
            .search(key, GeoReference.fromCoordinate(x, y),
                    // 单位默认是米,5000 = 5公里
                    new Distance(5000),
                    // 携带 withdistance
                    RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance()
                            // 是获取多少条-- end 是每一页的数量。
                            .limit(end));
    if (results == null) {
        return null;
    }

    // 获取结果
    List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
    
    if (list.size() <= from) {
            // 没有下一页,返回空
            return null;
        }
    
    // 截取数据
    ArrayList<Long> ids = new ArrayList<>(list.size());
    HashMap<String, Distance> distanceMap = new HashMap<>();
    list.stream()
            // 跳过 from 个
            .skip(from).forEach(geoLocationGeoResult -> {
                // 店铺ID
                String id = geoLocationGeoResult.getContent().getName();
                ids.add(Long.valueOf(id));
                // 获取距离
                Distance distance = geoLocationGeoResult.getDistance();
                distanceMap.put(id, distance);
            });
    // 5. 根据id查询shop
    String idStr = StrUtil.join(",", ids);
    List<Shop> shopList = query().in("id", ids).last("order by field(id, " + idStr + ")").list();
    shopList.forEach(shop -> {
        Distance distance = distanceMap.get(shop.getId().toString());
        // 获取距离
        double value = distance.getValue();
        shop.setDistance(value);
    });

    return shopList;
}

BitMap 签到

假如我们用一张表来存储用户签到信息,其结构应该如下:

Redis 进阶学习

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

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

这种方式,非常的耗内存,对服务器的压力也非常的大。

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

Redis 进阶学习

这样一个月下来,一个用户消耗 2 个字节。

把每一个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出现的位置

设置值

1 有 0 没有

# 将位图bm1中偏移量为0的位的值设置为1。
SETBIT bm1 0 1

获取值

# 查看位图 bm1 的 2 位置是否有数据。返回 0 没有
getbit bm1 2

获取总数

# 查看bm1总数
bitcount bm1

批量查询,返回十进制

BITFIELD GET key [GET type offset]
    key:指定要操作的键名。
    GET type:指定要获取的位的类型,可以是u{n}、i{n}或{n}@{offset}。
    u{n}表示无符号整数,即返回的位被解释为无符号整数。
    i{n}表示有符号整数,即返回的位被解释为有符号整数。
    {n}@{offset}表示获取从偏移量offset开始的n个位,并将其作为整数返回。
    offset:指定要获取的位的偏移量。

# 将位图bm1中偏移量为0的位的值作为无符号整数返回
BITFIELD GET bm1 u1 0

查询第一个 0 或1 出现的位置

# 查询 0 出现的位置
bitpos bm1 1

实战签到功能

Redis 进阶学习

通过 setBit 去设置key,但是通过 LocalDateTime 获取的一个月的第几天是从1 开始的,所以需要 - 1

Boolean bit = stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
@Override
public Result sign() {
    // 获取用户
    Long userId = UserHolder.getUser().getId();
    // 获取日期
    LocalDateTime dateTime = LocalDateTime.now();
    // 拼接key
    String keySuffix = dateTime.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;
    // 获取今天是本月的第几天
    int dayOfMonth = dateTime.getDayOfMonth();
    // 写入redis setbit key
    Boolean bit = stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
    return Result.ok(bit);
}

统计连续签到天数

首先说一下位移运算。这是向右位移后赋值的方式。比如:110101 位移后是 011010,右移后,左侧后补 0,并丢弃最右边的数据。

>>>=

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

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

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

BITFIELD key GET u[dayOfMonth] 0

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

与 1 做与运算,就能得到最后一个bit位。 随后右移1位,下一个bit位就成为了最后一个bit位。

首先获取十进制数,表示本月签到的结果,与 1 做位运算,之后的到的结果是最后一个bit位,从后往前做统计。

@Override
public Result signCount() {
    // 获取用户
    Long userId = UserHolder.getUser().getId();
    // 获取日期
    LocalDateTime dateTime = LocalDateTime.now();
    // 拼接key
    String keySuffix = dateTime.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;
    // 获取今天是本月的第几天
    int dayOfMonth = dateTime.getDayOfMonth();

    // 1. 获取本月截至今天的所有签到记录,返回一个十进制数。
    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);
    }
    Long num = result.get(0);
    if (num == null || num == 0) {
        return Result.ok(0);
    }
    // 2. 循环遍历
    int count = 0;
    while (true) {
        // 1. 用这个数字与 1 做运算,得到数字的最后一个 bit 位
        long n = num & 1;
        // 2. 判断这个 bit 位是不是 0
        if (n == 0) {
            // 3. 如果是 0 ,说明未签到,结束
            break;
        }
        // 4. 如果不为 0,说明签到了,计数 + 1
        count++;
        // 5. 把数字右移,抛弃最后一个 bit 位,继续下一个
        num >>>= 1;
    }
    return Result.ok(count);
}

HyperLogLog用法

UV:全称 Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。

PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。

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

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

加入元素

# 给 hl1 加入三个元素,会自动去重的
pfadd hl1 e1 e2 e3

PV 统计

# 统计 hl1 有多少元素
pfcount hl1

实战UV测试

首先记录下 Redis 内存

info memory
# Memory
used_memory:1665400
used_memory_human:1.59M

代码实战

@Test
public void ssw2() {
    String[] values = new String[1000];
    int j;
    for (int i = 0; i < 1000000; i++) {
        j = i % 1000;
        values[j] = "user_" + i;
        if (j == 999) {
            // 发送到 redis
            stringRedisTemplate.opsForHyperLogLog().add("hl2", values);
        }
    }
    // 统计数量
    Long count = stringRedisTemplate.opsForHyperLogLog().size("hl2");
    System.out.println("count = " + count);

}

结果 count = 997593

997593 / 1000000 = 0.997593

误差 0.997593

我们在看看内存消耗

之前是 1665400,插入一百万数据后是 1924712

1924712 - 1665400 = 259312

这是字节,转成 kb

259312 ➗ 1024 = 253.234375

消耗 253k

"# Memory
used_memory:1924712
used_memory_human:1.84M

总结

HyperLogLog的作用:

做海量数据的统计工作

HyperLogLog的优点:

  • 内存占用极低
  • 性能非常好

HyperLogLog的缺点:

  • 有一定的误差