Java中高级核心知识全面解析——Redis(分布式锁【简介、实现】、Redlock分布式锁、HyperLoglog【简介、原理、实现、使用】)2

一、分布式锁深入探究

1.分布式锁简介

是一种用来解决多个执行线程访问共享资源错误或数据不一致问题的工具。

如果 把一台服务器比作一个房子,那么线程就好比里面的住户,当他们想要共同访问一个共享资源,例如厕所的时候,如果厕所门上没有锁…更甚者厕所没装门…这是会出原则性的问题的…

装上了锁,大家用起来就安心多了,本质也就是同一时间只允许一个住户使用

而随着互联网世界的发展,单体应用已经越来越无法满足复杂互联网的高并发需求,转而慢慢朝着分布式方向发展,慢慢进化成了更大一些的住户。所以同样,我们需要引入分布式锁来解决分布式应用之间访问共享资源的并发问题。

1)为何需要分布式锁

一般情况下,我们使用分布式锁主要有两个场景:

  1. 避免不同节点重复相同的工作:比如用户执行了某个操作有可能不同节点会发送多封邮件;
  2. 避免破坏数据的正确性:如果两个节点在同一条数据上同时进行操作,可能会造成数据错误或不一致的情况出现;

2)Java中实现的常见方式

上面我们用简单的比喻说明了锁的本质:同一时间只允许一个用户操作。所以理论上,能够满足这个需求的工具我们都能够使用 (就是其他应用能帮我们加锁的):

  1. 基于 MySQL 中的锁:MySQL 本身有自带的悲观锁 for update 关键字,也可以自己实现悲观/乐观锁来达到目的;
  2. 基于 Zookeeper 有序节点:Zookeeper 允许临时创建有序的子节点,这样客户端获取节点列表时,就能够当前子节点列表中的序号判断是否能够获得锁;
  3. 基于 Redis 的单线程:由于 Redis 是单线程,所以命令会以串行的方式执行,并且本身提供了像SETNX(set if not exists)这样的指令,本身具有互斥性;

每个方案都有各自的优缺点,例如 MySQL 虽然直观理解容易,但是实现起来却需要额外考虑 锁超时加事务 等,并且性能局限于数据库,诸如此类我们在此不作讨论,重点关注 Redis。

3)Redis 分布式锁的问题

①、锁超时

假设现在我们有两台平行的服务 A B,其中 A 服务在获取锁之后由于未知神秘力量突然挂了,那么B服务就永远无法获取到锁了:

所以我们需要额外设置一个超时时间,来保证服务的可用性。

但是另一个问题随即而来:如果在加锁和释放锁之间的逻辑执行得太长,以至于超出了锁的超时限制,也会出现问题。因为这时候第一个线程持有锁过期了,而临界区的逻辑还没有执行完,与此同时第二个线程就提前拥有了这把锁,导致临界区的代码不能得到严格的串行执行。

为了避免这个问题,Redis分布式锁不要用于较长时间的任务。如果真的偶尔出现了问题,造成的数据小错乱可能就需要人工的干预。

有一个稍微安全一点的方案是 将锁的 value 值设置为一个随机数,释放锁时先匹配随机数是否一致,然后再删除 key,这是为了 确保当前线程占有的锁不会被其他线程释放,除非这个锁是因为过期了而被服务器自动释放的。

但是匹配 value 和删除 key 在 Redis 中并不是一个原子性的操作,也没有类似保证原子性的指令,所以可能需要使用像 Lua 这样的脚本来处理了,因为 Lua 脚本可以 保证多个指令的原子性执行

延伸的讨论:GC 可能引发的安全问题

Martin Kleppmann 曾与 Redis 之父 Antirez 就 Redis 实现分布式锁的安全性问题进行过深入的讨论,其中有一个问题就涉及到 GC。

熟悉 Java 的同学肯定对 GC 不陌生,在 GC 的时候会发生 STW(Stop-The-World),这本身是为了保障垃圾回收器的正常执行,但可能会引发如下的问题:

服务 A 获取了锁并设置了超时时间,但是服务 A 出现了 STW 且时间较长,导致了分布式锁进行了超时释放,在这个期间服务 B 获取到了锁,待服务 A STW 结束之后又恢复了锁,这就导致了 服务 A 和服务B 同时获取到了锁,这个时候分布式锁就不安全了。

不仅仅局限于Redis,Zookeeper和MySQL有同样的问题。

②、单点/多点问题

如果 Redis 采用单机部署模式,那就意味着当 Redis 故障了,就会导致整个服务不可用。

而如果采用主从模式部署,我们想象一个这样的场景:服务 A 申请到一把锁之后,如果作为主机的Redis宕机了,那么 服务 B 在申请锁的时候就会从从机那里获取到这把锁,为了解决这个问题,Redis作者提出了一种 RedLock 红锁 的算法 (Redission 同 Jedis):

// 三个 Redis 集群 
RLock lock1 = redissionInstance1.getLock("lock1"); 
RLock lock2 = redissionInstance2.getLock("lock2"); 
RLock lock3 = redissionInstance3.getLock("lock3"); 

RedissionRedLock lock = new RedissionLock(lock1, lock2, lock2); 
lock.lock(); 
// do something.... 
lock.unlock();

