Làm Chủ Singleflight: Chặn Đứng Cache Stampede

Tìm hiểu kỹ thuật gộp request (Singleflight) để ngăn cache stampede, xử lý triệt để bẫy context cancellation và data race trên con trỏ dùng chung.

Làm Chủ Singleflight: Chặn Đứng Cache Stampede
Trong bài viết này

Khi một khóa cache nóng (hot key) hết hạn trong hệ thống có lưu lượng truy cập cao, hàng trăm hoặc hàng nghìn request đồng thời có thể đổ dồn vào backend trong cùng một mili-giây để tái tạo dữ liệu. Pattern Singleflight—còn gọi là kỹ thuật gộp yêu cầu (request coalescing)—giúp xử lý triệt để hiện tượng cache stampede này bằng cách gom các tác vụ đang thực thi trùng lặp lại thành một lần gọi duy nhất mà không cần đến distributed lock phức tạp.

Tuy nhiên, dù ý tưởng rất trực quan, việc áp dụng Singleflight trên môi trường production thường làm phát sinh những lỗi đồng quy (concurrency) khó chịu liên quan đến vòng đời của context, đột biến dữ liệu trên vùng nhớ chung và lan truyền lỗi.

Bản Chất Của Sự Cố Cache Stampede

Trong điều kiện vận hành bình thường, các hệ thống in-memory cache như Redis hoặc Memcached đóng vai trò tấm khiên bảo vệ cơ sở dữ liệu, phản hồi các truy vấn đọc chỉ trong vài phần nghìn giây. Nguy cơ bắt đầu xuất hiện khi khóa cache hết hạn (TTL về 0) hoặc bị xóa do thiếu bộ nhớ.

Giả sử một endpoint đang gánh 2.000 request mỗi giây cho cùng một sản phẩm. Khi khóa cache biến mất, mọi luồng xử lý (worker/goroutine) cùng lúc gặp tình trạng cache miss. Trong khoảng thời gian ngắn mà luồng đầu tiên đang bận truy vấn database và nạp lại cache, hàng trăm luồng tiếp theo cũng chạy truy vấn y hệt.

Hiện tượng này—thường được gọi là cache stampede, thundering herd hoặc dog-piling—làm cạn kiệt connection pool của database, đẩy CPU chạm ngưỡng 100% và kéo tụt hiệu năng của toàn bộ dịch vụ.

Nhiều kỹ sư thường chọn giải pháp dùng distributed lock (như Redlock trên Redis) để chỉ cho phép một worker truy vấn DB. Tuy vậy, distributed lock tạo thêm độ trễ mạng, đòi hỏi tinh chỉnh TTL của lock để tránh deadlock và phức tạp hóa việc xử lý sự cố. Request coalescing giải quyết bài toán này ngay tại bộ nhớ tiến trình (in-process) với chi phí thấp hơn rất nhiều.

Cơ Chế Hoạt Động Của Singleflight

Sơ đồ minh họa nhiều yêu cầu đồng thời được gộp thành một truy vấn cơ sở dữ liệu duy nhất

Ngôn ngữ Go cung cấp một triển khai kinh điển thông qua package golang.org/x/sync/singleflight. Bên dưới nắp ca-pô, Group quản lý một bảng băm được bảo vệ bởi mutex, chứa các đối tượng cuộc gọi kèm theo sync.WaitGroup và kết quả trả về.

package repository

import (
	"context"
	"fmt"
	"golang.org/x/sync/singleflight"
)

var group singleflight.Group

func FetchProduct(ctx context.Context, id string) (*Product, error) {
	key := fmt.Sprintf("product:%s", id)

	v, err, shared := group.Do(key, func() (any, error) {
		// Chỉ một goroutine duy nhất thực thi truy vấn nặng này
		return db.QueryProduct(context.Background(), id)
	})
	if err != nil {
		return nil, err
	}

	// Sao chép sâu để tránh data race trên con trỏ dùng chung
	p := v.(*Product)
	return p.Clone(), nil
}

Khi goroutine A gọi group.Do(key, fn), hệ thống đăng ký khóa key và bắt đầu chạy hàm fn. Nếu goroutine B và C đến ngay sau đó trong lúc fn chưa hoàn tất, chúng nhận diện khóa đang chạy, bỏ qua việc gọi lại fn và chuyển sang trạng thái chờ thông qua WaitGroup.Wait(). Khi fn kết thúc, kết quả được gửi trả đồng thời cho cả ba goroutine, rồi khóa được dọn dẹp khỏi bảng băm.

