lectrue10 排序和聚合算法

        查询计划:到目前为止,我们主要讨论的是访问方法(即如何通过索引或扫描找到数据)。现在,我们需要讨论如何真正执行这些查询。

        数据库系统会将SQL语句编译成查询计划,查询计划是一个由算子组成的树状结构。我们将在后续的查询执行课程中详细讲解这部分内容。

        对于面向磁盘的数据库系统,我们将利用缓冲池来实现那些在处理过程中需要溢出到磁盘的算法,我们的核心目标是尽可能减少算法的I/O次数。


        排序:由于在关系模型中,表中的元组没有特定的顺序,因此 DBMS 需要对数据进行排序。排序(潜在地)被用于 ORDER BY、GROUP BY、JOIN 和 DISTINCT 算子。如果待排序的数据能全部装入内存,DBMS 可以使用标准排序算法(如快速排序)。如果数据量太大无法装入内存,则需要使用外部排序(如归并排序),该算法能够根据需要将数据溢出到磁盘,并且相比随机 I/O,它更倾向于使用顺序 I/O。

        如果查询包含带有LIMIT的ORDER BY,DBMS 只需扫描一次数据即可找到前 N 个元素。这被称为 Top-N 堆排序。堆排序的理想场景是前 N 个元素可以装入内存,这样 DBMS 在扫描数据时,只需在内存中维护一个排序好的优先级队列即可。

        对于大到无法装入内存的数据,标准排序算法是外部归并排序。这是一种分治算法,它将数据集分割成独立的初始轮次并分别进行排序。它可以根据需要将这些轮次溢出到磁盘,然后一次读回一个进行处理。该算法包含两个阶段:

        1.排序:首先,算法对能装入主存的小块数据进行排序,然后将排序后的页面写回磁盘。

        2.归并:然后,算法将排序后的子文件合并成一个更大的单一文件。

        注:什么是溢出?溢出是指当RAM不足以容纳当前处理的数据时,系统被迫将数据暂时写入硬盘的行为。

        排序轮次可以是早物化的(即具体数值直接存储在页面中),也可以是晚物化的(即轮次中仅存储记录 ID/RID,稍后再读取具体数值)。

        二路归并排序:归并排序最基础的版本是二路归并排序。

  • 排序阶段:算法读取每个页面,对其内部数据进行排序,然后将排序后的版本写回磁盘。
  • 归并阶段:算法使用三个缓冲页:
    • 从磁盘读入两个排序好的页面(作为输入)。
    • 将它们合并到第三个缓冲页(作为输出)中。
    • 每当第三个页面填满时,就将其写回磁盘并重置为空白页。
  • Run(轮次/归并段):每一组排序好的页面被称为一个 Run。算法随后递归地将这些 Run 合并在一起。

        开销分析:如果N是总页面数,该算法的性能指标如下。

        遍历次数:总共需要进行(1+

\log_{2}N

)次遍历。其中一次用于初始排序,

\log_{2}N

用于递归归并。

        总I/O开销:2N * 遍历次数,这是因为在每一次遍历中,每个页面都要执行一次I/O读取和一次I/O写入。

        注:更准确的说,这是因为在每一次遍历中,每个元组都要执行一次读取和写入。

        通用K路归并排序:通用版本的算法允许 DBMS 利用三个以上的缓冲页。假设 B 是可用的缓冲页总数。那么:

  • 排序阶段:算法可以一次读取 B 个页面,并向磁盘写回 N/B 个排序好的 Run。这里的Run就是最小有序单位,即递归深入的终点。
  • 归并阶段:每次遍历可以合并多达 K 个 Run,同样使用一个缓冲页存放合并后的数据,并根据需要写回磁盘。

        在通用版本中,算法执行

$1 + \lceil \log_{B-1} \lceil N/B \rceil \rceil$

次遍历,总I/O开销依然是2N * 遍历次数。

        注:K=B-1,因为在归并阶段,必须要有K个页面来存放来自K个不同Run的当前数据,同时留出一个页面来存放归并后的结果。

        二路归并时需要有两个指针,然后比较这两个指针上的最小值(假如是从小到大排序)并填入归并后的页面。而K路归并则需要K个指针。

        

$1 + \lceil \log_{B-1} \lceil N/B \rceil \rceil$

