system desing 系统设计(二): 数据库sharding和Consistent Hashing算法原理

  互联网上所有重要数据最终的归宿都是数据库(没有之一),所以数据库就面临两个最重要的问题:

  •  负载均衡load balance:很明显单节点的数据库肯定是无法满足性能需求的,用行话讲就是QPS很大,单节点的数据库无法承载了,必须要数据库集群来分担大量的QPS!那么问题又来了:怎么合理地把这些QPS尽可能均匀地well-distributed分摊到集群的每个node了?
  •    万一有节点挂掉,比如数据盘损坏,存放在里面的数据咋办?就这样丢了?很明显是不可能的!怎么才能做好数据的Replica了,从而避免单点故障 Single Point Failure?

  1、先说第二点:万一有节点挂掉,怎么保证数据不丢? 那就直接把同样的数据复制到不同的node了!著名的HDFS用的就是3副本思路:  同样的数据分别存放在3个不同的node,一旦有1个node挂掉,立马用另外两个节点的node来恢复,速度很快,上层应用还无感知!HDFS属于hadoop大数据体系,传统的关系型sql数据库又是怎么保证数据replica的了?这里以mysql为例,其replica的架构如下:

     

    这是个典型的master-slave架构,正常情况下数据都是从master读写,那slave又是干啥的了? mysql采用了“write ahead log”思路,master节点的任何操作都都会以 Log 的形式做一份记录【transaction就是依靠这个实现的,比如回滚roll back】,然后通知slave来读取log日志。因为日志只会append,不会updata,所以slave完全可以根据timestamp增量读取日志后再做数据的冗余备份;所以slave的数据相对master,稍微有一些延迟;平时做ETL时,如果业务对延时要求不高,能在一定程度上容忍,一般可以选择从slave读取!因为master只有1个,一旦挂掉,slave就会被promoted to be master,但是在promote的过程中,可能导致少量数据丢失或不一致!

  小结:为了完成replica,同样的数据分别在不同的节点存放是最核心的思路,大数据hadoop、关系型sql数据库、非关系型NoSql数据库等都是这样干的

       2、解决了数据丢失的问题,接下来就是数据库的负载均衡了!其实原理很简单:用户的QPS能被相对平均地分配到cluster的每个node即刻,该怎么做了?

     先说一种简单的拆法:vertical sharding!比如User Table有这么几个字段:avatar、nickname、username、password、email、phoneNo等。其实频繁需要读取的有avatar、nickname、email、phoneNo等;像username、password也就登陆的时候读一次,并不常用,所以可以根据读写的频率分成两张不同的表:把不常读取的username、password放在User Table中,把用户的属性avatar、nickname、email、phoneNo等放在User Profile Table中,两个表分别用UserId作为foreign key来关联,然后存放在两台不同的节点。即使其中一个挂掉,比如user profile table的节点挂掉,也不影响用户登陆,无非就是登陆后看不到头像、昵称而已!

       上述方法有缺陷:

  •  万一数据表只有2列,怎么继续拆分?
  •    需要根据业务理解来拆分,没法简单粗暴地让所有表都这么干;换句话说:不具备通用性

       能解决上述问题的,只能是horizontal sharding了!

       3 、horizontal sharding,故名思意就是水平切分,也就是按照行来切分,不同的行存储在不同的node,那么怎么解决某行数据应该存放在哪台node了?

  (1)按照timestamp来区分node:假如集群有3节点,6月份的数据放A节点;7月份的数据放B节点,8月份的数据放C节点,9月份的数据又放回A节点,这样合适么? 不能说这样做不好,只能说这样做在大多场景下毫无卵用!互联网业务时效性是很强的:比如刚发布的tweet,肯定比旧tweet访问的多。如果全部放在同一个节点,该节点的QPS肯定飙升,何来load banlance了?

  (2)按照mod来区分node:比如用uid mod 3,结果就是0、1、2,根据不同的结果存放不同的服务器,是不是万事大吉了?我只能说图样图森破!缺陷还是有的:

  •  万一数据库大部分数据的uid都是3,mod 3的结果就是0,那么大部分数据都放在0号node,发生了严重的数据倾斜,怎么办?(当然出现这种问题的另一个原因就是id没选好,不能全赖这种mod的思路不行)
  •    万一新增或裁撤node怎么办?mod的结果改变,会导致数据大迁移,下次insert或select数据的时候需要重新定位到新节点,比如下面这种情况:

     

     原来有3个节点,现在新增1个,80%的数据都要重新mod都迁移到新节点,迁移是对node 磁盘和网络的IO压力可想而知!

      (3)上面两种方式都有缺陷,怎么解决了?仔细思考第二种mod的缺陷,还是在于数据迁移太多导致了效率低;那么为什么会导致数据大迁移了?根本原因还是集群的node数量太少了!举个极端的例子: 假设原来有10 node,存放了11个数,按照mod的思路,1和11放1号节点,其余的对号入座!现在又加入一个node,按照mod的算法,是不是只需要移动第11行数就行了?也就是只需要移动1个数就行了,示意如下:标红的数字要移动

       

   所以如果想用mod方法来决定数据的存储节点,为了减少后续集群变动时数据的迁移,必须让节点数量够大,此时的问题又来了:

  •  集群的规模到底多大才够了?
  •    业务刚开始的时候数据量很小,此时搭建个大物理集群,服务器的cpu、内存、磁盘负载都很低,这不是浪费老板白花花的银子么?你确定大(资)老(本)板(家)愿意为这么多服务器买单?

     (4)此时天空一阵巨响,consistent hashing算法终于闪亮登场!按照一致性hash的理论:集群需要有2^64 -1个节点!对,是的,你没看错,整个集群确实规划了这么多节点。理论上讲:2^64是个非常大的数,互联网业务常见的各种id mod (2^64) 后肯定不会重复,所以出现冲突的可能性非常小,基本上算是“一条数据一个坑”,也就是每条数据的存储都有自己的“专属”节点node。读取数据的时候同样可以用id mod (2^64) 后看看在哪个节点,然后直接去该节点取就是了,是不是很简单高效啊?我就呵呵了,这么干读写数据确实方便和高效了,但谁又有能力来搭建和运维2^64节点的集群了? Are you ok?

       云计算都知道吧?通过虚拟化技术,把一台物理机虚拟成多个服务器,每台服务器都单独跑os和app,虚拟的服务器之间互不影响。通过虚拟化virtualization,成功地将少量的物理服务器扩展成了大量可用的虚拟服务器,这个思路是不是也可以借鉴了?既然都说到这里,那当然是可以的咯!consistent hash对集群物理服务器node的virtualization方法具体方法如下:

  •    原则上每台物理服务器“虚拟”出1000节点(这里为什么是1000,不是500了? 不是10000了?个人建议测试的时候可以多测不同的情况,看看那种情况负载相对均衡)
  •    取每台物理服务器的各种属性1个(最好是不变的),比如MAC地址、服务器名称等,然后扩展出1000个字符串,比如MAC-0001、MAC-0002.....MAC-1000,或者用服务器的名字扩展,比如DB1-0001、DB1-0002、DB1-0003......、DB1-1000等;
  •    选好一点的hash算法,能够达到“牵一发而动全身”效果的那种,对上述的字符串分别求hash,然后用这个hash值 mod 2^64,就得到了该虚拟节点在集群内的位置。
    •   为什么要求的hash算法能够达到“牵一发而动全身”效果? 大家记得MD5么?明文仅仅改一个字符,得到的hash值差异巨大!这里要的这是这种效果,才能让虚拟node节点在集群内的位置相对均匀分散well-distributed,更利于减少hash conflict和load balance
    •       至于hash算法,可以用MD5,也可以用sha1、sha256、FVN(这种算法可能听的少,特点就是能够高度分散相近的字符串),总之就是让hash值尽可能分散开来就行!
  •     通过上面3个步骤,node在集群的位置已经确定,现在该确定数据存放在哪个node了!这一步就简单多了:用数据的id mod 2^64,得到的值就是node的位置,直接存进去就行了!
  •    此时整个集群有3个物理节点,我们虚拟化出了3000个节点,数据id mod 2^64后万一不在这3000节点内怎么办了?就按照顺时针的方式查找,直到找到匹配的node为止!

       为了方便理解,很多大拿都把2^64抽象成了一个环(这里用的是IP地址来取hash值),整个过程示意如下:

  

        现在还剩最后一个问题思考了:增加或删除节点,能减少数据迁移的工作么?为什么能减少了?

   

        以上图为例:原本有3台服务器,每条数据分别mod 3来决定存放在哪台服务器;此时加入第4台服务器D;在没有引入virtual node思路前,D要复制A的数据才能应对hash值落在CD之间的查询。很明显加入D后,只帮A减轻了负担(CD段的数据原本由A承担查询,现在换成D了),C和B还是累成狗,半毛钱的好处都没捞到,你说冤不冤?引入了virtual node的思路后,A\B\C\D都用hash函数虚拟出了4000个相对离散、均匀的virtual node,使得A/B/C/D的虚拟节点能互相交织,你中有我,我中也有你,终于能够更加均衡地存储和查询数据了!

  总结:consistent hash算法看起来很复杂,细节很繁冗,但是背后的原理却很简单质朴:通过virtualization技术(本质是hash算法,比如FNV算法),把集群内各个物理节点存储和查询数据的范围相对分散、均匀地摊开了,让各个物理节点合理地扩充各自的“势力范围”hash算法把服务器相关字符串(名称、MAC、IP等)、用户数据key等原本毫不相干的字符串统一转成数字,这样就有了可比性。服务器字符串越多,分布越均匀,和key匹配地也就越均匀!

      (5)上述做法对存储和查询的数据也有一定的要求:每条数据的id必须全局唯一,不能重复!所以需要找个gloab web server来分配各种id值。server自生要通过加锁来保证数据操作的原子性(Atomic);因为分配id的QPS并不高(比如uid,也就是用户注册的时候申请,其他时候完全用不上),所以这么做并不会明细影响整体性能!

   4、光说不练假把式,来学(抄)习(袭)一下别人的consistent hash算法的源码:

