Bootstrap

缓存基本原理

缓存的设计思想在架构设计中十分常见。比如我们每天用的操作系统,都有系统缓存、用户缓存。磁盘有磁盘缓存区、CPU有CPU缓存区。再比如,在我们常用的经典框架中,也经常使用到缓存,Spring有IoC缓存,MyBatis有一级缓存、二级缓存。可以说缓存无处不在。

缓存的原理

空间换时间:使用更高速的存储设备空间暂存数据,来换查询时间减少
“好钢用在刀刃上”,利用系统中访问速度最快的那部分

  • 利用存储级别
    • 如将计算结果缓存在内存
    • 材料不同,读取速度不同
    • 将远程网络数据在本地缓存
  • 利用区域不同(如内存中靠近cpu的部分)

高速缓存行

局部性原理

程序的局部性原理是指计算机在执行某个程序时,倾向于使用最近使用的数据。局部性原理有两种表现形式:时间局部性和空间局部性。

  • 时间局部性是指被引用过的存储器位置很可能会被再次引用,例如:重复的引用一个变量时则表现出较好的时间局部性

  • 空间局部性是指被引用过的存储器位置附近的数据很可能将被引用;例如:遍历二维数组时按行序访问数据元素具有较好的空间局部性(两行之间顺序存放的)

缓存更新策略

缓存更新的策略主要分为三种:

  • Cache aside
  • Read/Write through
  • Write behind

Cache aside 通常会先更新数据库,然后再删除缓存,为了兜底通常还会将数据设置缓存时间。

Read/Write through 一般是由一个 Cache Provider 层对外提供读写操作,应用程序不用感知操作的是缓存还是数据库。写操作发生时,Cache Provider同时写入缓存和数据库

Write behind简单理解就是延迟写入,Cache Provider 每隔一段时间会批量输入数据库,优点是应用程序写入速度非常快。

Cache Aside方式比较常见

  • 直接操作缓存,批量进入数据库?操作系统文件缓存似乎有类似的解决方案。

Cache aside 旁路缓存

(1)读请求常见流程

图片

写请求常见流程

图片

为什么只删除旧缓存而不直接更新?因为如果有两个异步的写请求更新同一数据,可能会在缓存中出现脏数据

为什么不先删缓存再更新数据库?因为如果有一个读请求和一个写请求,在并发场景下可能会数据不一致

这都是一个请求被另一个请求战术穿插惹,,

如果不想先更新数据库,可以考虑延迟双删,删两次

在实际的系统中针对写请求还是推荐先更新数据库再删除缓存,但是在理论上还是存在问题,以下面这个例子说明。

图片

读请求在从数据库读到旧数据后、在缓存设置旧数据前,写请求处理更新了新数据并写在缓存中,但被之后 读请求 在缓存中设置旧数据覆盖。

删除失败重试机制:

可以引入消息队列,保存删除失败的key,重新尝试删除。这会对业务代码造成侵♂入。

因此可以考虑利用mysql-binlog,在数据插入后自动发送消息给mq,由mq的ACK机制确保删除缓存。

上述问题发生的概率其实非常低,因为通常数据库更新操作比内存操作耗时多出几个数量级,上图中最后一步回写缓存(set age 18)速度非常快,通常会在更新数据库之前完成。

以防万一我们得想一个兜底的办法:缓存数据设置过期时间。通常在系统中是可以允许少量的数据短时间不一致的场景出现。

Read through

在 Cache Aside 更新模式中,应用代码需要维护两个数据源头:一个是缓存,一个是数据库。而在 Read-Through 策略下,应用程序无需管理缓存和数据库,只需要将数据库的同步委托给缓存提供程序 Cache Provider 即可。所有数据交互都是通过抽象缓存层完成的。

图片

为啥缓存命中是返回null,,图错了吧,,

遇事不决加一层代理doge。应用程序只需要与Cache Provider交互,不用关心是从缓存取还是数据库。

如果缓存服务挂了,则Cache provider程序仍然可以通过直接转到数据源来进行操作。

Read-Through 适用于多次请求相同数据的场景。

为什么这里没讨论并发问题了?难道说并发请求发给Cache Provider之后,Cache Provider负责同步了?找个例子看看

应为cache要写一起写了吧

那其他的并发问题有没有加一层代理负责同步的情况?

  • 呃,,还有什么并发问题来着,, 不过像这样类似事务的重复性数据处理并发问题没有了吧,