二、Redis 分布式锁的实现

分布式锁类似于 “占坑”,而SETNX(SET if Not eXists)指令就是这样的一个操作,只允许被一个客户端占有,我们来看看 源码(t_string.c/setGenericCommand) 吧:

// SET/ SETEX/ SETTEX/ SETNX 最底层实现 
void setGenericCommand(client *c, int flags, robj *key, robj *val, robj *expire, 
int unit, robj *ok_reply, robj *abort_reply) {
    
     
	long long milliseconds = 0; /* initialized to avoid any harmness warning */ 
	// 如果定义了 key 的过期时间则保存到上面定义的变量中 
	// 如果过期时间设置错误则返回错误信息 
	if (expire) {
    
     
		if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != C_OK) 
			return; 
		if (milliseconds <= 0) {
    
     
			addReplyErrorFormat(c,"invalid expire time in %s",c->cmd->name); 
			return; 
		}
		if (unit == UNIT_SECONDS) milliseconds *= 1000; 
	}
	
	// lookupKeyWrite 函数是为执行写操作而取出 key 的值对象 
	// 这里的判断条件是: 
	// 1.如果设置了 NX(不存在),并且在数据库中找到了 key 值 
	// 2.或者设置了 XX(存在),并且在数据库中没有找到该 key 
	// => 那么回复 abort_reply 给客户端 
	if ((flags & OBJ_SET_NX && lookupKeyWrite(c->db,key) != NULL) || 
		(flags & OBJ_SET_XX && lookupKeyWrite(c->db,key) == NULL)) 
	{
    
     
		addReply(c, abort_reply ? abort_reply : shared.null[c->resp]); 
		return; 
	}
	
	// 在当前的数据库中设置键为 key 值为 value 的数据 
	genericSetKey(c->db,key,val,flags & OBJ_SET_KEEPTTL); 
	// 服务器每修改一个 key 后都会修改 dirty 值 
	server.dirty++; 
	if (expire) setExpire(c,c->db,key,mstime()+milliseconds); 
	notifyKeyspaceEvent(NOTIFY_STRING,"set",key,c->db->id); 
	if (expire) notifyKeyspaceEvent(NOTIFY_GENERIC, 
		"expire",key,c->db->id); 
	addReply(c, ok_reply ? ok_reply : shared.ok); 
}

就像上面介绍的那样,其实在之前版本的 Redis 中,由于SETNXEXPIRE并不是原子指令,所以在一起执行会出现问题。

也许你会想到使用 Redis 事务来解决,但在这里不行,因为EXPIRE命令依赖于SETNX的执行结果,而事务中没有if-else的分支逻辑,如果 SETNX 没有抢到锁,EXPIRE就不应该执行。

为了解决这个疑难问题,Redis 开源社区涌现了许多分布式锁的 library,为了治理这个乱象,后来在Redis 2.8 的版本中,加入了 SET 指令的扩展参数,使得 SETNX 可以和 EXPIRE 指令一起执行了:

> SET lock:test true ex 5 nx 
OK
... do something critical ... 
> del lock:test

你只需要符合 SET key value [EX seconds | PX milliseconds] [NX | XX] [KEEPTTL] 这样的格式就好了。

另外,官方文档也在 SETNX 文档中提到了这样一种思路:把 SETNX 对应 key 的 value 设置为<current Unix time + lock timeout + 1>,这样在其他客户端访问时就能够自己判断是否能够获取下一个 value 为上述格式的锁了。

1)代码实现

下面用 Jedis 来模拟实现以下,关键代码如下:

private static final String LOCK_SUCCESS = "OK"; 
private static final Long RELEASE_SUCCESS = 1L; 
private static final String SET_IF_NOT_EXIST = "NX"; 
private static final String SET_WITH_EXPIRE_TIME = "PX"; 

@Override 
public String acquire() {
    
     
	try {
    
    
		// 获取锁的超时时间,超过这个时间则放弃获取锁 
		long end = System.currentTimeMillis() + acquireTimeout; 
		// 随机生成一个 value 
		String requireToken = UUID.randomUUID().toString(); 
		while (System.currentTimeMillis() < end) {
    
     
			String result = jedis 
				.set(lockKey, requireToken, SET_IF_NOT_EXIST, 
SET_WITH_EXPIRE_TIME, expireTime); 
			if (LOCK_SUCCESS.equals(result)) {
    
     
				return requireToken; 
			}
			try {
    
    
				Thread.sleep(100); 
			} catch (InterruptedException e) {
    
     
				Thread.currentThread().interrupt(); 
			} 
		} 
	} catch (Exception e) {
    
     
		log.error("acquire lock due to error", e); 
	}
	
	return null; 
}

@Override 
public boolean release(String identify) {
    
     
	if (identify == null) {
    
     
		return false; 
	}
	
	String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return 
redis.call('del', KEYS[1]) else return 0 end"; 
	Object result = new Object(); 
	try {
    
    
		result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(identify)); 
		if (RELEASE_SUCCESS.equals(result)) {
    
     
			log.info("release lock success, requestToken:{}", identify); 
			return true; 
		} 
	} catch (Exception e) {
    
     
		log.error("release lock due to error", e); 
	} finally {
    
     
		if (jedis != null) {
    
     
			jedis.close(); 
		} 
	}
	
	log.info("release lock failed, requestToken:{}, result:{}", identify, result); 
	return false; 
}