/**
 * @author: https://kefeng.wang
 * @date: 2018-08-10 11:08
 **/
public class ConsistentHashing {
    // 物理节点
    private Set<String> physicalNodes = new TreeSet<String>() {
        {
            add("192.168.1.101");
            add("192.168.1.102");
            add("192.168.1.103");
            add("192.168.1.104");
        }
    };

    //虚拟节点
    private final int VIRTUAL_COPIES = 1048576; // 物理节点至虚拟节点的复制倍数
    private TreeMap<Long, String> virtualNodes = new TreeMap<>(); // 哈希值 => 物理节点

    // 32位的 Fowler-Noll-Vo 哈希算法
    // https://en.wikipedia.org/wiki/Fowler–Noll–Vo_hash_function
    private static Long FNVHash(String key) {
        final int p = 16777619;
        Long hash = 2166136261L;
        for (int idx = 0, num = key.length(); idx < num; ++idx) {
            hash = (hash ^ key.charAt(idx)) * p;//混淆明文
        }
     //牵一发而动全身 hash
+= hash << 13; hash ^= hash >> 7; hash += hash << 3; hash ^= hash >> 17; hash += hash << 5; if (hash < 0) { hash = Math.abs(hash); } return hash; } // 根据物理节点,构建虚拟节点映射表 public ConsistentHashing() { for (String nodeIp : physicalNodes) { addPhysicalNode(nodeIp); } } // 添加物理节点 public void addPhysicalNode(String nodeIp) { for (int idx = 0; idx < VIRTUAL_COPIES; ++idx) { long hash = FNVHash(nodeIp + "#" + idx); virtualNodes.put(hash, nodeIp); } } // 删除物理节点 public void removePhysicalNode(String nodeIp) { for (int idx = 0; idx < VIRTUAL_COPIES; ++idx) { long hash = FNVHash(nodeIp + "#" + idx); virtualNodes.remove(hash); } } // 查找对象映射的节点 public String getObjectNode(String object) { long hash = FNVHash(object); SortedMap<Long, String> tailMap = virtualNodes.tailMap(hash); // 所有大于 hash 的节点 Long key = tailMap.isEmpty() ? virtualNodes.firstKey() : tailMap.firstKey(); return virtualNodes.get(key); } // 统计对象与节点的映射关系 public void dumpObjectNodeMap(String label, int objectMin, int objectMax) { // 统计 Map<String, Integer> objectNodeMap = new TreeMap<>(); // IP => COUNT for (int object = objectMin; object <= objectMax; ++object) { String nodeIp = getObjectNode(Integer.toString(object)); Integer count = objectNodeMap.get(nodeIp); objectNodeMap.put(nodeIp, (count == null ? 0 : count + 1)); } // 打印 double totalCount = objectMax - objectMin + 1; System.out.println("======== " + label + " ========"); for (Map.Entry<String, Integer> entry : objectNodeMap.entrySet()) { long percent = (int) (100 * entry.getValue() / totalCount); System.out.println("IP=" + entry.getKey() + ": RATE=" + percent + "%"); } } public static void main(String[] args) { ConsistentHashing ch = new ConsistentHashing(); // 初始情况 ch.dumpObjectNodeMap("初始情况", 0, 65536); // 删除物理节点 ch.removePhysicalNode("192.168.1.103"); ch.dumpObjectNodeMap("删除物理节点", 0, 65536); // 添加物理节点 ch.addPhysicalNode("192.168.1.108"); ch.dumpObjectNodeMap("添加物理节点", 0, 65536); } }

  测试的时候可以设置 VIRTUAL_COPIES 不同的值,分别观察每个物理节点的负载情况,看看是否均衡;1就表示没有虚拟节点,全是物理节点;然后分别取100、1000、10000等不停的尝试!

   

最后,一句话总结:consistent hash 算法把服务器相关字符串(名称、ip、MAC等)和用户数据key统一转成数字,双方才能更加均匀地匹配! 

 

参考:

1、https://cloud.tencent.com/developer/article/1459776  consistency hashing算法

2、https://www.cnblogs.com/charlieroro/p/8486941.html  FVN哈希算法

posted @ 2022-08-14 21:49  第七子007  阅读(258)  评论(1编辑  收藏  举报