百度360必应搜狗淘宝本站头条
当前位置:网站首页 > IT技术 > 正文

HyperLogLog 是什么?这些场景使用它,让我枪出如龙,一笑破苍穹

wptr33 2024-12-18 17:32 32 浏览

在移动互联网的业务场景中,数据量很大,我们需要保存这样的信息:一个 key 关联了一个数据集合,同时对这个数据集合做统计。

比如:

  • 统计一个 APP 的日活、月活数;
  • 统计一个页面的每天被多少个不同账户访问量(Unique Visitor,UV));
  • 统计用户每天搜索不同词条的个数;
  • 统计注册 IP 数。

通常情况下,我们面临的用户数量以及访问量都是巨大的,比如百万、千万级别的用户数量,或者千万级别、甚至亿级别的访问信息。

今天「码哥」分别使用不同的数据类型来实现:统计一个页面的每天被多少个不同账户访问量这个功能,循序渐进的引出 HyperLogLog的原理与 Java 中整合 Redission 实战。

告诉大家一个技巧,Redis 官方网站现在能在线运行 Redis 指令了:https://redis.io/。如图:

使用 Set 实现

一个用户一天内多次访问一个网站只能算作一次,所以很容易就想到通过 Redis 的 Set 集合来实现。

比如微信 ID为「肖菜鸡」访问 「Redis为什么这么快」这篇文章时,我们把这个信息存到 Set 中。

SADD Redis为什么这么快:uv 肖菜鸡 谢霸哥 肖菜鸡
(integer) 1

「肖菜鸡」多次访问「Redis为什么这么快」页面,Set 的去重功能保证不会重复记录同一个「微信 ID」。

通过 SCARD 命令,统计「Redis 为什么这么快」页面 UV。指令返回一个集合的元素个数(也就是用户 ID)。

SCARD Redis为什么这么快:uv
(integer) 2

使用 Hash 实现

码老湿,还可以利用 Hash 类型实现,将用户 ID 作为 Hash 集合的 key,访问页面则执行 HSET 命令将 value 设置成 1。

即使「肖菜鸡」重复访问页面,重复执行命令,也只会把 key 等于「肖菜鸡」的 value 设置成 1。

最后,利用 HLEN 命令统计 Hash 集合中的元素个数就是 UV。

如下:

HSET Redis为什么这么快 肖菜鸡 1
// 统计 UV
HLEN Redis为什么这么快

使用 Bitmap 实现

Bitmap 的底层数据结构用的是 String 类型的 SDS 数据结构来保存位数组,Redis 把每个字节数组的 8 个 bit 位利用起来,每个 bit 位 表示一个元素的二值状态(不是 0 就是 1)。

Bitmap 提供了 GETBIT、SETBIT 操作,通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行读写操作,需要注意的是 offset 从 0 开始。

可以将 Bitmap 看成是一个 bit 为单位的数组,数组的每个单元只能存储 0 或者 1,数组的下标在 Bitmap 中叫做 offset 偏移量。

为了直观展示,我们可以理解成 buf 数组的每个字节用一行表示,每一行有 8 个 bit 位,8 个格子分别表示这个字节中的 8 个 bit 位,如下图所示:

8 个 bit 组成一个 Byte,所以 Bitmap 会极大地节省存储空间。 这就是 Bitmap 的优势。

如何使用 Bitmap 来统计页面的独立用户访问量呢?

Bitmap 提供了 SETBIT 和 BITCOUNT 操作,前者通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行写操作,需要注意的是 offset 从 0 开始。

后者统计给定指定的 bit 数组中,值 = 1 的 bit 位的数量。

需要注意的事,我们需要把「微信 ID」转换成数字,因为offset 是下标。

假设我们将「肖菜鸡」转换成编码6

第一步,执行下面指令表示「肖菜鸡」的编码为 6 并 访问「巧用Redis 数据类型实现亿级数据统计」这篇文章。

SETBIT 巧用Redis数据类型实现亿级数据统计 6 1

第二步,统计页面访问次数,使用 BITCOUNT 指令。该指令用于统计给定的 bit 数组中,值 = 1 的 bit 位的数量。

BITCOUNT 巧用Redis数据类型实现亿级数据统计

HyperLogLog 王者方案

Set 虽好,如果文章非常火爆达到千万级别,一个 Set 就保存了千万个用户的 ID,页面多了消耗的内存也太大了。

同理,Hash数据类型也是如此。

