【整理】互联网服务端技术体系:可扩展之数据分区

大而化小。


综述入口见:“互联网应用服务端的常用技术思想与机制纲要”

引子

随着业务的快速发展,数据量也在飞速增长。单个存储节点的容量和并发读写都是有瓶颈的。怎么办呢?要解决这个问题,只要思考一个问题即可:在一亿个数中找一个数,和在一百个数中找一个数,哪个更快 ? 显然是后者。

应对数据量膨胀的有效之法即是数据分区。将一个大的数据集划分为多个小的数据集分别进行管理,叫做数据分区。数据分区是分治策略的一种形式。本文总结关于数据分区的基本知识以及实践。


基本知识

常用的数据分区方式有两种:范围分区和哈希分区。根据情况,还可以采用冷热分区、混合分区等。

范围分区

将整个数据集按照顺序编号每 N 个一组划分成相对均匀的若干段连续范围。连续范围通常也是有序的。B+ 树的内节点和叶子节点实际上就是一个有序范围分区。范围分区可以有效支持范围查找。

最常用的范围分区按时间分区。按时间分区有两个问题:

  • 某个时间段的写负载都在一个分区上。为了避免这一点,可以进一步按 业务ID+时间戳 来分区。
  • 当数据量随着时间快速增长,一个时间分区的数据量也可能变得难以忍受。适用于数据规模增长不快的情形。
  • 某段时间的读负载很可能也在一个分区上。

哈希分区

通过一个或多个哈希函数,对数据的 key 计算出哈希值,然后按照哈希值取模来落到某个分区。哈希分区能让数据分布更加均匀,但无法避免热点 key 的热点访问。哈希分区不支持范围查找。

采用哈希分区的例子: DB 按照业务 ID 取模进行分库分表; ES 按照业务 ID 取模进行分片。

冷热分区

将变化相对恒定的热数据单独放在一个分区里,将冷数据放在归档分区里。

冷热分区的例子: 比如未完成订单相对于已完成订单是热数据,而且未完成订单的量在长期看来不会快速增长。因此,可以将未完成订单单独放在一个 ES 索引里(内部还可以分片),提供搜索。

混合分区

结合使用范围分区和哈希分区。可以使用某个列的前缀来构建哈希分区,而使用整列数据来构建范围分区。当然,这也增大了存储空间开销和运维开销。

分区问题

分区字段

分区字段的选择通常遵循两个原则:

  • 避免分区不均衡。对业务 ID 关联的记录数进行审查,如果某个业务 ID 能够关联的记录数可能占总记录数的比例很大,则按照该字段分区会存在分区数据不均衡问题。比如交易订单按店铺 ID 分,对于 VIP 大商家,就可能导致在某个分区上的热点数据;而按照用户分,则不会有,因为一个用户下单量是有限的,不会对整体产生影响。此外,如果对 2^m 取模分区,则 key 的低 m 位不能在短时间内聚集性,比如都是 0001 - 0010。要做到分区均衡,一种方法是保持 key 的随机性。比如取 MD5 的一小段。
  • 查询要求。梳理相应的查询请求,从中提取常见的查询字段。也可以通过分布式搜索引擎的方式来实现查询,使得分区字段选择不强依赖于查询请求。

分区数及大小

分区通常指的是逻辑分区,需要分配到物理节点上。一个物理节点通常有多个分区。要确定分区数及分区大小。分区大小通常以某个数据量为最大限度。

要估算分区数,需要拿到一些基本数据:

  • 预计要支撑多少读并发,写并发;要支撑多少年【规划值】;
  • 健康的单分区/单节点所能支撑的数据行/记录数、读并发量、写并发量【经验值/监控值】;
  • 当前总数据行/记录数、日增数据行/记录数;当前平均读并发量、平均写并发量、峰值读并发量、峰值写并发量【监控值】。可以监控每年/月的数据行/记录数、读 QPS、写 QPS 的增长趋势情况,作为未来技术优化决策的依据。