小插曲:
更多阿里、腾讯、美团、京东等一线互联网大厂Java面试真题;包含:基础、并发、锁、JVM、设计模式、数据结构、反射/IO、数据库、Redis、Spring、消息队列、分布式、Zookeeper、Dubbo、Mybatis、Maven、面经等。
更多Java程序员技术进阶小技巧;例如高效学习(如何学习和阅读代码、面对枯燥和量大的知识)高效沟通(沟通方式及技巧、沟通技术)
更多Java大牛分享的一些职业生涯分享文档


请点击这里添加》》》》》》》》》社群,免费获取


比你优秀的对手在学习,你的仇人在磨刀,你的闺蜜在减肥,隔壁老王在练腰, 我们必须不断学习,否则我们将被学习者超越!
趁年轻,使劲拼,给未来的自己一个交代!

二、Redlock分布式锁

1.什么是 RedLock

Redis 官方站这篇文章提出了一种权威的基于 Redis 实现分布式锁的方式名叫 Redlock,此种方式比原先的单节点的方法更安全。它可以保证以下特性:

  1. 安全特性:互斥访问,即永远只有一个 client 能拿到锁
  2. 避免死锁:最终 client 都可能拿到锁,不会出现死锁的情况,即使原本锁住某资源的 client crash了或者出现了网络分区
  3. 容错性:只要大部分 Redis 节点存活就可以正常提供服务

2.怎么在单节点上实现分布式锁

SET resource_name my_random_value NX PX 30000

主要依靠上述命令,该命令仅当 Key 不存在时(NX保证)set 值,并且设置过期时间 3000ms (PX保证),值 my_random_value 必须是所有 client 和所有锁请求发生期间唯一的,释放锁的逻辑是:

if redis.call("get",KEYS[1]) == ARGV[1] then 
	return redis.call("del",KEYS[1]) 
else
	return 0 
end

上述实现可以避免释放另一个client创建的锁,如果只有 del 命令的话,那么如果 client1 拿到 lock1 之后因为某些操作阻塞了很长时间,此时 Redis 端 lock1 已经过期了并且已经被重新分配给了 client2,那么 client1 此时再去释放这把锁就会造成 client2 原本获取到的锁被 client1 无故释放了,但现在为每个 client 分配一个 unique 的 string 值可以避免这个问题。至于如何去生成这个 unique string,方法很多随意选择一种就行了。

3.Redlock 算法

算法很易懂,起 5 个 master 节点,分布在不同的机房尽量保证可用性。为了获得锁,client 会进行如下操作:

  1. 得到当前的时间,微秒单位
  2. 尝试顺序地在 5 个实例上申请锁,当然需要使用相同的 key 和 random value,这里一个 client 需要合理设置与 master 节点沟通的 timeout 大小,避免长时间和一个 fail 了的节点浪费时间
  3. 当 client 在大于等于 3 个 master 上成功申请到锁的时候,且它会计算申请锁消耗了多少时间,这部分消耗的时间采用获得锁的当下时间减去第一步获得的时间戳得到,如果锁的持续时长(lockvalidity time)比流逝的时间多的话,那么锁就真正获取到了。
  4. 如果锁申请到了,那么锁真正的 lock validity time 应该是 origin(lock validity time) - 申请锁期间流逝的时间
  5. 如果 client 申请锁失败了,那么它就会在少部分申请成功锁的 master 节点上执行释放锁的操作,重置状态

4.失败重试

如果一个 client 申请锁失败了,那么它需要稍等一会在重试避免多个 client 同时申请锁的情况,最好的情况是一个 client 需要几乎同时向 5 个 master 发起锁申请。另外就是如果 client 申请锁失败了它需要尽快在它曾经申请到锁的 master 上执行 unlock 操作,便于其他 client 获得这把锁,避免这些锁过期造成的时间浪费,当然如果这时候网络分区使得 client 无法联系上这些 master,那么这种浪费就是不得不付出的代价了。

5.放锁

放锁操作很简单,就是依次释放所有节点上的锁就行了

6.性能、崩溃恢复和 fsync

如果我们的节点没有持久化机制,client 从 5 个 master 中的 3 个处获得了锁,然后其中一个重启了,这是注意 整个环境中又出现了 3 个 master 可供另一个 client 申请同一把锁! 违反了互斥性。如果我们开启了 AOF 持久化那么情况会稍微好转一些,因为 Redis 的过期机制是语义层面实现的,所以在server 挂了的时候时间依旧在流逝,重启之后锁状态不会受到污染。但是考虑断电之后呢,AOF部分命令没来得及刷回磁盘直接丢失了,除非我们配置刷回策略为 fsnyc = always,但这会损伤性能。解决这个问题的方法是,当一个节点重启之后,我们规定在 max TTL 期间它是不可用的,这样它就不会干扰原本已经申请到的锁,等到它 crash 前的那部分锁都过期了,环境不存在历史锁了,那么再把这个节点加进来正常工作。

三、如何做可靠的分布式锁,Redlock真的可行么

