在C#中过滤大型List的最佳方式?

时间:2017-11-17 10:50:35

标签: c# performance linq caching

我们有一个ASP.NET MVC Web应用程序,它通过Entity Framework连接到SQL Server DB。此应用程序的主要任务之一是允许用户快速搜索和过滤包含存档值的巨大数据库表。

表结构非常简单:Timestamp(DateTime),StationId(int),DatapointId(int),Value(double)。该表有一些在10到1亿行之间。我使用覆盖索引等对DB表进行了优化,但是当使用DatapointId,StationId,Time和Skipping进行过滤并且只获取我想要在页面上显示的部分时,用户体验仍然相当滞后。

所以我尝试了另一种方法:由于我们的服务器有很多RAM,我认为我们只需在Web应用启动时将整个存档表加载到List<ArchiveRow>,然后直接获取数据从此列表而不是往返数据库。这非常有效,将整个存档表(目前大约有1000万个条目)加载到List中大约需要9秒钟。 ArchiveRow是一个简单的对象,如下所示:

public class ArchiveResponse {
    public  int Length { get; set; }
    public int numShown { get; set; }
    public  int numFound { get; set; }
    public  int numTotal { get; set; }
    public  List<ArchiveRow> Rows { get; set; }
}

并据此:

public class ArchiveRow {
    public  int s { get; set; }
    public  int d { get; set; }
    public  DateTime t { get; set; }
    public  double v { get; set; }
}    

当我现在尝试使用Linq查询从List获取所需数据时,查询数据库的速度已经更快,但是当按多个条件进行过滤时,它仍然很慢。例如,当我使用一个StationId和12个DatapointIds进行过滤时,检索25行的窗口大约需要5秒钟。我已经从使用Where的过滤切换到使用联接,但我认为仍有改进的余地。有没有更好的方法来实现这样的缓存机制,同时尽可能降低内存消耗?是否有更适合此目的的其他收集类型?

所以这里是从ArchiveCache列表中过滤和获取相关数据的代码:

// Total number of entries in archive cache
var numTotal = ArchiveCache.Count();

// Initial Linq query
ParallelQuery<ArchiveCacheValue> query = ArchiveCache.AsParallel();

// The request may contain StationIds that the user is interested in,
// so here's the filtering by StationIds with a join:
if (request.StationIds.Count > 0)
{
    query = from a in ArchiveCache.AsParallel()
             join b in request.StationIds.AsParallel()
             on a.StationId equals b
             select a;
}

// The request may contain DatapointIds that the user is interested in,
// so here's the filtering by DatapointIds with a join:
if (request.DatapointIds.Count > 0)
{
    query = from a in query.AsParallel()
             join b in request.DatapointIds.AsParallel()
             on a.DataPointId equals b
             select a;
}

// Number of matching entries after filtering and before windowing
int numFound = query.Count();

// Pagination: Select only the current window that needs to be shown on the page
var result = query.Skip(request.Start == 0 ? 0 : request.Start - 1).Take(request.Length);

// Number of entries on the current page that will be shown
int numShown = result.Count();

// Build a response object, serialize it to Json and return to client
// Note: The projection with the Rows is not a bottleneck, it is only done to
// shorten 'StationId' to 's' etc. At this point there are only 25 to 50 rows,
// so that is no problem and happens in way less than 1 ms
ArchiveResponse myResponse = new ArchiveResponse();
myResponse.Length = request.Length;
myResponse.numShown = numShown;
myResponse.numFound = numFound;
myResponse.numTotal = numTotal;
myResponse.Rows = result.Select(x => new archRow() { s = x.StationId, d = x.DataPointId, t = x.DateValue, v = x.Value }).ToList();

return JsonSerializer.ToJsonString(myResponse);

更多细节:电台的数量通常在5到50之间,很少超过50.数据点的数量<7000。 Web应用程序设置为64位,并在web.config中设置<gcAllowVeryLargeObjects enabled="true" />

我真的很期待进一步的改进和建议。也许基于数组或类似的方法有一种完全不同的方法,它可以更好地执行而不用 linq?

4 个答案:

答案 0 :(得分:5)

您可以将存储调整为此特定查询类型。首先,从内存存档中创建字典:

ArchiveCacheByDatapoint = ArchiveCache.GroupBy(c => c.DataPointId)
            .ToDictionary(c => c.Key, c => c.ToList());
ArchiveCacheByStation = ArchiveCache.GroupBy(c => c.StationId)
            .ToDictionary(c => c.Key, c => c.ToList());

然后在查询中使用这些词典:

