封面画师:画师JW     封面ID:78362042

1. HBase 高级

1.1 架构原理

HBase详细架构图

HBase 依赖于 HDFS 与 Zookeeper。HBase 在启动时需要启动的两大进程——HMaster、HRegionServer。HMaster 管理一些 DDL 的操作,HRegionServer 则是管理一些 DML 的操作,同时 HMaster 也会对 HRegionServer 进行管理。HLog,即:预写日志,用于灾难恢复使用,在 Region Server 宕机后,可以从 log 中回滚还没有持久化的数据。一个 HRegionServer 上,可以有多个 Region;而一个表中,也可能存在多个 Region。一个 Region 中又有多个 Store(列族中可以有多个 Store),Store 的存储是隔离的,放在不同的文件夹。

一个列族对应多个 Store,那么这些 Store 一定不会在同一个 Region 中。一个 Region 里,不同的 Store 一定不是同一个列族的。(绕口令?)

(在 HBase 中,列相当于是数据的一部分,因为你在插入时指定了列,而建表时只指定了列族,而不是列。)

每个 Store 中包含与其对应的 MemStore 以及一个或多个 StoreFile(StoreFile 是实际数据存储文件 HFile 的轻量级封装,HFile 也是一种文件格式,HFile 是存在 DataNode 中的,但因为它是 HBase 的组成部分,因此将其归于 HBase 中)。MemStore 是在内存中的,保存了修改的数据,MemStore 中的数据写到文件中就是 StoreFile。HLog 会实时写入 DFS Client,用于数据恢复,同时 HFile 也会写入 DFS Client,然后经过 DFS Client 的作用,将数据储存在 HDFS 中的 DataNode。

Zookeeper 会分担 HMaster 的一部分工作,对客户端来说,将 表数据 的操作分担给 Zookeeper,注意,是表 数据,而不是表。

参考链接:详解HBase架构原理HBase原理深入解析(一)----HBase架构总览

1.2 写流程

参考链接:

有态度的HBase/Spark/BigData

HBase架构详解和数据的读写流程

HBase读写数据流程


HBase 是一个读比写慢的框架。

HBase写流程
  1. Client 先访问 Zookeeper,获取 hbase:meta 表位于哪个 Region Server。(meta 表中存储着表的位置信息,但并不是说对 A 表就只有一条信息,如果 A 数据太大而被划分成多个 Region,那么 meta 表中就会存储 A 表的多个信息,这些多个信息是以 RowKey 进行区分。)
  2. 访问对应的 Region Server,获取 hbase:meta 表,根据请求的 namespace:table/rowkey,查询出目标数据位于哪个 Region Server 中的那个 Region 中。并将该 table 的 region 信息以及 meta 表的位置信息缓存在客户端的 meta cache 中,方便下次访问;
  3. 与目标 Region Server 进行通讯;
  4. 将数据顺序写入(追加)到 WAL;
  5. 将数据写入对应的 Memstore,数据会在 Memstore 中进行排序;
  6. 向客户端发送 ack;
  7. 等达到 Memstore 的 刷写(Flush) 时机后,将数据写入 HFile。

我们可以说 HBase 是先将数据写入 WAL,在写入 Memstore,这种说法是一种不严谨的说法,但并不是错的,实际的操作顺序是:

  1. Hbase 做写操作时,先记录在本地的 WAL 中,但是不同步到 HDFS
  2. 把数据写入 Memstore
  3. 开始将 WAL 同步到 HDFS
  4. 最后如果 WAL 同步成功则结束,如果同步失败则回滚 Memstore

1.3 MemStore Flush

参考链接:

HBase Flush 解析

HBase参数配置及说明

MemStore Flush

不同的 store 位于不同的列族,因此经过 flush 后,在 HDFS 中也是位于不同的文件夹。

hbase.regionserver.global.memstore.size:

RegionServer 中有全局 MenStore 的大小,超过该大小就会触发 flush 到磁盘的操作,默认是堆大小的 40%,而且 RegionServer 级别的 Flush 会阻塞客户端读写。

