ecodeclub / eorm

简单 ORM 框架
Apache License 2.0
191 stars 64 forks source link

分库分表:查询特征提取与表达——以 merger 分发为例 #197

Closed flycash closed 2 months ago

flycash commented 1 year ago

仅限中文

背景

在结果集处理的相关问题下 #182 ,我们能够看到一个问题,即查询特征会直接决定了后续结果集如何处理。实际上,如果单纯站在分库分表的角度,那么不仅仅是结果集处理,连目标 SQL 生成都会收到查询特征的影响。比如说在计算 AVG(age) 的时候,生成的目标 SQL 就不是直接 SELECT AVG(age) 而是 SELECT COUNT and SUM。

于是我们就面临一个问题,如何提取并且表达这些查询特征?如果只考虑目前已知的几个情况:

现在我们的目标是根据这些特征来创建对应的 merger。

创建 merger 可能需要用到这些信息:

所以我们的困难就是两个:

如果以 merger 来作为例子,那么就可以归结为一句话:我怎么确定我该使用哪个实现?这方面强调非常高的扩展性.

if-else 方案

if-else 也可以称为飞机场方案(参考自 Redis)。即我们将全部的分发逻辑都放在一个巨大的方法里面。这个方案什么也不需要干,就是根据查询的特征来确定究竟应该使用哪个实现。

唯一需要注意的就是 if-else 代码要有条理。

比如说我们按照 SELECT 语句的顺序来写 if-else。

伪代码类似于:

func NewMerger(feats...query.Feature) merger.Merger {
     if SELECT xxx {
         if GROUP BY {
               // ....
         }
     } else if GROUP BY {
           // ...
     }
}

在这个伪代码里面,在 if 和 else 分支里面都进行了类似的判断。这是因为本身聚合函数和 ORDER BY 是可以合并使用的,也可以单独使用。所以在两个分支里面都需要判断。

这种写法可以确保我们后续在维护的时候只需要沿着 SELECT 的语法特性去检查,就可以比较容易看出来我们是否已经遗漏了。

状态机

目前我的初步设想是利用状态机的机制。它相当于将这些规则做成一个状态机:

这本质上就是:当我到达状态 A 的时候,我可以触达的下一个状态就有限了。例如在聚合函数的例子里面,我们首先判断有没有聚合函数,在有了聚合函数之后,就步入到有聚合函数的状态。在这个状态下,可以转换过去的就是两个状态:有 GROUP BY 或者没有 GROUP BY。当我进入到有 GROUP BY 之后,我就还可以朝着两个状态前进,即GROUP BY 里面的都是 SK,以及不全是 SK。当我进去全是 SK 的状态之后,我面前就得到了最终状态。这个最终状态就可以决定使用哪个 merger。

这算是一个不怎么常见的设计方案,即利用状态机来解决复杂的分发逻辑,并保持良好的扩展性。所以状态接口可以设计成为:

type State interface {
    Move(s *ShardingSelector) (State, error)
}

它的好处就是可以带来极好的扩展性。缺点就是整个状态转换的逻辑不直观,至少比一大堆的 if-else 更不直观。因为如果我们直接使用 if-else 的话,整个分发逻辑都在一个地方。而这种设计则是将分发逻辑分散到了不同的实现里面。

表格驱动

这是另外一种可行的思路。即我们维护一个表格。表头就是各种特征。那么具体到我们的程序里面,就是维护了一个 bool 数组。

features [N]bool

这个 N 就是我们的特征数量。例如说有聚合函数是一个特征,有AVG又是一个特征。那么类似地,可以用 features[0] 来代表是否有聚合函数, features[3] 是否具备GROUP BY。

事实上我们可以考虑将 [N]bool 替换一个简单的 64 比特的数字。那么我们就可以表达 64 种特征,将来也可以扩展到任意多个字节。

谁来分发

这里始终有一个问题就是,不管是用 if-else,还是状态驱动,还是表格驱动,谁来真正维护这种分发逻辑。

那么按照封装性来说,只有 merger 包才知道各个实现适用于什么场景。那么在这种情况下,只能由 merger 来分发。那么问题进一步就变成,如果 merger 来提供分发逻辑,那么它的接口该怎么设计?

我们无法放过去 merger 包里面,因为存在循环依赖的问题。

第一种可行的是:

// merger/dispatcher

func Dispatch(feat query.Feature, params Params) {

}

当然子包名和方法名都可以换。

接下来要解决的问题是,query.Feature 该如何定义,以及 ShardingSelector 怎么提供这个 Feature。

一种可行的设计是:

// query 包
type Query struct {
    Feature
}
type Feature struct {

}

ShardingSelector 负责准确描述每一个查询的特征。

紧接着又有一个问题。每一个 merger 实现的初始化参数都不一样,但是大致上可以归类为:

所以

type Params struct {
    OrderBy []merger.Column
   GroupBy []merger.Column
   Select []merger.Column
}

不过目前来看,还是不够优雅,依旧存在扩展性和可维护性的问题。

juniaoshaonian commented 1 year ago
type Selector struct {
    HasGroupBy bool
    HasOrderBy bool
    HasLimit   bool
    // group by 的键是否是分片键
    HasShardingKey bool
    HasAggregate   bool
    HasHaving      bool
}

