[WindowFunction] 系列二:TiDB 中的 Window Function 实现

本文深入探讨了TiDB数据库如何处理窗口函数,包括分组策略、并发处理、内存控制等方面。文章指出,TiDB通过将具有相同specname的窗口函数进行分组,处理窗口函数的frame,并利用specEqual函数检查是否需要更新规格。然而,一个潜在的优化点是未将row_number和rank等函数合并到同一窗口中,这可能是因为它们在处理frame时产生了不同设置。此外,为了减少内存消耗,row_number会添加默认frame。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

接下来几篇文章将分析一些热门数据库中的窗口函数实现方式,主要包括 节点间并发、节点内并发、具体实现、内存控制,以及其它值得注意的细节。

TiDB

代码:代码
文档:文档
design doc: design doc

窗口函数分组

在 parser 将 sql 解析为 ast 之后,tidb 的 planbuilder 会将 windowFunction 进行分组,合并具有相同 spec name 的窗口,核心逻辑在以下函数中:

func (b *PlanBuilder) groupWindowFuncs(windowFuncs []*ast.WindowFuncExpr) (map[*ast.WindowSpec][]*ast.WindowFuncExpr, []*ast.WindowSpec, error) {
	// updatedSpecMap is used to handle the specifications that have frame clause changed.
	updatedSpecMap := make(map[string][]*ast.WindowSpec)
	groupedWindow := make(map[*ast.WindowSpec][]*ast.WindowFuncExpr)
	orderedSpec := make([]*ast.WindowSpec, 0, len(windowFuncs))
	for _, windowFunc := range windowFuncs {
		if windowFunc.Spec.Name.L == "" {
			spec := &windowFunc.Spec
			if spec.Ref.L != "" {
				ref, ok := b.windowSpecs[spec.Ref.L]
				if !ok {
					return nil, nil, ErrWindowNoSuchWindow.GenWithStackByArgs(getWindowName(spec.Ref.O))
				}
				err := mergeWindowSpec(spec, ref)
				if err != nil {
					return nil, nil, err
				}
			}
			spec, _ = b.handleDefaultFrame(spec, windowFunc.F)
			groupedWindow[spec] = append(groupedWindow[spec], windowFunc)
			orderedSpec = appendIfAbsentWindowSpec(orderedSpec, spec)
			continue
		}

		name := windowFunc.Spec.Name.L
		spec, ok := b.windowSpecs[name]
		if !ok {
			return nil, nil, ErrWindowNoSuchWindow.GenWithStackByArgs(windowFunc.Spec.Name.O)
		}
		newSpec, updated := b.handleDefaultFrame(spec, windowFunc.F)
		if !updated {
			groupedWindow[spec] = append(groupedWindow[spec], windowFunc)
			orderedSpec = appendIfAbsentWindowSpec(orderedSpec, spec)
		} else {
			var updatedSpec *ast.WindowSpec
			if _, ok := updatedSpecMap[name]; !ok {
				updatedSpecMap[name] = []*ast.WindowSpec{newSpec}
				updatedSpec = newSpec
			} else {
				for _, spec := range updatedSpecMap[name] {
					eq, err := specEqual(spec, newSpec)
					if err != nil {
						return nil, nil, err
					}
					if eq {
						updatedSpec = spec
						break
					}
				}
				if updatedSpec == nil {
					updatedSpec = newSpec
					updatedSpecMap[name] = append(updatedSpecMap[name], newSpec)
				}
			}
			groupedWindow[updatedSpec] = append(groupedWindow[updatedSpec], windowFunc)
			orderedSpec = appendIfAbsentWindowSpec(orderedSpec, updatedSpec)
		}
	}
	// Unused window specs should also be checked in b.buildWindowFunctions,
	// so we add them to `groupedWindow` with empty window functions.
	for _, spec := range b.windowSpecs {
		if _, ok := groupedWindow[spec]; !ok {
			if _, ok = updatedSpecMap[spec.Name.L]; !ok {
				groupedWindow[spec] = nil
				orderedSpec = appendIfAbsentWindowSpec(orderedSpec, spec)
			}
		}
	}
	return groupedWindow, orderedSpec, nil

这个函数有两个返回值,第一个是 spec 到 func 的 map,第二个是 spec 的切片,函数流程如下:

依次遍历所有 windowFunction:

  1. 如果 spec 匿名:
    • 首先调用 mergeWindowSpec 处理 ref spec(形如 w2 as w1 order by deptid),填充 partitionBy 和 orderBy
    • 调用 handleDefaultFrame 函数处理 frame,会根据 func 的默认 frame 与 spec frame 进行组合。
    • 将这对 spec/func 添加到返回值中
    • continue
  2. 对于具名 spec:
    • 首先处理 frame,注意对于相同的 spec,不同的 func 可能会产生不同的 frame,从而改变 spec。
    • 如果 spec 不会因 frame 改变,添加到返回值中,返回。
  3. 否则,利用 specEqual 函数判断是否之前已经生成出来过相同的 spec。如果有就合并,如果没有就创建。
    然后添加到返回值中,返回。

想到一个优化点:为什么不用 specEqual 判断匿名 spec 和具名 spec 是否相等从而进行更多的合并。。联系了开发者,称最后没来得及做,所以留了个 todo。

一个问题:
在 tidb 中,在同一个窗口 w 上执行 row_number 和 rank,并不会将它们分到一个组中。

mysql> explain select *, row_number() over w, rank() over w from employee window w as (partition by deptid);
+--------------------------------+---------+-----------+----------------+---------------------------------------------------------------------------------------------------------+
| id                             | estRows | task      | access object  | operator info                                                                                           |
+--------------------------------+---------+-----------+----------------+---------------------------------------------------------------------------------------------------------+
| Projection_8                   | 17.00   | root      |                | test.employee.empid, test.employee.deptid, test.employee.salary, Column#8, Column#7                     |
| └─Window_10                    | 17.00   | root      |                | row_number()->Column#8 over(partition by test.employee.deptid rows between current row and current row) |
|   └─Window_11                  | 17.00   | root      |                | rank()->Column#7 over(partition by test.employee.deptid)                                                |
|     └─Sort_15                  | 17.00   | root      |                | test.employee.deptid                                                                                    |
|       └─TableReader_14         | 17.00   | root      |                | data:TableFullScan_13                                                                                   |
|         └─TableFullScan_13     | 17.00   | cop[tikv] | table:employee | keep order:false, stats:pseudo                                                                          |
+--------------------------------+---------+-----------+----------------+---------------------------------------------------------------------------------------------------------+

答案:因为 handleDefaultFrame 会分别对这两个函数进行处理:

  1. 给 row_number 加了一个 deafult frame(between current row and current row),rank 没加,所以不认为他们是一组了。

至于为什么要给 row_number 加 default_frame,开发者的回复是,为了避免将整个分区物化的内存消耗,所以给 row_number 加了一个宽度为 1 的窗口。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值