最近排查了一个很隐蔽的生产 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、本身无序,担心它拖慢排序是合理的,但这里基本无影响,原因有二:
- 主查询计划根本没变——前面说了,EF 本来就给主查询的
ORDER BY自动补了主键。加.ThenBy(Id)只是把这个排序也同步到了集合子查询,那条昂贵的 top-N 排序在改之前就一直在付。 - 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″——任何拆分加载的集合(明细行、子记录)都可能在分页边界上间歇性丢数据,只是不一定有人核对,隐蔽得很。
参考
- EF Core 官方文档:Single vs. Split Queries
- PostgreSQL:Incremental Sort(PG 13+)
文章摘自:https://www.cnblogs.com/laggage/p/21093702
