目录
一 概述为什么出现这几个数据类型
- 亿级数据收集统计流程
数据收集
数据清洗
数据统计
- 亿级数据收集统计面临的问题
高并发情况,能不能存的下
亿级数据存储完成后,怎么能方便的随时,瞬间取出
多条件多维度统计速度
- 常见统计的类型
聚合统计: 统计多个集合元素的聚合结果,也就是交,差,并等集合统计,例如set类型的交集差集,并集等集合运算
排序统计: list类型,zset(推荐),热点评论等
二值统计: 什么是二值统计,既至于两个值 true\false,签到,上下班打卡,使用bitmap(zset也可以但是要考虑百万千万级别)
基数统计: 去重,例如统计一个集合中不重复的元素,使用hyperloglog
- 使用场景(亿级数据收集统计)
统计每天的新增用户,跟第二天的留存用户
商品评论中,统计评论列表中的最新评论(统计+分页)
签到打卡,统计一个月内连续打卡用户
统计网页独立访客的UniqueVistor UV量
- 几个专业名词解释
UV: 独立访客Unique Visitor 一般理解为客户端ip,假设两个不同ip一天内都访问了某个网站,则说有两个uv,多次访问去重
PV: 页面浏览量Pae View 假设两个用户分别访问了某个网站3次,则说6个PV
DAU: 日活跃用户量Daily Active User,常用于网站登录(日活),产品的点击量,或者运营情况,去重的
MAU: 月活跃用户量Monthly Active User
二. bitmap
- 什么是bitmap: 由0和1状态表现的二进制位的bit数组,节省存储空间,存的快,取的快,bitmap是精确的,bitmap 最大位数是2^32,可以极大的节约存储空间,使用512m内存就可以存储42.9亿字节信息(2 ^32=4294967296),不适合亿级基数统计如果需要统计的有一亿个基数,大约需要100000000/8/1024/1024=12m,如果有1w个亿级比如热搜排行榜,热点视频等,就需要将近120G,所以不适合
- 优点: 底层是一个二进制位的bit数组,存储了0或1两个状态,跟普通数据类型比还是比较节省内存的
- 缺点:
- 不存储真实数据,只存储0或1的状态
- Bitmap的大小取决于需要记录的数据数量,假设亿级用户每天使用1个亿bitmap,占用128m的内存(10^8/8/1024/1024),10天需要120MB
- 简单命令示例
//1.setbit key offset value
//setbit 键 偏移位(偏移量是从0开始算) value只能是0或1
//3.获取指定key的指定偏移量对应的value
getbit key offect
//5.统计该key中为1的个数
bitcount key
//6.获取key1与key2两个key中相同偏移量都是1的个数(destkey 自定义表示"目的key"下方有示例)
bitop and destkey key1 key2
//7.与上面的命令配合使用,获取都是1的偏移量(destkey 自定义与上方的对应表示"目的key"下方有示例)
bitcount destkey
bitmap 的范围查询
- bitmap数据结构本身不支持范围查询,可以通过一些技巧来实现
- 假设要查询一段时间内的用户签到情况,
//signin:20230501: 时间作为key
//1001: 用户id作为偏移量
//1: 状态值
setbit signin:20230501 1001 1
- 通过"bitop and"命令拼接多个key,查询指定时间段内用户的签到情况,例如查询从2023年5月1日到5月7日内用户1001的签到情况,可以使用以下命令
bitop AND signin:20230501 signin:20230502 signin:20230503 signin:20230504 signin:20230505 signin:20230506 signin:20230507
bitmap 底层编码问题
- 通过执行"type key"命令查看到bitmap底层实际使用的是String类型
- 通过redis的"get key"命令获取bitmap的类型数据时返回却是乱码
- 原因是bitmap 底层是String类型,但是使用的是ascii进行编码的,可以使用"get key"命令,但是返回的是ascii表对应的值
根据 "STRLEN"命令了解 bitmap 的扩容
- bitmap 底层使用string字符串,可以使用"strlen key"命令来统计长度,但是要注意该命令按照字节来统计,在bitmap中一个偏移量占用一位,每八位一个byte,
- 查看下图发现首先执行"setbit k1 01"添加数据占用1位,然后执行"strlen"获取返回1,再添加>8位获取长度都是1,当执行了"setbit k1 9 1",添加k1的位数>8位(前面未添加的补0),再去获取长度返回2
- 一年365天全部签到占多少个字节365/8=46
- 总结bitmap每超过8位扩容一次,一次扩容增加8位也就是1个byte
使用场景
- bitmap 使用场景
日活统计
连续打卡签到
最近一周活跃用户
统计指定用户一年之内的登入天数
某用户365天哪天登录过
- 分析签到统计,如果少用户量可以通过mysql实现,如果大数据量,千万级,使用redis的bitmap, 没给用户一天签到用1个bit位,一年也就365个, key为"签到模块+用户id+日期", 签到就存储为1(假设按照年存储用户签到情况,365天需要365/8约等于46bit大小,1000w用户一年需要44mb就可以)
- 假设亿级用户每天使用1个亿bitmap,占用128m的内存(10^8/8/1024/1024),10天需要120MB,实际使用中Bitmap通常会设置过期时间,让redis自动删除不需要的内存开销
bitop 与 bitcount使用案例
- 场景案例: 获取连续两天都签到的用户
- 注册用户与偏移量进行关系映射:例如0对应用户id0001,1对应用户id0002
- 使用bitmap 存储,key为年月日例如"20210515",签到就进行存储
- 使用bitop 命令获取连续两个key都为1的偏移量个数
- 使用 bitcount+"目的key"命令获取对应的偏移量
- 通过映射表拿到偏移量对应的用户id
生产使用bitmap优化案例
- 需求: 查询用户历史订单,原逻辑直接通过用户信息查询所有订单判断是否存在,由于订单量较大,查询条件较少,查询慢,通过bitmap优化
- 用户在我方创建订单时,以酒店编码为key, 用户id为偏移量存储bitmap,最终有多少家酒店就会用多少个bitmap
- 查询时分两步走,先获取到所有酒店编码,通过酒店编码查询所有标识用户是否存在历史订单的bitmap,
- 如果存在对应的value为1,返回存有当前用户订单的酒店编码,上层再以酒店编码+用户信息作为查询条件查询
- 这样增加过滤条件,以酒店为维度,表中针对酒店编码添加索引,减少数据扫描数量,提高查询性能
- 防止bitmap数据过大,修改bitmap的key为"业务:酒店编码:年份:月份",修改业务按照月份查询历史订单,最高查询3个月前的
- 并且通过bitmap中的bitcount指令,提供了针对酒店入住数据的统计
- 用户查询在我方的历史订单时,通过bitmap查询,如果存在返回酒店编码,再通过酒店编码,
bitmap与布隆过滤器
- 可以通过bitmap实现布隆过滤器, 比如go-zero中就是,但是与实际的布隆过滤器之间也有不同,比较bitmap与真实的布隆过滤器之间的优缺点
- 总结: 当需要判断元素是否存在时,可以使用bitmap,当需要使用较少的内存空间来表示大量元素时,并且可以容忍一定的误判率时,可以使用布隆过滤器
- 布隆过滤器的特点:
- 空间效率高:布隆过滤器可以使用较少的内存空间来表示大量的元素。
- 查询效率高:布隆过滤器在查询元素是否存在时的效率很高,只需要进行几次哈希函数计算即可。
- 误判率较高:布隆过滤器在元素存在与否的判断上,存在一定的误判率,即可能将不存在的元素误判为存在。
- 不推荐删除元素:因为布隆过滤器使用哈希函数将元素映射到多个比特位上
- bitmap与布隆过滤器比较时: bitmap使用一个比特位来表示一个元素的存在与否,与redis的普通数据类型比确实节省了内存空间,但是如果数据量特别大,比如亿级,空间占用还是很大的,但是bitmap是精确的,不会存在误判
二. hyperloglog
- 什么是hyperloglog: 去重统计功能的基数估算发就是hyperloglog,
- 特点:在输入元素的数量或体积非常大时,计算基数所需要的空间总是固定的,并且是很小的,每个hyperloglog键需要12kb内存可以计算2^64个不同元素的基数,这和基数基数元素越多耗费内存越多的集合形成鲜明对比,但是hyperloglog只会根据元素来计算基数,不会存储元素本身,所以不能像其它类型一样返回各个元素
- hyperloglog是概率算法,是牺牲准确率换区空间的,对于对精度要求不高的情况下可以使用,因为概率算法本身不直接存储数据本身,能保证误差在一定范围内,又不占用空间,误差在0.81%左右(1.04/sqrt(m)) m表示redis使用寄存器的个数16384个等于0.81%
- 思考: 上面学过bitmap,学过set为什么不用这两个数据类型来统计基数,因为这里考虑的是大数据量千万级或亿级情况下,
bitmap用bit数组来表示元素是否出现,每个元素对应一位,需要n个bit,基数统计只需要获取存在的bit数组和新加入的元素按位计算即可,能够减少内存占用迅速响应,但是:如果需要统计的有一亿个基数,大约需要100000000/8/1024/1024=12m,如果有1w个亿级比如热搜排行榜,热点视频等,就需要将近120G,所以不适合,注意点bitmap是精确的
为什么不用redis的hash类型: 按照ipv4结构来说,ipv4一个地址最多占15个字节,像京东天猫这种每天访问量在亿级以上1.5亿字节=2g会打爆redis
- 小总结: hyperloglog: 只进行不重复基数统计,不是集合,也不保存数据本身,值记录数量,牺牲准确率换取空间,误差在0.81%左右
- hyperloglog 命令
//1.添加,var可以看为是key,element为对应该key的数据可以为空,可以是多个
PFADD var element element....emelent
//2.获取该var对应数据的个数(去重的)
PFCOUNT var
//3.合并多个var到新key dst自定义
PFMERGE dst var1 var2....varN
//4.获取上面dst中保存的个数
PFCOUNT dst
使用场景
- 统计某个网站的UV,统计某个文章的UV,例如统计京东的UV量, key为"jd:uv日期",后面的1,2,3,假设是访问用户的ip,黄色部分计算jd日期为uv1的访问量返回3,蓝色部分计算uv1,uv2两天的uv量量返回6(因为uv1与与uv2中有重复的)
- 统计一个网站uv 代码
package com.redis.test.controller;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
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;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@Api(description = "获取网站UV的Redis统计方案")
@RestController
public class HyperLogLogController {
@Autowired
private RedisTemplate redisTemplate;
private static final String uvKey = "uv:";
@ApiOperation("获得ip去重复后的首页访问量,总数统计")
@RequestMapping(value = "/getUV", method = RequestMethod.GET)
public long getUV(String date) {
return redisTemplate.opsForHyperLogLog().size(uvKey+date);
}
@ApiOperation("记录用户访问uv")
@RequestMapping(value = "/addUV", method = RequestMethod.GET)
public Integer addUV(String ip) {
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String timeStr = LocalDate.now().format(dateTimeFormatter);
redisTemplate.opsForHyperLogLog().add(uvKey + timeStr, ip);
return 0;
}
}
- 用户搜索关键词的数量
- 统计用户每天搜索的不同词条数
三. GEO
- 需求: 附近的人,打车软件附近的车辆,附近的店铺等等,经度, 纬度(-90, 90)跟地理位置相关的
- 获取举例我500米内的车辆需求,传统模式下,例如使用MySql
高并发下性能问题
查询的是一个范围矩形访问,而不是以我为中心r公里为半径的原型访问
精准度问题,我们要的是一个地球坐标,这种矩形计算在长距离上会有很大误差
- 核心点: 将三维的地球转换为二维的坐标,将二维的坐标转换为一维的点块,最终将一维的点块转换为二进制base32编码存储
- 简单解释经纬度:
经度:(-180, 180),东经为正数,西经为负数
纬度:(-90, 90),南纬为正数,北纬为负数
命令
//1.添加经纬度坐标
GEOADD
//2.返回经纬度
GEOPOS
//3.返回坐标的geohash表示
GEOHASH
//4.两个位置之间的举例
GEODIST
//5.以给定的经纬度为中心,返回与该经纬度距离不超过给定距离的所有位置元素
GEORADIUS
//6.以给定的元素为中心(元素可能重复,假设存储时 经纬度 天安门,这个天安门就是元素值)
//获取距离该元素不超过给定范围的元素
GEORADIUSBYMEMBER
GEOADD命令使用示例,与GEO数据类型问题
- 插入天安门,故宫,长城经纬度,并通过示例与"type key"命令查看存储的GEO数据类型为ZSET,然后使用ZSET的命令获取该key值"ZRANGE key start stop",返回的可能是乱码,需要执行" – raw"命令后,再次执查询会返回(“天安门 故宫 长城”)
- 可以这样理解zset类型是"key 分数 值"而 geo类型是 “key 经纬度 值”
GEOPOS命令使用示例
- 获取天安门,故宫,长城经纬度
GEOHASH 命令使用示例
- 获取天安门的hash编码
GEODIST 命令使用示例
- 计算两个位置的举例
GEORADIUS 命令使用示例
- 以给定的经纬度为中心,返回键包含的位置元素当中,与该键距离不超过给定距离的所有位置元素
- 其中有几个附加命令
- 示例:查看距离我不超过10mk的10个地标建筑
GEORADIUSBYMEMBER 命令使用示例
案例
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.geo.*;
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;
@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();
}
/**
*
* @param member 地理位置名称
* @return
*/
@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;
}
}