如果你只是为了性能,那没必要用 Redlock,它成本高且复杂,你只用一个 Redis 实例也够了,最多加个从防止主挂了。当然,你使用单节点的 Redis 那么断电或者一些情况下,你会丢失锁,但是你的目的只是加速性能且断电这种事情不会经常发生,这并不是什么大问题。并且如果你使用了单节点 Redis,那么很显然你这个应用需要的锁粒度是很模糊粗糙的,也不会是什么重要的服务。

那么是否 Redlock 对于要求正确性的场景就合适呢?Martin 列举了若干场景证明 Redlock 这种算法是不可靠的。

1.用锁保护资源

这节里 Martin 先将 Redlock 放在了一边而是仅讨论总体上一个分布式锁是怎么工作的。在分布式环境下,锁比 mutex 这类复杂,因为涉及到不同节点、网络通信并且他们随时可能无征兆的 fail 。Martin假设了一个场景,一个 client 要修改一个文件,它先申请得到锁,然后修改文件写回,放锁。另一个 client 再申请锁 … 代码流程如下:

// THIS CODE IS BROKEN 
function writeData(filename, data) {
    
     
	var lock = lockService.acquireLock(filename); 
	if (!lock) {
    
     
		throw 'Failed to acquire lock'; 
	}
	
	try {
    
    
		var file = storage.readFile(filename); 
		var updated = updateContents(file, data); 
		storage.writeFile(filename, updated); 
	} finally {
    
     
		lock.release(); 
	} 
}

可惜即使你的锁服务非常完美,上述代码还是可能跪,下面的流程图会告诉你为什么:

2.使用Fencing(栅栏)使得锁变安全

修复问题的方法也很简单:你需要在每次写操作时加入一个 fencing token。这个场景下,fencing token 可以是一个递增的数字(lock service 可以做到),每次有 client 申请锁就递增一次:

client1 申请锁同时拿到 token33,然后它进入长时间的停顿锁也过期了。client2 得到锁和 token34 写入数据,紧接着 client1 活过来之后尝试写入数据,自身 token33 比 34 小因此写入操作被拒绝。注意这需要存储层来检查 token,但这并不难实现。如果你使用 Zookeeper 作为 lock service 的话那么你可以使用 zxid 作为递增数字。

但是对于 Redlock 你要知道,没什么生成 fencing token 的方式,并且怎么修改 Redlock 算法使其能产生 fencing token 呢?好像并不那么显而易见。因为产生 token 需要单调递增,除非在单节点 Redis 上完成但是这又没有高可靠性,你好像需要引进一致性协议来让 Redlock 产生可靠的 fencing token。

3.使用时间来解决一致性

Redlock 无法产生 fencing token 早该成为在需求正确性的场景下弃用它的理由,但还有一些值得讨论的地方。

学术界有个说法,算法对时间不做假设:因为进程可能pause一段时间、数据包可能因为网络延迟延后到达、时钟可能根本就是错的。而可靠的算法依旧要在上述假设下做正确的事情。

对于 failure detector 来说,timeout 只能作为猜测某个节点 fail 的依据,因为网络延迟、本地时钟不正确等其他原因的限制。考虑到 Redis 使用 gettimeofday,而不是单调的时钟,会受到系统时间的影响,可能会突然前进或者后退一段时间,这会导致一个 key 更快或更慢地过期。

可见,Redlock 依赖于许多时间假设,它假设所有 Redis 节点都能对同一个 Key 在其过期前持有差不多的时间、跟过期时间相比网络延迟很小、跟过期时间相比进程 pause 很短。

4.用不可靠的时间打破 Redlock

这节 Martin 举了个因为时间问题,Redlock 不可靠的例子。

  1. client1 从 ABC 三个节点处申请到锁,DE由于网络原因请求没有到达
  2. C节点的时钟往前推了,导致 lock 过期’
  3. client2 在CDE处获得了锁,AB由于网络原因请求未到达
  4. 此时 client1 和 client2 都获得了锁

在 Redlock 官方文档中也提到了这个情况,不过是C崩溃的时候,Redlock 官方本身也是知道Redlock算法不是完全可靠的,官方为了解决这种问题建议使用延时启动。但是 Martin 这里分析得更加全面,指出延时启动不也是依赖于时钟的正确性的么?

接下来 Martin 又列举了进程 Pause 时而不是时钟不可靠时会发生的问题:

  1. client1从 ABCDE 处获得了锁
  2. 当获得锁的 response 还没到达 client1 时 client1 进入 GC 停顿
  3. 停顿期间锁已经过期了
  4. client2 在 ABCDE 处获得了锁
  5. client1 GC 完成收到了获得锁的 response,此时两个 client 又拿到了同一把锁

同时长时间的网络延迟也有可能导致同样的问题。

5.Redlock 的同步性假设

这些例子说明了,仅有在你假设了一个同步性系统模型的基础上,Redlock 才能正常工作,也就是系统能满足以下属性:

  1. 网络延时边界,即假设数据包一定能在某个最大延时之内到达
  2. 进程停顿边界,即进程停顿一定在某个最大时间之内
  3. 时钟错误边界,即不会从一个坏的 NTP 服务器处取得时间

6.结论