bool hasStations = request.StationIds.Length > 0;
bool hasDatapoints = request.DatapointIds.Length > 0;            
int numFound = 0;
List<ArchiveCacheValue> result;
if (hasDatapoints && hasStations) {
    // special case - filter by both
    result = new List<ArchiveCacheValue>();
    // store station filter in hash set
    var stationsFilter = new HashSet<int>(request.StationIds);
    // first filter by datapoints, because you have more different datapoints than stations
    foreach (var datapointId in request.DatapointIds.OrderBy(c => c)) {                    
        foreach (var item in ArchiveCacheByDatapoint[datapointId]) {                        
            if (stationsFilter.Contains(item.StationId)) {
                // both datapoint and station matches filter - found item
                numFound++;
                if (numFound >= request.Start && result.Count < request.Length) {
                    // add to result list if matches paging criteria
                    result.Add(item);
                }
            }
        }
    }
}
else if (hasDatapoints) {                
    var query = Enumerable.Empty<ArchiveCacheValue>();                
    foreach (var datapoint in request.DatapointIds.OrderBy(c => c))
    {
        var list = ArchiveCacheByDatapoint[datapoint];
        numFound += list.Count;
        query = query.Concat(list);
    }
    // execute query just once
    result = query.Skip(request.Start).Take(request.Length).ToList();
}
else if (hasStations) {                
    var query = Enumerable.Empty<ArchiveCacheValue>();
    foreach (var station in request.StationIds.OrderBy(c => c))
    {
        var list = ArchiveCacheByStation[station];
        numFound += list.Count;
        query = query.Concat(list);
    }
    // execute query just once
    result = query.Skip(request.Start).Take(request.Length).ToList();
}
else {
    // no need to do Count()
    numFound = ArchiveCache.Count;
    // no need to Skip\Take here really, ArchiveCache is list\array
    // so you can use indexes which will be faster
    result = ArchiveCache.Skip(request.Start).Take(request.Length).ToList();
}

// Number of entries on the current page that will be shown
int numShown = result.Count;

我测量过它,在我的机器上它运行1ms(有时长达10ms)我所尝试的所有类型的查询(仅按部分,仅按数据点,按部分和数据点),1亿项目

答案 1 :(得分:3)

我至少会将这些值存储在arrayvector struct ArchiveRow中,以确保所有数据都在连续的内存中。这样,您将从locality of reference中受益匪浅(即有效地使用L1缓存)。而且你还避免了list(指针/引用)的开销。 (更新:我快速查找了C# List。看起来ListC++ vector相同(即数组),C# LinkedList与{{1}相同有点令人困惑 - 哇,这里没有C++ list的指针开销

尝试使结构尽可能小。尽可能使用C# List<>甚至uint32uint16可能为32位,甚至可能datetime代替float(取决于您的值)。还要在结构中放置最宽的值(以便更好地对齐)。

即使是蛮力方法(扫描整个阵列,几个100MB的连续内存)也应该在1秒内完成。

为了进一步优化,您可以对数据进行排序(或者更好,检索从数据库中排序的数据),这将允许您进行二进制搜索,并保持结果集的值靠近在一起。例如:对doubleStationId进行排序。

如果数据变大,您可以在磁盘上存储相同的结构(在二进制文件中)并通过内存映射访问它。

此外,您只需要扫描前25个项目。然后,您可以存储最后检查的位置(针对该会话/查询),并在询问下一页时,从那里开始接下来的25个项目。这也将节省存储完整结果集所需的内存。

如果电台数量很少,并且数据在DataPointId上排序,您还可以使用每个StationId的起始位置保留一个小列表或跳转表(在导入时)并直接跳到那里扫描该站的数据点。

  

加载整个存档表大约需要9秒钟   目前大约有1000万条款进入名单。

如果您尚未执行此操作,请确保在列表中设置初始Capacity以避免多次重新分配。

答案 2 :(得分:1)

另一个优化。在内存中使用linq join效率不高。

因此,我不是加入request.StationIdsrequest.DatapointIds,而是从它们创建哈希集,并在哈希集上使用简单包含。这样的事情。

if (request.StationIds.Count > 0)
{
    var stationIdSet = new HashSet<int>(request.StationIds);
    query =  (from a in ArchiveCache
             stationIdSet.Contains(a.StationId)
             select a);
               // .AsParallel()
               // .MaxDegreeOfParallelism(...);
}

序列方式为9m记录和30个站点ID运行,以大约150 - 250毫秒执行连接

对于 MaxDegreeOfParallelism = 2 并行版本,两者(连接和哈希集)表现更差

注意:AsParallel正在增加开销,对于那些可能不是最佳选择的简单操作。

答案 3 :(得分:0)

Evk的答案有一些很好的优化,我只是想在原始代码中指出一些非常不优化的东西,也许删除它们会加速它:

您在内存中搜索三次

displayName

你应该真的一次,然后对该结果进行计数和分页。