Phần 8: Razor Pages với EF Core trong ASP.NET Core - Xử lý xung đột đồng thời (Concurrency)
Trang này là một phần trong chuỗi hướng dẫn tạo ứng dụng web Razor Pages sử dụng Entity Framework Core. Trang này trình bày cách xử lý xung đột đồng thời (concurrency conflicts) khi nhiều người dùng cập nhật cùng một entity cùng lúc.
Tổng quan
Xung đột đồng thời (concurrency conflicts) xảy ra khi:
- Người dùng A điều hướng đến trang chỉnh sửa của một entity
- Người dùng B cập nhật cùng entity đó trước khi A lưu thay đổi
Nếu không có cơ chế phát hiện đồng thời, người dùng lưu sau cùng sẽ ghi đè lên mọi thay đổi trước đó.
Các phương pháp xử lý đồng thời
Pessimistic Concurrency (Khóa dữ liệu)
- Dùng database locks để ngăn xung đột
- Phức tạp để triển khai
- Có thể gây vấn đề hiệu năng
- Không được tích hợp sẵn trong Entity Framework Core
Optimistic Concurrency (Đồng thời lạc quan)
Cho phép xung đột xảy ra, sau đó xử lý phù hợp. Có ba chiến lược:
- Theo dõi từng thuộc tính (Property-level tracking): Theo dõi thuộc tính nào thay đổi và chỉ cập nhật các cột đó
- Nhược điểm: Không thể ngăn xung đột trên cùng một thuộc tính
- Client Wins (Người dùng thắng): Bản cập nhật cuối cùng ghi đè mọi thay đổi trước đó
- Hành vi mặc định nếu không có xử lý đồng thời
- Store Wins (Cơ sở dữ liệu thắng): Giá trị trong database ưu tiên hơn giá trị từ client
- Được dùng trong hướng dẫn này
- Ngăn mất dữ liệu bằng cách thông báo cho người dùng về xung đột
Triển khai với Concurrency Tokens
Thêm thuộc tính theo dõi
Thêm thuộc tính ConcurrencyToken vào model:
Với SQL Server (Visual Studio):
[Timestamp]
public byte[] ConcurrencyToken { get; set; }Với SQLite/Non-SQL Server (Visual Studio Code):
public Guid ConcurrencyToken { get; set; } = Guid.NewGuid();Sau đó cấu hình trong DbContext.OnModelCreating():
modelBuilder.Entity<Department>()
.Property(d => d.ConcurrencyToken)
.IsConcurrencyToken();Cách hoạt động
- EF Core đưa giá trị concurrency token gốc vào mệnh đề
WHEREcủa câu lệnh UPDATE/DELETE - Nếu token không khớp với giá trị trong database, không có hàng nào bị ảnh hưởng
- EF Core ném ra ngoại lệ
DbUpdateConcurrencyException
Ví dụ SQL được tạo ra:
UPDATE [Departments] SET [Name] = @p0 WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2; SELECT [ConcurrencyToken] FROM [Departments] WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
Tạo Migration (Di chuyển schema)
Visual Studio:
Add-Migration RowVersion Update-Database
Visual Studio Code:
dotnet ef migrations add RowVersion dotnet ef database update
Xử lý xung đột trong trang Edit
Page Model cho Edit
Đặt giá trị concurrency token gốc trước khi cập nhật:
public async Task<IActionResult> OnPostAsync(int id)
{
// Lấy entity hiện tại từ DB
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Đặt giá trị token gốc để phát hiện xung đột
_context.Entry(departmentToUpdate)
.Property(d => d.ConcurrencyToken)
.OriginalValue = Department.ConcurrencyToken;
if (await TryUpdateModelAsync<Department>(
departmentToUpdate, "Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty,
"Unable to save. The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await SetDbErrorMessage(dbValues, clientValues, _context);
// Cập nhật token với giá trị database hiện tại
Department.ConcurrencyToken = dbValues.ConcurrencyToken;
ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
}
}
return Page();
}Razor Page Edit
Thêm concurrency token vào hidden field:
@page "{id:int}"
<form method="post">
<input type="hidden" asp-for="Department.DepartmentID" />
<input type="hidden" asp-for="Department.ConcurrencyToken" />
<!-- Các trường form khác -->
<input type="submit" value="Save" class="btn btn-primary" />
</form>Hiển thị thông báo lỗi
Khi phát hiện xung đột, hiển thị các giá trị hiện tại trong database:
private async Task SetDbErrorMessage(Department dbValues,
Department clientValues, SchoolContext context)
{
if (dbValues.Name != clientValues.Name)
{
ModelState.AddModelError("Department.Name",
$"Current value: {dbValues.Name}");
}
if (dbValues.Budget != clientValues.Budget)
{
ModelState.AddModelError("Department.Budget",
$"Current value: {dbValues.Budget:c}");
}
ModelState.AddModelError(string.Empty,
"The record you attempted to edit was modified by another user. " +
"The current values have been displayed. " +
"If you still want to edit, click Save again.");
}Xử lý xung đột trong trang Delete
Page Model cho Delete
public async Task<IActionResult> OnPostAsync(int id)
{
try
{
if (await _context.Departments.AnyAsync(m => m.DepartmentID == id))
{
_context.Departments.Remove(Department);
await _context.SaveChangesAsync();
}
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException)
{
return RedirectToPage("./Delete",
new { concurrencyError = true, id = id });
}
}Razor Page Delete
@page "{id:int}"
<form method="post">
<input type="hidden" asp-for="Department.DepartmentID" />
<input type="hidden" asp-for="Department.ConcurrencyToken" />
<input type="submit" value="Delete" class="btn btn-danger" />
</form>Kiểm tra đồng thời
- Mở hai tab trình duyệt để chỉnh sửa cùng một entity
- Thực hiện các thay đổi khác nhau ở mỗi tab
- Lưu ở tab đầu tiên (thành công)
- Lưu ở tab thứ hai (hiển thị lỗi đồng thời với các giá trị hiện tại trong database)
- Cập nhật các trường bị xung đột và lưu lại
Sự khác biệt giữa SQL Server và SQLite
| Khía cạnh | SQL Server | SQLite |
|---|---|---|
| Kiểu Token | byte[] | Guid |
| Attribute | [Timestamp] | Thủ công IsConcurrencyToken() |
| Tự động cập nhật | Tự động | Thủ công hoặc dùng trigger |
Các điểm quan trọng
- Thuộc tính
OriginalValueđảm bảo EF Core dùng giá trị từ lúc entity được tải - Model binder điền concurrency token từ hidden field
- Luôn cập nhật token hiển thị sau khi xảy ra xung đột để phát hiện các xung đột tiếp theo
- Xóa token khỏi
ModelStatesau khi cập nhật để tránh giá trị cũ - Phương pháp "Store Wins" ngăn mất dữ liệu âm thầm