写点什么

Hbase 内核剖析

用户头像
永健_何
关注
发布于: 2021 年 01 月 25 日
Hbase内核剖析

前言:

这是我第一次在 InfoQ 这个平台写文章,由于 InfoQ 这个平台的文章质量都比较高,希望这次自己也能贡献一篇合格的文章。这里就和各位聊一聊分布式数据库 hbase,hbase 是 hadoop 生态圈最常用也是使用最广泛的 Nosql 数据库,本文将对 hbase 的内核展开分析,一起探讨一下 hbase 的底层实现原理,这里先放一张 hbase 的架构图:


hbase 架构图

Hbase 基本介绍:

这里首先说一下 hbase 的几个明显的优点:

  1. 良好的可扩展性

因为 hbase 的架构底层的存储是 hdfs 分布式文件系统,所以天生就具有了分布式特点,如下图所示,另自己的内部架构也是支持 regionserver 级别的横向扩展的,正是良好的扩展性让 hbase 非常适合海量数据的存储。


hbase 与 hdfs 关系示意图


  1. 列式存储

对于 RDBMS 关系型数据库来而言,底层的存储结构是 Row-Store 行式存储,而 hbase 采用的是 Column-Store 列式存储,这里稍微提一下行存和列存的区别,如下图所示:行式存储下一张表的数据都是放在一起的,即一行记录是一条完整的信息,但列式存储下都被分开保存了,一列数据仅代表一条记录的部分信息,可以明显看出使用列式存储相对于个别列查询操作时,能够有效减少 IO,当然缺点就是读取完整记录或者进行更新操作时,比较繁琐。

行存储和列存储示意图


具体行存储和列存储的区别可以参考这篇文章《Column-Stores vs. Row-Stores: How Different Are They Really?》或者网上大牛的博客,这方面文章蛮多的,讲的会比我清晰很多,就不作展开。

Hbase 采用列式存储主要原因有两个:

1)在 OLAP 的场景下,如果还是使用行式存储,会导致大量的无谓的遍历,比如想要对某个列进行所有数据的统计,因为是行式存储,需要遍历所有的实体的所有的属性;如果列式存储,则只需要按照列进行查询即可,因为列式存储是以列一个物理存储单元,所以遍历只要遍历相应列的物理存储文件即可;

2)另一个就是可以方便的进行编码和压缩,因为一列中的值大概率是有大量重复的,可以对于这些重复的值进行编码以及压缩,节省存储空间。


  1. 高吞吐,高可用

hbase 的高可用特点可以从 hbase 架构图中看到,主要基于两个大数据组件实现:1)hdfs 分布式文件系统保证了 hbase 底层数据高可用性;2)zookeeper 分布式协同服务保证了 hbase 服务的高可用性。当然 hbase 也有明显的缺点:

  1. 不支持事物操作,即无法像 RDBMS 那样支持 sql 来完成一系列的事物操作;

  2. 不支持二级索引,hbase 只支持 rowkey 查询,不支持列属性的查询方式,导致查询效率明显弱于 RDBMS 数据库;

目前,这两个问题已经有很好的解决方案了,对于 sql 操作而言,可以通过开源的 sql 引擎实现,譬如:phoenix,hive,sparksql,calcite 等等;对于二级索引可以依赖 es 实现,JanusGraph 图数据库的底层存储就是 hbase+es,hbase 用来存储具体的图数据,而 es 给 hbase 中的点边创建二级索引,来加快了检索效率。


  • 小结:上面稍微对 hbase 的几个特点进行了简要的描述,这些特点让 hbase 成为大数据存储引擎的不二法宝,但是 hbase 是如何在海量数据存储中崭露头角呢,下面将从几个方面展开描述。

HBase 内核剖析



为什么 hbase 能够适用于海量数据的存储,这里将它的底层原理来进行分析,hbase 能给完成海量数据处理主要依赖以下几个点:LSM(Log-Structured-Merge-Tree)树SkipList 跳表Bloom Filter 布隆过滤器BlockCache 块缓存

LSM 树

LSM-tree 并不属于一个具体的数据结构,它更多是一种数据结构的设计思想。它起源于 1996 年的一篇论文《The Log-Structured Merge-Tree (LSM-Tree)》,没错这个论在 1996 年就提出了,而目前在 NoSQL 领域已经广泛使用,基本已经成为必选方案了。从英文全称可以看到有 Log-Structured 日志结构和 Merge-Tree 合并树两个单词,而这两个单词的实现就是 LSM 树的关键。

