一、生产环境下需要解决的问题
在生产环境中常常无论是面试还是实际工作都会遇到如下问题:
- 如何统计签到信息?,用户在手机App上的签到打卡,1天内对应1系列用户的签到记录,例如:新浪微博、钉钉打卡
- 某个应用网站上的网页访问信息如何统计,例如淘宝首页1个网页对应1系列的访问点击,每天有多少人浏览首页?
- 抖音电商直播,主播介绍的商品有评论,1个商品对应了1系列的评论,排序+展现+取前10条记录
- 公司系统上线后,你们的项目UV、PV、DAU分别是多少?
上面的问题主要是当数据量比较大的时候,如有上亿条数据的时候,数据的收集、统计和展示就是一个难点,怎么样存储、读取和分析统计有价值的数据就变得尤为困难
二、常见的统计类型有哪些?
常见的统计类型有一下四种如下:
2.1.聚合统计
统计多个集合元素的聚合结果(交并差集合统计)set集合:
2.2.排序统计
像美团最新评论留言的场景,设计一个展现列表,在对需要展示最新列表、排行榜(可以安装最新时间、低分、晒图)等场景展示时,建议使用zset
2.3.二值统计
集合元素的取值就只有 0 和 1 ,来记录签到还是没签到 bitmap
2.4.基数统计
只统计一个集合中不重复的元素个数 hyperloglog
三、hyperloglog
3.1.常见访问量名词
- UV (Unique Visitor) 独立访客,一般理解为客户端IP (需要考虑去重);
- PV (Page View) 页面浏览量,不用去重;
- DAU(Daily Active User)日活跃用户量,登录或者使用了某个产品的用户数(去重复登录的用户),常用于反映网站、互联网应用或者网络游戏的运营情况;
- MAU(Monthly Active User)月活跃用户量;
很多计数类场景,例如:每日注册 IP 数、每日访问 IP 数、页面实时访问数 PV、访问用户数 UV等。因为主要的目标高效、海量的数据进行计数,所以对存储的数据的内容并不太关心。也就是说它只能用于统计海量数量,不太涉及具体的统计对象的内容和精准性。
- 统计单日一个页面的访问量(PV),单次访问就算一次。
- 统计单日一个页面的用户访问量(UV),即按照用户为维度计算,单个用户一天内多次访问也只算一次。
- 多个key的合并统计,某个门户网站的所有模块的PV聚合统计就是整个网站的总PV。
3.2.hyperloglog
Redis 2.8.9 版本中新增了 HyperLogLog 类型。HyperLoglog 是 Redis 适用于海量数据的计算、统计,其特点是占用空间小,计算速度快。
HyperLoglog 采用了一种基数估计算法,因此,最终得到的结果会存在一定范围的误差(标准误差为 0.81%)。每个 HyperLogLog key 只占用 12 KB 内存,所以理论上可以存储大约2^64
个值,而 set(集合)则是元素越多占用的内存就越多,两者形成了鲜明的对比 。
基数定义
基数定义:一个集合中不重复的元素个数就表示该集合的基数,比如集合 {1,2,3,4,1,2} ,它的基数集合为 {1,2,3,4} ,所以基数为 4。HyperLogLog 正是通过基数估计算法来统计输入元素的基数。
场景应用
HyperLogLog 也有一些特定的使用场景,它最典型的应用场景就是统计网站用户月活量,或者网站页面的 UV(网站独立访客)数据等。UV 与 PV(页面浏览量) 不同,UV 需要去重,同一个用户一天之内的多次访问只能计数一次。这就要求用户的每一次访问都要带上自身的用户 ID,无论是登陆用户还是未登陆用户都需要一个唯一 ID 来标识。当一个网站拥有巨大的用户访问量时,我们可以使用 Redis 的 HyperLogLog 来统计网站的 UV (网站独立访客)数据,它提供的去重计数方案,虽说不精确,但 0.81% 的误差足以满足 UV 统计的需求。
3.3.工作原理
hyperloglog就是基数统计,可以直接去重,说到去重复统计功能有一下方式:
- hashSet
- bitmap
- bitmap是通过用位bit数组来表示各元素是否出现,每个元素对应一位,所需的总内存为N个bit。
- 新进入的元素只需要将已经有的bit数组和新加入的元素进行按位或计算就行。这个方式能大大减少内存占用且位操作迅速。
- 如果数据较大,比如一个样本案例就是一亿个基数拉值数据,一个样本就是一亿,如果要统计一亿个数据的基数拉值,大约需要内存 1100000000/8/1024/1024 约等于 12M,内存减少占用的效果显著。这样得到统计一个对象样本的基数值需要12M。
- 如果统计10000个对象样本(1w个亿级),就需要117.1875G将近120G,可见使用bitmaps还是不适用大数据量下(亿级)的基数计数场景,
- 但是bitmap是精确计算的
上面的无论是hashSet或者bitmap,当数据量比较大的时候,就会量变会引起质变,所以以上两种方式均不能满足需要,解决方案:
- 通过牺牲准确率来换取空间,对于不要求绝对准确率的场景下可以使用,因为概率算法不直接存储数据本身,
- 通过一定的概率统计方法预估基数值,同时保证误差在一定范围内,由于又不储存数据故此可以大大节约内存。
- 而HyperLogLog就是一种概率算法的实现,我们通过HyperLogLog就可以实现网站用户月活量,或者网站页面的 UV(网站独立访客)数据等统计。
hyperloglog原理说明
- 只是进行不重复的基数统计,不是集合也不会保存数据,只记录数量而记录具体的数据内容
- 但是hyperloglog统计存在误差
- hyperloglog提供不精确的去重技术方案
- 牺牲准确率来换取空间,误差仅仅只是 0.81% 左右)
- 关于误差在redis之父安特雷兹的博客中有所说明:http://antirez.com/news/75,如下图:
3.3.亿级网站uv的redis统计方案实施
3.3.1.对于技术的选型
统计uv对于亿级访问量的网站而言可以有一下三种方式:
- 使用mysql:使用mysql如果并发量太高,数据都需要存入mysql中,导致mysql的检索也会变慢;
- 用redis的hash结构存储:按照ipv4的结构来说明,一个ip最多15个字节(ip=“192.168.238.1xx”),某一天 1.5亿*15个字节 = 2G,一个月60G,内存直接没了,加内存条也很难避免内存爆掉的问题;
- hyperloglog(推荐):在Redis里面,每个HyperLogLog键只需要花费12KB内存,就可以计算接近2的64次方个不同元素的基数
3.3.2.为什么选择hyperloglog
Redis使用了2的14次方=16384个寄存器,按照上面的标准差,误差为0.81%,精度相当高。Redis使用一个long型哈希值的前14个比特用来确定桶编号,剩下的50个比特用来做基数估计。而2的6次方=64,所以只需要用6个比特表示下标值,在一般情况下,一个HLL数据结构占用内存的大小为16384*6 /8= 12kB,Redis将这种情况称为密集(dense)存储。
3.3.3.创建一个springboot项目
这里就是一个普通的springboot项目,在pom.xml中添加如下依赖:
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<jedis.version>4.3.1</jedis.version>
<lettuce-core.version>6.2.3.RELEASE</lettuce-core.version>
<junit.version>4.12</junit.version>
<log4j.version>1.2.17</log4j.version>
<lombok.version>1.16.18</lombok.version>
<swagger.version>2.9.2</swagger.version>
</properties>
<dependencies>
<!--springboot通用依赖模块-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--SpringBoot与Redis整合依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--swagger2-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>${swagger.version}</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>${swagger.version}</version>
</dependency>
<!--通用基础配置-->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
</dependency>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>${log4j.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<optional>true</optional>
</dependency>
</dependencies>
3.3.4.设置配置文件信息
在resources中添加如下内容:
server.port=7777
spring.application.name=redis7_study
# ========================logging=====================
logging.level.root=info
logging.level.com.atguigu.redis7=info
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger- %msg%n
logging.file.name=D:/mylogs2023/redis7_study.log
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger- %msg%n
# ========================swagger=====================
spring.swagger2.enabled=true
#在springboot2.6.X结合swagger2.9.X会提示documentationPluginsBootstrapper空指针异常,
#原因是在springboot2.6.X中将SpringMVC默认路径匹配策略从AntPathMatcher更改为PathPatternParser,
# 导致出错,解决办法是matching-strategy切换回之前ant_path_matcher
spring.mvc.pathmatch.matching-strategy=ant_path_matcher
# ========================redis单机=====================
spring.redis.database=0
# 修改为自己真实IP
spring.redis.host=192.168.42.132
spring.redis.port=6379
spring.redis.password=123456
spring.redis.lettuce.pool.max-active=8
spring.redis.lettuce.pool.max-wait=-1ms
spring.redis.lettuce.pool.max-idle=8
spring.redis.lettuce.pool.min-idle=0
3.3.4.创建config层
在下面创建 RedisConfig 内容如下:
package com.redis7.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
/**
* redis序列化的工具配置类,下面这个请一定开启配置
* 127.0.0.1:6379> keys *
* 1) "ord102" 序列化过
* 2) "\xac\xed\x00\x05t\x00\aord102" 没有序列化过
* this.redisTemplate.opsForValue(); //提供了操作string类型的所有方法
* this.redisTemplate.opsForList(); // 提供了操作list类型的所有方法
* this.redisTemplate.opsForSet(); //提供了操作set的所有方法
* this.redisTemplate.opsForHash(); //提供了操作hash表的所有方法
* this.redisTemplate.opsForZSet(); //提供了操作zset的所有方法
* @param lettuceConnectionFactory
* @return
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(lettuceConnectionFactory);
//设置key序列化方式string
redisTemplate.setKeySerializer(new StringRedisSerializer());
//设置value的序列化方式json,使用GenericJackson2JsonRedisSerializer替换默认序列化
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
创建 SwaggerConfig 内容如下:
package com.redis7.demo.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Configuration
@EnableSwagger2 //开启swagger功能
public class SwaggerConfig{
@Value("${spring.swagger2.enabled}")
private Boolean enabled;
@Bean
public Docket createRestApi() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.enable(enabled)
.select()
.apis(RequestHandlerSelectors.basePackage("com.redis7.demo")) //你自己的package
.paths(PathSelectors.any())
.build();
}
public ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("springboot利用swagger2构建api接口文档 "+"\t"+ DateTimeFormatter.ofPattern("yyyy-MM-dd").format(LocalDateTime.now()))
.description("springboot项目整合")
.version("1.0")
.termsOfServiceUrl("https://www.augus.com/")
.build();
}
}
3.3.5.创建service层
在下面创建 HyperLogLogService 内容如下:
package com.redis7.demo.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.Random;
import java.util.concurrent.TimeUnit;
/**
* @author Augus
*/
@Service
@Slf4j
public class HyperLogLogService {
@Resource
private RedisTemplate redisTemplate;
@PostConstruct //java自带的注解,在方法上加如该注解会在项目启动的时候执行该方法
public void init(){
log.info("--------------模拟后台有用户点击首页,每个用户来自不同ip地址-----------");
new Thread(()->{
String ip = null;
for (int i=1; i<=200; i++){
Random random = new Random();
ip = random.nextInt(256) + "." + random.nextInt(256) + "." + random.nextInt(256) + "." + random.nextInt(256);
//将ip存储到redis HyperLogLog数据中
Long uvip = redisTemplate.opsForHyperLogLog().add("uvip", ip);
log.info("ip:{},该ip地址访问首页的次数={}",ip,uvip);
//模拟间隔时间
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t1").start();
}
}
3.3.6.创建controller层
在下面创建 HyperLogLogController 内容如下:
package com.redis7.demo.controller;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
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 javax.annotation.Resource;
@Api(tags = "亿级UV时Redis统计方案")
@RestController
public class HyperLogLogController {
@Resource
private RedisTemplate redisTemplate;
@ApiOperation("获得IP去重后的首页访问量")
@RequestMapping(value = "/getuv", method = RequestMethod.GET)
public long GetUV() {
//统计HyperLogLog()中uvip这个键所对应的值的个数
return redisTemplate.opsForHyperLogLog().size("uvip");
}
}
3.3.6.测试
先启动redis,然后启动项目,在浏览器访问 http://localhost:7777/swagger-ui.html# 这个是swagger的地址,然后调用接口,如下图即可看到统计的数据
查看redis中数据和上面通过接口查询的数据量一致
四、GEO
4.1.常见问题说明
面试题说明:
移动互联网时代软件中位置信息越来越多,像交友软件中附近人、外卖软件中附近的美食店铺、打车软件附近的车辆等等。那这种附近各种形形色色的地理位置选择是如何实现的?存在那些问题:
- 查询性能问题,如果并发高,数据量大这种查询是会导致mysql数据库宕机
- 一般mysql查询的是一个平面矩形访问,例如叫车服务要以我为中心N公里为半径的圆形覆盖。
- 精准度的问题,我们知道地球不是平面坐标系,而是一个圆球,这种矩形计算在长距离计算时会有很大误差,mysql就显得不合适
- 那么地理位置的问题就可以通过Redis GEO来解决
如果想要获取经纬度的话,可以去百度地图拾取坐标系统 (baidu.com)
4.2.常用命令说明
1.GEOADD添加经纬度坐标
添加一个GEO地理位置代码如下:注意这里有中文,防止乱码在登录redis客户端的时候添加 --raw 参数
GEOADD city 108.953509 34.265619 "钟楼" 108.970604 34.224485 "大雁塔" 108.97638 34.270923 "永兴坊"
执行如下图:
2.GEOPOS返回经纬度
查看经纬度,例如:
GEOPOS city 永兴坊 钟楼
执行后如下:
3.GEOHASH返回坐标的geohash表示
返回坐标的geohash值:
GEOHASH city 永兴坊 钟楼 大雁塔
执行后如下:
4.GEODIST两个位置之间距离
查看两点之间的距离:
GEODIST city 永兴坊 钟楼 km
后面参数是距离单位:
- m 米
- km 千米
- ft 英尺
- mi 英里
5.GEORADIUS 以半径为中心,查找附近的位置坐标
georadius 以给定的经纬度为中心, 返回键包含的位置元素当中, 与中心的距离不超过给定最大距离的所有位置元素。
GEORADIUS city 108.990011 34.260013 10 km withdist withcoord count 10 withhash desc
参数说明:
- WITHDIST: 在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回。 距离的单位和用户给定的范围单位保持一致。
- WITHCOORD: 将位置元素的经度和维度也一并返回。
- WITHHASH: 以 52 位有符号整数的形式, 返回位置元素经过原始 geohash 编码的有序集合分值。 这个选项主要用于底层应用或者调试, 实际中的作用并不大
- COUNT 限定返回的记录数。
6.GEORADIUSBYMEMBER 找出位于指定范围内的元素,中心点是由给定的位置元素决定
以钟楼和中心查找10km范围以内的位置坐标
GEORADIUSBYMEMBER city 钟楼 10 km withdist withcoord count 10 withhash
如下图:
4.3.以景点附近范围内的推送功能为案例演示
4.3.1.案例场景:
- 美团app附近的酒店
- 社交类app查找附近人
- 高德地图附近的人或者一公里以内的各种营业厅、加油站、理发店、超市.....
4.3.2.架构设计
以Redis中GEO数据类型实现上述案例,参考地址:http://www.redis.cn/commands/geoadd.html,核心是:
GEORADIUS: 以给定的经纬度为中心,找出某一半径内的元素
4.3.3.案例落地
在service包下创建 GeoService,内容如下:
package com.redis7.demo.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.geo.*;
import org.springframework.data.geo.Point;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.awt.*;
import java.util.HashMap;
import java.util.List;
/**
* @Auther: Augus
* @Date: 2023-04-03 20:49
* @Description: com.redis7.demo.service
* @version: 1.0
*/
@Service
@Slf4j
public class GeoService {
public static final String CITY ="city";
@Autowired
private RedisTemplate redisTemplate;
public String geoAdd(){
//Point里面存放的经纬度
HashMap<String, Point> stringPointHashMap = new HashMap<>();
//给hashmap中添加值
stringPointHashMap.put("兴庆公园", new Point(108.990011,34.260013));
stringPointHashMap.put("钟楼", new Point(108.953509,34.265619));
stringPointHashMap.put("永兴坊", new Point(108.97638,34.270923));
stringPointHashMap.put("大雁塔", new Point(108.970604,34.224485));
//写入到redis
redisTemplate.opsForGeo().add(CITY,stringPointHashMap);
return stringPointHashMap.toString();
}
public Point position(String member){
//获取经纬度坐标
List<Point> list = redisTemplate.opsForGeo().position(CITY,member);
return list.get(0);
}
public String hash(String member){
//geohash 算法生成的base32编码值
List<String> list = redisTemplate.opsForGeo().hash(CITY, member);
return list.get(0);
}
public Distance distance(String member1, String member2){
//获取两个给定位置之间的距离 RedisGeoCommands.DistanceUnit.KILOMETERS指定单位
return redisTemplate.opsForGeo().distance(CITY,member1,member2, RedisGeoCommands.DistanceUnit.KILOMETERS);
}
public GeoResults redisByXY(){
//通过经度,纬度查找附件的,西安钟楼的经纬度108.953509,34.265619
Circle circle = new Circle(108.953509, 34.265619, Metrics.KILOMETERS.getMultiplier());
//返回符合要求的50条
RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().includeCoordinates().sortAscending().limit(50);
GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults= redisTemplate.opsForGeo().radius(CITY,circle, args);
return geoResults;
}
public GeoResults radiusByMember(){
//通过地方名称查找
String member="钟楼";
//返回符合要求的50条
RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().includeCoordinates().sortAscending().limit(50);
//半径10公里
Distance distance = new Distance(10, Metrics.KILOMETERS);
GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults= redisTemplate.opsForGeo().radius(CITY,member, distance,args);
return geoResults;
}
}
在 controller 包下创建 GeoService,内容如下:
package com.redis7.demo.controller;
import com.redis7.demo.service.GeoService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.geo.Point;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* @Auther: Augus
* @Date: 2023-04-03 21:31
* @Description: com.redis7.demo.controller
* @version: 1.0
*/
@Api(tags = "地图位置附近的酒店推送GEO")
@RestController
@Slf4j
public class GeoController {
@Resource
private GeoService geoService;
@ApiOperation("添加坐标geoadd")
@RequestMapping(value = "/geoadd", method = RequestMethod.GET)
public String getAdd(){
return geoService.geoAdd();
}
@ApiOperation("获取经纬度坐标geopos")
@RequestMapping(value = "/geopos", method = RequestMethod.GET)
public Point position(String member){
return geoService.position(member);
}
@ApiOperation("获取经纬度生成的base32编码值geohash")
@RequestMapping(value = "/geohash", method = RequestMethod.GET)
public String hash(String member){
return geoService.hash(member);
}
@ApiOperation("获取两个给定位置之间的距离")
@RequestMapping(value = "/geodist", method = RequestMethod.GET)
public Distance distance(String member1, String member2)
{
return geoService.distance(member1,member2);
}
@ApiOperation("通过经纬度查找钟楼附件的位置")
@RequestMapping(value = "/georadius", method = RequestMethod.GET)
public GeoResults rediusByXY(){
return geoService.redisByXY();
}
@ApiOperation("通过地点名称查找的位置,这里以钟楼为例")
@RequestMapping(value = "/georadiusByMember", method = RequestMethod.GET)
public GeoResults radiusByMember(){
return geoService.radiusByMember();
}
}
重启项目,访问swagger,http://localhost:7777/swagger-ui.html#,如下分别测试:
- 添加坐标geoadd:
- 获取两个给定位置之间的距离
- 获取经纬度生成的base32编码值geohash
- 获取经纬度坐标geopos
- 通过经纬度查找钟楼附件的位置
- 通过地方查找附近,本例以钟楼作为地址演示
五、bitmap
5.1.常见场景说明
无论是在实际工作还是面试都会遇到一下问题:
- 日活跃用户量的统计
- 连续签到打卡
- 最近—周的活跃用户
- 统计指定用户━年之中的登陆天数
- 某用户按照一年365天,哪几天登陆过?哪几天没有登陆?全年中登录的天数共计多少?
5.2.bitmap是什么?
说明:用String类型作为底层数据结构实现的一种统计二值状态的数据类型
位图本质是数组,它是基于String数据类型的按位的操作。该数组由多个二进制位组成,每个二进制位都对应一个偏移量(我们可以称之为一个索引或者位格)。Bitmap支持的最大位数是2^32位,它可以极大的节约存储空间,使用512M内存就可以存储多大42.9亿的字节信息(2^32 = 4294967296)
核心由0和1状态表现的二进制位的bit数组
5.3.bitmap能做什么?
位图用户状态的统计的场景,例如:
- 京东每日签到送京豆电影
- 开屏广告是否被点击播放过
- 钉钉打卡上下班,签到统计
5.4.以京东每日签到领取京豆为案例说明
1.需求说明
签到日历仅展示当月签到数据,签到日历需展示最近连续签到天数,假设当前日期是20230318,且20230316未签到,那么显示如下:
- 若20230317已签到且0318未签到,则连续签到天数为1
- 若20230317已签到且0618已签到,则连续签到天数为2
连续签到天数越多,奖励越大所有用户均可签到,截至2020年3月31日的12个月,京东年度活跃用户数3.87亿,同比增长24.8%,环比增长超2500万,此外,2020年3月移动端日均活跃用户数同比增长46%假设10%左右的用户参与签到,签到用户也高达3千万
2.传统实现上述功能方式
建表SQL:
CREATE TABLE user_sign
(
keyid BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT,
user_key VARCHAR(200),#京东用户ID
sign_date DATETIME,#签到日期(20230318)
sign_count INT #连续签到天数
)
插入数据:
INSERT INTO user_sign(user_key,sign_date,sign_count)
VALUES ('20230318-xxxx-xxxx-xxxx-xxxxxxxxxxxx','2023-03-18 18:11:12',1);
上述通过SQL存储每一条签到记录的方法正确但是难以落地实现,签到用户量较小时可以这样设计,但对于京东这个体量的用户(估算3000W签到用户,一天一条数据,一个月就是9亿数据),如果一条签到记录对应着当日用记录,那会很恐怖,如何解决这个痛点呢?,思路如下:
- 一条签到记录对应一条记录,会占据越来越大的空间。
- 一个月最多31天,刚好我们的int类型是32位,那这样一个int类型就可以搞定一个月,32位大于31天,当天来了位是1没来就是0。
- 一条数据直接存储一个月的签到记录,不再是存储一天的签到记录。
3.目前的解决方式可以通过redis的bitmap来实现签到功能
在签到统计时,每个用户一天的签到用1个bit位就能表示,一个月(假设是31天)的签到情况用31个bit位就可以,一年的签到也只需要用365个bit位,使用bitmap即可轻松解决。
4.基本命令
- SETBIT key offset value :将第offset的值设为value value只能是0或1 offset 从0开始
127.0.0.1:6379> SETBIT k1 1 1
(integer) 0
127.0.0.1:6379> SETBIT k1 7 1
(integer) 0
127.0.0.1:6379> get k1
"A"
- GETBIT key offset :获得第offset位的值
127.0.0.1:6379> GETBIT k1 1
(integer) 1
127.0.0.1:6379> GETBIT k1 2
(integer) 0
127.0.0.1:6379> GETBIT k1 7
(integer) 1
127.0.0.1:6379>
- setbit和getbit案例说明
按照天:
按照年:
按年去存储一个用户的签到情况,365 天只需要 365 / 8 ≈ 46 Byte,1000W 用户量一年也只需要 44 MB 就足够了。
假如是亿级的系统,每天使用1个1亿位的Bitmap约占12MB的内存(10^8/8/1024/1024),10天的Bitmap的内存开销约为120MB,内存压力不算太高。在实际使用时,最好对Bitmap设置过期时间,让Redis自动删除不再需要的签到记录以节省内存的消耗。
- STRLEN key :得出占多少字节 超过8位后自己按照8位一组一byte再扩容
127.0.0.1:6379> SETBIT k3 0 1
(integer) 0
127.0.0.1:6379> SETBIT k3 7 1
(integer) 0
# 占据了一个字节
127.0.0.1:6379> STRLEN k3
(integer) 1
# 不是字符串长度而是占据几个字节,超过8位后自己按照8位一组一byte再扩容
127.0.0.1:6379>
127.0.0.1:6379> SETBIT k3 8 1
(integer) 0
# 再次查看占据了2个字节
127.0.0.1:6379> STRLEN k3
2
- BITCOUNT key
统计出该key里面含有几个1,
127.0.0.1:6379> SETBIT k5 0 1
(integer) 0
127.0.0.1:6379> SETBIT k5 6 1
(integer) 0
127.0.0.1:6379> SETBIT k5 7 1
(integer) 0
# 统计k5中有几个1
127.0.0.1:6379> BITCOUNT k5
(integer) 3
一年365天,统计全年每天登陆占用多少字节
# 给k6设置几个值
127.0.0.1:6379> SETBIT k6 0 1
(integer) 0
127.0.0.1:6379> SETBIT k6 1 1
(integer) 0
127.0.0.1:6379> SETBIT k6 20 1
(integer) 0
127.0.0.1:6379> SETBIT k6 365 1
(integer) 0
# 统计k6中1的个数,1代表签到
127.0.0.1:6379> BITCOUNT k6
(integer) 4
# 统计占用的字节数
127.0.0.1:6379> STRLEN k6
(integer) 46
127.0.0.1:6379>
- bitop
BITOP and destKey key1 key2 // 对一个或多个 key 求逻辑并,并将结果保存到 destkey
BITOP or destKey key1 key2 // 对一个或多个 key 求逻辑或,并将结果保存到 destkey
BITOP XOR destKey key1 key2 // 对一个或多个 key 求逻辑异或,并将结果保存到 destkey
BITOP NOT destKey key1 key2 // 对一个或多个 key 求逻辑非,并将结果保存到 destkey
连续2天都签到的用户:
# 创建bitmap 20230318
127.0.0.1:6379> SETBIT 20230318 0 1
(integer) 0
127.0.0.1:6379> SETBIT 20230318 1 1
(integer) 0
127.0.0.1:6379> SETBIT 20230318 2 1
(integer) 0
127.0.0.1:6379> SETBIT 20230318 3 1
(integer) 0
# 创建bitmap 20230319
127.0.0.1:6379> SETBIT 20230319 0 1
(integer) 0
127.0.0.1:6379> SETBIT 20230319 1 1
(integer) 0
127.0.0.1:6379>
# 统计20230318里面有几个1
127.0.0.1:6379> BITCOUNT 20230318
(integer) 4
# 统计20230319里面有几个1
127.0.0.1:6379> BITCOUNT 20230319
(integer) 2
127.0.0.1:6379>
# 求20230318 20230319的并集,将结果保存到了destkey
127.0.0.1:6379> BITOP and destkey 20230318 20230319
(integer) 1
# 统计20230319里面有几个1
127.0.0.1:6379> BITCOUNT destkey
(integer) 2
127.0.0.1:6379>