初识Redis的数据类型HyperLogLog

前提

将来一段时间开发的项目或者需求会大量使用到Redis,趁着这段时间业务并不太繁忙,抽点时间预习和复习Redis的相关内容。恰好看到博客下面的UVPV统计,想到了最近看书里面提到的HyperLogLog数据类型,因而花点时间分析一下它的使用方式和使用场景(暂时不探究HyperLogLog的实现原理)。RedisHyperLogLog数据类型是Redid 2.8.9引入的,使用的时候确保Redis版本>= 2.8.9java

HyperLogLog简介

基数计数(cardinality counting),一般用来统计一个集合中不重复的元素个数。一个很常见的例子就是统计某个文章的UVUnique Visitor,独立访客,通常能够理解为客户端IP)。大数据量背景下,要实现基数计数,多数状况下不会选择存储全量的基数集合的元素,由于能够计算出存储的内存成本,假设一个每一个被统计的元素的平均大小为32bit,那么若是统计一亿个数据,占用的内存大小为:redis

  • 32 * 100000000 / 8 / 1024 / 1024 ≈ 381M

若是有多个集合,而且容许计算多个集合的合并计数结果,那么这个操做带来的复杂度多是毁灭性的。所以,不会使用BitmapTree或者HashSet等数据结构直接存储计数元素集合的方式进行计数,而是在不追求绝对准确计数结果的前提之下,使用基数计数的几率算法进行计数,目前常见的有几率算法如下三种:算法

  • Linear Counting(LC)
  • LogLog Counting(LLC)
  • HyperLogLog Counting(HLL)

因此,HyperLogLog实际上是一种基数计数几率算法,并非Redis特有的,Redis基于C语言实现了HyperLogLog而且提供了相关命令API入口。shell

Redis的做者Antirez为了记念Philippe Flajolet对组合数学和基数计算算法分析的研究,因此在设计HyperLogLog命令的时候使用了Philippe Flajolet姓名的英文首字母PF做为前缀。也就是说,Philippe Flajolet博士是HLL算法的重大贡献者,可是他其实并非RedisHyperLogLog数据类型的开发者。遗憾的是Philippe Flajolet博士于2011年3月22日因病在巴黎辞世。这个是Philippe Flajolet博士的维基百科照片:数据结构

Redis提供的HyperLogLog数据类型的特征:app

  • 基本特征:使用HyperLogLog Counting(HLL)实现,只作基数计算,不会保存元数据
  • 内存占用:HyperLogLog每一个KEY最多占用12K的内存空间,能够计算接近2^64个不一样元素的基数,它的存储空间采用稀疏矩阵存储,空间占用很小,仅仅在计数基数个数慢慢变大,稀疏矩阵占用空间渐渐超过了阈值时才会一次性转变成稠密矩阵,转变成稠密矩阵以后才会占用12K的内存空间。
  • 计数偏差范围:基数计数的结果是一个标准偏差(Standard Error)为0.81%的近似值,当数据量不大的时候,获得的结果也多是一个准确值。

内存占用小(每一个KEY最高占用12K)是HyperLogLog的最大优点,而它存在两个相对明显的限制:异步

  • 计算结果并非准确值,存在标准偏差,这是因为它本质上是用几率算法致使的。
  • 不保存基数的元数据,这一点对须要使用元数据进行数据分析的场景并不友好。

HyperLogLog命令使用

Redis提供的HyperLogLog数据类型一共有三个命令APIPFADDPFCOUNTPFMERGEpost

PFADD

PFADD命令参数以下:大数据

PFADD key element [element …]

支持此命令的Redis版本是:>= 2.8.9
时间复杂度:每添加一个元素的复杂度为O(1)ui

  • 功能:将全部元素参数element添加到键为keyHyperLogLog数据结构中。

PFADD命令的执行流程以下:

PFADD命令的使用方式以下:

127.0.0.1:6379> PFADD food apple fish
(integer) 1
127.0.0.1:6379> PFADD food apple
(integer) 0
127.0.0.1:6379> PFADD throwable
(integer) 1
127.0.0.1:6379> SET name doge
OK
127.0.0.1:6379> PFADD name throwable
(error) WRONGTYPE Key is not a valid HyperLogLog string value.

虽然HyperLogLog数据结构本质是一个字符串,可是不能在String类型的KEY使用HyperLogLog的相关命令。

PFCOUNT

PFCOUNT命令参数以下:

PFCOUNT key [key …]

支持此命令的Redis版本是:>= 2.8.9
时间复杂度:返回单个HyperLogLog的基数计数值的复杂度为O(1),平均常数时间比较低。当参数为多个key的时候,复杂度为O(N),N为key的个数。

  • PFCOUNT命令使用单个key的时候,返回储存在给定键的HyperLogLog数据结构的近似基数,若是键不存在, 则返回0
  • PFCOUNT命令使用key的时候,返回储存在给定的全部HyperLogLog数据结构的并集的近似基数,也就是会把全部的HyperLogLog数据结构合并到一个临时的HyperLogLog数据结构,而后计算出近似基数。

PFCOUNT命令的使用方式以下:

