〇、前言
对于一个对象 List,通过调用 .Where() 来筛选数据,当数据量比较大时,就会大大拖慢程序运行效率,从秒级直接到分钟级。
即 O(N*M) 中,N 的基数非常大时,单次查询就比较慢,循环 M 次的累加时间将逐渐扩大。
那么有没有更好的方法来做筛选,本文将进行简单尝试,主要涉及三种方法:ToDictionary()、ToLookup()、Span<T>,供参考。
一、前因后果
1.1 为什么通过调用 .Where() 来筛选数据会比较慢?
通过调用 .Where() 来筛选数据,每次查询都会进行一次线性扫描(时间复杂度为 O(N))。
例如以下代码示例。
// 两个对象,通过 params2 进行关联,将 params1 赋值给 params3
// 这是一个对象数量有限的列表
var detail_objs = new List<ObjectModel_1>(); // 属性:params2,params3
string sql_getdata = "select params1,params2 from tableName where flag>0";
// 这是一个上百万的对象列表
var objectlist = dapperFactory.Query<ObjectModel>(sql_getdata).ToList();
ObjectModel obj = null;
foreach (var item in detail_objs)
{
obj = objectlist.Where(a => a.params2 == item.params2).FirstOrDefault();
if (obj != null)
{
item.params3 = obj.params1;
}
}
如果 detail_objs 有 M 条数据,总的时间复杂度就会飙升到 O(N * M),这会导致极其严重的性能问题。
1.2 一个简单的解决方案:使用 ToDictionary 或 ToLookup 建立索引
解决方案的核心思路是,用空间换时间:将列表转换为哈希字典结构,把查找的时间复杂度降为 O(1)。
先准备简单的测试数据。
using System;
using System.Collections.Generic;
using System.Linq;
public class Employee
{
public string Name { get; set; }
public string Department { get; set; }
}
var employees = new List<Employee>
{
new Employee { Name = "张三", Department = "研发部" },
new Employee { Name = "李四", Department = "销售部" },
new Employee { Name = "王五", Department = "研发部" },
new Employee { Name = "赵六", Department = "销售部" },
new Employee { Name = "钱七", Department = "研发部" },
};
1.2.1 关于 ToDictionary() 的简单示例
// 三个不同的【实现方案】
// 【一】【仅适用于 key 不重复的场景】否则抛异常
Dictionary<string, Employee> nameDict = employees
.ToDictionary(e => e.Name); // 假设 Name 唯一
Console.WriteLine(nameDict["张三"].Department); // 输出:研发部
// 【二】先 GroupBy,再 ToDictionary 【实现一对多索引】
Dictionary<string, List<Employee>> deptDict = employees
.GroupBy(e => e.Department)
.ToDictionary(
g => g.Key, // key = 部门名称
g => g.ToList() // value = 该部门的员工列表
);
// 【三】手动遍历构建(性能更好,只遍历一次)
var deptDict = new Dictionary<string, List<Employee>>();
foreach (var emp in employees)
{
if (!deptDict.TryGetValue(emp.Department, out var list))
{
list = new List<Employee>();
deptDict[emp.Department] = list;
}
list.Add(emp);
}
// 【使用】
// 按 key 查找 ------ 【返回该部门的所有员工】
var devTeam = deptDict["研发部"];
foreach (var emp in devTeam)
{
Console.WriteLine(emp.Name); // 输出:张三、王五、钱七
}
// 【直接查找不存在的 key】 ------ 会抛 KeyNotFoundException!
// var hrTeam = deptDict["人事部"]; // 抛异常
// 【正确做法】用 TryGetValue
if (deptDict.TryGetValue("人事部", out var hrTeam))
{
foreach (var emp in hrTeam)
Console.WriteLine(emp.Name);
}
else
{
Console.WriteLine("人事部没有员工"); // 输出:人事部没有员工
}
// 【指定 Value Selector(只索引某个属性)】
// 等价于 ToLookup 的 element selector
Dictionary<string, List<string>> nameByDept = employees
.GroupBy(e => e.Department)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.Name).ToList() // 只取 Name
);
foreach (var name in nameByDept["研发部"])
{
Console.WriteLine(name); // 输出:张三、王五、钱七
}
1.2.2 关于 ToLookup() 的简单示例
// 【按 Department 建立索引】
ILookup<string, Employee> deptLookup = employees.ToLookup(e => e.Department);
// 【使用索引查询】
// 按 key 查找 ------ 返回该部门的所有员工
var devTeam = deptLookup["研发部"];
foreach (var emp in devTeam)
{
Console.WriteLine(emp.Name); // 输出:张三、王五、钱七
}
// 【查找不存在的 key ------ 返回空集合】
// (不会抛异常!这是比 GroupBy + ToDictionary 好的地方)
var hrTeam = deptLookup["人事部"];
Console.WriteLine(hrTeam.Count()); // 输出:0
// 【指定 Element Selector(只索引某个属性)】
// 第二个参数指定 value 取哪个字段
ILookup<string, string> nameByDept = employees.ToLookup(
e => e.Department, // key selector
e => e.Name // element selector
);
foreach (var name in nameByDept["研发部"])
{
Console.WriteLine(name); // 输出:张三、王五、钱七
}
// 【自定义比较器(忽略大小写等)】
ILookup<string, Employee> lookup = employees.ToLookup(
e => e.Department,
StringComparer.OrdinalIgnoreCase // 忽略大小写
);
1.3 数据库层面的优化建议
除了代码层面的算法优化,处理上百万条数据时,数据库查询本身也可能成为瓶颈。
以下是可以采取的两个优化点:
1)只查询需要的字段
只查询业务逻辑中用到的字段,其中 Model 也按照这个来设计,这避免了加载不必要的列,减少了内存分配和网络传输开销。
2)在数据库端直接 JOIN
如果目标数据列表可以存入临时表或表值参数,最彻底的做法是直接在 SQL 语句中完成关联(JOIN),避免将超百万条数据全部拉到 C# 内存中处理。
1.4 内存与 GC 压力优化(Span<T>)
由于数据量较大时,ToList() 会在堆上分配大量内存。如果这段代码处于高频调用的业务路径中,可以尝试以下两种方案。
1)如果 employees 对象列表,在应用生命周期内不发生变化,可以考虑将其缓存起来(如:使用 MemoryCache 或静态变量),避免每次都去查库和分配内存。
2)如果在极高频且对 GC 极其敏感的场景下,可以考虑使用 Span<T> 配合原生 for 循环来替代 LINQ,以实现零内存分配的遍历,但对于您当前的场景,使用 ToDictionary 已经能解决 99% 的性能问题。
**下边是一个基于 Dapper 的简单示例。**Dapper 是微 ORM,它通过反射或表达式树直接将 DataReader 的行映射到目标类型。Dapper 完美支持 struct 和 record struct。
// 1. 定义 readonly record struct (C# 10+)
// 位置参数会自动生成构造函数和属性,Dapper 可以直接通过构造函数映射
public readonly record struct DataStruct(string dataid, string databm, string guigexh);
// 2. 使用 Dapper 直接查询为 struct 数组
using var conn = new SqlConnection("数据库连接字符串");
string sql = "SELECT shujuid, shujubm FROM tableName";
// 直接查询为 struct
// 注意:建议调用 .ToArray(),因为 Span<T> 需要数组(连续内存),而 Query 返回的是 IEnumerable/List
DataStruct[] dataArray = conn.Query<DataStruct>(sql).ToArray();
// 3. 配合 Span 进行零 GC 遍历/构建字典
Span<DataStruct> span = dataArray.AsSpan();
var dict = new Dictionary<string, DataStruct>(span.Length);
for (int i = 0; i < span.Length; i++)
{
ref readonly var item = ref span[i];
dict[item.dataid] = item; // 值类型拷贝,无堆分配
}