FDE PulseViệc làm FDE đang mở 641Mới trong 7 ngày 50Công ty đang tuyển 98Nhận làm từ xa 24%Lương trung vị (Mỹ) $203kTuyển nhiều nhất Databricks 122
EN

Tờ báo của nghề Forward Deployed Engineer

Bách khoa

Khi hai agent cùng ghi một bản ghi của khách: transaction và mức cô lập trong database

Hai agent cùng đọc số dư 100, mỗi agent ghi đúng phần việc của mình, vậy mà khách vẫn mất 50 credit và log không có lấy một dòng báo lỗi.

Dãy tủ máy chủ trong phòng server, ánh đèn xanh, gợi hạ tầng database đang xử lý nhiều tiến trình cùng lúc.
Ảnh: Taylor Vick / Unsplash
Chia sẻLinkedInFacebookX

Tóm tắt nhanh

  • Đọc giá trị, tính trong code rồi ghi lại là cách nhanh nhất để mất update khi hai agent chạy cùng lúc
  • PostgreSQL mặc định chạy Read Committed, MySQL InnoDB chạy REPEATABLE READ, CockroachDB chạy SERIALIZABLE, nên cùng một đoạn code có thể chạy ra ba kết quả khác nhau
  • Mức cô lập càng chặt thì client càng phải sẵn sàng retry, và vòng retry phải bắt cả deadlock chứ không chỉ serialization failure
Bảng so sánh ba cách chặn lost update khi hai agent cùng ghi một bản ghi: update nguyên tử (bên sau đợi, hợp với cộng trừ số, nên thử trước), SELECT … FOR UPDATE (bên kia đợi khóa, hợp với đọc rồi quyết định), và Repeatable Read kèm retry (một bên bị hủy rồi chạy lại, hợp với logic phức tạp).
Với các phép cộng trừ, để database tự tính trong câu lệnh UPDATE là cách nên thử trước. Còn khi agent phải đọc rồi mới quyết định thì cần dùng khóa, hoặc chạy ở mức cô lập cao hơn kèm retry. Nguồn: tài liệu PostgreSQL, theo bài viết.

Thử hình dung sáng thứ Hai, khách báo một tài khoản bị trừ sai credit. Bạn mở log ra: agent hỗ trợ cộng 50 credit đền bù, agent billing trừ 30 tiền phí, cả hai đều báo thành công. Số dư đúng ra phải là 120, nhưng database đang ghi 70.

Code không có bug theo nghĩa thông thường. Hai agent chạy đúng logic của mình, chỉ có điều chúng chạy cùng một lúc. Khi bạn đưa agent vào hệ thống của khách, chuyện nhiều tiến trình cùng ghi một bản ghi sẽ xảy ra thường xuyên. Con số còn lại trong database phụ thuộc vào cách bạn dùng transaction và vào mức cô lập (isolation level) mà database đang chạy.

Phần lớn engineer biết chữ ACID, nhưng ít người biết chữ I trong đó đang được cấu hình thế nào trên database của khách. FDE thì cần biết, vì lỗi loại này không ném exception. Nó chỉ làm dữ liệu sai đi rồi chờ khách tự phát hiện.

Vì sao 50 credit biến mất?

Xếp hai phiên cạnh nhau theo đúng thứ tự thời gian là thấy ngay câu trả lời. Hai agent dùng chung một đoạn code: đọc số dư, tính số dư mới trong Python, rồi ghi lại.

-- Agent A (hỗ trợ)              -- Agent B (billing)
SELECT balance FROM accounts      SELECT balance FROM accounts
 WHERE id = 42;   -- 100           WHERE id = 42;   -- 100
-- tính 100 + 50 = 150            -- tính 100 - 30 = 70
UPDATE accounts SET balance = 150
 WHERE id = 42; COMMIT;
                                  UPDATE accounts SET balance = 70
                                   WHERE id = 42; COMMIT;

Lỗi này có tên là lost update: lần ghi sau đè lên lần ghi trước mà không hề biết lần ghi trước tồn tại. Con số 70 đã được tính sẵn trong Python từ một lần đọc cũ, nên database chỉ nhận một hằng số và ghi nó vào.

Read Committed lại chính là mức mặc định của PostgreSQL. Vì vậy, trừ khi có người chủ động đổi cấu hình, kiểu code đọc, tính, ghi như trên không có gì bảo vệ trước tình huống hai agent chạy xen kẽ.

Ba cách sửa, mỗi cách cho một kiểu bài toán