127.0.0.1:6379> PFADD POST:1 ip-1 ip-2
(integer) 1
127.0.0.1:6379> PFADD POST:2 ip-2 ip-3 ip-4
(integer) 1
127.0.0.1:6379> PFCOUNT POST:1
(integer) 2
127.0.0.1:6379> PFCOUNT POST:1 POST:2
(integer) 4
127.0.0.1:6379> PFCOUNT NOT_EXIST_KEY
(integer) 0

PFMERGE

PFMERGE命令参数以下:

PFMERGE destkey sourcekey [sourcekey ...]

支持此命令的Redis版本是:>= 2.8.9
时间复杂度:O(N),其中N为被合并的HyperLogLog数据结构的数量,此命令的常数时间比较高

  • 功能:把多个HyperLogLog数据结构合并为一个新的键为destkeyHyperLogLog数据结构,合并后的HyperLogLog的基数接近于全部输入HyperLogLog的可见集合(Observed Set)的并集的基数。
  • 命令返回值:只会返回字符串OK

PFMERGE命令的使用方式以下

127.0.0.1:6379> PFADD POST:1 ip-1 ip-2
(integer) 1
127.0.0.1:6379> PFADD POST:2 ip-2 ip-3 ip-4
(integer) 1
127.0.0.1:6379> PFMERGE POST:1-2 POST:1 POST:2
OK
127.0.0.1:6379> PFCOUNT POST:1-2
(integer) 4

使用HyperLogLog统计UV的案例

假设如今有个简单的场景,就是统计博客文章的UV,要求UV的计数不须要准确,也不须要保存客户端的IP数据。下面就这个场景,使用HyperLogLog作一个简单的方案和编码实施。

这个流程可能步骤的前后顺序可能会有所调整,可是要作的操做是基本不变的。先简单假设,文章的内容和统计数据都是后台服务返回的,两个接口是分开设计。引入Redis的高级客户端Lettuce依赖:

<dependency>
    <groupId>io.lettuce</groupId>
    <artifactId>lettuce-core</artifactId>
    <version>5.2.1.RELEASE</version>
</dependency>

编码以下:

public class UvTest {

    private static RedisCommands<String, String> COMMANDS;

    @BeforeClass
    public static void beforeClass() throws Exception {
        // 初始化Redis客户端
        RedisURI uri = RedisURI.builder().withHost("localhost").withPort(6379).build();
        RedisClient redisClient = RedisClient.create(uri);
        StatefulRedisConnection<String, String> connect = redisClient.connect();
        COMMANDS = connect.sync();
    }

    @Data
    public static class PostDetail {

        private Long id;
        private String content;
    }

    private PostDetail selectPostDetail(Long id) {
        PostDetail detail = new PostDetail();
        detail.setContent("content");
        detail.setId(id);
        return detail;
    }

    private PostDetail getPostDetail(String clientIp, Long postId) {
        PostDetail detail = selectPostDetail(postId);
        String key = "puv:" + postId;
        COMMANDS.pfadd(key, clientIp);
        return detail;
    }

    private Long getPostUv(Long postId) {
        String key = "puv:" + postId;
        return COMMANDS.pfcount(key);
    }

    @Test
    public void testViewPost() throws Exception {
        Long postId = 1L;
        getPostDetail("111.111.111.111", postId);
        getPostDetail("111.111.111.222", postId);
        getPostDetail("111.111.111.333", postId);
        getPostDetail("111.111.111.444", postId);
        System.out.println(String.format("The uv count of post [%d] is %d", postId, getPostUv(postId)));
    }
}

输出结果:

The uv count of post [1] is 4

能够适当使用更多数量的不一样客户端IP调用getPostDetail(),而后统计一下偏差。

题外话-如何准确地统计UV

若是想要准确统计UV,则须要注意几个点:

  • 内存或者磁盘容量须要准备充足,由于就目前的基数计数算法来看,没有任何算法能够在不保存元数据的前提下进行准确计数。
  • 若是须要作用户行为分析,那么元数据最终须要持久化,这一点应该依托于大数据体系,在这一方面笔者没有经验,因此暂时很少说。

假设在不考虑内存成本的前提下,咱们依然可使用Redis作准确和实时的UV统计,简单就可使用Set数据类型,增长UV只须要使用SADD命令,统计UV只须要使用SCARD命令(时间复杂度为O(1),能够放心使用)。举例:

127.0.0.1:6379> SADD puv:1 ip-1 ip-2
(integer) 2
127.0.0.1:6379> SADD puv:1 ip-3 ip-4
(integer) 2
127.0.0.1:6379> SCARD puv:1
(integer) 4

若是这些统计数据仅仅是用户端展现,那么能够采用异步设计:

在体量小的时候,上面的全部应用的功能能够在同一个服务中完成,消息队列能够用线程池的异步方案替代。

小结

这篇文章只是简单介绍了HyperLogLog的使用和统计UV的使用场景。总的来讲就是:在(1)原始数据量巨大,(2)内存占用要求尽量小,(3)容许计数存在必定偏差而且(4)不要求存放元数据的场景下,能够优先考虑使用HyperLogLog进行计数。

参考资料:

(本文完 c-3-d e-a-20191117)

相关文章
相关标签/搜索