Write through

Write-Through 策略下,当发生数据更新(Write)时,缓存提供程序 Cache Provider 负责更新底层数据源和缓存。

缓存与数据源保持一致,并且写入时始终通过抽象缓存层到达数据源。图片

Write behind

也被成为Write back, 简单理解就是:应用程序更新数据时只更新缓存, Cache Provider每隔一段时间将数据刷新到数据库中。说白了就是延迟写入

图片

  • 优点是数据写入速度非常快,适用于频繁写的场景。
  • 缺点是缓存和数据库不是强一致性,对一致性要求高的系统慎用。

类似linux中的PageCache

经典问题

缓存集中失效

当业务系统查询数据时,首先会查询缓存,如果缓存中数据不存在,然后查询DB再将数据预热到Cache中,并返回。缓存的性能比 DB 高 50~100 倍以上。

图片

很多业务场景,如:秒杀商品、微博热搜排行、或者一些活动数据,都是通过跑任务方式,将DB数据批量、集中预热到缓存中,缓存数据有着近乎相同的过期时间

当过这批数据过期时,会一起过期,此时,对这批数据的所有请求,都会出现缓存失效,从而将压力转嫁到DB,DB的请求量激增,压力变大,响应开始变慢。

针对大量缓存数据同时过期的情况:
  • 那就通过随机、微调、均匀设置等方式设置过期时间,从而避免同一时间过期;如过期时间=基础时间+随机时间

  • 添加互斥锁,使得构建缓存的操作不会在同一时间进行。

  • 双key策略,主key是原始缓存,备key为拷贝缓存,主key失效时,可以访问备key,主key缓存失效时间设置为短期,备key设置为长期。

  • 后台更新缓存策略,采用定时任务或者消息队列的方式进行redis缓存更新或移除等。

缓存穿透

是指请求访问的数据既不在redis中,也不在数据库中,导致没有触发"查到数据后回写缓存"。
这种情况下缓存形同虚设,每次请求都会打到数据库上。

比如黑客攻击系统,不断的去查询系统中不存在的用户;
或者电商系统中,用户搜索某类商品,但是这类商品再系统中根本不存在;

解决方案:

  • 方案一:查存DB 后,如果数据不存在,预热一个特殊空值到缓存中,设定过期时间如60s。这样,后续查询都会命中缓存
  • 方案二:构造一个布隆过滤器BloomFilter,初始化全量数据,当接到请求时,在BloomFilter中判断这个key是否存在,如果不存在,直接返回即可,无需再查询缓存和DB
    • 简单来说,就是可以引入了多个相互独立的哈希函数,保证在给定的空间和误判率下,完成元素判重。因为我们知道,存在hash碰撞这样一种情况,那如果只使用一个hash函数,则碰撞冲突的概率明显会变大,那为了减少这种冲突,我们可以多引入几个hash函数,而布隆过滤器算法的核心思想就是利用多个不同的hash函数来解决这样一种冲突。它的优点是空间效率高,查询时间短,远超其他算法,而它的缺点就是会存在一定的误识别率,
    • 但只要没有通过布隆过滤器的校验,那么这个key就一定不存在
    • 多引入个hash函数?

在网关层进行鉴权、流量控制也可以避免缓存穿透造成系统不可用

设置过期时间是为了将来数据库中真的有这条数据时能查到

3、缓存雪崩

缓存雪崩是指部分缓存节点不可用,进而导致整个缓存体系甚至服务系统不可用的情况。

当较大的流量洪峰到来时,如果大流量 key 比较集中,正好在某 1~2 个缓存节点,很容易将这些缓存节点的内存、网卡过载,缓存节点异常 Crash,然后这些异常节点下线,这些大流量 key 请求又被 rehash 到其他缓存节点,进而导致其他缓存节点也被过载 Crash,缓存异常持续扩散,最终导致整个缓存体系异常,无法对外提供服务。

  • 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中。

4、缓存热点

对于突发事件,大量用户同时去访问热点信息,这个突发热点信息所在的缓存节点就很容易出现过载和卡顿现象,甚至 Crash,我们称之为缓存热点。

这个在新浪微博经常遇到,某大V明星出轨、结婚、离婚,瞬间引发数百千万的吃瓜群众围观,访问同一个key,流量集中打在一个缓存节点机器,很容易打爆网卡、带宽、CPU的上限,最终导致缓存不可用。