Cách đơn giản nhất là để database tự tính. Khi viết UPDATE accounts SET balance = balance + 50 WHERE id = 42, phép cộng nằm ngay trong câu lệnh, nên không còn hằng số nào được tính từ một lần đọc cũ như ví dụ trên.

Với các thay đổi kiểu cộng trừ một con số, đây là cách nên thử trước. Dù vậy, bạn vẫn nên tự kiểm chứng bằng hai cửa sổ psql trên đúng database của khách.

Có điều agent thường phải đọc rồi mới quyết định, chẳng hạn chỉ trừ phí khi số dư đủ, hoặc gọi LLM chọn hành động dựa trên trạng thái hiện tại của khách. Lúc đó bạn cần khóa bi quan: SELECT ... FOR UPDATE khóa những dòng vừa đọc như thể chúng sắp bị update, chặn agent kia ghi chồng lên.

BEGIN;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- quyết định trong code khi dòng 42 đang bị khóa
UPDATE accounts SET balance = 70 WHERE id = 42;
COMMIT;

Cách thứ ba dành cho PostgreSQL: chạy transaction ở Repeatable Read hoặc Serializable và chấp nhận rằng database có thể hủy transaction của bạn bằng lỗi serialization. Tài liệu PostgreSQL yêu cầu ứng dụng chạy ở Repeatable Read phải sẵn sàng retry khi gặp lỗi đó. Khi gặp, bạn phải hủy và chạy lại cả transaction từ đầu, không chỉ câu lệnh vừa lỗi.

Lưu ý cách này nói về hành vi của PostgreSQL. MySQL InnoDB cũng có mức mang tên REPEATABLE READ, nhưng cùng tên không có nghĩa cùng hành vi, nên đừng mặc định rằng nó bảo vệ bạn theo cùng kiểu.

Vòng retry còn phải bắt thêm một loại lỗi nữa. Trong psycopg, SerializationFailure (mã 40001) và DeadlockDetected (mã 40P01) là hai lớp lỗi riêng biệt. Nếu chỉ bắt lớp thứ nhất, vòng retry sẽ bỏ sót deadlock.

import random
import time

from psycopg import errors

RETRYABLE = (errors.SerializationFailure, errors.DeadlockDetected)

def run_with_retry(conn, work, attempts=5):
    for i in range(attempts):
        try:
            with conn.transaction():
                conn.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
                return work(conn)  # đọc lại từ đầu mỗi lần thử
        except RETRYABLE:
            if i == attempts - 1:
                raise
            # backoff có jitter để hai agent không va nhau lần nữa ngay lập tức
            time.sleep(0.05 * 2 ** i + random.uniform(0, 0.05))

Điều quan trọng nhất trong đoạn code này là work phải đọc lại dữ liệu ở mỗi lần thử. Nếu agent giữ kết quả đọc cũ, hay giữ cả câu trả lời LLM đã tính từ trạng thái cũ, thì retry chỉ là ghi sai thêm một lần nữa.

Dòng time.sleep là backoff tăng dần kèm một chút ngẫu nhiên; thiếu nó, hai agent vừa va nhau có thể lập tức thử lại và va nhau tiếp.

Cách Cơ chế Hợp với
Update nguyên tử Phép tính nằm trong câu lệnh, không dùng số đọc cũ Cộng trừ số, đếm
SELECT ... FOR UPDATE Khóa dòng vừa đọc, agent kia không ghi chồng được Đọc rồi quyết định, tranh chấp cao
Repeatable Read/Serializable + retry (PostgreSQL) PostgreSQL hủy transaction bằng lỗi serialization, client chạy lại cả transaction Tranh chấp thấp, logic phức tạp

Snapshot vẫn để lọt một kiểu lỗi

Nâng mức cô lập rồi vẫn còn một cái bẫy. Jepsen mô tả snapshot isolation cho phép write skew: các transaction đọc chung một phần trạng thái, mỗi bên sửa một tập đối tượng khác nhau, rồi cả hai cùng commit. Tài liệu PostgreSQL cũng nói Repeatable Read chặn mọi hiện tượng trong bảng của họ trừ serialization anomaly, nhóm chứa write skew.

Thử hình dung khách có quy tắc mỗi tài khoản phải còn ít nhất một người liên hệ khẩn cấp, và tài khoản 42 đang có hai người. Agent A gỡ người thứ nhất, agent B gỡ người thứ hai.

Cả hai đều đếm thấy còn 2 nên cho là an toàn. Mỗi bên xóa một dòng khác nhau, không dòng nào bị ghi hai lần, nên không có xung đột nào để báo. Kết quả là tài khoản không còn ai để gọi.