热点数据

无论是范围分区还是哈希分区,瞬时大并发的热点 key 的访问都是难以避免和应对的。热点 key 访问的可考虑方案:

  • 热点 key 的访问可以在热点 key 的基础上再加若干位,使得热点 key 的访问被打散,读的时候需要合并所有被打散到的分区;这样,分区的计算公式会相对复杂一点,而且不易扩展到其它 key 上。
  • 通过实时计算自动检测到热点 key 的可能性,提前加载好缓存,或者做到自适应均衡负载。

辅助字段查询

辅助字段的查询,通常是先找到辅助字段所关联的分区字段(主键),再按分区字段进行查询。需要构建“辅助字段-分区字段”的映射信息。这个映射信息的存储和分区有两种方式:

  • 关联到哪个分区字段的值,就放在对应的分区里。辅助字段的某个值的映射信息可能分布在多个分区上。根据辅助字段查询时,需要合并所有分区的查询数据。比如说 name = qin 关联到两个 ID 1, 2;那么 name:1 放在 1 对应的分区里,name:2 放在 2 对应的分区里。辅助字段的分区与分区字段的分区是绑定的。 DB 采用这种做法。
  • 单独为映射信息做统一的全局存储和分区。辅助字段的某个值的映射信息只在一个分区上。辅助字段的不同值的映射可能在不同的分区。比如说 name = qin 关联两个 ID 1,2 会作为一个整体放在某个分区里; name = ming 关联两个 ID 3,4 作为一个整体放在另一个分区里。辅助字段的分区是单独设计的,与分区字段的分区无关。 ES 采用这种做法。

分区再均衡

当数据量/访问量剧增需要增加数据节点,或者机器宕机需要下线数据节点时,原有分区的数据需要在变更后的节点集合上重新分布。称为分区再均衡。

分区再均衡的方法 hash Mod N 。静态分区是采用固定分区数,动态分区则会增加或减少分区数。动态分区有利于让分区数据大小不超出某个最大限制。

分区再均衡有两种方案:

  • 保持分区数不动,增加物理节点。使用 Steal Partition 的方法。DB 采用这种方法。ES 的主分片也是固定分片数。
  • 保持物理节点不动,增大分区数。动态分区一般要应用哈希一致性算法。一般 K-V 存储用这种。

为什么 DB 一般采用固定分区 ? 因为 DB 往往要支持多个字段的查询,除了主字段分区以外,还要考虑辅字段分区。动态分区会增大这种复杂性。而 K-V 存储一般只支持主字段查询,没有额外要考虑。

分区应用

DB

实际应用中,最常见的数据分区就是 DB 的分库分表。分库分表有水平和垂直两个维度。水平,通常是按行;垂直,通常是按业务或字段。水平分库,是将单个库的数据切分为多个库;水平分表,是将单个表的数据切分为多个表。 库和表的 Schema 都是与原来完全一致的。

那么,何时采用分库,何时采用分表呢 ? 分库和分表的数量如何定?如何进行实际的分库分表操作?有哪些要注意的事项呢?

  • 分库的原因:单库的连接数和并发读写容量是有瓶颈的;此外多个业务争用同一个库的连接数,会相互影响;
  • 分表的原因: 表的数据量太大, SQL 执行慢。
  • 分库分表的基本标准判断: 存储量 100G+ , 日增 20w+ , 单表数据量 1y+, 高峰期并发读写 1w+ 。

分库也能达到分表的效果。那么何时采用分表呢?如果表的数据量上涨,但是单库的并发读写容量并没有多少上涨,则采用分表会更简单一些,运维成本应该也少一些。如果是因为需要支撑更多的并发读写,则首选分库,能足够解决并发读写的问题。单库的并发读写一般保持在 1000-2000 之间。分库之后,通常同时也实现了分表。如果不够,再细分表。分库分表的乘积数量通常选择 2 的幂次,因为在将数据分布到某个分区上时,需要进行取模操作,对 2 的 N 幂次取模只要取低 N 位即可。分库和分表也需要考虑好几年之用。一般 512, 1024 比较多。因为扩容时比较麻烦,需要进行分区再均衡。对于运行在线上的服务来说,如果需要人工来做,风险会比较高。

