Hai request cùng đọc một bản ghi, cùng sửa và cùng báo thành công không có nghĩa dữ liệu vẫn đúng. Nếu lần ghi sau âm thầm đè lên thay đổi của lần ghi trước, hệ thống đã gặp lost update: người dùng A cập nhật địa chỉ, người dùng B cập nhật hạn mức từ một bản sao cũ, và một trong hai thay đổi biến mất mà không có lỗi. Sự cố này thường không xuất hiện khi test thủ công, nhưng xảy ra tự nhiên khi nhiều tab, mobile app, worker và webhook cùng tác động lên một tài nguyên.
Bài viết trình bày một thiết kế thực dụng để kiểm soát cập nhật đồng thời trong backend: nhận diện invariant cần bảo vệ, dùng cột version và câu lệnh cập nhật có điều kiện, ánh xạ phiên bản sang ETag/If-Match ở HTTP, trả conflict có thể xử lý, phân biệt optimistic locking với row lock, và kiểm thử race condition một cách xác định. Mục tiêu không phải khóa mọi thứ, mà là không để một thay đổi hợp lệ bị mất trong im lặng.
1. Lost update hình thành như thế nào?
Giả sử hồ sơ khách hàng có email, phone và version = 8. Hai client đọc cùng trạng thái. Client A đổi email rồi lưu. Client B vẫn giữ bản sao cũ, đổi số điện thoại và gửi toàn bộ object lên. Nếu backend chỉ chạy UPDATE customers SET email = ?, phone = ? WHERE id = ?, request B có thể ghi lại email cũ và xóa thay đổi vừa thành công của A.
- A và B cùng đọc phiên bản 8.
- A cập nhật dựa trên phiên bản 8; database lưu phiên bản 9.
- B cập nhật từ snapshot phiên bản 8 nhưng không khai báo điều kiện phiên bản.
- Database chấp nhận lần ghi B; một phần dữ liệu của A bị mất.
Transaction đơn thuần không tự ngăn tình huống này. Mỗi request có thể chạy trong một transaction hợp lệ, nhưng quyết định của request B được tạo từ dữ liệu đã lỗi thời trước khi transaction B ghi. Điều cần bảo vệ là quan hệ “tôi chỉ cập nhật nếu trạng thái tôi đã đọc vẫn là trạng thái hiện tại”.
Concurrency control tốt biến một lần ghi dựa trên dữ liệu cũ thành conflict nhìn thấy được, thay vì một thành công giả làm mất dữ liệu.
2. Bắt đầu từ invariant, không bắt đầu từ loại khóa
Trước khi chọn optimistic hay pessimistic locking, hãy viết rõ invariant. Hồ sơ có thể cho phép hai trường độc lập được sửa song song; tồn kho phải không âm; trạng thái đơn hàng chỉ được đi theo transition hợp lệ; số dư phải phản ánh đúng mọi bút toán. Mỗi invariant dẫn đến phạm vi đồng thời khác nhau.
| Tình huống | Invariant | Cơ chế phù hợp thường gặp |
|---|---|---|
| Sửa hồ sơ ít xung đột | Không ghi đè thay đổi mới hơn | Version column hoặc ETag |
| Trừ tồn kho | Số lượng không xuống dưới 0 | Atomic conditional update |
| Cấp số thứ tự | Mỗi số chỉ cấp một lần | Sequence hoặc primitive của database |
| Workflow nhiều bước | Transition phải dựa trên trạng thái hiện tại | Compare-and-set theo state và version |
| Tính toán dài cần snapshot ổn định | Dữ liệu không đổi trong vùng xử lý | Row lock hoặc thiết kế lại workflow |
Không nên đọc dữ liệu vào ứng dụng, tự tính điều kiện rồi cập nhật mà thiếu predicate ở database. Giữa lần kiểm tra và lần ghi luôn có một cửa sổ để request khác chen vào. Invariant quan trọng phải được thể hiện bằng câu lệnh nguyên tử, constraint, unique index hoặc transaction phù hợp tại nơi dữ liệu được lưu.
3. Mẫu cột version và compare-and-set
Mẫu phổ biến nhất thêm một số nguyên tăng dần vào bản ghi. Client đọc cả dữ liệu và phiên bản. Khi ghi, backend đặt phiên bản cũ vào mệnh đề WHERE, đồng thời tăng phiên bản trong cùng câu lệnh. Số dòng bị ảnh hưởng chính là kết quả của phép compare-and-set.
UPDATE customer_profiles
SET display_name = :display_name,
phone = :phone,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
AND version = :expected_version;
Nếu affected rows bằng 1, trạng thái mong đợi vẫn còn hiệu lực và cập nhật thành công. Nếu bằng 0, có ba khả năng cần phân biệt: bản ghi không tồn tại, đã bị xóa, hoặc phiên bản đã thay đổi. Backend nên đọc lại tối thiểu metadata cần thiết để trả kết quả rõ ràng, nhưng không được tự chạy lại update bằng phiên bản mới nếu chưa hiểu ý định người dùng.
begin transaction
affected = update ... where id = input.id and version = input.version
if affected == 0:
current = select id, version from customer_profiles where id = input.id
if current is null: return NOT_FOUND
return CONFLICT(current.version)
commit
Cột version nên là dữ liệu do server quản lý. Không cho client chỉ định phiên bản mới; client chỉ gửi phiên bản kỳ vọng. Dùng timestamp làm token có thể gặp độ phân giải, chuẩn hóa timezone hoặc nhiều cập nhật trong cùng đơn vị thời gian. Một số nguyên đơn điệu thường dễ so sánh, log và kiểm thử hơn.
4. Đưa version ra HTTP bằng ETag và If-Match
Với REST API, phiên bản tài nguyên có thể biểu diễn bằng ETag. Response đọc tài nguyên trả một opaque validator, ví dụ ETag: "customer-42-v8". Client giữ giá trị này và gửi If-Match: "customer-42-v8" khi thực hiện PUT, PATCH hoặc DELETE. Server chỉ chấp nhận thao tác nếu validator còn khớp.
GET /api/customers/42
HTTP/1.1 200 OK
ETag: "customer-42-v8"
Content-Type: application/json
{"id":42,"display_name":"An","phone":"090..."}
PATCH /api/customers/42
If-Match: "customer-42-v8"
Content-Type: application/json
{"phone":"091..."}
Nếu tài nguyên đã sang phiên bản 9, precondition không còn đúng. Với precondition HTTP thất bại, 412 Precondition Failed diễn đạt sát contract. Một API dùng trường version trong JSON cũng có thể chọn 409 Conflict; điều quan trọng là contract nhất quán, tài liệu hóa được và client biết cách lấy trạng thái mới. Nếu endpoint bắt buộc chống ghi mù nhưng request thiếu If-Match, 428 Precondition Required là lựa chọn rõ ràng.
ETag phải là validator của representation hoặc trạng thái mà thao tác đang bảo vệ, không phải một chuỗi trang trí. Không nhét dữ liệu nhạy cảm vào ETag. Có thể ký hoặc mã hóa cấu trúc nội bộ, nhưng client phải xem token là opaque và chỉ gửi lại nguyên giá trị.
5. PATCH không tự giải quyết xung đột
Nhiều đội cho rằng gửi riêng trường thay đổi bằng PATCH sẽ loại bỏ lost update. PATCH chỉ giảm vùng ghi đè, không chứng minh thay đổi còn hợp lệ. Hai người cùng sửa status, hoặc một người đổi loại khách hàng trong khi người khác đặt hạn mức dựa trên loại cũ, vẫn có thể phá invariant dù payload nhỏ.
Có thể chọn một trong ba mức kiểm soát. Version toàn tài nguyên đơn giản và an toàn nhưng tạo conflict khi hai trường thật sự độc lập. Version theo aggregate gom các trường có chung invariant và thường là điểm cân bằng tốt. Version theo field giảm conflict giả nhưng làm contract, audit và merge phức tạp hơn. Đừng tối ưu độ chi tiết trước khi đo tần suất conflict thực tế.
Với API nhận toàn bộ object, cần đặc biệt tránh mô hình mass assignment từ snapshot cũ. Chỉ map các trường endpoint cho phép, validate transition dựa trên trạng thái hiện tại, và vẫn kiểm tra version. Một DTO rõ ràng vừa giảm ghi đè ngoài ý muốn vừa thu hẹp bề mặt bảo mật.
6. Xử lý conflict như một kết quả nghiệp vụ
Conflict do optimistic locking là kết quả dự kiến trong hệ thống có đồng thời, không phải lỗi hạ tầng cần trả 500. Response nên có mã máy đọc ổn định, tài nguyên bị ảnh hưởng, phiên bản client đã dùng, phiên bản hiện tại và hướng xử lý. Tránh trả toàn bộ bản ghi nếu có dữ liệu mà client không được phép xem.
{
"error": "resource_version_conflict",
"resource": "customer_profile",
"id": "42",
"expected_version": 8,
"current_version": 9,
"action": "reload_and_review"
}
Giao diện người dùng nên giữ thay đổi chưa lưu, tải bản mới và cho người dùng so sánh. Tự động merge chỉ phù hợp khi quy tắc merge được định nghĩa theo domain, ví dụ thêm hai phần tử độc lập vào một tập hợp. “Last write wins” là một chính sách hợp lệ cho dữ liệu không quan trọng như vị trí con trỏ tạm thời, nhưng phải là quyết định có chủ đích chứ không phải hành vi ngẫu nhiên của database.
API không nên retry mù optimistic conflict. Retry cùng payload với phiên bản mới có thể biến một quyết định dựa trên trạng thái cũ thành quyết định sai trên trạng thái mới. Chỉ tự retry khi operation có tính giao hoán hoặc backend có thể tính lại hoàn toàn từ dữ liệu hiện tại và vẫn bảo toàn ý định.
7. Khi nào cần atomic update thay vì version?
Nếu thao tác có thể biểu diễn hoàn toàn trong một câu lệnh, atomic conditional update thường gọn và mạnh hơn vòng đọc-sửa-ghi. Ví dụ giữ tồn kho không âm:
UPDATE inventory
SET available = available - :quantity,
version = version + 1
WHERE sku = :sku
AND available >= :quantity;
Affected rows bằng 0 có thể nghĩa là SKU không tồn tại hoặc không đủ hàng; backend đọc thêm để phân loại. Không cần lấy số lượng ra ứng dụng, kiểm tra rồi ghi lại. Tương tự, bộ đếm có thể dùng SET value = value + 1, transition có thể dùng WHERE status = 'pending'. Predicate phải mô tả trực tiếp precondition của nghiệp vụ.
Version vẫn hữu ích cho audit và đồng bộ client, nhưng không thay thế constraint. Unique index bảo vệ tính duy nhất; foreign key bảo vệ tham chiếu; check constraint bảo vệ miền giá trị. Cơ chế đúng nằm gần invariant nhất và không phụ thuộc mọi caller nhớ làm đúng.
8. Optimistic locking và row lock khác nhau ở đâu?
Optimistic locking không giữ khóa trong thời gian người dùng suy nghĩ. Nó cho phép cạnh tranh và phát hiện xung đột tại lúc ghi, phù hợp khi conflict tương đối hiếm hoặc request cần phản hồi độc lập. Pessimistic locking, chẳng hạn đọc hàng để cập nhật trong transaction, ngăn transaction khác sửa hàng đó cho đến khi khóa được giải phóng.
| Tiêu chí | Optimistic | Pessimistic |
|---|---|---|
| Thời điểm xử lý cạnh tranh | Phát hiện lúc ghi | Chặn trước khi ghi |
| Chi phí khi ít xung đột | Thấp | Có khóa và thời gian chờ |
| Khi xung đột cao | Nhiều conflict/retry | Có thể ổn định hơn nhưng giảm throughput |
| Thời gian transaction | Thường ngắn | Phải giữ rất ngắn |
| Rủi ro chính | Retry sai ý định | Deadlock, timeout và lock convoy |
Không giữ row lock trong lúc gọi dịch vụ ngoài, chờ người dùng hoặc chạy tác vụ dài. Điều đó kéo dài transaction, làm cạn connection pool và lan truyền độ trễ. Nếu quy trình kéo dài nhiều giây hoặc phút, hãy dùng reservation có thời hạn, state machine, saga hoặc job bất đồng bộ thay vì một transaction mở lâu.
9. Aggregate nhiều bảng và side effect bên ngoài
Một aggregate có thể trải qua nhiều bảng: đơn hàng, dòng hàng và tổng tiền. Version nên đặt ở root đại diện cho invariant chung. Transaction cập nhật bảng con đồng thời tăng version root với điều kiện phiên bản kỳ vọng. Nếu không tăng root khi bảng con đổi, client đọc aggregate sẽ không nhận ra snapshot đã cũ.
Với side effect như gửi email hoặc publish event, không nên gọi trực tiếp trước khi transaction commit. Ghi một outbox record trong cùng transaction với cập nhật có điều kiện; worker chỉ publish sau commit. Khóa idempotency của command và message ID của outbox giải quyết bài toán khác với version: idempotency chống thực hiện trùng cùng một yêu cầu, còn version chống một yêu cầu hợp lệ nhưng dựa trên trạng thái cũ.
begin transaction
update orders
set status = 'approved', version = version + 1
where id = :id and status = 'pending' and version = :version
require affected_rows == 1
insert into outbox(event_id, aggregate_id, event_type, payload)
commit
Nếu thao tác chạm nhiều aggregate độc lập, một version chung có thể gây contention lớn. Hãy xem lại ranh giới aggregate và ownership dữ liệu. Đồng thời không có nghĩa mọi bảng phải nằm trong một global lock.
10. Migration cột version an toàn
Khi thêm optimistic locking vào hệ thống đang chạy, triển khai theo các bước tương thích. Đầu tiên thêm cột có default phù hợp và backfill nếu database yêu cầu. Sau đó deploy code đọc và trả version nhưng chưa bắt buộc client gửi. Tiếp theo cập nhật client, quan sát adoption, rồi mới bắt buộc precondition trên endpoint ghi. Cuối cùng loại bỏ nhánh tương thích cũ.
- Không dùng giá trị null như một đường bỏ qua kiểm tra vô thời hạn.
- Đảm bảo mọi đường ghi, gồm admin tool, cron và consumer, đều tăng version.
- Đặt version trong audit log trước/sau để điều tra conflict.
- Kiểm tra ORM có thực sự đưa version vào mệnh đề WHERE và kiểm tra affected rows.
- Không reset version khi restore một phần dữ liệu nếu client cũ còn hoạt động.
Nếu có nhiều service cùng ghi một database, contract version phải được thống nhất trước. Một writer bỏ quên tăng version sẽ làm validator nói dối. Về dài hạn, một owner duy nhất cho mỗi aggregate hoặc command API rõ ràng giúp giảm đường ghi ngoài kiểm soát.
11. Quan sát conflict để phân biệt bảo vệ và vấn đề thiết kế
Metric cần ghi số cập nhật thành công, số conflict, endpoint, loại tài nguyên và nguồn caller. Không gắn user ID hoặc giá trị dữ liệu nhạy cảm vào label có cardinality cao. Log có cấu trúc nên chứa request ID, resource ID đã chuẩn hóa, expected/current version và kết quả, nhưng không log toàn payload.
Một tỷ lệ conflict nhỏ có thể chứng minh cơ chế đang bảo vệ dữ liệu đúng cách. Tỷ lệ tăng đột biến sau deploy có thể chỉ ra client không giữ ETag, cache trả snapshot cũ, hoặc một worker ghi quá thường xuyên. Conflict tập trung ở một aggregate nóng có thể yêu cầu atomic command, partition dữ liệu, serialize theo key hoặc thay đổi UX.
- Cảnh báo khi conflict rate thay đổi mạnh so với baseline, không chỉ khi lớn hơn 0.
- Đo thời gian từ lần đọc đến lần ghi để tìm workflow giữ snapshot quá lâu.
- Theo dõi số lần người dùng reload rồi vẫn conflict.
- Phân biệt conflict version với unique constraint, deadlock và lock timeout.
12. Kiểm thử race condition một cách xác định
Test tuần tự không đủ. Một integration test tốt tạo hai connection hoặc hai request, cho cả hai đọc cùng version, rồi điều phối để cùng ghi. Chỉ một update được thành công; update còn lại phải trả conflict và dữ liệu cuối phải chứa đúng thay đổi đã thắng. Dùng barrier hoặc latch thay vì phụ thuộc vào sleep ngẫu nhiên.
snapshotA = readCustomer(42) // version 8
snapshotB = readCustomer(42) // version 8
resultA = updateCustomer(42, snapshotA.version, changeA)
resultB = updateCustomer(42, snapshotB.version, changeB)
assert exactlyOne(resultA, resultB).isSuccess()
assert exactlyOne(resultA, resultB).isConflict()
assert readCustomer(42).version == 9
Test thêm việc xóa cạnh tranh với cập nhật, transition trạng thái không hợp lệ, request thiếu precondition, idempotent retry sau timeout, và aggregate nhiều bảng. Chạy test trên đúng loại database production vì isolation và affected-row semantics có thể khác database giả lập. Test API phải xác nhận ETag mới được trả sau update và ETag cũ bị từ chối.
Checklist triển khai
- Invariant và phạm vi aggregate được viết rõ trước khi chọn cơ chế khóa.
- Câu lệnh ghi chứa expected version hoặc business precondition trong WHERE.
- Backend kiểm tra affected rows và không coi 0 dòng là thành công.
- ETag/If-Match hoặc trường version có contract nhất quán cho client.
- Conflict trả mã ổn định và không bị chuyển thành HTTP 500.
- Không tự retry khi chưa chứng minh operation có thể tính lại an toàn.
- Mọi writer đều tăng version; constraint database vẫn bảo vệ invariant cốt lõi.
- Metric, log và test cạnh tranh xác nhận cơ chế hoạt động trong production.
Optimistic locking không loại bỏ cạnh tranh; nó làm cạnh tranh trở nên minh bạch và có thể xử lý. Thiết kế tốt bắt đầu bằng invariant, đặt precondition vào đúng câu lệnh database, truyền validator qua API và coi conflict là một nhánh bình thường. Khi kết hợp version, atomic update, constraint, idempotency và quan sát vận hành đúng chỗ, backend có thể chịu nhiều writer mà không đánh đổi tính toàn vẹn dữ liệu.




Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.