LSM-tree 最大的特点就是写入速度快,主要利用了磁盘的顺序写,这点与 RDBMS 有着显著不同,正是这个原因致使 hbase 的写入速度明显优于关系型数据库,但就像孟子《鱼我所欲也》中那句话“鱼和熊掌不可兼得”,LSM-tree 是在牺牲了读取速度换来了高效的写入速度,这里就与关系型数据库的 b+树稍作对比,来了解 LSM-tree 为啥写入快,读取慢。

首先,从磁盘的读写原理来分析,LSM 树采用的是顺序读写,b+树采用的是随机读写,二者的区别是:随机读写以理解为数据被存储到磁盘不同的区域,那就是逻辑上相离很近但物理却可能相隔很远,进行读取的时候,由于存储空间不是连续的,所以需要多次寻道和旋转延迟,主要时间耗费在寻道和磁盘旋转这块,而顺序读写主要时间则花费在了传输时间。

对于 LSM 树的写入流程如下:



lsm 树的写入流程

1)首先写入到内存中(内存没有寻道速度的问题,随机写的性能得到大幅提升),在内存中构建一颗有序小树,随着小树越来越大,达到一定大小时,批量 flush 到磁盘上这样大大的减少了磁盘 I/O。在 hbase 的写入过程中,数据首先写入到内存的 MemStore,当达到默认值 128M 时,就会触发 flush 操作,将这批数据进行落盘持久化,当然这个过程可能会涉及到 split 和 compaction 操作(hbase 的一个自身优化操作),具体这里不作展开;

2)同时为了防止数据在内存丢失,会先写到一个 log 文件中,确保数据不会丢失,而这个 log 文件采用的写入方式是 append 形式,即把最新记录直接写到记录文件的末尾,所以也非常快。在 hbase 中,这个日志叫 WAL(Write Ahead Log)。因此,可以理解 LSM 树使用日志文件和一个内存存储结构把随机写操作转换为顺序写,充分利用一次 I/O。

3)随着小树越来越多,读的性能会越来越差,因此需要在适当的时候,对磁盘中的小树进行 merge,多棵小树变成一颗大树,在 hbase 中,这个操作称为 compact(minor compact 和 major compact)


  • 小结:通过以上步骤可以深入体会 LSM 树的精髓:Log-Structured 和 Merge-Tree 这两个单词,也不得不佩服 1996 年就提出这么前沿的思想,当时可还没有大数据这个概念!可能大家也看到这个结构除了读取性能上,还有一个很大的缺点就是:由于树的合并,导致写被放大。

SkipList 跳表

跳表全称为跳跃列表,它允许快速查询,插入和删除一个有序连续元素的数据链表。跳跃列表的平均查找和插入时间复杂度都是 O(logn)。它的查询速度很高,主要是通过维护一个多层次的链表,且每一层链表中的元素是前一层链表元素的子集,也可以理解上层是下层 1/2 元素的索引。在查询的时候从最上层的稀疏层开始,然后采用一种类似“二分”的算法,确定查找数据的范围,再像下查找,直到找到所需元素,典型的空间换时间的思想。



可以看出,跳跃表是按照层次构造的,最底层是一个全量有序链表,依次向上都是它的精简版,而在上层链表中节点的后续下一步的节点是以随机化的方式进行的,因此在上层链表中可以跳过部分列表,因此称谓之为跳表。很多大数据组件的内部查询逻辑都用到了这个数据结构,主要原因就是查询效率高,且实现复杂度低于红黑树。

在 hbase 源码中,很多处代码使用了 ConcurrentSkipListMap,下面列举几个类

1.CachedBlocksByFile

