如果跑过一年以上的EF Core生产项目,大概都见过这种场面:实体类越堆越多,映射配置全挤在OnModelCreating里,索引靠DBA手工补,每次加字段都心惊肉跳,怕上下文里漏改一处,迁移文件最后缠成一团。标题里的"优化后的模型",准确说是我在一个订单系统重构过程中,对EF Core数据模型做的全套整理——从实体定义、Fluent API配置、索引约束、关系映射,到并发控制、软删除、迁移落地,每个环节都重新过了一遍,最终沉淀出一套能直接复用的模型设计基准。
这套"优化后的模型"不是简单地把实体换个写法,而是让模型同时兼顾三件事:读起来像领域模型,跑起来像调优过的SQL,改起来不用全局搜索。文章会给出完整的配置代码和每个配置背后的"为什么",适合正在维护中大型EF Core项目的后端开发,也适合刚打算系统化使用Fluent API的入门者。
1. 我为什么把整套EF Core模型重写一遍
1.1 默认约定是"能跑",不是"好用"
EF Core的默认约定解决的是"从零到一"的问题:表名自动用实体名的复数、列名直接用属性名、字符串默认nvarchar(max)、decimal默认decimal(18,2)、DateTime默认映射datetime2、枚举默认存int。这些默认值在Demo阶段完全够用,但一上生产,每个默认值都可能是隐患。
我当初踩得最疼的坑就是字符串列。一张订单表的Remark、ReceiverAddress这类字段根本没指定长度,数据库全是nvarchar(max)。等到列表页查询条件涉及Status和CreatedAt,模型里又没有对应索引,SQL Server只能走全表扫描。表里几百万行数据时,一条本该几十毫秒的列表查询硬生生跑到两三秒。查执行计划,全是Table Scan。
这不是EF Core的问题,是我把"模型设计"这件事交给约定去做了。默认约定只保证能映射、能查询,不保证查询走索引、列类型合理、约束完整。模型优化,第一步就是把该显式声明的东西全部显式声明。
1.2 优化要解决的四个目标
重构前我给自己列了四个目标,后面所有改动都围绕它们展开:
- 一致性:表名、列名、长度、精度、默认值的风格在全库范围内统一,不再出现一张表叫
Order另一张表叫product_info这种混乱。 - 可查询性:每个高频查询条件都有对应索引,唯一性约束在模型层面就能看出来,而不是靠DBA事后补。
- 可维护性:所有映射拆到独立配置类,
DbContext只负责聚合,改一个实体不动全局。 - 可解释性:模型和真实表结构一一对应,开发看代码就知道数据库里有什么索引、什么约束,不再依赖手工翻数据库。
这套优化的适用场景是有固定业务、表结构需要受控变更的中大型项目。如果是草稿阶段的Demo,直接让EF Core默认生成就好,暂时不必花这个成本。
2. 模型优化的基础设施:实体类与配置类的工程化拆分
2.1 实体类只保留数据形状,不掺业务动作
很多人喜欢在实体类里塞业务方法,比如CalculateTotal()、Validate()。放在领域驱动设计里有它的道理,但在EF Core的查询映射场景里,实体类更应该保持纯数据形状,否则序列化、变更跟踪、代理类生成都会出现奇怪问题。我现在的做法是:实体类里只有属性和必要的私有字段,业务逻辑全部放到服务层或领域服务里。
以订单实体为例,优化后的定义大致是这样:
public class Order { public long Id { get; private set; } public string OrderNo { get; private set; } = string.Empty; public long CustomerId { get; private set; } public decimal TotalAmount { get; private set; } public int Status { get; private set; } public bool IsDeleted { get; private set; } public DateTime CreatedAt { get; private set; } public DateTime? PaidAt { get; private set; } }注意几个细节:主键Id是long自增,业务编号OrderNo和客户IDCustomerId是不可变字符串/数值,状态字段先用int承载枚举值,软删除标记IsDeleted直接建模。导航属性这块,我倾向于在主要实体上保留必要的集合导航,但要小心循环引用。后面关系映射章节会重点讲。
集合导航属性一定要在构造函数里初始化,比如Orders = new HashSet<Order>();,否则Include加载后再 Add 子项容易踩空引用。
2.2 一个实体对应一个配置类,Fluent配置颗粒化
模型优化最立竿见影的一件事,就是把OnModelCreating里的几千行配置拆成独立配置类。EF Core 提供了IEntityTypeConfiguration<T>接口,每个实体一个配置类,职责单一,改一处不牵连全局。
public class OrderConfiguration : IEntityTypeConfiguration<Order> { public void Configure(EntityTypeBuilder<Order> builder) { builder.ToTable("orders"); builder.HasKey(x => x.Id); builder.Property(x => x.OrderNo) .HasMaxLength(32) .IsRequired(); builder.Property(x => x.TotalAmount) .HasPrecision(18, 2); builder.Property(x => x.Status) .HasConversion<int>() .IsRequired(); builder.Property(x => x.IsDeleted) .HasDefaultValue(false) .IsRequired(); builder.HasIndex(x => new { x.CustomerId, x.CreatedAt }) .IsDescending(false, true); builder.HasIndex(x => x.OrderNo) .IsUnique(); builder.HasQueryFilter(x => !x.IsDeleted); } }这里的每个配置都有原因:ToTable("orders")统一表名单数小写风格,跨数据库行为一致;HasMaxLength(32)限制业务编号长度,避免nvarchar(max);HasPrecision(18, 2)显式指定金额精度;HasIndex和IsUnique在模型层就把索引和唯一约束固化下来;HasQueryFilter是软删除的全局过滤,稍后单独展开。配置类里只允许做映射相关的事,不写业务逻辑,也不允许引用DbContext实例。
2.3 ApplyConfigurationsFromAssembly:一行代码加载全部配置
配置类建好之后,OnModelCreating里的代码可以精简到几乎只有一行:
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(MyDbContext).Assembly); }ApplyConfigurationsFromAssembly会扫描指定程序集里所有实现IEntityTypeConfiguration<T>的类,自动注册。前提是配置类和DbContext在同一个程序集。如果拆到了独立的类库,就传那个类库的Assembly。
这里可以顺手做一件事:自定义默认约定。EF Core 支持ConfigureConventions,可以在全局范围内统一某些映射规则,例如所有DateTime属性默认映射为datetime2,所有string属性默认加一个最大长度而不是放任max:
protected override void ConfigureConventions(ModelBuilder configurationBuilder) { configurationBuilder.Properties<string>() .HaveMaxLength(200); }这个全局动态约定能兜住那些漏配的字符串属性,但我仍然建议重要字段用配置类显式声明——约定给的是底线,配置类给的是确定性。
3. 查询优化的前置模型设计:主键、索引与唯一约束
3.1 主键用int自增、long自增还是Guid?
主键策略直接影响插入性能和分布式场景的取舍。我的建议分三种情况:
| 主键方案 | 适用场景 | 注意事项 |
|---|---|---|
| int/long自增 | 单库单表、写入量中等 | 迁移、合并数据麻烦,但索引紧凑,性能好 |
| Guid | 分布式、多库合并、离线创建 | 无序Guid会导致页分裂,建议用有序Guid |
| 雪花ID/有序Guid | 分布式高并发 | 需要额外生成器,插入性能略低但可控 |
订单系统的实际情况是单库,所以继续保留long自增,配合SQL Server的IDENTITY。如果当初定的是Guid,我一定会用NEWSEQUENTIALID()或应用层的有序Guid生成器,而不是Guid.NewGuid()。无序Guid在聚集索引上的页分裂问题,插入量一上来就能感受到。
3.2 复合索引的列顺序不是随便排的
最典型的索引优化场景就是订单列表页:按客户查、按状态过滤、按创建时间排序。查询条件大致是:
WHERE CustomerId = @customerId AND Status = @status ORDER BY CreatedAt DESC我最初的做法是在CustomerId和CreatedAt上分别建两个单列索引,结果查询优化器只能选其中一个,另一个条件回表过滤。优化后才明白,复合索引列顺序有讲究:等值条件的列放前面,排序/范围条件的列放后面。所以最终用了(CustomerId, CreatedAt)复合索引:
builder.HasIndex(x => new { x.CustomerId, x.CreatedAt }) .IsDescending(false, true);IsDescending(false, true)的意思是:CustomerId升序,CreatedAt降序。这样既能快速定位某客户的数据,又能直接按时间倒序返回,避免额外的排序操作。如果查询里还经常加Status做等值过滤,可以考虑(CustomerId, Status, CreatedAt)三列索引,但要注意列顺序必须和查询条件的等值顺序一致。
3.3 过滤索引和软删除的配对
软删除会带来一个隐蔽问题:表里堆积了大量IsDeleted = 1的历史数据,普通索引会把死数据也索引进去,索引越来越臃肿,查询性能慢慢恶化。SQL Server的过滤索引就是干这个的:
builder.HasIndex(x => x.CreatedAt) .HasFilter("[IsDeleted] = 0");这个过滤索引只索引未删除的数据,体积小、维护成本低,和后面讲的全局查询过滤器HasQueryFilter(x => !x.IsDeleted)是完美搭配。EF Core生成SQL时会把过滤条件和索引条件对齐,查询优化器更容易选中这个索引。
需要注意,HasFilter里的表名和列名是数据库层面的实际名称,所以要用[IsDeleted]这种中括号写法。如果列名配置成了别的风格,过滤条件也要跟着改。
3.4 唯一约束在软删除下的特殊处理
业务上订单号必须唯一,直接给OrderNo加唯一索引就行。但软删除场景下有个经典坑:一条记录被软删除后仍留在表里,如果订单号允许重新使用,就会和已删除的记录起冲突。这时需要把IsDeleted一起放进唯一索引:
builder.HasIndex(x => new { x.OrderNo, x.IsDeleted }) .IsUnique();这个唯一索引保证的是"同一订单号在相同删除标记下只能一条",软删除后旧记录的IsDeleted = 1,新插入的记录IsDeleted = 0,互不冲突。如果要求更严格——只有未删除的数据保证订单号唯一,那就用过滤唯一索引:
builder.HasIndex(x => x.OrderNo) .IsUnique() .HasFilter("[IsDeleted] = 0");两种方案各有取舍,我最终选了后者,因为它语义更直接:活动订单的订单号永不重复。
4. 列映射的精细化:类型、长度、精度,一个都不能少
4.1 字符串列别再无脑喂给nvarchar(max)
前面说过了,nvarchar(max)的三大问题:不能直接建索引、取出大对象白白耗内存、数据库无法做合理的统计信息估算。优化后的规则是每列都要有HasMaxLength。
HasMaxLength(200)生成出来是nvarchar(200)。如果一个字段理论上限是16位,就配HasMaxLength(16),不要图省事全写200。长度约束本身也是数据校验的一部分,太宽会掩盖上游问题。顺带一提,EF Core 默认对string可空性有一套约定:引用类型默认可空,但如果你启用了可空引用类型(NRT),EF Core 会遵循属性的可空性自动判断列是否NOT NULL。两种方式我更喜欢在配置类里显式写IsRequired(),因为NRT是编译期契约,数据库约束是运行期底线,二者不该互相替代。
4.2 decimal精度:金额、单价、税率分开定义
EF Core 对decimal的默认精度是decimal(18,2),这个默认值对绝大多数国内项目够用,但对两种场景不够:金额累加可能出现精度问题,税率和单价则可能设计上就需要超过两位小数。
我的习惯是:
- 金额累计字段(订单总额、账户余额):
HasPrecision(18, 2) - 单价字段:
HasPrecision(10, 2) - 税率、折扣率字段:
HasPrecision(5, 4)
builder.Property(x => x.TotalAmount).HasPrecision(18, 2); builder.Property(x => x.UnitPrice).HasPrecision(10, 2); builder.Property(x => x.TaxRate).HasPrecision(5, 4);这里有个容易忽略的点:如果数据库已经存在decimal(18,2)且数据有实际精度,你后来把模型改成decimal(18,4),迁移会尝试ALTER COLUMN,在数据量大的表上可能被锁表或者转换失败。所以精度配置要在一开始就定好,后期改动成本很高。
4.3 DateTime和DateTimeOffset,别再混为一谈
EF Core 默认把DateTime映射成 SQL Server 的datetime2,这个行为比datetime好得多,因为datetime2精度高(100纳秒)、范围大(0001-9999)。真正要注意的是DateTime和DateTimeOffset的选择。
- 只关心本地时间、不跨时区:用
DateTime,简单直接,排序也快。 - 需要记录"某一时刻"且可能跨时区展示:用
DateTimeOffset,它能保存时区偏移,换算不会丢信息。
订单创建时间我用的是DateTime,因为所有操作都在统一时区,展示也不需要按用户时区换算。但支付回调时间、第三方接口时间戳这类跨系统数据,用DateTimeOffset更稳。如果某个属性是DateTimeOffset,EF Core 会自动映射datetimeoffset,一般不用显式配置,但为了跨数据库一致性,我还是会写:
builder.Property(x => x.CallbackTime).HasColumnType("datetimeoffset");4.4 枚举字段:存int还是存string
枚举的默认存储是int,这没问题,查询、索引都比较高效。但有一个运维侧的需求:DBA 直接查数据库时,看到3不知道什么意思,看到'Paid'一目了然。
我一开始把订单Status转成了字符串存储:
builder.Property(x => x.Status) .HasConversion<string>() .HasMaxLength(20) .IsRequired();跑了大半年,发现这个决定是过度设计。理由有三:一是字符串枚举导致排序不再按枚举定义顺序;二是DBA真的查库,更喜欢联回枚举字典而不是硬编码字符串;三是如果未来枚举改名,字符串里的脏数据很难清理。后来我又改回了int存储,只在代码里维护枚举定义,并在数据库表注释里写清楚每个值代表什么。作为折中,如果确实需要可读性,可以用HasComment给列加注释,而不是改存储类型。
4.5 默认值、计算列和影子属性的妙用
数据库默认值能减少应用层的空值处理。创建时间这类字段,我既会在实体里赋初值,也会在数据库层面兜底:
builder.Property(x => x.CreatedAt) .HasDefaultValueSql("GETDATE()") .IsRequired();HasDefaultValueSql让数据库在插入时自动填充,应用层漏了也不会上出NULL。计算列HasComputedColumnSql我用的比较克制,只在类似"冗余统计值"的场景用,复杂计算交给数据库反而影响写入性能。
影子属性(Shadow Property)是模型优化里很容易被忽略的工具。比如有些审计字段实体类里不暴露,但需要插入时自动写入,可以这样配置:
builder.Property<DateTime>("CreatedAt") .HasDefaultValueSql("GETDATE()");实体类里没有CreatedAt属性,但EF Core在插入时会自动填入数据库默认值。需要读取时可以通过dbContext.Entry(order).Property("CreatedAt").CurrentValue访问。这种设计适合"数据库层面的字段,不想污染实体契约"的场景,但别滥用,影子属性和实体属性混在一起,时间久了也是一笔糊涂账。
5. 关系映射与外键管理:一对多、多对多、值对象
5.1 一对多关系里导航属性的两难
订单和客户是典型的一对多。最完整的关系配置是这样:
public class Customer { public long Id { get; set; } public string Name { get; set; } = string.Empty; public ICollection<Order> Orders { get; set; } = new HashSet<Order>(); } public class Order { public long Id { get; set; } public long CustomerId { get; set; } public Customer Customer { get; set; } = null!; }配置类里:
builder.HasOne(x => x.Customer) .WithMany(x => x.Orders) .HasForeignKey(x => x.CustomerId) .OnDelete(DeleteBehavior.Restrict);两个点要重点说明。第一,双向导航属性方便写Include(x => x.Orders),但也容易造成循环引用,尤其是做JSON序列化时必须配置引用处理。建议只在业务上确实需要反向遍历的地方保留集合导航,可有可无的就砍掉。第二,级联删除默认行为是:当关系为必需关系时,EF Core 默认Cascade。但SQL Server不允许同一条外键路径上存在多个级联删除路径,一旦表关系复杂,迁移会直接报错。我在订单表里把客户外键的OnDelete显式改成Restrict,业务也要求不能删有订单的客户,一拍即合。
5.2 多对多:用ET Existing的跳过导航还是显式中间实体
EF Core 5 以后的多对多配置省事很多,不需要手动建联接实体。以文章和标签为例:
public class Post { public ICollection<Tag> Tags { get; set; } = new HashSet<Tag>(); } public class Tag { public ICollection<Post> Posts { get; set; } = new HashSet<Post>(); }EF Core 会自动生成一张PostTag联接表。如果你想控制联接表名或结构,用UsingEntity配置:
builder.HasMany(x => x.Tags) .WithMany(x => x.Posts) .UsingEntity(j => j.ToTable("post_tags"));这里有一个朴素的取舍原则:如果多对多关系表只需要两个外键,就用隐式联接实体,EF Core 全帮你管;如果关系本身有额外属性(比如关注时间、排序权重),就必须显式定义中间实体。我自己在订单和促销活动的多对多上选择了显式中间实体,因为每个关联都带EffectiveDate、ExpiredDate,隐式方案根本承载不了这些字段。
5.3 Owned Entity:把值对象嵌进同一个表
地址这类只属于订单、没有独立生命周期的数据,用OwnsOne非常合适:
builder.OwnsOne(x => x.Address, a => { a.Property(p => p.Province).HasMaxLength(32); a.Property(p => p.City).HasMaxLength(32); a.Property(p => p.Detail).HasMaxLength(128); });这样Address不会单独建表,而是把Province、City、Detail列直接放在orders表里,映射规则是属性名拼接(Address_Province)。EF Core 的OwnsOne是真正在ORM层面实现了值对象概念:对象整体加载、整体保存,不会单独被其他表引用。
如果是子项集合,比如订单行项目,可以用OwnsMany,这会生成单独的子表,并且子表主键由EF自动管理。EF Core 7 之后还支持把值对象序列化成JSON列:
builder.OwnsOne(x => x.Address).ToJson();JSON列让结构更灵活,但代价是无法对里面字段做索引和高性能过滤。我在日志类场景用过,业务主表上没敢碰。
5.4 导航属性加载策略与查询形状
模型优化做到最后,会直接影响查询形状。EF Core 默认 Lazy Loading 是关闭的,这很正确。我用的是显式加载和Include,并且在高风险查询上加了AsSplitQuery():
var order = await context.Orders .Include(x => x.Items) .Include(x => x.Customer) .AsSplitQuery() .FirstOrDefaultAsync(x => x.Id == id);AsSplitQuery把原来一条大SQL拆成多条独立SQL,避免一对多 Join 数据行相乘导致的笛卡尔积膨胀。模型层面能配合的就是:导航属性别设成 LazyLoading 的 virtual,集合导航别搞太多嵌套。关系设计的越收敛,生成的SQL越干净。
6. 并发控制、软删除与全局过滤器的组合玩法
6.1 RowVersion并发令牌,防的就是脏更新
订单系统经常遇到两个操作员同时改同一单。没有并发控制时,后提交的人直接覆盖前一个人的修改。SQL Server 的 rowversion 类型是天然的并发令牌:
builder.Property<byte[]>("RowVersion") .IsRowVersion();配置之后,这张表的每一行都会有一个每次更新自动递增的版本号。EF Core 在SaveChanges时会把RowVersion放进WHERE条件,如果数据库里的值已经被别人改过,更新影响行数为0,EF Core 会抛出DbUpdateConcurrencyException。处理方式通常是重试或提示用户刷新:
catch (DbUpdateConcurrencyException ex) { var entry = ex.Entries.Single(); var databaseValues = await entry.GetDatabaseValuesAsync(); // 根据 databaseValues 决定是刷新后重试,还是直接报冲突 }这个字段用影子属性配置,实体类里完全不暴露,业务层不需要关心它,内部却实实在在起着并发保护的作用。注意 rowversion 只能存在于 SQL Server,其他数据库需要改用其他方式,比如IsConcurrencyToken配合自定义字段。
6.2 软删除与全局查询过滤器:查询层自动隐身
软删除字段配好之后,用HasQueryFilter让EF Core在所有查询上自动追加条件:
builder.HasQueryFilter(x => !x.IsDeleted);这个过滤器的强大之处在于:你用Include加载导航属性时,EF Core 也会自动对关联实体应用同样的过滤器。也就是说,一张订单的客户如果被软删除,Include出来的客户也会被自动过滤掉,不会出现"主实体正常、关联实体已经逻辑删了但查得出来"的错位。
设计时的坑是,如果实体有多个查询入口,比如直连DbSet<T>和通过导航属性访问,过滤器都会生效,这没问题。但如果你确实想查已删除数据,就要用IgnoreQueryFilters():
var deletedOrders = await context.Orders .IgnoreQueryFilters() .Where(x => x.IsDeleted) .ToListAsync();全局过滤器真正难受的地方是性能。它会让EF Core在每一条SQL上都追加WHERE [IsDeleted] = 0,如果表很大且缺少配套索引,这个附加条件不但没用,反而干扰优化器。这也是我在索引章节说"过滤索引和全局过滤器要配对"的原因——过滤器负责逻辑正确,过滤索引负责这种逻辑下的查询性能。
6.3 全局过滤器影响下的索引选择
实际调优过程中我发现一个现象:合成了HasQueryFilter之后,同样一条查询,EF Core生成的SQL先WHERE CustomerId = ... AND IsDeleted = 0,如果没有合适索引,优化器可能选一个不太匹配的索引回表。把索引改成复合索引并且带上IsDeleted列,比如(CustomerId, IsDeleted, CreatedAt DESC),才能完全贴合过滤器的形状。
当然,索引列越多,写入维护成本越高。我的取舍是:高频查询路径上的核心表(订单、支付流水)才这样设计,像配置表、日志表这种数据量不大或者查询模式不固定的,保持简单就好。模型优化永远要针对真实查询模式,不是为了漂亮把所有索引都加一遍。
7. 迁移实战:优化后的模型如何平滑落到数据库
7.1 迁移顺序:先本地清库,再走生产变更
模型和数据库同步靠迁移。本地开发最粗暴高效的做法是先删库再重新迁移,但生产环境不能这么干,必须走增量变更。
我重构时的做法是先清空所有历史迁移文件,删掉Migrations目录,重新生成一个干净的基线迁移:
dotnet ef migrations add InitModel dotnet ef database update这条命令会对比当前模型和空数据库,生成一张建库脚本。本地跑通后,再针对生产环境生成增量SQL脚本:
dotnet ef migrations script InitModel生成的SQL可以给DBA审阅后再执行。这个环节的教训是:不要手工改迁移文件。迁移文件本身就是模型和数据库的差值描述,手工改完之后下一次Add-Migration是基于模型快照做对比的,可能完全察觉不到你已经改了,轻则白改,重则生成错误的Drop/Create。
7.2 模型快照和改名操作,最容易翻车的地方
Migrations目录里有一个xxxModelSnapshot.cs,它是当前模型状态的快照,也是下一次Add-Migration的对比基准。很多人不知道它的存在,直到某次改名改得乱七八糟才意识到有这个东西。
举个例子:我想把实体名从OrderInfo改成Order,直接改完实体和配置类后执行Add-Migration RenameOrder,EF Core对比快照后发现"原来有个OrderInfo表,现在有个Order表",模型和快照对不上,会生成一个DropTable+CreateTable的组合,直接把数据全丢。正确做法是生成迁移后把DropTable改成RenameTable:
migrationBuilder.RenameTable( name: "OrderInfo", schema: "dbo", newName: "Order");列改名同理,要用RenameColumn而不是AlterColumn。想避免手工补救,最稳妥的方案是:先Add-Migration的时候看清生成的迁移内容,如果出现你想不到的Drop操作,先dotnet ef migrations remove撤销,调整模型后再重新生成。
7.3 预编译模型CompiledModel,启动加速的加分项
重构后半程,服务实例数量越来越多,每次冷启动都要扫描一遍所有实体的映射配置,启动时间变得有点长。EF Core 6 之后支持预编译模型,用一条命令把模型配置编译成现成的类:
dotnet ef dbcontext optimize --output-dir Compiled然后在OnConfiguring里替换:
optionsBuilder.UseModel(CompiledModels.MyDbContextModel.Instance);实测冷启动时间能缩短一半以上。但这个方案的代价是模型被固化了:以后改实体、改配置,必须重新执行optimize,否则运行时和编译模型不一致,查询结果就是旧的映射。
预编译模型还要求模型不能被动态修改,像运行时条件映射、动态建表这类玩法都会冲突。如果项目已经稳定、配置基本不再变动,这个优化很值;如果还在快速迭代阶段,我建议先缓一缓。
8. 重构完大半年后的复盘:哪些优化最值,哪些是过度设计
重构完到现在跑了快一年,回头看这个"优化后的模型",有些决策成了后续所有项目的基准,有些确实做过头了。
最值的是三件事:配置类拆分、索引显式建模、字符串列长度约束。配置类拆分让团队里任何一个人接手一个新实体,都能照着现有OrderConfiguration的模式写,不用翻上下文;索引显式建模让每次查慢SQL时,打开模型文件就能知道有没有对应索引,省掉大量和DBA来回确认的时间;字符串长度约束虽然润物细无声,但能挡住很多上游脏数据。
值但要有前提的是预编译模型和影子属性。预编译模型在服务实例多、冷启动敏感的架构下是利器,但代价是"配置不可动态变"这个约束得同事们都理解并遵守。影子属性适合审计字段,但我也见过有人把所有列全搞成影子属性,最终实体类瘦成皮包骨,查询代码里到处是黑魔法一样的Property("xxx"),维护成本直接爆炸。
过度设计最明显的是枚举转字符串。当时图DBA查库方便,后来发现枚举变更、排序规则、索引体积都受了影响,最后又改回int。这件事给我的教训是:模型优化不能为了"看起来顺眼"牺牲数据库本身的优势,int枚举在数据库层面就是比字符串高效,可读性的问题应该靠注释和文档去解决,不该靠改存储类型。
如果你也想做一轮模型重构,我给一个顺序建议:先梳理实体和配置类拆分,再逐一检查列类型和长度,然后根据真实慢查询补索引和唯一约束,最后再碰并发令牌、全局过滤器这些进阶配置。顺序反了容易越改越乱,顺序对了,每改一步都能明显感受到查询变快了、代码变顺了。这就是优化后的模型该有的样子。