type State interface {
    Move(s *Selector) (State, error)
}
// 终结状态
type MergerState interface {
    State
    GetMergerType() Merger
}
// 初始状态
type InitialState struct {
}

func (i InitialState) Move(s *Selector) (State, error) {
    // 判断是否含有groupby字句
    if s.HasGroupBy {
        return GroupByState{}, nil
    }
    return NoGroupByState{}, nil
}

type GroupByState struct {
}

// groupby的键是否为分片键
func (g GroupByState) Move(s *Selector) (State, error) {
    if s.HasShardingKey {
        return GroupByShardingKeyState{}, nil
    }
    return NoShardingKeyState{}, nil
}

type GroupByShardingKeyState struct {
}

// 是否含有聚合函数模块
func (g GroupByShardingKeyState) Move(s *Selector) (State, error) {
    if s.HasAggregate {
        return GroupByShardingKeyAggregateState{}, nil
    }
    return GroupByShardingKeyNoAggregateState{}, nil
}

type GroupByShardingKeyAggregateState struct {
}
// 是否含有having字句
func (g GroupByShardingKeyAggregateState) Move(s *Selector) (State, error) {
    if s.HasHaving {
        return GroupByShardingKeyAggregateHavingState, nil
    }
    return GroupByShardingKeyAggregateNoHavingState{},nil
}

type GroupByShardingKeyAggregateNoHavingState struct {
}

// 判断是否含有order by字句
func (g GroupByShardingKeyAggregateNoHavingState) Move(s *Selector) (State, error) {
    if s.HasOrderBy {
        return GroupByShardingKeyAggregateNoHavingOrderByState{}, nil
    }
    return nil, nil
}

// 最终到达最终状态,有groupby且groupby的键为分片键,有聚合函数,没有having字句,有order by字句
type GroupByShardingKeyAggregateNoHavingOrderByState struct {

}

// 最终状态move的返回值是nil,通过这个我们可以拿到最后的merger
func (g GroupByShardingKeyAggregateNoHavingOrderByState) Move(s *Selector) (State, error) {
    return nil,nil
}

func (g GroupByShardingKeyAggregateNoHavingOrderByState) GetMergerType() Merger {
    return &sortmerger.Merger{}
}

大佬这样设计我们的分发逻辑是否可行,上面简单展示了分发含groupby且groupby的键是分片键有聚合函数没有having有orderby的情况

flycash commented 1 year ago

牛逼,完全理解了我的思路。

我先确认一个点,就是我提到了两个思路,一个是状态机的思路,一个是 if-else 的思路。你从你个人的实践经验出发,你喜欢哪一个?

juniaoshaonian commented 1 year ago

这个状态机的好一点,但是状态机有个问题就是我的中间状态比较多,有什么方法可以优化吗

longyue0521 commented 1 year ago

选if-else还是状态机,问题复杂度及看代码的实现思路.

如果按照上面的示例,Move的职责就是根据判断条件创建下一个状态并在最终状态内部干所有的活.可能if-else更好理解一些,省去不必要的“空”中间态.

func New(* ShardingSelector) (Merger, error) {
     // 有groupby且groupby的键为分片键,有聚合函数,没有having字句,有order by字句
     if  s.HasOrderBy && s.HasShardingKey && s.HasAggregate && !s.HasHaving && s.HasOrderBy {
        return GroupByShardingKeyAggregateNoHavingOrderByState{}.GetMergerType, nil
     }
    // ......
}
// 最终到达最终状态,有groupby且groupby的键为分片键,有聚合函数,没有having字句,有order by字句
type GroupByShardingKeyAggregateNoHavingOrderByState struct {}

// 最终状态move的返回值是nil,通过这个我们可以拿到最后的merger
func (g GroupByShardingKeyAggregateNoHavingOrderByState) Move(s *Selector) (State, error) {
    return nil,nil
}

func (g GroupByShardingKeyAggregateNoHavingOrderByState) GetMergerType() Merger {
    return &sortmerger.Merger{}
}

如果Move还需要解析一些数据,创建“子”merger,那么可能状态机更好——关注点分离,每一个状态处理一部分并在最终状态组合.

initState <--> state1 <-->  state2
                        解析数据        解析数据
                        创建merger1 创建merger2并组合merger1形成最终的merger

如果大部分merger是这种(未来极可能会变成这种情况),分阶段创建“子”merger并在最终状态里组合成最终merger,用状态机比较好.

flycash commented 1 year ago

这里我有两个想法。

在第二种想法中,困难之处在于怎么让 merger 接收灵活的参数,比如说有没有 HAVING,有没有 GROUP BY 等。在我最开始的设计中,采用了非常偷懒的设计,接收整个 ShardingSelector,merger 包肯定是用不了的,因为这意味着一个循环引用。或许可以考虑定义个一个非常简单的结构体,里面就放这种查询特征。

根据判断条件创建下一个状态并在最终状态内部干所有的活

这也是我犹豫的一个点。如果站在 eorm 本身来说,的确是可以在最终状态内部干所有的活。但是如果我后面用这个 merger 建设一个 driver 形态,或者 proxy 形态的分库分表,那么就无法复用这部分了。我说 merger 看上去是最适合放这种分发逻辑,也是出于这种考虑。