hbase.regionserver.global.memstore.size.lower.limit:

在刷写时,有一个安全设置,有时候集群的「写负载」非常高,写入量一直超过了 flush 的量,这时,我们就希望 MemStore 不要超过一定的安全设置。在这种情况下,写操作将被一直阻塞到一个 MemStore 可被管理的大小。这个大小在默认情况下是 堆大小 * 0.4 * 0.95,即:堆大小的 38%。换句话说,也就是当 RegionServer 级别的 flush 操作(Flush 顺序是按照 Memstore 由大到小执行的)发生后,会阻塞客户端读写,一直阻塞到整个 RegionServer 级别的 MemStore 大小为堆大小的 38%。

hbase.regionserver.optionalcacheflushinterval:

内存中的文件在自动刷新之前能够存活的最长时间,默认是 1h。当内存中最后一条数据的存活时间达到 1h 时,将被 flush。

hbase.hregion.memstore.flush.size:

单个 Region 里单个 MemStore 的缓存大小,超过后那么整个 HRegion 就会被 flush,默认 128M。

1.4 读流程

HBase读流程

HBase 读数据时,并不是按照内存、缓存、磁盘的顺序进行读取的,而是 一起读取 的。

在实际情况中,我们在查询(读取)数据时,返回的是时间戳大的数据,那如果按照内存 MemStore、缓存 Block Cache、磁盘 StoreFile 的顺序进行假设读取呢?我们假定磁盘中有一条数据 A,其时间戳为 a,这时我们向 HBase 插入数据 B,并令时间戳为 b,且 a > b(AA、BB 是同一个单元格不同版本的数据)。由于 HBase 还没有将 B 数据 flush 到磁盘中,此时数据 B 存在内存中。如果按照内存、缓存、磁盘的顺序进行查询(读取),此时返回给我们的数据是 B,即:时间戳小的数据,这显然与我们的实际情况不符,也验证了 HBase 读数据时,不是按照内存、缓存、磁盘的顺序进行读取的。

HBase 在读取数据时,Block Cache 的使用情况:假设磁盘中保存了数据 AA,其时间戳为 aa,这时候我们查询数据 AA,AA 就会被写入 Block Cache。然后我们向 HBase 中插入数据 BB,其时间戳为 bb,显然:bb > aa,然后我们手动将数据 BB flush 到磁盘中(AA、BB 是同一个单元格不同版本的数据)。然后我们查询这个单元格,HBase 在进行数据读取时,会读取 Block Cache 中的数据 AA,磁盘中的数据 BB,而不会读取磁盘中的数据 AA(因为在 Block Cache 读取数据 AA,所以就不再磁盘中读取数据 AA 了)。

1.5 StoreFile Compaction

由于 MemStore 每次刷写都会生成一个新的 HFile,且同一字段的不同版本(TimeStamp)和不同类型(Put/Delete)有可能会分布在不同的 HFile 中,因此查询时需要遍历所有的 HFile。为了减少 HFile 的个数,以及清理掉过期和删除的数据,会进行 StoreFile Compaction。

Compaction 分为两种,分别是 Minor Compaction(小合并)Major Compaction(大合并)。MinorCompaction 会将临近的若干个较小的 HFile 合并成一个较大的 HFile,但 不会清理过期和删除的数据。Major Compaction 会将一个 Store 下的所有 HFile 合并成一个大的 HFile,并且 会清理掉过期和删除的数据

大合并和小合并都是在 Region 的内部进行的,并不涉及到 Region 之间的合并。merge_region 是指将两个 Region 合并为一个 Region。

StoreFile Compaction

在 HBase Shell 中也有关于 Compact 的命令:compact、compact_rs、major_compact、merge_region。

默认文件超过 3 个,执行 compact 触发大合并(作用等于 major_compact)。

为了保证数据的一致性,合并生成新文件后并不会立即删除旧文件,而是会等一会再删除。