解决方案:

  • 首先能先找到这个热key来,比如通过Spark实时流分析,及时发现新的热点key。
  • 将集中化流量打散,避免一个缓存节点过载。由于只有一个key,我们可以在key的后面拼上有序编号,比如key#01key#02。。。key#10多个副本,这些加工后的key位于多个缓存节点上。
  • 每次请求时,客户端随机访问一个即可

可以设计一个缓存服务治理管理后台,实时监控缓存的SLA,并打通分布式配置中心,对于一些hot key可以快速、动态扩容。

5、缓存大Key

当访问缓存时,如果key对应的value过大,读写、加载很容易超时,容易引发网络拥堵。另外缓存的字段较多时,每个字段的变更都会引发缓存数据的变更,频繁的读写,导致慢查询。如果大key过期被缓存淘汰失效,预热数据要花费较多的时间,也会导致慢查询。

所以我们在设计缓存的时候,要注意缓存的粒度,既不能过大,如果过大很容易导致网络拥堵;也不能过小,如果太小,查询频率会很高,每次请求都要查询多次。

解决方案:

  • 方案一:设置一个阈值,当value的长度超过阈值时,对内容启动压缩,降低kv的大小
  • 方案二:评估大key所占的比例,由于很多框架采用池化技术,如:Memcache,可以预先分配大对象空间。真正业务请求时,直接拿来即用。
  • 方案三:颗粒划分,将大key拆分为多个小key,独立维护,成本会降低不少
  • 方案四:大key要设置合理的过期时间,尽量不淘汰那些大key

6、缓存数据一致性

一份数据通常会存在DB缓存中,由此会带来一个问题,如何保证这两者的数据一致性。另外,缓存热点问题会引入多个副本备份,也可能会发生不一致现象。图片

解决方案:

  • 方案一:当缓存更新失败后,进行重试,如果重试失败,将失败的key写入MQ消息队列,通过异步任务补偿缓存,保证数据的一致性。(异步重试)
  • 方案二:设置一个较短的过期时间,通过自修复的方式,在缓存过期后,缓存重新加载最新的数据

7、数据并发竞争预热

解决方案:

  • 方案一:引入一把全局锁,当缓存未命中时,先尝试获取全局锁,如果拿到锁,才有资格去查询DB,并将数据预热到缓存中。虽然,client端发起的请求非常多,但是由于拿不到锁,只能处于等待状态,当缓存中的数据预热成功后,再从缓存中获取

图片

为了便于理解,简单画了个流程图。这里面特别注意一个点,由于有一个并发时间差,所以会有一个二次check缓存是否有值的校验,防止缓存预热重复覆盖。

  • 方案二:缓存数据创建多个备份,当一个过期失效后,可以访问其他备份。

缓存污染

1、定义

缓存污染是指,由于历史原因,缓存中有很多 key 没有设置过期时间,导致很多 key 其实已经没有用了,但是一直存放在 redis 中,时间久了,redis 内存就被占满了

2、解决方案

  • 缓存尽量设置过期时间
  • 设置缓存淘汰策略为最近最少使用的原则,然后将这些数据删除

缓存击穿

高并发环境下,大量用户请求查同一条数据缓存未命中的数据,导致去查数据库。同缓存穿透的区别在于,缓存穿透时查询的数据在数据库中也没有,而对于缓存击穿来说,缓存过期也可能导致缓存击穿

  • 添加互斥锁,即当过期之后,除了请求过来的第一个查询的请求可以获取到锁请求到数据库,并再次更新到缓存中,其他的会被阻塞住,直到锁被释放,同时新的缓存也被更新上去了,后续请求又会请求到缓存上,这样就不会出现缓存击穿了。
  • 接口限流、熔断
  • 只在失效后锁?并发预热解决方案里,人家缓存未命中时全给你锁了

JSR107缓存规范

Java Specification Requests,Java 规范提案。2012年10月26日JSR规范委员会发布了JSR 107(JCache API的首个早期草案)。JCache 规范定义了一种对Java对象临时在内存中进行缓存的方法,包括对象的创建、共享访问、假脱机(spooling)、失效、各JVM的一致性等,可被用于缓存JSP内最经常读取的数据。

用的比较少

