最近把hbase-storage-plugin代码分享到github 上,为了记录笔者当时的思路,所以写了这篇文章。
1、初衷:做一个集中的大容量存储引擎
1.1、起因
自从进入公司运维部以后,虽然一直在做开发的工作,但是跟DBA同学可以“亲密”接触,从而可以体会到个中的各种酸甜苦辣。在我们这边,DBA同学遇到的很多告警是磁盘空间告警,半夜起来处理这种故障实在是让人狼心。
在处理这种磁盘故障的过程中,发现很多业务库中存储了日志型的数据,定期就需要删除,近期的数据访问就不是很频繁,至于很多历史数据,就更是很少访问了。
考虑存在冷热数据的不同,一直琢磨在MySQL基础上实现一个大容量存储引擎。热数据用Innodb存储,等它变成冷数据,就改成大容量引擎。
1.2 第一次尝试
刚开始笔者考虑基于HDFS上做一个MySQL存储引擎,由于HDFS文件不能修改。正好利用LevelDB的存储特性,只会生成文件,而不会修改文件,于是改造了LevelDB的代码,让LevelDB运行在HDFS之上,然后基于LevelDB做了一个MySQL存储引擎。
这样在开发环境可以跑起来了,但是实际运行中,经常出现内存问题,因为HDFS的C-API是基于JVM的,没有纯C的库,内存问题无法解决,最后只能放弃。
因为hbase提供了一个thrift服务,可以支持c++语言,而且hbase天然有索引特性,这样我们在实现主键功能时会非常简单,所以最后敲定了hbase。
1.3、打算解决的问题
如果我们有了hbase这样一个海量的MySQL存储引擎,我们就可以解决以下几个难题。
1、冷热数据采用不同引擎
如下图所示:把近期的热数据先用Innodb引擎存储,随着时间的推移,逐步把一些老数据表,通过alter table 表名 engine hbase改成用Hbase来存储。
通过这种方式,可以在数据的高效访问与数据保存周期上达到双赢,重复利用了Innodb的性能和hbase海量容量的特性。
2、主从库采用不同引擎
主从库采用不同的引擎,在主库中采用Innodb,并且只保留近期数据。从库中用hbase引擎存储所有数据,历史数据从主库删除的时候,不删除从库中的表。
这样也可以达到数据长期保存的效果,而且还可以防止因hbase引擎代理问题,影响线上业务。
3、集中存储,数据共享
一套Hbase存储多套业务数据,甚至于,可以让不同业务访问相同的Hbase的表。一个业务的表也轻松的转移到另外一个业务中来。
2、hbase存储引擎的开发
2.1 主数据存储格式
首先,每一张MySQL表,对应在Hbase中建立一张对应的表,所以在MySQL的增删改查都会对应到Hbase表中的操作。
Bbase只有一个rowkey用来定位数据,而MySQL的键可以有多个字段组成,为了实现键查询和键 前缀查询,笔者首先按照MySQL主键字段顺序逐个组织成一个字节数组,也就是最后要存储到Hbase中的RowKey。
MySQL中主键的字段类型,这里只列了整数型和字符串型,开发Hbase存储引擎的时候,笔者只支持以下的数据类型成为主键:
MYSQL_TYPE_LONG
MYSQL_TYPE_LONGLONG
MYSQL_TYPE_TINY
MYSQL_TYPE_SHORT
MYSQL_TYPE_INT24
MYSQL_TYPE_TIME
MYSQL_TYPE_DATETIME
MYSQL_TYPE_TIMESTAMP
MYSQL_TYPE_VAR_STRING
MYSQL_TYPE_VARCHAR
MYSQL_TYPE_BIT
MYSQL_TYPE_STRING
这些字段类型除了后面的4个是字符串以外,前面的都可以转换成为整数型数据。按照图中的格式存储主键,主要是为了实现键字段数据还原、键顺序查询(order by)等功能。
由于hbase支持字段,所以数据字段就按照hbase的字段来存储。
由于Hbase天然具有顺序,所以笔者按照主键存储在Rowkey,数据字段存储在hbase的列中,这样主数据存储了根据主键定位数据的能力,所以Hbase引擎表是一种列簇表。从代码中我们就可以看出来:
virtual bool primary_key_is_clustered() { return TRUE; }
2.2 第二索引功能
第二索引功能的实现有赖于Hbase对一个表有批量写操作的支持,下面我们先看一下Hbase支持的批量写操作API。
/**
* Performs multiple mutations atomically on a single row. Currently
* {@link Put} and {@link Delete} are supported.
*
* @param rm object that specifies the set of mutations to perform atomically
* @throws IOException
*/
void mutateRow(final RowMutations rm) throws IOException;
这个API可以保证这些变更操作的原子性,基于这个保证,笔者就能够轻易的实现第二索引功能了。
2.2.1 第二索引存储格式
为了保证操作的原子性,笔者把第二索引的存储也存储在主数据对应的这张Hbase表中,格式为:
RowKey:格式是有组成键值的字段按照顺序组成
entry:key 字段存储了主键的数据。
2.2.2 第二索引数据变更
在增删改查时,和主数据一起生成一批Mutation,在Hbase中一次性对表进行操作,从而保证了原子性。
2.3.2 TODOList
开源的代码中实现了唯一性的第二索引,对于非唯一的第二索引,可以考虑把重复的键值存放在相同的第二索引Rowkey下。
2.3 批量数据插入
MySQL存储引擎提供了很多优化的操作能力,譬如批量数据插入,当我们load数据、批量插入或者做一些表变更(如:更换存储引擎)的时候,会用到批量数据操作。
批量数据操作会先缓存一些数据行,当达到缓存大小时,把这些数据一次性的写入底层存储中,这里也利用了Hbase的批量操作能力。
2.4 基于主键的查询优化
当一条SQL语句中,指定了所有的主键字段的情况下, 这时候,是可以避免采用范围查询,而是直接采用基于rowkey的定位查询功能的。笔者实现了下面的函数:
virtual int index_read_idx_map(uchar * buf, uint index, const uchar * key,
key_part_map keypart_map, enum ha_rkey_function find_flag);
在这个函数中,是直接调用了ScannerID HbaseClient::scannerOpenWithScan(const Text& tableName, const TScan& scan, const std::map<Text, Text> & attributes)函数来快速定位到主键的。
2.5 其他
由于MySQL实例访问Hbase是通过网络来访问的,所以这里做一些底层的优化处理,如:连接池、连接重建等,还有很多优化的空间。
3、改造thrift server
开发完引擎以后,与hbase一起联调,一旦建立几个连接,后续的连接请求就无法服务了,主要原因是thrift server才用了传统的半同步半异步设计模式,每个新的连接,会启动一个独立的线程来为它服务,一旦线程用完就无法再为后续的连接请求服务了。
如何解决这个问题呢,可以把这种模式改造成反应器设计模式,就能够提供高并发的服务了。
于是基于swift重新实现了hbase的thrift server,swift是一套基于netty实现的thrift服务框架,开发的步骤主要是:
1)基于thrift协议文件,生成服务框架:
java -jar .\swift-generator-cli-0.19.3-standalone.jar -override_package org.apache.hadoop.hbase.swift.generated -use_java_namespace org\apache\hadoop\hbase\thrift\Hbase.thrift -out ..\java
2)在生成的框架中实现hbase的访问逻辑。
3)重写thrift server之后,还有一个好处是我们可以扩展thrift server的能力,笔者在原有的API的基础上添加了几个API,如下图所示:
有了这些api,我们就可以利用它们来实现一些额外的功能,如:更改引擎,truncate table语法等。
有兴趣研究swift的可以看一下笔者很早以前记录的一篇文章(今天放到简书):http://www.jianshu.com/p/49c619d33307
4、总结
笔者在公司内部没有采用这个方案,最终选择了mariadb来解决这种日志型存储的问题,日志性的表可以选择tokudb引擎,一般能达到4倍以上的压缩比,好的情况下可以达到10倍。在公司现有业务场景下基本上能解决绝大多数问题了。毕竟Mariadb的成熟度高,使用广,稳定性好。当然仍然无法解决海量的存储问题。
后来笔者基于思路完成了大部分代码,近期把它开源了放在了github上:
https://github.com/herry2038/mysql-hbase-storage-plugin
主要是笔者觉得hbase这个思路不错,一方面交流学习,另一方面希望有机会能继续完善项目。