public static class CachedBlocksByFile {    private int count;    private int dataBlockCount;    private long size;    private long dataSize;    private final long now = System.nanoTime();    /**     * How many blocks to look at before we give up.     * There could be many millions of blocks. We don't want the     * ui to freeze while we run through 1B blocks... users will     * think hbase dead. UI displays warning in red when stats     * are incomplete.     */    private final int max;    public static final int DEFAULT_MAX = 1000000;
CachedBlocksByFile() { this(null); }
CachedBlocksByFile(final Configuration c) { this.max = c == null? DEFAULT_MAX: c.getInt("hbase.ui.blockcache.by.file.max", DEFAULT_MAX); }
/** * Map by filename. use concurent utils because we want our Map and contained blocks sorted. */ private transient NavigableMap<String, NavigableSet<CachedBlock>> cachedBlockByFile = new ConcurrentSkipListMap<>();
复制代码

2.PrefetchExecutor

public final class PrefetchExecutor {
private static final Logger LOG = LoggerFactory.getLogger(PrefetchExecutor.class);
/** Futures for tracking block prefetch activity */ private static final Map<Path,Future<?>> prefetchFutures = new ConcurrentSkipListMap<>(); /** Executor pool shared among all HFiles for block prefetch */ private static final ScheduledExecutorService prefetchExecutorPool; /** Delay before beginning prefetch */ private static final int prefetchDelayMillis; /** Variation in prefetch delay times, to mitigate stampedes */ private static final float prefetchDelayVariation;
复制代码

3.ServerManager

public class ServerManager {  public static final String WAIT_ON_REGIONSERVERS_MAXTOSTART =      "hbase.master.wait.on.regionservers.maxtostart";
public static final String WAIT_ON_REGIONSERVERS_MINTOSTART = "hbase.master.wait.on.regionservers.mintostart";
public static final String WAIT_ON_REGIONSERVERS_TIMEOUT = "hbase.master.wait.on.regionservers.timeout";
public static final String WAIT_ON_REGIONSERVERS_INTERVAL = "hbase.master.wait.on.regionservers.interval";
/** * see HBASE-20727 * if set to true, flushedSequenceIdByRegion and storeFlushedSequenceIdsByRegion * will be persisted to HDFS and loaded when master restart to speed up log split */ public static final String PERSIST_FLUSHEDSEQUENCEID = "hbase.master.persist.flushedsequenceid.enabled";
public static final boolean PERSIST_FLUSHEDSEQUENCEID_DEFAULT = true;
public static final String FLUSHEDSEQUENCEID_FLUSHER_INTERVAL = "hbase.master.flushedsequenceid.flusher.interval";
public static final int FLUSHEDSEQUENCEID_FLUSHER_INTERVAL_DEFAULT = 3 * 60 * 60 * 1000; // 3 hours
public static final String MAX_CLOCK_SKEW_MS = "hbase.master.maxclockskew";
private static final Logger LOG = LoggerFactory.getLogger(ServerManager.class);
// Set if we are to shutdown the cluster. private AtomicBoolean clusterShutdown = new AtomicBoolean(false);
/** * The last flushed sequence id for a region. */ private final ConcurrentNavigableMap<byte[], Long> flushedSequenceIdByRegion = new ConcurrentSkipListMap<>(Bytes.BYTES_COMPARATOR);
复制代码

4.RegionStates

public class RegionStates {  private static final Logger LOG = LoggerFactory.getLogger(RegionStates.class);
// This comparator sorts the RegionStates by time stamp then Region name. // Comparing by timestamp alone can lead us to discard different RegionStates that happen // to share a timestamp. private static class RegionStateStampComparator implements Comparator<RegionState> { @Override public int compare(final RegionState l, final RegionState r) { int stampCmp = Long.compare(l.getStamp(), r.getStamp()); return stampCmp != 0 ? stampCmp : RegionInfo.COMPARATOR.compare(l.getRegion(), r.getRegion()); } }
public final static RegionStateStampComparator REGION_STATE_STAMP_COMPARATOR = new RegionStateStampComparator();
private final Object regionsMapLock = new Object();
// TODO: Replace the ConcurrentSkipListMaps /** * RegionName -- i.e. RegionInfo.getRegionName() -- as bytes to {@link RegionStateNode} */ private final ConcurrentSkipListMap<byte[], RegionStateNode> regionsMap = new ConcurrentSkipListMap<>(Bytes.BYTES_COMPARATOR);
复制代码

5.RAMCache

static class RAMCache {    /**     * Defined the map as {@link ConcurrentHashMap} explicitly here, because in     * {@link RAMCache#get(BlockCacheKey)} and     * {@link RAMCache#putIfAbsent(BlockCacheKey, BucketCache.RAMQueueEntry)} , we need to     * guarantee the atomicity of map#computeIfPresent(key, func) and map#putIfAbsent(key, func).     * Besides, the func method can execute exactly once only when the key is present(or absent)     * and under the lock context. Otherwise, the reference count of block will be messed up.     * Notice that the {@link java.util.concurrent.ConcurrentSkipListMap} can not guarantee that.     */    final ConcurrentHashMap<BlockCacheKey, RAMQueueEntry> delegate = new ConcurrentHashMap<>();
复制代码

其他还有很多类同样用到了这个集合,可见 SkipList 跳表这个数据结构的强大之处,而上述代码中的 ConcurrentSkipListMap 具体操作这里就不作展开,我也没有进一步的学习研究,感兴趣的同学可以下载 hbase-master 源码进行学习,我的版本是 hbase-2.4.0,对应的源码下载链接:https://mirrors.tuna.tsinghua.edu.cn/apache/hbase/2.4.0/hbase-2.4.0-src.tar.gz


