无并发请求时EF Core抛出DbUpdateConcurrencyException的原因排查
我正在开发一个用Entity Framework Core管理考试尝试与答案的ASP.NET Core Web API。在Update方法中,我获取ExamAttempt实例,修改其关联的答案,再通过UnitOfWork.Save()保存更改,但一直碰到DbUpdateConcurrencyException异常,提示信息如下:
The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded.
我是唯一测试该接口的人,理论上不该存在并发请求或数据变更。
相关代码片段
public async Task<OperationResult<ExamAttemptReponseDto>> Update(Guid id, UpdateExamAttemptDto attemptDto, Guid studentId) { var includeProperties = "ExamAttemptAnswers,Exam"; var ExamAttempt = await UnitOfWork.ExamAttempt.Get(a => a.Id == id, includeProperties); if (ExamAttempt == null) { return OperationResult<ExamAttemptReponseDto>.Failure("Exam attempt not found"); } // 获取所有考试题目 var examQuestions = await UnitOfWork.ExamQuestion .GetAll("Question, Question.QuestionChoices", q => q.ExamId == ExamAttempt.ExamId); // 计算考试结束时间 var ExamEndDate = ExamAttempt.Exam.StartAt.AddMinutes(ExamAttempt.Exam.DurationInMinutes); var canModify = ((ExamAttempt.StudentId == studentId) && (ExamEndDate >= DateTime.Now) && ExamAttempt.SubmittedAt == null); if (!canModify) { return OperationResult<ExamAttemptReponseDto>.Failure("You no longer can modify this attempt"); } var answersToAdd = new List<ExamAttemptAnswer>(); foreach (var updatedAnswer in attemptDto.answers) { var associatedQuestion = examQuestions.ToList().Find(q => q.QuestionId == updatedAnswer.QuestionId); if (associatedQuestion == null) { return OperationResult<ExamAttemptReponseDto>.Failure("The question you're trying to access is not found in this exam"); } var questionHasAnswers = ExamAttempt.ExamAttemptAnswers.Any(a => a.QuestionId == associatedQuestion.Id); if (questionHasAnswers && associatedQuestion.Question is MultiChoiceQuestion) { return OperationResult<ExamAttemptReponseDto>.Failure("You can't add more answers to MCQ"); } var questionChoiceBelongsToQuestion = associatedQuestion.Question.QuestionChoices .Any(c => c.Id == updatedAnswer.AnswerId); if (!questionChoiceBelongsToQuestion) { return OperationResult<ExamAttemptReponseDto>.Failure("The answer you're trying to select doesn't belong to the question"); } var answerAlreadyExists = ExamAttempt.ExamAttemptAnswers .Any(a => a.QuestionId == associatedQuestion.Id && a.AnswerId == updatedAnswer.AnswerId); if (answerAlreadyExists) { continue; } answersToAdd.Add(new ExamAttemptAnswer() { Id = Guid.NewGuid(), ExamAttemptId = ExamAttempt.Id, AnswerId = updatedAnswer.AnswerId, LastModified = DateTime.Now, QuestionId = associatedQuestion.Id }); } ExamAttempt.ExamAttemptAnswers.AddRange(answersToAdd); UnitOfWork.ExamAttempt.Update(ExamAttempt); var result = await UnitOfWork.Save(); if (result < attemptDto.answers.Count) { return OperationResult<ExamAttemptReponseDto>.Failure("Can't update attempt answers"); } return OperationResult<ExamAttemptReponseDto>.Success(null); }
已尝试的排查步骤
- 确认无其他进程修改数据
- 检查已正确加载对应的
ExamAttempt及关联实体 - 验证无其他用户并发更新数据
可能的原因及解决办法
1. 多余的Update调用导致状态异常
你在添加完新的ExamAttemptAnswer后调用了UnitOfWork.ExamAttempt.Update(ExamAttempt),但ExamAttempt是从数据库加载的跟踪实体,EF Core会自动跟踪它的所有变更(包括关联集合的新增)。手动调用Update会强制将实体状态设为Modified,EF Core会尝试更新ExamAttempt主表的所有字段,如果此时主实体的并发校验字段(比如RowVersion)没有被正确加载或匹配,就会触发并发异常。
解决办法:删除UnitOfWork.ExamAttempt.Update(ExamAttempt);这一行,直接调用Save()即可。跟踪状态下的实体变更会被EF Core自动检测,无需手动调用Update。
2. 并发令牌配置或加载问题
如果ExamAttempt实体配置了并发令牌(比如带[Timestamp]或[ConcurrencyCheck]标记的字段),但加载实体时没有包含该字段,EF Core会认为数据已被修改。
解决办法:
- 检查
ExamAttempt实体的配置,确保并发令牌字段被包含在includeProperties中(比如如果字段名为RowVersion,则修改为"ExamAttemptAnswers,Exam,RowVersion")。 - 确认该字段在数据库中是正确生成的(比如SQL Server的
ROWVERSION类型)。
3. UnitOfWork的Save方法实现异常
检查UnitOfWork.Save()的代码,确认它只是正确调用DbContext.SaveChangesAsync(),没有额外修改实体状态或过滤变更的逻辑。如果Save方法内部有错误处理或状态重置,可能导致变更未被正确提交。
解决办法:
- 确保
Save方法的核心逻辑是return await _dbContext.SaveChangesAsync();,没有多余的操作。
4. ExamAttemptAnswer的外键或主键问题
虽然你用了Guid.NewGuid()生成ExamAttemptAnswer的主键,但如果数据库中该字段的主键策略不是手动赋值,或者ExamAttemptId与当前ExamAttempt.Id不匹配,可能导致新增失败,进而引发主实体的更新检测异常。
解决办法:
- 确认
ExamAttemptAnswer的主键配置允许手动赋值(比如Fluent API中使用HasKey(x => x.Id).ValueGeneratedNever())。 - 调试时检查
ExamAttempt.Id和新增ExamAttemptAnswer的ExamAttemptId是否完全一致。
内容的提问来源于stack exchange,提问作者Marko Sami

