Chuyển đến nội dung
MINH VO Ghi chép từ công việc
của một kỹ sư tại Việt Nam
Cơ bản10 phút đọc

Ngăn ghi đè thay đổi bằng UPDATE có điều kiện

Bảo vệ các lần sửa hồ sơ đồng thời bằng UPDATE kiểm tra phiên bản, phân biệt 0 hàng với lỗi và kiểm tra giải quyết xung đột cùng bên ghi bỏ qua phiên bản.

Các thành phần máy chủ màu ô liu và than chì nối nhau trên nền be ấm

Hai người cùng mở một hồ sơ. Người thứ nhất đổi tên hiển thị; người thứ hai đổi ngôn ngữ giao diện. Nếu người sau gửi lại bản cũ của mọi trường, lần lưu ấy có thể âm thầm đưa tên cũ trở lại. Bọc thao tác lưu trong giao dịch không cho PostgreSQL biết người sửa đã nhìn thấy phiên bản nào lúc đầu.

Câu cập nhật có kiểm tra phiên bản bổ sung điều kiện còn thiếu: chỉ áp dụng thay đổi nếu hàng vẫn có revision mà tôi đã đọc. Bài này dành cho người đã biết cập nhật SQL và giao dịch cơ bản. Ví dụ dùng PostgreSQL 17 ở mức Read Committed, bảng thông thường không có trigger hay row-level security và hồ sơ mang mã 7. Phân quyền cùng giao diện HTTP là những phần việc riêng.

Giữ phiên bản cùng các giá trị mà nó mô tả

Tạo dữ liệu dùng thử:

CREATE TABLE profiles (
  id bigint PRIMARY KEY,
  display_name text NOT NULL
    CHECK (char_length(display_name) BETWEEN 1 AND 80),
  locale text NOT NULL CHECK (locale IN ('en', 'vi')),
  version bigint NOT NULL DEFAULT 0 CHECK (version >= 0)
);
INSERT INTO profiles (id, display_name, locale)
VALUES (7, 'Ada', 'en');

Danh sách ngôn ngữ nhỏ này thuộc về ví dụ, không phải danh mục mã ngôn ngữ đầy đủ. Ban đầu, cả hai người đều chạy:

SELECT display_name, locale, version FROM profiles WHERE id = 7;

Mỗi người nhận Ada, en và phiên bản 0. Ứng dụng phải giữ phiên bản đó cùng các giá trị đang sửa. Lần lưu sau cần gửi phiên bản của biểu mẫu đã tải, thay vì lấy phiên bản mới nhất ngay trước khi ghi rồi coi như người dùng đã xem nó.

Mục Optimistic Offline Lock ngày 5 tháng 3 năm 2003 của David Rice mô tả khoảng cách giữa một thao tác nghiệp vụ và từng giao dịch cơ sở dữ liệu. Người sửa có thể suy nghĩ vài phút giữa lúc tải và lưu. “Offline” ở đây nói tới sự tách rời đó, không bắt buộc trình duyệt mất kết nối. Việc kiểm tra diễn ra khi lưu nên cơ sở dữ liệu không cần giữ khóa hàng suốt thời gian người dùng đang chỉnh sửa.

Chỉ có bộ đếm chưa ngăn được mất thay đổi

Đoạn tái hiện cố ý yếu sau đặt hai lần lưu trong một giao dịch tạm để bạn xem kết quả rồi hủy:

BEGIN;
UPDATE profiles
SET display_name = 'Ada Nguyen', version = version + 1
WHERE id = 7;
UPDATE profiles
SET display_name = 'Ada', locale = 'vi', version = version + 1
WHERE id = 7;
SELECT display_name, locale, version FROM profiles WHERE id = 7;
ROLLBACK;

Kết quả là Ada, vi, phiên bản hai. Bộ đếm tăng hai lần nhưng payload thứ hai đưa tên cũ trở lại. Cả hai câu cập nhật SQL đều hợp lệ. ROLLBACK đưa dữ liệu về hàng ban đầu để dùng trong phần tiếp theo. Lỗi nằm ở việc thiếu so sánh với phiên bản từng người đã tải, không phải quên tăng bộ đếm.

Giao dịch chạy sau không thể tự suy ra rằng Ada đến từ biểu mẫu cũ thay vì một quyết định đổi tên có chủ đích. Thông tin đó phải nằm trong điều kiện ghi. Tài liệu UPDATE xác định hàng đủ điều kiện qua WHERE và các giá trị sau cập nhật có thể trả về.

So sánh và tăng phiên bản trong cùng câu lệnh

Người A đổi tên bằng phiên bản mình thực sự đã đọc:

UPDATE profiles
SET display_name = 'Ada Nguyen', version = version + 1
WHERE id = 7 AND version = 0
RETURNING display_name, locale, version;