  • 小结:SkipList 跳表广泛使用于 KV 数据库中,Hbase 同样在代码中使用,并借此来实现高效的插入、删除、查找等操作。

Bloom Filter 布隆过滤器

在一般场景下,判断一个元素是否在集合里面,最简单的方法就是遍历一遍,就可以得知答案,但是海量数据的场景下,百万亿或千万亿的数据的全部遍历,显然是不允许的,而 bloom 过滤器就能很好的解决这个问题。 bloom 算法类似一个 hash set,可以用来判断一个元素是否在一个集合中,优点就是查询效率非常高,缺点就是存在误判。

基本思想就是,把集合中的每一个值按照提供的 hash 算法算出对应的 hash 值,由于 hash 冲突的问题不可避免,所以建议一个元素通过多个 hash 算法来计算,降低误判率,然后将 Hash 值对数组长度取模后得到需要计入数组的索引值,并且将数组这个位置的值从 0 改成 1。

在判断一个元素是否存在于这个集合中时,你只需要将这个元素按照相同的算法计算出索引值,如果这个位置的值为 0,则该元素一定不存在集合中,如果为 1,则代表这个数有可能存在集合中,记住是有可能(hash 冲突的原因)。



上图中,F 的值计算为 0,则可以认为 F 元素不在该集合中,而 A,B,C,D 值为 1,则有可能存在集合中。接下来看看 hbase 是如何使用 bloom 过滤器的。

hbase 中数据存储形式最终以一个个 hfile 进行存储,hfile 中是一个列族的部分数据。当进行 get 查找时,hbase 会通过 blockindex 块索引机制,即通过块索引来确定哪些块可能包含这个 rowkey,regionServer 需要加载每一个块来检查该块中是否真的包含该行的单元格,由于块索引的覆盖范围太大,就会导致加载很多文件,为了减少不必要的文件加载和 I/O 操作,可以通过 bloom 过滤器进行优化,增大 hbase 的吞吐率。因此,hbase 会在生成 StoreFile(Hfile)时包含一份布隆过滤器结构的数据,称其为 MetaBlock;MetaBlock 与 DataBlock(真实的 KeyValue 数据)一起由 LRUBlockCache 维护。



开启 bloomfilter 后,hbase 的 get 数据流程就会先从布隆过滤器中查找,根据布隆过滤器的查询结果再会有一定的存储及内存 cache 开销,但是能够带来查询性能的提高。hbase 中 bloomfilter 接口代码如下,其中核心方法就是 contains 方法


public interface BloomFilter extends BloomFilterBase {
/** * Check if the specified key is contained in the bloom filter. * @param keyCell the key to check for the existence of * @param bloom bloom filter data to search. This can be null if auto-loading * is supported. * @param type The type of Bloom ROW/ ROW_COL * @return true if matched by bloom, false if not */ boolean contains(Cell keyCell, ByteBuff bloom, BloomType type);
/** * Check if the specified key is contained in the bloom filter. * @param buf data to check for existence of * @param offset offset into the data * @param length length of the data * @param bloom bloom filter data to search. This can be null if auto-loading * is supported. * @return true if matched by bloom, false if not */ boolean contains(byte[] buf, int offset, int length, ByteBuff bloom);
/** * @return true if this Bloom filter can automatically load its data * and thus allows a null byte buffer to be passed to contains() */ boolean supportsAutoLoading();}
复制代码

CompoundBloomFilter 为对应的实现类:

public class CompoundBloomFilter extends CompoundBloomFilterBase    implements BloomFilter {
/** Used to load chunks on demand */ private HFile.Reader reader;
private HFileBlockIndex.BlockIndexReader index;
private int hashCount; private Hash hash;
private long[] numQueriesPerChunk; private long[] numPositivesPerChunk;
/** * De-serialization for compound Bloom filter metadata. Must be consistent * with what {@link CompoundBloomFilterWriter} does. * * @param meta serialized Bloom filter metadata without any magic blocks * @throws IOException */ public CompoundBloomFilter(DataInput meta, HFile.Reader reader) throws IOException { this.reader = reader;
totalByteSize = meta.readLong(); hashCount = meta.readInt(); hashType = meta.readInt(); totalKeyCount = meta.readLong(); totalMaxKeys = meta.readLong(); numChunks = meta.readInt(); byte[] comparatorClassName = Bytes.readByteArray(meta); // The writer would have return 0 as the vint length for the case of // Bytes.BYTES_RAWCOMPARATOR. In such cases do not initialize comparator, it can be // null if (comparatorClassName.length != 0) { comparator = FixedFileTrailer.createComparator(Bytes.toString(comparatorClassName)); }
hash = Hash.getInstance(hashType); if (hash == null) { throw new IllegalArgumentException("Invalid hash type: " + hashType); } // We will pass null for ROW block if(comparator == null) { index = new HFileBlockIndex.ByteArrayKeyBlockIndexReader(1); } else { index = new HFileBlockIndex.CellBasedKeyBlockIndexReader(comparator, 1); } index.readRootIndex(meta, numChunks); }
@Override public boolean contains(byte[] key, int keyOffset, int keyLength, ByteBuff bloom) { int block = index.rootBlockContainingKey(key, keyOffset, keyLength); if (block < 0) { return false; // This key is not in the file. } boolean result; HFileBlock bloomBlock = getBloomBlock(block); try { ByteBuff bloomBuf = bloomBlock.getBufferReadOnly(); result = BloomFilterUtil.contains(key, keyOffset, keyLength, bloomBuf, bloomBlock.headerSize(), bloomBlock.getUncompressedSizeWithoutHeader(), hash, hashCount); } finally { // After the use, should release the block to deallocate byte buffers. bloomBlock.release(); } if (numPositivesPerChunk != null && result) { // Update statistics. Only used in unit tests. ++numPositivesPerChunk[block]; } return result; }
private HFileBlock getBloomBlock(int block) { HFileBlock bloomBlock; try { // We cache the block and use a positional read. bloomBlock = reader.readBlock(index.getRootBlockOffset(block), index.getRootBlockDataSize(block), true, true, false, true, BlockType.BLOOM_CHUNK, null); } catch (IOException ex) { // The Bloom filter is broken, turn it off. throw new IllegalArgumentException("Failed to load Bloom block", ex); }
if (numQueriesPerChunk != null) { // Update statistics. Only used in unit tests. ++numQueriesPerChunk[block]; } return bloomBlock; }
@Override public boolean contains(Cell keyCell, ByteBuff bloom, BloomType type) { int block = index.rootBlockContainingKey(keyCell); if (block < 0) { return false; // This key is not in the file. } boolean result; HFileBlock bloomBlock = getBloomBlock(block); try { ByteBuff bloomBuf = bloomBlock.getBufferReadOnly(); result = BloomFilterUtil.contains(keyCell, bloomBuf, bloomBlock.headerSize(), bloomBlock.getUncompressedSizeWithoutHeader(), hash, hashCount, type); } finally { // After the use, should release the block to deallocate the byte buffers. bloomBlock.release(); } if (numPositivesPerChunk != null && result) { // Update statistics. Only used in unit tests. ++numPositivesPerChunk[block]; } return result; }
复制代码

Hbase 支持的 bloom 过滤器的类型:

public enum BloomType {  /**   * Bloomfilters disabled   */  NONE,  /**   * Bloom enabled with Table row as Key   */  ROW,  /**   * Bloom enabled with Table row &amp; column (family+qualifier) as Key   */  ROWCOL,  /**   * Bloom enabled with Table row prefix as Key, specify the length of the prefix   */  ROWPREFIX_FIXED_LENGTH}
复制代码


  • 小结:hbase 根据 rowkey 进行查询的时候,一般情况下就会将包含该 rowkey 的 hfile 全部加载,但这种粗粒度的查询方式就会加载了很多不必要文件数据,浪费 I/O。因此,hbase 为了减少不必要 hfile 文件的加载,引入了 bloomfilter,即采用特定的算法获取数据的 key 的值,通过“0”或“1”,快速反馈数据不存在于该文件或可能存在该文件中,大大减少了“不存在“情况下的文件加载操作,进而加大吞吐率。

BlockCache 块缓存

在上述 bloom 过滤器的介绍中,提及到 LRUBlockCache 这个词,没错这个就是缓存,在加快数据处理过程,缓存是一个不可缺少的利器。hbase 的缓存主要包括两部分,一个是 MemStore 用于提高 hbase 的写入性能,即写缓存,另一个就是 BlockCache 用于提高 hbase 的读取性能,即读缓存。BlockCache 与 MemStore 最大的不同之处就是,一个 regionserver 对应一个 BlockCache 和多个 MemStore(一个 Store 一个 MemStore)。在 hbase 中 BlockCache 的实现方式有两种,一种实现方式是 on-heap 堆内内存模式,另一种实现方式是 off-heap 堆外内存模式。默认采用的是前者,实现是 LRUBlockCache。

  • LRUBlockCache

核心就是页面淘汰算法,在 hbase 中包括三种优先级,以适应于于:scan-resistance 以及 in-memory ColumnFamilies 场景:

  1. Single Access 优先级:当一个数据块第一次从 HDFS 读取时,它会具有这种优先级,并且在缓存空间需要被回收(置换)时,它属于优先被考虑范围内。它的优点在于:一般被扫描(scanned)读取的数据块,相较于之后会被用到的数据块,更应该被优先清除

  2. Multi Access 优先级:如果一个数据块,属于 Single Access 优先级,但是之后被再次访问,则它会升级为 Multi Access 优先级。在缓存里的内容需要被清除(置换)时,这部分内容属于次要被考虑的范围

  3. In-memory Access 优先级:如果数据块族被配置为“in-memory”,则会具有这种优先级,并且与它被访问的次数无关。HBase Catalog 便是被配置的这个优先级。

  • BucketCache

hbase 提供 BucketCache 是将数据存在堆外内存(off-heap),因此这块数据就不受 JVM 内存管理,能够有效减少 hbase 由于 cms gc 算法产生的内存碎片而触发 full gc 的问题。 BucketCache 通过配置可以工作在三种模

式下:

  • heap: 这些 Bucket 是从 JVM Heap 中申请;

  • offheap: 使用 DirectByteBuffer 技术实现堆外内存存储管理

  • file: 使用类似 SSD 的高速缓存文件存储数据块。

无论哪种模式, BucketCache 都会申请许多带有固定大小标签的 Bucket。可在 hbase-site.xml 文件中配置。

具体 LRUBlockCache 和 BucketCache 相关细节可在 hbase 源码中可见,这块本人没有深入研究学习,感兴趣的朋友可以阅读 LruBlockCache 和 BucketCache 两个类源码:

//LRUBlockCachepublic class LruBlockCache implements FirstLevelBlockCache{...}//BucketCachepublic class BucketCache implements BlockCache, HeapSize{...}
复制代码


  • 小结:hbase 与其他数据库一样,同样采用了缓存机制来提高查询效率。在读缓存方面,hbase 提供了 on-heap 和 off-heap 的两种缓存配置方式,hbase 支持 HBase 将 BucketCache 和 LRUBlockCache 搭配使用,称为 CombinedBlockCache,其中 LRUBlockCache 中主要存储 Index Block 和 Bloom Block,而将 Data Block 存储在 BucketCache 中。这样一次随机读, 首先会去 LRUBlockCache 中查到对应的索引块 Index Block,然后再到 BucketCache 查找对应数据块 Data Block,能够极大降低了 JVM GC 对业务请求的实际影响。


总结



在此对 hbase 的内核底层原理作了简要的分析,其中很多的思想是值得我们借鉴学习的,上文提到的内容仅仅是 hbase 的冰山一角,里面还有很多的技术需要我深入的挖掘和学习,作为 coder 的我们希望能在每一次技术革新中都得到成长,实现自我价值,加油!最后,感谢各位耐心阅读,希望在 hbase 上的了解对大家有所帮助,上文描述若有不正确的地方,欢迎拍砖指出!

参考链接:

https://hbase.apache.org/book.html#_preface

https://blog.csdn.net/valada/article/details/104289035

https://www.jianshu.com/p/5c846e205f5f

https://zhuanlan.zhihu.com/p/68516038

https://blog.csdn.net/u010325193/article/details/87743551

https://www.w3cschool.cn/hbase_doc/hbase_doc-vxnl2k1n.html

https://blog.csdn.net/qq_38180223/article/details/80922114


发布于: 2021 年 01 月 25 日阅读数: 166
用户头像

永健_何

关注

水平不高,始终坚持 2018.10.21 加入

还未添加个人简介

评论 (1 条评论)

发布
用户头像
很棒
2021 年 01 月 25 日 12:38
回复
没有更多了
Hbase内核剖析