Java Caching定义了5个核心接口

  • CachingProvider

    定义了创建、配置、获取、管理和控制多个CacheManager。一个应用可以在运行期间访问多个CachingProvider

  • CacheManager

    定义了创建、配置、获取、管理和控制多个唯一命名的Cache,这些Cache存在于CacheManage的上下文中,一个CacheManage只被一个CachingProvider拥有

  • Cache

    类似于Map的数据结构并临时储存以key为索引的值,一个Cache仅仅被一个CacheManage所拥有

  • Entry

    存储在Cache中的key-value对

  • Expiry

    存储在Cache的条目有一个定义的有效期,一旦超过这个时间,就会设置过期的状态,过期无法被访问,更新,删除。缓存的有效期可以通过ExpiryPolicy设置。

可以直接用ConcurrentHashMap做缓存,定时任务每隔 30 秒 ,执行缓存加载方法,刷新缓存。

虽然使用 JDK Map 能快捷构建缓存,但缓存的功能还是比较孱弱的。

因为现实场景里,我们可能需要给缓存添加缓存统计过期失效淘汰策略等功能。

于是,本地缓存框架应运而生。

流行的 Java 缓存框架包括:Ehcache , Google Guava , Caffeine Cache 。

图片

虽然本地缓存框架的功能很强大,但是本地缓存的缺陷依然明显。

1、高并发的场景,应用重启之后,本地缓存就失效了,系统的负载就比较大,需要花较长的时间才能恢复;

2、每个应用节点都会维护自己的单独缓存,缓存同步比较头疼

多级缓存

多级缓存有如下优势:

  1. 离用户越近,速度越快;
  2. 减少分布式缓存查询频率,降低序列化和反序列化的 CPU 消耗;
  3. 大幅度减少网络 IO 以及带宽消耗。

本地缓存做为一级缓存,分布式缓存做为二级缓存,首先从一级缓存中查询,若能查询到数据则直接返回,否则从二级缓存中查询,若二级缓存中可以查询到数据,则回填到一级缓存中,并返回数据。若二级缓存也查询不到,则从数据源中查询,将结果分别回填到一级缓存,二级缓存中。

图片

2018年,笔者服务的一家电商公司需要进行 app 首页接口的性能优化。笔者花了大概两天的时间完成了整个方案,采取的是两级缓存模式,同时利用了 Guava 的惰性加载机制,整体架构如下图所示:

图片

缓存读取流程如下:

1、业务网关刚启动时,本地缓存没有数据,读取 Redis 缓存,如果 Redis 缓存也没数据,则通过 RPC 调用导购服务读取数据,然后再将数据写入本地缓存和 Redis 中;若 Redis 缓存不为空,则将缓存数据写入本地缓存中。

2、由于步骤1已经对本地缓存预热,后续请求直接读取本地缓存,返回给用户端。

3、Guava 配置了 refresh 机制,每隔一段时间会调用自定义 LoadingCache 线程池(5个最大线程,5个核心线程)去导购服务同步数据到本地缓存和 Redis 中。

优化后,性能表现很好,平均耗时在 5ms 左右。最开始我以为出现问题的几率很小,可是有一天晚上,突然发现 app 端首页显示的数据时而相同,时而不同。

也就是说:虽然 LoadingCache 线程一直在调用接口更新缓存信息,但是各个 服务器本地缓存中的数据并非完成一致。说明了两个很重要的点:

1、惰性加载仍然可能造成多台机器的数据不一致

2、LoadingCache 线程池数量配置的不太合理, 导致了线程堆积

最终,我们的解决方案是:

1、惰性加载结合消息机制来更新缓存数据,也就是:当导购服务的配置发生变化时,通知业务网关重新拉取数据,更新缓存。

2、适当调大 LoadigCache 的线程池参数,并在线程池埋点,监控线程池的使用情况,当线程繁忙时能发出告警,然后动态修改线程池参数。

缓存之所以能够让系统“更快”,本质上做到了如下两点:

  • 减小 CPU 消耗

    将原来需要实时计算的内容提前算好、把一些公用的数据进行复用,这可以减少 CPU 消耗,从而提升响应性能。

  • 减小 I/O 消耗

    将原来对网络、磁盘等较慢介质的读写访问变为对内存等较快介质的访问,从而提升响应性能。

对于应用系统来讲,我们经常将缓存划分为本地缓存分布式缓存