至于 Bitmap,它更适合于「二值状态统计」的使用场景,统计精度高,虽然内存占用要比HashMap少,但是对于大量数据还是会占用较大内存。

咋办呢?

这些就是典型的「基数统计」应用场景,基数统计:统计一个集合中不重复元素的个数。

HyperLogLog 的优点在于它所需的内存并不会因为集合的大小而改变,无论集合包含的元素有多少个,HyperLogLog进行计算所需的内存总是固定的,并且是非常少的

每个 HyperLogLog 最多只需要花费 12KB 内存,在标准误差 0.81%的前提下,就可以计算 2 的 64 次方个元素的基数。

Redis 实战

HyperLogLog 使用太简单了。PFADD、PFCOUNT、PFMERGE三个指令打天下。

PFADD

将访问页面的每个用户 ID 添加到 HyperLogLog 中。

PFADD Redis主从同步原理:uv userID1 userID 2 useID3

PFCOUNT

利用 PFCOUNT 获取 「Redis主从同步原理」文章的 UV值。

PFCOUNT Redis主从同步原理:uv

PFMERGE 使用场景

HyperLogLog` 除了上面的 `PFADD` 和 `PFCOIUNT` 外,还提供了 `PFMERGE

语法

PFMERGE destkey sourcekey [sourcekey ...]

比如在网站中我们有两个内容差不多的页面,运营说需要这两个页面的数据进行合并。

其中页面的 UV 访问量也需要合并,那这个时候 PFMERGE 就可以派上用场了,也就是同样的用户访问这两个页面则只算做一次

如下所示:Redis、MySQL 两个 HyperLogLog 集合分别保存了两个页面用户访问数据。

PFADD Redis数据 user1 user2 user3
PFADD MySQL数据 user1 user2 user4
PFMERGE 数据库 Redis数据 MySQL数据
PFCOUNT 数据库 // 返回值 = 4

将多个 HyperLogLog 合并(merge)为一个 HyperLogLog , 合并后的 HyperLogLog 的基数接近于所有输入 HyperLogLog 的可见集合(observed set)的并集。

user1、user2 都访问了 Redis 和 MySQL,只算访问了一次。

Redission 实战

详细源码「码哥」上传到 GitHub 了:https://github.com/MageByte-Zero/springboot-parent-pom.git

pom 依赖

<dependency>
  <groupId>org.redisson</groupId>
  <artifactId>redisson-spring-boot-starter</artifactId>
  <version>3.16.7</version>
</dependency>

添加数据到 Log

// 添加单个元素
public <T> void add(String logName, T item) {
  RHyperLogLog<T> hyperLogLog = redissonClient.getHyperLogLog(logName);
  hyperLogLog.add(item);
}

// 将集合数据添加到 HyperLogLog
public <T> void addAll(String logName, List<T> items) {
  RHyperLogLog<T> hyperLogLog = redissonClient.getHyperLogLog(logName);
  hyperLogLog.addAll(items);
}

合并

/**
 * 将 otherLogNames 的 log 合并到 logName
 *
 * @param logName       当前 log
 * @param otherLogNames 需要合并到当前 log 的其他 logs
 * @param <T>
 */
public <T> void merge(String logName, String... otherLogNames) {
  RHyperLogLog<T> hyperLogLog = redissonClient.getHyperLogLog(logName);
  hyperLogLog.mergeWith(otherLogNames);
}

统计基数

public <T> long count(String logName) {
  RHyperLogLog<T> hyperLogLog = redissonClient.getHyperLogLog(logName);
  return hyperLogLog.count();
}

单元测试

@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest(classes = RedissionApplication.class)
public class HyperLogLogTest {

    @Autowired
    private HyperLogLogService hyperLogLogService;

    @Test
    public void testAdd() {
        String logName = "码哥字节:Redis为什么这么快:uv";
        String item = "肖菜鸡";
        hyperLogLogService.add(logName, item);
        log.info("添加元素[{}]到 log [{}] 中。", item, logName);
    }

    @Test
    public void testCount() {
        String logName = "码哥字节:Redis为什么这么快:uv";
        long count = hyperLogLogService.count(logName);
        log.info("logName = {} count = {}.", logName, count);
    }

