Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ShardingSelector: 增强的 ShardingAlgorithm 设计与实现 #157

Closed
Tracked by #185
flycash opened this issue Feb 27, 2023 · 6 comments · Fixed by #162
Closed
Tracked by #185

ShardingSelector: 增强的 ShardingAlgorithm 设计与实现 #157

flycash opened this issue Feb 27, 2023 · 6 comments · Fixed by #162

Comments

@flycash
Copy link
Contributor

flycash commented Feb 27, 2023

仅限中文

使用场景

目前在合并请求 #145 里面我们初步解决了最简单的分库分表场景,即只考虑等值查询的条件,该如何生成SQL。

现在我们需要进一步强化 “分库分表” 规则这么一个概念。对于一个分库分表规则来说,它需要解决:

  • 根据输入的分库分表键的值,判断会命中哪个目标表
  • 在没有提供任何的分库分表键的时候,返回全部库和全部表,注意要返回类似于 "db.tbl" 这种形态的,而不能独立返回 []db, []tbl 这种

进一步说,如果在提供了分库分表键的情况下我们只能确定库,那么分库分表规则能够返回该库下面的所有的命中的表,也就是第一种情况的一个特例而已。

基本分库分表

但是进一步思考,我们会发现这个 ShardingAlgorithm 不是很好设计。因为用户需要的各种分库分表规则简直千变万化:

  • 简单的哈希分库分表:比如说按照 user_id % 32 来进行分库分表
  • 范围分库分表:比如说按照日期进行分库分表
  • 其它分库分表:这一类一般不需要按照哈希或者范围之类的来计算,可能就是用值来拼凑一下,或者判断一下标记位啥的;
    • 比如说按照国家或者地区来分库分表,比如说简单的写法就是 $region_user_db 这种表达式,那么当 region = cn 的时候,就变成了 cn_user_db。如果 $region 没指定,或者说部分数据没有按照地区分库分表,那么就会落到一个兜底的,比如说 user_db 里面
    • 按照压力测试来进行分库分表,也就是引入了所谓的影子库和影子表的问题,类似于 $shadow_user_db,如果是压测请求,那么就会命中 shadow_user_db。这种的特殊之处在于是不是压测请求,是需要从 context.Context 里面判断的
    • 按照 A/B 测试来进行分库分表:A/B 测试其实很少会使用不同的数据源,但是也防不住。所以在这种情况下它会类似于压力测试的案例

复合分库分表规则

很多时候,分库分表并不是只使用我们前面提到的那些基本分库分表的做法,而是可能涉及到多个组合在一起。

最典型的就是在压测的情况下,同时业务数据本身就是分库分表的。举个例子,假如说现在我们的生产库 user_db 是按照 user_id / 32 % 32 来分库,按照 user_id %32 来进行分表,那么对于生产库,我们可以用表达式写成:

user_db_$(user_id/32%32).user_tbl_$(user_id%32)

与之对应的影子库影子表则是:

shadow_user_db_$(user_id/32%32).shadow_user_tbl_$(user_id%32)

那么两个合并在一起则是:

$(shadow)_user_db_$(user_id/32%32).$(shadow_user)_tbl_$(user_id%32)

这里面有一个假设,就是影子库和影子表的分库分表规则和线上库的是一模一样的。但是有些时候有些公司不按套路来出牌,那么影子库和影子表的分库分表规则就是不同的,例如说生产库按照 32 来划分,而影子库影子表因为数据比较少,采用了 4 来划分,那么就变成了:

user_db_$(user_id/32%32).user_tbl_$(user_id%32)
shadow_user_db_$(user_id/4%4).shadow_user_tbl_$(user_id%4)

这个问题类似于后面我们提到的 zone 问题。这是一个很典型的场景:某一个分库分表的键取值,会影响其他分库分表键对应的分库分表逻辑

zone 问题

所谓 zone,简单直白的解释就是不同的机房,不同 zone 之间可能允许通信,也可能不允许通信。比如说一个国际大厂,它的 zone 可能分成美国 zone,东南亚 zone,中国 zone。当然这只是一个简单的例子,具体 zone 怎么划分都是各个公司根据自己的业务和合规情况来划分的。

