这几天开发了一个库,叫 tableman
,https://github.com/alsotang/tableman。名字取的比较挫。
这个库的作用是处理多个二维表之间的 join 或者 group 关系。
这里所说的二维表,是指这样的一种结构:
var table = [
{id: 1, num: 2},
{id: 2, num: 10},
{id: 3, num: 20},
];
node-mysql 从数据库中取出的数据就长上面这个样。
我现在面对的业务,数据会存储在不同的数据源上,有 mysql,也有 hbase,还有一些阿里的内部存储服务。而且这些数据经常需要按特定键进行 join
。
这个场景之前的解决方案叫做 itier(虽然阿里管数据安全比较严格,但既然有开源并且与业务无关,我就提一下)。
https://github.com/xianbei/itier https://github.com/aleafs/itier-client
当然,上面两个 repo 很久没有更新了。
这个 itier 的解决方案如下:
itier 以 sql 为 interface,它接受的输入类似这样:
select aaa.user_name, aaa.user_age, bbb.user_trade_money
from mysql_table aaa
join hbase bbb on aaa.user_id === bbb.uid
where aaa.age > 20 and bbb.gender = 0 and aaa.user_name = 'alsotang';
itier 作为一个数据中间层,代理所有的客户端发来的后端数据源的查询。在 itier 中,会有 msyql-driver、hbase-driver 等等适配层。这样适配层可以接受 sql 语句,并将相应的 sql 语句转换成对应数据库的查询,并以二维表的形式返回。
比如上面的 sql 会被拆成:
select user_name, user_age
from mysql_table
where aaa.age > 20 and aaa.user_name = 'alsotang'
和
select user_trade_money from hbase where bbb.gender = 0 and bbb.uid in (:aaa___user_id)
类似的形式。
先查询 mysql,得到 user_id 之后,去查询 hbase 获得更多的详细数据。
itier 在拿到不同数据库传来的二维表后,通过解析 sql 语句中的 group, where 或者 join 关系。将多张二维表合成一张,并返回给客户端。
在使用中,踩的坑如下:
一、 灵活性
sql 过于灵活。sql 如果是被逻辑完全的实现,各种组合使用会非常灵活。
在解析 sql 的时候,如果 group by 和 join 的条件稍微复杂一点,甚至 select 的条件稍微变化,就可能出现数据中间层解析不到的边角情况。
我们必须限定用户使用一个 sql 的子集,用户需要去记忆规则。为了不踩坑,我已经开始慢慢在使用那些我实践过的子集,而不是依照 sql 的规则来写语句。
这种感觉就像是在套模板,我脑中记下了多表连接查询时可以使用的模板、记下了可以使用的 select 场景的模板。这时我还不如抽出并固定下这些模板,以函数和参数的形式精准提供,不支持的特性就直接抛错。
而对于新手来说,由于对子集的生疏,很有可能写出数据中间层不支持的语句来。这时 sql 的便利性并不比它带来的混乱更多。
二、 缺少数据源的元信息
如下面两句 sql
select mysql.user_name, sum(hbase.login_time)
from mysql aaa
join hbase bbb on aaa.id = bbb.id
where mysql.user_name = 'alstoang' and bbb.valid = 1
select mysql.user_name, sum(mysql.login_time)
from mysql aaa
join hbase bbb on aaa.id = bbb.id
where mysql.user_name = 'alstoang' and bbb.valid = 1
两者所代表的处理过程就不相同,他们只是在 select 字段有所差别。
而 select 中的这个 sum
,到底应该在数据全部取回之后再进行,还是交由数据库来进行,数据中间层很难判断。
如果最终要扫一万行记录,而只返回一条记录的话。在数据库支持 sum 的情况下,数据中间层只需要接收一条数据;如果像上面的情况,hbase 不支持 sum,那数据中间层需要取回这一万条,然后在自己的内存中做 sum。
那为了保险起见,每次都全量取回吗?
这个例子举得不是太精妙,当然,我们可以在 itier 中标注一下:hbase-driver 不支持 sql 特性,mysql-driver 支持 sql 特性。这样,当查询遇到 mysql 时,可以透传;遇到 hbase 时,只好全量取回。
这样还是不能解决的问题是:某些数据源(比如 G 数据源)声称支持 sql,但只支持 sql 的一部分特性。那这时,数据中间层还需要在 driver 的层面对 G 数据源的支持的子集专门又做个适配?或者由程序员想办法 workaround 地让数据中间层产生 G 数据源能够理解的 sql?
再比如三张表 join,按某字段排个序取 limit 100。到底在取这三张表数据的那个阶段去 limit 100?没有元数据难以判断。数据中间层现在的策略是适当多取一部分。
再比如三张表 join,先后顺序怎么判断?哪张是大表,哪张是小表?先扫后扫哪个会导致总扫描行数最少。程序员虽然知道,但数据中间层难以判断。
鉴于以上的考虑,我打算再遇到多数据源查询时,去掉数据中间层这个概念。将查询的步骤留给程序员,要求查询回来的结果都是二维表,并提供一个库让他们方便地处理取回的二维表数据。
这样虽然在代码量上,会比写 sql 语句多出不少字数。但算上踩坑和调试和尝试 workaround 的时间来说,是有收益的。
于是有了 tableman 这样一个库。
在开发之前,我很多时间都花在了设计上,就是希望 tableman 的接口既能满足通用的需求,又不会导致调用时写的字数太多。
在设计之初,我本来想采用链式调用 api 的,比如
tableman(someTable).join(otherTable).group(some_field).select('sum(money)')
后来我发现,当实现链式调用时,我又进了上述所说的【灵活性】的坑。于是改成传 options 的形式,让每个支持的特性都有对应的名字。不能去随意组合处理方式,而 tableman 要尽可能枚举出常用的形式来。
后来我想了下,为什么 request 和 superagent 之间,后者的链式调用这么优美。
原因是,superagent 并不需要处理负责的组合条件,每个 superagent 暴露出来的方法都只是让那个 superagent 实例的内部多存储一个条件。当 .end
被调用时,把这些条件分别塞进对应的 http 协议中的位置,发出查询即可。
而如果 tableman 要实现链式调用,感觉是要写个 sql 引擎的意思。
在这个场景中,less is really more。