如何得出,排序阶段之后我们有N/B个Run,每次合并K个Run,再加上到达初始阶段的那个1,就是那个遍历次数的公式。

        双缓冲优化:外部归并排序的一种优化手段是预取,在系统处理Run的同时,后台预取下一个 Run 并将其存储在第二个缓冲区中。通过持续利用磁盘,这减少了每一步等待 I/O 请求的时间。这种优化需要使用多线程,因为预取应该在处理当前 Run 的计算逻辑时同步发生。

        比较优化:代码优化常用于加速排序中的比较操作。与其将比较器作为函数指针传递给排序算法,不如将其针对特定的键类型进行硬编码。C++中的模板特化就是一个例子,另一种针对字符串比较的优化是后缀截断,长VARCHAR键的二进制前缀可以先进行相等检查,只有当前缀相等,才会回退到较慢的标准字符串比较。

        使用B+树:有时利用现有的B+树索引来辅助排序比使用外部归并排序更有优势。

  • 聚簇索引:DBMS 只需遍历 B+ 树。由于是聚簇索引,数据按正确顺序存储,因此 I/O 访问是顺序的。这总是优于外部归并排序,因为不需要额外的计算。
  • 非聚簇索引:遍历树几乎总是更糟糕的,因为每条记录可能存储在任何页面中,几乎所有的记录访问都会导致一次磁盘随机读取。

        聚合:在查询计划中,聚合操作符将一个或多个元组的值合并为单个标量值。实现聚合的方法有两种,排序和哈希。

        排序:数据库管理系统(DBMS)首先根据 GROUP BY 的键对元组进行排序。如果所有数据都能装入缓冲池,可以使用内存排序算法(如快速排序);如果数据量超过内存大小,则使用外部归并排序算法。随后,DBMS 对排序后的数据进行顺序扫描以计算聚合结果。该操作符的输出将按键排序。

        在执行排序聚合时,调整查询操作的顺序以最大化效率至关重要。例如,如果查询需要过滤,最好先执行过滤,然后再对过滤后的数据进行排序,以减少需要排序的数据量。

        哈希:在计算聚合时,哈希的计算开销可能比排序更低。DBMS 在扫描表时会填充一个临时哈希表。对于每条记录,检查哈希表中是否已经存在相应的条目,并进行适当的修改。如果哈希表的大小太大而无法装入内存,DBMS 必须将其溢出到磁盘。完成这一过程分为两个阶段:

        分区:使用哈希函数 h1 根据目标哈希键将元组拆分到磁盘上的分区中。这将把所有匹配的元组放入同一个分区。假设总共有 B 个缓冲区,我们将有 B-1 个用于分区的输出缓冲区和 1 个用于输入数据的缓冲区。如果任何分区满了,DBMS 将其溢出到磁盘。

        重哈希:对于磁盘上的每个分区,将其页面读入内存,并基于第二个哈希函数h2(h1≠h2)构建内存哈希表,然后遍历该哈希表的每个桶,将匹配的元组汇集在一起以计算聚合。这假设每个分区都能装入内存。重哈希的作用域是单个分区内的数据,它是粒度更细的哈希。

        在重哈希阶段,DBMS可以存储形式为(GroupByKey -> RunningValue)(分组键 -> 运行值)的键值对来计算聚合。RunningValue 的内容取决于具体的聚合函数(如 SUM, AVG, COUNT 等)。向哈希表中插入新元组时:

  • 如果找到了匹配的 GroupByKey,则相应地更新 RunningValue
  • 否则,插入一个新的 (GroupByKey -> RunningValue) 对。

        通常情况下,对于聚合操作,哈希总是更高效的,除非数据预先已经排好序(例如紧跟在OEDER BY之后)。

        注:聚合本身过程其实并不困难,我们只需要遍历一遍表即可。问题在于,为了聚合过程的效率,我们需要先让表保持一个相对有序的状态。排序是直接使其有序,而哈希则是让键相同的数据尽可能聚在一起。我们通过哈希函数h1,将“键的哈希值相同的元组”分到同一桶中,然后又通过另一个哈希函数h2将“键的哈希值相同的元组”又分到同一桶中。通过这样,我们尽可能避免了哈希冲突。(哈希值相同不一定key就相同,对于不同的哈希函数,相同的键可以得到不同的值,不同的键也可以得到相同的值,这也使为什么要使用两个哈希函数进一步细分)

        哈希实现的好处在于:

        1.避免了

$O(N \log N)$

的排序成本。

        2.分而治之,假如表有100G,内存只有1G。使用排序的话,虽然有外部归并排序,但多路归并时的I/O开销很大,且逻辑复杂。而哈希将100G的大表划分为了100个1G的分区。这样,处理第二个分区时,第一个分区的内存就可以清空重用了。

        3.在重哈希阶段,由于分区足够小(能完全装入 CPU 缓存或内存),构建哈希表的操作都在高速缓存中进行,极少触发 Page Fault。相比之下,排序在大跨度交换数据时,容易导致缓存未命中。

Read more

