Bài 5: Database Error Translation & Retry Mechanism
Ở các bài học trước, chúng ta đã tối ưu hóa kết nối, quản lý Transaction an toàn và kiểm soát thời gian thực thi bằng Context Timeout. Tuy nhiên, một hệ thống Backend chuẩn Enterprise không chỉ cần chạy nhanh mà còn phải chống chọi được với các sự cố ngoại cảnh (mất kết nối mạng tạm thời, cơ sở dữ liệu quá tải) và báo lỗi thông minh cho tầng nghiệp vụ.
Trong bài học này, chúng ta sẽ giải quyết hai bài toán cốt lõi:
-
Database Error Translation (Chuẩn hóa lỗi): Biến các mã lỗi mã hóa thô kệch từ database (như
pq: duplicate key value violates unique constraint) thành các Business Error tường minh. -
Retry Mechanism (Cơ chế thử lại thông minh): Tự động thử lại các câu lệnh truy vấn khi gặp sự cố tạm thời bằng thuật toán Exponential Backoff.
1. Vấn đề của việc trả lỗi Database thô ra tầng Service
Khi tầng Repository tương tác trực tiếp với cơ sở dữ liệu (ví dụ PostgreSQL), lỗi trả về thường là lỗi đặc thù của driver:
-
Trùng khóa chính (Duplicate Key):
pq: duplicate key value violates unique constraint "users_email_key" -
Khóa ngoại không tồn tại (Foreign Key Violation):
pq: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey"
Hệ quả: Nếu tầng Service hoặc Controller của bạn phải đi viết câu lệnh strings.Contains(err.Error(), "violates unique constraint") để kiểm tra lỗi, mã nguồn sẽ trở nên cực kỳ bẩn, dính chặt (tightly coupled) vào một loại database cụ thể. Nếu bạn đổi sang MySQL hay SQLite, toàn bộ logic bắt lỗi sẽ vỡ vụn.
2. Giải pháp: Database Error Translation Layer
Chúng ta áp dụng tư tưởng Error Translation bằng cách tạo ra một tầng dịch thuật lỗi ngay tại Wrapper. Tầng này sẽ đọc mã lỗi gốc của database (ví dụ mã SQLSTATE của PostgreSQL) và bọc chúng thành các lỗi chuẩn của domain ứng dụng.
A. Định nghĩa các Domain Errors chuẩn hóa
Tạo file internal/domain/errors.go:
Go
package domain
import "errors"
var (
ErrNotFound = errors.New("dữ liệu không tồn tại")
ErrAlreadyExists = errors.New("dữ liệu đã tồn tại trong hệ thống")
ErrForeignKey = errors.New("vi phạm ràng buộc dữ liệu liên quan")
ErrConflict = errors.New("xung đột dữ liệu (concurrency conflict)")
)
B. Viết hàm Translator cho PostgreSQL
Sử dụng thư viện driver PostgreSQL ([github.com/lib/pq](https://github.com/lib/pq)) để bóc tách mã lỗi SQLSTATE:
Go
package database
import (
"errors"
"github.com/lib/pq"
"your-project/internal/domain"
)
// TranslateSQLError dịch các lỗi thô của database sang Domain Error thuần túy
func TranslateSQLError(err error) error {
if err == nil {
return nil
}
// Kiểm tra xem có phải lỗi từ driver PostgreSQL không
var pqErr *pq.Error
if errors.As(err, &pqErr) {
switch pqErr.Code.Name() {
case "unique_violation": // Mã lỗi 23505
return domain.ErrAlreadyExists
case "foreign_key_violation": // Mã lỗi 23503
return domain.ErrForeignKey
case "serialization_failure", "deadlock_detected": // Mã lỗi 40001, 40P01
return domain.ErrConflict
}
}
// Xử lý các lỗi chuẩn chung
if err.Error() == "sql: no rows in result set" {
return domain.ErrNotFound
}
return err
}
Tại tầng Repository, mỗi khi nhận lỗi từ driver, bạn chỉ việc bọc qua hàm TranslateSQLError:
Go
func (r *UserRepository) Create(ctx context.Context, user *User) error {
query := `INSERT INTO users (email, name) VALUES ($1, $2)`
_, err := r.db.ExecContext(ctx, query, user.Email, user.Name)
if err != nil {
return TranslateSQLError(err) // Trả về domain.ErrAlreadyExists sạch sẽ
}
return nil
}
Giờ đây, tầng Service chỉ cần kiểm tra errors.Is(err, domain.ErrAlreadyExists) mà không cần quan tâm bên dưới dùng cơ sở dữ liệu gì!
3. Retry Mechanism với Exponential Backoff
Trên môi trường Cloud / Microservices phân tán, các sự cố chớp nhoáng (ví dụ: mạng giữa app và database rớt 1 giây, hoặc database bị nghẽn tạm thời do quá tải CPU) là chuyện thường ngày. Nếu câu lệnh query thất bại ngay lập tức mà không thử lại, trải nghiệm người dùng sẽ rất tệ.
Tuy nhiên, không phải lỗi nào cũng được retry. Bạn chỉ được phép retry các lỗi mang tính tạm thời (Transient Errors) như lỗi kết nối mạng, timeout, hoặc deadlock. Tuyệt đối không retry các lỗi nghiệp vụ như ErrAlreadyExists hay ErrNotFound.
Thuật toán Exponential Backoff:
Thay vì retry liên tục dồn dập (làm database chết hẳn), ta sẽ tăng dần thời gian chờ sau mỗi lần thử thất bại (Ví dụ: Lần 1 chờ 100ms, lần 2 chờ 200ms, lần 3 chờ 400ms...).
Triển khai Wrapper Retry Helper:
Tạo file pkg/database/retry.go:
Go
package database
import (
"context"
"errors"
"time"
)
// IsTransientError định nghĩa những lỗi nào được phép thử lại
func IsTransientError(err error) bool {
if err == nil {
return false
}
// Ví dụ: Kiểm tra nếu là lỗi kết nối hoặc conflict/deadlock
var pqErr *pq.Error
if errors.As(err, &pqErr) {
// Mã lỗi 40001 (serialization_failure), 40P01 (deadlock) hoặc lỗi kết nối
if pqErr.Code.Name() == "serialization_failure" || pqErr.Code.Name() == "deadlock_detected" {
return true
}
}
// Hoặc các lỗi timeout do context
if errors.Is(err, context.DeadlineExceeded) {
return true
}
return false
}
// DoWithRetry thực thi hàm truyền vào kèm cơ chế Exponential Backoff
func DoWithRetry(ctx context.Context, attempts int, sleep time.Duration, fn func() error) error {
for i := 0; i < attempts; i++ {
err := fn()
if err == nil {
return nil // Thành công, thoát hàm
}
// Nếu không phải lỗi tạm thời, hoặc đã hết số lần thử -> trả về lỗi luôn
if !IsTransientError(err) || i == attempts-1 {
return err
}
// Chờ theo thời gian backoff trước khi thử lại
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(sleep):
}
// Nhân đôi thời gian chờ cho lần tiếp theo (Exponential Backoff)
sleep *= 2
}
return errors.New("vượt quá số lần thử lại cho phép")
}
Ứng dụng tại tầng Service hoặc Repository:
Go
func (s *OrderService) CreateOrder(ctx context.Context, order *Order) error {
// Tự động thử lại tối đa 3 lần nếu gặp lỗi deadlock hoặc transient error
return database.DoWithRetry(ctx, 3, 100*time.Millisecond, func() error {
return s.orderRepo.Create(ctx, order)
})
}
Tổng kết
Bằng cách tích hợp Database Error Translation, chúng ta đã bóc tách và chuẩn hóa toàn bộ mã lỗi database phức tạp thành các Domain Error sạch sẽ. Kết hợp cùng Retry Mechanism (Exponential Backoff), hệ thống của bạn giờ đây đã có khả năng tự phục hồi mạnh mẽ trước các sự cố chớp nhoáng trên môi trường Production.
Tuy nhiên, làm thế nào để quan sát toàn bộ các câu lệnh SQL đang chạy, đo lường thời gian thực thi và tự động phát hiện các câu lệnh chạy chậm (Slow Queries) để tối ưu hóa?
Đó chính là nội dung của bài học cuối cùng trong series: Query Performance Metrics, Slow Query Logging & Tracing.
All rights reserved