Skip to content

如何通过批处理让我的 EF Core 查询快 3.42 倍

Published: at 12:00 AM

如何通过批处理让我的 EF Core 查询快 3.42 倍

摘要

如果你在构建 .NET 应用程序,EF Core 是一个非常棒的 ORM。今天,我会告诉你一个简单的想法,我用它获得了将近 4 倍的性能提升。

原文 How I Made My EF Core Query 3.42x Faster With BatchingMilan Jovanović 发表。


EF Core 是一个非常棒的 ORM,如果你在构建 .NET 应用程序。

但它和其他工具一样,你可能会以次优的方式使用它。

今天,我会告诉你一个简单的想法,我用它获得了将近 4 倍的性能提升

我并不是说你会看到相同的结果,但理解这个想法会让你的查询更快。

为什么这个查询次优

这是我想用来解释这个强大想法的例子。它取自我正在处理的一个生产应用程序,但为了这个例子我简化了它。

我们使用 InvoiceService 获取某公司的一组发票。发票可能来自第三方 API 或其他持久化存储。我们缺乏详细的商品信息,因此我们查询数据库以填充缺失的数据。

下面高亮的 LINQ 查询 本身并不差。它在一个数据库查询(往返)中返回所有商品。

但它缺少一个重要的意识,这可以解锁进一步的性能提升。

因为我们在迭代发票时,我们多次查询数据库

app.MapGet("invoices/{companyId}", (
    long companyId,
    InvoiceService invoiceService,
    AppDbContext dbContext) =>
{
    IEnumerable<Invoice> invoices = invoiceService.GetForCompanyId(
        companyId,
        take: 10);

    var invoiceDtos = new List<InvoiceDto>();
    foreach (var invoice in invoices)
    {
        var invoiceDto = new InvoiceDto
        {
            Id = invoice.Id,
            CompanyId = invoice.CompanyId,
            IssuedDate = invoice.IssuedDate,
            DueDate = invoice.DueDate,
            Number = invoice.Number
        };

        var lineItemDtos = await dbContext
            .LineItems
            .Where(li => invoice.LineItemIds.Contains(li.Id))
            .Select(li => new LineItemDto
            {
                Id = li.Id,
                Name = li.Name,
                Price = li.Price,
                Quantity = li.Quantity
            })
            .ToArrayAsync();

        invoiceDto.LineItems = lineItemDtos;

        invoiceDtos.Add(invoiceDto);
    }

    return invoiceDtos;
});

一旦你理解了这一点,解决方案就是应用一个简单的想法。

与其为每张发票获取商品,我们可以提前查询所有商品。

批处理来救场

这是相同的查询,但经过重构以仅查询一次商品。这意味着只有一次到数据库的往返。

最终设计有三个组件:

一旦我们有了字典,我们就可以循环遍历发票并分配商品。填充商品变成了一个字典查找(廉价)而不是数据库查询(昂贵)。

在决定这种解决方案是否可行之前,你还需要考虑一些事情。

你一次可以从数据库加载多少记录?

每张发票平均包含约 20 个商品,而我们只获取了十张发票。所以,我们从数据库加载了约 200 个商品。大多数应用程序可以处理这个负载。但如果你一次加载几千行,情况可能会有所不同。

app.MapGet("invoices/{companyId}", (
    long companyId,
    InvoiceService invoiceService,
    AppDbContext dbContext) =>
{
    IEnumerable<Invoice> invoices = invoiceService.GetForCompanyId(
        companyId,
        take: 10);

    long[] lineItemIds = invoices
        .SelectMany(invoice => invoice.LineItemIds)
        .ToArray();

    var lineItemDtos = await dbContext
        .LineItems
        .Where(li => lineItemIds.Contains(li.Id))
        .Select(li => new LineItemDto
        {
            Id = li.Id,
            Name = li.Name,
            Price = li.Price,
            Quantity = li.Quantity
        })
        .ToListAsync();

    Dictionary<long, LineItemDto> lineItemsDictionary =
        lineItemDtos.ToDictionary(keySelector: li => li.Id);

    var invoiceDtos = new List<InvoiceDto>();
    foreach (var invoice in invoices)
    {
        var invoiceDto = new InvoiceDto
        {
            Id = invoice.Id,
            CompanyId = invoice.CompanyId,
            IssuedDate = invoice.IssuedDate,
            DueDate = invoice.DueDate,
            Number = invoice.Number,
            LineItems = invoice
                .LineItemIds
                .Select(li => lineItemsDictionary[li])
                .ToArray()
        };

        invoiceDtos.Add(invoiceDto);
    }

    return invoiceDtos;
})

快多少?

批处理变体似乎会更快,对吗?

我们在第一个版本中有 N 个查询(每个发票一个),在批处理版本中有一个查询。

以下是我使用 BenchmarkDotNet 得到的基准测试结果:

方法平均错误标准差Gen0Gen1分配
ForeachQuery1,919.3 us10.00 us8.35 us19.53133.9063359.4 KB
BatchedQuery558.6 us2.62 us2.19 us15.62501.9531276.7 KB

foreach 版本平均需要 1913.3 us(微秒)。
批处理版本平均需要 558.6 us

批处理版本快了 3.42 倍。这是在本地 SQL 数据库上的结果。

如果你在查询远程数据库,由于网络往返时间的影响,批处理版本应该会更快。当你有 N 个查询(foreach 版本)时,这种影响会迅速累积。

收获

这种方法的力量在于它的简单性和效率。通过批处理数据库查询,我们大大减少了往数据库的往返次数。这通常是最大的性能瓶颈之一。

但关键是要明白,这种方法并不是一刀切的解决方案。

EF Core 提供了许多功能和优化,但如何有效地使用它们,取决于开发人员。

最后,永远记住要进行测量和基准测试。我们在这个案例中看到的改进是通过基准测试量化的。没有正确的测量,很容易做出无意中降低性能的更改。