分库分表的实际步骤:

  • STEP1: 开发。将原有的读写老库切换到读写新库。如果原来的访问 DB 层已经用 DAO 层隔离,那么改造的代码只要在 DAO 层切换库即可,上层的业务代码都不用动。因此,在做 DB 访问的时候,要注意 DAO 层的设计。
  • STEP2: 测试。测试包括两个部分: 1. 业务的全量回归; 2. 读写新库、读写老库。读新库和读老库的数据要做全字段数据对比,覆盖各种场景的数据。
  • STEP3: 部署。发布新的代码到线上。
  • STEP4: 数据迁移。以某个时间点为界,老库的所有数据必须迁移到新库中(最终统一读写新库),写入新库的数据要异步同步到老库里(回滚用)。 数据迁移要考虑两个特殊的时间段: 1. 从老库切换到新库的一小段切换时间的新流量; 2. 回滚时从新库切回老库的一小段切换时间的新流量。

对于第一点来说,要着重考虑数据不丢失、不重叠。要保证数据不丢失,则需要将切换的这一小段时间的数据积压在新库这边,待开启新库读写后,这段时间的流量直接进入新库,再同步到老库。切换的瞬间,停止老库的写。要保证数据不重叠,需要有唯一索引做保证,或者代码里做兼容,且重叠数据量很小。

对于第二点来说,要考虑数据一致性。一般采用双写模式可以避免这一点。也就是,切换之后,异步写老库。这样,新流量总是进入老库。或者评估业务影响,如果短暂的不一致不影响业务的话,做到最终一致性亦可。

在分库分表之后,还需要分别考虑读写流量及相应的扩容。通常写主读从,读流量更多,保证扩容在从库上比较合适。因为从库不直接影响线上服务。


ES

ES 的数据分区体现在分片(Shard)的概念。ES 的所有文档(Document)存储在索引(Index)里。索引是一个逻辑名字空间,包含一个或多个物理分片。一个分片的底层即为一个 Lucene 索引。ES 的所有文档均衡存储在集群中各个节点的分片中。ES 也会在节点之间自动移动分片,以保证集群的数据均衡。ES 分片分为主分片和复制分片。每个文档都属于某个主分片。主分片在索引创建之后就固定了。复制分片用来实现冗余和容错,复制分片是可变的。

ES 对文档的操作是在分片的单位内进行的。实际上就是针对倒排索引的操作。倒排索引是不可变的,因此可以放在内核文件缓冲区里支持并发读。ES 更新文档必须重建索引,而不是直接更新现有索引。为了支持高效更新索引,倒排索引的搜索结构是一个 commit point 文件指明的待写入磁盘的 Segment 列表 + in-memory indexing buffer 。Segment 可以看做是 ES 的可搜索最小单位。新文档会先放在 in-memory indexing buffer 里。当文档更新时,新版本的文档会移动到 commit point 里,而老版本的文档会移动到 .del 文件里异步删除。ES 通过 fsync 操作将 Segment 写入磁盘进行持久化。由于 ES 可以直接打开处于文件缓冲区的 commit point 文件中的 Segment 进行查询(默认 1s 刷新),使得查询不必写入磁盘后才能查询到,从而做到准实时。

ES 分片策略会影响 ES 集群的性能、安全和稳定性。ES 分片策略主要考虑的问题:分片算法如何?需要多少分片?分片大小如何 ? 分片算法可以按照时间分区,也可以按照取模分区。分片数估算有一个经验法则:确保对于节点上已配置的每个 GB,将分片数量保持在 20 以下。如果某个节点拥有 30GB 的堆内存,那其最多可有 600 个分片,但是在此限值范围内,设置的分片数量越少,效果就越好。一般而言,这可以帮助集群保持良好的运行状态。分片应当尽量分布在不同的节点上,避免资源争用。

