缓存-缓存问题&数据分布&一致性哈希算法

版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/Fly_Fly_Zhang/article/details/92568613

为什么要有缓存:

随着互联网的普及,内容信息越来越复杂,用户数和访问量越来越大,我们的应用需要支撑更多的并发量,同时我们的应用服务器和数据库服务器 所做的计算也越来越多。但是往往我们的应用服务器资源是有限的,数据库每秒能接受的请求次数也是有限的(或者文件读写的次数)。如何利用有限的资源提供尽可能大地吞吐量? 一个有效的办法就是引入缓存,每个环节的请求可以从缓存中直接获取目标数据并返回,从而减少计算量,有效提升响应目的,让有限的资源服务更多的用户。

缓存特征

命中率:

当某个请求能够通过访问缓存得到响应时,称为缓存命中。
缓存命中率越高,缓存利用率也就越高。

最大空间:

缓存通常位于内存中(也有可能在硬盘中),内存的空间通常比磁盘空间小的多,因此缓存的最大空间不可能非常大。
当缓存存放的数据量超过最大空间时,就需要淘汰部分数据来存放新到达的数据。

淘汰策略:

  • FIFO: 先进先出策略,在实时性的场景下,需要经常访问最新的数据,那么就可以使用FIFO,使最先进入的数据(最晚的数据)被淘汰。
  • LFU: 最少使用策略:
    无论是否过期,根据元素被使用次数判断,清除使用次数较少的元素释放空间。算法主要比较元素的hitcount(命中次数),在保证高频数据有效性的场景下,可是使用这类策略。
  • LRU(Least Recently Used): 最近最久未使用策略,优先淘汰最久未使用的数据,也就是上次访问时间举例现在最远的数据。该策略可以保证内存中的数据都是热点数据,也就是经常被访问的数据,从而保证缓存命中率。
    还有几种简单策略:
  • 根据过期时间判断,清理过期时间最长的元素;
  • 根据过期时间判断,清理最近要过期的元素;
  • 随机清理;
  • 根据关键字(或元素内容)长短清理。

缓存分类

从缓存硬件介质看,分为内存和硬盘两种,从技术上,可以分为内存,硬盘文件,数据库

  • 内存:将缓存存储于内存中是最快的选择,无需额外的IO开销,但是内存的缺点是没有持久化的落地物理磁盘,一旦应用异常break down(发生故障)而重新启动,数据很难或者无法复原。
  • 硬盘:一般来说,很多缓存框架会结合使用内存和硬盘,在内存空间分配已满或者在异常情况下,可以主动或者被动的将内存空间数据持久化到硬盘中,达到释放空间或备份数据的目的。
  • 数据库:前面提到,增加缓存的策略之一就是为了减少数据库的IO压力。现在使用数据库又回到老问题? 实际上,数据库也有很多类型,那些不支持SQL,只简单的key-value存储结构的特殊数据库(如BerkeleyDB和Redis),响应速度和吞吐量都远高于我们的关系型数据库。

LRU:

下面是一个基于双向链表+HashMap的LRU算法实现,对算法的解释如下:

  • 基本思路是当访问某个节点时,将其从原来位置删除,重新插入链表头部,这样就能保证链表尾部存储的就是最近最久未使用的节点,当节点数量大于缓存最大空间时就删除链表尾部的节点。
  • 为了使删除操作的时间复杂度未O(1),那么就不能采用遍历的方式来找到某个节点。HashMap存储着Key到节点的映射,通过key就能以O(1)的时间将其从双向链表中删除。

代码实现:

package com.Demo.LRUDemo;

import java.util.HashMap;
import java.util.Iterator;

/**
 * @Created with IntelliJ IDEA
 * @Description:
 * @Package: com.Demo.LRUDemo
 * @author: FLy-Fly-Zhang
 * @Date: 2019/6/17
 * @Time: 10:27
 */