Chạy bên ngoài giao dịch đang mở để câu lệnh thành công tự commit. Nó trả Ada Nguyen, en, phiên bản một. So sánh và thay đổi diễn ra trong cùng một câu lệnh cơ sở dữ liệu. Phiên bản mới được tính từ hàng, thay vì nhận một số phiên bản thay thế tùy ý từ client.

Người B vẫn giữ biểu mẫu đầy đủ ban đầu, chỉ đổi trường ngôn ngữ sang tiếng Việt:

UPDATE profiles
SET display_name = 'Ada', locale = 'vi', version = version + 1
WHERE id = 7 AND version = 0
RETURNING display_name, locale, version;

Câu này không trả hàng nào. Nó không phát sinh ngoại lệ SQL. Hồ sơ vẫn tồn tại nhưng phiên bản đã là một nên không còn khớp điều kiện của B. Tên giữ nguyên Ada Nguyen, ngôn ngữ vẫn là en.

Cả hai người tải phiên bản 0; A đổi tên và commit phiên bản một nên lần lưu toàn bộ biểu mẫu của B vẫn dùng phiên bản 0 chỉ ảnh hưởng 0 hàng và giữ thay đổi của A.Mở ảnh kích thước đầy đủ ↗

Điều kiện phiên bản từ chối một lần ghi từ trạng thái cũ. Nó không tự quyết định cách kết hợp ý định của hai người.

Với phép tìm theo khóa chính này, một hàng trả về nghĩa là cập nhật đã xảy ra; 0 hàng nghĩa là không có hàng nào khớp. Hồ sơ không tồn tại cũng cho 0. PostgreSQL nêu rõ cập nhật 0 hàng vẫn là lệnh thành công, nên chỉ bắt ngoại lệ sẽ không nhận diện được xung đột này. Hãy kiểm tra hàng trả về hoặc số hàng bị ảnh hưởng mà driver cung cấp.

Giải quyết xung đột bằng dữ liệu hiện tại

Chạy lại truy vấn đọc ban đầu rồi cho B xem tên, ngôn ngữ và phiên bản hiện tại. Nếu B vẫn chỉ muốn chuyển ngôn ngữ, người dùng có thể gửi riêng trường ấy với phiên bản vừa xem:

UPDATE profiles
SET locale = 'vi', version = version + 1
WHERE id = 7 AND version = 1
RETURNING display_name, locale, version;

Kết quả là Ada Nguyen, vi, phiên bản hai. Tên của A được giữ vì lần ghi mới chủ động chỉ sửa ngôn ngữ. Đây là một lựa chọn giải quyết cụ thể cho ví dụ, không phải quy tắc tự gộp phổ quát. Các trường có ý nghĩa nghiệp vụ phụ thuộc nhau có thể cần quyết định khác hoặc thêm bước kiểm tra.

Nếu chỉ thay số không mà B kỳ vọng bằng một rồi gửi lại toàn bộ payload cũ, điều kiện phiên bản cũng sẽ đạt. Nó vẫn đưa tên về Ada và làm mất thay đổi của A. Vì thế, vòng lặp âm thầm lấy phiên bản mới nhất để thử lại có thể phá chính cơ chế bảo vệ mà nó định cung cấp. Làm mới token không đồng nghĩa với việc đã xem trạng thái mới.

Truy vấn đọc sau thất bại cũng là một quan sát mới. Trong đối chứng hai phiên cục bộ, câu cập nhật của B ban đầu trả 0 vì phiên bản cũ; một phiên khác xóa hàng trước truy vấn chẩn đoán của B. Truy vấn ấy không tìm thấy gì. Nó mô tả cơ sở dữ liệu hiện tại, không phải lời giải thích nguyên tử cho lý do câu cập nhật trước ảnh hưởng 0 hàng. Cần giữ sự phân biệt này khi thiết kế thông báo xung đột và tài nguyên không còn tồn tại.

Phân biệt không khớp điều kiện với lỗi cơ sở dữ liệu

Từ phiên bản hai, thử một giá trị ngôn ngữ không được hỗ trợ:

BEGIN;
UPDATE profiles
SET locale = 'xx', version = version + 1
WHERE id = 7 AND version = 2
RETURNING display_name, locale, version;
ROLLBACK;
SELECT display_name, locale, version FROM profiles WHERE id = 7;

Câu cập nhật khớp điều kiện gây lỗi ràng buộc check 23514. Sau rollback, tên, ngôn ngữ và phiên bản đều giữ nguyên. Tài liệu ràng buộc giải thích cách cơ sở dữ liệu áp đặt điều kiện lên hàng được lưu. Lần tăng phiên bản được thử cũng không tồn tại sau khi câu lệnh thất bại.

