Tới nội dung chính
caltalys.dev
Proxy

Ném exception mà dữ liệu vẫn được commit

Cái vỏ có đó, lời gọi có đi qua nó, mà vẫn không rollback: bảng luật mặc định của Spring chỉ rollback với unchecked exception.

Đồ hình trừu tượng của tập 2.1 trong Quyển Một
Caltalys 9 phút đọc

Thằng em nhắn, kèm theo một đoạn phân tích - dạo này nó không gửi mỗi triệu chứng nữa, nó gửi cả giả thuyết:

thang em Anh, em có method đặt hàng. Giữa chừng hết hàng thì em ném exception, và em đinh ninh transaction sẽ rollback. Nhưng DB vẫn ghi. Em đã kiểm rồi: có proxy, gọi từ ngoài qua bean inject, không this, không final. Cái vỏ có đó và lời gọi có đi qua nó. Vậy mà nó không rollback. Em bắt đầu nghi là vỏ có đó nhưng… không làm gì?

Code của nó:

public class InsufficientStockException extends Exception { ... }

@Service
public class OrderService {

    @Transactional
    public void placeOrder(Order order) throws InsufficientStockException {
        repo.insertOrder(order);                    // đã chạy
        if (stock.isOut(order)) {
            throw new InsufficientStockException(); // ném đàng hoàng
        }
        repo.insertPayment(order);
    }
}

Exception ném ra thật. Bay tới tận controller thật. Và cái order dở dang vẫn nằm trong DB. Không warning, không một dòng log nào bảo “tao vừa commit đấy nhé”. Commit lặng lẽ như chưa có chuyện gì.

Nó nghi đúng hướng nhưng sai một chữ. Cái vỏ có làm việc. Làm đúng việc của nó. Chỉ là việc của nó không giống điều thằng em tưởng.

Mùa trước, chúng ta dành bốn bài để trả lời cái vỏ là ai. Mùa này là câu hỏi tiếp theo: trong khoảnh khắc chặn lời gọi, cái vỏ làm gì? Với @Transactional, người đứng trong vỏ tên là TransactionInterceptor - bạn đã gặp cái tên này rồi, ở bài self-invocation, khi mình bảo bạn đặt breakpoint vào nó. Hôm nay ta mở hẳn nó ra.

Việc nó làm, kể gọn trong bốn bước. Một: mở (hoặc mượn - chuyện “mượn” để bài sau) một transaction. Hai: gọi vào method của bạn, rồi đứng chờ ở cửa ra. Ba: nhìn xem cái gì bay ra từ trong đó. Bốn: quyết định - commit hay rollback.

Đọc kỹ bước ba. Cái vỏ không nhìn thấy code của bạn chạy. Nó không biết if nào trượt, insert nào đau. Nó đứng ngoài, và toàn bộ thông tin nó có về những gì xảy ra bên trong gói gọn trong đúng một thứ: vật thể bay ra ở cửa. Return value bay ra - mọi chuyện ổn. Exception bay ra - à, mà khoan. Exception loại nào?

Đây là điều ít ai đọc tới trong docs, và là toàn bộ bug hôm nay: với cái vỏ, exception không phải cứ là exception. Nó có một bảng luật, và bảng luật mặc định ghi thế này: RuntimeExceptionError bay ra - rollback. Còn checked exception bay ra - commit. Commit như thường, commit đàng hoàng, commit đúng luật.

InsufficientStockException extends Exception. Checked. Bay qua mặt cái vỏ, cái vỏ gật đầu, và commit.

Không có bug ở đâu cả. Không phải vỏ ngủ quên. Nó tra bảng luật, thấy “loại này: cho qua”, và làm đúng như được viết.

Nhưng khoan - luật gì kỳ vậy? Sao lỗi lại được chia loại để cái thì hủy, cái thì lưu?

Vì trong triết lý mà Spring thừa kế (từ thời EJB, và giữ nguyên tới nay), hai loại exception mang hai nghĩa khác nhau. RuntimeExceptiontai nạn: NPE, chia cho 0, ràng buộc DB nổ - những thứ không ai lường trước, và khi tai nạn xảy ra thì mọi dấu vết dở dang phải được dọn sạch. Còn checked exception - thứ bạn phải khai throws, phải bắt buộc xử lý - được coi là kết quả có tên: “hết hàng”, “số dư không đủ”, “file không tồn tại”. Một nhánh nghiệp vụ, chẳng qua nhánh đó buồn. Mà đã là kết quả nghiệp vụ thì những gì ghi trước đó có thể là cố ý - biết đâu bạn muốn giữ cái order với trạng thái thất bại? Nên vỏ không dám tự tiện hủy.

Bạn không cần đồng ý với triết lý đó - nửa thế giới Java cũng không đồng ý, và đó là lý do exception nghiệp vụ ngày nay đa số extends RuntimeException. Nhưng bạn cần biết nó tồn tại, vì nó đang chạy trong mọi app Boot bạn viết, ngay lúc này, im lặng.

Fix thì một dòng, và giờ bạn hiểu vì sao dòng đó chạy - nó không “bật rollback”, nó sửa bảng luật:

@Transactional(rollbackFor = Exception.class)

Hoặc gọn hơn về dài hạn: cho exception nghiệp vụ của bạn extends RuntimeException, và cả team thống nhất một lần cho xong.

Thằng em đọc tới đây chắc đã gật gù. Nhưng câu chuyện hôm nay còn một nửa, và nửa sau mới là thứ khiến người ta bạc tóc - vì nó là mặt ngược của nửa đầu.

Nửa đầu: exception bay qua vỏ mà vỏ cho qua. Nửa sau: exception không bay tới vỏ - mà transaction vẫn chết, kèm một thông báo lỗi nhìn như trò đùa.

