+1

Bài 3: Quản lý Transaction & Unit of Work Pattern trong Golang

Ở Bài 2, chúng ta đã thiết lập thành công kết nối cơ sở dữ liệu và tối ưu hóa Connection Pool. Tuy nhiên, một bài toán phức tạp hơn luôn xuất hiện trên thực tế: Làm thế nào để thực thi một chuỗi các thao tác thay đổi dữ liệu trên nhiều bảng khác nhau nhưng phải đảm bảo tính nguyên tử (Atomicity - ACID)? (Ví dụ: Tạo đơn hàng thành công thì bắt buộc phải trừ kho sản phẩm tương ứng; nếu trừ kho lỗi, toàn bộ đơn hàng phải được hoàn tác/rollback).

Trong bài học này, chúng ta sẽ mổ xẻ cách quản lý Transaction trong Golang và hiện thực hóa mô hình Transaction Manager / Unit of Work Pattern thông qua Database Wrapper một cách sạch sẽ nhất.

1. Vấn đề kinh điển khi quản lý Transaction trong Go

Trong chuẩn database/sql, một Transaction được đại diện bởi đối tượng *sql.Tx. Trong khi đó, các câu lệnh thông thường chạy trên *sql.DB.

Khi thiết kế theo Repository Pattern sạch sẽ, các phương thức của bạn thường nhận context.Context thay vì nhận *sql.Tx.

Go

// TỆ: Nếu ép Repository nhận *sql.Tx để chạy transaction, bạn làm lộ tầng chi tiết cơ sở dữ liệu lên Service
func (r *OrderRepo) Create(tx *sql.Tx, order *Order) error { ... }

Những hệ quả tồi tệ nếu xử lý thủ công không khéo:

  1. Lộ tầng hạ tầng (Leaking Abstraction): Tầng Service nghiệp vụ buộc phải biết đến sự tồn tại của *sql.Tx, làm mất ý nghĩa của Repository Pattern.

  2. Rối rắm mã nguồn: Việc gọi Begin(), Commit(), và Rollback() nằm rải rác trong các khối lệnh if err != nil khiến code dài dòng, dễ xảy ra tình trạng quên rollback dẫn đến treo kết nối database (Connection Leak).

2. Giải pháp: Context-Based Transaction Manager (Unit of Work)

Để giải quyết triệt để vấn đề trên, chúng ta áp dụng tư tưởng của Unit of Work Pattern kết hợp với Context Propagation.

Cơ chế hoạt động:

  1. Chúng ta tạo ra một Transaction Manager chuyên trách việc mở (Begin), commit (Commit) hoặc rollback (Rollback) transaction.

  2. Khi một transaction được bắt đầu, đối tượng *sql.Tx sẽ được nhúng ngầm bên trong context.Context.

  3. Các Repository bên dưới khi thực hiện câu lệnh SQL sẽ tự động kiểm tra xem trong context có đang chứa *sql.Tx nào không:

    • Có: Sử dụng *sql.Tx đó để chạy truy vấn (nằm trong transaction chung).

    • Không: Sử dụng *sql.DB bình thường (chạy độc lập).

3. Hiện thực hóa Transaction Manager trong Golang

Bước 1: Quản lý khóa Context an toàn

Tạo file pkg/database/tx.go:

Go

package database

import (
	"context"
	"database/sql"
	"fmt"
)

// Định nghĩa kiểu khóa riêng tư để tránh xung đột trong context
type contextKey string
const txKey contextKey = "db_transaction_key"

// TxManager định nghĩa giao diện quản lý transaction chung cho ứng dụng
type TxManager interface {
	Do(ctx context.Context, fn func(ctx context.Context) error) error
}

type sqlTxManager struct {
	db *sql.DB
}

func NewTxManager(db *sql.DB) TxManager {
	return &sqlTxManager{db: db}
}

