EF Core 和 PostgreSQL 的组合在 .NET 生态里使用得越来越频繁,但只要一提到 JSONB,很多人的第一反应就是“用 EF Core 操作到底该怎么搞”。官方文档写了但不够详细,社区里说法又五花八门,什么“必须写原生 SQL”“用 OwnsOne 映射”“Npgsql 自带 Jsonb 类型”,看多了反而更懵。
我最近在一个电商后台项目里集中做了几轮实测,把三种主流操作方式全部撸了一遍,包含代码实现、查询写入差异、索引和序列化细节,最后得出一个很实在的结论:所谓“性能之王”,取决于你的业务到底在 JSONB 里要做什么。这篇文章我就把整个比对过程完整记录下来,适合正在纠结方案选型、或者已经在用 EF Core 操作 PostgreSQL JSONB 但性能不如预期的开发者,看完你就能直接判断自己该用哪种。
1. 三种方式到底在争什么:先看懂 JSONB 在 ORM 里的位置
1.1 JSONB 与普通 JSON:先搞清数据库这件事
PostgreSQL 提供两种 JSON 数据类型:json和jsonb。前者存的是文本,每次读取都要解析;后者存的是二进制格式,插入时会做解析和转换,查询时不需要重复解析,而且支持 GIN 索引、@>、?这些操作符,实际项目中我们说的“JSONB 复杂查询”基本都建立在jsonb之上。
EF Core 本身并不认识 PostgreSQL 的jsonb。EF Core 是 ORM,它对属性的理解是“表里的某一列”,列的类型可以自定义映射,但等到了 PostgreSQL 驱动那边,JSONB 既不是字符串,也不是普通的对象,它是NpgsqlTypes.NpgsqlDbType.Jsonb。这中间跨了一层,导致很多人在配置模型时不知道该把这个属性配成什么,也不知道查询时能不能在 LINQ 里直接写x.Attributes.SourceChannel。
我见过不少项目里的做法是:数据库已经是jsonb,EF Core 模型里却把对应属性配成string,查询时把整张表读回来,在内存里反序列化再过滤。少量数据没事,数据量到了十万级,这种写法基本是不可用的。所以要谈性能,先得明确我们是从数据库底层开始设计,还是在现有代码上打补丁。
1.2 三种方案的 AB 面:从存储模型到查询路径的根本差异
我需要测试的三种方式,本质上对应了三种不同的“JSONB 翻译策略”:
第一种是原生 SQL。完全绕过 EF Core 的 LINQ 翻译,直接用 SQL 语句操作attributes->>'source_channel'这种 jsonb 表达式。ORM 在这里只负责执行和映射结果。
第二种是把整个 JSONB 当做一个复杂的对象属性,在 EF Core 里配置一种值转换(Value Converter),写入时序列化成 JSON 字符串,读取时反序列化成 C# 对象。查询时基本不能按 JSON 内部字段过滤,只能整体存取。
第三种是 EF Core 7 起提供的 JSON 实体映射,用OwnsOne配合ToJson()让 EF 把嵌套对象建模为一个 JSONB 列。它最大的特点是 LINQ 查询可以直接写x.Attributes.SourceChannel,EF 会把这种访问翻译成 SQL 里的->>操作。
三种方式在写法上一个比一个“像原生 ORM”,但从性能角度来看,恰恰是越智能的方式,中间层做的额外工作越多。我这里不是劝退高级用法,而是想告诉你:选型不是“哪个看起来更高级”,而是“哪一个的额外开销是你能接受的”。
2. 实测代码:三种方式逐一带你落地
2.1 前置准备:一个约 10 万行数据的订单表
我搭建的模拟环境是 .NET 8 + EF Core 8 + PostgreSQL 15,NuGet 包用Npgsql.EntityFrameworkCore.PostgreSQL8.x。测试表叫orders,结构如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | uuid | 主键 |
| order_no | varchar | 订单号 |
| customer_name | varchar | 客户名 |
| attributes | jsonb | 扩展属性,存渠道来源、是否VIP、备注等 |
我在attributes里放了一个固定结构的子对象,平均每条数据大约 2KB,表总行数控制在 10 万。为了模拟不同渠道来源,我随机生成了source_channel为app、web、mini三种值。
需要说明的是,以下代码都是在同一个项目里分文件写的,不是同时配置在一个 DbContext 上,避免配置冲突。
2.2 方式一:原生 SQL 直接干
这种方式最直接。我定义了一个查询用的 DTO,然后使用上下文里的Database.SqlQueryRaw方法执行原生 SQL:
public class OrderRow { public Guid Id { get; set; } public string OrderNo { get; set; } public string CustomerName { get; set; } public OrderAttributes Attributes { get; set; } } var sql = @" SELECT id, order_no, customer_name, attributes FROM orders WHERE attributes->>'source_channel' = @channel ORDER BY created_at DESC LIMIT @take"; var rows = await context.Database .SqlQueryRaw<OrderRow>( sql, new NpgsqlParameter("channel", "app"), new NpgsqlParameter("take", 10)) .ToListAsync();SqlQueryRaw会把查询结果映射到OrderRow,attributes列在 Npgsql 驱动读取时会自动转换成JsonDocument或根据目标类型反序列化。这里有个细节你可能需要留意:如果 DTO 的属性类型是OrderAttributes,Npgsql 需要能够从jsonb列反序列化到它,所以这个类不能是密封的限制,普通 POCO 即可。
这种方式的优点写得懂 SQL 就能看懂,执行路径完全可控,缺点是 SQL 需要手动维护,一旦字段变了,SQL 和模型容易不同步。
2.3 方式二:整个对象序列化进 jsonb
方式二在产品代码里最常见,配置也最简单。实体类里直接放一个复杂属性,然后用HasConversion指定序列化规则:
public class Order { public Guid Id { get; set; } public string OrderNo { get; set; } public string CustomerName { get; set; } public OrderAttributes Attributes { get; set; } } public class OrderAttributes { public string SourceChannel { get; set; } public bool IsVip { get; set; } public string Remark { get; set; } }配置代码如下:
modelBuilder.Entity<Order>(entity => { entity.ToTable("orders"); entity.HasKey(x => x.Id); entity.Property(x => x.Attributes) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null), v => JsonSerializer.Deserialize<OrderAttributes>(v, (JsonSerializerOptions)null) ); });注意:HasColumnType("jsonb")告诉 Npgsql 这一列是 jsonb,HasConversion负责 EF Core 拿到实体对象和数据库值之间的转换。查询时如果你尝试写:
var result = await context.Orders .Where(x => x.Attributes.SourceChannel == "app") .ToListAsync();EF Core 并不会把Attributes.SourceChannel翻译成 SQL,因为它在这里看到的只是一个序列化的整体属性,EF 没有能力把对象内部字段“掰开”到 SQL 里。我实测的结果就是代码编译能通过,但运行时会抛异常,提示无法翻译表达式。
所以方式二的查询套路只能是先取数据再在内存里过滤。为了性能对比,我这里采取的是:
var all = await context.Orders.Take(10000).ToListAsync(); var result = all .Where(x => x.Attributes != null && x.Attributes.SourceChannel == "app") .Take(10) .ToList();我先取一万条到内存,再过滤。这个写法的性能代价会直接在测试数据里暴露出来。
2.4 方式三:EF Core 7 的 JSON 实体映射
方式三是 EF Core 7 新增的功能,用OwnsOne加ToJson()配置。它的意思是:这段嵌套对象不是单独的一张表,而是以 JSON 形式存在同一行。
modelBuilder.Entity<Order>(entity => { entity.ToTable("orders"); entity.HasKey(x => x.Id); entity.OwnsOne(x => x.Attributes, owned => { owned.ToJson(); owned.Property(x => x.SourceChannel).HasColumnName("source_channel"); owned.Property(x => x.IsVip).HasColumnName("is_vip"); owned.Property(x => x.Remark).HasColumnName("remark"); }); });这里有一点容易误解:HasColumnName配置的不是数据库表里的独立列,而是 JSON 内部字段的“名称路径”。ToJson()会让 EF 确定这一个 JSONB 列存储整个对象,这些字段名是 JSON 文档里用的。
配置好之后,查询就可以直接用 LINQ 访问 JSON 内部字段了:
var result = await context.Orders .Where(x => x.Attributes.SourceChannel == "app") .OrderByDescending(x => x.CreatedAt) .Take(10) .ToListAsync();EF Core 8 能把Attributes.SourceChannel翻译成attributes->>'source_channel',我在日志里看到生成的 SQL 大致是:
SELECT id, order_no, customer_name, attributes FROM orders WHERE attributes->>'source_channel' = 'app' ORDER BY created_at DESC LIMIT 10方式三在编码体验上是最舒服的,像操作普通属性一样操作 JSON 内部字段,但它的翻译和序列化逻辑复杂,执行开销到底怎么样,下一节的测试数据会说明。
3. 同一台机器上的性能数字:差距到底在哪
3.1 测试场景与指标口径
我的测试环境是同一台开发机,数据库和应用程序都在本地,避免了网络开销干扰。测试分两类场景:
一类是查询场景,目标是从 10 万行表里找出source_channel为app的最新 10 条记录。对方式二来说,因为没法下推过滤条件,我先Take(10000)拉到内存再过滤;另外我额外加了一个对比组,就是让它把 10 万行全查出来过滤,看看“最差写法”能有多慢。
另一类是写入场景,分为单条插入和一批量插入 1000 条。写入时方式二和方式三都需要把对象序列化后再发给 PostgreSQL,所以额外开销主要在序列化这一步。
每种测试我跑 5 次取中位数,单位毫秒。这个数字不是权威基准,但能反映同一个环境下的量级差异。
3.2 查询场景实测结果
查询场景的结果非常直观:
| 方式 | 查询写法 | 耗时(中位数) |
|---|---|---|
| 方式一 | 原生 SQL,->>下推过滤 | 9 ms |
| 方式二 | 取 10000 条到内存过滤 | 312 ms |
| 方式二 | 全表取回内存过滤 | 1580 ms |
| 方式三 | LINQx.Attributes.SourceChannel == "app" | 46 ms |
方式一最快,因为 SQL 是手写的,没有任何翻译层,PostgreSQL 直接执行->>过滤,最后只回传 10 条数据,网络和对象映射的代价都很小。
方式三 46 毫秒也很快,它和方式一的差距主要来自 EF Core 的表达式树翻译、结果物化以及 owned entity 的跟踪机制。
方式二在查询内部字段时是灾难级别的性能。取 1 万条到内存已经要 300 多毫秒了,全表查询更是到 1.5 秒。如果有人告诉你“JSONB 反正就是存扩展字段,不查它”,你还能忍;但只要有一天业务需要按里面某个字段过滤,这种写法一定会成为整个接口的瓶颈。
3.3 写入场景实测结果
写入场景的数据也很有意思:
| 方式 | 单条插入 | 批量插入 1000 条 |
|---|---|---|
| 方式一(原生 SQL) | 1.1 ms | 78 ms |
| 方式二(值转换序列化) | 1.3 ms | 96 ms |
| 方式三(ToJson 映射) | 2.1 ms | 152 ms |
这里方式二和方式三的差距主要在于:方式二只有一个整体属性,EF 只需要做一次OrderAttributes到字符串的序列化;方式三因为是 owned entity,EF Core 需要把整个子对象结构拆开处理,加上导航跟踪机制的开销,序列化链路更长,所以批量插入时差距更明显。
但说实话,写入侧的差距并没有查询侧那么悬殊,单条 1 到 2 毫秒对绝大多数业务来说都可以接受。批量插入如果达到几千条以上,方式一的优势才会变得不可忽略。
3.4 性能差异的来源分析:序列化、表达式翻译和执行计划
很多人看到上面的数据,第一反应是“方式三比方式二强很多”,这没错,但要问一句为什么。
第一个关键是过滤条件下推。方式二读取 1 万行再过滤,意味着数据库返回 1 万行完整对象,然后你在应用层做低效的对象解包。而方式一和方式三都在数据库层完成了过滤,传输数据只有 10 行。
第二个关键是 JSON 反序列化次数。方式二里,即使你只想要SourceChannel这一个字段,EF 也会把整个attributes字符串反序列化成完整的OrderAttributes对象,Remark再大也得一起处理。方式三里,EF Core 8 对 owned entity 的反序列化虽然也做整个 JSON 的读取,但查询引擎知道目标类型结构,处理路径比通用序列化更集中。
第三个关键在翻译层。方式一完全没有翻译层,SqlQueryRaw直接把 SQL 交给驱动,少掉不少中间计算。方式三需要把表达式树转成 SQL,性能理论上会差一点,但换来的是类型安全和动态查询组合能力。
我在测试中还发现:方式三生成的 SQL 和方式一手写的推荐 SQL 基本一致,但执行计划里有一个细节,方式三默认会对attributes整体做::jsonb之类的类型转换,手写 SQL 时我完全可控,所以方式一能更轻松地命中特殊索引。这一点放到后面的避坑章节细说。
4. 常见问题与排查技巧:这些坑我替你先踩了
4.1 索引不生效:GIN 不是万能的
PostgreSQL 的 JSONB 最让人眼馋的就是 GIN 索引,但很多人建了 GIN 索引后发现查询还是很慢,原因在于 GIN 索引只对@>、?、?|这类操作符有效。像attributes->>'source_channel' = 'app'这种写法,GIN 索引是不会去用的。
要让->>表达式也走索引,需要单独创建表达式索引:
CREATE INDEX ix_orders_source_channel ON orders ((attributes->>'source_channel'));我在测试方式三时发现,如果你查询前没有建这个表达式索引,哪怕只查 10 条,数据库也大概率做全表扫描或者 bitmap 扫描,10 万行数据量时耗时在 80 到 100 毫秒,比有索引时的 9 毫秒高了近 10 倍。很多人没有意识到“GIN 索引”和“表达式索引”是两个东西,建了 GIN 索引就以为万事大吉,这是最经典的一个坑。
如果你要同时查多个 JSON 内部字段,可以建多个表达式索引,或者用jsonb_path_ops的 GIN 索引配合@>操作符来查整个子对象。选择哪种,要看你实际的查询模式。
4.2 更新 jsonb 时到底更新了什么
方式三的另一个坑体现在更新场景。EF Core 7+ 对OwnsOne的 JSON 映射,默认行为是:只要子对象内部任何一个属性发生变化,EF 就会把整个attributes列重新序列化并整体写回。也就是说,你在代码里只改了Remark,但 PostgreSQL 收到的是一个完整的jsonb替换更新,而不是只更新其中一个键。
这在数据量大时会有两个隐患。一是并发覆盖风险:两个请求同时读到同一行,各自修改不同字段,后提交的会把先提交的整个覆盖掉。如果你的业务存在多人同时改扩展属性的情况,建议改用显式UPDATE ... SET attributes = jsonb_set(...)这样的原生 SQL,只更新指定键。二是性能问题:每次 SaveChanges 都会序列化整个子对象,虽然多数场景下这只带来微秒级延迟,但批量更新时会被放大。
4.3 序列化和查询翻译的边界问题
方式二的值转换我遇到过两个实际报错。
第一个是循环引用问题。如果在OrderAttributes里放了导航属性或者反向引用,JsonSerializer.Serialize默认配置会直接抛异常,提示检测到循环引用。解决方法是专门写一个 JSON 序列化配置,或者把 DTO 和领域模型分开。
第二个是JsonDocument翻车。有人图省事,把配置成JsonDocument类型,代码写成entity.Property(x => x.Attributes).HasColumnType("jsonb")。这在写入时没问题,但查询出来后JsonDocument是一次性的,用完必须 Dispose,你不能把它像普通属性那样跨越多次调用去使用。EF Core 查询返回的实体如果带着JsonDocument,很容易在后续访问时出现“object disposed”异常,排查起来很费劲。
方式三的查询翻译也有边界。EF Core 7 对 JSON 映射的查询支持不是 100% 覆盖所有 LINQ 操作,比如在嵌套数组里调用Any配合复杂条件时,某些组合会提示“无法翻译”。我遇到过一次在 JSON 数组里做Where(w => w.Price > 100 && w.Stock > 0)的查询,EF 没法翻译成合适的 SQL 操作符,最后只能兜底用原生 SQL。所以如果你计划在 JSONB 里存数组并频繁做内部查询,一定先写一个最小验证 Demo,不要等高复杂度查询写完才发现边界。
4.4 快速排查思路列表
后面再看别人代码或者自己接口变慢时,我习惯按这条思路排查:
- 先看 EF 生成的 SQL 是什么,是不是把过滤条件全下推了。
- 再看数据库执行计划,是 Seq Scan 还是 Index Scan。
- 如果明明有表达式索引还是没走,检查查询里是不是用了函数包裹,比如
LOWER(attributes->>'source_channel'),表达式索引也要按同样的函数写法建。 - 如果写入慢,优先怀疑序列化配置,尤其是有无多余转换。
- 如果出现数据覆盖,优先怀疑更新整个 jsonb 列的逻辑,而不是去改并发配置。
5. 选型建议:别再问谁是性能之王,先问自己什么场景
5.1 三种方式的适配场景对照
做了这么一大轮测试,我的最终判断很简单:不存在绝对王者,只存在场景匹配。
| 业务场景 | 推荐方式 | 理由 |
|---|---|---|
| 需要频繁按 JSON 内部字段过滤和排序 | 方式一或方式三 | 方式一最快,方式三开发效率更高 |
| 查询条件动态多变,难以手写 SQL | 方式三 | LINQ 拼接方便,还能保持类型安全 |
| JSONB 只做扩展信息存储,永远不查内部字段 | 方式二 | 配置最简,性能足够 |
| 报表类复杂聚合,要用到 jsonb 操作符 | 方式一 | 原生 SQL 表达力最强 |
| 高并发写,且多个字段分别更新 | 方式一配合jsonb_set | 避免整体覆盖 |
| 旧项目已经在用值转换方案,想最小改动 | 方式二 | 性能可接受时,不值得重写 |
这里我想特别强调一种混合用法:主查询逻辑用方式三写,让 EF 帮你做类型安全过滤;当遇到 EF 无法翻译的复杂 JSONB 操作时,再退回到方式一手写 SQL。两种方式可以同时存在,因为最终只是 SQL 层面的事。
5.2 我个人的团队使用规范建议
如果你正在带项目或者制定团队规范,我建议给自己定三条硬性规则。
第一条:如果表里已经有 jsonb 列,且这个列未来会被作为查询条件,禁止用方式二那种“整体读取再内存过滤”的写法,除非行数在几千以内。这条规则能避免大多数性能事故。
第二条:使用方式三时,必须同步检查有没有给->>用到的字段建表达式索引。EF Core 不会自动帮你建,也不会有任何警告。
第三条:把原生 SQL 封装在仓储或者专门的查询服务里,不要让裸 SQL 散落在 Controller 或业务代码里。目的是让后来者知道这段 SQL 为什么存在,免得某天被当作“多余代码”清理掉。
根据我个人实测下来的感受,方式三在开发效率上确实是三种方式里最舒服的,它在接口复杂度的处理上更符合「对象范式」的直觉,但前提是团队里有人能搞清楚 JSONB 的索引和更新机制。否则我会更建议保守一点,能用方式一解决核心性能问题的就用手写 SQL,方式三在“简单查询 + 嵌套对象”的组合下用,不要一上来就把所有 JSONB 操作都交给它。
最后分享一个实用小技巧:在DbContext里打开EnableSensitiveDataLogging和LogTo,把生成的 SQL 打印出来跑一遍 EXPLAIN,很多性能问题一眼就能看出来是索引没命中还是过滤没下推。这套“看 SQL、看执行计划、看序列化”的组合拳,比折腾任何高级调优参数都管用。