HBase 合并 Shell 命令:hbase compact


hbase.hregion.majorcompaction:

一个 Region 进行 Major Compaction 合并的周期,在这个点的时候,这个 Region 下的所有 HFile 都会进行合并。默认时间是 7 天。这个操作十分消耗资源,在生产环境中我们常将其设置为 0 以关闭这个操作,而在应用空闲的时间手动触发。

同时还有一个抖动比例的设置,但在生产环境中我们都会将其关闭,因此不再叙述。

hbase.hregion.compationThreshold:

一个 Store 里面允许存的 HFile 的个数,超过这个个数会被写到新的一个 HFile 里面,也就是每个 Region 的每个列族对应的 MemStore 在 flush 为 HFile 的时候默认情况下达到 3 个 HFile 的时候(大于等于)就会对这些文件进行合并重写为一个新的文件,设置个数越大可以减少触发合并的时候,但是每次合并的时间就会越长。

1.6 读写拓展

我们可以 Kill 掉 HMaster 的端口,然后在一张表内进行插入数据、查询数据等操作,这时候并不会报错且操作成功;但如果我们创建一张表,就会显示「无法从 Zookeeper 中获取 Master」的错误,告诉我们这样的操作无法实现。我们可以得出:元数据的操作需要有 HMaster 的存在,而表内数据的操作并不需要。

通过读写的流程我们能看到,在读写时没有 HMaster 的参与,但是都有 Zookeeper 的参与。同时,在上一段中我们也说了 Master 的获取需要依赖 Zookeeper,根据这两条,我们在写客户端代码时,只需要 Zookeeper 的地址就可以了。

虽然表内数据的读写没有 HMaster 的参与,但并不是就能将 HMaster 关闭。长期在 HMaster 不存在的情况下进行读写会导致集群不健康。当 Region 需要切分时,需要依靠 Master 修改元数据,但由于没有 Master 的存在导致不能进行切分。就算能够进行切分,但切分成功后需要将切分后的 Region 调度到其他节点,调度时需要依靠 Master 而导致无法进行调度(正是因为文件太大才进行切分,结果切分了又无法调度到其他节点,那么这样的切分又有什么用呢?)。

1.7 数据真正删除的时间

  • Flush 时
  • 合并时

Flush 真正删除的数据:

同一个内存(MemStore)中,过时的数据、被删除标记标记上的数据。不能真正删除跨越了多个文件的数据,只能真正删除在同一内存。比如,我在磁盘中存有一条张三的信息,这时候我将内存中的李四(张三与李四同表、同列族、同列,仅时间戳存在差异)Flush 到磁盘,磁盘中张三的数据并不会被真正删除。

Compact(major)真正删除的数据:

即:大合并时真正删除的数据。大合并是将磁盘中的多个文件写入内存,然后再 rewrite 回磁盘合并成一个文件,在这种情况下,上述列举的张三、李四就会被真正删除一个(相当于它俩在内存中见面了,所以会被真正删除一个)。


DeleteColumn 标记真正删除的时机:不会在 Flush 时删除,会在大合并时删除。 为什么?

我们假设在一单元格内有一数据 A,时间戳为 a,然后我们将其 Flush(刷洗)进硬盘。这时候,我们在向这单元格插入数据 B,时间戳为 b,显然:b > a。这时候我们对内存中的数据 B 进行 deleteall 操作,然后会产生一个 DeleteColumn 标记。如果我们进行版本查询,我们依然能够查看到数据 B,也能看到 DeleteColumn 标记。

然后我们对这个表进行 flush(合并内存中的数据),再进行版本查询,发现数据 B 已经不见,而 DeleteColumn 标记依燃存在。最后我们对表进行大合并,发现 DeleteColumn 标记和数据 A 都被真正删除。这证明了我们的观点,那么 HBase 为什么要这么做呢?