Đó là lý do tài liệu PostgreSQL cảnh báo rằng ép quy tắc nghiệp vụ ở Repeatable Read khó chạy đúng nếu không dùng khóa tường minh cẩn thận. Mấu chốt của write skew là hai bên không đụng vào cùng một dòng, vậy một hướng hợp lý là bắt cả hai đi qua một dòng chung: SELECT ... FOR UPDATE trên chính dòng tài khoản 42 trước khi đếm.

Hướng còn lại là Serializable. Wiki PostgreSQL mô tả write skew là dạng anomaly đơn giản nhất mà SSI chặn được: ai commit trước thì thắng, transaction kia bị rollback và phải chạy lại, tức là lại cần đến vòng retry ở trên.

Cùng tên mức cô lập, mỗi database hiểu một kiểu

Đây là chỗ FDE dễ vấp nhất khi chuyển từ khách này sang khách khác. MySQL InnoDB mặc định chạy REPEATABLE READ chứ không phải Read Committed như PostgreSQL. CockroachDB mặc định chạy SERIALIZABLE và yêu cầu client tự viết logic retry, với lỗi mã 40001.

Martin Kleppmann, trong dự án Hermitage kiểm thử chữ I của ACID, nhận xét rằng một cái tên như repeatable read mang nghĩa khác nhau ở mỗi database.

Hệ quả cho bạn khá rõ. Code agent chạy ổn trên CockroachDB ở dự án trước, nếu dựa vào việc database tự chặn xung đột, có thể làm sai dữ liệu khi chạy trên PostgreSQL mặc định. Ngược lại, code viết cho PostgreSQL mà thiếu retry sẽ gặp lỗi 40001 trên CockroachDB mà không có ai xử lý. Hãy kiểm tra cấu hình thật, đừng dựa vào trí nhớ.

Thêm khóa thì thêm một rủi ro: deadlock

Khi đã dùng FOR UPDATE, bạn sẽ gặp deadlock: agent A khóa tài khoản rồi đợi khóa hóa đơn, agent B khóa hóa đơn rồi đợi khóa tài khoản. PostgreSQL tự phát hiện tình huống này và hủy một transaction để các transaction còn lại chạy tiếp.

Deadlock là lỗi khác với serialization failure, nên kể cả khi chạy Read Committed bạn vẫn cần vòng retry, và vòng đó phải bắt DeadlockDetected như đoạn code ở trên. Tài liệu MySQL cũng dặn tương tự: luôn sẵn sàng chạy lại transaction bị hủy do deadlock.

Theo tài liệu PostgreSQL, cách phòng tốt nhất là mọi ứng dụng đều khóa nhiều đối tượng theo cùng một thứ tự. Bạn có thể đặt quy ước cho cả hệ thống agent, chẳng hạn luôn khóa tài khoản trước hóa đơn, hoặc khóa theo id tăng dần, rồi ghi quy ước đó vào tài liệu bàn giao để đội của khách không phá vỡ nó về sau.

Những cái bẫy hay gặp ở site khách

Cái bẫy phổ biến nhất là để agent tự sinh SQL kiểu đọc, tính trong code rồi ghi mà không ai rà lại xem có tranh chấp không. Phổ biến không kém là có retry nhưng không đọc lại dữ liệu, retry dồn dập không có backoff, hoặc retry chỉ bắt serialization failure mà quên deadlock.

Cái bẫy riêng của hệ thống agent là giữ transaction mở trong lúc chờ LLM trả lời. Tài liệu MySQL khuyên giữ transaction nhỏ và ngắn để giảm va chạm. Vì thế hãy đọc dữ liệu, gọi model bên ngoài transaction, rồi mới mở một transaction ngắn để kiểm tra lại và ghi.

Lần tới, trước khi cho agent thứ hai ghi vào cùng một bảng, hãy tự hỏi nếu hai agent chạy trong cùng một mili giây thì con số nào sẽ còn lại. Vào phỏng vấn FDE, kể lại được một lần bạn tìm ra lost update kiểu này và sửa xong sẽ thuyết phục hơn nhiều so với chữ “PostgreSQL” nằm trong danh sách kỹ năng.

Bài này có hữu ích không?

Dùng cùng trợ lý AIHỏi Claude ↗Hỏi ChatGPT ↗
9 nguồn
Đọc tiếp trên lộ trình · Chặng 2: Kỹ thuật rộngĐọc hộp thư Outlook của khách bằng Microsoft Graph mà không mở cả tenantLấy được token chỉ là phần dễ; phần khó là chứng minh với IT của khách rằng app của bạn không đọc được hộp thư nào ngoài phạm vi đã thỏa thuận.