1. 为什么默认字段的索引总是被漏掉
在 .Net 项目里用 EFCore Code First 做数据层,最容易被忽略的不是主键、不是外键,而是那些「每个实体都有、但没人专门管」的默认字段。典型代表就是CreatedAt、UpdatedAt、IsDeleted、TenantId这几个。它们通常来自一个BaseEntity基类,业务代码里到处都在用,可一到建索引这一步,大家往往只记得给Name、Email这种显眼字段加,默认字段就集体裸奔了。
后果在数据量小的时候完全看不出来,等到单表几十万行、查询里又带着WHERE IsDeleted = 0 AND CreatedAt > @start这种条件时,全表扫描的代价就上来了。更麻烦的是,这类字段的索引需求是「批量」的——十几个实体都要加,你不可能一个个手写HasIndex,写漏一个就是隐患。
这篇就聚焦这个落地问题:在 EFCore Code First 模式下,怎么用一套可复用的配置骨架,给所有实体的默认字段批量补上索引,并且用迁移命令验证它真的生效了。同时我会把 TaoToken 的统一 Key/API 通道配置片段一起放进来,因为很多团队在本地调试模型、跑迁移脚本、做代码生成时,Key 管理是另一件烦人事,顺手统一掉能省不少事。
适合谁看:正在用 .Net 6/7/8 + EFCore 做 Code First 开发,实体有公共基类,想一次性把默认字段索引规范落地的同学。下面所有代码都可以直接复制进项目改。
2. TaoToken 前置:统一 Key 与 API 通道
在动手写索引配置之前,先把工具链的入口理顺。TaoToken 在这里的角色是统一模型调用与 API 通道,你可以在官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 了解整体能力,API 入口是 https://taotoken.net/api(这个地址不加 UTM)。
为什么索引配置这件事要提它?因为实际开发里,你经常需要让模型帮你生成实体、审查迁移脚本、或者解释某条 SQL 的执行计划。如果每个项目、每台机器都各配一套 Key,切换环境时很容易乱。把 Key 收敛到一份settings.json里,配合统一的 API 通道,本地调试和 CI 都能复用。
先拿到 Key:进入控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,在 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 创建一个。创建后复制出来,注意它只完整显示一次。
然后在项目根目录或用户配置目录放一份settings.json,片段如下:
{ "TaoToken": { "BaseUrl": "https://taotoken.net/api", "ApiKey": "sk-你的Key粘贴在这里", "DefaultModel": "claude-sonnet", "TimeoutSeconds": 60 } }读取时用 .Net 的配置系统绑定即可,比如在Program.cs里:
var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddJsonFile("settings.json", optional: true, reloadOnChange: true); var taoToken = builder.Configuration.GetSection("TaoToken"); var baseUrl = taoToken["BaseUrl"]; var apiKey = taoToken["ApiKey"];这里有个坑要提前说:settings.json千万别提交到 Git。把它加进.gitignore,团队里每人本地放一份,或者用环境变量覆盖ApiKey。我见过有人把 Key 直接写进appsettings.json推上仓库,后面只能紧急轮换。
配置好之后,你可以先用模型对话页面 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 做一次连通性验证,确认 Key 和通道都正常,再回到索引配置的正题。
3. 可复制的 DbContext 配置骨架
现在进入核心部分。目标很明确:在OnModelCreating里,对所有继承自公共基类的实体,自动给默认字段加索引,同时保留手动配置复合索引的能力。
先定义基类,把默认字段集中起来:
public abstract class BaseEntity { public DateTime CreatedAt { get; set; } public DateTime? UpdatedAt { get; set; } public bool IsDeleted { get; set; } public long TenantId { get; set; } }然后是DbContext的骨架。关键思路是:遍历所有实体类型,判断它是否继承自BaseEntity,如果是,就按字段名找到对应属性并调用HasIndex。这样新增实体时不用改配置,自动生效。
public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); // 默认字段索引:批量应用到所有 BaseEntity 子类 foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { if (!typeof(BaseEntity).IsAssignableFrom(entityType.ClrType)) continue; var builder = modelBuilder.Entity(entityType.ClrType); // CreatedAt 单列索引,常用于时间范围查询 builder.HasIndex(nameof(BaseEntity.CreatedAt)) .HasDatabaseName($"IX_{entityType.ClrType.Name}_CreatedAt"); // IsDeleted 单列索引,配合软删除过滤 builder.HasIndex(nameof(BaseEntity.IsDeleted)) .HasDatabaseName($"IX_{entityType.ClrType.Name}_IsDeleted"); // TenantId + IsDeleted 复合索引,多租户场景高频 builder.HasIndex(nameof(BaseEntity.TenantId), nameof(BaseEntity.IsDeleted)) .HasDatabaseName($"IX_{entityType.ClrType.Name}_Tenant_Deleted"); } } }这里用HasIndex(string)而不是 lambda,是因为遍历时拿不到强类型表达式,用属性名字符串更直接。HasDatabaseName统一命名规则,避免 EFCore 自动生成的名字在不同版本间漂移,迁移脚本对比时更干净。
如果你只想给部分实体加,可以加一个标记接口做白名单:
public interface IIndexedEntity { } public class Order : BaseEntity, IIndexedEntity { }然后把判断条件改成typeof(IIndexedEntity).IsAssignableFrom(entityType.ClrType)。这样默认字段索引只落在你明确标记的实体上,避免给日志表这种写入密集、查询很少的表加无用索引。
复合索引的排序和筛选条件也可以在这个骨架里扩展。比如软删除场景下,你希望索引只覆盖未删除数据:
builder.HasIndex(nameof(BaseEntity.TenantId), nameof(BaseEntity.CreatedAt)) .HasDatabaseName($"IX_{entityType.ClrType.Name}_Tenant_CreatedAt") .HasFilter("[IsDeleted] = 0");注意HasFilter里的写法是 SQL Server 方言,如果你用 PostgreSQL,要改成"IsDeleted = false"。这个差异在跨数据库项目里必须留意,否则迁移会报错。
4. 迁移命令与验证动作
配置写完,必须通过迁移落到数据库,并且验证索引真的建出来了。三步走。
第一步,生成迁移:
dotnet ef migrations add AddDefaultFieldIndexes如果你项目里DbContext不在启动项目,需要指定:
dotnet ef migrations add AddDefaultFieldIndexes \ --project src/Infrastructure \ --startup-project src/Api第二步,检查生成的迁移文件。打开Migrations/xxxx_AddDefaultFieldIndexes.cs,你应该能看到类似这样的CreateIndex调用:
migrationBuilder.CreateIndex( name: "IX_Order_CreatedAt", table: "Orders", column: "CreatedAt"); migrationBuilder.CreateIndex( name: "IX_Order_Tenant_Deleted", table: "Orders", columns: new[] { "TenantId", "IsDeleted" });如果这里没有你预期的索引,说明OnModelCreating里的判断条件没命中,回去检查实体是否真的继承自BaseEntity,以及GetEntityTypes()是否包含了它。
第三步,应用迁移并验证:
dotnet ef database update然后直接查数据库的索引视图确认。SQL Server 下:
SELECT i.name AS IndexName, t.name AS TableName FROM sys.indexes i JOIN sys.tables t ON i.object_id = t.object_id WHERE i.name LIKE 'IX_%_CreatedAt' OR i.name LIKE 'IX_%_Tenant_Deleted' ORDER BY t.name;PostgreSQL 下:
SELECT indexname, tablename FROM pg_indexes WHERE indexname LIKE 'IX_%_CreatedAt' ORDER BY tablename;能查到记录,就说明批量索引生效了。这一步别省,我踩过的坑就是迁移文件生成了但database update时因为筛选条件方言不匹配静默失败,只看代码以为成了,实际库里啥都没有。
验证完索引,顺手用 TaoToken 的模型对话通道 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 把生成的迁移脚本贴进去,让它帮你审一遍有没有遗漏的实体或命名冲突,比人眼扫快得多。
5. 本篇常见错排查
报错一:The property 'CreatedAt' cannot be added to entity type 'X' because it is not defined
原因是你遍历到的实体没有CreatedAt属性,但判断条件却让它进来了。检查typeof(BaseEntity).IsAssignableFrom是否写反——正确写法是基类在前、实体类型在后。写反了会匹配到一堆无关类型。
报错二:迁移生成成功,但database update报Incorrect syntax near 'IsDeleted'
这是HasFilter方言问题。SQL Server 用[IsDeleted] = 0,PostgreSQL 用"IsDeleted" = false,MySQL 8 用`IsDeleted` = 0。如果你的项目要跨库,建议把筛选条件抽成配置项,按 Provider 分支:
var isSqlServer = Database.IsSqlServer(); var filter = isSqlServer ? "[IsDeleted] = 0" : "\"IsDeleted\" = false";报错三:索引名重复冲突
如果你手动给某个实体写过HasIndex,批量逻辑又给它加了一遍,迁移会报重复索引名。解决办法是批量逻辑里先判断该索引是否已存在,或者统一约定:默认字段索引只走批量逻辑,业务字段索引才手写。命名前缀区分开,IX_给批量,UX_给唯一索引,一眼能分清来源。
报错四:settings.json读取不到,Key 为 null
检查AddJsonFile的路径和CopyToOutputDirectory。如果settings.json放在项目根但没设置复制到输出目录,运行时读的是bin下的路径,自然找不到。在.csproj里加:
<ItemGroup> <None Update="settings.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup>报错五:迁移文件里索引顺序和预期不一致
HasIndex的字段顺序决定复合索引的列顺序,而列顺序直接影响查询能否命中。TenantId, IsDeleted和IsDeleted, TenantId是两个不同的索引。多租户查询通常是WHERE TenantId = @t AND IsDeleted = 0,所以TenantId放前面。这个顺序错了,索引可能完全用不上。
6. 把配置沉淀成团队规范
索引配置这件事,写一次不难,难的是让团队每个人都记得写、写对。我的做法是把上面这套骨架放进项目的Infrastructure层,作为BaseDbContext的默认行为,新项目直接继承。同时配一份简短的 README,说明三件事:默认字段索引自动加、业务字段索引手写、迁移后必须查索引视图验证。
长期做编码和 Agent 协作的话,可以考虑用 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 把模型调用额度统一管理,配合接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 里的说明,把代码审查、迁移脚本生成这些环节串起来。Claude Code 相关的接入可以参考 https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claudecode&utm_campaign=rewrite ,把索引规范的检查做成一个可复用的提示词模板。
最后留一个实用技巧:在 CI 里加一步,迁移生成后自动 grep 迁移文件里CreateIndex的数量,和实体数量做对比,数量对不上就 fail。这样默认字段索引漏加的问题在合并前就能拦住,不用等到线上慢查询报警。