Martin 认为 Redlock 实在不是一个好的选择,对于需求性能的分布式锁应用它太重了且成本高;对于需求正确性的应用来说它不够安全。因为它对高危的时钟或者说其他上述列举的情况进行了不可靠的假设,如果你的应用只需要高性能的分布式锁不要求多高的正确性,那么单节点 Redis 够了;如果你的应用想要保住正确性,那么不建议 Redlock,建议使用一个合适的一致性协调系统,例如 Zookeeper,且保证存在 fencing token。


小插曲:
更多阿里、腾讯、美团、京东等一线互联网大厂Java面试真题;包含:基础、并发、锁、JVM、设计模式、数据结构、反射/IO、数据库、Redis、Spring、消息队列、分布式、Zookeeper、Dubbo、Mybatis、Maven、面经等。
更多Java程序员技术进阶小技巧;例如高效学习(如何学习和阅读代码、面对枯燥和量大的知识)高效沟通(沟通方式及技巧、沟通技术)
更多Java大牛分享的一些职业生涯分享文档


请点击这里添加》》》》》》》》》社群,免费获取


比你优秀的对手在学习,你的仇人在磨刀,你的闺蜜在减肥,隔壁老王在练腰, 我们必须不断学习,否则我们将被学习者超越!
趁年轻,使劲拼,给未来的自己一个交代!

四、神奇的HyperLoglog解决统计问题

1.HyperLogLog 简介

HyperLogLog 是最早由Flajolet及其同事在 2007 年提出的一种 估算基数的近似最优算法。但跟原版论文不同的是,好像很多书包括 Redis 作者都把它称为一种 新的数据结构(new datastruct) (算法实现确 实需要一种特定的数据结构来实现)。

1)关于基数统计

基数统计(Cardinality Counting) 通常是用来统计一个集合中不重复的元素个数。

思考这样的一个场景: 如果你负责开发维护一个大型的网站,有一天老板找产品经理要网站上每个网页的 UV(独立访客,每个用户每天只记录一次),然后让你来开发这个统计模块,你会如何实现?

如果统计 PV(浏览量,用户没点一次记录一次),那非常好办,给每个页面配置一个独立的 Redis 计数器就可以了,把这个计数器的 key 后缀加上当天的日期。这样每来一个请求,就执行 INCRBY 指令一次,最终就可以统计出所有的 PV 数据了。

但是 UV 不同,它要去重,同一个用户一天之内的多次访问请求只能计数一次。这就要求了每一个网页请求都需要带上用户的 ID,无论是登录用户还是未登录的用户,都需要一个唯一 ID 来标识。

你也许马上就想到了一个 简单的解决方案:那就是 为每一个页面设置一个独立的 set 集合 来存储所有当天访问过此页面的用户 ID。但这样的 问题 就是:

  1. 存储空间巨大: 如果网站访问量一大,你需要用来存储的 set 集合就会非常大,如果页面再一多…为了一个去重功能耗费的资源就可以直接让你 老板打死你
  2. 统计复杂: 这么多 set 集合如果要聚合统计一下,又是一个复杂的事情;

2)基数统计的常用方法

对于上述这样需要 基数统计 的事情,通常来说有两种比 set 集合更好的解决方案:

①、第一种:B 树

B 树最大的优势就是插入和查找效率很高,如果用 B 树存储要统计的数据,可以快速判断新来的数据是否存在,并快速将元素插入 B 树。要计算基础值,只需要计算 B 树的节点个数就行了。

不过将 B 树结构维护到内存中,能够解决统计和计算的问题,但是 并没有节省内存

②、第二种:bitmap

bitmap 可以理解为通过一个 bit 数组来存储特定数据的一种数据结构,每一个 bit 位都能独立包含信息,bit 是数据的最小存储单位,因此能大量节省空间,也可以将整个 bit 数据一次性 load 到内存计算。如果定义一个很大的 bit 数组,基础统计中 每一个元素对应到 bit 数组中的一位,例如:

bitmap 还有一个明显的优势是 可以轻松合并多个统计结果,只需要对多个结果求异或就可以了,也可以大大减少存储内存。可以简单做一个计算,如果要统计 1 亿 个数据的基数值,大约需要的内存100_000_000/ 8/ 1024/ 1024 ≈ 12 M ,如果用 32 bit 的 int 代表 每一个 统计的数据,大约需要内存32 * 100_000_000/ 8/ 1024/ 1024 ≈ 381 M

可以看到 bitmap 对于内存的节省显而易见,但仍然不够。统计一个对象的基数值就需要 12 M ,如果统计 1 万个对象,就需要接近 120 G ,对于大数据的场景仍然不适用。

3)概率算法

实际上目前还没有发现更好的在 大数据场景准确计算 基数的高效算法,因此在不追求绝对精确的情况下,使用概率算法算是一个不错的解决方案。

概率算法 不直接存储 数据集合本身,通过一定的 概率统计方法预估基数值,这种方法可以大大节省内存,同时保证误差控制在一定范围内。目前用于基数计数的概率算法包括:

  • Linear Counting(LC):早期的基数估计算法,LC 在空间复杂度方面并不算优秀,实际上 LC 的空间复杂度与上文中简单 bitmap 方法是一样的(但是有个常数项级别的降低),都是 O(Nmax)
  • LogLog Counting(LLC):LogLog Counting 相比于 LC 更加节省内存,空间复杂度只有O(log2(log2(Nmax)))
  • HyperLogLog Counting(HLL):HyperLogLog Counting 是基于 LLC 的优化和改进,在同样空间复杂度情况下,能够比 LLC 的基数估计误差更小

