引入StorageHandler
,Hive用户使用SQL可读写外部数据源,具体请移步这篇文章
Hive Storage Handler入门和实战。
JdbcStorageHandler
是StorageHandler
的一个扩展,使得Hive能够读取 JDBC 数据源,具体介绍请移步Apache Hive 联邦查询。
下面我简单介绍自己使用的一个案例。
本地已经搭建好了Hadoop3.3.0版本的分布式集群,并安装了Apache Hive 2.3.8版本,机器和对应的节点信息如下
CNSZ22PL0272 | CNSZ22PL0273 | CNSZ22PL0274 | |
---|---|---|---|
HDFS | NameNode | DataNode | DataNode |
YARN | NodeManager | ResourceManager | NodeManager |
Hive | Hive |
我们有一张mysql类型的表endpoint_tmp
,结构如下
CREATE TABLE `endpoint_tmp` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`ENDPOINT` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL,
`ts` int(11) DEFAULT NULL,
`t_create` datetime NOT NULL COMMENT 'create time',
`t_modify` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'last modify time',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_endpoint` (`ENDPOINT`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
其所在连接信息为
jdbc:mysql://ip:3306/graph
通过如下语法,可以在Hive中创建一张关联上述endpoint_tmp
的表,表名为endpoint
CREATE EXTERNAL TABLE endpoint
(
id INT,
endpoint STRING,
ts INT,
t_create TIMESTAMP,
t_modify TIMESTAMP
)
STORED BY 'org.apache.hive.storage.jdbc.JdbcStorageHandler'
TBLPROPERTIES (
"hive.sql.database.type" = "MYSQL","hive.sql.jdbc.driver" = "com.mysql.jdbc.Driver","hive.sql.jdbc.url" = "jdbc:mysql://100.80.170.91:3306/graph","hive.sql.dbcp.username" = "falcon","hive.sql.dbcp.password" = "+nlmG2zq+nlmG2zq","hive.sql.query"="select id,endpoint,ts,t_create,t_modify from endpoint_tmp","hive.sql.dbcp.maxActive" = "1"
);
其中endpoint
表字段名和字段类型都要与endpoint_tmp
表中的对应;
STORED BY
必须指定使用'org.apache.hive.storage.jdbc.JdbcStorageHandler'
,表明这是一张与jdbc相关的表;
TBLPROPERTIES
中每个字段的具体含义,在前文Apache Hive 联邦查询中可以找到详细的介绍。
需要注意的是,TBLPROPERTIES
中hive.sql.query
书写的字段顺序,必须和CREATE EXTERNAL TABLE
中两个小括号里的字段顺序一致,否则在Hive中查询后,无法将字段名和值正确对应。
现在我们进入Hive命令行界面,通过上述语法创建表
建表成功!
在endpoint_tmp
表中已存在一条如下数据
上面我们演示的是JdbcStorageHandler
的基本使用方式。
下面我们从源码的角度分析JdbcStorageHandler
在Hive中的实现原理,以便于后续我们对其进行改造时做到了然于胸。
上述开发步骤使用的是Apache Hive 2.3.8版本,所以我们需要下载对应的源码包Hive2.3.8源码包下载地址,下载好之后使用IDEA打开并下载项目所需依赖。
最终整体代码结构如下,其中用红色箭头指向的包就是与JdbcStorageHandler
有关的核心代码
JdbcStorageHandler
相关的逻辑并不复杂,核心逻辑是先根据数据源划分切片,然后根据划分好的切片去数据源查询数据。下面我带着大家一步步分析(最好是按照Hive远程debug步骤,提前在本地搭建好远程debug的环境)。
当我们在Hive中输入一条sql时,Hive首先会将其变为一个语法树数据结构,然后根据语法树结构转化为逻辑执行计划,最后对其进行优化并生成物理执行计划,也就是MR任务,最终将MR任务提交到YARN执行。
这个过程是Hive逻辑的基本框架,不是本文要分析的重点。
我们要分析的是:
在MR任务提交到YARN执行之前
1.如何根据jdbc数据源划分MapTask数量?
2.每个MapTask查询多少数据?
我们先看JdbcStorageHandler
类,也就是创建表是我们指定的'org.apache.hive.storage.jdbc.JdbcStorageHandler'
。
它实现了HiveStorageHandler
接口并重写了getInputFormatClass
方法,方法返回JdbcInputFormat
类
JdbcInputFormat
类继承了HiveInputFormat
类。
写过MR程序的都知道InputFormat
作用是用来做分片的,那么里面必然有个getSplits
方法返回划分好的切片数,我们查看JdbcInputFormat
中该方法逻辑,具体看注释
public InputSplit[] getSplits(JobConf job, int numSplits) throws IOException {
try {
//numSplits即切片数
if (numSplits <= 0) {
numSplits = 1;
}
LOGGER.debug("Creating {} input splits", numSplits);
if (dbAccessor == null) {
//实际获取的是MySqlDatabaseAccessor类,后面会介绍
dbAccessor = DatabaseAccessorFactory.getAccessor(job);
}
//根据MySqlDatabaseAccessor类,查询数据库的记录总数
int numRecords = dbAccessor.getTotalNumberOfRecords(job);
// 每个切片需要查询的条数=记录总数/切片数量
int numRecordsPerSplit = numRecords / numSplits;
int numSplitsWithExtraRecords = numRecords % numSplits;
LOGGER.debug("Num records = {}", numRecords);
//创建InputSplit数组,大小为numSplits
InputSplit[] splits = new InputSplit[numSplits];
Path[] tablePaths = FileInputFormat.getInputPaths(job);
int offset = 0;
for (int i = 0; i < numSplits; i++) {
int numRecordsInThisSplit = numRecordsPerSplit;
if (i < numSplitsWithExtraRecords) {
numRecordsInThisSplit++;
}
//创建JdbcInputSplit
//其中numRecordsInThisSplit变量未来会被设置为sql语句limit关键字的第一个参数
//offset对应limit的第二个参数
//比如 select * from table limit 10,0
splits[i] = new JdbcInputSplit(numRecordsInThisSplit, offset, tablePaths[0]);
offset += numRecordsInThisSplit;
}
//返回切片
return splits;
}
catch (Exception e) {
LOGGER.error("Error while splitting input data.", e);
throw new IOException(e);
}
}
其中numSplits
参数很关键,它是通过上游HiveInputFormat
类的getSplits
方法传入的。在Hive中可以通过设置如下参数,来改变numSplits
参数的值。
set mapred.map.tasks=数字
该参数决定着每个表将来生成的MapTask数量。这就回答了上面提到的第一个问题
是如何根据jdbc数据源划分MapTask数量的?
第二个很关键的地方是getTotalNumberOfRecords
方法——查询数据库的记录总数。
Hive会根据记录总数/切片数量,计算每个MapTask需要从数据库查询的数据量。
这就回答了上面提到的第二个问题
每个MapTask查询多少数据?
此外,DatabaseAccessorFactory.getAccessor(job)
返回的具体实现以及getTotalNumberOfRecords
方法是如何查询总数据量的,在后面会有介绍。
总结一下,getSplits
方法主要做了以下事情:
1.通过MySqlDatabaseAccessor类的getTotalNumberOfRecords方法获取数据源的总记录
2.根据记录总数和切片数量,计算每个切片对应的limit和offset
3.返回切片数组
与数据源有交互的工作,都是这个接口完成的,包括如下方法:
具体实现类如下
上面说到getSplits
方法里会通过DatabaseAccessorFactory.getAccessor(job)
获取具体的实现类,具体逻辑如下
public static DatabaseAccessor getAccessor(DatabaseType dbType) {
DatabaseAccessor accessor = null;
switch (dbType) {
case MYSQL:
//如果建表时hive.sql.database.type指定的MYSQL,则返回MySqlDatabaseAccessor
accessor = new MySqlDatabaseAccessor();
break;
default:
//反之返回GenericJdbcDatabaseAccessor
accessor = new GenericJdbcDatabaseAccessor();
break;
}
return accessor;
}
如果建表时hive.sql.database.type
指定MYSQL
,这个方法就会返回MySqlDatabaseAccessor
,反之返回GenericJdbcDatabaseAccessor
。
其中GenericJdbcDatabaseAccessor
类使用模板设计模式,定义了通用的模板方法。
下面我们看看接口中每个方法的作用。
- getColumnNames
方法
通过JDBC规范连接数据库,获取表的字段名信息。主要场景是hive解析sql生成语法树时,给语法树设置元数据信息。
但这个方法不是我们关注的重点。
- getTotalNumberOfRecords
方法
该方法就是上述getSplits
方法里用到的逻辑,代码如下,具体看注释
@Override
public int getTotalNumberOfRecords(Configuration conf) throws HiveJdbcDatabaseAccessException {
Connection conn = null;
PreparedStatement ps = null;
ResultSet rs = null;
try {
//使用JDBC规范加载驱动、获取数据源
initializeDatabaseConnection(conf);
//获取hive.sql.query属性的值,即select id,endpoint,ts,t_create,t_modify from endpoint_tmp
String sql = JdbcStorageConfigManager.getQueryToExecute(conf);
//查询总记录数的sql
//SELECT COUNT(*) FROM (select id,endpoint,ts,t_create,t_modify from endpoint_tmp) tmptable
String countQuery = "SELECT COUNT(*) FROM (" + sql + ") tmptable";
LOGGER.debug("Query to execute is [{}]", countQuery);
//获取连接
conn = dbcpDataSource.getConnection();
ps = conn.prepareStatement(countQuery);
rs = ps.executeQuery();
//返回记录总数
if (rs.next()) {
return rs.getInt(1);
}
else {
LOGGER.warn("The count query did not return any results.", countQuery);
throw new HiveJdbcDatabaseAccessException("Count query did not return any results.");
}
}
catch (HiveJdbcDatabaseAccessException he) {
throw he;
}
catch (Exception e) {
LOGGER.error("Caught exception while trying to get the number of records", e);
throw new HiveJdbcDatabaseAccessException(e);
}
finally {
cleanupResources(conn, ps, rs);
}
}
initializeDatabaseConnection
方法会根据建表时hive.sql.jdbc.url
属性的值,使用JDBC规范加载驱动、获取数据源。
JdbcStorageConfigManager.getQueryToExecute
获取hive.sql.query
属性的值,也就是我们建表时指定的sql,然后它被拼接成查询总记录数的sql,再交给JDBC数据源查询,并返回总记录数。
- getRecordIterator
方法
查询分片数据并返回JdbcRecordIterator
对象,后面详细介绍。
通过上面分析可知,我们通过JdbcInputFormat
类的getSplits
方法获取了切片信息。之后hive将MR任务提交至YARN,MapTask任务便开始运行。
众所周知,每个MapTask运行时,在map阶段都会需要根据当前分片信息,使用RecordReader
类去存储端读取实际的数据。
如果我们查询的是HDFS的数据,则使用FileInputFormat
中的LineRecordReader
读取文件每一行。
而我们现在用的是JdbcInputFormat
这里,hive会怎么读取呢?
根据分片信息,生成的带有limit和offset的sql语句,去jdbc数据源查询数据,然后使用JdbcRecordReader遍历每行数据。
因此,我们需要分析JdbcRecordReader
的读取数据的逻辑,代码如下
//map阶段会在循环里调用next方法
@Override
public boolean next(LongWritable key, MapWritable value) throws IOException {
try {
LOGGER.debug("JdbcRecordReader.next called");
if (dbAccessor == null) {
dbAccessor = DatabaseAccessorFactory.getAccessor(conf);
//根据limit和offset查询当前分片数据,并返回JdbcRecordIterator
iterator = dbAccessor.getRecordIterator(conf, split.getLimit(), split.getOffset());
}
//如果有下一行
if (iterator.hasNext()) {
LOGGER.debug("JdbcRecordReader has more records to read.");
key.set(pos);
pos++;
//通过JdbcRecordIterator获取当前行
Map<String, String> record = iterator.next();
if ((record != null) && (!record.isEmpty())) {
//设置当前行的所有字段名和字段值
for (Entry<String, String> entry : record.entrySet()) {
value.put(new Text(entry.getKey()), new Text(entry.getValue()));
}
return true;
}
else {
LOGGER.debug("JdbcRecordReader got null record.");
return false;
}
}
else {
LOGGER.debug("JdbcRecordReader has no more records to read.");
return false;
}
}
catch (Exception e) {
LOGGER.error("An error occurred while reading the next record from DB.", e);
return false;
}
}
map阶段会在循环里调用上面的next
方法,每次遍历一行,会设置当前行的所有字段名和字段值并返回。
而我们数据是从哪里来的呢?通过dbAccessor.getRecordIterator(conf, split.getLimit(), split.getOffset())
方法获取——根据limit和offset查询当前分片数据,并返回JdbcRecordIterator
。
我们重点分析dbAccessor.getRecordIterator(conf, split.getLimit(), split.getOffset())
这段代码。
@Override
public JdbcRecordIterator
getRecordIterator(Configuration conf, int limit, int offset) throws HiveJdbcDatabaseAccessException {
Connection conn = null;
PreparedStatement ps = null;
ResultSet rs = null;
try {
//使用JDBC规范加载驱动、获取数据源
initializeDatabaseConnection(conf);
//获取hive.sql.query属性的值,此处是select id,endpoint,ts,t_create,t_modify from endpoint_tmp
String sql = JdbcStorageConfigManager.getQueryToExecute(conf);
//拼接limit和offset
//select id,endpoint,ts,t_create,t_modify from endpoint_tmp limit 10,0
String limitQuery = addLimitAndOffsetToQuery(sql, limit, offset);
LOGGER.debug("Query to execute is [{}]", limitQuery);
//从数据源获取连接
conn = dbcpDataSource.getConnection();
ps = conn.prepareStatement(limitQuery, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);
ps.setFetchSize(getFetchSize(conf));
//查询数据
rs = ps.executeQuery();
//返回JdbcRecordIterator,持有当前分片的数据
return new JdbcRecordIterator(conn, ps, rs);
}
catch (Exception e) {
LOGGER.error("Caught exception while trying to execute query", e);
cleanupResources(conn, ps, rs);
throw new HiveJdbcDatabaseAccessException("Caught exception while trying to execute query", e);
}
}
其中limitQuery
就是生成的带有limit和offset的sql语句。
总结一下,getRecordIterator
方法主要做了以下事情:
1.用JDBC规范加载驱动、获取数据源
2.生成带有limit和offset的sql语句
3.根据sql查询当前分片数据,并返回JdbcRecordIterator
本文介绍了JdbcStorageHandler
的基本使用方式,以及简单的源码分析。
通过JdbcStorageHandler
,我们可以使用标准的JDBC方式读取数据。
但是,由于JdbcStorageHandler
本身不支持写入数据到JDBC,所以目前仅限于查询数据的场景。