缓存 IQueryable 以提高 LINQ-to-SQL 性能






4.69/5 (7投票s)
一种提高 LINQ-to-SQL 性能的方法,同时保持比 DataReader 更高的可维护性。
引言
普通 DataReader 方法通过 SQL 语句直接从数据库检索数据是最直接的,并且可能是所有更高层架构下的终极功能块。然而,维护纯文本 SQL 语句是一件痛苦的事情。每当数据库结构发生变化时,所有 SQL 语句都必须重新检查其正确性。字段名称的一个小错别字只能通过运行所有测试,甚至只能在实际生产环境中运行后才能发现,这意味着非常高的维护成本。
Hibernate 等框架在将源代码中的类定义与数据库中的表进行关联方面做得很好。然而,由于定义的关联不是源代码的组成部分,因此数据库的更改需要关联定义的更改和源代码中逻辑的更改。所有上述更改都不会被编译器进行“语法检查”,这意味着错误通常只有在运行程序时才能发现。
LINQ-to-SQL 的出现为这个问题带来了曙光。表的关系、查询的条件以及围绕 SQL 的许多其他部分现在已成为源代码的组成部分。它们将被编译器检查,并在运行时转化为 SQL 语句,几乎无需人工干预。在运行前,编译器就能防止小错别字和“更改遗漏”等问题。
然而,LINQ-to-SQL 确实存在其问题,主要是执行开销和缓存。执行 LINQ-to-SQL 语句比通过 DataReader 执行直接 SQL 语句需要更长的时间。可能的原因是从 LINQ 表达式转换为 SQL 命令,将查询数据检索到对象,以及创建其他必要的元数据。通过使用缓存来存储检索到的数据供以后使用,可以部分节省这种成本。但是,当多个应用程序修改数据库时,缓存可能会导致缓存数据与数据库中的实际数据之间出现不一致。
本文的目的是提供一种数据检索方法,该方法具有 LINQ-to-SQL 的高可维护性,同时将执行开销降至最低。
Microsoft SQL LINQ-to-SQL 是本文的目标系统。也可以使用其他 LINQ-to-SQL 系统,如 DbLinq 和 ALinq,但需要稍微不同的编码才能使其正常工作。原因是所有 LINQ-to-SQL 的供应商都有自己的 IQueryable
和 DataContext
实现。
方法
IQueryable
是 LINQ-to-SQL 的核心类。它保存一个沿执行路径构建的 LINQ 表达式树,并在需要时准备好转换为 SQL 命令。对于 Where()
和 Select()
等 LINQ 方法,它们只是修改 IQueryable
的表达式树以包含更多信息。以下是方法体中定义的 LINQ 语句“from p in Persons where p.Id > id select p”的表达式树。
1. Call Where()
2. __Constant Table(Person)
3. __Quote
4. ____Lambda
5. ______Equal
6. ________MemberAccess Id
7. __________Parameter p
8. ________MemberAccess id
9. __________Constant value(Prototype.Database.AllQueries+<>c__DisplayClass2
表达式树 #1DisplayClass2
是编译器定义的“匿名”方法上下文类,在运行时创建,用于存储 LINQ 表达式所需的值。在 IQueryable
构建过程中遇到的每次方法调用,都会创建这样一个 DisplayClass
。(对同一方法的多次调用会引入相同类型的 DisplayClass
的多个实例)。它们对于 IQueryable
的特定实例是恒定的,尽管它们可能在 IQueryable
的不同实例中具有不同的值。
上面的表达式树将在执行读取之前转换为以下 SQL 语句。
SELECT [t0].[id] AS [Id],
[t0].[name] AS [Name], [t0].[birthday] AS [Birthday], [t0].[value] AS [Value]
FROM [person] AS [t0]
WHERE [t0].[id] = @p0
此时,不难看出上面 SQL 命令中唯一的变量是其参数 @p0
,它与表达式树 #1第 8 行的分支表达式相关联。从这个观察可以看出,如果存储了 IQueryable
表达式分支与 SQL 命令参数之间的关系,以及 SQL 命令本身,则可以进行一个开销较低的操作。当要执行这样一个 IQueryable
时,会重用其关联的 SQL 命令,并从 IQueryable
中重新评估 SQL 命令参数,从而消除了从表达式树重新创建新 SQL 命令对象的麻烦。
因此,执行缓存的 LINQ-to-SQL 查询的描述性步骤如下:
- 查找匹配的缓存查询
- 如果找不到匹配项,则进行构建
- 使用
DataContext.GetCommand
获取 SQL 命令(供将来使用的模板) - 构建 display class 实例的 getter
- 使用 display class 实例作为输入来构建 SQL 参数的 getter
- 运行 display class 实例的 getter 以获取实际的 display class 实例
- 使用执行参数 getter 的值在 SQL 模板中设置参数
- 执行修改后的 SQL 语句并检索 DataReader
- 从 DataReader 中读取并进行转换
此方法涉及的关键步骤是:
- 区分
IQueryable
- 从当前
IQueryable
中检索 display class 实例 - 使用检索到的 display class 实例重新构造 SQL 命令参数
- 将 DataReader 读取到对象中并进行转换
比较 IQueryables
由于每个 IQueryable
都存储着一个表达式树,因此比较 IQueryable
只是比较它们的表达式树。比较是逐节点、逐分支进行的。发现任何差异都意味着这两个 IQueryable
在本质上是不同的。一个常量表达式,例如示例中的第 8 行,只需要与类型进行比较,而不是值,有一个例外。
当出现涉及值数组的 Contains()
方法调用时,会出现一个特别有趣的情况。例如:
LINQ
var ids = new List<int> {1, 2, 3, 4, 5, 20, 70 };
from p in persons where
ids.Contains(p.Id) select p;
其表达式树
1. Call Where()
2. __Constant Table(Person)
3. __Quote
4. ____Lambda
5. ______Call ids.Contains()
6. ________MemberAccess p.Id
7. __________Parameter p
其 SQL 命令
SELECT [t0].[id] AS [Id],
[t0].[name] AS [Name], [t0].[birthday] AS [Birthday], [t0].[value] AS [Value]
FROM [person] AS [t0]
WHERE [t0].[id] IN (@p0, @p1, @p2, @p3, @p4, @p5, @p6)
如果数组(列表)中的值数量不同,SQL 命令的形式也会不同。最简单的方法是将具有不同元素数量的 IQueryable
视为独立的个体,但缺点是内存中会有更多的缓存命令。另一种选择是重用具有较长列表的缓存命令。通过这样做,为命令参数设置实际值需要额外的逻辑来使用现有数组值替换不存在的数组值。与最简单解决方案增加的内存需求相比,后者的额外执行时间似乎是可以接受的成本。因此,此方法中使用较长列表的备选方案来处理 Contains()
查询。
检索 display class 实例
从这一步开始,所有操作都将通过编译后的委托来加速。这些委托的组合和编译成本很高。但从第二次开始就可以节省下来,并且对于这种方法的性能至关重要。
有趣的是,IQueryable
并不显式地保存 display class 实例列表,相反,这些实例埋藏在表达式树中。要找出所有实例需要递归遍历所有级别的子表达式。由于可能存在相同 display 类型(如前所述,在查询组合过程中多次调用同一方法时)的多个实例,因此需要严格比较 display class 实例,并且需要进行完整遍历。遍历结果是从表达式树的顶节点到包含特定 display class 实例的 Constant 表达式节点的逻辑“路径”。第一个 LINQ 示例中检索 display class 实例的逻辑路径是:
((((((ExpressionRoot).Arguments.get_Item(1)).Operand).Body).Right).Expression).Value
为一个 IQueryable
找到的此类逻辑路径被组合成表达式树并编译成可执行委托。它们已准备好在下一阶段使用。
加载命令参数
由于查询表达式树是这样构建的,因此从表达式树的一个分支到一个命令参数存在一定的映射关系。通过观察,参数分支是没有 Parameter 表达式或 Constant Table 表达式的最大分支。在表达式树 #1 中,从第 8 行开始存在一个参数。
在存在多个参数的情况下,参数的顺序由 LINQ-to-SQL 实现决定,供应商之间可能有所不同,并且特定于每个表达式节点。例如,在 Microsoft 实现的 Binary 表达式中,在 Left 分支中找到的任何参数都优先于在 Right 分支中找到的参数。
对于数组/列表上的 Contains()
方法,数组/列表中的每个单独值都被视为一个参数,顺序通常是它们在数组/列表中的出现顺序。
如前所述,此方法使用具有较长列表的缓存查询。因此,在运行查询中的实际数组/列表中的元素数量总是等于或少于用于构建缓存的数组/列表中的元素数量。不能始终通过索引直接检索元素。相反,在检索之前执行长度检查。如果当前索引大于实际数组/列表的长度,则使用索引 0,否则直接使用该索引。最终的参数表达式如下所示:
Param_0.ids.get_Item(IIF((Param_0.ids.Count > 0), 0, 0))
Param_0.ids.get_Item(IIF((Param_0.ids.Count > 1), 1, 0))
而不是
Param_0.ids.get_Item(0)
Param_0.ids.get_Item(1)
在一个只有单个元素的列表上运行这些表达式等同于:
Param_0.ids.get_Item(0)
Param_0.ids.get_Item(0)
即使在 IN
子句中出现两个相同的重复值是冗余的,它仍然是逻辑上正确的。性能不会受到太大影响,并且通过不为这个只有一个元素的实例创建单独的缓存查询可以节省一些内存空间。
最终要存储和缓存的对象是可执行的 lambda 表达式,它将实际的 display class 实例作为参数,以及准备好插入 SQL 语句作为参数的返回值。从为每个参数获得的表达式中,用与 display class 完全相同的类型的 Parameter 表达式替换任何持有 display class 实例的 Constant 表达式。在处理过的表达式之上放置一个 LambdaExpression 并进行编译。这将成为准备好提取实际参数的可执行表达式。
读取数据并转换
对于读取一行数据,通常会检索一个完整的对象,并且常常会对来自数据库的原始数据进行一次或多次转换。不带转换的读取通过对象属性名或附加的 Column 属性,对对象属性与数据列进行直接匹配。带转换的读取通常有两种形式,例如:
from p in Persons select new OtherForm { Face = p.Name }
或
from p in Persons select ConvertToOtherForm(p)
对于直接创建对象,LINQ-to-SQL 会将目标对象的字段名添加到 SQL 语句的 SELECT 列表中,这看起来像:
SELECT [t0].[name] AS [Face] FROM [person] AS [t0]
然后,直接的字段到属性读取足以获取数据。
对于使用方法的转换,没有明显的方法可以知道方法内部会执行什么操作,因此会进行完整读取以获取起始对象,然后调用转换方法。
因此,在最终对象中需要一个可执行的 LambdaExpression。表达式的第一部分是一个新的对象语句,它使用一个特定的 DataReader.Get()
方法来检索相应的值。其余部分是使用静态方法进行的进一步转换。仅举例说明,本节中显示的第一个查询将有一个读取 LambdaExpression 表示为:
reader => new OtherForm() {Face = reader.GetString(0)}
示例代码
作为附件提供了一组包含在 Visual Studio 2010 测试项目中的示例代码。要准备好示例数据库,我们需要在一个完全受控的数据连接到 SQL Express 上运行测试用例“Preparation.RebuildDatabase”。此测试用例将插入伪随机值用于测试目的。
此方法的所有实现都包含在 Prototype.Database.Utility
类中,其中最重要的入口是扩展方法 ExecuteList<T>()
。示例代码演示了该方法的实用性。因此,大部分实现仅足够使项目中的所有测试用例正常工作。特别是,数据读取 LambdaExpression 是基于对象属性名在不区分大小写的情况下等于字段名,并且属性类型匹配相应字段类型的假设。在生产环境中,这个假设可能并不总是成立。
方法基准测试
测试项目中也提供了一个对各种读取方法的简单基准测试,名称为“Benchmark.SQLRepeatSingleLoad”。它用于衡量只返回一条记录的查询的平均执行时间。基准测试在五种方法上进行:直接 DataReader 读取、直接 LINQ(可能包含 DataContext 缓存)、编译的 LINQ、LINQ 到命令到 DataReader,以及最后提到的方法。
在虚拟机上运行基准测试会得到以下结果:
0.114759ms Reader
0.186484ms ExecuteList (this approach)
0.315588ms Linq
0.615396ms Linq-Get-DataReader
2.300208ms Compiled Linq
这揭示了相比于普通 LINQ 实现的性能提升。
结语
这种方法介于 DataReader 方法和 LINQ-to-SQL 方法之间,它通过 LINQ 提供了比直接 DataReader 实现更高的可维护性,并且在读取性能上优于普通的 LINQ-to-SQL。此外,示例代码展示了该方法的可行性,在未经修改的情况下,其灵活性不足以用于生产环境。
对于任何糟糕的命名和几乎没有注释,我深表歉意。希望您喜欢阅读本文并喜欢玩示例。