那么很多时候业务方都会要求分库分表解决 zone 的问题,比较棘手的就是不同 zone 内部分库分表的规则又不同,比如说中国大陆人口众多,那么按照 32 来分是合理的,而台湾作为一个省,屁大点人口,可能只需要按照 4 来分,于是我们就有两个:

cn_user_db_$(user_id/32%32).user_tbl_$(user_id%32)
tw_user_db_$(user_id/4%4).user_tbl_$(user_id%4)

一般来说,zone 的划分不太会影响分表。

那么也可以看出来,它本质上和影子库影子表面临的问题是一样的。

表达式问题

前面我已经多次提到了分库分表的一种形式表达,比如说在影子库影子表里面使用的:

user_db_$(user_id/32%32).user_tbl_$(user_id%32)
shadow_user_db_$(user_id/4%4).shadow_user_tbl_$(user_id%4)

那么这里就会有一个问题,我们的框架要不要支持这种表达式?以及如果支持的话,这种表达式应该如何定位,是应该认定为是一个框架必须要支持的核心功能,还是应该要认定为它只是组织分库分表表达式的一种形态?

那么很显然,既然我会问这个问题,而且根据我一贯的设计风格,那么我的答案是:表达式确实只应该作为一个扩展功能

也就是从设计上来说,即便分库分表不支持任何的表达式,那么用户依旧可以通过编程接口来指定自己的分库分表规则,类似于:

a := &MyShardingAlgorithm{}
err := registry.Register(&User{}, WithSharding(a))

那么为什么现在普遍分库分表都有类似的表达式呢?很简单,就是一个历史惯性而已。最开始的分库分表都倾向于为了减轻用户的接入难度。然而就我的观察来说,一些人很难学会怎么写这些表达式,比如说我在某处接到最多的问题就是这个分库分表该怎么写。

因此我认为表达式整体上不如编程接口。或者说,在提供了编程接口之后,我们没有十分强烈的动机去提供这么一种表达式解析的支持。

未来我们可以考虑支持。

分库分表对 DSN 的影响

注意,这里我们讨论的是 DSN(data source name),而不是 DNS。这里我依旧采用前面的这种表达式形式来阐述这个问题。

前面我举例的都是只影响到了 DB 名字和表名。那么从实际情况上来看,事情还要复杂一点。

比如说,这种分库分表规则:

$region.db.mycompany.com:3306/$region_user_db?timeout=123

那么显然我们的分库分表影响到了数据库的连接信息。

实际上,如果从最宽泛的角度谈,那么分库分表本身会影响 DSN 的任何一个部分。也就是说,包含端口、参数部分。

大多数情况下,一家公司内部如果使用分库分表的话,端口大多数时候都是同一个,不管你是分库还是读写分离的从库,都是使用一个固定的端口,比如说 3306,或者出于安全的考虑,大家都用另外一个,例如 4406 等。

分库分表对主从分离的影响

一般来说,如果一个公司准备采用分库分表的解决方案了,那么基本上可以认为这家公司肯定用了读写分离,也就是说数据库大概率都是一个主从集群。

所以我们在设计分库分表的时候要考虑到这个情况:

$region.db.master.mycompany.com:3306/$region_user_db?timeout=123
$region.db.slave.mycompany.com:3306/$region_user_db?tineout=456

实际上,公司可能有多个从库藏在这个从库的 DSN 背后,但是公司也可能一个从库给一个 DSN:

$region.db.master.mycompany.com:3306/$region_user_db?timeout=123
$region.db.slave1.mycompany.com:3306/$region_user_db?tineout=456
$region.db.slave2.mycompany.com:3306/$region_user_db?tineout=456

大体上我们认为,在分库分表的时候确定的应该是一个主从集群,至于这个主从集群内部究竟是怎么搞的,分库分表算法一点都不关心。

可行方案

我们的解决方案有一个非常核心的原则:分库分表中间件只知道接口,而不知道任何细节。形象点说,就是我把具体的分库分表算法,比如说哈希分库分表、范围分库分表或者复合分库分表算法从整个框架里面挪走,我的分库分表依旧能够正常运作。

这意味着:

  • 没有任何分库分表实现具有特殊地位
  • 分库分表接口的设计与实现本身可以独立出去作为一个单独的项目