HBase

HBase 的数据分区体现在 Region。 Region 是 HBase 均衡地存储数据的基本单元。Region 数据的均匀性,体现在 Rowkey 的设计上。 HBase Region 具有自动拆分能力,可以指定拆分策略,Region 在达到指定条件时会自动拆分成两个。可以指定的拆分策略有: IncreasingToUpperBoundRegionSplitPolicy 根据公式min(r^2*flushSize,maxFileSize) 确定的大小;ConstantSizeRegionSplitPolicy Region 大小超过指定值 maxFileSize;DelimitedKeyPrefixRegionSplitPolicy 以指定分隔符的前缀 splitPoint,确保相同前缀的数据划分到同一 Region;KeyPrefixRegionSplitPolicy 指定 Rowkey 前缀来划分,适合于固定前缀。

除了 Region 自动拆分,还需要进行 Region 预分区。Region 预分区需要指定分为多少个 Region ,每个 Region 的 startKey 和 endKey (底层会转化为 byte 数组)。 如果数据能够比较均匀落到指定的 startKey 和 endKey, 就可以避免后续频繁的 Region Split。Region Split 虽然灵活,却会消耗集群资源,影响集群性能和稳定性。

HBase Region 的大小及数量的确定,可参考业界实践 “HBase最佳实践之Region数量&大小”。官方推荐的 Regionserver上的 Region 个数范围在 20~200;每个 Region 的大小在 10G~30G 之间。

分区示例

范围分区

假设有固定长度为 strlen 的字符串,字符取值集合限于 a-z ,且取值随机,要划分为 n 个分区。那么分区范围计算如下:

public class StringDividing {

    private static char[] chars = new char[] {
            'a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z'
    };

    private static final Integer CHAR_LEN = 26;

    public static List<String> divide(int strlen, int n) {
        int maxValue = maxValue(strlen);
        List<Integer> ranges = Dividing.divideBy(maxValue, n);
        return ranges.stream().map(num -> int2str(num, strlen)).collect(Collectors.toList());
    }

    public static int maxValue(int m) {
        int multiply = 1;
        while (m>0) {
            multiply *= CHAR_LEN;
            m--;
        }
        return multiply - 1;
    }

    /**
     * 将整型转换为对应的字符串
     */
    private static String int2str(int num, int strlen) {
        if (num < CHAR_LEN) {
            return nchars('a', strlen-1) + chars[num];
        }
        StringBuilder s = new StringBuilder();
        while ( num >= CHAR_LEN) {
            int r = num % CHAR_LEN;
            num = num / CHAR_LEN;
            s.append(chars[r]);
        }
        s.append(chars[num % CHAR_LEN]);
        return s.reverse().toString() + nchars('a', strlen-s.length());
    }

    private static String nchars(char c, int n) {
        StringBuilder s = new StringBuilder();
        while (n > 0) {
            s.append(c);
            n--;
        }
        return s.toString();
    }

    public static void main(String[] args) {
        for (int len=1; len < 6; len++) {
            divide(len,8).forEach(
                    e -> System.out.println(e)
            );
        }
    }
}

public class Dividing {

    public static List<Integer> divideBy(int totalSize, int num) {
        List<Integer> parts = new ArrayList<Integer>();
        if (totalSize <= 0) {
            return parts;
        }

        int i = 0;
        int persize = totalSize / num;
        while (num > 0) {
            parts.add(persize*i);
            i++;
            num--;
        }
        return parts;
    }
}

哈希分区

这里抽取了 Dubbo 的一致性哈希算法实现。核心是 TreeMap[Long, T] virtualNodes 的变更操作和 key 的哈希计算。