    @Test
    public void testMerge() {
        ArrayList<String> items = new ArrayList<>();
        items.add("肖菜鸡");
        items.add("谢霸哥");
        items.add("陈小白");

        String otherLogName = "码哥字节:Redis多线程模型原理与实战:uv";
        hyperLogLogService.addAll(otherLogName, items);
        log.info("添加 {} 个元素到 log [{}] 中。", items.size(), otherLogName);

        String logName = "码哥字节:Redis为什么这么快:uv";
        hyperLogLogService.merge(logName, otherLogName);
        log.info("将 {} 合并到 {}.", otherLogName, logName);

        long count = hyperLogLogService.count(logName);
        log.info("合并后的 count = {}.", count);
    }
}

基本原理

HyperLogLog 是一种概率数据结构,它使用概率算法来统计集合的近似基数。而它算法的最本源则是伯努利过程。

伯努利过程就是一个抛硬币实验的过程。抛一枚正常硬币,落地可能是正面,也可能是反面,二者的概率都是 1/2

伯努利过程就是一直抛硬币,直到落地时出现正面位置,并记录下抛掷次数k

比如说,抛一次硬币就出现正面了,此时 k1; 第一次抛硬币是反面,则继续抛,直到第三次才出现正面,此时 k 为 3。

对于 n 次伯努利过程,我们会得到 n 个出现正面的投掷次数值 k1, k2 ... kn, 其中这里的最大值是 k_max

根据一顿数学推导,我们可以得出一个结论: 2^{k_ max} 来作为n的估计值。

也就是说你可以根据最大投掷次数近似的推算出进行了几次伯努利过程。

所以 HyperLogLog 的基本思想是利用集合中数字的比特串第一个 1 出现位置的最大值来预估整体基数,但是这种预估方法存在较大误差,为了改善误差情况,HyperLogLog中引入分桶平均的概念,计算 m 个桶的调和平均值。

Redis 中 HyperLogLog 一共分了 2^14 个桶,也就是 16384 个桶。每个桶中是一个 6 bit 的数组,如下图所示。

关于 HyperLogLog 的原理过于复杂,如果想要了解的请移步:

  • https://www.zhihu.com/question/53416615
  • https://en.wikipedia.org/wiki/HyperLogLog
  • 用户日活月活怎么统计 - Redis HyperLogLog 详解

Redis 对 HyperLogLog 的存储进行了优化,在计数比较小的时候,存储空间采用系数矩阵,占用空间很小。

只有在计数很大,稀疏矩阵占用的空间超过了阈值才会转变成稠密矩阵,占用 12KB 空间。

为何只需要 12 KB 呀?

HyperLogLog 实现中用到的是 16384 个桶,也就是 2^14,每个桶的 maxbits 需要 6 个 bits 来存储,最大可以表示 maxbits=63,于是总共占用内存就是2^14 * 6 / 8 = 12k字节。

总结

分别使用了 HashBitmapHyperLogLog 来实现:

  • Hash:算法简单,统计精度高,少量数据下使用,对于海量数据会占据大量内存;
  • Bitmap:位图算法,适合用于「二值统计场景」,具体可参考我这篇文章,对于大量不同页面数据统计还是会占用较大内存。
  • Set:利用去重特性实现,一个 Set 就保存了千万个用户的 ID,页面多了消耗的内存也太大了。在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近2^64 个不同元素的基数。因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以 HyperLogLog 不能像集合那样,返回输入的各个元素。
  • HyperLogLog是一种算法,并非 Redis 独有
  • 目的是做基数统计,故不是集合,不会保存元数据,只记录数量而不是数值
  • 耗空间极小,支持输入非常体积的数据量
  • 核心是基数估算算法,主要表现为计算时内存的使用和数据合并的处理。最终数值存在一定误差
  • Redis中每个Hyperloglog key占用了12K的内存用于标记基数(官方文档)
  • pfadd 命令并不会一次性分配12k内存,而是随着基数的增加而逐渐增加内存分配;而pfmerge操作则会将sourcekey合并后存储在12k大小的key中,由hyperloglog合并操作的原理(两个Hyperloglog合并时需要单独比较每个桶的值)可以很容易理解。
  • 误差说明:基数估计的结果是一个带有 0.81% 标准错误(standard error)的近似值。是可接受的范围
  • RedisHyperLogLog 的存储进行优化,在计数比较小时,存储空间采用稀疏矩阵存储,空间占用很小,仅仅在计数慢慢变大,稀疏矩阵占用空间渐渐超过了阈值时才会一次性转变成稠密矩阵,才会占用 12k 的空间