Chuyện xảy ra khi codebase lớn lên một chút. placeOrder giờ gọi sang một bean khác:

@Service
public class OrderService {

    @Autowired NotificationService notifier;   // bean khác, cũng @Transactional

    @Transactional
    public void placeOrder(Order order) {
        repo.insertOrder(order);
        try {
            notifier.notifyWarehouse(order);   // thằng này ném RuntimeException
        } catch (Exception e) {
            log.warn("kho báo lỗi, kệ, đơn vẫn phải ghi");   // nuốt gọn
        }
        repo.insertPayment(order);   // chạy tiếp bình thường
    }
}

Ý đồ rất người: thông báo kho chỉ là phụ, nó chết thì kệ nó, đơn hàng vẫn phải ghi. Nên catch, log một dòng, đi tiếp. Code chạy hết method, êm ru, không exception nào thoát ra ngoài.

Và ở cửa ra, khi cái vỏ commit, nó nổ:

UnexpectedRollbackException: Transaction silently rolled back
because it has been marked as rollback-only

Đọc thông báo đó lần đầu, ai cũng có chung một phản ứng: rolled back? Ai rollback? Tao có ném gì ra đâu - tao còn catch cẩn thận rồi mà? Exception thì bị nuốt từ đời nào, mà transaction vẫn chết, và thứ chết cuối cùng lại là chính cái commit của bạn.

Đây là chỗ mình phải kể cho bạn một chi tiết mà từ đầu bài mình giấu: notifyWarehouse cũng có @Transactional, tức lời gọi đó cũng đi qua một cái vỏ khác - vỏ của notifier. Và khi RuntimeException bay ra khỏi method đó, vỏ của notifier đã kịp nhìn thấy nó trước khi cái catch của bạn tóm được. Vỏ tra bảng luật: runtime - rollback. Nhưng - vì một lý do sẽ là toàn bộ bài sau - nó không rollback ngay được. Thay vào đó, nó cắm một lá cờ lên transaction: rollback-only. Từ giây phút đó, transaction này đã bị tuyên án. Không gỡ được. Mọi thứ chạy sau - cái catch của bạn, dòng log “kệ”, cái insertPayment - đều chạy trên một transaction đã chết mà chưa ai báo tang. Đến khi vỏ ngoài cùng định commit, nó thấy lá cờ, và thay vì commit lặng lẽ nó ném cho bạn cái exception “silently rolled back” kia - có lẽ là cái tên mỉa mai nhất trong cả Spring.

Ghép hai nửa lại, chúng khớp nhau đến lạnh người. Nửa đầu: lỗi có thật nhưng thuộc loại vỏ cho qua - commit. Nửa sau: lỗi đã được bạn xử lý xong nhưng trót bay qua một cái vỏ khác trước - rollback. Trong cả hai, số phận transaction không nằm ở chỗ chuyện gì đã xảy ra trong code của bạn. Nó nằm ở chỗ cái gì bay qua vỏ, và vỏ nào thấy nó trước.

Catch không cứu được thứ đã bị nhìn thấy. Và ném cũng không giết được thứ vỏ không thèm chấp.

Muốn tự kiểm chứng cả bài trong một phút: đặt breakpoint tại TransactionAspectSupport.completeTransactionAfterThrowing() - đứng đúng cửa ra của vỏ - rồi ném lần lượt một checked và một runtime, xem nhánh nào được rẽ vào với từng loại. Sau đó tái hiện kịch bản catch-nuốt và nhìn UnexpectedRollbackException nổ ở commit, cách xa nơi lỗi thật hàng chục dòng.

Còn nguyên lý, thứ sống lâu hơn rollbackFor: với một kẻ đứng giữa, exception không phải là lỗi - nó là tín hiệu điều khiển. Và tín hiệu thì có giao thức: loại nào nghĩa là gì, ai được thấy trước, thấy rồi có rút lại được không. HTTP status code, exit code của process, signal của OS - cùng một luật. Bạn không cãi được giao thức bằng cách xử lý lỗi bên trong nhà; kẻ đứng giữa chỉ tin những gì đi qua cổng của nó.

Thằng em đọc xong, nhắn:

Với một kẻ đứng giữa, exception không phải là lỗi - nó là tín hiệu điều khiển.

thang em Vậy rollback chưa bao giờ là chuyện có lỗi hay không. Nó là chuyện cái vỏ nhìn thấy gì bay ngang qua.

Đúng rồi đó. Nhưng còn một câu em chưa hỏi mà lẽ ra phải hỏi: cái lá cờ của vỏ notifier - sao nó cắm được lên transaction của mày? Hai method, hai bean, hai cái vỏ, hai chữ @Transactional - mà sao chỉ có một transaction để mà chết chung?

Giữ câu đó. Nó là toàn bộ bài sau. 😃

Tài nguyên tập 7

Cheat-sheet tập 7 Toàn bộ cơ chế trên một trang - in ra hoặc lưu về máy được. Source demo - tập 2.1JDK 17. mvn spring-boot:runEpisode21Runner in khối [EP 2.1]: đếm số user còn lại sau khi ném checked exception, rồi tới chỗ outer nuốt lỗi của inner và nổ ở cửa ra.

Vị trí trong Lớp Trung Gian Vô Hình

Bài này trả lời

  • Ném checked exception mà giao dịch vẫn commit
  • UnexpectedRollbackException ở một method trông chẳng liên quan
  • @Transactional không mở giao dịch, dữ liệu vẫn ghi từng phần
Bản in
Chia sẻ: Facebook X LinkedIn Email
Nhận bài mới qua email
Powered by follow.it