public class LRUDemoOne<K,V> implements Iterable<K>{
    private class Node{
        Node pre;
        Node next;
        K k;
        V v;
        public Node(K k,V v){
            this.k=k;
            this.v=v;
        }
    }
    private Node head;
    private Node tail;
    //O(1)查找节点
    private HashMap<K,Node> map;
    //缓存大小
    private int maxSize;
    public LRUDemoOne(int maxSize){
        this.maxSize=maxSize;
        //尽量使得每个Hash下面只有一个key-value键值对,使得可以O(1)实现找到对应key-value
        //4/3这个数字是因为HashMap的默认阈值为75%  4/3*3/4=1
        this.map=new HashMap<>(maxSize*4/3);
        this.head=new Node(null,null);
        this.tail=new Node(null,null);
        head.next=tail;
        tail.pre=head;
    }
    /*
     * 访问一个节点将其从原来位置删除,并重新查入头部。
     */
    public V get(K key){
        if(!map.containsKey(key)){
            return null;
        }
        Node node=map.get(key);
        //从原来位置删除
        unlink(node);
        //插入头部
        appendHead(node);
        return node.v;
    }
    public void put(K key,V value){
        //如果存在相当于更新数据==缓存命中
        //不存在相当于插入数据
        if(map.containsKey(key)){
            Node node=map.get(key);
            unlink(node);
        }
        Node node=new Node(key,value);
        map.put(key,node);
        appendHead(node);
        //如果插入数据时,内存已满,则删除最近最久未使用数据
        if(map.size()>maxSize){
            Node remove=removeTail();
            map.remove(remove);
        }
    }
    /*
     * @Description : 删除此节点
     * @param null
     * @return :
     * @exception :
     * @date :   2019/6/17 11:09
     */
    private void unlink(Node node){
        node.pre.next=node.next;
        node.next.pre=node.pre;
    }

    private void appendHead(Node node){
        node.next=head.next;
        head.next=node;
    }
    private Node removeTail(){
        Node node=tail.pre;
        node.pre.next=tail; //前驱连接尾节点
        tail.pre=node.pre; //尾节点连接前面
        return node;
    }
    @Override
    public Iterator<K> iterator() {
        return new Iterator<K>() {
            private Node cur=head.next;
            @Override
            public boolean hasNext() {
                return cur!=tail;
            }

            @Override
            public void remove() {

            }

            @Override
            public K next() {
               Node node=cur;
               cur=cur.next;
               return node.k;
            }
        };
    }

    public static void main(String[] args) {

    }
}

缓存分类

浏览器:

当HTTP响应允许进行缓存时,浏览器会将HTML,CSS,JS,图片等静态资源进行缓存。

ISP:

网络服务提供商(ISP)是网络访问的第一跳,通过将数据缓存在ISP中能够大大提高用户的访问速度。

反向代理:

反向代理位于服务器之前,请求与响应都需要经过反向代理。通过将数据缓存在反向代理,在用户请求时就可以直接使用缓存进行响应。

本地缓存:

使用Guava Cache(Google 开源的java重用工具库里的缓存工具)将数据缓存在服务器本地内存中,服务器代码可以直接读取代码,速度非常快。
本地缓存指的是在应用中缓存组件,其最大优点就是应用和cache是在同一个进程内部,请求缓存非常迅速,没有过多的网络开销等,在单应用不需要集群支持或者集群情况下个节点无需互相通知的场景下使用本地缓存较为合适;同时,它的缺点是跟应用程序耦合,多个应用程序无法直接共享缓存,各应用或集群的各节点都需要维护自己的单独缓存,对内存是一种浪费。

分布式缓存:

使用Redis,Memcache 等分布式缓存将数据缓存在分布式缓存系统中。
相对于本地缓存来说,分布式缓存单独部署,可以根据需求分配硬件资源。不仅如此,服务器集群都可以访问分布式缓存。而本地缓存需要在服务器集群之间进行同步,实现和性能开销都非常大。

数据库缓存:

MySQL等数据库管理系统具有自己的查询缓存机制来提高SQL查询效率。

参考资料:

美团技术团队-缓存那些事

CDN (Content distribution network/内容分发网络)

CDN是一种通过互连的网络系统,利用更靠近用户的服务器更快更可靠的将HTML,CSS,JS,音乐,视频,图片等静态资源分发给用户。

优点:

  • 更快地将数据分发给用户。
  • 通过部署多台服务器,从而提高系统整体地带宽性能。
  • 多台服务器可以看成是一种冗(rong)余机制,从而提供可用性。在这里插入图片描述

缓存问题

缓存穿透:

指的是对某个一定不存在地数据进行请求,该请求将会穿透缓存到达数据库。

解决方案:
  • 对这些不存在的数据缓存一个空数据。
  • 对这类请求进行过滤。

缓存雪崩:

指的是由于数据还没有被加载到缓存中,或者缓存数据在同一时间大面积过期,又或者缓存服务器宕机,导致大量的请求都去到达数据库。
在存在缓存的系统中,系统非常依赖缓存,缓存分担了很大一部分的数据请求。当发生缓存雪崩时,数据库无法处理这么大的请求,导致数据库崩溃。

解决方案:
  • 为防止缓存在同一时间大面积过期导致的缓存雪崩,可以通过观察用户行为,合理设置缓存过期时间。
  • 为了防止缓存服务器宕机出现的缓存雪崩,可以使用分布式缓存,分布式缓存的每个节点只缓存部分数据,当某个节点宕机时可以保证其它节点的缓存依然可用。
  • 也可以进行缓存预热,避免在系统刚启动不久由于还未将大量数据进行缓存而导致缓存雪崩。

缓存一致性:

缓存一致性要求更新数据的同时缓存数据也能够实时更新。

解决方案:
  • 在数据更新的同时立即去更新缓存。
  • 在读缓存之前先判断缓存是否是最新的,如果不是则进行更新。
    要保证缓存一致性需要付出很大的代价,缓存数据最好是那些一致性要求不高的数据,允许缓存数据存在一些脏数据。

数据分布:

哈希分布:

hash分布就是将数据计算hash值之后,按照hash值分配到不同的结点上。例如有N个结点,数据的主键为key,则将改数据分配的结点序号为: hash(key)%N。
传统的hash分布算法有个问题:当节点数量变化时,也就是N值变化,那么几乎所有数据都需要重新分布。将导致大量的数据迁移。

顺序分布:

将数据划分为多个连续的部分,按照数据的ID或者时间分布到不同节点上。如ID范围为1-7000。使用顺序分布可以将其划分成多个子表。对应主键的范围为1-1000,1001-2000…

顺序分布相比于Hash分布的优点:
  • 能保证数据原有的顺序。
  • 能够准确控制每台服务存储的数据量,从而使得存储空间的利用率最大。

一致性哈希:

Distribute Hash Table(DHT)是一种哈希分布方式,其目的是为了克服传统hash分布在服务器节点数量变化时大量数据失效的问题。
在分布式存储系统中,要将数据存储到具体的节点上,如果我们采用普通的hash算法进行路由,将数据映射到具体节点上,如key%N ,key是数据的key,N是机器节点数,如果一个机器加入或者退出这个集群,则所有数据映射都无效,如果是持久化存储则需要做数据迁移,如果是分布式缓存,则其它缓存失效。

hash算法应满足的四个适应条件:

均衡性:

均衡性是指hash的结果能够尽可能分布到所有的缓冲中去,这样可以使得所有的缓冲空间都得到利用。

单调性:

单调性是指如果已经有一些内容通过hash分派到相应的缓冲中,又有新的缓冲区加入到系统中,那么hash的结果应该能够保证原有已分配的内容可以被映射到新的缓冲区中去,而不会被映射到旧的缓冲区集合中的其它缓冲区(当缓冲区大小变化时一致性hash尽量保证已分配的内容不被重新映射新的缓冲区)。