Bẫy 1: Nguy Cơ Hủy Bỏ Context Của Caller Đầu Tiên

Một lỗi kinh điển khi đưa Singleflight vào production là truyền trực tiếp context của request HTTP (ví dụ r.Context()) vào hàm thực thi dùng chung.

Nếu Goroutine A là người kích hoạt lời gọi với context có thời gian timeout 30ms hoặc client ngắt kết nối sớm, context của Goroutine A sẽ bị hủy. Nếu driver database tôn trọng tín hiệu hủy này, truy vấn SQL sẽ dừng ngay lập tức. Hệ quả là Goroutine B và Goroutine C—vốn có thể có hạn mức thời gian dài hơn và đang kiên nhẫn chờ đợi—đều nhận về lỗi context canceled oan uổng.

Cách xử lý an toàn:

  1. Tách biệt context thực thi của tác vụ dùng chung khỏi context của từng caller. Sử dụng một context nền độc lập (context.Background()) có gắn timeout vận hành tối đa hợp lý bên trong closure.
  2. Hoặc sử dụng group.DoChan kết hợp với select để mỗi goroutine có thể tự thoát nếu context riêng của nó hết hạn, mà không làm gián đoạn tác vụ đang chạy của các luồng khác.

Bẫy 2: Data Race Do Dùng Chung Con Trỏ (Shared Pointer)

Do Singleflight phân phối cùng một giá trị any (interface{}) cho toàn bộ các goroutine đang chờ đợi, việc trả về một con trỏ có thể thay đổi (mutable pointer) tiềm ẩn nguy cơ xung đột dữ liệu rất lớn.

Hãy tưởng tượng hàm nạp trả về *Product. Goroutine B nhận kết quả và thay đổi trường product.Discount dựa trên hạng thành viên của user trước khi serialize ra JSON. Cùng lúc đó, Goroutine C đọc dữ liệu từ chính con trỏ đó. Điều này gây ra data race kinh điển, sai lệch trạng thái bộ nhớ hoặc thậm chí crash tiến trình do concurrent map/slice read-write.

Các nguyên tắc cốt lõi cần nhớ:

  • Với các cấu trúc dữ liệu nhỏ, ưu tiên trả về dạng giá trị (value type struct) thay vì con trỏ.
  • Với đối tượng phức tạp, luôn thực hiện copy/clone dữ liệu trước khi thay đổi bất kỳ trường nào.
  • Coi kết quả trả về từ Singleflight là dữ liệu chỉ đọc (read-only) bất biến.

Bẫy 3: Nghẽn Luồng Toàn Diện Và Lan Truyền Lỗi

Việc gộp request đồng nghĩa với việc bạn đang tập trung rủi ro vào một mối duy nhất. Nếu tác vụ gốc bị treo do nghẽn socket ở dịch vụ bên ngoài, tất cả các request đang chờ khóa đó cũng sẽ bị treo theo. Tương tự, nếu tác vụ ném ra lỗi, toàn bộ các caller đều nhận chung một thất bại.

Để tăng cường độ bền vững cho hệ thống:

  • Tích hợp Circuit Breaker xung quanh hàm gọi upstream để nhanh chóng ngắt luồng khi dịch vụ phụ thuộc gặp sự cố.
  • Dùng group.Forget(key) chủ động nếu bạn muốn ép hệ thống thực hiện một lượt fetch mới thay vì để các request sau tiếp tục chờ một tác vụ chạy quá lâu.
  • Kết hợp Singleflight với chiến lược cache Stale-While-Revalidate: trả ngay dữ liệu cũ đã hết hạn cho người dùng trong lúc một goroutine duy nhất âm thầm cập nhật dữ liệu mới ở background.

Lời Kết

Singleflight là một trong những công cụ gọn gàng và hiệu quả nhất để chặn đứng thảm họa cache stampede. Bằng cách hiểu rõ cơ chế vận hành, tách biệt context caller, sao chép con trỏ dùng chung và thiết lập giới hạn thời gian chặt chẽ, bạn có thể bảo vệ database an toàn trước những đợt bùng nổ lưu lượng mà không cần đến sự phức tạp của hạ tầng khóa phân tán.

Nguồn tham khảo

  1. vertexaisearch.cloud.google.com
  2. vertexaisearch.cloud.google.com
  3. vertexaisearch.cloud.google.com
  4. vertexaisearch.cloud.google.com
  5. vertexaisearch.cloud.google.com
  6. vertexaisearch.cloud.google.com

Có hỗ trợ AI · PKN duyệt lại

0

Phản hồi

Đang tải bình luận…