Redis高级数据类型 :bitmap、hyperloglog和GEO

本文介绍了如何利用Bitmap高效存储用户签到信息,HyperLogLog进行大数据基数统计,以及GEO在地理位置查询中的应用。通过实例展示了它们在实际场景中的操作和性能优势。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、bitmap

1. bitmap的应用

应用场景:

例如签到,打卡的应用,每天的状态只需要用一个bit来存储,就算一年365天也只需要356bit

按年去存储一个用户的签到情况,365天只需要365/8~46 Byte,1000W用户量一年也只需要44 MB就足够了。

假如是亿级的系统,每天使用1个1亿位的Bitmap约占12MB的内存(10^8/8/1024/1024),10天的Bitmap的内存开销约为120MB,内存压力不算太高。

在实际使用时,最好对Bitmap设置过期时间,让Redis自动删除不再需要的签到记录以节省内存开销。

在这里插入图片描述

使用举例:

127.0.0.1:6379> SETBIT sign:user1:202106 1 1	# 第一天签到
(integer) 0
127.0.0.1:6379> SETBIT sign:user1:202106 2 1	# 第二天签到
(integer) 0
127.0.0.1:6379> SETBIT sign:user1:202106 3 1	# 第三天签到
(integer) 0
127.0.0.1:6379> SETBIT sign:user1:202106 8 1	# 第八天签到
(integer) 0
127.0.0.1:6379> GETBIT sign:user1:202106 1		# 获取第一天是否签到,1表示签到
(integer) 1
127.0.0.1:6379> GETBIT sign:user1:202106 2		# 获取第二天是否签到,1表示签到
(integer) 1
127.0.0.1:6379> GETBIT sign:user1:202106 5		# 获取第五天是否签到,0表示未签到
(integer) 0
127.0.0.1:6379> BITCOUNT sign:user1:202106		# 获取本月总共签到几次
(integer) 4

高级用法:

对指定key按位进行交、并、非、异或操作,并将结果保存到destKey中

bitop op destKey key1 [key2...]
  • and:交
  • or:并
  • not:非
  • xor:异或

使用举例:

在这里插入图片描述

127.0.0.1:6379> SETBIT sign:20210619 1 1	# 日期20210619 用户id为 1 的用户签到
(integer) 0
127.0.0.1:6379> SETBIT sign:20210619 2 1	# 日期20210619 用户id为 2 的用户签到
(integer) 0
127.0.0.1:6379> SETBIT sign:20210620 1 1	# 日期20210620 用户id为 1 的用户签到
(integer) 0
127.0.0.1:6379> BITOP and destKey sign:20210619 sign:20210620	# 两个bitmap取与运算
(integer) 1
127.0.0.1:6379> BITCOUNT destKey	# 两天都签到的人有 1 个
(integer) 1
127.0.0.1:6379> GETBIT destKey 1	# 结果为 1 表示用户 1 两都签到了
(integer) 1
127.0.0.1:6379> GETBIT destKey 2	# 结果为 0 表示用户 2 某一天没有签到
(integer) 0

2. bitmap的实现

在这里插入图片描述

Redis 使用字符串对象来表示位数组,因为字符串对象时二进制安全的,所以可以直接存储位数组。

在这里插入图片描述

  • buf数组的每个字节都用一行来表示,每行的第一个格子buf[i]表示这是buf数组的哪个字节,而buf[i]之后的八个格子则分别代表这一字节中的八个位。
  • buf数组保存位数组的顺序和我们平时书写位数组的顺序是完全相反的,也就是逆序,例如buf[2] 0000 1111 实际保存的位数组是 1111 0000。这有利于SETBIT操作的实现。

2.1 bitmap 命令的实现

  • GETBIT 命令实现

GETBIT 用于返回在该位数组中某偏移量上的二进制位。

GETBIT <bitarray> <offset>

具体过程是:

  1. 计算byte= offset ÷ 8 ,byte值记录了offset偏移量指定的二进制位保存在位数组的哪个字节。
  2. 计算bit=(offset mod 8)+1,bit值记录了offset偏移量指定的二进制位是byte字节的第几个二进制位。
  3. 根据byte值和bit值,在位数组bitarray中定位offset偏移量指定的二进制位,并返回这个位的值。

所以总体的时间复杂度为 O(1)。

  • SETBIT 命令实现

SETBIT 操作可以将位数组的某个偏移量上的二进制位设置为value

SETBIT <bitarray> <offset> <value>

具体操作过程:

  1. 计算len= offset ÷ 8 + 1,len值记录了保存offset偏移量指定的二进制位至少需要多少字节。
  2. 检查bitarray键保存的位数组(也即是SDS)的长度是否小于 len,如果是的话,将SDS的长度扩展为len字节,并将所有新扩展空间的二进制位的值设置为0。
    • SDS 会对字符串扩展做空间的预分配,如果长度小于1MB则分配长度双倍的空间,如果大于 1MB,则多分配1MB的空间。
  3. 然后和上面GETBIT的操作一样获取到定位,之后对该位进行修改,并且返回原值。