分散性:

在分布式环境中,终端有可能看不到所有的缓冲,而是只能看到其中的一部分。当终端希望通过hash过程将内容映射到缓冲区上时,由于不同终端所见的缓冲范围有可能不同,从而导致hash结果不一致,最终的结果是相同的内容被不同的终端映射到不同的缓冲区中。这种情况是需要被避免的,因为其导致相同内容被存储到不同的缓冲区,降低了系统存储的效率,分散性的定义就是上述情况发生的严重程度。好的hash算法应该尽量避免不一致的情况发生,也就是降低分散性。

负载(load)

负载问题实际上是从另一个角度看待分散性问题。既然不同的终端可能将相同的内容映射到不同的缓冲区中,那么对于一个特定的缓冲区而言,也可能被不同的用户映射不同内容。因此好的hash算法应该尽量降低缓冲的负荷。

环型哈希空间基本原理:

将hash空间[0,2^n-1]看成一个hash环,每个服务器节点都配置到hash环上。每个数据对象 通过hash取模得到hash值之后,存放到hash环中顺时针 方向 第一个大于等于 该hash值得节点上。

注意:
  • 节点的个数是自定义的,但是需要满足业务需求,也就是说,我们预计下业务需求为N台服务器,但是我们创建环形哈希空间时,创建(2^n)-1 个节点,这使得我们小范围增加服务器时,其hash的N不会发生变化,从而保证数据相对稳定。
  • 整个hash环我们可以使用TreeMap来实现,因为TreeMap是排序的。
  • 节点上是服务器,数据通过hash后缓存到顺时针第一个服务器中。
映射服务器节点:

将各个服务器使用Hash进行一个hash,具体可以选择服务器的ip或者唯一主机名作为关键字进行hash,这样每台服务器就能确定其在hash环上的位置。假设我们对四台服务器使用ip地址进行hash:
在这里插入图片描述

映射数据:

现在我们将objectA,objectB,objectC,objectD四个对象通过特定的Hash函数计算出对应的key值,然后散列到hash环上,然后从数据所正在位置顺时针走,第一台服务器就是其应该定位到的服务器。
一致性hash在增加或者删除节点时只会影响到hash环中相邻的节点,如下图新增节点x,只需将它前一个节点c上的数据重新进行分配即可,对于节点A,B,D都没有影响。
在这里插入图片描述

服务器的删除与添加
  • 如果此时服务器C宕机了,此时objectA,B,D都不会受影响,只有ObjectC会重新分配到D上,其它数据不会发生改变。
  • 如果环境中新增加一台服务器NodeX, 通过hash算法将NodeX映射到环中,通过顺时针迁移规则,那么ObjectC被迁移到了NodeX中,其它对象保持原有的存储位置。
    通过对节点添加和删除的分析,一致性hash算法在保持单调性的同时,还使数据前移达到了最小,这样的算法对于分布式集群 来说很合适,避免了大量数据前移,减小了服务器的压力。

在这里插入图片描述

虚拟节点:

上面描述的一致性hash存在数据分布不均匀的问题,节点存储的数据量会存在很大不同。
数据不均匀主要是因为节点在hash环上分布不均匀,这种情况在节点数量很少的情况下尤其明显。
为了解决这种数据倾斜问题,一致性哈希算法引入了虚拟节点机制,即对每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。具体做法可以先确定每个物理节点关联的虚拟节点数量,然后在ip或者主机名后面增加编号。例如上面的情况,可以为每台服务器计算三个虚拟节点,于是可以分别计算 “Node A#1”、“Node A#2”、“Node A#3”、“Node B#1”、“Node B#2”、“Node B#3”的哈希值,于是形成六个虚拟节点:
在这里插入图片描述
数据定位算法不变,只是多了一步虚拟节点到实际节点的映射。

参考资料:

一致性hash算法

展开阅读全文

没有更多推荐了,返回首页