摘要
1) 一句话总结 本文通过 DAX Studio 的服务器计时功能,深入剖析了 DAX 筛选器的底层执行机制,强调了最大化存储引擎(SE)效率并减少公式引擎(FE)负载以优化查询性能的核心原则。
2) 关键要点
- 双引擎机制:存储引擎(SE)支持多核 CPU,负责高效检索数据并执行简单聚合;公式引擎(FE)仅支持单核 CPU,应尽量让 SE 承担更多查询工作。
- 简单筛选器的执行:简单的条件筛选通常会被直接转化为 SE 查询中的 WHERE 子句,只需单次 SE 查询即可高效完成。
- 引擎的自动优化:在某些简单场景下(如单列筛选),即使开发者使用了
FILTER()函数,DAX 引擎也能将其优化为与简单筛选相同的底层执行计划。 - 度量值内部筛选:将筛选器移入度量值内部会改变执行计划,可能导致引擎生成多个 SE 查询(例如一个用于获取筛选数据,另一个用于获取全局列表)。
- FILTER() 函数的底层逻辑:
FILTER()会保留现有的筛选上下文并添加新上下文。在复杂场景下,这会导致中间结果的“物化”(生成大量临时行),从而增加 SE 查询数量并拖慢速度。 - 复杂筛选器的代价:使用
CONTAINSSTRING()等复杂函数进行筛选时,匹配工作会被推给单核的 FE 处理(测试案例中 FE 耗时占比达 99.6%),极大地消耗 CPU 资源。 - 核心优化目标:编写 DAX 时应致力于:最小化 FE 的工作量、减少中间数据的物化(降低查询统计中的 Rows 数量)、以及减少 SE 查询的总数。
3) 风险与隐患
- 滥用 FILTER() 函数的风险:直觉上易于使用的
FILTER()函数不仅执行速度通常比简单筛选器慢、代码更繁琐,还可能因为其处理筛选上下文的特殊方式而产生不正确或令人困惑的结果。 - 复杂函数导致的性能瓶颈:在筛选器中使用复杂的 DAX 函数(如字符串查找)会迫使单核的公式引擎(FE)承担繁重计算,导致查询耗时显著增加(如从毫秒级劣化至数秒)。
- 外部逻辑拖累性能:报表设计人员可能会在视觉对象或报表层面添加非最优的筛选逻辑,从而破坏底层 DAX 的性能,必要时需通过创建专用的本地度量值来替代和优化。
正文
你是否曾想过,在 DAX 表达式中应用筛选器时究竟会发生什么?
简介
在开发 DAX 表达式(例如 DAX 度量值)或编写 DAX 查询时,我们总是会用到筛选器。
但是,当我们应用筛选器时,到底发生了什么?
本文正是为了探讨这个问题。
我将从简单的查询开始,并添加各种变体,以探索其底层机制。
我使用了 DAX Studio,并开启了显示每个查询的服务器计时(server timings)选项。
如果你想了解有关此功能的更多信息以及如何解读结果,请阅读本文末尾“参考资料”部分的第一篇文章。
让我们从基础查询开始:
当我们激活服务器计时并执行查询时,我们会得到执行统计信息以及获取数据所需的存储引擎(Storage Engine, SE)查询:
如你所见,我们只需要一个存储引擎(SE)查询即可检索结果。
该查询仅需 47 毫秒即可完成,并且几乎完全由 SE 处理(95.7%)。
SE 在查询上花费的时间越多越好,因为它是从数据存储和表中检索数据的组件。此外,SE 可以使用多个 CPU 核心,而公式引擎(Formula Engine, FE)只能使用一个。我们无法像检查 SE 查询那样轻松地准确检查 FE 中发生的情况。
你可以在上面提到的文章中了解有关这两个引擎之间差异的更多信息。
简短说明:
几个月前,我在这里写过一篇标题非常相似的文章。但是,那篇文章仅涉及时间智能函数中的日期筛选器,而本文则更进一步,深入探究其奥秘。
本文比那篇文章更具通用性。
如果你错过了那篇文章,我已将该文章的链接以及有关当前主题的其他资源添加到了下方的“参考资料”部分。
添加简单的筛选器
接下来,我们在查询中添加一个针对产品颜色为红色的简单筛选器:
以下是查询以及限制为产品颜色为红色的结果:
当我们查看查询统计信息时,我们会看到:
如你所见,整个查询在一个 SE 查询中执行完毕。
筛选器位于查询的 WHERE 子句中。因此,仅检索受限的数据。
这在“Rows”(行数)列中清晰可见,因为此查询仅返回 14 行。
但是,当我们使用 FILTER() 函数来筛选产品时会发生什么:
你可能知道,由于 FILTER() 函数的工作原理,通常不建议使用它。
你可以在下方“参考资料”部分链接的第二篇文章中了解有关此主题的更多信息。
结果没有改变:
但它如何影响执行计划和 SE 查询呢?
如你所见,在这种情况下,SE 优化了查询,产生了与之前相同的执行计划。
但是,随着我们更改代码,我们将看到使用 FILTER() 并不总是一个好主意。
添加多个筛选器
现在,当我们在查询中添加多个筛选器时会发生什么?
虽然结果对我们来说没那么有趣,但让我们看看查询统计信息:
同样,该查询可以由包含这两个筛选器的单个 SE 查询来处理。
查询执行得非常快,以至于 FE 时间百分比相对较高,但它仍然只花了 6 毫秒。
当将查询更改为使用 FILTER() 函数时,SE 查询也没有改变:
这表明,对于这种查询,引擎可以优化执行,以找到完成 DAX 查询的最有效方法。
无论如何,结果没有改变。在这两种情况下它都是相同的,理应如此,因为我们并没有改变筛选器本身。但请耐心等待;我稍后会回到 FILTER() 函数,并解释为什么理解它的影响很重要。
将筛选器移入度量值
接下来,让我们看看将筛选器移入度量值时会发生什么。
到目前为止,查询的构建方式使得度量值 [Sum Online Sales] 从外部接收其筛选器。
让我们尝试这样做:
如你所见,筛选器应用在度量值 [Online Sales A. Datum] 内部。
当然,结果中每一行的结果数字都是相同的,因为 Brand(品牌)被设置为“A. Datum”:
但执行过程略有不同:
这一次,我们有两个 SE 查询。
-
获取品牌“A. Datum”销售额的查询。此查询包含针对该品牌的筛选器。
-
第二个查询用于获取结果集中所有品牌的列表。
第一个查询对我们来说最重要,因为它仍然显示了在度量值内设置的品牌筛选器。
这个查询可以完全由 SE 通过一个简单的筛选器以非常高效的方式处理。
但是,在大多数情况下,我们希望向查询(或报表中的视觉对象)添加多个度量值。
当我们将 [Sum Online Sales] 度量值添加到查询中时会发生什么?
结果并不是特别重要,因为它显示了一列每个品牌的销售额,以及另一列被筛选品牌的销售额。
但查询统计信息很有趣:
正如你在 SE 查询中标记为红色的行所看到的,Brand(品牌)筛选器不再存在。
因为引擎识别出度量值中的筛选器与查询中的筛选器应用于同一列,所以它将筛选器移至 FE 并返回结果。
现在,当我们在度量值中筛选另一列(例如颜色)时会发生什么:
同样,结果并不是特别有趣。我们感兴趣的是查询统计信息:
如你所见,这次我们有两个按 BrandName(品牌名称)进行的查询。一个没有颜色筛选器,另一个有颜色筛选器。
两个查询返回相同数量的行(14 行)——每个品牌一行。
FE 负责将这两个结果合并到一个表中。
整个查询仍然主要由 SE 处理,这非常棒。
但现在,让我们将 FILTER() 函数添加到筛选器中:
对于此示例,我更改了度量值,使用 IN 运算符筛选两个值:
在这个变体中,SE 查询与之前的类似。
筛选器直接传递到查询的 WHERE 子句中。
但是当我把它改成这样时会发生什么:
首先,结果发生了变化:
原因是 FILTER() 的工作方式完全不同。
它保留了现有的筛选上下文并添加了一个新的筛选上下文。
我在另一篇文章中解释了这种行为,我已将其作为下方“参考资料”部分中的第二个链接添加。
此外,SE 无法再在一个查询中处理此问题:
前两个查询检索要筛选的品牌的值(请参阅以粉色标记的查询)。
请注意前两个查询返回的大量行(324 行和 2,560 行)。这是执行计算所需的中间结果的物化(materialization)。
第三个查询使用这些中间结果来筛选数据(以红色标记)。
第三个查询的结果只有两行——也就是我们在总体结果中看到的那两行。
正如我在另一篇文章中所描述的,必须谨慎使用 FILTER()。
它不仅速度慢得多,而且其工作原理与简单的筛选器完全不同。
无论如何,我可以通过在 FILTER() 调用中添加一个 ALL() 来恢复之前的行为:
我不想隐瞒这个示例很特殊的事实,因为应用的筛选器影响的是查询中使用的同一列。
当更改查询以筛选国家/地区时,引擎可以优化执行并再次使用简单的形式:
如你所见,当筛选的列与 DAX 查询中使用的列不同时,引擎会优化查询的执行并退回到简单的筛选器。在蓝色插图中,你可以看到结果。
当不太熟练的开发人员编写 DAX 度量值时,我经常看到这种形式的筛选。
使用 FILTER() 函数看起来很直观,但它可能会产生不正确或令人困惑的结果,并且比简单的筛选器慢。我强烈建议阅读下方链接的关于此函数的文章,以及 dax.guide 文档和 SQLBI.com 上链接的文章。
此外,与使用简单的筛选器相比,我必须输入更多的代码。
作为一个“懒人”,这是在不必要时不使用 FILTER() 的一个重要原因。
添加复杂的筛选器
最后,我想展示使用 DAX 函数(例如 CONTAINSSTRING())应用筛选器时会发生什么。
当你在报表中使用切片器筛选特定订单并检索所购产品的品牌时,就会执行此类查询。
由于此时结果并不重要,让我们直接查看查询统计信息:
虽然查询耗时超过 6 秒才完成,但 99.6% 的时间都花在了 FE 执行 CONTAINSSTRING() 函数以在数据中查找匹配行上。此操作非常耗费 CPU 资源,因为 FE 只能使用一个核心。当我在笔记本电脑上执行此查询时,它需要多花 2 秒以上的时间。
我故意选择了一个较慢的函数来演示其效果。
但 SE 仍然能够通过单个查询来执行该查询。然而,在当前情况下,这一事实带来的积极影响微乎其微。
结论
虽然我无意建议你应该做什么或不该做什么,但我想向你展示以不同方式编写 DAX 代码以及在度量值或查询中应用筛选器所带来的后果。
DAX 引擎在优化查询方面非常高效,但它们也有局限性。
因此,在编写 DAX 代码时,我们必须始终保持谨慎。
如果性能很差,或者别人编写的代码看起来很奇怪,我们应该对其进行分析以确定如何改进。
我想向你展示如何进行分析,以及在分析 DAX 代码时应该注意什么。
请记住:
-
存储引擎(SE)可以使用多个 CPU 核心。
-
SE 完成的工作越多越好。
-
SE 只能执行简单的聚合和简单的数学函数(如 +、-、x 和 /)。
-
尽量减少公式引擎(FE)的工作量。
-
FE 只能使用一个 CPU 核心。
-
尽量减少数据的物化(查询统计信息中的 Rows 列)。
-
尽量减少 SE 查询的数量。
我知道,业务需求有时会迫使我们编写并非最优的 DAX 代码。
更糟糕的是,报表设计人员可能会在报表中添加导致性能下降的逻辑。
在这种情况下,请消除该逻辑并再次检查响应时间。对于此类情况,探索创建专用的度量值可能是值得的。请记住,在通过实时连接(live connection)连接到语义模型的报表中,是可以创建本地度量值的。
但最重要的是:编写 DAX 代码时要从容不迫。避免匆忙编写 DAX 代码,这样你就不必日后再花时间去优化它,从而节省了时间。我是凭经验说话的。那种感觉非常糟糕。
希望你学到了一些新知识。
参考资料
要了解有关如何解读 DAX Studio 中服务器计时(Server Timings)结果的详细信息,请阅读这篇文章:
你对如何正确使用 FILTER() 函数感到好奇吗?请阅读这篇:
另一个可能损害性能的 DAX 函数是 KEEPFILTERS()。要了解有关 KEEPFILTERS() 函数的更多信息,请阅读这篇文章:
这里是提到的关于日期筛选器的文章:
Data Mozart 撰写的一篇关于存储引擎的有趣博客文章:
就像我之前的文章一样,我使用了 Contoso 示例数据集。你可以从微软这里免费下载 ContosoRetailDW 数据集。
如本文档所述,Contoso 数据可以在 MIT 许可证下免费使用。我更改了数据集,将数据转移到了近期的日期。
作者
分享本文
-
在 Facebook 上分享
-
在 LinkedIn 上分享
-
在 X 上分享
Towards Data Science 是一个社区出版物。提交你的见解以触达我们的全球受众,并通过 TDS 作者付款计划赚取收益。