其中,HyperLogLog 的表现是惊人的,上面我们简单计算过用 bitmap 存储 1 个亿 统计数据大概需要12 M 内存,而在 HyperLoglog 中,只需要不到 1 K 内存就能够做到!在 Redis 中实现的HyperLoglog也只需要 12 K 内存,在 标准误差 0.81% 的前提下,能够统计 264 个数据

这是怎么做到的?! 下面赶紧来了解一下!

2.HyperLogLog 原理

我们来思考一个抛硬币的游戏:你连续掷 n 次硬币,然后说出其中连续掷为正面的最大次数,我来猜你一共抛了多少次。

这很容易理解吧,例如:你说你这一次 最多连续出现了 2 次 正面,那么我就可以知道你这一次投掷的次数并不多,所以 我可能会猜是 5 或者是其他小一些的数字,但如果你说你这一次 最多连续出现了 20次 正面,虽然我觉得不可能,但我仍然知道你花了特别多的时间,所以 我说 GUN…。

这期间我可能会要求你重复实验,然后我得到了更多的数据之后就会估计得更准。我们来把刚才的游戏换一种说法

这张图的意思是,我们给定一系列的随机整数,记录下低位连续零位的最大长度 K,即为图中的maxbit通过这个 K 值我们就可以估算出随机数的数量 N

1)代码实验

我们可以简单编写代码做一个实验,来探究一下 K 和 N 之间的关系:

public class PfTest {
    
     

	static class BitKeeper {
    
     
	
		private int maxbit; 
		
		public void random() {
    
     
			long value = ThreadLocalRandom.current().nextLong(2L << 32); 
			int bit = lowZeros(value); 
			if (bit > this.maxbit) {
    
     
				this.maxbit = bit; 
			} 
		}
		
		private int lowZeros(long value) {
    
     
			int i = 0; 
			for (; i < 32; i++) {
    
     
				if (value >> i << i != value) {
    
     
					break; 
				} 
			}
			return i - 1; 
		} 
	}
	static class Experiment {
    
     
	
		private int n; 
		private BitKeeper keeper; 
		
		public Experiment(int n) {
    
     
			this.n = n; 
			this.keeper = new BitKeeper(); 
		}
		
		public void work() {
    
     
			for (int i = 0; i < n; i++) {
    
     
				this.keeper.random(); 
			} 
		}
		
		public void debug() {
    
     
			System.out 
				.printf("%d %.2f %d\n", this.n, Math.log(this.n) / Math.log(2), this.keeper.maxbit); 
			} 
		}
		
		public static void main(String[] args) {
    
     
			for (int i = 1000; i < 100000; i += 100) {
    
     
				Experiment exp = new Experiment(i); 
				exp.work(); 
				exp.debug(); 
			} 
		} 
	}

跟上图中的过程是一致的,话说为啥叫 PfTest 呢,包括 Redis 中的命令也一样带有一个 PF 前缀,还记得嘛,因为 HyperLogLog 的提出者上文提到过的,叫 Philippe Flajolet

截取部分输出查看:

//n n/log2 maxbit 
34000 15.05 13 
35000 15.10 13 
36000 15.14 16 
37000 15.18 17 
38000 15.21 14 
39000 15.25 16 
40000 15.29 14 
41000 15.32 16 
42000 15.36 18

会发现 KN 的对数之间存在显著的线性相关性:N 约等于 2的k次方

2)更近一步:分桶平均


public class PfTest {
    
     
	
	static class BitKeeper {
    
     
		// 无变化, 代码省略 
	}
	
	static class Experiment {
    
     
	
		private int n; 
		private int k; 
		private BitKeeper[] keepers; 
		
		public Experiment(int n) {
    
     
			this(n, 1024); 
		}
		
		public Experiment(int n, int k) {
    
     
			this.n = n; 
			this.k = k; 
			this.keepers = new BitKeeper[k]; 
			for (int i = 0; i < k; i++) {
    
     
				this.keepers[i] = new BitKeeper(); 
			} 
		}
		
		public void work() {
    
     
			for (int i = 0; i < this.n; i++) {
    
     
				long m = ThreadLocalRandom.current().nextLong(1L << 32); 
				BitKeeper keeper = keepers[(int) (((m & 0xfff0000) >> 16) % keepers.length)];
				keeper.random(); 
			} 
		}
		
		public double estimate() {
    
     
			double sumbitsInverse = 0.0; 
			for (BitKeeper keeper : keepers) {
    
     
				sumbitsInverse += 1.0 / (float) keeper.maxbit; 
			}
			double avgBits = (float) keepers.length / sumbitsInverse; 
			return Math.pow(2, avgBits) * this.k; 
		} 
	}
	public static void main(String[] args) {
    
     
		for (int i = 100000; i < 1000000; i += 100000) {
    
     
			Experiment exp = new Experiment(i); 
			exp.work(); 
			double est = exp.estimate(); 
			System.out.printf("%d %.2f %.2f\n", i, est, Math.abs(est - i) / i); 
		} 
	} 
}

