Skip to content
Go back

EF Core 里,`Where + Contains` 不是批量查询的终点

EF Core 大批量读取概念图

很多人第一次写 EF Core 的批量查询,直觉都是这一句:先拿一组 ID,再来一个 Where(...Contains(...))。它不丑,也不神秘,而且在数据量不大时完全没问题。

问题出在它太容易被复制了。几十个 ID 能跑,几千个 ID 也许还能跑,于是这段写法慢慢从后台任务长进了同步流程、库存校验、目录对账、第三方数据导入,最后你才发现,真正拖垮接口的不是 LINQ 本身,而是你把一个适合小集合的模式,用到了大集合场景里。

Anton Martyniuk 最近在 X 上用一个赞助视频专门讲了这件事,配套给出了一个完整的示例仓库,演示如何用 EF Core Extensions 处理大批量读取。标题写得很猛,像在对 Where + Contains 宣战。我的判断更克制一点:Contains 不是错,它只是有明确的适用边界。

真正的问题,不是写法丑,是规模变了

先看最常见的版本:

var products = await dbContext.Products
    .Where(p => productIds.Contains(p.Id))
    .ToListAsync();

如果 productIds 只有几十个,通常没什么可说的。可一旦集合上千,风险会开始叠加:

这就是很多 AI 代码建议容易误导人的地方。模型特别擅长补出这句 LINQ,因为它在局部上是正确的,也最像“正常 EF Core 代码”。但模型通常不知道你这组 ID 到底是 30 个,还是 30,000 个;也不知道它是页面筛选,还是夜间同步任务。

AI 很会补出“能跑”的数据库代码,人要负责判断它能不能在真实数据规模下继续跑。

这点在今天反而更重要了。AI 已经把“写出一个查询”这件事变得很便宜,但数据库的约束、执行计划的代价、连接池的压力,这些底层规律一点也没变。

这 5 个方法,分别在解决什么问题

Anton 链接的示例仓库里,BulkReadEfCoreExtensions 这个项目把批量读取拆成了 5 个方法。它不是简单替换 Contains,而是把“你到底想匹配什么”分成了不同场景。

方法适合场景输入来源返回结果
WhereBulkContains从外部系统拿到一批主键,想查数据库里对应的实体一组 ID数据库中的匹配实体
WhereBulkNotContains想找“数据库里有哪些不在外部清单里”一组 ID数据库中的非匹配实体
BulkRead输入不只是 ID,而是一组带字段的对象对象列表数据库中的匹配实体
WhereBulkContainsFilterList想从输入列表里筛出“哪些已经存在”对象列表输入列表对应的已存在项
WhereBulkNotContainsFilterList想从输入列表里筛出“哪些还不存在”对象列表输入列表对应的缺失项

这个拆法很有价值,因为它把“查数据库”变成了更明确的业务问题。比如商品目录同步时,你经常会同时遇到三类需求:

这三件事看起来都像“查一下”,但实现思路和返回方向并不一样。

仓库里的例子,比帖子正文更值得看

示例仓库里最直观的一段,是 WhereBulkContains

app.MapGet("/products/where-bulk-contains", async (ShippingDbContext dbContext) =>
{
    var productIds = await GetProductIdsFromExternalSystem(dbContext);

    var products = await dbContext.Products
        .Include(product => product.Category)
        .WhereBulkContains(productIds, x => x.Id)
        .ToListAsync();

    return Results.Ok(products);
});

这里的重点不是 API 名字更酷,而是它把“外部系统给了我一大组键”当成一个专门问题来处理。WhereBulkNotContains 也是同样的思路,只是方向反过来,用来找数据库中的差集。

另一个我觉得更实用的是 BulkRead

app.MapPost("/products/bulk-read", (ShippingDbContext dbContext, List<ProductInRequest> input) =>
{
    var products = dbContext.Products.BulkRead(input);
    return Results.Ok(products);
});

为什么这个方法更接近真实业务?因为很多对账任务拿到的根本不是单纯 ID,而是一组半结构化输入。仓库里的 ProductInRequest 就是一个很小但很真实的例子:

public readonly record struct ProductInRequest(
    int Id,
    string? ProductCode,
    string? SupplierCode
);

这类输入在导入、同步、补数任务里特别常见。AI 当然也能帮你很快搭出这个 API 外壳,但它不擅长主动提醒你“你应该按业务键匹配,而不是只按自增 ID 匹配”。这仍然是人的设计工作。

为什么这套方案会更稳

EF Core Extensions 的思路,是把这些大集合匹配问题交给更适合的底层机制处理。根据官方文档,WhereBulkContains 这类方法会借助临时表来绕开参数数量限制,并支持更复杂的匹配方式。要注意,官方也明确说了,它不一定比小集合上的 Contains 更快;它真正的价值,是当输入规模和匹配条件开始失控时,你还能用更稳定的方式把查询写下去。

从工程角度看,真正值得记住的是这个原则:

当你的查询条件本身已经像一张“小表”时,就别再假装它只是一个普通的 List<int>

一旦你接受这个判断,后面的选择其实不只一种:

这就是今天看这条帖子最该补上的一层背景。原帖是赞助内容,推广的是具体产品;但对读者真正有用的,不是“记住这个库名”,而是“什么时候该升级思路”。

什么时候继续用 Contains,什么时候该换工具

如果你只是页面筛选、后台管理台的小批量操作,或者几十到几百个 ID 的普通查询,我不会急着把 Contains 打成反模式。它简单、可读、没有额外依赖,维护成本也低。

但如果你已经落在下面这些场景里,我会认真考虑换方案:

这里最容易被 AI 带偏的地方,是它会优先给你最少改动的答案:继续 LINQ,继续分批,继续在应用层补丁。短期看很省事,长期看就是把数据库问题伪装成了 C# 问题。

AI 时代,这类数据库判断反而更值钱

今天很多开发者第一次接触数据库性能问题,不是从生产事故开始,而是从 AI 生成的代码开始。模型很会给出“最像示例代码”的方案,于是大家更容易把局部最优当成全局最优。

这件事已经变了:写代码本身不再稀缺,识别边界条件才稀缺。

可没变的也很明确:

所以现在更该关注什么?不是“AI 能不能写 EF Core 查询”,这个问题已经没什么信息量了。更重要的是:

这才是今天数据库开发里最实际的能力差距。

最后一句实话

Anton 这条帖子的标题有点耸动,但它抓住了一个真实痛点:很多 EF Core 项目不是不会查数据,而是到了大集合场景还在用小集合思维查数据

如果你现在就在做导入、同步、库存、目录或对账任务,别急着记库名,先问自己一句:我手里的这组条件,到底只是几个参数,还是已经接近一张表了?一旦答案是后者,Where + Contains 多半就该让位了。

参考


Tags


Previous

LLM 写形式化规约,看着像回事,其实什么都没验证

Next

微软把 Agent 应用真正拼起来了:Agent Framework、Foundry、MCP 和 Aspire 的实战样板