好文推荐

  • Redis 实战篇:巧用数据类型实现亿级数据统计
  • 硬核 | Redis 布隆(Bloom Filter)过滤器原理与实战
  • Redis 实战篇:巧用 Bitmap 实现亿级海量数据统计
  • Redis 实战篇:通过 Geo 类型实现附近的人邂逅女神
  • Redis 分布式锁的正确实现原理演化历程与 Redisson 实战总结

来源:https://www.cnblogs.com/uniqueDong/p/16139521.html

相关推荐

MySQL进阶五之自动读写分离mysql-proxy

自动读写分离目前,大量现网用户的业务场景中存在读多写少、业务负载无法预测等情况,在有大量读请求的应用场景下,单个实例可能无法承受读取压力,甚至会对业务产生影响。为了实现读取能力的弹性扩展,分担数据库压...

Postgres vs MySQL_vs2022连接mysql数据库

...

3分钟短文 | Laravel SQL筛选两个日期之间的记录,怎么写?

引言今天说一个细分的需求,在模型中,或者使用laravel提供的EloquentORM功能,构造查询语句时,返回位于两个指定的日期之间的条目。应该怎么写?本文通过几个例子,为大家梳理一下。学习时...

一文由浅入深带你完全掌握MySQL的锁机制原理与应用

本文将跟大家聊聊InnoDB的锁。本文比较长,包括一条SQL是如何加锁的,一些加锁规则、如何分析和解决死锁问题等内容,建议耐心读完,肯定对大家有帮助的。为什么需要加锁呢?...

验证Mysql中联合索引的最左匹配原则

后端面试中一定是必问mysql的,在以往的面试中好几个面试官都反馈我Mysql基础不行,今天来着重复习一下自己的弱点知识。在Mysql调优中索引优化又是非常重要的方法,不管公司的大小只要后端项目中用到...

MySQL索引解析(联合索引/最左前缀/覆盖索引/索引下推)

目录1.索引基础...

你会看 MySQL 的执行计划(EXPLAIN)吗?

SQL执行太慢怎么办?我们通常会使用EXPLAIN命令来查看SQL的执行计划,然后根据执行计划找出问题所在并进行优化。用法简介...

MySQL 从入门到精通(四)之索引结构

索引概述索引(index),是帮助MySQL高效获取数据的数据结构(有序),在数据之外,数据库系统还维护者满足特定查询算法的数据结构,这些数据结构以某种方式引用(指向)数据,这样就可以在这些数据结构...

mysql总结——面试中最常问到的知识点

mysql作为开源数据库中的榜一大哥,一直是面试官们考察的重中之重。今天,我们来总结一下mysql的知识点,供大家复习参照,看完这些知识点,再加上一些边角细节,基本上能够应付大多mysql相关面试了(...

mysql总结——面试中最常问到的知识点(2)

首先我们回顾一下上篇内容,主要复习了索引,事务,锁,以及SQL优化的工具。本篇文章接着写后面的内容。性能优化索引优化,SQL中索引的相关优化主要有以下几个方面:最好是全匹配。如果是联合索引的话,遵循最...

MySQL基础全知全解!超详细无废话!轻松上手~

本期内容提醒:全篇2300+字,篇幅较长,可搭配饭菜一同“食”用,全篇无废话(除了这句),干货满满,可收藏供后期反复观看。注:MySQL中语法不区分大小写,本篇中...

深入剖析 MySQL 中的锁机制原理_mysql 锁详解

在互联网软件开发领域,MySQL作为一款广泛应用的关系型数据库管理系统,其锁机制在保障数据一致性和实现并发控制方面扮演着举足轻重的角色。对于互联网软件开发人员而言,深入理解MySQL的锁机制原理...

Java 与 MySQL 性能优化:MySQL分区表设计与性能优化全解析

引言在数据库管理领域,随着数据量的不断增长,如何高效地管理和操作数据成为了一个关键问题。MySQL分区表作为一种有效的数据管理技术,能够将大型表划分为多个更小、更易管理的分区,从而提升数据库的性能和可...

MySQL基础篇:DQL数据查询操作_mysql 查

一、基础查询DQL基础查询语法SELECT字段列表FROM表名列表WHERE条件列表GROUPBY分组字段列表HAVING分组后条件列表ORDERBY排序字段列表LIMIT...

MySql:索引的基本使用_mysql索引的使用和原理

一、索引基础概念1.什么是索引?索引是数据库表的特殊数据结构(通常是B+树),用于...