EFCore AsSplitQuery + 非唯一排序 + Take 导致 Include 集合丢失

最近排查了一个很隐蔽的生产 bug:一个 CSV 导出接口,部分订单的 InvoiceAmount(发票金额)导出成了 0,但数据库里这些订单明明有非 0 的付款记录。而且很邪门——带关键字 search 的导出没问题,全量导出才复现。

查到最后,根因是 EF Core 的 拆分查询(AsSplitQuery)+ 非唯一 ORDER BY + Skip/Take 三者一起触发的一个数据正确性陷阱。这个坑在官方文档里其实有一句警告,但很容易被忽略,记录一下。

环境:EF Core 7 + Npgsql 7 + PostgreSQL。EF Core 5/6/8 同样适用(拆分查询从 5.0 引入)。

现象

一个”列表/导出”查询,大致长这样(简化):

var items = await db.Orders
    .AsNoTrackingWithIdentityResolution()
    .AsSplitQuery()                       // 关键点 1:拆分查询
    .Where(o => o.ShopId == shopId)
    .Include(o => o.Payments)             // 关键点 2:Include 一个集合
    .OrderByDescending(o => o.SubmittedDate)   // 关键点 3:排序列不唯一
    .Take(5000)                           // 关键点 4:Skip/Take 截断
    .ToListAsync();

// 之后在内存里映射:取第一笔"提交订单"付款的金额
foreach (var o in items)
{
    var pay = o.Payments.FirstOrDefault(p => p.Type == PaymentType.Submit);
    model.InvoiceAmount = pay?.InvoiceAmount ?? 0;   // ← 部分行这里变成了 0
}

Payments 集合对一部分订单没加载出来(是空集合),于是 pay 为 null,InvoiceAmount 落到默认值 0。但这些订单在库里其实是有付款记录的。

复现

用一个最小工程就能稳定复现(同 EF/Npgsql 版本,本地 PostgreSQL)。造 2000 条订单,每条都挂一笔非 0 付款,并且让 SubmittedDate 大量重复(模拟批量导入产生的相同时间戳):

最小复现代码

public class Order
{
    public Guid Id { get; set; }
    public Guid ShopId { get; set; }
    public DateTime SubmittedDate { get; set; }
    public List<Payment> Payments { get; set; } = new();
}
public class Payment
{
    public int Id { get; set; }
    public Guid OrderId { get; set; }
    public decimal InvoiceAmount { get; set; }
}

// 造数据:2000 单,每单一笔金额 100 的付款;SubmittedDate 只有 5 个不同值(大量并列)
for (int i = 0; i < 2000; i++)
{
    var id = Guid.NewGuid();
    rows.Add(new Order {
        Id = id, ShopId = shopId,
        SubmittedDate = baseDate.AddDays(i % 5),   // ← 制造并列
        Payments = { new Payment { OrderId = id, InvoiceAmount = 100m } }
    });
}

// 对比几种查询,统计"加载出来 Payments 为空"的行数
async Task Run(bool split, bool tieBreaker, int take)
{
    IQueryable<Order> q = db.Orders.AsNoTrackingWithIdentityResolution()
        .Where(o => o.ShopId == shopId).Include(o => o.Payments);
    q = split ? q.AsSplitQuery() : q.AsSingleQuery();
    IOrderedQueryable<Order> oq = q.OrderByDescending(o => o.SubmittedDate);
    if (tieBreaker) oq = oq.ThenBy(o => o.Id);
    var items = await oq.Take(take).ToListAsync();
    int empty = items.Count(o => o.Payments.Count == 0);
    Console.WriteLine($"split={split} tie={tieBreaker} take={take} -> 空集合行数={empty}");
}

结果一目了然:

场景 空集合行数(= 金额丢失)
AsSplitQuery + 非唯一排序 + Take(500)(截断) 54 / 500
同上 + .ThenBy(o => o.Id)(唯一排序) 0
AsSplitQuery + Take(5000)(> 总数,不截断) 0
AsSingleQuery + Take(500)(不拆分) 0

这也解释了开头那个”带 search 反而正常”的怪现象:search 只命中单个店铺、结果远小于 Take 上限,没发生截断;全量导出结果集超过 Take,才触发。

根因

AsSplitQuery 会把每个集合 Include 拆成一条独立的 SQL。主查询查订单主体,Payments 单独用一条子查询去 JOIN。这条集合子查询会把主查询的过滤 + 排序 + LIMIT 重新算一遍,用来确定”要给哪些主实体加载集合”,大概长这样:

-- 集合子查询(简化)
SELECT p.*
FROM (
    SELECT o."Id", o."SubmittedDate"
    FROM "Orders" o
    WHERE o."ShopId" = @shop
    ORDER BY o."SubmittedDate" DESC       -- ← 注意这里没有唯一列
    LIMIT 5000
) AS t0
JOIN "Payments" p ON p."OrderId" = t0."Id"
ORDER BY t0."SubmittedDate" DESC

