Transaction và isolation level trong PostgreSQL
1. Tổng quan
Khi nhiều request cùng đọc và ghi một bảng, kết quả có thể sai dù từng câu SQL đều đúng. Isolation level quyết định một transaction “nhìn thấy” bao nhiêu thay đổi của các transaction khác.
Trong bài này, chúng ta sẽ xem PostgreSQL triển khai các isolation level ra sao. Bạn sẽ tái hiện được các lỗi lost update và write skew. Cuối bài, bạn sẽ biết cách chọn level phù hợp, cấu hình nó trong Spring Boot và viết logic retry đúng cách.
2. Transaction trong PostgreSQL
Một transaction gom nhiều câu lệnh thành một đơn vị. Hoặc tất cả thành công, hoặc không câu nào có hiệu lực:
CREATE TABLE account (
id BIGINT PRIMARY KEY,
owner TEXT NOT NULL,
balance NUMERIC(12, 2) NOT NULL CHECK (balance >= 0)
);
INSERT INTO account VALUES (1, 'An', 1000), (2, 'Binh', 500);
BEGIN;
UPDATE account SET balance = balance - 300 WHERE id = 1;
UPDATE account SET balance = balance + 300 WHERE id = 2;
COMMIT;
Nếu câu UPDATE thứ hai lỗi, bạn gọi ROLLBACK và số dư của An được giữ nguyên. Khi không có BEGIN, PostgreSQL chạy ở chế độ autocommit: mỗi câu lệnh là một transaction riêng.
PostgreSQL dùng MVCC (Multi-Version Concurrency Control). Mỗi lệnh UPDATE tạo ra một phiên bản mới của dòng thay vì ghi đè. Mỗi transaction đọc dữ liệu qua một snapshot. Vì vậy, thao tác đọc không chặn thao tác ghi, và ngược lại. Isolation level quyết định snapshot được lấy khi nào: ở đầu mỗi câu lệnh, hay một lần cho cả transaction.
3. Các hiện tượng bất thường và bốn isolation level
Chuẩn SQL định nghĩa các hiện tượng sau:
- Dirty read: đọc được dữ liệu mà transaction khác chưa commit.
- Non-repeatable read: đọc cùng một dòng hai lần nhưng nhận giá trị khác nhau.
- Phantom read: chạy lại cùng một truy vấn có điều kiện, kết quả xuất hiện thêm hoặc mất đi dòng.
- Lost update: hai transaction cùng đọc rồi ghi một dòng, và một lần ghi bị ghi đè mất.
- Write skew: hai transaction đọc chung dữ liệu, rồi mỗi bên ghi vào một dòng khác nhau, làm vỡ một ràng buộc nghiệp vụ.
Bảng dưới đây mô tả cách PostgreSQL xử lý từng hiện tượng:
Level Dirty read Non-repeatable Phantom Lost update Write skew
READ UNCOMMITTED Không Có Có Có Có
READ COMMITTED Không Có Có Có Có
REPEATABLE READ Không Không Không Không (*) Có
SERIALIZABLE Không Không Không Không (*) Không (*)
(*) Transaction bị hủy với SQLSTATE 40001, ứng dụng phải retry.
Có hai điểm khác chuẩn SQL bạn cần nhớ:
READ UNCOMMITTEDtrong PostgreSQL hoạt động y hệtREAD COMMITTED. Bạn không bao giờ gặp dirty read.REPEATABLE READchặn được cả phantom read, vì nó thực chất là snapshot isolation.
Level mặc định là READ COMMITTED. Bạn kiểm tra bằng lệnh sau:
SHOW default_transaction_isolation;
default_transaction_isolation
-------------------------------
read committed
4. READ COMMITTED
Ở level này, mỗi câu lệnh lấy một snapshot mới. Hãy mở hai phiên psql và chạy xen kẽ theo thứ tự:
-- Phiên A
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 700.00
-- Phiên B
UPDATE account SET balance = 900 WHERE id = 1; -- autocommit
-- Phiên A
SELECT balance FROM account WHERE id = 1; -- 900.00
COMMIT;
Trong cùng một transaction, phiên A thấy hai giá trị khác nhau. Đây là non-repeatable read.
4.1. Lost update
Lỗi hay gặp nhất trong ứng dụng là mẫu “đọc lên Java, tính, rồi ghi lại”:
BigDecimal balance = jdbc.queryForObject(
"SELECT balance FROM account WHERE id = ?", BigDecimal.class, 1L);
jdbc.update("UPDATE account SET balance = ? WHERE id = ?",
balance.subtract(new BigDecimal("100")), 1L);
Giả sử hai thread cùng chạy đoạn này và cùng đọc được 1000. Cả hai sẽ ghi 900. Kết quả là một lần trừ tiền bị mất.
Có hai cách sửa mà vẫn giữ READ COMMITTED.
Cách 1: để database tự tính, viết thành một câu lệnh nguyên tử.
UPDATE account SET balance = balance - 100 WHERE id = 1;
Khi gặp một dòng đang bị transaction khác cập nhật, PostgreSQL chờ transaction đó commit. Sau đó nó đọc lại phiên bản mới nhất của dòng rồi mới áp phép trừ.
Cách 2: khóa dòng ngay khi đọc bằng SELECT ... FOR UPDATE.
BEGIN;
SELECT balance FROM account WHERE id = 1 FOR UPDATE;
-- ứng dụng kiểm tra nghiệp vụ, tính toán...
UPDATE account SET balance = 900 WHERE id = 1;
COMMIT;
Transaction thứ hai sẽ bị chặn ở lệnh SELECT ... FOR UPDATE cho đến khi transaction đầu commit. Vì vậy nó luôn đọc được giá trị mới.
5. REPEATABLE READ
Ở level này, snapshot được lấy một lần tại câu lệnh đầu tiên của transaction. Mọi lần đọc sau đều thấy cùng một trạng thái dữ liệu.
Nếu bạn cố cập nhật một dòng mà transaction khác đã sửa và commit sau thời điểm lấy snapshot, PostgreSQL sẽ từ chối:
-- Phiên A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM account WHERE id = 1; -- 1000.00
-- Phiên B
UPDATE account SET balance = balance - 100 WHERE id = 1; -- commit
-- Phiên A
UPDATE account SET balance = balance - 200 WHERE id = 1;
ERROR: could not serialize access due to concurrent update
Lost update không xảy ra âm thầm nữa. Đổi lại, phiên A phải ROLLBACK và chạy lại từ đầu. Mã lỗi 40001 (serialization_failure) là tín hiệu để ứng dụng retry.
6. SERIALIZABLE và write skew
REPEATABLE READ vẫn để lọt write skew. Xét ví dụ kinh điển sau: bệnh viện yêu cầu luôn có ít nhất một bác sĩ trực.
CREATE TABLE doctor (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
on_call BOOLEAN NOT NULL
);
INSERT INTO doctor VALUES (1, 'Lan', true), (2, 'Minh', true);
Cả hai bác sĩ cùng xin nghỉ trực, mỗi người trong một transaction:
-- A và B cùng chạy:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctor WHERE on_call; -- cả hai đều thấy 2
-- A
UPDATE doctor SET on_call = false WHERE id = 1;
-- B
UPDATE doctor SET on_call = false WHERE id = 2;
COMMIT; -- A: OK
COMMIT; -- B: OK -> không còn ai trực!
Hai transaction ghi vào hai dòng khác nhau, nên không có xung đột ghi nào bị phát hiện. Nếu đổi sang SERIALIZABLE, PostgreSQL dùng cơ chế Serializable Snapshot Isolation (SSI). SSI theo dõi các phụ thuộc đọc–ghi giữa các transaction và hủy một trong hai:
ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt.
HINT: The transaction might succeed if retried.
Lỗi có thể xuất hiện ở câu UPDATE hoặc lúc COMMIT. Mã lỗi vẫn là 40001.
Lưu ý:
SERIALIZABLEkhông khóa thêm dòng nào. Nó chỉ phát hiện xung đột và hủy transaction. Với SSI, PostgreSQL có thể hủy nhầm cả những transaction không thực sự xung đột. Vì vậy, khi tải ghi đồng thời cao, tỉ lệ retry có thể tăng đáng kể.
7. Dùng isolation level trong Spring Boot
Thêm các dependency sau:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
Cách đơn giản nhất là khai báo isolation level trên @Transactional:
@Service
public class ReportService {
private final JdbcTemplate jdbc;
public ReportService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public BigDecimal totalBalance() {
BigDecimal total = jdbc.queryForObject(
"SELECT sum(balance) FROM account", BigDecimal.class);
Long count = jdbc.queryForObject(
"SELECT count(*) FROM account", Long.class);
// cả hai truy vấn dùng chung một snapshot -> số liệu nhất quán
return total;
}
}
Spring gọi Connection.setTransactionIsolation(...) trước khi bắt đầu transaction. Driver PostgreSQL sẽ chuyển nó thành SET TRANSACTION ISOLATION LEVEL ....
7.1. Retry khi gặp serialization failure
Retry phải bao bên ngoài transaction. Retry bên trong là vô nghĩa, vì transaction đã bị đánh dấu hỏng. Dùng TransactionTemplate giúp tránh bẫy self-invocation của @Transactional:
@Service
public class OnCallService {
private static final int MAX_ATTEMPTS = 3;
private final JdbcTemplate jdbc;
private final TransactionTemplate tx;
public OnCallService(JdbcTemplate jdbc, PlatformTransactionManager tm) {
this.jdbc = jdbc;
this.tx = new TransactionTemplate(tm);
this.tx.setIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE);
}
public void goOffCall(long doctorId) {
for (int attempt = 1; ; attempt++) {
try {
tx.executeWithoutResult(status -> doGoOffCall(doctorId));
return;
} catch (ConcurrencyFailureException e) {
if (attempt >= MAX_ATTEMPTS) {
throw e;
}
}
}
}
private void doGoOffCall(long doctorId) {
Integer onCall = jdbc.queryForObject(
"SELECT count(*) FROM doctor WHERE on_call", Integer.class);
if (onCall == null || onCall < 2) {
throw new IllegalStateException("Phải còn ít nhất một bác sĩ trực");
}
jdbc.update("UPDATE doctor SET on_call = false WHERE id = ?", doctorId);
}
}
Đoạn code trên hoạt động như sau:
setIsolationLevel(ISOLATION_SERIALIZABLE)áp dụng cho mọi lần gọiexecuteWithoutResult.- Spring dịch SQLSTATE
40001(và deadlock40P01) thành các lớp con củaConcurrencyFailureException. Nhờ đó bạn không phải đọc mã lỗi của driver. - Ở lần retry, transaction mới lấy snapshot mới. Nó thấy bác sĩ kia đã nghỉ, nên ném
IllegalStateException. Ràng buộc nghiệp vụ được giữ đúng.
Lưu ý: Spring Boot 3 tự cấu hình
JdbcTransactionManager. Lớp này dịch cả lỗi xảy ra lúc commit thànhDataAccessException. Nếu bạn tự khai báo beanDataSourceTransactionManager, lỗi commit sẽ thànhTransactionSystemExceptionvà vòng retry trên sẽ không bắt được.
8. Kiểm thử write skew với Testcontainers
Để tái hiện chính xác thứ tự xen kẽ, ta dùng trực tiếp hai Connection JDBC trong cùng một thread. Cách này không phụ thuộc vào thời điểm chạy của thread. Thêm dependency test:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Lớp test:
@Testcontainers
class WriteSkewTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@BeforeEach
void setUp() throws SQLException {
try (Connection c = connect(); Statement st = c.createStatement()) {
st.execute("DROP TABLE IF EXISTS doctor");
st.execute("CREATE TABLE doctor (id BIGINT PRIMARY KEY, name TEXT, on_call BOOLEAN)");
st.execute("INSERT INTO doctor VALUES (1, 'Lan', true), (2, 'Minh', true)");
}
}
@Test
void repeatableReadAllowsWriteSkew() throws SQLException {
runConcurrently(Connection.TRANSACTION_REPEATABLE_READ);
assertThat(countOnCall()).isZero();
}
@Test
void serializableRejectsWriteSkew() throws SQLException {
assertThatThrownBy(() -> runConcurrently(Connection.TRANSACTION_SERIALIZABLE))
.isInstanceOf(SQLException.class)
.extracting(e -> ((SQLException) e).getSQLState())
.isEqualTo("40001");
assertThat(countOnCall()).isEqualTo(1);
}
private void runConcurrently(int level) throws SQLException {
try (Connection a = begin(level); Connection b = begin(level)) {
assertThat(countOnCall(a)).isEqualTo(2);
assertThat(countOnCall(b)).isEqualTo(2);
offCall(a, 1);
a.commit();
offCall(b, 2);
b.commit();
}
}
private Connection begin(int level) throws SQLException {
Connection c = connect();
c.setAutoCommit(false);
c.setTransactionIsolation(level);
return c;
}
private Connection connect() throws SQLException {
return DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
}
private void offCall(Connection c, long id) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(
"UPDATE doctor SET on_call = false WHERE id = ?")) {
ps.setLong(1, id);
ps.executeUpdate();
}
}
private int countOnCall(Connection c) throws SQLException {
try (Statement st = c.createStatement();
ResultSet rs = st.executeQuery("SELECT count(*) FROM doctor WHERE on_call")) {
rs.next();
return rs.getInt(1);
}
}
private int countOnCall() throws SQLException {
try (Connection c = connect()) {
return countOnCall(c);
}
}
}
Kết quả mong đợi của hai test:
repeatableReadAllowsWriteSkew -> PASSED (cả hai commit, 0 bác sĩ trực)
serializableRejectsWriteSkew -> PASSED (B bị hủy với 40001, còn 1 bác sĩ trực)
Test thứ hai không khẳng định lỗi xảy ra ở UPDATE hay COMMIT của B. Nó chỉ kiểm tra mã 40001, vì thời điểm SSI phát hiện xung đột có thể thay đổi giữa các phiên bản PostgreSQL.
9. Lỗi hay gặp và khi nào không nên dùng
- Nâng level nhưng không retry. Ở
REPEATABLE READvàSERIALIZABLE, lỗi40001là hành vi bình thường chứ không phải bug. Không có retry, người dùng sẽ nhận lỗi 500 ngẫu nhiên. - Retry bên trong
@Transactional. Transaction đã hỏng phải được rollback rồi bắt đầu lại từ đầu. Hãy đặt retry ở tầng gọi bên ngoài. - Gọi API bên ngoài trong transaction có retry. Email hoặc lệnh thanh toán có thể bị gửi nhiều lần. Hãy thực hiện side effect sau khi commit thành công.
- Đặt
SERIALIZABLEcho toàn hệ thống “cho chắc”. Cách này gây chi phí theo dõi SSI và nhiều retry. Chỉ dùng cho những đoạn có ràng buộc trải trên nhiều dòng. - Trộn level trong cùng một nghiệp vụ. SSI chỉ bảo vệ khi mọi transaction liên quan đều chạy
SERIALIZABLE. Một transactionREAD COMMITTEDghi vào cùng bảng vẫn có thể phá vỡ ràng buộc. - Transaction
REPEATABLE READchạy quá lâu. Snapshot cũ ngănVACUUMdọn các phiên bản dòng đã chết, khiến bảng phình to. Hãy giữ transaction ngắn.
Lưu ý: Với ràng buộc biểu diễn được bằng schema, chẳng hạn
CHECK,UNIQUEhay exclusion constraint, hãy ưu tiên dùng constraint. Nó rẻ hơn và đáng tin hơn việc nâng isolation level.
10. Kết luận
PostgreSQL mặc định chạy READ COMMITTED. Level này đủ dùng cho phần lớn ứng dụng, miễn là bạn viết cập nhật nguyên tử hoặc dùng SELECT ... FOR UPDATE để tránh lost update.
REPEATABLE READ cho một snapshot nhất quán suốt transaction, phù hợp cho báo cáo. SERIALIZABLE chặn được cả write skew. Cả hai level này đều yêu cầu ứng dụng retry khi gặp SQLSTATE 40001.
Trong Spring Boot, bạn khai báo level qua @Transactional(isolation = ...) hoặc TransactionTemplate, bắt ConcurrencyFailureException ở bên ngoài transaction và kiểm chứng hành vi bằng Testcontainers với thứ tự thao tác xác định.