Dùng psql tương tác cho ví dụ lỗi dự kiến và chạy rollback sau lỗi. Nếu script dừng ngay khi gặp lỗi, bên gọi phải lo phần dọn trạng thái. Trong mã ứng dụng, phải rollback giao dịch tường minh đã lỗi trước khi dùng lại kết nối. Ngược lại, không khớp phiên bản và trả 0 hàng không đưa giao dịch vào trạng thái đã hủy.

Kết quả không khớp cũng không chứng nhận các giá trị gửi lên là hợp lệ. Nếu WHERE không tìm thấy hàng, không có hàng cập nhật nào để đánh giá ràng buộc ngôn ngữ này. Kiểm tra dạng đầu vào của yêu cầu và diễn giải kết quả cơ sở dữ liệu là hai việc riêng.

Cập nhật khớp thành công trả một hàng cùng phiên bản mới; phiên bản cũ hoặc hàng thiếu trả 0 mà không có lỗi SQL; dữ liệu lưu không hợp lệ gây lỗi và cần dọn giao dịch.Mở ảnh kích thước đầy đủ ↗

Một hàng, 0 hàng và ngoại lệ là ba kết quả khác nhau. Lớp bọc driver cần giữ được sự khác biệt đó cho bên gọi.

Mọi bên ghi liên quan đều phải tuân theo phiên bản

Từ phiên bản hai, đối chứng chỉ dùng để rollback sau mô phỏng một đoạn ghi quản trị cũ quên giao thức:

BEGIN;
UPDATE profiles SET display_name = 'Operations change' WHERE id = 7;
UPDATE profiles
SET display_name = 'Old form', version = version + 1
WHERE id = 7 AND version = 2
RETURNING display_name, locale, version;
ROLLBACK;

Kết quả là Old form, vi, phiên bản ba. Thay đổi từ vận hành không cập nhật phiên bản nên revision vẫn là hai, và biểu mẫu cũ còn khớp. Cột phiên bản không phát hiện được thay đổi mà các bên ghi không ghi nhận. Cần rà cả job nền, script và đường bảo trì bên cạnh endpoint chính.

Giao thức được chọn tăng phiên bản cho mỗi lần cập nhật được chấp nhận, kể cả khi lưu lại cùng một giá trị trường. Con số ghi nhận các revision được nhận, không đếm số ký tự thay đổi. Bộ đếm bigint hữu hạn cũng có thể tràn; kiểm tra cục bộ ở phiên bản tối đa gây lỗi và giữ nguyên hàng thay vì quay vòng. Không nên đặt lại phiên bản để né lỗi đó.

Danh tính hàng cũng có vòng đời. Xóa hồ sơ rồi tạo lại cùng mã với phiên bản 0 có thể làm biểu mẫu phiên bản 0 từ trước khớp trở lại. Hãy bảo toàn danh tính hoặc đưa thế hệ phù hợp vào token kiểm soát đồng thời nếu mã có thể được tái sử dụng. Phiên bản cũng không thay thế phân quyền: bên gọi biết nó vẫn cần quyền thay đổi hồ sơ.

Lưu theo cách lạc quan vẫn dùng khóa cơ sở dữ liệu

Hai kết nối PostgreSQL thật đã thử cùng một phiên bản kỳ vọng đồng thời. A giữ cập nhật chưa kết thúc trong khi cập nhật của B chờ. Khi A commit, B kiểm tra lại hàng rồi trả 0 mà không có lỗi SQL. Khi A rollback, B khớp phiên bản 0 và lưu thay đổi riêng thành phiên bản một.

Đó là kết quả theo quy tắc cập nhật Read Committed. “Lạc quan” mô tả việc phát hiện xung đột lúc lưu qua cả khoảng thời gian chỉnh sửa; nó không có nghĩa PostgreSQL cập nhật mà không dùng khóa. Ví dụ không khẳng định thông lượng hay cơ chế không khóa.

Cuối cùng, revision không phải khóa idempotency của yêu cầu. Gửi lại yêu cầu A vốn đã thành công cũng trả 0 vì phiên bản đã tăng. Chỉ kết quả đó không phân biệt được một yêu cầu đã được áp dụng với thay đổi xen giữa của người khác, nhất là sau khi mất phản hồi.

Để thực hành, dùng hai phiên và chạy cả hai thứ tự commit. Ghi lại các hàng trả về, giá trị trường cuối cùng và phiên bản. Sau đó thử mã không tồn tại, ngôn ngữ sai và bên ghi bỏ qua phiên bản. Kết quả hữu ích là một quyết định của ứng dụng cho từng tình huống, kể cả lúc người dùng phải xem hồ sơ mới hơn trước khi chọn nội dung sẽ lưu.

Nguồn tham khảo & đọc thêm

  1. PostgreSQL 17: UPDATE
  2. PostgreSQL 17: transaction isolation
  3. PostgreSQL 17: constraints
  4. David Rice: Optimistic Offline Lock, Patterns of Enterprise Application Architecture catalog (5 March 2003)
← Về trang bài viết

Hình minh họa

100%