最开始我们已将数据 A 放进磁盘,然后我们在同一单元格插入了数据 B,这时候按照我们的逻辑,数据 A 应该被真正删除,但是按照 HBase 的逻辑,数据 A 并不会被真正删除,数据 A 依然存在磁盘当中,只是 HBase 返回了时间戳大的数据 B,给用户的感觉好像是已经真正删除了数据 A。再然后,我们删除(不是真正的删除,只是执行了 delete 操作)了数据 B(由于数据 B 至今未被 Flush,因此它还在内存中)并对表进行 Flush。由于我们删除了数据 B,按照我们的逻辑,因为数据 A 被数据 B 覆盖,数据 B 又被我们删除,用户应该查询不到这个单元格的数据,而真实情况也是这样。

但如果 DeleteColumn 标记在 FLush 时被删除,用户在查询表时就会看到数据 A,这显然是不符合常情的。因此我们需要在进行大合并时删除 DeleteColumn 标记,因为只有大合并时会将磁盘中的数据写会磁盘,然后进行比较,真正删除数据 A 与 DeleteColumn 标记。


如果我们设置了多个版本,在进行真正删除的时候,会保留最大的几个版本。

1.8 Split 流程

默认情况下,每个表起初都只有一个 Region,随着数据的不断写入,Region 会自动进行拆分(按 RowKey 进行拆分)。刚拆分时,两个子 Region 都位于当前的 Region Server,但处于负载均衡的考虑,HMaster 有可能有可能会将某个 Region 转移给其他的 Region Server。

Split 时机

  1. 当一个 Region 中的某个 Store 下所有 StoreFile 的总大小超过 hbase.hregion.max.filesize 时,该 Region 就会进行拆分(HBase 0.94 之前的版本)。

    hbase.hregion.max.filesize:HStoreFile 最大的大小,当某个 region 的某个列族超过这个大小(默认 10G)时会进行 Region 拆分。

  2. 当一个 Region 中的某个 Store 下所有 StoreFile 的总大小超过 Min(R2 * “hbase.hregion.memstore.flush.size”, “hbase.hregion.max.filesize”)时,该 Region 就会进行拆分,其中 R 为当前 Region Server 中属于该 Table 的 Region 个数(HBase 0.94 之后的版本)。

这样的自动切分会产生 HBase 的数据热点问题,因此我们在生产环境中建表时会进行预分区。

Split自动切分流程


官方建议在建表时不使用多个列族,原因:

如果我们使用了多个列族(假设有列族 i1,i2,i3),然后在插入数据时,向 i1 中 put 了大量的数据,而 i2,i3 中仅仅只有几个数据。这时如果触发了全局的 Flush 时,由于 i2,i3 中也有几条数据而也会形成一个文件(这是一个小 Store),但是 i1 会形成一个较大的文件(大 Store)。如果列族过多,且发生了这种情况,那么就会出现多个小文件,为了避免这种情况的发生,官方做出了这样的建议。

但是我们在实际生产时,也可以创建多个列族,但是要尽量保证每个列族中的数据量同步增长。

2. API 操作

2.1 常用 API

导入依赖

1
2
3
4
5
<dependency>
    <groupId>org.apache.hbase</groupId>
    <artifactId>hbase-client</artifactId>
    <version>2.2.2</version>
</dependency>