本地缓存 :应用中的缓存组件,缓存组件和应用在同一进程中,缓存的读写非常快,没有网络开销。但各应用或集群的各节点都需要维护自己的单独缓存,无法共享缓存。

分布式缓存:和应用分离的缓存组件或服务,与本地应用隔离,多个应用可直接共享缓存。

上面这种查询操作其实用Spring Cache来操作更简单,直接使用@Cacheable即可实现,为什么还要使用RedisTemplate来直接操作呢?因为作为缓存,我们所希望的是,如果Redis宕机了,我们的业务逻辑不会有影响,而使用Spring Cache来实现的话,当Redis宕机以后,用户的登录等种种操作就会都无法进行了。

  • ?为什么这么说?redis宕机怎么影响Spring Cache?

缓存有各种分类,常见的是与应用耦合程度划分为:本地缓存local cache和分布式缓存remote cache

本地缓存

本地缓存,由于存在于应用程序的本地内存,应用和缓存在同一个进程内,且没有网络延迟,所以速度快

但本地缓存的大小通常受到物理内存的限制,而且还要兼顾应用程序正常运行,容量有限,扩展性差,无法轻松扩展到多个节点。还有就是多个应用实例下无法直接的共享缓存,数据的一致性难以保证,复杂度高。数据会随着应用程序的重启而丢失

适合读写密集、对数据一致性要求较低、网络环境不稳定的场景

分布式缓存

主要是指与应用分离的独立缓存组件,比如redis,可扩展性强,容量大,可以通过集群水平扩展;通过通过一致性哈希等技术,保证多节点之间的数据一致性,而且都集成好了,开发者一般直接使用这些特性.

由于存在网络延迟,与本地缓存相比,速度较慢;硬件成本也较高,来保证其高可用、高可靠性

更适合电商平台、社交网络等流量并发大的平台,或者互联网这种随着业务增长,需要弹性扩展以满足需求的场景

还有综合二者特点的多级缓存,将本地缓存和分布式缓存结合起来,本地缓存作为一级缓存,存储更新频率低,访问频率高数据;分布式缓存作为二级缓存,存储更新频率很高的数据

多级缓存更新策略:当用户获取数据时,先从一级缓存中获取数据,如果一级缓存有数据则返回数据,否则从二级缓存中获取数据。如果二级缓存中有数据则更新一级缓存,然后将数据返回客户端。如果二级缓存没有数据则去数据库查询数据,然后更新二级缓存,接着再更新一级缓存,最后将数据返回给客户端。这里逻辑其实和CPU内部的缓存很像.

一致性Hash

一致性哈希算法(consistent hashing)

对于分布式存储,不同机器上存储不同对象的数据,我们使用哈希函数建立从数据到服务器之间的映射关系。

一、简单传统的哈希函数

当集群中数据量很大时,采用一般的哈希函数,在节点数量动态变化的情况下(如机器宕机)会造成大量的数据迁移,导致网络通信压力的剧增,严重情况,还可能导致数据库宕机。

m = hash(o) mod n
  • 其中,o为对象名称,n为机器的数量,m为机器编号。

考虑以下例子:

3个机器节点,10个数据 的哈希值分别为1,2,3,4,…,10。使用的哈希函数为:(m=hash(o) mod 3)

机器0 上保存的数据有:3,6,9
机器1 上保存的数据有:1,4,7,10
机器2 上保存的数据有:2,5,8

当增加一台机器后,此时n = 4,各个机器上存储的数据分别为:

机器0 上保存的数据有:4,8
机器1 上保存的数据有:1,5,9
机器2 上保存的数据有:2,6,10
机器3 上保存的数据有:3,7

只有数据1和数据2没有移动,其他数据均在rehash过程中移动。

二、一致性哈希

一致性hash算法正是为了解决此类问题的方法,它可以保证当机器增加或者减少时,节点之间的数据迁移只限于两个节点之间,不会造成全局的网络问题。

1. 环形Hash空间

按照常用的hash算法来将对应的key哈希到一个具有232次方个桶的空间中,即0~(232)-1的数字空间中。现在我们可以将这些数字头尾相连,想象成一个闭合的环形。如下图:

img

2. 将数据通过hash算法映射到环上

将object1、object2、object3、object4四个对象通过特定的Hash函数计算出对应的key值,然后散列到Hash环上。如下图:
Hash(object1) = key1;
Hash(object2) = key2;
Hash(object3) = key3;
Hash(object4) = key4;

