深夜十一点半,我刚准备合上电脑,手机弹出一条线上告警:订单主表里出现了两条一模一样的订单号。第一反应是不可能——代码里明明做过唯一性判断。可打开数据库一查,两条记录静静地躺在那里,连创建时间都只差了0.3秒。这不是第一次了。后来我复盘了整个项目的Entity Framework Core数据链路,发现问题根本不在“有没有写判断”,而在于我从来没有认真对待过一个词:数据完整性。
Entity Framework Core(下面我直接叫EF Core)是.NET生态里绕不开的ORM框架,但很多人对它的认知还停留在“少写点SQL”这个层面。真正把EF Core用出价值,恰恰要看你在数据完整性上做了多少功课:外键关系怎么约束、并发时怎么防止互相覆盖、删除一条数据会不会连带误删、谁在什么时间改了什么数据能不能追溯。这篇文章我不打算讲教科书,就用我自己踩过的坑和补过的洞,把EF Core里保证数据完整性的完整实践拆给你看。适合正在用EF Core做业务系统、被数据错乱问题折腾过的开发者,也适合准备从0设计一个可维护数据模型的朋友参考。
1. 先想清楚:EF Core里的“数据完整性”到底指什么
很多人一提数据完整性,第一反应就是“数据库里的外键和主键约束”。这话没错,但放到EF Core这个语境下,它只是冰山一角。EF Core的数据完整性是一个贯穿“模型定义—迁移同步—事务提交—并发控制—删除策略—审计追溯”的工程链路。我习惯把完整性拆成四个层次来理解。
1.1 四类完整性约束与EF Core的映射
第一类是实体完整性,解决的是“每一行数据都能被唯一识别”的问题。对应到EF Core里就是主键设计,无论是默认的Id约定、Guid主键还是业务主键,都要保证在表内不重复。
第二类是引用完整性,解决的是“表与表之间的关系不能悬空”的问题。比如订单明细里的OrderId必须指向一个真实存在的订单,不能出现“孤儿数据”。EF Core里通过外键属性、导航属性和关系配置来实现。
第三类是域完整性,解决的是“字段值必须符合业务规则范围”的问题。比如价格不能为负数、手机号不能超过11位、状态字段不能填一个未知枚举值。EF Core里用数据注解或Fluent API,配合数据库列的精确类型来约束。
第四类是用户自定义完整性,也就是业务自己定义的规则。比如一个用户在同一时间段内不能重复参加同一个活动、一个SKU在同一个仓库里库存记录唯一,这类规则通常通过唯一索引、组合唯一约束甚至数据库触发器实现。
第四类完整性有几个典型的坑。
EF Core中配置一个组合唯一索引,很多人会这样写:
csharp复制modelBuilder.Entity<OrderItem>()
.HasIndex(x => new { x.OrderId, x.SkuId })
.IsUnique();
这个配置生成的SQL里确实有唯一索引,但只要你在应用层没有提前捕获DbUpdateException并做友好提示,用户就会看到一条“无法保存更改”的原始报错。更隐蔽的问题是,如果你只在应用层用“先查询再插入”的方式判断是否重复,在高并发场景下极大概率出现竞态问题——两个请求同时查到“不存在”,同时插入,然后数据库约束炸掉。我在文章后面会专门讲这条链路的正确姿势。
1.2 为什么不能只靠数据库约束,也不能只靠应用层校验
这是一个很容易走极端的点。刚入门的同学觉得“数据库有约束就够了,EF Core模型随便写写也没关系”,结果模型里定义的可空字段和数据库的非空字段不一致,等到执行迁移或者线上插入数据时才报错。另一端是过于依赖应用层校验,Everywhere写满if判断,但并发一高就漏。
我的原则很明确:EF Core模型约束负责“友好提示”,数据库约束负责“最终兜底”,迁移脚本负责“保持一致”。模型上标注必填、长度、精度,用户在UI层收到的是清晰的中文错误信息;数据库层则通过非空、外键、唯一索引、Check约束,确保任何绕过应用层的写入也无法破坏数据。这两者不是二选一,而是同一道防线的两道闸门。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从模型设计开始:把完整性写进实体定义里
数据完整性不是等表建好之后再补的,而是在你写第一个实体类的时候就已经决定了。我见过太多项目,实体类里全是string类型的属性,什么约束标注都没有,全靠数据库迁移时顺手改几列。结果就是模型和数据库各说各话,最后连EF Core自己都分不清哪个才是准的。
2.1 一套可以直接抄的属性约束规范:数据注解 vs Fluent API
EF Core里配置约束有两种主流方式:数据注解(Data Annotations)和Fluent API。数据注解写起来简单直观,直接贴在实体属性上,适合快速开发;Fluent API集中在OnModelCreating里统一管理,可读性更强,而且能配置数据注解搞不定的高级约束,比如组合唯一索引、级联删除行为、行版本并发令牌。
我的建议是:能用Fluent API就优先用Fluent API,尤其项目规模一大,所有的约束集中在同一个地方排查起来极其舒服。数据注解的典型写法是这样:
csharp复制public class Product
{
public int Id { get; set; }
[Required]
[MaxLength(50)]
public string Sku { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; }
[Column(TypeName = "decimal(18,2)")]
public decimal Price { get; set; }
}
Fluent API的等价写法是这样的:
csharp复制protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.Property(x => x.Sku)
.IsRequired()
.HasMaxLength(50);
entity.Property(x => x.Name)
.IsRequired()
.HasMaxLength(200);
entity.Property(x => x.Price)
.HasPrecision(18, 2);
entity.HasIndex(x => x.Sku)
.IsUnique();
});
}
这里有个细节很多人没注意:HasPrecision(18, 2)比HasColumnType("decimal(18,2)")更推荐。前者是EF Core 5.0之后引入的精准配置方式,生成的迁移SQL更规范,而且不绑定特定数据库的方言;后者写死在字符串里,将来要切换数据库(比如从SQL Server切到PostgreSQL)就得重新改一遍。
2.2 关系配置里最容易被忽视的级联行为
关系配置是数据完整性的重灾区。很多人写实体类时只关注“有没有导航属性”“外键字段对不对”,却完全忽略了DeleteBehavior的设置。EF Core默认的级联删除策略,会直接决定你删除一条父记录时,子表数据是被连带删除、外键置空还是直接报错。
举个例子,订单和订单明细的关系:
csharp复制public class Order
{
public int Id { get; set; }
public string OrderNo { get; set; }
public ICollection<OrderItem> Items { get; set; }
}
public class OrderItem
{
public int Id { get; set; }
public int OrderId { get; set; }
public Order Order { get; set; }
public string Sku { get; set; }
public int Quantity { get; set; }
}
OrderItem的OrderId是非空外键,属于“必填关系”。这种情况下EF Core的默认行为是Cascade,也就是删除订单时,所有明细直接被连带删除。这通常符合业务预期,但如果你配置错了关系可选性(比如把OrderId声明成了int?),EF Core就会默认采用ClientSetNull——在客户端把子实体的外键置为null,而数据库层的实际行为可能变成SetNull或者Restrict,于是你就会看到“删除主表成功,子表数据变成一堆悬浮记录”的诡异现象。
所以每次配置一对多关系时,我都会明确写清楚DeleteBehavior:
csharp复制modelBuilder.Entity<OrderItem>()
.HasOne(x => x.Order)
.WithMany(x => x.Items)
.HasForeignKey(x => x.OrderId)
.OnDelete(DeleteBehavior.Cascade);
如果业务要求“订单删除后明细必须保留”,就换成DeleteBehavior.Restrict或DeleteBehavior.SetNull,同时确保外键字段类型是int?。这种显式声明看上去是多写了几行,但能救命。
2.3 用迁移把模型约束“下沉”到数据库
模型定义好了,约束也写上了,接下来最重要的一步是把这些约束变成数据库里的真实结构。EF Core的迁移功能本质上是“模型快照对比器”:你每次修改模型,执行dotnet ef migrations add XXX时,EF Core会对比当前模型和上一次迁移快照,生成一个包含新增表、新增列、新增索引、外键变更的增量脚本。
很多人执行完dotnet ef database update就以为万事大吉,但我强烈建议你在更新数据库之前,先执行dotnet ef migrations script生成完整的SQL脚本,人工审查一遍。尤其是索引和外键部分,有时候EF Core生成的索引命名虽然规范,但字段顺序可能不符合你的查询场景;外键的ON DELETE行为也可能因为模型关系配置的细微差别,生成出你完全没预料到的SQL。
审查迁移还有一个隐藏价值:提前发现数据库约束和模型约束不一致的问题。比如你在模型属性上标记了IsRequired(),迁移脚本里就应该出现NOT NULL,如果没出现,说明模型配置没生效,得马上回去检查。
3. 事务与并发控制:防止脏数据写入的实战配置
模型约束管的是“单条数据的合法性”,但数据完整性还有一大半问题出在“多条操作之间的协调”。这里必须聊透两件事:事务边界和并发冲突。
3.1 你以为是单条SQL,其实是隐式事务:SaveChanges的默认行为
EF Core的SaveChanges方法不是像很多人想的那样“一条一条执行SQL”,而是一个隐式事务。当你有多个新增、修改、删除操作在一次SaveChanges里提交时,EF Core会把它们打包进同一个数据库事务:要么全成功,要么全失败。
这个默认行为很贴心,但它有一个问题:很多人不知道SaveChanges的隐式事务边界只覆盖“这几次操作”,如果你的业务逻辑里调用了多次SaveChanges,期间任何一次失败,前几次已经提交的数据是不会自动回滚的。
我遇到过一个典型场景:创建订单时要同时写订单主表、订单明细、扣减库存、记录操作日志,这四个操作被写成了四次SaveChanges,结果第四次日志写入失败,订单和库存已经提交了,整个数据状态直接乱套。正确写法是显式开启一个事务:
csharp复制await using var transaction = await db.Database.BeginTransactionAsync();
try
{
db.Orders.Add(order);
await db.SaveChangesAsync();
db.OrderItems.AddRange(orderItems);
await db.SaveChangesAsync();
await inventoryService.DecreaseAsync(sku, quantity);
await db.SaveChangesAsync();
await db.OperationLogs.AddAsync(log);
await db.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
注意一个细节:BeginTransactionAsync之后,外面任何一次SaveChangesAsync失败抛异常,你都要在catch里显式Rollback。不要依赖using块自动回滚,因为事务对象释放时并不会自动回滚,除非你明确调用了Rollback或者显式Dispose——养成在catch里写的习惯,代码逻辑更清晰。
3.2 并发令牌:让“最后写入获胜”不再发生
事务保证了操作要么全做要么全不做,但并发场景下还有一个更隐蔽的问题:两个人同时修改同一条记录,后提交的人直接覆盖先提交的人的数据。这在EF Core里是默认行为,叫“最后写入获胜”(Last Write Wins)。
解决这个问题需要并发令牌(Concurrency Token)。核心思路是:数据库中有一个字段,每次记录更新时自动变化(比如自增版本号或者数据库行版本),EF Core在更新时会把“我读取时的版本号”放到WHERE条件里,如果提交时发现版本号已经变了,说明这条记录在读取后被别人改过,EF Core会抛出DbUpdateConcurrencyException。
SQL Server下最省事的做法是用rowversion:
csharp复制public class Order
{
public int Id { get; set; }
public string OrderNo { get; set; }
public decimal TotalAmount { get; set; }
public byte[] RowVersion { get; set; }
}
在Fluent API里配置:
csharp复制modelBuilder.Entity<Order>()
.Property(x => x.RowVersion)
.IsRowVersion();
如果是PostgreSQL,可以用xmin系统列,或者自己维护一个Version字段。配置好之后,捕获冲突并处理:
csharp复制try
{
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
var databaseValues = await entry.GetDatabaseValuesAsync();
if (databaseValues == null)
{
// 行已被其他人删除
}
else
{
// 读取数据库当前值,合并或者提示用户重新编辑
var dbOrder = (Order)databaseValues.ToObject();
// 具体策略:refetch后重试,或者直接返回冲突提示
}
}
}
这里有一个很重要的实战心得:并发冲突处理不是“自动重试就完事”。 无脑重试可能把用户的新输入覆盖掉,也可能在冲突频繁的场景里造成死循环。我通常的做法是:如果是关键字段(比如金额、状态)冲突,直接返回409给前端,提示用户“数据已被其他人修改”,同时把数据库的最新值带回前端做对比;如果只是非关键字段冲突,才考虑合并更新。
3.3 隔离级别:什么时候需要升级事务隔离级别
事务的隔离级别决定了“这个事务能看到别人提交的什么数据”。EF Core默认使用的是数据库的默认隔离级别,SQL Server是读已提交(Read Committed),这个级别下最经典的问题是不可重复读:同一个事务里两次读取同一条记录,可能读到不一样的值。
什么时候需要升级隔离级别?典型场景是统计类和报表类:你希望在事务执行过程中,所有数据都保持“某个时间点的一致性快照”。SQL Server里可以用快照隔离(Snapshot),PostgreSQL里可以用可重复读(Repeatable Read)。
在EF Core里设置隔离级别很简单:
csharp复制await using var transaction = await db.Database
.BeginTransactionAsync(IsolationLevel.RepeatableRead);
但这里我要提个醒:隔离级别不是越高越好。RepeatableRead和Serializable会加大锁的粒度,高并发下容易造成锁等待和死锁。我自己在订单系统里很少主动升级隔离级别,只在跑对账、生成报表这种低并发、强一致场景才用。日常业务写入,保持默认的读已提交,配合并发令牌和唯一索引,已经能覆盖绝大多数完整性需求。
还有一个容易忽略的参数是Command Timeout。大型批处理或者报表查询很容易超过默认的30秒,SQL Server会直接杀连接,导致事务失败。可以在DbContext的配置里设置:
csharp复制optionsBuilder.UseSqlServer(
connectionString,
sqlOptions => sqlOptions.CommandTimeout(120));
但也要设置合理的上限,超时机制本身是一种保护,盲目调到10分钟只会让系统在慢查询时更迟钝。
4. 删除与归档:如何避免“删一条数据塌一片关系”
删除操作对数据完整性的威胁,比插入和更新更严重。插入最多失败重来,更新最多覆盖旧值,但删除一旦关联处理错误,可能是整片数据物理消失,连恢复的机会都没有。这一章我讲两个点:级联删除的安全边界,以及用软删除替代物理删除的工程实践。
4.1 级联删除的默认行为并不安全,先看关系是否可空
前面提过,EF Core对必填关系的默认行为是Cascade,即可空关系的默认行为是ClientSetNull。很多开发者在建模时只关心查询的便利性,忽略了外键可空性带来的连锁反应。
举个例子,假设你的业务模型里,Order和Customer是“多对一”关系,Order.CustomerId是可空的int?。有一天运营误操作删除了一个客户,你会惊讶地发现:订单记录并没有被删除,但所有订单的CustomerId都变成了NULL,而且EF Core在内存里也会把导航属性Customer置为null。如果后续查询没做空值保护,可能直接导致空引用异常;如果报表里用CustomerId关联客户,这些订单全部变成了“无主”数据。
所以你设计关系时,一定要想清楚两个问题:这个外键在业务上是否必须存在?如果关联的父记录被删除,子记录应该是“跟着删”“置空”还是“拒绝删除”?
| 业务场景 | 推荐DeleteBehavior | 外键可空性 |
|---|---|---|
| 订单明细随订单删除 | Cascade | 必填 |
| 操作日志保留,不随主记录删除 | Restrict | 必填(或改为可空) |
| 客户删除后订单保留,但客户信息清空 | SetNull | 可空 |
| 需要人工确认,禁止直接删除父记录 | Restrict | 必填 |
我强烈推荐在Order/OrderItem这种“强归属关系”里用Cascade,在“弱关联关系”里用Restrict而不是SetNull。因为SetNull会悄悄改变业务数据的语义,排查问题时非常头痛,宁可让删除操作直接报错,也不要把错误静默写入数据库。
4.2 用软删除+全局查询过滤器实现可回滚的删除
物理删数据这件事,对完整性的威胁不仅在于“关联数据”,还在于“无法追溯”。所以越来越多系统选择软删除:删除操作实际上只是给记录打个标记,查询时自动过滤掉。
EF Core实现软删除非常顺手,核心有两点:实体加IsDeleted字段,配置全局查询过滤器。
csharp复制public class Order
{
public int Id { get; set; }
public string OrderNo { get; set; }
public bool IsDeleted { get; set; }
}
modelBuilder.Entity<Order>()
.HasQueryFilter(x => !x.IsDeleted);
配置了HasQueryFilter之后,EF Core会在所有自动生成的查询SQL里自动追加WHERE IsDeleted = 0,你不用在每个查询方法里手动加条件。但要小心一个问题:Include子查询也会被过滤。比如你查询一个未删除的客户,Include他的订单列表,EF Core会同时过滤订单的IsDeleted。大多数情况下这是期望行为,但如果你需要查询“包含已删除订单的客户”,就得用IgnoreQueryFilters(),这个API要谨慎使用,用的时候最好注释说明原因。
软删除还有一个隐藏操作需要处理:把SaveChanges里的Delete行为替换为“标记删除”。做法是重写DbContext的SaveChanges:
csharp复制public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
foreach (var entry in ChangeTracker.Entries<ISoftDelete>())
{
if (entry.State == EntityState.Deleted)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
}
}
return await base.SaveChangesAsync(cancellationToken);
}
软删除看起来麻烦一点,但收益很大:误删可以恢复,审计可以追踪,而且业务数据分析时也能通过“已删除标记”识别异常操作。如果你的系统对数据可靠性要求高,我建议直接全表软删除起步。
5. 审计字段与数据可追溯性:给完整性加上“最后一道保险”
约束和事务保证了数据“写进去的时候是好的”,但数据完整性还有一个常常被忽略的维度:可追溯性。出了问题之后,你能不能快速回答“这条数据是谁在什么时间创建的?最后一次修改是什么时候?当时改了什么?”如果回答不了,那你的系统离“完整性”其实还有一段距离。
5.1 谁在什么时候改了什么:通用审计字段该包含哪些内容
我做系统时有个习惯:几乎每张业务表都带上一组审计字段。最低配是三件套:CreatedAt(创建时间)、UpdatedAt(最后修改时间)、CreatedBy(创建人)。如果系统足够敏感,再加UpdatedBy(最后修改人)和RowVersion(并发版本)。
有人可能会问,这些字段直接用数据库的GETDATE()或者默认值不就行了吗?问题在于CreatedBy这类业务字段数据库不知道是谁,所以必须在应用层填充。如果你不想在每个业务方法里手动赋值,最好的办法是重写DbContext的SaveChangesAsync,在提交前统一处理。
csharp复制public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var entries = ChangeTracker.Entries()
.Where(e => e.State == EntityState.Added
|| e.State == EntityState.Modified);
foreach (var entry in entries)
{
if (entry.Entity is IAuditable auditable)
{
var now = DateTimeOffset.UtcNow;
if (entry.State == EntityState.Added)
{
auditable.CreatedAt = now;
auditable.CreatedBy = _currentUser?.UserId;
}
auditable.UpdatedAt = now;
auditable.UpdatedBy = _currentUser?.UserId;
}
}
return await base.SaveChangesAsync(cancellationToken);
}
这里有个常见的坑:如果你在SaveChanges里直接通过构造函数注入IHttpContextAccessor获取当前用户,EF Core的DbContext生命周期如果大于一个请求(比如注册成了单例),就可能拿到过期用户信息,甚至线程安全问题。所以DbContext一定要注册成Scoped(默认就是这个),每次请求一个实例,然后通过属性注入或者方法参数传入当前用户标识,不要直接在DbContext里解析HTTP上下文。
5.2 只记字段值还不够:变更日志怎么做
审计字段告诉你“改完了”,但没告诉你“改之前是什么”。对于敏感数据(订单金额、用户状态、权限配置),最好再配合变更日志,记录字段的旧值和新值。
实现思路也不复杂,在SaveChanges里遍历ChangeTracker.Entries(),对处于Modified状态的实体,用Properties集合逐个比对IsModified标记:
csharp复制foreach (var entry in ChangeTracker.Entries())
{
if (entry.State == EntityState.Modified)
{
foreach (var prop in entry.Properties)
{
if (prop.IsModified
&& !Equals(prop.OriginalValue, prop.CurrentValue))
{
// 记录实体名、属性名、原始值、新值、操作人、操作时间
}
}
}
}
这种方式的开销不小,全表全字段记录会影响性能,所以我建议只对核心业务实体启用。可以在实体上定义接口标记,比如IChangeTracked,只对实现了该接口的实体做值对比。
回忆当初我踩过的一个坑:最开始我把变更日志也放在同一个事务里保存,导致每次修改业务数据都要额外写一次日志表。看起来问题不大,但高并发场景下日志表很快成了写入瓶颈。后来我把日志改成异步批量写(先落内存队列,再批量刷库),这样既不影响主链路的延迟,也不会因为日志表故障导致业务失败。如果你刚起步,至少也要把日志操作放在事务外,做成可失败的旁路动作。
6. 常见问题与排查技巧实录
数据完整性这个话题,到这儿已经把“预防”讲得差不多了,但实践中最耗时间的其实还是“排查”。我把自己在真实项目里遇到过的、以及身边同事问过我最多的问题整理成了一张速查表,再补充几个我常用的排查套路。
6.1 一屏看全:六个经典数据完整性坑
6.1 六个高频事故的成因、表现与解法
| 问题现象 | 根本原因 | 推荐解法 |
|---|---|---|
| 删除父记录后,子表数据外键全部变成NULL | 外键被配置成可空,EF Core默认ClientSetNull | 明确关系的必填属性,显式声明DeleteBehavior |
| 同一业务编号出现重复记录 | 应用层“先查后插”,并发窗口期未加锁 | 数据库加唯一索引或组合唯一索引,兜底防重 |
| 两个用户同时编辑一条记录,后保存的覆盖先保存的 | 无并发令牌,EF Core默认最后写入获胜 | 配置rowversion或Version字段,捕获DbUpdateConcurrencyException |
| 迁移脚本生成的约束和模型配置不一致 | 修改模型后漏审迁移SQL,索引/外键配置被自动跳过 | 每次review迁移脚本,CI中加入迁移检查 |
| 事务中某一步失败,前面已提交的数据没有回滚 | 多次调用SaveChanges,但未使用显式事务 | 用BeginTransaction + Commit/ Rollback包裹完整业务操作 |
| 统计查询里软删除数据被算进去 | 查询绕过EF Core(直接SQL)或忘了HasQueryFilter | 统一通过DbSet访问数据,必要时用IgnoreQueryFilters并写明原因 |
6.2 排查技巧:如何快速定位约束失败和并发冲突
面对一堆“保存失败”的报错,很多人第一反应是狂点调试、打断点,但效率很低。我的习惯是先打开EF Core的详细日志,再对异常做分层定位。
开发环境里,强烈建议开启EnableSensitiveDataLogging,这样日志里能直接看到SQL参数值,省去手动猜数据的痛苦:
csharp复制optionsBuilder.LogTo(
Console.WriteLine,
LogLevel.Information)
.EnableSensitiveDataLogging()
.EnableDetailedErrors();
注意这两个API在生产环境不要开,会泄漏敏感数据。我一般用配置项控制,只允许Development环境开启。
定位约束失败时,第一步是抓DbUpdateException的InnerException。EF Core的映射层会把数据库原始错误包装一层,但数据库的详细信息(比如“违反UNIQUE KEY约束”“外键冲突”)永远是排查的锚点:
csharp复制catch (DbUpdateException ex) when (ex.InnerException != null)
{
var sqlException = ex.InnerException;
// 根据错误号判断:2601/2627唯一索引冲突,547外键约束冲突等
}
SQL Server这边,2627是唯一索引冲突,547是外键约束冲突,547还包括删除被引用数据时的阻止错误。PostgreSQL的错误码不一样,23505是唯一约束冲突,23503是外键约束冲突。如果你能把这些错误码映射成业务提示,用户看到的不再是冰冷的异常堆栈,而是一句“该名称已被使用”或“该客户存在关联单据,无法删除”。
6.3 从根源上降低排查成本:把约束评审写进工作流
排查技巧再多,都不如从源头少犯错。我在团队里定了一条规矩:迁移脚本必须走代码评审。每次有同事提交migrations文件夹下的新文件,相关负责人要重点确认三点:
第一,新增的索引是否和业务查询匹配;第二,外键的ON DELETE行为是否符合业务预期;第三,是否有应加而未加的唯一约束。
如果团队使用了GitLab CI/CD或者其他CI平台,还可以加一道自动化检查。比如在流水线里通过dotnet ef migrations script对比当前模型和上一次发布的迁移脚本,如果发现数据库模型漂移(有人改模型没生成迁移)就直接失败。这里顺带提一句,如果你的发布流程里已经有Docker镜像构建和自动化部署,迁移脚本的生成可以是其中一个独立Job;但更关键的是,这个Job必须使用“发布版ConnectionString指向的数据库类型”,比如你在CI里连的是PostgreSQL,迁移脚本就要按PostgreSQL方言生成,别从SQL Server的开发环境带过来。
结尾
写了这么多,回到文章开头那个凌晨的重复订单。那次事故之后,我再也没有把“数据完整性”简化为“加个唯一索引”或者“在Service层写个判断”。我给自己定了一条很简单的验收标准:任何一个写操作,都要能同时回答四个问题——约束在模型层和数据库层是否双重存在?事务边界是否覆盖了所有关联写入?并发冲突是否有明确的处理策略?这条数据如果出问题,能不能追溯是谁在什么时候改的? 这套标准不一定能杜绝所有线上问题,但至少能把“数据悄悄变脏”的概率压到最低。如果你也正在用EF Core做业务系统,我建议从今天开始,打开你的项目,从实体类和外键关系入手,把这条链路完整过一遍。你花在这个排查上的时间,大概率比以后补数据、对账、写说明文档的时间少得多。