创建连接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class HBaseApi {

    private static Connection connection = null;
    private static Admin admin = null;

    static {
        try {
            //获取配置信息
            Configuration configuration = HBaseConfiguration.create();
            // 第二个参数为HBase服务器的ip地址,我在此处修改了开发机的host文件
            configuration.set("hbase.zookeeper.quorum", "hbase");
            //创建连接对象
            connection = ConnectionFactory.createConnection(configuration);
            //创建Admin对象
            admin = connection.getAdmin();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

如果引入 Spring,我们通常编写一个配置类,然后将连接信息注入 Spring 中。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class HBaseUtil {
    private static Connection connection = null;
    private static Admin admin = null;

    public  HBaseUtil(Configuration conf) {
        try {
            connection = ConnectionFactory.createConnection(conf);
            admin = connection.getAdmin();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

@Configuration
public class HBaseConfig {
    @Bean
    public HBaseUtil getHbaseService() {
        org.apache.hadoop.conf.Configuration conf = HBaseConfiguration.create();
        conf.set("hbase.zookeeper.quorum", "hbase");
        return new HBaseUtil(conf);
    }
}

关闭连接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 关闭Admin和Connection
public static void close() {
    if (admin != null) {
        try {
            admin.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    if (connection != null) {
        try {
            connection.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

判断表是否存在

1
2
3
4
5
6
7
8
9
/**
* 判断表是否存在
* @param tableName
* @return
* @throws IOException
*/
public static boolean isTableExist(String tableName) throws IOException {
    return admin.tableExists(TableName.valueOf(tableName));
}

创建表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
 * 创建表
 * @param tableName 表名
 * @param cfs 列族名
 * @throws IOException
 */
public static void createTable(String tableName, String... cfs) throws IOException {
    // 判断是否存在列族信息
    if (cfs.length <= 0) {
        System.out.println("请设置列族信息");
        return;
    }
    // 判断表是否存在
    if (isTableExist(tableName)) {
        System.out.println(tableName + "表已存在");
        return;
    }
    // 创建表描述器
    HTableDescriptor hTableDescriptor = new HTableDescriptor(TableName.valueOf(tableName));
    //添加列族信息
    for (String cf : cfs) {
        //创建列族描述器
        HColumnDescriptor hColumnDescriptor = new HColumnDescriptor(cf);
        //添加具体的列族信息
        hTableDescriptor.addFamily(hColumnDescriptor);
    }
    //创建表
    admin.createTable(hTableDescriptor);
}

删除表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
 * 删除表
 * @param tableName 表名
 * @throws IOException
 */
public static void dropTable(String tableName) throws IOException {
    // 判断表是否存在
    if (!isTableExist(tableName)) {
        System.out.println(tableName + "表不存在!");
        return;
    }
    // 使表处于不可用状态
    admin.disableTable(TableName.valueOf(tableName));
    // 删除表
    admin.deleteTable(TableName.valueOf(tableName));
}

创建命名空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
 * 创建命名空间
 * @param ns namespace 命名空间
 */
public static void createNameSpace(String ns) {
    //创建命名空间描述器
    NamespaceDescriptor namespaceDescriptor = NamespaceDescriptor.create(ns).build();
    // 创建命名空间
    try {
        admin.createNamespace(namespaceDescriptor);
    } catch (NamespaceExistException e) {
        System.out.println(ns + "命名空间已存在!");
    } catch (IOException e) {
        e.printStackTrace();
    }
    //System.out.println("虽然命名空间存在,但还是能够运行到这~");
}

插入值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
 * 向表插入数据
 * @param tableName 表名
 * @param rowKey 行键
 * @param cf 列族
 * @param cn 列名
 * @param value
 * @throws IOException
 */
public static void putData(String tableName, String rowKey,
                           String cf, String cn, String value) throws IOException {
    if (isTableExist(tableName)) {
        // 获取表对象
        Table table = connection.getTable(TableName.valueOf(tableName));
        //创建Put对象
        Put put = new Put(Bytes.toBytes(rowKey));
        // 给Put对象赋值
        put.addColumn(Bytes.toBytes(cf), Bytes.toBytes(cn), Bytes.toBytes(value));
        // 插入数据
        table.put(put);
        table.close();
    } else {
        System.out.println(tableName + "表不存在!");
    }
}

使用 get 方式获取数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
 * 获取数据(get)
 * @param tableName 表名
 * @param rowKey 行键
 * @param cf 列族
 * @param cn 列名
 * @return
 * @throws IOException
 */
public static Result getData(String tableName, String rowKey, String cf, String cn) throws IOException {

    // 获取表对象
    Table table = connection.getTable(TableName.valueOf(tableName));
    //创建get对象
    Get get = new Get(Bytes.toBytes(rowKey));
    //指定获取的列族
    //get.addFamily(Bytes.toBytes(cf));
    // 指定列族中的列
    get.addColumn(Bytes.toBytes(cf), Bytes.toBytes(cn));
    //设置获取数据的版本数
    get.setMaxVersions(5);
    //获取数据
    Result result = table.get(get);
    //解析Result
    /*for (Cell cell : result.rawCells()) {
        //打印数据
        System.out.println("CF:" + Bytes.toString(CellUtil.cloneFamily(cell)) +
                ",CN:" + Bytes.toString(CellUtil.cloneQualifier(cell)) +
                ",Value:" + Bytes.toString(CellUtil.cloneValue(cell)));
    }*/
    //关闭表连接
    table.close();
    return result;
}

使用 scan 获取数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
     * 获取数据(scan)
     * @param tableName 表名
     * @param filterList 过滤器
     * @param isPrint 是否打印
     * @return
     * @throws IOException
     */
    public static ResultScanner scanTable(String tableName, FilterList filterList, boolean isPrint) throws IOException {
        //获取表对象
        Table table = connection.getTable(TableName.valueOf(tableName));
        //构建Scan对象 Scan范围扫描,左闭右开
//        Scan scan = new Scan(Bytes.toBytes("1001"), Bytes.toBytes("1003"));
        Scan scan = new Scan();
        scan.setFilter(filterList);
        //扫描表
        ResultScanner resultScanner = table.getScanner(scan);
        if (isPrint){
            //解析ResultScanner
            for (Result result : resultScanner) {
                //解析result并打印
                for (Cell cell : result.rawCells()) {
                    System.out.println("RK:" + Bytes.toString(CellUtil.cloneRow(cell)) +
                            ",CF:" + Bytes.toString(CellUtil.cloneFamily(cell)) +
                            ",CN:" + Bytes.toString(CellUtil.cloneQualifier(cell)) +
                            ",Value:" + Bytes.toString(CellUtil.cloneValue(cell)));
                }
            }
        }
        table.close();
        return resultScanner;
    }

删除数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
 * 删除数据
 * @param tableName 表名
 * @param rowKey 行键
 * @param cf 列族
 * @param cn 列名
 * @throws IOException
 */
public static void deleteData(String tableName, String rowKey, String cf, String cn) throws IOException {
    // 获取表对象
    Table table = connection.getTable(TableName.valueOf(tableName));
    //构建删除对象
    Delete delete = new Delete(Bytes.toBytes(rowKey));
    //设置删除的列
    // 注意 addColumn(慎用)与addColumns的区别
    /**
     *  addColumn中指定时间戳,只会删除指定时间戳的数据
     *
     * */
    //delete.addColumns(Bytes.toBytes(cf), Bytes.toBytes(cn));
    //设置删除指定的列族
    //delete.addFamily(Bytes.toBytes(cf));
    //执行删除操作
    table.delete(delete);
    table.close();
}

2.2 过滤器

参考链接:【HBase】Java实现过滤器查询

2.3 删除表中数据

指定 RowKey 进行删除: 使用 DeleteFamily 标记,删除所有版本。如果传入了时间戳,就会删除小于等于指定时间戳的所有数据。

指定 RowKey 与列族: 使用 DeleteFamily 标记,删除所有版本。如果传入了时间戳,就会删除小于等于指定时间戳的所有数据。

指定 RowKey、列族与列:

  • 使用 addColumn 方法(慎用):使用 Delete 标记,删除单个版本。如果不传入时间戳,则删除最大时间戳的数据;如果传入时间戳,只会删除指定时间戳的数据。在不同时间段(数据 Flush 前后)删除数据后,查询数据得到的结果不同
  • 使用 addColumns 方法:使用 DeleteColumn 标记。如果传入了时间戳,就会删除小于等于指定时间戳的所有数据