img

3. 将机器通过hash算法映射到环上

假设现在有NODE1,NODE2,NODE3三台机器,通过Hash算法(机器IP或机器的唯一的名称作为输入)得到对应的KEY值,映射到环中,其示意图如下:
Hash(NODE1) = KEY1;
Hash(NODE2) = KEY2;
Hash(NODE3) = KEY3;

img

4. 将数据存储到机器上

通过上图可以看出对象与机器处于同一哈希空间中,这样按顺时针转动object1存储到了NODE1中,object3存储到了NODE2中,object2、object4存储到了NODE3中。

img

5. 机器的添加与删除

  1. 向集群中添加一台新机器
    向集群中增加机器c4,c4经过hash函数后映射到机器c2和c3之间。这时根据顺时针存储的规则,数据m4从机器c2迁移到机器c4。数据的移动仅发生在c2和c4之间,其他机器上的数据并未受到影响。

img

\2. 从集群中删除一台机器
从集群中删除机器c1,这时只有c1原有的数据需要迁移到机器c3,其他数据并未受到影响。

img

  • 相比于之前的简单取模方法中动态增删集群中机器的数量时,造成全局的数据迁移,使用一致性哈希算法将大大改善这种情况,减轻了网络通信的压力。

存在的问题:

当集群中的节点数量较少时,可能会出现节点在哈希空间中分布不平衡的问题。如下图所示,图中节点A、B、C分布较为集中,造成hash环的倾斜。数据1、2、3、4、6全部被存储到了节点A上,节点B上只存储了数据5,而节点C上什么数据都没有存储。A、B、C三台机器的负载极其不均衡

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在极端情况下,假如A节点出现故障,存储在A上的数据要全部转移到B上,大量的数据导可能会导致节点B的崩溃,之后A和B上所有的数据向节点C迁移,导致节点C也崩溃,由此导致整个集群宕机。这种情况被称为雪崩效应

解决方法——虚拟节点

解决哈希环偏斜问题的方法就是,让集群中的节点尽可能的多,从而让各个节点均匀的分布在哈希空间中。在现实情境下,机器的数量一般都是固定的,所以我们只能将现有的物理节通过虚拟的方法复制多个出来,这些由实际节点虚拟复制而来的节点被称为虚拟节点。加入虚拟节点后的情况如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从上图可得:加入虚拟节点后,节点A存储数据1、3;节点B存储5、4;节点C存储2、6。节点的负载很均衡。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Chord 环

在分布式集群中,经常要寻找指定数据存储的物理节点,关于这个问题有三种比较典型的方法来解决。

三种典型的解决方案:

1. Napster:

  • 使用一个中心服务器接收所有的查询,中心服务器返回数据存储的节点位置信息。
  • 存在的问题:中心服务器单点失效导致整个网络瘫痪。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

2. Gnutella:

  • 使用消息洪泛(message flooding)来定位数据。一个消息被发到系统内每一个节点,直到找到其需要的数据为止。使用生存时间(TTL)来限制网络内转发消息的数量。
    存在的问题:消息数与节点数成线性关系,导致网络负载较重。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 Gnutella

3. SN型:

  • 现在大多数采用所谓超级节点(Super Node),SN保存网络中节点的索引信息,这一点和中心服务器类型一样,但是网内有多个SN,其索引信息会在这些SN中进行传播,所以整个系统的崩溃几率就会小很多。尽管如此,网络还是有崩溃的可能。

分布式散列表——Chord算法

一、Chord实现原理

  • Chord算法是一致性哈希算法的一种实现方式,数据和机器的组织方式同上节所讲,在Chord中使用SHA-1算法所谓其哈希函数,每一项为160bit的大整数,哈希空间的大小为2^160,下图为Chord简化后(6bit的哈希值)的示意图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Chord环

二、Chord资源定位:

资源定位是Chord协议的核心功能,我们首先从一个简单的定位方法讲起:

顺序查找:

每个节点都只保存其后继节点信息,当发起查询时,节点首先在本地查找,如果没有则询问其后继节点,如果资源k的哈希值位于本节点和下一节点之间,则说明k存储在其后继节点上;如果不在,则下一个节点向其后继节点发起同样的查询,直到找到 hash(node) > hash(k)

chord 顺序查找

