一、大数据背景
1、什么是大数据
随着信息化时代的发展,企业对数据的处理面临三大问题。
第一是数据的急剧增长,一些大型的企业每天都会产生 TB 级别的数据。
第二是数据本身变得复杂,不仅包含传统的关系型数据,还包括来自网页、Web 日志文件、社交媒体论坛、电子邮件、文档、传感器数据等原始、半结构化和非结构化数据。传统系统可能很难存储、分析这些数据的内容,更不要说挖掘有价值的信息,因为传统的数据库、数据仓库、联机事务处理等技术并不适合处理这些数据。
第三是需要在数据变化的过程中对它的数量和种类进行分析,而不只是在 “静止” 状态进行分析。业界定义这种情况为从单纯批量计算模式到实时动态计算模式的内涵式转变。内涵式即结构优化、质量提高,是一种实现实质性的跨越式的进程。
为了解决上述三个问题,大数据强调 3V 特征,即 Volumn(量级)、Varity(种类)和 Velocity(速度)。所以大数据的定义是:高速(Velocity)涌现的大量(Volume)的多样化(Variety)数据。
2、为什么大数据至关重要
通常情况下,数据必须经过清理才能规范地存放到数据仓库中。相反大数据解决方案不仅会利用传统仓库且数量庞大的数据,而且不需要改变原有数据格式,保留了数据的真实性,并能够快速访问海量的信息。对于不能使用传统关系型数据库方法处理的信息所带来的挑战,大数据解决方案非常适合。大数据之所以重要,是因为其具备解决现实问题的三个关键方面:
- 分析各种不同来源的结构化和非结构化数据的理想选择
- 当需要分析所有或大部分数据,或者对一个数据抽样分析效果不明显时,大数据解决方案是理想的选择
- 未预先确定数据的业务度量指标时,是进行迭代式和探索式分析的理想选择
3、NoSQL 在大数据中扮演的角色
NoSQL,是 Not only SQL 的缩写,泛指非关系型的数据库。与关系型数据库相比,存在许多显著的不同点,其中最重要的是NoSQL不使用SQL作为查询语言。其数据存储可以不需要固定的表模式,也通常会避免使用SQL的JOIN操作,一般又都具备水平可扩展的特性。NoSQL的实现具有两个特征:使用硬盘、把随机存储器作存储载体。
传统关系型数据库的缺陷
(1)高并发读写的瓶颈
WEB 2.0网站要根据用户个性化信息来实时生成动态页面、提供动态信息,所以基本上无法使用静态化技术,因此数据库并发负载非常高,可能峰值会达到每秒上万次读写请求。关系数据库应付上万次SQL查询还勉强顶得住,但是应付上万次SQL写数据请求,硬盘IO却无法承受。其实对于普通的BBS网站,往往也存在相对高并发写请求的需求,例如,人人网的实时统计在线用户状态,记录热门帖子的点击次数,投票计数等,这是一个相当普遍的业务需求。
(2)可扩展性的限制
基于WEB的架构中,数据库是最难进行横向扩展的,当应用系统的用户量和访问量与日俱增时,数据库系统却无法像 WEB Server 和 APP Server 那样简单地通过添加更多的硬件和服务节点来扩展性能和负载能力。对于很多需要提供24小时不间断服务的网站来说,对数据库系统进行升级和扩展是非常痛苦的事情,往往需要停机维护和数据迁移,而不能通过横向添加节点的方式实现无缝扩展。
(3)事务一致性的负面影响
事务执行的结果必须是使数据库从一个一致性状态变到另一个一致性状态。 保证数据库一致性是指当事务完成时,必须使所有数据都具有一致的状态。在关系型数据库中,所有的规则必须应用到事务的修改上,以便维护所有数据的完整性,这随之而来的是性能的大幅度下降。很多 WEB 系统并不需要严格的数据库事务,对读一致性的要求很低,有些场合对写一致性要求也不高。因此数据库事务管理成了高负载下的一个沉重负担。
(4)复杂 SQL 查询的弱化
任何大数据量的WEB系统,都非常忌讳几个大表间的关联查询,以及复杂的数据分析类型的SQL查询,特别是SNS类型的网站,从需求以及产品设计角度就避免了这种情况的产生。更多的情况往往只是单表的主键查询,以及单表的简单条件分页查询,SQL的功能被极大地弱化了,所以这部分功能不能得到充分的发挥。
NoSQL数据库的优势
(1)扩展性强
NoSQL数据库种类繁多,但是一个共同的特点就是去掉关系数据库的关系特性,数据之间弱关系,非常容易扩展。例如,HBase、Cassandra等系统的水平扩展性能非常优越,非常容易实现支撑数据从TB到PB级别的过度。
(2)并发性能好
NoSQL数据库具有非常良好的读写性能,尤其在大数据量下,同样表现优秀。这得益于它的弱关系性,数据库的结构简单。一般MySQL使用 Query Cache,每当表发生更新操作时,Cache就会失效,这是一种大粒度的Cache,在针对WEB 2.0的交互中频繁应用,Cache性能并不高。而NoSQL的Cache是记录级的,是一种细粒度的Cache,所以NoSQL在这个层面上来说性能要高很多。
(3)数据模型灵活
NoSQL无需事先为要存储的数据建立字段,随时可以存储自定义的数据格式。而在关系数据库中,增删字段是一件非常麻烦的事情。对于数据量非常大的表,增加字段简直就是一场噩梦。NoSQL允许使用者随时随地添加字段,并且字段类型可以是任意格式。
HBase 作为 NoSQL 数据库的一种,当然也具备上面提到的种种优势。使用过Hadoop的读者知道,Hadoop最适合的应用场景是离线批量处理数据,其离线分析的效率非常高,能在分钟级别处理TB级的数据,但是一般的应用系统并不适合批量模式访问,更多的还是用户的随机访问,就类似访问关系型数据库中的某条记录一样。HBase的列式存储的特性支撑它实时随机读取、基于KEY的特殊访问需求。当然,HBase还有不少新特性,其中不乏有趣的特性,在接下来的内容中将会详细的介绍。
二、BigTable
1、产生背景
使用传统的关系型数据库时,需要根据应用系统设计一张张的数据表,数据表可以看成是一个实体(Entity),也可以看成是实体之间存在的联系(Relationship),为了实现 ACID 的特性,数据库需要添加很多的约束,处理的逻辑很重。
当数据量达到几千万甚至上亿,并发量达到几百时,数据库的查询性能会急剧下降,即使使用了分表分库、在应用和数据库中间加一层缓存,甚至想方设法各种 “骚操作” 来深度优化数据库,都无法从根本上解决关系型数据库的容量和性能瓶颈,特别是针对大数据的应用场景更是捉襟见肘。
我们无法同时满足 CAP 特性,又希望又性能容量上可以水平扩展,这时候需要换一种思路,降低对数据库的事务性约束和要求,谷歌的 BigTable 就是在这种背景下应运而生。
2、什么是 BigTable
BigTable 是一个分布式的结构化的数据存储系统,为处理海量数据而设计,可以扩展到PB级数据和上千台服务器。在 google 的很多产品中被使用,这些应用对 Bigtable提出了不同的挑战,比如数据规模的要求、延迟的要求。Bigtable能满足这些多变的要求,为这些产品成功地提供了灵活、高性能的存储解决方案。
Bigtable看起来像一个数据库,采用了很多数据库的实现策略。但是Bigtable并不支持完整的关系型数据模型;而是为客户端提供了一种简单的数据模型,客户端可以动态地控制数据的布局和格式,并且利用底层数据存储的局部性特征。Bigtable将数据统统看成无意义的字节串,客户端需要将结构化和非结构化数据串行化再存入Bigtable。
3、BigTable 的数据模型
Bigtable不是关系型数据库,但是却沿用了很多关系型数据库的术语,像table(表)、row(行)、column(列)等。在理解 BigTable 的数据模型时最好不要代入关系型数据库的概念,这有助于我们更好地理解什么是 BigTable。
Google 的论文中这样描述:“BigTable 是一个稀疏的、分布式的、持久化存储的多维度排序 Map”。
首先 Map 就是一个键值对(key-value)的映射,多维度指 BigTable 的 key 是三维的,即行键(row key)、列键(column key)和时间戳(timestamp),行键和列键都是字节串,时间戳是64位整型;而值是一个字节串。
可以用以下描述来表示一条数据。
(row:string, column:string, time:int64)→string
稀疏指同个表不同的行,列可能完全不一样;分布式指 BigTable 的数据存储基于 GFS,GFS 是一个分布式文件系统,除此之外,BigTable 本身也是一个主从分布式架构;持久化指 BigTable 的数据最终会以文件的形式保存到 GFS 磁盘中。
既然 key 是多维的,就能建立多级索引。
行是表的第一级索引,如果把该行的列、时间和值看成一个整体,就可以简化为一维键值映射,类似于:
table {
"1" : {"v1"},
"2" : {"v2"},
...
}
列是第二级索引,每行拥有的列是不受限制的,可以随时增加减少。为了方便管理,列被分为多个列族(column family,是访问控制的单元),一个列族里的列一般存储相同类型的数据。一行的列族很少变化,但是列族里的列可以随意添加删除。列键按照family:qualifier格式命名的。这次我们将列拿出来,将时间和值看成一个整体,简化为二维键值映射,类似于:
student {
"1" : {
"info:name" : {"Tom"},
"info:age" : {"20"},
"grade:math" : {"100"}
},
"2" : {
"info:name" : {"Jerry"},
"info:age" : {"21"},
"grade:math" : {"90"}
},
}
也可以将列族当作一层新的索引,类似于:
student {
"1" : {
"info" : {
"name" : {"Tom"},
"age" : {"20"}
},
"grade" : {
"math" : "100"
}
}
}
时间戳是第三级索引。Bigtable允许保存数据的多个版本,版本区分的依据就是时间戳。时间戳可以由Bigtable赋值,代表数据进入Bigtable的准确时间,也可以由客户端赋值。数据的不同版本按照时间戳降序存储,因此先读到的是最新版本的数据。我们加入时间戳后,就得到了Bigtable的完整数据模型,类似于:
student {
"1" : {
"info:name" : {
1 : "Tom
},
"info:age" : {
1 : "20"
},
"grade:math" : {
7 : "90",
10 : "100"
}
},
}
利用时间戳提供的多版本特性,可以查询指定版本的数据。查询时,如果只给出行列,那么返回的是最新版本的数据;如果给出了行列时间戳,那么返回的是时间小于或等于时间戳的数据。
如查询 “1”/”grade:math”,返回的值是 “100”;查询 “1”/”grade:math”/7,返回的值是 “90”;如果查询 “1”/”grade:math”/5,返回的结果为空。
【参考】https://dzone.com/articles/understanding-hbase-and-bigtab。
HBase 的设计和实现借鉴了 BigTable 的思想和理念。
三、列式存储 vs 行式存储
组织关系数据库有两种方法:
- 行式存储
- 列式存储
行式数据库中通过记录来组织数据,使所有数据与在内存中彼此相邻的记录相关联。行式数据库是传统组织数据的方式,并且仍然为快速存储数据提供一些关键优势,为读写进行了有效的优化。
传统的关系型数据库都是行式存储:
- Oracle
- MySQL
列式存储数据库是按照字段来组织数据的,使所有数据与在内存中彼此相邻的字段相关联。列式存储数据库已经变得流行,并且在数据查询上有更好的表现,为列上的读和计算进行了有效的优化。
常见的列式存储数据库有:
1、行式存储
传统的数据库管理系统被设计用来存储数据。在读写一行数据上做了很多优化,这导致一系列的选择包括行存储的架构。
在列式存储中,数据一行行存储,一行的第一列紧跟在上一行的最后一列后面。
以 Facebook_Friends 表数据为例
在行式数据库中,数据会被按照顺序逐行存储在磁盘上。
这使得写入一行数据是很快的,因为需要完成的所有内容都要写入数据的另一行到达数据的末尾。
行式数据库中的写
现在要添加一行,只需要在数据文件后追加。
行式数据库依然广泛地用于联机事务处理(OLTP)类型的应用,因为能够很好地写入数据库。但是数据库的另外一个使用场景是不需要事务性处理的数据分析,联机分析处理(OLAP)需要数据库能够支持对数据的即席(ad hoc)查询,在这个场景下,行式数据库比列式存储数据库要慢。****
行式数据库中的读
假设每个磁盘只能存储三个列,则上述数据需要3个磁盘存储。
现在要计算所有人的年龄之和,那么必须读取每个磁盘的每一行,并且加载到内存中,虽然 name、city 这两行每用到,依然会作为一行数据整体加载到内存中,磁盘和内存的开销都比较大。
2、列式存储
不同行的相同列会放在一起存储
发生一行新的记录的写的时候,必须找到每一列的位置再插入,很明显,写性能不如行式存储
假设每个磁盘只能存储3列,则会按下图方式存储
同样是计算年龄之和,现在只需要从磁盘3中读取加载数据到内存,并执行运算,磁盘内存的开销都减少了。
查询时很高效,比如查询所有朋友姓名:select * from facebook_friend;
行式存储会读取每一行数据并抽取出其中的 name 列,列式存储只需要从磁盘1中读取数据即可。
注意不同列存储的位置是严格对应的,比如 name 列 Tim 存储在 Dave 之后,那么 Tim 的年龄也应该存储在 Dave 的年龄之后。
3、总结
优缺点对比如下:
行式存储 | 列式存储 | |
---|---|---|
优点 | ① 数据被保存在一起 ② INSERT/UPDATE容易 |
① 查询时只有涉及到的列会被读取, 投影(projection)很高效 ② 任何列都能作为索引 |
缺点 | 选择(Selection)时即使只涉及某几列,所有数据也都会被读取 | 选择完成时,被选择的列要重新组装 INSERT/UPDATE比较麻烦 |
4、数据压缩与查询优化
我们可以根据列式存储的特点进行数据压缩,简单地说就是利用字典表,每个字符串在字典表里只出现一次,降低数据冗余度,达到压缩的目的。
利用数据压缩可以优化查询性能
select * from tab
where customer_name = 'Miller' and material = 'Refrigerator';
有点类似于位图索引,针对每个查询条件的列生成一个位图,满足条件的记录对应的位为1,要同时满足多个查询条件,就对位图做与运算,计算结果中为1的位对应的记录就是我们想要的查询结果。
四、HBase
HBase 是一个高可靠、高性能、面向列、可伸缩的分布式数据库,利用 HBase 技术可在廉价 PC 上搭建起大规模结构化存储集群。HBase 参考 Google 的 BigTable 建模,使用类似 GFS 的 HDFS 作为底层文件存储系统,在其上可以运行 MapReduce 批量处理数据,使用 ZooKeeper 作为协同服务组件。
HBase 的整个项目使用 Java 语言实现,是 Apache 基金会 Hadoop 项目的一部分,既是模仿 Google BigTable 的开源产品,同时又是 Hadoop 的衍生产品。而 Hadoop 作为批量离线计算系统已经得到了业界的普遍认可,并经过了工业上的验证,所以 HBase 具备 “站在巨人肩膀之上” 的优势。
HBase 是一种非关系型数据库,即 NoSQL 数据库。在 CAP 理论中,属于 CP 类型的系统。
1、特性
(1)容量巨大
HBase 的单表可以有百亿行、百万列,数据矩阵横向和纵向两个维度所支持的数据量级都非常有弹性。传统的关系型数据库,如 Oracle 和 MySQL 等,如果数据记录在亿级别,查询和写入的性能都会呈指数级下降,所以更大的数据量级对传统数据库来讲是一种灾难。而 HBase 对于存储百亿、千亿甚至更多的数据都不存在任何问题。对于高维数据,百万量级的列没有任何问题。千万和亿级别的列,HBase 也支持,只是这种情况下访问单个 Rowkey 可能造成访问超时,如果限定某个列则不会出现这种问题。
(2)面向列
传统行式数据库的特性如下:
- 数据是按行存储的
- 没有索引的查询使用大量I/O
- 建立索引和物化视图需要花费大量的时间和资源
- 面对查询需求,数据库必须被大量膨胀才能满足需求
列式数据库的特性如下:
- 数据按列存储,即每一列单独存放
- 数据即索引
- 只访问查询涉及的列,可以大量降低系统I/O
- 每一列由一个线索来处理,即查询的并发处理性能高
- 数据类型一致,数据特征相似,可以高度压缩
列式存储不仅解决了数据稀疏性问题,最大程度上节省存储开销,而且在查询发生时,仅检索查询涉及的列,能够大量降低磁盘I/O。HBase 使用列式存储,既保证了查询性能,又方便为字段的数据聚集存储涉及更好的压缩和解压算法。
(3)稀疏性
传统行式存储中,某个列即使为 NULL,也需要占用存储空间(比如在一张大表中,为了兼容性和设计的便利,不会所有的业务都使用所有的字段,这就导致大多数情况下,不管什么业务都不会每一列都存储值),这就造成存储空间的浪费。但对于 HBase 来说,为空的列并不占用存储空间,因此表可以设计得非常稀疏。
(4)扩展性
HBase 底层文件存储依赖 HDFS,因此 HDFS 的扩展性也使其具备了扩展性。同时,HBase 的 Region 和 RegionServer 的概念对应的数据可以分区,分区后数据可以位于不同的机器上,所以在 HBase 核心架构层面也具备可扩展性。HBase 的扩展性还是热扩展,即在不停止现有服务的前提下,可以随时添加或者减少节点。
(5)高可靠性
HBase 提供 WAL 和 Replication 机制。前者保证了数据写入时不会因集群异常而导致写入数据的丢失;后者保证了在集群出现严重问题时,数据不会发生丢失或者损坏。而且 HBase 底层使用 HDFS,HDFS 本身的副本机制很大程度上保证了 HBase 的高可靠性。同时,协调服务的 ZooKeeper 组件是经过工业验证的 ,具备高可用性和高可靠性。
(6)高性能
底层的 LSM 数据结构和 Rowkey 有序排列等架构上的独特设计,使得 HBase 具备非常高的写入性能。Region 切分、主键索引和缓存机制使得 HBase 在海量数据下具备一定的随机读取性能,该性能针对 Rowkey 的查询能够达到毫秒级别。同时,HBase 对于高并发的场景也具备很好的适应能力。
2、核心模块
HBase 的核心功能模块有 4 个,分别是:客户端 Client、协调服务模块 ZooKeeper、主节点 HMaster 和 RegionServer,这些组件的描述和相互之间的关联关系如下:
(1)客户端
客户端 Client 是整个 HBase 系统的入口。使用者直接通过客户端操作 HBase,客户端使用 HBase 的 RPC 机制与 HMaster 和 RegionServer 进行通信。对于管理类操作,Client 与 HMaster 进行 RPC 通信;对于数据读写类操作,Client 与 RegionServer 进行 RPC 交互。
(2)协调服务组件 ZooKeeper
ZooKeeper Quorum(队列)负责管理 HBase 中多 HMaster 的选举、服务器之间状态同步等。具体有:存储 HBase 元数据信息、实时监控 RegionServer、存储所有 Region 的寻址入口、保证集群中只有一个 HMaster 节点。
(3)主节点 HMaster
HMaster 没有单点问题,在 HBase 中可以启动多个 HMaster,通过 ZooKeeper 的 Master 选举机制保证总有一个 Master 正常运行并提供服务,其他 HMaster 作为备选时刻准备(当目前 HMaster 出现问题时)提供服务。HMaster 主要负责 Table 和 Region 的管理工作:
- 管理用户对 Table 的增、删、查、改操作
- 管理 RegionServer 的负载均衡,调整 Region 分布
- 在 Region 分裂后,负责新 Region 的分配
- 在 RegionServer 死机后,负责失效 RegionServer 上的 Region 迁移
(4)Region 节点 HRegionServer
HRegionServer 主要负责响应用户 I/O 请求,向 HDFS 文件系统中读写数据,是 HBase 中最核心的模块。HRegionServer 内部管理了一系列 HRegion 对象,每个 HRegion 对应了 Table 中的一个 Region。
HRegion 由多个 HStore 组成,每个 HStore 对应了 Table 中一个 Column Family 的存储。可以看出每个 Column Family 其实就是一个集中的存储单元,因此最好将具备共同 I/O 特性的列放在一个 Column Family 中,这样能保证读写的高效性。
HStore 存储是 HBase 存储的核心,由两部分组成:MemStore 和 StoreFile。MemStore 是 Sorted Memory Buffer,用户写入的数据首先会放入 MemStore 中,当 MemStore 满了以后会缓冲(flush)成一个 StoreFile(底层实现是 HFile),当 StoreFile 文件数量增长到一定阈值,会触发 Compact 操作,将多个 StoreFiles 合并成一个 StoreFile,在合并过程中会进行版本合并和数据删除,因此可以看出 HBase 其实只有增加数据,所有的更新和删除操作都是在后续的 Compact 过程中进行的,这使得用户的写操作只要进入内存中就可以立即返回,保证了 HBase I/O 的高性能。
StoreFiles 在触发 Compact 操作后,会逐步形成越来越大的 StoreFile,当单个 StoreFile 大小超过一定阈值后,会触发 Split 操作,同时把当前 Region 分裂成两个 Region,父 Region 会下线,新分裂的 2 个子 Region 会被 HMaster 分配到相应的 HRegionServer 上,使得原先1个 Region 的压力得以分流到2个 Region 上。
每个 HRegionServer 中都有一个 HLog 对象,HLog 是一个实现 Write Ahead Log 的类,在每次用户操作写入 MemStore 的同时,也会写一份数据到 HLog 文件中,HLog 文件定期会滚动出新,并删除旧的文件(已经持久化到 StoreFile 中的数据)。在 HRegionServer 意外终止后,HMaster 会通过 ZooKeeper 感知到,首先处理遗留的 HLog 文件,将其中不同 Region 的 Log 数据进行拆分,分别放到相应 Region 的目录下,然后再将失效的 Region 重新分配,领取到这些 Region 的 HRegionServer 在加载 Region 的过程中,会发现有历史 HLog 需要处理,因此会将 HLog 中的数据回放到 MemStore 中,然后缓冲(flush)到 StoreFiles,完成数据恢复。
3、数据模型
(1)逻辑模型
在本图中,列簇(Column Family)对应的值就是 info 和 area ,列( Column 或者称为 Qualifier )对应的就是 name、 age、 country 和 city ,Row key 对应的就是 Row 1 和 Row 2,Cell 对应的就是具体的值。
- Row key :表的主键,按照字典序排序。
- 列簇:在 HBase 中,列簇将表进行横向切割。
- 列:属于某一个列簇,在 HBase 中可以进行动态的添加。
- Cell : 是指具体的 Value 。
- Version :在这张图里面没有显示出来,这个是指版本号,用时间戳(TimeStamp)来表示。
既然 HBase 是 KV 的数据库,那么当然是以获取 KEY 的形式来获取到 Value 啦。在 HBase 中的 KEY 组成是这样的:
KEY 的组成是以 Row key 、CF(Column Family) 、Column 和 TimeStamp 组成的。
TimeStamp 在 HBase 中充当的作用就是版本号,因为在 HBase 中有着数据多版本的特性,所以同一个 KEY 可以有多个版本的 Value 值(可以通过配置来设置多少个版本)。查询的话是默认取回最新版本的那条数据,但是也可以进行查询多个版本号的数据,在接下来的进阶操作文章中会有演示。
RegionServer 和 Region 的关系
- 一个 Region Server 就是一个机器节点(服务器)
- 一个 Region Server 包含着多个 Region
- 一个 Region 包含着多个列簇 (CF)
- 一个 Region Server 中可以有多张 Table,一张 Table 可以有多个 Region
(2)物理模型
逻辑结构和物理结构的对应关系:
逻辑结构 | 物理结构 |
---|---|
Region Server | HRegion Server |
Region | HRegion |
Column Family | HStore(这里指的是 Store) |
在具体的物理结构中
- HRegion Server 就是一个机器节点,包含多个 HRegion ,但是这些 HRegion 不一定是来自于同一个 Table ,负责响应的是用户的 IO 请求,和 | | |进行交互,是服务器中的一个进程。
- HRegion 包含多个 HStore 。
- 一个 CF 组成一个 HStore ,默认是 10 G,如果大于 10G 会进行分裂。HStore 是 HBase 的核心存储单元,一个 HStore 由 MemStore 和 StoreFile 组成。
- MemStore 是一块内存,默认大小是 128M,如果超过了这个大小,那么就会进行刷盘,把内存里的数据刷进到 StoreFile 中。
- 在 HStore 对应着的是 Table 里面的 Column Family,不管有 CF 中有多少的数据,都会存储在 HStore 中,为了避免访问不同的 HStore 而导致的效率低下。
- HRegion 是 Hbase 中分布式存储和负载均衡的最小单元,但不是存储的最小单元。
五、体系架构
1、Hadoop 生态圈
在 Hadoop 生态圈中,存储层是 HDFS 和 HBase,HDFS 是最底层的数据存储文件系统,HBase 是基于 HDFS 的 NoSQL 数据库;在存储层之上,是资源调度和计算层 Yarn;基于 Yarn 之上是 MapReduce、Spark 等分布式计算引擎。
最终的目的,我们是要通过 SQL 就能查询大数据,这就需要数据分析引擎来实现,主要由 Hive、Pig 来完成。
要分析处理大数据,首先得采集数据,所以需要有数据采集引擎,由 Sqoop、Flume 完成,Sqoop 主要采集关系型数据库的数据,Flume 主要采集日志类型的数据。
各种组件,为了避免单点故障,还需要实现 HA,通过分布式协调服务 ZooKeeper 实现。
最后要把所有的组件集成到一个 web 控制台上,方便进行查询、操作和监控,通过集成管理工具 HUE 实现。
2、HBase 设计
HBase 是一个主从架构,主节点为 Master,从节点为 RegionServer。
Master 存储表定义的元信息,RegionServer 直接负责存储表数据(底层存储依赖于 HDFS)。
RegionServer 非常依赖于 ZooKeeper,ZooKeeper 管理了 HBase 所有 RegionServer 的信息,包括具体的数据段放在哪台 RegionServer 上。当客户端向 HBase 发起请求时,并不是直接请求 Master 或 RegionServer,而是先跟 ZooKeeper 通信,查询出要连接哪个 RegionServer,然后再连接 RegionServer,如下图所示:
跟一般的主从架构不同,当 Master 故障时,HBase 依然能够提供正常的查询服务,但无法创建、修改和删除表。
这是因为客户端查询时不需要 Master 的参与,Master 的职责是各种 RegionServer 之间的协调工作,比如建表、删表、移动 Region、合并等操作,它们的共性就是需要跨多个 RegionServer,这些操作由哪个 RegionServer 来执行都不合适,所以 HBase 就将这些操作放到了 Master 上。
这种结构降低了对 Master 的依赖,减轻 Master 单点故障的影响,集群依然可以正常地运行,依然可以存储和删除数据。
六、环境搭建
一般生产环境中,HBase 底层数据存储是依赖于 HDFS 的,而 HDFS 又依赖于 Java 环境,所以在安装搭建 HBase 环境之前,应该先将 Hadoop 和 Java 环境先安装配置好。
环境机器配置:
1、本地模式
本地模式不需要HDFS的支持,直接把数据存储在操作系统中。
环境:虚拟机 192.168.190.111(bigdata111)
IP | HOST | 配置 |
---|---|---|
192.168.190.111 | bigdata111 | 2C4G |
192.168.190.112 | bigdata112 | 2C4G |
192.168.190.113 | bigdata113 | 2C4G |
192.168.190.114 | bigdata114 | 2C4G |
(1)安装配置
解压到安装目录
tar -zxvf hbase-1.3.1-bin.tar.gz -C ~/training/
配置环境变量(~/.bash_profile
)
HBASE_HOME=/root/training/hbase-1.3.1
export HBASE_HOME
PATH=$HBASE_HOME/bin:$HIVE_HOME/bin:$PATH
export PATH
修改完后让配置生效
source ~/.bash_profile
(2)HBase 配置
HBase 的配置文件目录位于 $HBASE_HOME/conf
,有两个文件需要修改配置。
第一个是 hbase-env.sh
,修改 JAVA_HOME
...
# Set environment variables here.
# This script sets variables multiple times over the course of starting an hbase process,
# so try to keep things idempotent unless you want to take an even deeper look
# into the startup scripts (bin/hbase, etc.)
# The java implementation to use. Java 1.7+ required.
export JAVA_HOME=/root/training/jdk1.8.0_181
...
第二个是 hbase-site.xml
,配置 HBase 数据存储的路径
<configuration>
<!--数据存储位置-->
<property>
<name>hbase.rootdir</name>
<!--数据保存在本地-->
<value>file:///root/training/hbase-1.3.1/data</value>
</property>
</configuration>
(3)启动
在 $HBASE_HOME 目录下有很多的脚本,配置好环境变量后可以直接执行,本地模式的 hbase 将数据存储在本地文件系统上,不需要依赖 HDFS,可直接通过以下命令完成启动。
start-hbase.sh
(4)测试
HBase shell 是 HBase 自带的命令行工具,可通过该工具测试 HBase 启动使用是否正常。
# 进入 hbase shell
[root@bigdata111 bin]# hbase shell
...
# 创建一个 student 表,表中有两个列族 info、grade
hbase(main):001:0> create 'student','info','grade'
...
# 查看库中有哪些表
hbase(main):002:0> list
...
# 往表中插入数据
hbase(main):003:0> put 'student','s01','info:name','Tom'
...
# 查看表中数据
hbase(main):004:0> scan 'student'
...
# 退出
hbase(main):005:0> exit
查看保存 hbase 的本地文件目录,可以看到表和列族都会被存储为一个目录
(5)停止
stop-hbase.sh
2、伪分布模式
伪分布模式会在单机上模拟一个分布式的环境,具备HBase所有的功能,多用于开发和测试。
环境:① HBase – bigdata111
② HDFS – bigdata111,bigdata112,bigdata113
(1)HBase 配置
修改 hbase-env.sh
,启用 HBase 自带的 ZK
export HBASE_MANAGES_ZK=true
修改 hbase-site.xml
<configuration>
<!--数据存储位置-->
<property>
<name>hbase.rootdir</name>
<!--数据保存在HDFS上-->
<value>hdfs://bigdata111:9000/hbase</value>
</property>
<!--表示是一个分布式的环境-->
<property>
<name>hbase.cluster.distributed</name>
<value>true</value>
</property>
<!--ZK的地址-->
<property>
<name>hbase.zookeeper.quorum</name>
<value>bigdata111</value>
</property>
<!--Region的冗余-->
<property>
<name>dfs.replication</name>
<value>1</value>
</property>
</configuration>
修改 regionservers
,表示 HBase 的从节点列表,这里使用本机的 host
bigdata111
(2)启动
先启动 hadoop
start-all.sh
启动 HBase,可以看到先启动了自带的 zk
start-hbase.sh
查看 jps,可以看到 HDFS 的进程,HBase 的进程和自带的 ZK 的进程。
(3)测试
执行和本地模式同样的 hbase shell 测试,在 HDFS 的 /hbase 目录下生成了很多文件。
其中 /hbase/data 下的文件目录与本地模式目录下生成的文件是一样的。
3、全分布模式
全分布模式下 HBase 会被部署在多台机器上,由一个主节点和多个从节点组成。
环境:① HDFS – bigdata111,bigdata112,bigdata113
② HBase – bigdata111,bigdata112,bigdata113
(1)HBase 配置
先把安装包和配置在 bigdata111 上做好,再复制到 bigdata112、bigdata113 上即可。
配置系统环境变量 ~/.bash_profile
HBASE_HOME=/root/training/hbase-1.3.1
export HBASE_HOME
配置 $HBASE_HOME/conf/hbase-env.sh
...
export JAVA_HOME=/root/training/jdk1.8.0_181
...
export HBASE_MANAGES_ZK=true
...
配置 $HBASE_HOME/conf/hbase-site/xml
<configuration>
<!--数据存储位置-->
<property>
<name>hbase.rootdir</name>
<!--数据保存在HDFS上-->
<value>hdfs://bigdata111:9000/hbase</value>
</property>
<!--表示是一个分布式的环境-->
<property>
<name>hbase.cluster.distributed</name>
<value>true</value>
</property>
<!--ZK的地址-->
<property>
<name>hbase.zookeeper.quorum</name>
<value>bigdata111</value>
</property>
<!--Region的冗余-->
<property>
<name>dfs.replication</name>
<value>2</value>
</property>
</configuration>
HBase 配置 bigdata111 为主节点,其余两个为从节点,修改 regionservers
bigdata112
bigdata113
把111上的 hbase 复制到 112、113
scp -r /root/training/hbase-1.3.1/ root@bigdata112:/root/training
scp -r /root/training/hbase-1.3.1/ root@bigdata113:/root/training
(2)启动
在 bigdata111 上启动 hadoop 和 hbase
start-all.sh
start-hbase.sh
启动之后查看 jps
# bigdata111
[root@bigdata111 conf]# jps
2338 NameNode
2530 SecondaryNameNode
2692 ResourceManager
5013 HQuorumPeer
5290 Jps
5084 HMaster
# bigdata112
[root@bigdata112 conf]# jps
1461 NodeManager
1941 Jps
1350 DataNode
# bigdata113
[root@bigdata113 conf]# jps
1880 Jps
1307 DataNode
1419 NodeManager
bigdata112、bigdata113 上并没有 hbase 相关的进程,这是什么情况?查看 hbase 主节点的日志,发现不断产生大量报错
2022-03-22 00:11:51,939 INFO [bigdata111:16000.activeMasterManager] master.ServerManager: Waiting for region servers count to settle; currently checked in 0, slept for 428963 ms, expecting minimum of 1, maximum of 2147483647, timeout of 4500 ms, interval of 1500 ms.
2022-03-22 00:11:53,461 INFO [bigdata111:16000.activeMasterManager] master.ServerManager: Waiting for region servers count to settle; currently checked in 0, slept for 430485 ms, expecting minimum of 1, maximum of 2147483647, timeout of 4500 ms, interval of 1500 ms.
原因是两个 RegionServer 跟 HMaster 的时间不同步,停掉 hbase,同步时间后
# 同步方法1:所有机器直接同步到某个时间
date -s "yyyy-MM-dd HH:mm:ss"
# 同步方法2:跟时间服务器同步
ntpdate [timeserver]
同步完后重启 hbase
可以看到 bigdata112、bigdata113 都有 RegionServer 的进程了
访问 HBase 的 web console:http://192.168.190.111:16010/master-status,可以看到 master 为 bigdata111,Region Servers 为 bigdata112、bigdata113。
(3)测试
在 bigdata111 上开启 hbase shell,结果随便执行一个查询的操作抛出异常
原因是读取了伪分布模式下 hbase 在 HDFS 上生成的文件是有问题的,停掉 hbase 服务,清空 /hbase 目录,删除 hbase 安装目录重新安装复制,重启 hbase 再测试,还是一样的报错,最终才定位到是因为 zk 还保留这个表的信息,所以认为表已存在无法创建,从 zk 上删除信息后,表创建成功。
# 进入 hbase zkcli
hbase zkcli
...
# 查看存在的表信息
[zk: bigdata111:2181(CONNECTED) 0] ls /hbase/table
[hbase:meta, hbase:namespace, student]
# 删除表信息
[zk: bigdata111:2181(CONNECTED) 1] rmr /hbase/table/student
# 再次查看是否删除成功
[zk: bigdata111:2181(CONNECTED) 2] ls /hbase/table
[hbase:meta, hbase:namespace]
# 退出
[zk: bigdata111:2181(CONNECTED) 3] quit
重新开启 hbase shell 测试,命令执行正常
查看 hbase web console,可以看到新创建的表和列族
4、HA 模式
HBase 实现高可用只需要在某个 Region Server 上开启一个 master 守护进程即可。
hbase-daemon.sh start master
开启之后查看 web console,可以看到备用的 master
现在杀死 bigdata111 上的 HMaster 进程,模拟生产主节点故障的情况
可以看到 bigdata112 上出现了 HMaster 的进程,它成为了 hbase 的主节点
这时候访问 hbase web console 的地址要变为:http://192.168.190.112:16010/master-status,可以看到这时 bigdata112 既是主节点,又是从节点。
假设经过一段时间原来的主节点 bigdata111 从故障中恢复,要重新加入集群,并成为备用主节点,则执行
hbase-daemon.sh start regionserver
hbase-daemon.sh start master
查看控制台
可以看到 hbase 的 HA,新主从节点的加入,操作非常简单。
通过 ZooInspector,可以直接从 zk 了解 hbase 集群的状态,包括哪个节点是 master,哪个节点是 Region Server。
七、操作 HBase
1、HBase shell
(1)create 建表
# 语法:create 'tablename','column-family1','column-family2',...,'column-familyN'
#
# 创建一个 student 表,表中有两个列族 info、grade
create 'student','info','grade'
(2)list 查询数据库中的表
# 语法:list
#
# 相当于SQL中 show tables 的效果
hbase(main):001:0> list
(3)describe 查看表结构
# 也可以使用缩写 desc '表名'
hbase(main):003:0> describe 'student'
Table student is ENABLED
student
COLUMN FAMILIES DESCRIPTION
{NAME => 'grade', BLOOMFILTER => 'ROW', VERSIONS => '1', IN_MEMORY => 'false', KEEP_DELETED_CELLS => 'FALSE', DATA_BLOCK_ENCODING => 'NONE', TTL => 'FOREVER', COMPRESSION => 'NONE', MI
N_VERSIONS => '0', BLOCKCACHE => 'true', BLOCKSIZE => '65536', REPLICATION_SCOPE => '0'}
{NAME => 'info', BLOOMFILTER => 'ROW', VERSIONS => '1', IN_MEMORY => 'false', KEEP_DELETED_CELLS => 'FALSE', DATA_BLOCK_ENCODING => 'NONE', TTL => 'FOREVER', COMPRESSION => 'NONE', MIN
_VERSIONS => '0', BLOCKCACHE => 'true', BLOCKSIZE => '65536', REPLICATION_SCOPE => '0'}
2 row(s) in 0.0860 seconds
(4)put 插入数据
# 语法:put 'tablename','rawkey','family1:column1','value1',...,'familyN:columnN','valueN'
#
# 往学生表插入一行数据,rawkey 为 s001,info列族的name属性为 Tom
hbase(main):005:0> put 'student','s001','info:name','Tom'
0 row(s) in 0.0550 seconds
(5)scan 查看数据
# 语法:scan 'tablename'
#
# 查询学生表中所有数据,相当于:select * from student;
hbase(main):006:0> scan 'student'
ROW COLUMN+CELL s001 column=info:name, timestamp=1648041822338, value=Tom 1 row(s) in 0.0290 seconds
(6)get 查询数据
get 查询跟 scan 的差异:get 是根据 rawkey 查询单条数据。
# 语法:get 'tablename','rawkey'
#
# 查询 student 表中 rawkey 为 's001' 的数据
hbase(main):007:0> get 'student','s001'
COLUMN CELL info:name timestamp=1648041822338, value=Tom 1 row(s) in 0.0140 seconds
插入的每一条数据都会生成一个时间戳,如果是同一行数据,对同一列插入不同数据,会保留数据的多个版本,可以通过指定时间戳获得对应的版本数据,默认情况下查询只会返回最新的版本。
hbase(main):032:0> get 'student','s001'
COLUMN CELL info:name timestamp=1648043400032, value=Jerry 1 row(s) in 0.0040 seconds
hbase(main):033:0> get 'student','s001',{ COLUMN => 'info:name', TIMESTAMP => 1648043355198 }
COLUMN CELL info:name timestamp=1648043355198, value=Tom 1 row(s) in 0.0060 seconds
hbase(main):034:0> get 'student','s001',{ COLUMN => 'info:name', TIMESTAMP => 1648043400032 }
COLUMN CELL info:name timestamp=1648043400032, value=Jerry 1 row(s) in 0.0080 seconds
(7)delete 删除数据
# 语法:delete 'tablename','rawkey','family:column'
#
# 删除数据不是以行为单位的,而是列
hbase(main):048:0> delete 'student','s001','grade'
0 row(s) in 0.0030 seconds
hbase(main):049:0> get 'student','s001'
COLUMN CELL grade:math timestamp=1648044130996, value=100 grade:music timestamp=1648044154962, value=95 info:name timestamp=1648043400032, value=Jerry 1 row(s) in 0.0030 seconds
hbase(main):050:0> delete 'student','s001','grade:math'
0 row(s) in 0.0030 seconds
hbase(main):051:0> get 'student','s001'
COLUMN CELL grade:music timestamp=1648044154962, value=95 info:name timestamp=1648043400032, value=Jerry 1 row(s) in 0.0070 seconds
(8)deleteall 删除整行数据
hbase(main):052:0> deleteall 'student','s001'
0 row(s) in 0.0080 seconds
hbase(main):053:0> get 'student','s001'
COLUMN CELL
0 row(s) in 0.0040 seconds
(9)drop 删除表
# 语法:drop 'tablename'
#
# 删除表前必须先禁用表,否则会报错:ERROR: Table student is enabled. Disable it first.
hbase(main):024:0> disable 'student'
0 row(s) in 2.2630 seconds
hbase(main):025:0> drop 'student'
0 row(s) in 1.2520 seconds
2、Java API
(1)CURD
① 创建表
// 如果程序执行卡住不动,可能是客户端无法解析 hbase 主机名,检查 host 即可
// 创建表
@Test
public void testCreateTable() throws Exception {
// 配置ZooKeeper的地址
Configuration conf = new Configuration();
conf.set("hbase.zookeeper.quorum", "192.168.190.111");
// 客户端
HBaseAdmin admin = new HBaseAdmin(conf);
//创建表的描述符
HTableDescriptor htd = new HTableDescriptor(TableName.valueOf("mystudent"));
// 表中的列族
htd.addFamily(new HColumnDescriptor("info"));
htd.addFamily(new HColumnDescriptor("grade"));
// 创建表
admin.createTable(htd);
admin.close();
}
② 插入数据
// 插入数据: 单条数据
@Test
public void testPut() throws Exception {
// 配置ZooKeeper的地址
Configuration conf = new Configuration();
conf.set("hbase.zookeeper.quorum", "192.168.190.111");
// 得到表的客户端
HTable table = new HTable(conf, "mystudent");
// 插入的数据:构造Put(参数:行键)
Put put = new Put(Bytes.toBytes("s001"));
// put.addColumn(family, 列族的名字
// qualifier, 列的名字
// value) 值
put.addColumn(Bytes.toBytes("info"),
Bytes.toBytes("name"),
Bytes.toBytes("Tom"));
table.put(put);
// 插入数据:多条数据
// table.put(List<Put>);
table.close();
}
③ 查询单条数据
// 查询数据Get
@Test
public void testGet() throws Exception {
// 配置ZooKeeper的地址
Configuration conf = new Configuration();
conf.set("hbase.zookeeper.quorum", "192.168.190.111");
// 得到表的客户端
HTable table = new HTable(conf, "mystudent");
// 构造Get对象
Get get = new Get(Bytes.toBytes("s001"));
// 查询
Result r = table.get(get);
// 取出数据
String name = Bytes.toString(r.getValue(Bytes.toBytes("info"), Bytes.toBytes("name")));
System.out.println(name);
table.close();
}
④ 查询多条数据
// 查询数据
@Test
public void testScan() throws Exception {
// 配置ZooKeeper的地址
Configuration conf = new Configuration();
conf.set("hbase.zookeeper.quorum", "192.168.190.111");
// 得到表的客户端
HTable table = new HTable(conf, "mystudent");
// 定义一个扫描器
Scan scan = new Scan();
// scan.setFilter(filter) 过滤器
// 通过扫描器查询数据
// 在该集合中保存的是Result
ResultScanner rs = table.getScanner(scan);
for(Result r:rs) {
//取出数据
String name = Bytes.toString(r.getValue(Bytes.toBytes("info"), Bytes.toBytes("name")));
System.out.println(name);
}
table.close();
}
⑤ 删除表
// 删除表
@Test
public void testDrop() throws Exception {
// 配置ZooKeeper的地址
Configuration conf = new Configuration();
conf.set("hbase.zookeeper.quorum", "192.168.190.111");
// 客户端
HBaseAdmin admin = new HBaseAdmin(conf);
// 删除表
admin.disableTable("mystudent");
admin.deleteTable("mystudent");
admin.close();
}
(2)MapReduce 处理 HBase
首先分析需求,还是 WordCount 小程序的例子,首先创建数据库 word,列族为 content,content:info 为每一行文本数据;计算结果保存在 stat 数据库中,rowkey 为分词的每个单词,计数存储在 content:result 中。
① 数据初始化
通过以下代码,往 word 表插入三条数据。
public class WordCountDataInit {
@Test
public void createTable() throws IOException {
//指定的配置信息: ZooKeeper
Configuration conf = new Configuration();
conf.set("hbase.zookeeper.quorum", "192.168.190.111");
//创建一个HBase客户端: HBaseAdmin
HBaseAdmin admin = new HBaseAdmin(conf);
//创建一个表的描述符: 表名
HTableDescriptor hd = new HTableDescriptor(TableName.valueOf("word"));
//创建列族描述符
HColumnDescriptor hcd1 = new HColumnDescriptor("content");
//加入列族
hd.addFamily(hcd1);
//创建表
admin.createTable(hd);
//关闭客户端
admin.close();
}
@Test
public void insertData() throws IOException {
//指定的配置信息: ZooKeeper
Configuration conf = new Configuration();
conf.set("hbase.zookeeper.quorum", "192.168.190.111");
//客户端
HTable table = new HTable(conf, "word");
Put put1 = new Put(Bytes.toBytes("1"));
put1.add(Bytes.toBytes("content"), Bytes.toBytes("info"), Bytes.toBytes("I love Beijing"));
Put put2 = new Put(Bytes.toBytes("2"));
put2.add(Bytes.toBytes("content"), Bytes.toBytes("info"), Bytes.toBytes("I love China"));
Put put3 = new Put(Bytes.toBytes("3"));
put3.add(Bytes.toBytes("content"), Bytes.toBytes("info"), Bytes.toBytes("Beijing is the capital of China"));
List<Put> list = new ArrayList<Put>();
list.add(put1);
list.add(put2);
list.add(put3);
//插入数据
table.put(list);
table.close();
}
}
② mapper
要通过 MapReduce 程序操作 HBase,mapper 需要继承 org.apache.hadoop.hbase.mapreduce.TableMapper
,map 的输入处理的就是一条 HBase 表记录,至于是哪个表在作业配置中指定。
// 现在的输入就是HBase表中的一条记录
// k2单词 v2记一次数
public class WordCountMapper extends TableMapper<Text, IntWritable> {
@Override
protected void map(ImmutableBytesWritable key1, Result value1, Context context)
throws IOException, InterruptedException {
/*
* key1:代表的是行键rowkey
* value1:该行记录
* 数据 : I love Beijing
*/
String data = Bytes.toString(value1.getValue(Bytes.toBytes("content"), Bytes.toBytes("info")));
// 分词
String[] words = data.split(" ");
for (String word : words) {
context.write(new Text(word), new IntWritable(1));
}
}
}
③ reducer
和 mapper 一样,reducer 同样要继承 org.apache.hadoop.hbase.mapreduce.TableReducer
,reduce 的输出就是往 HBase 里插入一条记录,插入的表在作业配置中指定,插入的列族和列则直接在 reducer 中指定。
// k3 v3 k4是输出记录的行键
public class WordCountReducer extends TableReducer<Text, IntWritable, ImmutableBytesWritable> {
@Override
protected void reduce(Text key3, Iterable<IntWritable> value3, Context context)
throws IOException, InterruptedException {
// 计算总数
int total = 0;
for (IntWritable v : value3) {
total += v.get();
}
// 输出的是表中的一条记录
// 构造一个Put对象
Put put = new Put(Bytes.toBytes(key3.toString()));
put.addColumn(Bytes.toBytes("content"), Bytes.toBytes("result"), Bytes.toBytes(String.valueOf(total)));
// 输出 单词作为rowkey
context.write(new ImmutableBytesWritable(Bytes.toBytes(key3.toString())), put);
}
}
④ Job
public class WordCountMain {
public static void main(String[] args) throws Exception {
// 指定的配置信息: ZooKeeper
Configuration conf = new Configuration();
conf.set("hbase.zookeeper.quorum", "192.168.190.111");
// 创建任务,指定任务的入口
Job job = Job.getInstance(conf);
job.setJarByClass(WordCountMain.class);
// 定义一个扫描器,指定读取的列
Scan scan = new Scan();
scan.addColumn(Bytes.toBytes("content"), Bytes.toBytes("info"));
// 使用工具类指定任务的Map
TableMapReduceUtil.initTableMapperJob(Bytes.toBytes("word"), // 指定读取HBase数据所在的表
scan, // 扫描器
WordCountMapper.class,// 指定处理的Mapper Class
Text.class, // k2
IntWritable.class, // v2
job);
// 使用工具类指定任务的reducer
TableMapReduceUtil.initTableReducerJob("stat",
WordCountReducer.class,
job);
// 执行任务
job.waitForCompletion(true);
}
}
提交到 hadoop 上执行
hadoop jar hbase-mr.jar
结果失败了,发现是没有提前创建好保存作业结果的 stat 表。
创建好表重新执行,作业成功
查看 stat 表,结果正常
3、Web Console
我们还可以通过 web console 访问 HBase,需要访问 master 节点的 16010 端口。
网页控制台具备的功能有:
- 1)查看集群节点信息(主从节点、备用节点)
- 2)基本表信息(包括用户表、系统表)
- 3)查看执行过的 HBase 任务
- 4)查看日志,设置日志级别
- 5)dump 信息
- 6)Hbase 配置
作者:小胡_鸭
链接:https://www.jianshu.com/p/8cb7caed4fc3
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
原创文章,作者:奋斗,如若转载,请注明出处:https://blog.ytso.com/292439.html