因为buf数组使用逆序来保存位数组,所以当程序对buf数组进行扩展之后,写入操作可以直接在新扩展的二进制位中完成,而不必改动位数组原来已有的二进制位。所以时间复杂度也是 O(1)。

  • BITCOUNT 命令实现

BITCOUNT命令用于统计给定位数组中,值为1的二进制位的数量。

BITCOUNT key [start end] 

先介绍几种二进制统计的算法:

  1. 遍历法:遍历整个数组通过计数器计算二进制位数。
  2. 查表法:维护一张表,表中每个键是对应位数组,而值是1的数量,如果键是八位的位数组,则一次可以检查8个二进制位。
  3. variable-precision SWAR算法:具体可以看JDK 实现的 Integer.bitCount() ;
  4. Redis 的实现:如果二进制数量小于128位,则通过查表法来统计,如果大于,则使用SWAR算法。
  • BITOP 命令实现

该命令会创建一个新的空白位数组,然后将与,或,异或运算的结果,放到新数组中。

二、hyperloglog

通过牺牲准确率来换取空间,对于不要求绝对准确率的场景下可以使用,因为概率算法不直接存储数据本身.
通过一定的概率统计方法预估基数值,同时保证误差在一定范围内,由于又不储存数据故此可以大大节约内存

hyperloglog就是一种概率算法的实现。

  • 用于进行基数统计,不是集合,不保存数据,只记录数量而不是具体数据
  • 核心是基数估算算法,最终数值存在一定误差
  • 误差范围:基数估计的结果是一个带有 0.81% 标准错误的近似值
  • 耗空间极小,每个hyperloglog key占用了12K的内存用于标记基数
  • pfadd命令不是一次性分配12K内存使用,会随着基数的增加内存逐渐增大
  • Pfmerge命令合并后占用的存储空间为12K,无论合并之前数据量多少

1. 基本操作

  • 添加数据
PFADD key element1, element2...
  • 统计数据
PFCOUNT key1 key2....
  • 合并数据
PFMERGE destkey sourcekey [sourcekey...]

具体示例

主要应用于大数量的访问网站人数的统计

127.0.0.1:6379> PFADD taobao:uv 1 2 3	# 例如1 2 3 是用户ip 添加taobao 当天uv(Unique Visitor 独立访客)
(integer) 1
127.0.0.1:6379> PFCOUNT taobao:uv	# 获取当日访问人数
(integer) 3
127.0.0.1:6379> PFADD jd:uv1 1 2 3	# 添加jd 第一天uv
(integer) 1
127.0.0.1:6379> PFADD jd:uv2 1 2 3 4 5	# 添加jd 第二天uv
(integer) 1
127.0.0.1:6379> PFMERGE total:uv jd:uv1 jd:uv2	# 合并jd两天uv
OK
127.0.0.1:6379> PFCOUNT total:uv	# 获取jd两天uv总和
(integer) 5

如果对于HyperLogLog算法有兴趣了解的可以看这篇文章,HyperLogLog是大数据基数统计中的常见方法,无论是Redis,Spark还是 Flink都提供了这个功能,其目的就是在一定的误差范围内,用最小的空间复杂度来估算一个数据流的基数。

HyperLogLog 的结构也是raw编码形式的string

在这里插入图片描述

HyperLogLog实现原理

2. 简单案例

统计网站首页亿级UV的统计

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

/**
 * @Description: 获取uv的controller
 * @Date 2021/6/18 17:16
 * @@author: A.iguodala
 */
@Api("案例实战:网站首页亿级UV的Redis统计方案")
@RestController
@Slf4j
public class HyperLogLogController {

    @Autowired
    private RedisTemplate redisTemplate;

    @ApiOperation("获得ip去重复后的首页访问量,总数统计")
    @RequestMapping(value = "/uv",method = RequestMethod.GET)
    public long uv()
    {
        //pfcount
        return redisTemplate.opsForHyperLogLog().size("uv");
    }

}

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.util.Random;
import java.util.concurrent.TimeUnit;
/**
 * @Description:service 模拟大量用户访问该网站
 * @Date 2021/6/18 17:16
 * @@author: A.iguodala
 */