这个过程有点 类似于选秀节目里面的打分,一堆专业评委打分,但是有一些评委因为自己特别喜欢所以给高了,一些评委又打低了,所以一般都要 屏蔽最高分最低分,然后 再计算平均值,这样的出来的分数就差不多是公平公正的了。

上述代码就有 1024 个 “评委”,并且在计算平均值的时候,采用了 调和平均数,也就是倒数的平均值,它能有效地平滑离群值的影响:

avg = (3 + 4 + 5 + 104) / 4 = 29 
avg = 4 / (1/3 + 1/4 + 1/5 + 1/104) = 5.044

观察脚本的输出,误差率百分比控制在个位数:

100000 94274.94 0.06 
200000 194092.62 0.03 
300000 277329.92 0.08 
400000 373281.66 0.07 
500000 501551.60 0.00 
600000 596078.40 0.01 
700000 687265.72 0.02 
800000 828778.96 0.04 
900000 944683.53 0.05

真实的 HyperLogLog 要比上面的示例代码更加复杂一些,也更加精确一些。上面这个算法在随机次数很少的情况下会出现除零错误,因为 maxbit = 0 是不可以求倒数的。

3)真实的 HyperLogLog

有一个神奇的网站,可以动态地让你观察到 HyperLogLog 的算法到底是怎么执行的:http://content.research.neustar.biz/blog/hll.html

其中的一些概念这里稍微解释一下,您就可以自行去点击 step 来观察了:

  • m 表示分桶个数: 从图中可以看到,这里分成了 64 个桶;
  • 蓝色的 bit 表示在桶中的位置: 例如图中的 101110 实则表示二进制的 46 ,所以该元素被统计在中间大表格 Register Values 中标红的第 46 个桶之中;
  • 绿色的 bit 表示第一个 1 出现的位置: 从图中可以看到标绿的 bit 中,从右往左数,第一位就是1,所以在 Register Values 第 46 个桶中写入 1;
  • 红色 bit 表示绿色 bit 的值的累加: 下一个出现在第 46 个桶的元素值会被累加;

①、为什么要统计 Hash 值中第一个 1 出现的位置?

②、PF 的内存占用为什么是 12 KB?

3.Redis 中的 HyperLogLog 实现

从上面我们算是对 HyperLogLog 的算法和思想有了一定的了解,并且知道了一个 HyperLogLog 实际占用的空间大约是 12 KB ,但 Redis 对于内存的优化非常变态,当 计数比较小 的时候,大多数桶的计数值都是 ,这个时候 Redis 就会适当节约空间,转换成另外一种 稀疏存储方式,与之相对的,正常的存储模式叫做 密集存储,这种方式会恒定地占用 12 KB

1)密集型存储结构

密集型的存储结构非常简单,就是 16384 个 6 bit 连续串成 的字符串位图:

我们都知道,一个字节是由 8 个 bit 组成的,这样 6 bit 排列的结构就会导致,有一些桶会 跨越字节边界,我们需要 对这一个或者两个字节进行适当的移位拼接 才可以得到具体的计数值。

假设桶的编号为 index ,这个 6 bity 计数值的起始字节偏移用 offset_bytes 表示,它在这个字节的其实比特位置偏移用 offset_bits 表示,于是我们有:

offset_bytes = (index * 6) / 8 
offset_bits = (index * 6) % 8

前者是商,后者是余数。比如 bucket 2 的字节偏移是 1,也就是第 2 个字节。它的位偏移是 4,也就是第 2 个字节的第 5 个位开始是 bucket 2 的计数值。需要注意的是 字节位序是左边低位右边高位,而通常我们使用的字节都是左边高位右边低位。

这里就涉及到两种情况,如果 offset_bits 小于等于 2,说明这 6 bit 在一个字节的内部,可以直接使用下面的表达式得到计数值 val

val = buffer[offset_bytes] >> offset_bits # 向右移位

如果 offset_bits 大于 2,那么就会涉及到 跨越字节边界,我们需要拼接两个字节的位片段:

# 低位值 
low_val = buffer[offset_bytes] >> offset_bits 
# 低位个数 
low_bits = 8 - offset_bits 
# 拼接,保留低6位 
val = (high_val << low_bits | low_val) & 0b111111

不过下面 Redis 的源码要晦涩一点,看形式它似乎只考虑了跨越字节边界的情况。这是因为如果 6 bit在单个字节内,上面代码中的 high_val 的值是零,所以这一份代码可以同时照顾单字节和双字节:

// 获取指定桶的计数值 
#define HLL_DENSE_GET_REGISTER(target,p,regnum) do {
    
     \ 
	uint8_t *_p = (uint8_t*) p; \ 
	unsigned long _byte = regnum*HLL_BITS/8; \
	unsigned long _fb = regnum*HLL_BITS&7; \ # %8 = &7 
	unsigned long _fb8 = 8 - _fb; \ 
	unsigned long b0 = _p[_byte]; \ 
	unsigned long b1 = _p[_byte+1]; \ 
	target = ((b0 >> _fb) | (b1 << _fb8)) & HLL_REGISTER_MAX; \ 
} while(0) 

