问题提出
时常会思考一个问题,SQL作为一种与数据交互的标准化语言,可以说是数据分析最强大的工具。不管是关注事务的OLTP型数据库或者是关注分析的OLAP型数据库,提供基本的SQL支持都是必须的。
如果抽象一下,万事万物皆为数据的载体,一个excel,一个txt文本,甚至一个二维数组。如果要在内存中对上述的结构进行操作,往往需要很复杂的读取和遍历操作,当涉及到多列过滤,排序和分组时,工作量呈几何倍数增加。因此,我在实际工作中遇到的问题就是,能否通过标准化的SQL来查询内存数据呢?
技术选型
首先能想到的方法有两种:
1.借用中间数据库:将数据导入到某种数据库中,例如MySQL或者Hive,这样做的好处在于,可以直接借助数据库引擎的SQL查询能力,并且可以存储数据。缺点在于,如果需求并没有存储数据的要求,且要求查询速率时,该方法显然不合适。
2.使用内存数据库:想法与第一种类似,有没有某种内存数据库,可以快速转化数据,快速查询数据呢?网上搜索一番后发现,其实出发点有些问题,既然是数据库,肯定逃不过数据存储,有存储就有延迟。但我们的目标是想尽可能削减甚至忽略这部分工作,把重心放在SQL查询上。
换种说法,内存数据结构或者集合可以看成存储数据的数据库,为什么非要找另一种存储方式,且就单单想借用它的SQL查询能力呢?
柳暗花明
在网上用各种关键词搜索无果后,脑袋里突然冒出一个东西:Calcite。之前尝试过用它做SQL解析,效果不是很好,并且在Hive中经常能看到它的身影,一查果然有门道。官网有这么一段描述,很有意思:
It contains many of the pieces that comprise a typical database management system, but omits some key functions: storage of data, algorithms to process data, and a repository for storing metadata.
Calcite intentionally stays out of the business of storing and processing data. As we shall see, this makes it an excellent choice for mediating between applications and one or more data storage locations and data processing engines. It is also a perfect foundation for building a database: just add data.
翻译一下,大概意思就是:Calcite不同于传统数据库的点在于,不存储数据,没有数据处理的算法,不存储元数据。它的作用是协调应用与存储在各处的数据,仅仅通过添加数据就可以创建一个数据库。
Show me the code!
官网有很多例子,特别是ReflectiveSchema和csv的例子,是很好的引导,其他各种数据库引擎可以通过各种Adapter接入,下面我们实现一个二维数组的查询,通过这个例子,大家可以简单改写实现各种数据结构的查询。
1.由上而下,先实现数据库Schema(可以忽略SchemaFactory),把source看作二维数组,meta看作元数据字段信息。
public class MemorySchema extends AbstractSchema {
private Map tableMap;
private List meta;
private List> source;
public MemorySchema(List meta, List> source){
this.meta = meta;
this.source = source;
}
@Override
public Map getTableMap(){
if(CollectionUtils.isEmpty(tableMap)){
tableMap = new HashMap<>();
tableMap.put("memory", new MemoryTable(meta, source));
}
return tableMap;
}
}
2.实现Table,模拟一张表
public class MemoryTable extends AbstractTable implements ScannableTable {
private List meta;
private List> source;
public MemoryTable(List meta, List> source){
this.meta = meta;
this.source = source;
}
@Override
public RelDataType getRowType(RelDataTypeFactory relDataTypeFactory) {
JavaTypeFactory typeFactory = (JavaTypeFactory) relDataTypeFactory;
//字段名
List names = new ArrayList<>();
//类型
List types = new ArrayList<>();
for(MemoryColumn col : meta){
names.add(col.getName());
RelDataType relDataType = typeFactory.createJavaType(col.getType());
relDataType = SqlTypeUtil.addCharsetAndCollation(relDataType, typeFactory);
types.add(relDataType);
}
return typeFactory.createStructType(Pair.zip(names,types));
}
@Override
public Enumerable
3.实现Enumerator,它模拟一个迭代器,枚举每一行数据
public class MemoryEnumerator implements Enumerator {
private List> source;
private int i = -1;
private int length;
public MemoryEnumerator(List> source){
this.source = source;
length = source.size();
}
@Override
public Object[] current() {
List list = source.get(i);
return list.toArray();
}
@Override
public boolean moveNext() {
if(i < length - 1){
i++;
return true;
}
return false;
}
@Override
public void reset() {
i = 0;
}
@Override
public void close() {
}
}
以上三部分就是核心,Schema,Table和Enumerator。
总结:MemorySchema需要实现getTableMap()方法,它的作用是返回数据库中所有表:表名 -> 表的映射关系。
MemoryTable实现getRowType()方法,返回字段信息,也就是元数据信息。继承最简单的ScannableTable,实现scan()方法,返回枚举器。
MemoryEnumerator继承Enumerator,实现接口的各个方法即可。有一点需要注意的是:按我们这种写法,i需要初始化为-1,初始化成0,查询到的数据会少一行。
补充类:
public class MemoryColumn {
private String name;
private Class type;
public MemoryColumn(String name, Class type){
this.name = name;
this.type = type;
}
public String getName() {
return name;
}
public Class getType() {
return type;
}
public void setType(Class type) {
this.type = type;
}
public void setName(String name) {
this.name = name;
}
}
然后calcite-core的版本:1.20.0
验证
public class CalciteTest {
Properties info;
Connection connection;
Statement statement;
ResultSet resultSet;
public void getData(List meta, List> source) throws SQLException {
// 构造Schema
Schema memory = new MemorySchema(meta, source);
// 设置连接参数
info = new Properties();
info.setProperty(CalciteConnectionProperty.DEFAULT_NULL_COLLATION.camelName(), NullCollation.LAST.name());
info.setProperty(CalciteConnectionProperty.CASE_SENSITIVE.camelName(), "false");
// 建立连接
connection = DriverManager.getConnection("jdbc:calcite:", info);
// 执行查询
statement = connection.createStatement();
// 取得Calcite连接
CalciteConnection calciteConnection = connection.unwrap(CalciteConnection.class);
// 取得RootSchema RootSchema是所有Schema的父Schema
SchemaPlus rootSchema = calciteConnection.getRootSchema();
// 添加schema
rootSchema.add("memory", memory);
// 编写SQL
String sql = "select * from memory.memory where COALESCE (id, 0) <> 2 order by id asc";
resultSet = statement.executeQuery(sql);
while (resultSet.next()){
System.out.println(resultSet.getString(1)+":"+resultSet.getString(2)+":"+resultSet.getString(3));
}
resultSet.close();
statement.close();
connection.close();
}
public static void main(String[] args) throws SQLException {
List meta = new ArrayList<>();
List> source = new ArrayList<>();
MemoryColumn id = new MemoryColumn("id", Long.class);
MemoryColumn name = new MemoryColumn("name", String.class);
MemoryColumn age = new MemoryColumn("age", Integer.class);
meta.add(id);meta.add(name);meta.add(age);
List line1 = new ArrayList(){
{
add(null);
add("a");
add(1);
}
};
List line2 = new ArrayList(){
{
add(2L);
add("b");
add(2);
}
};
List line3 = new ArrayList(){
{
add(3L);
add("c");
add(3);
}
};
List line4 = new ArrayList(){
{
add(null);
add("c");
add(4);
}
};
source.add(line1);source.add(line2);source.add(line4);source.add(line3);
new CalciteTest().getData(meta, source);
}
}
代码本身不是很难,有一点需要注意,就是NullCollation.LAST.name()属性,它可以使得排序时,null值总是被放到最后,官网上还有其他3种。更多SQL语法详见官网
另外一个比较关心的点是效率问题,通过arthas监控测试环境的服务,测试5w数据时,耗时小于97ms;1w数据时,耗时小于70ms;1000数据时,耗时20ms。如果大家遇到类似问题可以尝试用一下,但效率问题还是需要关注一下。