Flutter 组件 spry 适配鸿蒙 HarmonyOS 实战:轻量化 Web 框架,构建高性能端侧微服务与 Middleware 治理架构

Flutter 组件 spry 适配鸿蒙 HarmonyOS 实战:轻量化 Web 框架,构建高性能端侧微服务与 Middleware 治理架构

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.ZEEKLOG.net Flutter 组件 spry 适配鸿蒙 HarmonyOS 实战:轻量化 Web 框架,构建高性能端侧微服务与 Middleware 治理架构 前言 在鸿蒙(OpenHarmony)生态迈向全场景分布式协同、涉及设备端侧 API 暴露、轻量化资源服务镜像及严苛的跨端 RPC 通信背景下,如何实现一套既能保持极低内存足迹(Footprint)、又能提供类似后端(Node.js/Koa)般丝滑开发体验且具备全异步处理能力的“端侧 Web 基座”,已成为决定应用分布式自治能力与全栈同构效率的关键。在鸿蒙设备这类强调 AOT 极致效能与背景任务严格限制的环境下,如果应用依然采用重量级的 HTTP 服务端,由于由于进程级的上下文切换开销,极易由于由于“算力溢出”导致鸿蒙应用在作为服务端响应时发生明显的电量损耗。 我们需要一种能够解耦路由逻辑、支持

By Ne0inhk
前端八股文面经大全:字节前端一面(2026-2-1)·面经深度解析

前端八股文面经大全:字节前端一面(2026-2-1)·面经深度解析

前言 大家好,我是木斯佳。 在这个春节假期,当大家都在谈论返乡、团圆与休息时,作为一名技术人,我的思考却不由自主地转向了行业的「冬」与「春」。 相信很多人都感受到了,在AI浪潮的席卷之下,前端领域的门槛在变高,纯粹的“增删改查”岗位正在肉眼可见地减少。曾经热闹非凡的面经分享,如今也沉寂了许多。但我们都知道,市场的潮水退去,留下的才是真正在踏实准备、努力沉淀的人。学习的需求,从未消失,只是变得更加务实和深入。 正值春节,也是复盘与规划的好时机。结合ZEEKLOG这次「春节代码贺新年」活动所提倡的“用技术视角记录春节、复盘成长”,我决定在这个假期持续更新专栏,帮助年后参加春招的同学。 这个专栏的初衷很简单:拒绝过时的、流水线式的PDF引流贴,专注于收集和整理当下最新、最真实的前端面试资料。 我会在每一份面经和八股文的基础上,尝试从面试官的角度去拆解问题背后的逻辑,而不仅仅是提供一份静态的背诵答案。无论你是校招还是社招,目标是中大厂还是新兴团队,只要是真实发生、有价值的面试经历,我都会在这个专栏里为你沉淀下来。 温馨提示:市面上的面经鱼龙混杂,

By Ne0inhk

3D效果:HTML5 WebGL结合AI实现智能3D场景渲染

3D效果:HTML5 WebGL结合AI实现智能3D场景渲染 📝 本章学习目标:本章聚焦高级主题,帮助读者掌握工程化开发能力。通过本章学习,你将全面掌握"3D效果:HTML5 WebGL结合AI实现智能3D场景渲染"这一核心主题。 一、引言:为什么这个话题如此重要 在前端技术快速发展的今天,3D效果:HTML5 WebGL结合AI实现智能3D场景渲染已经成为每个前端开发者必须掌握的核心技能。HTML5作为现代Web开发的基石,与AI技术的深度融合正在重新定义前端开发的边界和可能性。 1.1 背景与意义 💡 核心认知:HTML5与AI的结合,让前端开发从"静态展示"进化为"智能交互"。这种变革不仅提升了用户体验,更开辟了前端开发的新范式。 从2020年TensorFlow.js的成熟,到如今AI辅助开发工具的普及,前端开发正在经历一场智能化革命。据统计,超过70%的前端项目已经开始尝试集成AI能力,AI辅助前端开发工具的市场规模已突破十亿美元。 1.2 本章结构概览 为了帮助读者系统性地掌握本章内容,

By Ne0inhk

2.28 GBDT算法原理详解:梯度提升决策树,从数学推导到代码实现

2.28 GBDT算法原理详解:梯度提升决策树,从数学推导到代码实现 引言 GBDT(Gradient Boosting Decision Tree)是梯度提升决策树,是集成学习中最强大的算法之一。XGBoost、LightGBM都是基于GBDT的优化。本文将深入解析GBDT的数学原理,并提供完整的代码实现。 一、GBDT原理 1.1 核心思想 GBDT通过串行训练多个决策树,每个树修正前面所有树的误差。

By Ne0inhk