package zzz.study.algorithm.dividing;

import com.google.common.collect.Lists;

import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.function.Function;

public class ConsistentHashLoadBalance {

    public static void main(String[] args) {
        List<String> nodes = Lists.newArrayList("192.168.1.1", "192.168.2.25", "192.168.3.255", "255.255.1.1");

        ConsistentHashSelector<String> selector = new ConsistentHashSelector(nodes, Function.identity());
        test(selector);

        selector.addNode("8.8.8.8");
        test(selector);
    }


    private static void test(ConsistentHashSelector<String> selector) {
        Map<String, List<Integer>> map = new HashMap<>();
        for (int i=1; i < 16000; i+=1) {
            String node = selector.select(String.valueOf(i));
            List<Integer> objs = map.getOrDefault(node, new ArrayList<>());
            objs.add(i);
            map.put(node, objs);
        }
        map.forEach(
                (key, values) -> {
                    System.out.println(key + " contains: " + values.size() + " --- " + values);
                }
        );
    }

    private static final class ConsistentHashSelector<T> {

        private final TreeMap<Long, T> virtualNodes;
        private final int replicaNumber = 160;
        private final Function<T, String> keyFunc;

        ConsistentHashSelector(List<T> nodes, Function<T, String> keyFunc) {
            this.virtualNodes = new TreeMap<Long, T>();
            this.keyFunc = keyFunc;
            assert keyFunc != null;
            for (T node : nodes) {
                addNode(node);
            }
        }

        public boolean addNode(T node) {
            opNode(node, (m, no) -> virtualNodes.put(m,no));
            return true;
        }

        public boolean removeNode(T node) {
            opNode(node, (m, no) -> virtualNodes.remove(m));
            return true;
        }

        public void opNode(T node, BiConsumer<Long, T> hashFunc) {
            String key = keyFunc.apply(node);
            for (int i = 0; i < replicaNumber / 4; i++) {
                byte[] digest = md5(key + i);
                for (int h = 0; h < 4; h++) {
                    long m = hash(digest, h);
                    hashFunc.accept(m, node);
                }
            }
        }

        public T select(String key) {
            byte[] digest = md5(key);
            return selectForKey(hash(digest, 0));
        }

        private T selectForKey(long hash) {
            Map.Entry<Long, T> entry = virtualNodes.ceilingEntry(hash);
            if (entry == null) {
                entry = virtualNodes.firstEntry();
            }
            return entry.getValue();
        }

        private long hash(byte[] digest, int number) {
            return (((long) (digest[3 + number * 4] & 0xFF) << 24)
                    | ((long) (digest[2 + number * 4] & 0xFF) << 16)
                    | ((long) (digest[1 + number * 4] & 0xFF) << 8)
                    | (digest[number * 4] & 0xFF))
                    & 0xFFFFFFFFL;
        }

        private byte[] md5(String value) {
            MessageDigest md5;
            try {
                md5 = MessageDigest.getInstance("MD5");
            } catch (NoSuchAlgorithmException e) {
                throw new IllegalStateException(e.getMessage(), e);
            }
            md5.reset();
            byte[] bytes;
            try {
                bytes = value.getBytes("UTF-8");
            } catch (UnsupportedEncodingException e) {
                throw new IllegalStateException(e.getMessage(), e);
            }
            md5.update(bytes);
            return md5.digest();
        }

    }

}


小结

分治是最为基本的计算机思想之一。而数据分区是应对海量数据处理的基本前提。常见数据分区有范围分区和哈希分区两种,根据情况选用。

分区是逻辑概念。分区往往会分布到多个机器节点上。数据分区要考虑数据均匀分布问题、分区大小及分区数。数据分区加冗余,构成了高可用分布式系统的基础。

参考资料

posted @ 2020-11-06 20:37  琴水玉  阅读(312)  评论(0编辑  收藏  举报