实际上,因为常规来说我们一直说的都是分库分表,但是从前面的场景分析,我们应该能够看出来,严格说法应该是分集群分库分表。在这里我将 zone 之类的概念看做是一个是由集群衍生出来的业务规则上的概念,作为分库分表中间件,实际上没有什么zone,region 之类的概念。只有在特定的分集群分库分表规则实现里面会有

核心接口

核心接口只需要一个:

type Algorithm interface {
	// ShardingKeys 返回所有的 sharding key
	// 这部分不包含任何放在 context.Context 中的部分,例如 shadow 标记位等
	// 或者说,它只是指数据库中用于分库分表的列
	ShardingKeys() []string
	// Broadcast 返回所有的目标库、目标表
	Broadcast(ctx context.Context) []Dst
	// Sharding 返回分库分表之后目标库和目标表信息
	Sharding(ctx context.Context, req Request) (Result, error)
}

type Result struct {
	Dsts []Dst
}

type Dst struct {
	Name  string
	DB    string
	Table string
}

type Request struct {
	SkValues map[string]any
}

注意:

  • 返回的 Dst 里面包含了 DSN、DB 和 Table 三个字段。其中 Name 其实是一个值得斟酌的字段,它主要是为了解决 $(region).master.db.mycompany.com:3306/user_db 这种问题。或者说,它类似于别的框架中 data source 的概念;
  • 如果某个实现无法从 ctx 或者 req 里面找到任何跟定位分库分表有关的信息,那么就应该返回所有的候选的数据库和数据表。这也就是所谓的广播效果;
  • Broadcast 依旧引入了一个 ctx 作为输入,是因为我们要考虑类似于影子库上的广播要求。从理论上来说,它等价于 Sharding 方法里面没有传入任何的 sharding key。这个方法可以考虑去除;
  • 之所引入了 Request 和 Result 两个结构体,就是以防万一,如果我们将来需要扩展分库分表的功能,不至于连后悔药都没得吃;
  • 在 SkValues 里面我们使用了 any 来作为值类型,这意味着我们并不解决任何类型转换的问题。也就是意味着,用户在构造查询的时候,传入的是什么参数,我们就会原封不动往下传;
  • 在断定一个查询语句会命中哪个表的时候,会多次调用这个 Sharding 方法。比如说在按照 UserID 进行分库分表的场景下,类似于查询条件 WHERE (user_id = ?) OR (user_id = ?),则会调用两次 Sharding 方法

那么用伪代码来描述使用起来的效果则是:

meta := db.r.Get(t)
shardingResp := meta.algorithm.Sharding(req)
for _, dst := range shardingResp.Dsts {
ds := findDs(dst.Name)
ds.Exec(ctx, sql, args...)
}

用文字来简短描述则是:

  • 通过遍历 WHERE 里面的每一个查询条件(插入的话则是插入的元素),针对每一个条件调用 Sharding 方法
  • 根据 AND OR 的合并规则筛选目标节点
  • 根据目标节点中的 Name 找到对应的 Datasource,而后让对应的 Datasource 执行查询
  • 后续则是处理结果集

统一的 Datasource 抽象

我计划支持一个统一的 Datasource 抽象。它会有以下实现:

  • 单一的 DB,在 GO 里面,它的代表就是 sql.DB 实例
  • 主从集群,(+影子集群)
  • 分库分表集群,即包含了所有库所在的集群信息,这些集群信息可以考虑包含影子集群
  • zone 集群。如果将来真的有千奇百怪的 zone 之类的问题,那么我希望将对应的变更局限在这里

目前在第一期里面,这个不需要支持,后续再支持,我会额外创建 issue。

AND OR NOT

在引用了这个抽象之后,对 AND、OR、NOT 的支持是要发生变更的,但是我们保持已有的逻辑不变,只是在取并集或者合集的时候,将 Dst.Name 纳入考虑。即只有 Name, DB, Table 三者相等我们才认为是完全相同的。

但是在已有逻辑实现中,我们会碰到巨大的性能问题,即我们会频繁调用 Sharding 方法,引入额外的内存分配,这是我们后续要考虑优化的地方。

哈希实现

这里我们讨论一个简单的实现,即数据源相同的,或者说返回的 Dst 里面的 Name 都是同一个的情况。那么一个哈希实现我们很容易设计出来:

type Hash struct {
	Datasource   string
	Base         int
	ShardingKey  string
	DBPattern    string
	TablePattern string
}

func (h Hash) ShardingKeys() []string {
	return []string{h.ShardingKey}
}

func (h Hash) Broadcast(ctx context.Context) []Dst {
	panic("implement me")
}

func (h Hash) Sharding(ctx context.Context, req Request) (Result, error) {
	skValue, ok := req.SkValues[h.ShardingKey]
	if !ok {
		return Result{
			Dsts: h.Broadcast(ctx),
		}, nil
	}
	return Result{
		Dsts: []Dst{
			{
				Name:  h.Datasource,
				DB:    fmt.Sprintf(h.DBPattern, skValue.(int)%h.Base),
				Table: fmt.Sprintf(h.TablePattern, skValue.(int)%h.Base),
			},
		},
	}, nil
}

不过现实中一般哈希分库分表都不会那么简单粗暴,比如说它们可能用时前面的那种 DB/32%32 这种,那么简单修改这个 Hash 就可以提供一个新的实现。

基于范围的分库分表实现也是类似。但是基于范围的分库分表有一个地方比较恶心。比如说分表是按照日,分库是按照月,分集群是按照年。那么就意味着在转年的时候我们必须要有办法初始化一个新的数据源。

复合分库分表

我们可以简单写出来一个混合了影子库和哈希分库分表的:

func (h ShadowHash) Sharding(ctx context.Context, req Request) (Result, error) {
	skValue, ok := req.SkValues[h.ShardingKey]
	if !ok {
		return Result{
			Dsts: h.Broadcast(ctx),
		}, nil
	}
	if ctx.Value("shadow") == "true" {
		return Result{
			Dsts: []Dst{
				{
					Name:  h.Datasource,
					DB:    fmt.Sprintf("shadow_"+h.DBPattern, skValue.(int)%h.Base),
					Table: fmt.Sprintf(h.TablePattern, skValue.(int)%h.Base),
				},
			},
		}, nil
	}
	return Result{
		Dsts: []Dst{
			{
				Name:  h.Datasource,
				DB:    fmt.Sprintf(h.DBPattern, skValue.(int)%h.Base),
				Table: fmt.Sprintf(h.TablePattern, skValue.(int)%h.Base),
			},
		},
	}, nil
}

如果采用了影子表,或者影子集群,或者甚至于影子库和影子表的分库分表规则都不同,那么就做类似的修改

基于表达式的实现

我们可以考虑提供类似于其它框架支持的表达式的分库分表算法实现。

type Expr struct {

}

那么问题其实就剩下了表达式解析与字符串替换的问题了。比如说:

user_db_$(user_id/32%32).user_tbl_$(user_id%32)

那么也就是要把表达式 $(user_id/32%32) 提取出来,而且要知道知道 user_id 的值要从 SkValues 中拿到,然后再执行 /32%32。

后续考虑支持,第一期不支持。

测试

单元测试

单元测试必须覆盖以下所有的场景:

  • 只分表
  • 只分库
  • 同时分库分表
  • 只分 Datasoure
  • 分集群分库分表混合影子库影子表

以上所有的测试用例要进一步考虑 AND,OR,NOT 的效果

其它

多种分库分表规则

在一些特定情况下,用户对一张表都有多套分库分表,这种我们暂时不打算支持。我总体上认为是业务层面上设计不合理,所以中间件犯不着支持,毕竟我们是开源又不是公司内部项目,老板说支持就必须支持。

而且大多数情况下,用户可以通过组合 Algorithm 来达成类似的目标。

其它语句

正如我之前说过的,我们并不打算解决所有的问题。我们这里主要集中解决增删改查,而且是对业务数据的增删改查。

那么一些特别的语句,比如说 CREATE USER 之类的。当然并不是说不能执行,而是说犯不着特意在分库分表内核为它们留下接口。

只是说随缘,如果不需要额外的努力也恰好可以支持,那就支持;如果需要额外的努力,那么我们就不支持。

审慎思考核心与非核心

前面梳理了不同的分库分表场景之后,我要强烈批评两个错误设计:

  • 给予某一类分库分表特殊地位的设计
  • 认为分库分表表达式是分库分表核心功能的设计

