EF Core 默认映射规则里,一个 CLR 实体通常对应一张数据库表,普通项目很少打破这一点。但当表的列越来越多,尤其是备注、日志、附件地址等大字段和核心查询字段混在一起,查询主列表时也要扫描这些大字段,页宽变大、缓存命中下降,维护成本也跟着上涨。这时可以把一个实体类的属性拆到多张物理表,让主表保持较窄的常用列,拆分表存放访问频率低或体积大的数据。EF Core 提供了流式 API 配置这种映射,查询和保存仍然围绕同一个 C# 对象进行。接下来会先厘清 Table Splitting 和 Entity Splitting 的术语差异,再给出配置、查询和避坑方案。

在进入代码之前,需要明确 EF Core 对多表映射的约束:同一个实体拆到多少张表,这些表都必须共享同一个主键,所有表的主键列名称和类型要一致。EF Core 不会创建单独的外键关系,而是通过主键等值 Join 把多张表拼回一个对象。因此拆分表里的一行永远对应主表的一行,删除时要同时清理,插入时要写多张表。这个约束是理解后续配置和运行行为的核心。
Table Splitting 与 Entity Splitting 的概念差异
在 EF Core 官方文档里,Table Splitting 通常指两个实体类型通过一对一关系映射到同一张表,共享同一个行。例如 Order 和 OrderDetail 两个实体都映射到 Orders 表,OrderDetail 的主键同时作为外键指向 Order,数据行合并到一起。这种用法可以减少不必要的表数量,同时保留导航属性的聚合边界。
Entity Splitting 则是指一个实体映射到多张表,例如同一个 Order 实体拆到 Orders 和 OrderDetails 两张物理表。很多中文文章把后者也叫表拆分,因为从数据库视角看,确实把一个逻辑表拆开了。本文标题用 Table Splitting 作为入口,实际演示的是单实体多表映射;共享主键是两者共同的核心机制。了解这一点后,再看到 ToTable 多次调用就不会与导航属性的一对一配置混淆。
无论是哪种拆分方式,EF Core 在查询和保存时都会自动处理主键关联。区别在于,单实体多表映射不要求你创建额外实体类,业务层拿到的始终是同一个对象;而两个实体共享一行的方式则需要在代码里维护一对一导航属性。团队在选择时可以先判断:拆分后是否想让业务代码感知到物理存储差异。如果不想感知,单实体多表更合适。
使用 Fluent API 配置单实体映射到多张表
先定义一个常见的订单实体。假设订单主列表只需要查询单号、客户名,而配送地址、物流单号和备注信息访问频率较低且备注字段很大,适合拆到另一张表。实体定义如下:
public class Order
{
public int Id { get; set; }
public string OrderNo { get; set; }
public string CustomerName { get; set; }
public string ShippingAddress { get; set; }
public string TrackingNumber { get; set; }
public string Remark { get; set; }
}在 DbContext 的 OnModelCreating 里,给同一个实体多次调用 ToTable,并在每次调用中通过 Property 指定该表包含哪些属性。Id 主键必须在每张表都出现,因为 EF Core 不会自动把主键复制到所有拆分表。配置代码可以这样写:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(entity =>
{
entity.HasKey(o => o.Id);
entity.ToTable("Orders", table =>
{
table.Property(o => o.Id);
table.Property(o => o.OrderNo);
table.Property(o => o.CustomerName);
});
entity.ToTable("OrderDetails", table =>
{
table.Property(o => o.Id);
table.Property(o => o.ShippingAddress);
table.Property(o => o.TrackingNumber);
table.Property(o => o.Remark);
});
});
}这段配置会生成两张表:Orders 表包含 Id、OrderNo、CustomerName,OrderDetails 表包含 Id、ShippingAddress、TrackingNumber、Remark。两张表都没有外键约束,而是通过 Id 等值连接。如果希望列名不叫 Id 而叫 OrderId,可以在 Property 后继续调用 HasColumnName,但要保证两张表的主键列名称和类型完全一致。
实际开发中建议把属性清单写全,避免某个属性没有被任何 ToTable 声明而导致映射不完整。如果某个属性重复出现在多张表里,EF Core 会认为该列同时存在,更新时可能产生冗余写入。迁移生成后,可以查看生成的建表脚本,确认主键和列分布是否符合预期。
如果团队更习惯导航属性,也可以把 Order 拆成 Order 和 OrderDetail 两个实体,用 HasOne、WithOne 加上 HasForeignKey 配置主键外键相同。不过这已经不是单实体多表,而是不同的聚合边界。本文侧重一个实体对象不被切割,业务层无需感知底层是两张表。
查询、保存与更新的实际行为
查询完整实体时,EF Core 会自动生成连接两张表的 SQL。例如执行 context.Orders.FirstOrDefaultAsync(o => o.Id == 1) 时,生成语句类似 SELECT 两个表的所有映射列,并通过 INNER JOIN OrderDetails ON Orders.Id = OrderDetails.Id 拼回对象。这个 Join 在完整加载时是必须的,因为实体包含拆分表里的属性。
但如果只想展示订单主列表,建议使用投影只取需要的主表列。投影后 EF Core 会明白不需要拆分表数据,生成的 SQL 只查 Orders 表,不会 Join OrderDetails。例如:
var list = await context.Orders
.Where(o => o.CustomerName.Contains("张"))
.Select(o => new OrderListDto
{
Id = o.Id,
OrderNo = o.OrderNo,
CustomerName = o.CustomerName
})
.ToListAsync();投影类型可以是 DTO,也可以是匿名对象。这是拆表后最重要的性能习惯:避免无差别加载完整实体,否则本来只想读三列,结果把大字段备注也一起查出来,拆表的意义就失效了。分页列表尤其要注意这一点。
保存新对象时,context.Add(new Order { ... }) 会触发两张表的插入。如果主键由数据库生成,EF Core 会先向主表插入拿到 Id,再向拆分表插入同一 Id,整个过程默认在一个事务里提交。更新时,EF Core 的变更追踪器会做属性级差异比较。如果只改了 ShippingAddress,那么只会向 OrderDetails 表生成 Update 语句;如果只改了 OrderNo,则只更新 Orders 表。删除实体时,两张表中的对应行会被同时删除,拆分表不能独立于主表存在。
这里有一个细节需要注意:如果修改了某个拆分表属性,但 EF Core 认为该实体处于 Unchanged 状态,可能不会发出更新。这通常发生在手工构造对象并通过 Update 方法附加时,建议使用 Attach 后设置 State 或先查询再修改。
常见坑与规避建议
第一个常见问题是拆分表缺少对应行。如果历史数据里 OrderDetails 表没有 Id 等于某个主表 Id 的行,查询完整订单会因为 INNER JOIN 而查不到任何结果,尽管主表明明有数据。迁移完拆分表后必须回填所有主表已有的 Id。可以用一段 SQL 补齐:
INSERT INTO OrderDetails (Id, ShippingAddress, TrackingNumber, Remark) SELECT o.Id, NULL, NULL, NULL FROM Orders AS o WHERE NOT EXISTS (SELECT 1 FROM OrderDetails AS d WHERE d.Id = o.Id);
第二个问题是主键生成策略。如果主键不是简单自增,而是客户端生成的 Guid 或 HiLo,插入时两表共享同一个值,不会受影响。但要注意不要在拆分表里再配置另一个数据库生成值,否则两边主键会不一致。拆分表的主键必须完全依赖主表生成逻辑。
第三个问题是并发令牌。如果实体里有并发令牌属性,EF Core 通常只会把它映射到一张表。若多张表都需要乐观并发控制,就要额外处理,否则仅靠一张表的版本号或时间戳无法覆盖另一张表的独立更新。普通场景下建议只保留一个并发令牌在主表。
第四个问题是数据库层面的完整性。EF Core 迁移不会为拆分表创建外键约束,因为实体仍是一个模型。手工 SQL 或外部系统可能只写入一张表,导致数据不一致。可以在数据库层添加约束或触发器,或者约定所有写入都通过 EF Core 完成。对于报表查询,包装一个视图连接两张表,能让 DBA 和 BI 工具更方便地使用数据。
最后再强调一点:单实体多表映射不是银弹。如果只是偶发的性能问题,倒不如先看索引和查询投影;如果实体本身的聚合边界已经不清了,拆成两个实体可能更利于长期维护。只有当业务层希望保持一个对象,同时物理存储又确实需要冷热分离时,这种配置才是合适的。