问题就在这条子查询的 ORDER BY ... LIMIT 上:SubmittedDate 不唯一。当结果集超过 LIMIT 需要截断时,边界上那批”时间相同”的并列行,主查询和这条集合子查询各自 LIMIT 选中的行集可能不一样(PostgreSQL 对并列行的物理顺序不做保证,两次执行计划不同就可能选出不同的行)。于是:主查询返回的某些订单,其 Id 不在集合子查询选中的行集里 → Payments join 不上 → 空集合 → 金额变 0。

一个关键细节:EF 只给”主查询”补了主键

ToQueryString()主查询的 SQL,会发现 EF Core 其实自动ORDER BY 末尾补了主键:

-- 主查询(未加 tiebreaker 时)EF 也会自动补 PK:
... ORDER BY o."SubmittedDate" DESC, o."Id"

但这个”自动补主键”的行为只作用于主查询,不会传播到拆分出来的集合子查询!集合子查询的内层 LIMIT 还是只按 SubmittedDate 排。这就是主查询和集合子查询”选出不同行集”的根本原因,也是为什么 bug 只影响集合 Include、不影响主体字段。

解决办法

给排序显式加一个唯一列(主键)作为 tiebreaker,让排序在整条查询里全局唯一——这样它会同时出现在集合子查询的内层排序里,两边 LIMIT 选中的行集就一致了:

.OrderByDescending(o => o.SubmittedDate)
.ThenBy(o => o.Id)        // ← 唯一 tiebreaker,一行搞定

如果排序是用 System.Linq.Dynamic.Core 的字符串写的(OrderBy("submittedDate desc")),拼一个主键即可:OrderBy("submittedDate desc, Id");或者强转后 ThenBy

queryable = ((IOrderedQueryable<Order>)queryable.OrderBy(sortStr)).ThenBy(o => o.Id);

其它思路(不如加 tiebreaker):

  • AsSingleQuery():单条 SQL 不存在多子查询分歧,能规避。但集合 Include 多时会笛卡尔积膨胀,正是当初改用拆分查询的原因,不划算。
  • 换一个本身唯一的排序列:治标,换个非唯一列又会中招。

官方文档原话(Single vs. Split Queries):
“When using split queries with Skip/Take, pay special attention to making your query ordering fully unique; not doing so could cause incorrect data to be returned.”
排序不唯一在单查询里最多顺序不稳定,但在拆分查询 + Skip/Take里会直接返回错误数据

性能:加个 GUID 主键排序会不会变慢?

主键是随机 GUID、本身无序,担心它拖慢排序是合理的,但这里基本无影响,原因有二:

  1. 主查询计划根本没变——前面说了,EF 本来就给主查询的 ORDER BY 自动补了主键。加 .ThenBy(Id) 只是把这个排序也同步到了集合子查询,那条昂贵的 top-N 排序在改之前就一直在付。
  2. Id 只是次级 tiebreaker,不是范围扫描键。主排序列(SubmittedDate)走索引拿到有序数据,Id 只在”主键列相等”的并列小组内排序。PostgreSQL(13+)用 Incremental Sort 处理:走同一个 SubmittedDate 索引,只对小分组做内存 quicksort。

EXPLAIN (ANALYZE) 实测(3.5w 行取 5000,warm cache):

-- 无 tiebreaker: ORDER BY SubmittedDate DESC
Limit -> Index Scan using IX_Orders_SubmittedDate      Execution ~10 ms

-- 有 tiebreaker: ORDER BY SubmittedDate DESC, Id
Limit -> Incremental Sort (Presorted Key: SubmittedDate,  -- 同一个索引
            Full-sort Groups: 157  quicksort  Peak Memory: 27kB)
      -> Index Scan using IX_Orders_SubmittedDate       Execution ~7 ms

两者走同一个索引,差异在噪声范围内。前提是主排序列要有索引(否则 , Id 可能退化成全表排序),SubmittedDate / CreatedDate 这类排序列一般都建了索引。唯一要留意的极端情况:某个排序值上有海量并列行(比如批量导入几万行同一时间戳),那一组会做较大的内存 quicksort,但受 LIMIT 截断、纯内存、GUID 排序很快——而且这恰恰是必须有 tiebreaker 才能保证数据正确的数据形态。

小结 / Checklist

只要满足下面全部条件,就要警惕这个坑:

  • 用了 AsSplitQuery()(或全局配了 UseQuerySplittingBehavior(SplitQuery)
  • Include集合导航属性
  • Skip / Take(分页 / 导出限量)
  • ORDER BY 的列不唯一(时间、状态、名称……)
  • 结果集可能超过 Take 上限(会真正截断)

修复就一句话:给排序补一个唯一列(通常是主键)做 tiebreaker。顺带一提,这个坑不只影响”金额变 0″——任何拆分加载的集合(明细行、子记录)都可能在分页边界上间歇性丢数据,只是不一定有人核对,隐蔽得很。

参考

文章摘自:https://www.cnblogs.com/laggage/p/21093702