节点N8查找资源K54,N8的后继节点N14不合符54є (8; 14],于是N14向其后继节点N21发起同样的查询,依次类推,直到节点N56满足54є (51; 56],于是得知资源K54在N56这个节点上。

  • 存在的问题:查找次数与节点个数成线性关系,时间复杂度是O(N),对一个上百万节点,且节点经常加入、退出的P2P网络来说,O(N)是不可忍受的,因此Chord提出了下面非线性查找的算法。

使用路由表提高效率

在每个节点N上都维护了最多有m项(m为哈希结果的位数)的路由表(finger table),用来定位资源。这个表的第i项是hash(node)+2^(i-1)处所属节点的位置。如下图所示(m=6):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

chord 路由表

节点N8的路由表中,左边一列包含了N8+1到N8+32(2^5-1)的位置,右边一列对应其所属节点的信息。比如N8+1-N14,表示在N8后的第一个位置上的资源由N14来负责。这样记录有以下优势:

  1. 每个节点只包含全网中一小部分节点的信息。
  2. 每个节点对于临近节点负责的位置知道的更多,比如N8节点对于N14负责的位置知道3处,而对N21负责的位置只知道1处。
  3. 路由表通常不包含直接找到后继节点的信息,往往需要询问其他节点来完成。

三、查询步骤

当在某个节点上查找资源时,首先判断其后继节点有没有持有该资源,若没有则直接从该节点的路由表从最远处开始查找。直到找到第一个hash(node)小于hash(data)的节点,然后跳转到此节点上,进行新一轮的查找。当hash(data)落在此节点和其后继节点之间时,则说明资源存储在当前节点的后继节点上。

例如:节点N8寻找K54这个资源

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

首先,在N8上查找后继节点为N14,发现K54并不符合54є (8; 14]的要求,那么直接在N8的路由表上查找符合这个要求的表项(由远及近查找),此时N8的路由表为:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

N8_fingertable

我们发现路由表中最远的一项N8+32–N42满足42є (8; 54],那么跳到N42这个节点上继续查找。N42的后继节点为N48,不符合54є (42; 48]的要求,说明N48不持有资源54,此时,开始在N42的路由表上查找:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

N42_fingertable

我们由远及近开始查找,发现N42+8–N51满足51є (42; 54],则说明N51这个点离持有K54这个资源的节点最近,那么此时跳到N51这个节点上继续查找。N51节点的后继节点为N56,符合54є (51; 56],此时定位完成,N56持有资源节点K54。

四、路由表的维护

添加节点

Chord通过在每个节点的后台周期性的进行询问后继节点的前驱节点是不是自己来更新后继节点以及路由表中的项。例如向以下chord环中添加节点:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

chord添加节点之前

现在N26节点要加入系统,首先它将自己的后继节点修改为N32,之后N26通知N32,N32接到通知后将自己前驱节点修改为N26。如下图所示:

chord添加节点N26

最后数据K24从N32迁移到N26.

数据K24从N32迁移到N26

下一个周期中,N21询问其后继节点N32的前序节点是不是自己,此时发现N32的前序节点已经是N26。于是N21将其后继节点修改为N26,并通知N26为自己的后继节点,N26接到通知后将N21设置为自己的前驱节点。如下图所示:

N21修改其后继节点

加入操作会带来的影响:

\1. 正确性方面:当一个节点加入系统,而一个查找发生在询问周期结束前,那么此时系统会有三个状态:

  • 所有后继指针和路由表项都正确时:对正确性没有影响。
  • 后继指针正确但表项不正确:查找结果正确,但速度稍慢(在目标节点和目标节点的后继处加入非常多个节点时)。如下图:

加入非常多个节点

  • 后继指针和路由表项都不正确:此时查找失败,Chord上层的软件会发现数据查找失败,在一段时间后会进行重试。

\2. 效率方面:当询问周期完成时,对查找效率的影响不会超过O(log N) 的时间。当询问周期未完成时,只有在目标节点和目标节点的后继处加入非常多个节点时才会有性能影响。可以证明,只要路由表调整速度快于网络节点数量加倍的速度,性能就不受影响。

拓展

缓存与缓冲的区别

  • 对于缓冲来说,接收者接下来想要使用的数据就在缓冲区中;
  • 而缓存的有效性是依赖于局部性原理的,即对于缓存来说,快速存储器接下来想要使用的数据不一定就在缓冲存储器中。
;