【整理】互联网服务端技术体系:可扩展之数据分区
大而化小。
综述入口见:“互联网应用服务端的常用技术思想与机制纲要”
引子
随着业务的快速发展,数据量也在飞速增长。单个存储节点的容量和并发读写都是有瓶颈的。怎么办呢?要解决这个问题,只要思考一个问题即可:在一亿个数中找一个数,和在一百个数中找一个数,哪个更快 ? 显然是后者。
应对数据量膨胀的有效之法即是数据分区。将一个大的数据集划分为多个小的数据集分别进行管理,叫做数据分区。数据分区是分治策略的一种形式。本文总结关于数据分区的基本知识以及实践。
基本知识
常用的数据分区方式有两种:范围分区和哈希分区。根据情况,还可以采用冷热分区、混合分区等。
范围分区
将整个数据集按照顺序编号每 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();
}
}
}
小结
分治是最为基本的计算机思想之一。而数据分区是应对海量数据处理的基本前提。常见数据分区有范围分区和哈希分区两种,根据情况选用。
分区是逻辑概念。分区往往会分布到多个机器节点上。数据分区要考虑数据均匀分布问题、分区大小及分区数。数据分区加冗余,构成了高可用分布式系统的基础。