// Do bọc toàn bộ logic nghiệp vụ trong một transaction khép kín
func (m *sqlTxManager) Do(ctx context.Context, fn func(ctx context.Context) error) error {
	// Kiểm tra xem đã có transaction nào chạy trước đó chưa (Nested transaction nếu cần)
	if _, ok := ctx.Value(txKey).(*sql.Tx); ok {
		return fn(ctx) // Đã nằm trong transaction, tiếp tục chạy
	}

	// 1. Bắt đầu Transaction mới
	tx, err := m.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
	if err != nil {
		return fmt.Errorf("không thể khởi tạo transaction: %w", err)
	}

	// 2. Gắn transaction vào context
	txCtx := context.WithValue(ctx, txKey, tx)

	// 3. Thực thi khối lệnh nghiệp vụ truyền vào qua hàm callback (fn)
	if err := fn(txCtx); err != nil {
		// Nếu có lỗi, tiến hành Rollback toàn bộ
		if rbErr := tx.Rollback(); rbErr != nil {
			return fmt.Errorf("lỗi rollback transaction (nguyên nhân gốc: %v): %v", err, rbErr)
		}
		return err
	}

	// 4. Nếu thành công hoàn toàn, tiến hành Commit
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("lỗi commit transaction: %w", err)
	}

	return nil
}

// Helper để các Repository trích xuất *sql.Tx hoặc fallback về *sql.DB
func Executor(ctx context.Context, db *sql.DB) interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
} {
	if tx, ok := ctx.Value(txKey).(*sql.Tx); ok {
		return tx
	}
	return db
}

4. Ứng dụng thực tế tại tầng Repository & Service

Nhờ có TxManager và hàm Executor, các Repository của bạn không cần biết đến sự tồn tại của transaction thủ công nữa, code cực kỳ sạch sẽ:

Sử dụng trong Repository:

Go

func (r *OrderRepo) Create(ctx context.Context, order *Order) error {
	query := `INSERT INTO orders (id, customer_id, total_amount) VALUES ($1, $2, $3)`
	
	// database.Executor tự động chọn dùng tx nếu có trong context, ngược lại dùng db gốc
	exec := database.Executor(ctx, r.db)
	
	_, err := exec.ExecContext(ctx, query, order.ID, order.CustomerID, order.TotalAmount)
	return err
}

Sử dụng tại tầng Service (Orchestration):

Khi cần tạo đơn hàng và trừ kho đồng thời, Service chỉ việc gọi TxManager.Do():

Go

type OrderService struct {
	orderRepo   OrderRepository
	productRepo ProductRepository
	txManager   database.TxManager // Tiêm TxManager vào Service
}

func (s *OrderService) Checkout(ctx context.Context, order *Order) error {
	// Sử dụng TxManager để đảm bảo tính nguyên tử
	return s.txManager.Do(ctx, func(ctx context.Context) error {
		// 1. Tạo đơn hàng
		if err := s.orderRepo.Create(ctx, order); err != nil {
			return err // Trả về lỗi bất kỳ ở đây sẽ tự động kích hoạt Rollback
		}

		// 2. Trừ số lượng tồn kho sản phẩm
		if err := s.productRepo.DecreaseStock(ctx, order.ProductID, order.Quantity); err != nil {
			return err // Lỗi trừ kho -> Rollback cả bước 1 lẫn bước 2
		}

		return nil
	})
}

Tổng kết

Mô hình Context-Based Transaction Manager / Unit of Work giúp chúng ta giải quyết triệt để bài toán quản lý Transaction phức tạp trong các hệ thống phân tầng. Tầng Service hoàn toàn sạch sẽ, không bị bám bẩn bởi mã nguồn quản lý transaction thủ công, trong khi các Repository vẫn đảm bảo tính linh hoạt khi chia sẻ chung một phiên giao dịch cơ sở dữ liệu.

Tuy nhiên, khi gọi các câu lệnh SQL qua lại, điều gì sẽ xảy ra nếu cơ sở dữ liệu bị treo hoặc client ngắt kết nối giữa chừng? Làm thế nào để tự động hủy bỏ query bằng Context Timeout?

Đó chính là nội dung của Bài 4 tiếp theo.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí