Cách khắc phục lỗi 'sql: transaction has already been committed or rolled back' trong Go

cơ bản🔷 Go2026-07-28| Go (Golang) sử dụng package database/sql với bất kỳ SQL driver nào (PostgreSQL, MySQL, SQLite, v.v.)

Error Message

sql: transaction has already been committed or rolled back
#golang#sql#cơ sở dữ liệu#backend

Tại sao lỗi này xảy ra

Trong Go, một transaction handle (*sql.Tx) là một đối tượng sử dụng một lần. Sau khi bạn gọi Commit() hoặc Rollback(), transaction đó kết thúc và kết nối database bên dưới sẽ quay trở lại pool. Nếu bạn cố gắng sử dụng lại handle đó cho Exec, Query, hoặc thậm chí là một lệnh Commit khác, Go sẽ kích hoạt lỗi cụ thể này.

Hãy tưởng tượng nó giống như một hợp đồng giấy. Một khi nó đã được ký kết hoặc bị hủy bỏ, bạn không thể thêm các điều khoản mới vào đó. Bạn phải bắt đầu một transaction hoàn toàn mới cho bất kỳ công việc tiếp theo nào.

Các nguyên nhân gốc rễ phổ biến

Việc debug lỗi này thường liên quan đến việc truy vết vòng đời của biến tx. Hầu hết các lập trình viên gặp phải vấn đề này theo một trong ba cách sau:

1. Xung đột thứ tự Defer

Đây là nguyên nhân phổ biến nhất. Bạn có thể gọi tx.Commit() ở cuối một hàm thành công, nhưng bạn cũng có một lệnh defer tx.Rollback() ở phía trên. Mặc dù database/sql được thiết kế để biến Rollback() thành một lệnh không thực thi (no-op) sau khi commit thành công, nhưng logic kiểm tra lỗi cụ thể trong khối defer của bạn có thể bắt được tín hiệu sql.ErrTxDone và coi đó là một lỗi.

2. Lỗi logic rẽ nhánh

Các cấu trúc if-else phức tạp thường che giấu các lỗi hoàn tất hai lần (double-completion). Ví dụ, bạn có thể commit bên trong một khối if nhưng quên return ngay lập tức. Sau đó, code tiếp tục thực hiện lệnh gọi database thứ hai hoặc một lệnh Commit() cuối cùng ở cuối hàm, gây ra lỗi ở lần thử thứ hai.

3. Lỗi trong vòng lặp

Nếu bạn đang xử lý 100 dòng dữ liệu trong một transaction duy nhất và dòng thứ 5 kích hoạt Rollback(), thì lần lặp thứ 6 sẽ thất bại. Nó thất bại không phải vì dữ liệu, mà vì transaction mà nó đang cố gắng sử dụng đã kết thúc.

Các giải pháp hiệu quả

Giải pháp 1: Mẫu chuẩn (Idiomatic Pattern)

Cách mạnh mẽ nhất để xử lý transaction là sử dụng defer để đảm bảo an toàn và chỉ commit ở dòng cuối cùng khi thành công. Nếu có bất kỳ lỗi nào xảy ra trước đó, hàm sẽ trả về và defer đảm bảo database không bị treo kết nối.

func adjustBalance(db *sql.DB, userID int, amount float64) error {
    tx, err := db.Begin()
    if err != nil {
        return err
    }

    // Đây là chốt chặn an toàn. Nó sẽ không làm gì nếu tx.Commit() được gọi trước.
    defer tx.Rollback()

    // Ví dụ: Trừ số tiền amount từ tài khoản người dùng
    _, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, userID)
    if err != nil {
        return err
    }

    // Hoàn tất công việc. Sau dòng này, transaction sẽ được đóng.
    return tx.Commit()
}

Giải pháp 2: Rollback im lặng trong Closure

Nếu bạn cần log lỗi trong quá trình rollback nhưng muốn tránh các thông báo gây nhiễu "already committed", hãy sử dụng một closure. Mẫu này bỏ qua lỗi sql.ErrTxDone, vốn là tên nội bộ của thông báo "already committed or rolled back".

defer func() {
    err := tx.Rollback()
    if err != nil && err != sql.ErrTxDone {
        log.Printf("Lỗi rollback thực tế: %v", err)
    }
}()

Giải pháp 3: Bảo vệ logic rẽ nhánh

Luôn đi kèm các lệnh gọi Commit() với một câu lệnh return. Nếu hàm của bạn quá dài (ví dụ: trên 50 dòng), hãy chia nhỏ logic nghiệp vụ thành các hàm con. Truyền đối tượng tx vào các hàm con đó để hàm chính vẫn là "chủ sở hữu" duy nhất của vòng đời transaction.

Cách kiểm tra bản sửa lỗi

Đừng chỉ giả định rằng nó đã được sửa. Hãy kiểm tra cụ thể hai kịch bản sau:

- **Trường hợp thành công (Happy Path):** Chạy một thao tác thành công và kiểm tra database của bạn. Đảm bảo `Commit()` đã xảy ra và không có lỗi nào xuất hiện trong console.
- **Trường hợp thất bại (Failure Path):** Cố tình tạo ra một lỗi database (như vi phạm ràng buộc duy nhất - unique constraint violation). Xác nhận rằng `Rollback()` đã được kích hoạt và log của bạn hiển thị lỗi database *gốc*, chứ không phải lỗi phụ "transaction already committed".

Những lưu ý quan trọng

- **Sử dụng một lần:** Coi mỗi `*sql.Tx` như một công cụ dùng một lần. Sau khi hoàn thành, hãy loại bỏ nó.
- **Defer sớm:** Đặt lệnh `defer tx.Rollback()` ngay sau `db.Begin()` để ngăn rò rỉ kết nối khi xảy ra panic.
- **Kiểm tra ErrTxDone:** Loại bỏ lỗi cụ thể này trong log của bạn để giữ cho log production được sạch sẽ.
- **Giữ transaction ngắn gọn:** Cố gắng để các transaction chỉ kéo dài vài mili giây thay vì vài giây, nhằm tránh khóa các dòng dữ liệu và rơi vào các bẫy logic.

Related Error Notes