Appearance
表拆分(Table Splitting)
概述
表拆分(Table Splitting)是 EF Core 的一种高级映射技术,允许多个实体类型共享同一个数据库表。这与传统的"一表一实体"模式不同,表拆分可以将一个大表逻辑上拆分为多个实体,或者将相关数据组织到不同的实体类中,但物理上存储在同一个表中。
使用场景
- 大表优化: 将常用字段和不常用字段分离
- 性能优化: 减少查询时加载的数据量
- 关注点分离: 将不同业务逻辑的数据分开
- 遗留系统: 适配现有数据库结构
基础概念
传统模式 vs 表拆分
传统模式(一表一实体):
数据库表: Products
┌────┬──────────┬───────┬────────────┬──────────────┐
│ Id │ Name │ Price │ Stock │ Description │
└────┴──────────┴───────┴────────────┴──────────────┘
C# 实体: Product (包含所有字段)表拆分模式:
数据库表: Products (同一个表)
┌────┬──────────┬───────┬────────────┬──────────────┐
│ Id │ Name │ Price │ Stock │ Description │
└────┴──────────┴───────┴────────────┴──────────────┘
C# 实体:
- ProductInfo (Id, Name, Price) - 常用信息
- ProductInventory (Id, Stock) - 库存信息
- ProductDetails (Id, Description) - 详细信息配置方法
示例 1: 基本信息与详细信息拆分
定义实体
csharp
// 产品基本信息
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
// 导航属性指向详细信息
public ProductDetails? Details { get; set; }
}
// 产品详细信息
public class ProductDetails
{
public int Id { get; set; }
public string Description { get; set; } = string.Empty;
public string Specifications { get; set; } = string.Empty;
public byte[]? ImageData { get; set; }
// 导航属性指向基本信息
public Product? Product { get; set; }
}Fluent API 配置
csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 配置 Product
modelBuilder.Entity<Product>(entity =>
{
entity.ToTable("Products");
entity.HasKey(p => p.Id);
entity.Property(p => p.Name).IsRequired().HasMaxLength(200);
entity.Property(p => p.Price).HasColumnType("decimal(18,2)");
});
// 配置 ProductDetails - 共享同一个表
modelBuilder.Entity<ProductDetails>(entity =>
{
entity.ToTable("Products"); // 关键: 指定相同的表名
entity.HasKey(d => d.Id);
// 配置一对一关系
entity.HasOne(d => d.Product)
.WithOne(p => p.Details)
.HasForeignKey<ProductDetails>(d => d.Id); // 共享主键
entity.Property(d => d.Description).HasMaxLength(2000);
entity.Property(d => d.Specifications).HasMaxLength(4000);
entity.Property(d => d.ImageData).HasColumnType("varbinary(max)");
});
}使用示例
csharp
// 只查询基本信息(不加载详细信息)
var products = await context.Products
.Where(p => p.Price > 100)
.ToListAsync();
// 需要时才加载详细信息
var productWithDetails = await context.Products
.Include(p => p.Details)
.FirstOrDefaultAsync(p => p.Id == 1);
Console.WriteLine(productWithDetails.Name);
Console.WriteLine(productWithDetails.Details?.Description);示例 2: 用户信息与认证信息拆分
定义实体
csharp
// 用户基本信息
public class User
{
public int Id { get; set; }
public string Username { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public UserAuthentication? Authentication { get; set; }
}
// 用户认证信息(敏感数据)
public class UserAuthentication
{
public int Id { get; set; }
public string PasswordHash { get; set; } = string.Empty;
public string Salt { get; set; } = string.Empty;
public DateTime? LastLoginAt { get; set; }
public int FailedLoginAttempts { get; set; }
public User? User { get; set; }
}配置
csharp
modelBuilder.Entity<User>(entity =>
{
entity.ToTable("Users");
entity.HasKey(u => u.Id);
});
modelBuilder.Entity<UserAuthentication>(entity =>
{
entity.ToTable("Users"); // 共享 Users 表
entity.HasKey(a => a.Id);
entity.HasOne(a => a.User)
.WithOne(u => u.Authentication)
.HasForeignKey<UserAuthentication>(a => a.Id);
// 敏感字段
entity.Property(a => a.PasswordHash).IsRequired();
entity.Property(a => a.Salt).IsRequired();
});使用场景
csharp
// 场景 1: 显示用户列表(不需要密码信息)
var users = await context.Users
.Select(u => new { u.Id, u.Username, u.Email })
.ToListAsync();
// 场景 2: 用户登录(需要认证信息)
var userWithAuth = await context.Users
.Include(u => u.Authentication)
.FirstOrDefaultAsync(u => u.Username == username);
if (userWithAuth != null && VerifyPassword(password, userWithAuth.Authentication.PasswordHash))
{
// 登录成功
userWithAuth.Authentication.LastLoginAt = DateTime.UtcNow;
userWithAuth.Authentication.FailedLoginAttempts = 0;
await context.SaveChangesAsync();
}
else
{
// 记录失败尝试
if (userWithAuth != null)
{
userWithAuth.Authentication.FailedLoginAttempts++;
await context.SaveChangesAsync();
}
}示例 3: 订单统计信息拆分
定义实体
csharp
// 订单基本信息
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public DateTime OrderDate { get; set; }
public string Status { get; set; } = "Pending";
public OrderStatistics? Statistics { get; set; }
}
// 订单统计信息(计算字段)
public class OrderStatistics
{
public int Id { get; set; }
public int ItemCount { get; set; }
public decimal Subtotal { get; set; }
public decimal TaxAmount { get; set; }
public decimal ShippingCost { get; set; }
public decimal TotalAmount { get; set; }
public Order? Order { get; set; }
}配置
csharp
modelBuilder.Entity<Order>(entity =>
{
entity.ToTable("Orders");
entity.HasKey(o => o.Id);
});
modelBuilder.Entity<OrderStatistics>(entity =>
{
entity.ToTable("Orders"); // 共享 Orders 表
entity.HasKey(s => s.Id);
entity.HasOne(s => s.Order)
.WithOne(o => o.Statistics)
.HasForeignKey<OrderStatistics>(s => s.Id);
});高级配置
1. 可选的拆分实体
csharp
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
// 可选: 可能没有详细信息
public ProductDetails? Details { get; set; }
}
modelBuilder.Entity<ProductDetails>(entity =>
{
entity.ToTable("Products");
entity.HasOne(d => d.Product)
.WithOne(p => p.Details)
.HasForeignKey<ProductDetails>(d => d.Id)
.IsRequired(false); // 可选关系
});2. 多个拆分实体
csharp
// 一个主实体,多个拆分实体
public class Article
{
public int Id { get; set; }
public string Title { get; set; }
public DateTime PublishedAt { get; set; }
public ArticleContent? Content { get; set; }
public ArticleMetadata? Metadata { get; set; }
public ArticleStatistics? Statistics { get; set; }
}
// 配置所有实体共享 Articles 表
modelBuilder.Entity<Article>().ToTable("Articles");
modelBuilder.Entity<ArticleContent>().ToTable("Articles");
modelBuilder.Entity<ArticleMetadata>().ToTable("Articles");
modelBuilder.Entity<ArticleStatistics>().ToTable("Articles");
// 配置外键关系
modelBuilder.Entity<ArticleContent>()
.HasOne(c => c.Article)
.WithOne(a => a.Content)
.HasForeignKey<ArticleContent>(c => c.Id);
modelBuilder.Entity<ArticleMetadata>()
.HasOne(m => m.Article)
.WithOne(a => a.Metadata)
.HasForeignKey<ArticleMetadata>(m => m.Id);
modelBuilder.Entity<ArticleStatistics>()
.HasOne(s => s.Article)
.WithOne(a => a.Statistics)
.HasForeignKey<ArticleStatistics>(s => s.Id);3. 并发控制
csharp
modelBuilder.Entity<Product>(entity =>
{
entity.ToTable("Products");
entity.Property(p => p.RowVersion).IsRowVersion();
});
modelBuilder.Entity<ProductDetails>(entity =>
{
entity.ToTable("Products");
// 共享主实体的 RowVersion
});性能优化
1. 减少数据传输
csharp
// ❌ 不使用表拆分: 每次都加载大字段
var products = await context.Products.ToListAsync();
// SELECT Id, Name, Price, Description, Specifications, ImageData FROM Products
// ✅ 使用表拆分: 只加载需要的数据
var products = await context.Products
.Select(p => new { p.Id, p.Name, p.Price })
.ToListAsync();
// SELECT Id, Name, Price FROM Products
// 需要时才加载详细信息
var product = await context.Products
.Include(p => p.Details)
.FirstOrDefaultAsync(p => p.Id == 1);2. 投影查询优化
csharp
// 只选择需要的拆分实体
var productSummaries = await context.Products
.Select(p => new ProductSummaryDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price,
HasDetails = p.Details != null
})
.ToListAsync();3. 批量操作
csharp
// 更新基本信息
await context.Products
.Where(p => p.CategoryId == 1)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price * 1.1m));
// 更新详细信息
await context.Set<ProductDetails>()
.Where(d => d.Product!.CategoryId == 1)
.ExecuteUpdateAsync(setters => setters
.SetProperty(d => d.Specifications, "Updated"));最佳实践
✅ 推荐做法
1. 根据访问频率拆分
csharp
// 高频访问字段 -> 主实体
public class Product
{
public int Id { get; set; }
public string Name { get; set; } // 经常查询
public decimal Price { get; set; } // 经常查询
}
// 低频访问字段 -> 拆分实体
public class ProductDetails
{
public int Id { get; set; }
public string Description { get; set; } // 偶尔查看
public byte[] ImageData { get; set; } // 很少访问,大数据
}2. 根据安全性要求拆分
csharp
// 公开信息
public class User
{
public string Username { get; set; }
public string Email { get; set; }
}
// 敏感信息
public class UserCredentials
{
public string PasswordHash { get; set; }
public string ApiKey { get; set; }
}3. 添加适当的索引
sql
-- 为主键创建聚簇索引
CREATE CLUSTERED INDEX IX_Products_Id ON Products(Id);
-- 为常用查询字段创建非聚簇索引
CREATE NONCLUSTERED INDEX IX_Products_Price ON Products(Price);❌ 避免的陷阱
1. 不要过度拆分
csharp
// ❌ 错误: 拆分太细,增加复杂度
public class ProductBasic { }
public class ProductPrice { }
public class ProductStock { }
public class ProductDescription { }
// ✅ 正确: 合理分组
public class Product { } // 基本信息
public class ProductDetails { } // 详细信息建议: 每个表最多 2-3 个拆分实体
2. 不要忘记配置关系
csharp
// ❌ 错误: 忘记配置外键
modelBuilder.Entity<ProductDetails>(entity =>
{
entity.ToTable("Products");
// 忘记配置 HasOne/WithOne/HasForeignKey
});
// ✅ 正确: 完整配置
modelBuilder.Entity<ProductDetails>(entity =>
{
entity.ToTable("Products");
entity.HasOne(d => d.Product)
.WithOne(p => p.Details)
.HasForeignKey<ProductDetails>(d => d.Id);
});3. 注意空值处理
csharp
// ❌ 可能导致 NullReferenceException
var description = product.Details.Description;
// ✅ 安全访问
var description = product.Details?.Description ?? "N/A";
// 或使用条件加载
var product = await context.Products
.Include(p => p.Details)
.FirstOrDefaultAsync(p => p.Id == id);与其他技术对比
| 技术 | 表数量 | 实体数量 | 适用场景 |
|---|---|---|---|
| 表拆分 | 1个表 | 多个实体 | 大表优化,关注点分离 |
| 表分片 | 多个表 | 1个实体 | 水平扩展,分库分表 |
| TPH继承 | 1个表 | 多个实体 | 类型层次结构 |
| TPT继承 | 多个表 | 多个实体 | 严格的类型隔离 |
总结
核心优势
✅ 性能提升: 减少不必要的数据加载
✅ 灵活性强: 按需加载不同部分
✅ 安全性好: 敏感数据独立管理
✅ 维护性高: 关注点清晰分离
适用场景
- 大文本字段(Description, Content)
- 二进制数据(Image, File)
- 敏感信息(Password, Token)
- 统计数据(Count, Sum)
- 审计日志(CreatedAt, ModifiedAt)
注意事项
⚠️ 不要过度拆分(保持简单)
⚠️ 始终配置正确的关系
⚠️ 注意空值和异常处理
⚠️ 测试查询性能
表拆分是优化 EF Core 性能的有力工具,合理使用可以显著提升应用效率! 🚀