Redis高阶4-数据统计
数据统计
一:hyperloglog
Hyperloglog就是去重复统计的基数估计算法
Redis在2.8.9版本添加了 HyperLogLog 结构。
Redis HyperLogLog是用来做基数统计的算法,HyperLogLog的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。
在Redis里面,每个HyperLogLog键只需要花费12KB内存,就可以计算接近2^64个不同元素的基数。
这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。
但是,因为HyperLogL og只会根据输入元素来计算基数,而不会储存输入元素本身,所以HyperLogLog 不能像集合那样,返回输入的各个元素。
1.1 名词解释
-
什么是UV
Unique Visitor,独立访客,一般理解为客户端IP
-
什么是PV
Page View,页面浏览量
-
什么是DAU
Daily Active User,日活跃用户量,登录或者使用了某个产品的用户数(去重复登录的用户),常用于反映网站、互联网应用或者网络游戏的运营情况
-
什么是MAU
MonthIy Active User,月活跃用户量
1.2 需求
很多计数类场景,比如 每日注册 IP 数、每日访问 IP 数、页面实时访问数 PV、访问用户数 UV等。
因为主要的目标高效、巨量地进行计数,所以对存储的数据的内容并不太关心。
也就是说它只能用于统计巨量数量,不太涉及具体的统计对象的内容和精准性。
统计单日一个页面的访问量(PV),单次访问就算一次。
统计单日一个页面的用户访问量(UV),即按照用户为维度计算,单个用户一天内多次访问也只算一次。
多个key的合并统计,某个门户网站的所有模块的PV聚合统计就是整个网站的总PV。
1.3 原理
-
概率算法
通过牺牲准确率来换取空间,对于不要求绝对准确率的场景下可以使用,因为概率算法不直接存储数据本身,通过一定的概率统计方法预估基数值,同时保证误差在一定范围内,由于又不储存数据故此可以大大节约内存。
HyperLogLog就是一种概率算法的实现。
-
原理说明
只是进行不重复的基数统计,不是集合也不保存数据,只记录数量而不是具体内容。
Hyperloglog提供不精确的去重计数方案,牺牲准确率来换取空间,误差仅仅只是0.81%左右.
http://antirez.com/news/75
1.4 场景模拟
-
需求
UV的统计需要去重,一个用户一天内的多次访问只能算作一次
-
方案
用mysql
保存数据有限
用Redis的hash解构存储
redis——hash = <keyDay,<ip,1>>
按照ipv4的结构来说明,每个ipv4的地址最多是15个字节(ip = “192.168.111.1”,最多xxx.xxx.xxx.xxx)
某一天的1.5亿 * 15个字节= 2G,一个月60G,redis死定了。o(╥﹏╥)o
hyperloglog
为什么是只需要花费12Kb?
-
代码
HyperLogLogService
@Service @Slf4j public class HyperLogLogService { @Resource private RedisTemplate redisTemplate; /** * 模拟后台有用户点击首页,每个用户来自不同ip地址 */ @PostConstruct public void init() { log.info("------模拟后台有用户点击首页,每个用户来自不同ip地址"); new Thread(() -> { String ip = null; for (int i = 1; i <=200; i++) { Random r = new Random(); ip = r.nextInt(256) + "." + r.nextInt(256) + "." + r.nextInt(256) + "." + r.nextInt(256); Long hll = redisTemplate.opsForHyperLogLog().add("hll", ip); log.info("ip={},该ip地址访问首页的次数={}",ip,hll); //暂停几秒钟线程 try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } } },"t1").start(); } }
HyperLogLogController
@Api(description = "淘宝亿级UV的Redis统计方案") @RestController @Slf4j public class HyperLogLogController { @Resource private RedisTemplate redisTemplate; @ApiOperation("获得IP去重后的首页访问量") @RequestMapping(value = "/uv",method = RequestMethod.GET) public long uv() { //pfcount return redisTemplate.opsForHyperLogLog().size("hll"); } }
-
启动测试
二:GEO
2.1 场景模拟
-
需求
- 附近的酒店
- 附近的人
- 一公里内的各种商店
-
代码实现
GeoController
@Api(tags = "美团地图位置附近的酒店推送GEO") @RestController @Slf4j public class GeoController { @Resource private GeoService geoService; @ApiOperation("添加坐标geoadd") @RequestMapping(value = "/geoadd",method = RequestMethod.GET) public String geoAdd() { 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 radiusByxy() { return geoService.radiusByxy(); } @ApiOperation("通过地方查找附近,本例写死天安门作为地址") @RequestMapping(value = "/georadiusByMember",method = RequestMethod.GET) public GeoResults radiusByMember() { return geoService.radiusByMember(); } }
GeoService
import lombok.extern.slf4j.Slf4j; 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.stereotype.Service; import java.util.HashMap; import java.util.List; import java.util.Map; @Service @Slf4j public class GeoService { public static final String CITY ="city"; @Autowired private RedisTemplate redisTemplate; 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(); } public Point position(String member) { //获取经纬度坐标 List<Point> list= this.redisTemplate.opsForGeo().position(CITY,member); return list.get(0); } public String hash(String member) { //geohash算法生成的base32编码值 List<String> list= this.redisTemplate.opsForGeo().hash(CITY,member); return list.get(0); } public Distance distance(String member1, String member2) { //获取两个给定位置之间的距离 Distance distance= this.redisTemplate.opsForGeo().distance(CITY,member1,member2, RedisGeoCommands.DistanceUnit.KILOMETERS); return distance; } public GeoResults radiusByxy() { //通过经度,纬度查找附近的,北京王府井位置116.418017,39.914402 Circle circle = new Circle(116.418017, 39.914402, Metrics.KILOMETERS.getMultiplier()); //返回50条 RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().includeCoordinates().sortAscending().limit(50); GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults= this.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= this.redisTemplate.opsForGeo().radius(CITY,member, distance,args); return geoResults; } }
-
启动测试
三 bitmap
说明:用String类型作为底层数据结构实现的一种统计二值状态的数据类型
位图本质是数组,它是基于String数据类型的按位的操作。该数组由多个二进制位组成,每个二进制位都对应一个偏移量(我们可以称之为一个索引或者位格)。Bitmap支持的最大位数是232位,它可以极大的节约存储空间,使用512M内存就可以存储多大42.9亿的字节信息(232 = 4294967296)
由0和1状态表现的二进制位的bit数组
3.1 需求
签到日历仅展示当月签到数据签到日历需展示最近连续签到天数假设当前日期是20210618,且20210616未签到若20210617已签到且0618未签到,则连续签到天数为1若20210617已签到且0618已签到,则连续签到天数为2连续签到天数越多,奖励越大所有用户均可签到截至2020年3月31日的12个月,京东年度活跃用户数3.87亿,同比增长24.8%,环比增长超2500万,此外,2020年3月移动端日均活跃用户数同比增长46%假设10%左右的用户参与签到,签到用户也高达3千万。。。。。。o(╥﹏╥)o
传统mysql方式
CREATE TABLE user_sign ( keyid BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT, user_key VARCHAR(200),#京东用户ID ** sign_date DATETIME,#**签到日期(20210618) sign_count INT #连续签到天数 ) INSERT INTO user_sign(user_key,sign_date,sign_count) VALUES (‘20210618-xxxx-xxxx-xxxx-xxxxxxxxxxxx’,‘2020-06-18 15:11:12’,1); **SELECT ** sign_count **FROM ** user_sign **WHERE ** user_key = **‘20210618-xxxx-xxxx-xxxx-xxxxxxxxxxxx’ ** AND sign_date BETWEEN ‘2020-06-17 00:00:00’ AND **‘2020-06-18 23:59:59’ ****ORDER BY ** sign_date **DESC ** LIMIT 1; 方法正确但是难以落地实现,o(╥﹏╥)o。
签到用户量较小时这么设计能行,但京东这个体量的用户(估算3000W签到用户,一天一条数据,一个月就是9亿数据)
对于京东这样的体量,如果一条签到记录对应着当日用记录,那会很恐怖…
如何解决这个痛点?
1 一条签到记录对应一条记录,会占据越来越大的空间。
2 一个月最多31天,刚好我们的int类型是32位,那这样一个int类型就可以搞定一个月,32位大于31天,当天来了位是1没来就是0。
3 一条数据直接存储一个月的签到记录,不再是存储一天的签到记录。
基于Redis的Bitmap实现签到日历
在签到统计时,每个用户一天的签到用1个bit位就能表示,一个月(假设是31天)的签到情况用31个bit位就可以,一年的签到也只需要用365个bit位,根本不用太复杂的集合类型
3.2 场景模拟
见布隆过滤器