@Service
@Slf4j
public class HyperLogLogService {

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 模拟有用户来点击首页,每个用户就是不同的ip,不重复记录,重复不记录
     */
    @PostConstruct
    public void init() {
        log.info("------模拟后台有用户点击,每个用户ip不同");
        //自己启动线程模拟,实际上产不是线程
        new Thread(() -> {
            String ip = null;
            for (int i = 1; i <= 20000; i++) {
                Random random = new Random();
                ip = random.nextInt(255) + "." + random.nextInt(255) + "." + random.nextInt(255) + "." + random.nextInt(255);

                Long hll = redisTemplate.opsForHyperLogLog().add("uv", ip);
                log.info("ip={},该ip访问过的次数={}", ip, hll);
                //暂停3秒钟线程
                try {
                    TimeUnit.SECONDS.sleep(3);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "t1").start();
    }

}

三、GEO

GEO听名字都知道应用于地理位置的一些业务,例如打车附近的车辆,美团搜索附近的美食,微信附近的人等等

1. 基本操作

# 添加多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中
GEOADD key longitude latitude member [longitude latitude member ...] 

# 从键里面返回所有给定位置元素的位置(经度和纬度)
GEOPOS key member [member ...] 

# 返回两个给定位置之间的距离。
GEODIST key member1 member2 [unit] 

# 以给定的经纬度为中心, 返回与中心的距离不超过给定最大距离的所有位置元素。
GEORADIUS key longitude latitude radius m|km|ft|mi [withcoord] [withdist] [withhash] [count count]

# GEORADIUSBYMEMBER 跟GEORADIUS类似
GEORADIUSBYMEMBER key member radius m|km|ft|mi [withcoord] [withdist] [withhash] [count count]

# 返回一个或多个位置元素的 Geohash 表示
GEOHASH key member [member ...]

2. 数据结构

在这里插入图片描述
查询可知,GEO使用的数据类型是zset,底层结构采用的是ziplist ,因为我的数据量小,所以应该在数据量大的情况下,采用dict+ skiplist(字典保证O(1)的查询单个位置的效率,skiplist保证范围查找或者排序的效率)

3. 简单案例

import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.geo.Metrics;
import org.springframework.data.geo.Point;
import org.springframework.data.geo.Circle;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @Description
 * @Date 2021/6/18 17:16
 * @@author: A.iguodala
 */
@RestController
public class GeoController
{
    public  static final String CITY ="city";

    @Autowired
    private RedisTemplate redisTemplate;

    @ApiOperation("新增天安门故宫长城经纬度")
    @RequestMapping(value = "/geoadd",method = RequestMethod.POST)
    public String geoAdd()
    {
        Map<String, Point> map= new HashMap<>();
        map.put("天安门",new Point(116.403963,39.915119));
        map.put("故宫",new Point(116.403414 ,39.924091));
        map.put("长城" ,new Point(116.024067,40.362639));

        redisTemplate.opsForGeo().add(CITY,map);

        return map.toString();
    }

    @ApiOperation("获取地理位置的坐标")
    @RequestMapping(value = "/geopos",method = RequestMethod.GET)
    public Point position(String member) {
        //获取经纬度坐标
        List<Point> list= this.redisTemplate.opsForGeo().position(CITY,member);
        return list.get(0);
    }

    @ApiOperation("geohash算法生成的base32编码值")
    @RequestMapping(value = "/geohash",method = RequestMethod.GET)
    public String hash(String member) {
        //geohash算法生成的base32编码值
        List<String> list= this.redisTemplate.opsForGeo().hash(CITY,member);
        return list.get(0);
    }

    @ApiOperation("计算两个位置之间的距离")
    @RequestMapping(value = "/geodist",method = RequestMethod.GET)
    public Distance distance(String member1, String member2) {
        Distance distance= this.redisTemplate.opsForGeo().distance(CITY,member1,member2, RedisGeoCommands.DistanceUnit.KILOMETERS);
        return distance;
    }

    /**
     * 通过经度,纬度查找附近的
     * 北京王府井位置116.418017,39.914402,这里为了方便,故意写死
     */
    @ApiOperation("通过经度,纬度查找附近的")
    @RequestMapping(value = "/georadius",method = RequestMethod.GET)
    public GeoResults radiusByxy() {
        //这个坐标是北京王府井位置
        Circle circle = new Circle(116.418017, 39.914402, Metrics.MILES.getMultiplier());
        //返回50条
        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().includeCoordinates().sortAscending().limit(10);
        GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults= this.redisTemplate.opsForGeo().radius(CITY,circle, args);
        return geoResults;
    }

    /**
     * 通过地方查找附近
     */
    @ApiOperation("通过地方查找附近")
    @RequestMapping(value = "/georadiusByMember",method = RequestMethod.GET)
    public GeoResults radiusByMember() {
        String member="天安门";
        //返回50条
        RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().includeCoordinates().sortAscending().limit(10);
        //半径10公里内
        Distance distance=new Distance(10, Metrics.KILOMETERS);
        GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults= this.redisTemplate.opsForGeo().radius(CITY,member, distance,args);
        return geoResults;
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值