就第一个问题来说,我认为很多分库分表中间件都犯了这个错误。比如说对哈希分库分表进行了特殊处理,或者说给予了特殊的地位。甚至于在设计表达式的时候,都给予了它特殊的地位。

这里我要提及一个案例,就是某司的分库分表中间件,欠缺一个对分库分表算法的一个抽象。后来果然在扩展支持一些功能的时候遇到了问题。比如说另我印象很深刻的就是支持 zone 概念的时候,在分库分表中间件中引入了和 zone 相关的很多概念。

他们的做法不同于我在这里提及的把 zone 等概念限定到某一个具体的分库分表算法的实现里面,而是在分库分表核心内部就引入了很多和 zone 相关的东西。

这就是典型的巨大的设计错误。而且在可以预计的未来,他们还会遇到更多的困难。

当然这并不是这家公司这部分开发人员才会犯下的错误,而是大多数设计者在设计中间件都会犯的错误。

常见的原因则是中间件研发者往往会受到业务研发的影响。一个业务研发认为重要的功能,中间件研发者很容易被误导,做成框架的核心功能。

而实际上,业务上认为重要的功能并不等于中间件要解决的核心问题。那么对于一个研发者来说,他就要审慎思考当公司要求中间件提供一个功能的时候,这个功能究竟是不是中间件的核心功能。

@flycash flycash changed the title ShardingSelector: 增强的 ShardingAlgorithm 设计与实现(未完待续) ShardingSelector: 增强的 ShardingAlgorithm 设计与实现 Feb 28, 2023
@flycash
Copy link
Contributor Author

flycash commented Mar 1, 2023

我要额外补充一个,即我提到的分集群分库分表。如果进一步考虑,我们其实能够设想到,在规模庞大的互联网公司里面,一般还伴随着 zone 、机房之类的概念。

zone 我已经说过了,本质上不同 zone 就是不同机房,然后再加上一些隔离和防火墙之类的东西,然后根据各个国家的监管搞一些访问规则控制。但是本质上就是不同的机房。

那么也就是说,在分集群的基础上可能还有更加宽泛的概念,比如说分机房。也就是进一步区分北京机房或者上海机房这种。比如说某个业务规则说,如果我 ctx 里面带上了一个地理区域信息,那么你需要根据地理区域信息,判断我这个数据要丢过去哪个机房。

但是这种也可以通过扩展接口实现来达成目标。从这里进一步引申出来的,就是所谓的数据库异地多活,比如说在上海有一个机房,而后在北京有一个机房,北京机房只是作为上海机房的备份,在上海机房崩溃之后,业务方可能希望分库分表中间件能够自动顺滑地切换到北京机房,那么同样也可以通过扩展我们的接口来达成类似的目标。不过这可能需要考虑更加多的问题,比如说 Algorithm 的这个异地容灾实现里面,它需要解决探活之类的问题。

但是正如我所说的,这一类需求,如果某个公司有,那么就证明他们的业务很复杂,体量非常大,也就是说他们有非常多优秀的程序员,那么他们应该自己开发分库分表中间件,而不是用我们这种开源的。毕竟我是从来不会考虑这种超大规模厂的独特需求的——你考虑了也没有用,里面的人要刷 KPI 是能找出一千个一万个理由来自研。

@Stone-afk
Copy link
Collaborator

这里的 Broadcast(ctx context.Context) 是否是按用户指定的分库分表,还是根据算法推导所有的分库分表,如果是自定义的话,是需要一个保存所有库名和一个保存所有表名的容器,但是看设计,这个设计是倾向于通过算法推导的

@Stone-afk
Copy link
Collaborator

如果要在接口外边控制只分库或者只分表,仅当前的接口设计可能不够又或者说通过 ctx 传递标记位控制

@Stone-afk
Copy link
Collaborator

又或者说,在可以直接通过装饰器由使用者自定义该能力

@Stone-afk
Copy link
Collaborator

// 分库分表
// 只分库
// 只分表
// 分集群分库
// 分集群分表
// 分集群分库分表

会出现只分集群分库,或者分集群分表的 情况吗?个人认为不太可能,因为都集群了,那肯定意味着分库分表了

@flycash
Copy link
Contributor Author

flycash commented Mar 2, 2023 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Done
Development

Successfully merging a pull request may close this issue.

2 participants