// 设置指定桶的计数值 
#define HLL_DENSE_SET_REGISTER(p,regnum,val) do {
    
     \ 
	uint8_t *_p = (uint8_t*) p; \ 
	unsigned long _byte = regnum*HLL_BITS/8; \ 
	unsigned long _fb = regnum*HLL_BITS&7; \ 
	unsigned long _fb8 = 8 - _fb; \ 
	unsigned long _v = val; \ 
	_p[_byte] &= ~(HLL_REGISTER_MAX << _fb); \ 
	_p[_byte] |= _v << _fb; \ 
	_p[_byte+1] &= ~(HLL_REGISTER_MAX >> _fb8); \ 
	_p[_byte+1] |= _v >> _fb8; \ 
} while(0)

2)稀疏存储结构

稀疏存储适用于很多计数值都是零的情况。下图表示了一般稀疏存储计数值的状态:

多个连续桶的计数值都是零 时,Redis 提供了几种不同的表达形式:

  • 00xxxxxx :前缀两个零表示接下来的 6bit 整数值加 1 就是零值计数器的数量,注意这里要加 1是因为数量如果为零是没有意义的。比如 00010101表示连续 22 个零值计数器。
  • 01xxxxxx yyyyyyyy :6bit 最多只能表示连续 64 个零值计数器,这样扩展出的 14bit 可以表示最多连续 16384 个零值计数器。这意味着 HyperLogLog 数据结构中 16384 个桶的初始状态,所有的计数器都是零值,可以直接使用 2 个字节来表示。
  • 1vvvvvxx:中间 5bit 表示计数值,尾部 2bit 表示连续几个桶。它的意思是连续(xx +1)个计数值都是 (vvvvv + 1)。比如 10101011 表示连续4个计数值都是 11

注意 上面第三种方式 的计数值最大只能表示到 32 ,而 HyperLogLog 的密集存储单个计数值用 6bit表示,最大可以表示到 63当稀疏存储的某个计数值需要调整到大于 32时,Redis 就会立即转换HyperLogLog的存储结构,将稀疏存储转换成密集存储。

3)对象头

HyperLogLog 除了需要存储 16384 个桶的计数值之外,它还有一些附加的字段需要存储,比如总计数缓存、存储类型。所以它使用了一个额外的对象头来表示:

struct hllhdr {
    
     
	char magic[4]; /* 魔术字符串"HYLL" */ 
	uint8_t encoding; /* 存储类型 HLL_DENSE or HLL_SPARSE. */ 
	uint8_t notused[3]; /* 保留三个字节未来可能会使用 */ 
	uint8_t card[8]; /* 总计数缓存 */ 
	uint8_t registers[]; /* 所有桶的计数器 */ 
};

所以 HyperLogLog 整体的内部结构就是 HLL 对象头 加上 16384 个桶的计数值位图。它在 Redis 的内部结构表现就是一个字符串位图。你可以把 HyperLogLog 对象当成普通的字符串来进行处理

> PFADD codehole python java golang 
(integer) 1 
> GET codehole 
"HYLL\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80C\x03\x84MK\x80P\xb8\x80^\x f3"

但是 不可以 使用 HyperLogLog 指令来 操纵普通的字符串,因为它需要检查对象头魔术字符串是否是"HYLL"

4.HyperLogLog的使用

HyperLogLog 提供了两个指令PFADDPFCOUNT,字面意思就是一个是增加,另一个是获取计数。PFADDset 集合的 SADD 的用法是一样的,来一个用户 ID,就将用户 ID 塞进去就是, PFCOUNTSCARD 的用法是一致的,直接获取计数值:

> PFADD codehole user1 
(interger) 1 
> PFCOUNT codehole 
(integer) 1 
> PFADD codehole user2 
(integer) 1 
> PFCOUNT codehole 
(integer) 2 
> PFADD codehole user3 
(integer) 1 
> PFCOUNT codehole 
(integer) 3 
> PFADD codehole user4 user 5 
(integer) 1 
> PFCOUNT codehole 
(integer) 5

我们可以用 Java 编写一个脚本来试试 HyperLogLog 的准确性到底有多少:

public class JedisTest {
    
     
	public static void main(String[] args) {
    
     
		for (int i = 0; i < 100000; i++) {
    
     
			jedis.pfadd("codehole", "user" + i); 
		}
		long total = jedis.pfcount("codehole"); 
		System.out.printf("%d %d\n", 100000, total); 
		jedis.close(); 
	} 
}

结果输出如下:

100000 99723

发现 10 万条数据只差了277 ,按照百分比误差率是 0.277%,对于巨量的 UV 需求来说,这个误差率真的不算高。

当然,除了上面的 PFADDPFCOUNT 之外,还提供了第三个PFMEGER指令,用于将多个计数值累加在一起形成一个新的 pf 值:

> PFADD nosql "Redis" "MongoDB" "Memcached" 
(integer) 1 

> PFADD RDBMS "MySQL" "MSSQL" "PostgreSQL" 
(integer) 1 

> PFMERGE databases nosql RDBMS OK> PFCOUNT databases 
(integer) 6

参考资料:《Java中高级核心知识全面解析》限量100份,有一些人已经通过我之前的文章获取了哦!
名额有限先到先得!!!
有想要获取这份学习资料的同学可以点击这里免费获取》》》》》》》

猜你喜欢

转载自blog.csdn.net/Java_Caiyo/article/details/111565702