Chuyện bắt đầu từ mấy con bot

Đợt rồi API đọc sản phẩm bên mình bị quét. Tụi nó hiền lắm, không brute force, không payload lạ — chỉ bắn id linh tinh: id=99999999, id=88888888, mỗi giây vài trăm request. Vấn đề nằm ở chỗ id đó không tồn tại.

Luồng cũ của mình đơn giản vậy nè:

  1. Check Redis → miss (vì id có tồn tại đâu mà cache)
  2. Xuống PostgreSQL query
  3. Không có row → trả 404
  4. Không cache gì hết, vì cache "không tồn tại" cũng là cache rác

Nên mỗi request rác là một lần chạm DB. DB vẫn xanh, nhưng connection pool cạn, p99 của API thật bị kéo theo. Trong tài liệu người ta gọi đây là cache penetration — bot đục xuyên qua cache thẳng xuống database.

Bloom filter giải quyết chuyện gì

Bloom filter là một bit array + vài hàm hash. Thêm phần tử thì bật vài bit, hỏi thì coi mấy bit đó có bật hết không. Đặc tính quan trọng nhất:

  • Hỏi "không có" → chắc chắn 100% là không có
  • Hỏi "có" → có thể có, cũng có thể là false positive

Vậy nên nó không dùng để trả kết quả, mà dùng làm cái cửa chặn trước database. Chặn được 99% request rác rồi thì DB chỉ còn làm việc thật.

Cái hay là bộ nhớ. 1 triệu id, tỉ lệ false positive 1% → khoảng 1.14 MB. Không phải 1 triệu id × 8 byte, mà là 1.14 MB cho cả triệu id. Nhét vô RAM của app server dư sức.

Code thực tế

Mình không tự viết bit array, xài bits-and-blooms/bloom cho khỏe:

type ProductReader struct {
	bloom *bloom.BloomFilter
	cache *redis.Client
	db    *sql.DB
}

func (r *ProductReader) GetByID(ctx context.Context, id int64) (*Product, error) {
	key := []byte(strconv.FormatInt(id, 10))

	// Cửa chặn: chắc chắn không tồn tại thì trả luôn, không chạm Redis lẫn DB
	if !r.bloom.Test(key) {
		return nil, ErrNotFound
	}

	if p, ok := r.cacheGet(ctx, id); ok {
		return p, nil
	}

	p, err := r.dbQuery(ctx, id)
	if errors.Is(err, sql.ErrNoRows) {
		// id có trong bloom nhưng DB không có → false positive, cache luôn
		r.cacheMiss(ctx, id)
		return nil, ErrNotFound
	}
	return p, err
}

Điểm cần chú ý: nhánh false positive vẫn phải cache lại cái "không tồn tại" với TTL ngắn (30–60s). Bloom filter chỉ giảm tải, không thay cache.

Build bloom lúc khởi động:

func buildBloom(db *sql.DB, n uint) (*bloom.BloomFilter, error) {
	// p = 0.01 → 1% false positive
	f := bloom.NewWithEstimates(n, 0.01)
	rows, err := db.Query(`SELECT id FROM products WHERE deleted_at IS NULL`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		f.Add([]byte(strconv.FormatInt(id, 10)))
	}
	return f, nil
}

Tham số tính sao cho đúng

Đừng đoán. Công thức chuẩn:

  • m = -n * ln(p) / (ln2)² (số bit)
  • k = (m/n) * ln2 (số hàm hash)

Với n = 5.000.000, p = 0.01 → m ≈ 47.9 triệu bit ≈ 5.7 MB, k ≈ 7. Bảy hàm hash nghe nhiều nhưng mỗi lần test chỉ là 7 phép tính bit, rẻ hơn một lần query DB cả nghìn lần.

Mình log cả ApproximateCount() và tỉ lệ false positive thực tế mỗi giờ. Khi FP vượt 2% thì rebuild. Đừng để bloom "phình" mà không ai biết.

Mấy cái bẫy mình đã sập

1. Bloom filter không xóa được phần tử. Xóa một bit có thể làm mất luôn phần tử khác. Sản phẩm xóa mềm thì ổn (rebuild định kỳ), nhưng nếu dữ liệu xóa liên tục thì phải xài counting Bloom filter hoặc scalable Bloom filter — đắt hơn nhiều. Cân nhắc trước khi chọn.

2. Lệch giữa các instance. Mỗi pod giữ bloom riêng, rebuild từ DB. Nếu pod A thêm sản phẩm mới mà pod B chưa biết, request vô B sẽ bị chặn oan. Cách chữa: sau khi INSERT thì publish event để các pod Add ngay, hoặc để TTL ngắn cho cái sai số đó.

3. Rebuild là bài toán vận hành. 5 triệu row scan một lần lúc khởi động, pod phải ready sau đó. Mình chạy rebuild song song với việc phục vụ, chỉ swap con trỏ khi build xong (atomic.Pointer), không block traffic.

4. Đừng dùng cho dữ liệu đổi liên tục. Bloom filter hợp với tập id tương đối tĩnh: user id, product id, slug đã publish. Với thứ thay đổi mỗi phút thì nó chỉ tạo thêm tầng sai.

Kết quả

Sau khi gắn bloom filter: DB QPS phần đọc giảm khoảng 90%, connection pool thôi cạn, p99 của API thật về lại mức cũ. Chi phí: 6 MB RAM mỗi pod và một hàm build lúc boot.

Nói gọn: bloom filter không làm hệ thống nhanh hơn, nó làm hệ thống bớt bị hỏi những câu vô nghĩa. Mà trong backend, bớt việc vô nghĩa thường là món tối ưu rẻ nhất.

Xem các bài viết khác