+1

Bài 4: Context Propagation, Query Timeout & Statement Caching

Ở Bài 3, chúng ta đã làm chủ nghệ thuật quản lý Transaction và Unit of Work sạch sẽ thông qua context.Context. Tiếp nối mạch kiến trúc đó, bài học hôm nay sẽ đi sâu vào hai kỹ thuật tối ưu hóa cốt lõi khi thao tác với cơ sở dữ liệu trên môi trường Production:

  1. Context Propagation & Query Timeout: Ngăn chặn các câu lệnh SQL chạy treo vô tận (Slow queries / Deadlocks) làm nghẽn toàn bộ Connection Pool.

  2. Prepared Statement & Caching: Tối ưu hóa hiệu năng thực thi câu lệnh SQL lặp đi lặp lại hàng triệu lần.

1. Vấn đề của việc thiếu Query Timeout

Trong các hệ thống phân tán, một sự cố kinh điển xảy ra khi cơ sở dữ liệu gặp tải cao hoặc bảng dữ liệu bị thiếu Index: câu lệnh SQL bỗng nhiên mất tới 10, 20 giây để thực thi thay vì vài mili-giây.

Nếu không cài đặt giới hạn thời gian (Timeout), ứng dụng của bạn sẽ:

  • Giữ chặt kết nối trong Connection Pool suốt 20 giây đó.

  • Khi có hàng ngàn request cùng dồn đến, toàn bộ MaxOpenConns sẽ bị cạn kiệt, khiến hệ thống sập hoàn toàn (Cascading Failure).

  • Thậm chí khi người dùng đã tắt trình duyệt hoặc ngắt kết nối (Client Disconnection), ứng dụng vẫn cặm cụi chạy câu lệnh SQL nặng nhọc đó ở phía backend một cách lãng phí.

2. Triển khai Context Propagation & Timeout trong Database Wrapper

Go hỗ trợ sẵn cơ chế truyền tải thông tin hủy bỏ thông qua context.Context. Tất cả các hàm thực thi truy vấn trong database/sql đều có các phiên bản kết thúc bằng hậu tố Context (QueryContext, ExecContext, QueryRowContext).

A. Xây dựng Wrapper hỗ trợ Timeout tự động tại tầng Repository

Thay vì để mỗi lập trình viên tự nhớ gán timeout thủ công, chúng ta có thể chuẩn hóa việc này ngay tại tầng cấu hình hoặc thông qua các wrapper helper:

Go

package repository

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

type ProductRepository struct {
	db          *sql.DB
	defaultTimeout time.Duration
}

func NewProductRepository(db *sql.DB, timeout time.Duration) *ProductRepository {
	return &ProductRepository{
		db:             db,
		defaultTimeout: timeout, // Ví dụ: 3 giây cho mọi query thông thường
	}
}

func (r *ProductRepository) FindByID(ctx context.Context, id int64) (*Product, error) {
	// 1. Kiểm tra xem context hiện tại đã có deadline/timeout chưa. 
	// Nếu chưa, tự động bọc một timeout mặc định để bảo vệ hệ thống.
	if _, ok := ctx.Deadline(); !ok {
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, r.defaultTimeout)
		defer cancel()
	}

	query := `SELECT id, name, price FROM products WHERE id = $1`
	
	var p Product
	// 2. Truyền context vào QueryRowContext
	err := r.db.QueryRowContext(ctx, query, id).Scan(&p.ID, &p.Name, &p.Price)
	if err != nil {
		if err == sql.ErrNoRows {
			return nil, ErrNotFound
		}
		// Nếu lỗi do timeout hoặc context bị hủy bỏ, Go sẽ trả về context.DeadlineExceeded hoặc context.Canceled
		return nil, err
	}

	return &p, nil
}

Khi câu lệnh SQL vượt quá thời gian defaultTimeout (ví dụ 3 giây), database/sql sẽ tự động gửi tín hiệu hủy bỏ xuống cơ sở dữ liệu (nếu hỗ trợ giao thức hủy query) và trả về lỗi ngay lập tức, giải phóng kết nối về cho pool.

3. Tối ưu hóa hiệu năng với Statement Caching

Khi hệ thống của bạn thực thi một câu lệnh SQL hàng triệu lần (ví dụ: SELECT * FROM users WHERE id = $1), cơ sở dữ liệu thông thường phải trải qua 3 bước cho mỗi lần gọi:

  1. Parsing: Phân tích cú pháp câu lệnh SQL.

  2. Planning: Lên kế hoạch thực thi tối ưu nhất (Execution Plan).

  3. Executing: Thực thi và trả về dữ liệu.

Quá trình Parsing và Planning ngốn rất nhiều tài nguyên CPU của database server.

Giải pháp: Prepared Statement

Prepared Statement giúp cơ sở dữ liệu chỉ biên dịch (parse & plan) câu lệnh SQL đúng một lần duy nhất ở lần gọi đầu tiên, các lần gọi sau chỉ việc truyền tham số vào và chạy trực tiếp.

Triển khai Prepared Statement trong Go:

database/sql hỗ trợ sẵn cơ chế chuẩn bị trước statement thông qua db.PrepareContext(). Tuy nhiên, để tránh việc tạo đi tạo lại statement gây rò rỉ bộ nhớ, ta thường lưu trữ chúng dạng cache trong struct của repository.

Go

package repository

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

type CachedUserRepository struct {
	db          *sql.DB
	stmtFindByID *sql.Stmt
}

func NewCachedUserRepository(ctx context.Context, db *sql.DB) (*CachedUserRepository, error) {
	// Biên dịch trước câu lệnh SQL khi khởi động ứng dụng
	stmt, err := db.PrepareContext(ctx, `SELECT id, name, email FROM users WHERE id = $1`)
	if err != nil {
		return nil, fmt.Errorf("không thể prepare statement: %w", err)
	}

	return &CachedUserRepository{
		db:           db,
		stmtFindByID: stmt,
	}, nil
}

func (r *CachedUserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
	var user User
	// Sử dụng lại stmt đã được cache sẵn thay vì truyền chuỗi SQL thô
	err := r.stmtFindByID.QueryRowContext(ctx, id).Scan(&user.ID, &user.Name, &user.Email)
	if err != nil {
		return nil, err
	}
	return &user, nil
}

// Nhớ đóng statement khi ứng dụng tắt
func (r *CachedUserRepository) Close() {
	if r.stmtFindByID != nil {
		r.stmtFindByID.Close()
	}
}

Lưu ý thực chiến: Khi sử dụng Connection Pool, việc quản lý Prepared Statement thủ công trên nhiều kết nối đồng thời có thể phức tạp. Nhiều driver hiện đại (như pgx cho PostgreSQL) đã tích hợp sẵn cơ chế Automatic Statement Caching ngầm bên dưới, giúp bạn hưởng trọn vẹn tốc độ của Prepared Statement mà không cần viết code cache thủ công phức tạp.

Tổng kết

Thông qua Context Propagation & Query Timeout, chúng ta đã tạo ra một chiếc "van an toàn" ngăn chặn các câu lệnh SQL treo vô tận làm sập connection pool. Kết hợp cùng Statement Caching, hệ thống đạt được hiệu năng tối ưu hóa tối đa khi xử lý lưu lượng truy vấn lớn.

Tuy nhiên, khi cơ sở dữ liệu trả về các mã lỗi (như vi phạm ràng buộc unique, khóa ngoại, hay xung đột Deadlock), làm thế nào để chuyển đổi chúng thành các Business Error tường minh và tự động thử lại (Retry Mechanism) khi gặp sự cố mạng tạm thời?

Đó chính là nội dung của Bài 5 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í