若是如今要开发一个功能:html
统计APP或网页的一个页面,天天有多少用户点击进入的次数。同一个用户的反复点击进入记为 1 次,也就是统计 UV 数据。redis
让你来开发这个统计模块,你会如何实现?算法
若是统计 PV 数据,只要给网页一个独立的 Redis 计数器就能够了,这个计数器的 key 的格式为 puv:{pid}:{yyyyMMdd}
。每来一个请求就 incrby 一次,就能够统计出全部的 PV 数据。数据结构
可是 UV 不同,它要去重,同一个用户一天以内的屡次访问请求只能计数一次。这就要求每个网页请求都须要带上用户的 ID,不管是登录用户仍是未登录用户都须要一个惟一 ID 来标识。ide
你可能会立刻想到,用 Hash
数据类型就能知足去重。这确实是一种解决方法,可是当这个页面的日活达到百万或千万以上级别的话,Hash
的内存开销就会很是大。post
咱们来估算一下采用 Hash
的内存空间是多大。假设 key
是 int
类型,对应的是用户ID,value
是 bool
类型,表示已访问,当有百万级不一样用户访问时,内存空间为:100万 * (32+8)bit = 40MB
。优化
那有更好的方法吗?有的,下面来介绍基于 HyperLogLog
的解决方案。首先咱们先来了解一下 HyperLogLog
。ui
HyperLogLog
的做用是提供不精确的去重计数方案。虽然不精确,但也不是很是不精确,标准偏差是 0.81%
,这样的精确度已经能够知足上面的 UV 统计需求了。url
它的优势是使用极少的内存就能统计大量的数据,Redis 实现的 HyperLogLog,只须要 12K
内存就能统计 $2^64$
个数据。远比 Hash
的内存开销要少。spa
HyperLogLog(HLL)
是一种用于基数计数的几率算法,是基于 LogLog(LLC)
算法的优化和改进,在一样空间复杂度下,可以比 LLC 的基数估计偏差更小。
HyperLogLog
算法的通俗说明:假设咱们为一个数据集合生成一个8位的哈希串,那么咱们获得00000111的几率是很低的,也就是说,咱们生成大量连续的0的几率是很低的。生成连续5个0的几率是1/32,那么咱们获得这个串时,能够估算,这个数据集的基数是32。
再深刻的那就是数学公式,可参考本文最后的参考连接前往研究。
命令 | 说明 | 可用版本 | 时间复杂度 |
PFADD | 添加 | >= 2.8.9 | O(1) |
PFCOUNT | 得到基数值 | >= 2.8.9 | O(1) |
PFMERGE | 合并多个key | >= 2.8.9 | O(N) |
using StackExchange.Redis;using System;public class PageUVDemo { private static IDatabase db; static void Main(string[] args) { ConnectionMultiplexer connection = ConnectionMultiplexer.Connect("192.168.0.104:7001,password=123456"); db = connection.GetDatabase(); Console.WriteLine("hll:"); HLLVisit(1000, 1000); HLLVisit(10000, 10000); HLLVisit(100000, 100000); Console.WriteLine("hash:"); HashVisit(1000, 1000); HashVisit(10000, 10000); HashVisit(100000, 100000); connection.Close(); } static void HLLVisit(int times, int pid) { string key = $"puv:hll:{pid}"; DateTime start = DateTime.Now; for (int i = 0; i < times; i++) { db.HyperLogLogAdd(key, i); } long total = db.HyperLogLogLength(key); DateTime end = DateTime.Now; Console.WriteLine("插入{0}次:", times); Console.WriteLine(" total:{0}", total); Console.WriteLine(" duration:{0:F2}s", (end - start).TotalSeconds); Console.WriteLine(); } static void HashVisit(int times, int pid) { string key = $"puv:hash:{pid}"; DateTime start = DateTime.Now; for (int i = 0; i < times; i++) { db.HashSet(key, i, true); } long total = db.HashLength(key); DateTime end = DateTime.Now; Console.WriteLine("插入{0}次:", times); Console.WriteLine(" total:{0}", total); Console.WriteLine(" duration:{0:F2}s", (end - start).TotalSeconds); Console.WriteLine(); } }
运行结果
结果对比
数据经过 redis-rdb-tools
导出,更多请查看。
数据类型 | 插入次数 | 内存开销 | 时间开销 | 偏差率 |
hash | 1000 | 35KB | 3.45s | 0% |
10000 | 426KB | 34.65s | 0% | |
100000 | 3880KB | 342.36s | 0% | |
hll | 1000 | 2KB | 3.57s | 0.1% |
10000 | 14KB | 33.25s | 0.13% | |
100000 | 14KB | 307.80s | 0.44% |
从上面的结果能够看出,10万次级别下,HyperLogLog 的偏差率很低,0.44%,但内存开销是 Hash 的0.3%,随着数量级的提高,内存开销差距也越大。
不追求百分百的准确度时,使用 HyperLogLog 数据结构能减小内存开销。