Ngày Database của tôi “ngỏm”: Một cơn ác mộng Cache trong thực tế
Vào năm 2021, tôi vận hành backend cho một trang thương mại điện tử quy mô vừa trong một đợt flash sale lớn. Mọi thứ có vẻ ổn định cho đến khi một đợt truy cập khổng lồ ập đến. Nhưng đây không phải là những khách hàng hào hứng. Đó là một cuộc tấn công bot có mục tiêu. Những con bot này không quan tâm đến hàng giảm giá; chúng truy vấn hàng triệu ID sản phẩm ngẫu nhiên, không hề tồn tại với tốc độ khoảng 50.000 request mỗi giây.
Kiến trúc của chúng tôi tuân theo một mô hình chuẩn. Ứng dụng kiểm tra Redis, và nếu không tìm thấy gì (một cache miss), nó sẽ truy cập dồn dập vào database PostgreSQL. Vì các con bot đang đoán những ID chưa từng tồn tại, Redis liên tục trả về null. Mọi request độc hại đều vượt qua cache và chọc thẳng vào đĩa cứng. Chỉ trong vòng ba phút, mức sử dụng CPU của database đã chạm ngưỡng 100%. Trang web bị sập. Đây chính là định nghĩa kinh điển của Cache Penetration.
Tại sao Caching tiêu chuẩn lại thất bại dưới áp lực lớn
Cache giống như những chiếc gương tốc độ cao. Chúng cực kỳ giỏi trong việc lưu trữ những gì đang có. Đáng tiếc là chúng lại rất tệ trong việc cho bạn biết cái gì không tồn tại mà không làm ngốn sạch ngân sách RAM của bạn.
Đầu tiên tôi đã thử “negative caching” (cache giá trị âm). Tôi lưu các giá trị null cho những ID không tồn tại đó vào Redis với TTL là 5 phút. Kết quả là một thảm họa. Các con bot chỉ việc tạo ra 15 triệu chuỗi định danh mới mỗi giờ. Lượng RAM sử dụng của Redis vọt từ 2GB lên 14GB chỉ trong một buổi sáng. Tôi không giải quyết được vấn đề; tôi chỉ đang di dời điểm nghẽn cổ chai. Tôi cần một cách để xác thực xem một key có tồn tại hay không trước khi làm phiền database, và tôi cần thực hiện việc đó một cách tiết kiệm nhất.
Cứu cánh từ Probabilistic Filters (Bộ lọc xác suất)
Để khắc phục tình trạng này mà không phải trả hóa đơn AWS khổng lồ, tôi đã tìm đến các cấu trúc dữ liệu xác suất: Bloom Filters và Cuckoo Filters. Những bộ lọc này không lưu trữ dữ liệu thô. Thay vào đó, chúng sử dụng các dấu vân tay toán học (fingerprints) nhỏ gọn để cho bạn biết liệu một mục là “chắc chắn không tồn tại” hay “có thể tồn tại”.
Bloom Filters: Người cựu binh đáng tin cậy
A Bloom Filter sử dụng một mảng bit và một vài hàm băm (hash functions). Khi bạn thêm một mục, bộ lọc sẽ băm nó nhiều lần và chuyển các bit cụ thể sang giá trị 1. Nó cực kỳ tinh gọn—bạn có thể biểu diễn 1 triệu mục chỉ trong 1.2MB với tỷ lệ sai sót 1%.
- Chắc chắn Không: Nếu bạn kiểm tra một ID và chỉ cần một bit băm có giá trị
0, mục đó 100% không tồn tại. Bạn có thể hủy request ngay lập tức. - Có thể Có: Nếu tất cả các bit đều là
1, mục đó có thể có trong database. Có một xác suất nhỏ (False Positive – Dương tính giả) rằng các mục khác nhau dùng chung các bit giống nhau.
Cuckoo Filters: Giải pháp thay thế hiện đại
Cuối cùng, tôi đã chuyển sang Cuckoo Filters. Chúng sử dụng một kỹ thuật gọi là cuckoo hashing để lưu trữ dấu vân tay. Điểm cộng lớn nhất? Cuckoo Filters hỗ trợ xóa (deletions). Nếu bạn xóa một sản phẩm khỏi DB, bạn thực sự có thể xóa nó khỏi bộ lọc. Với Bloom Filter tiêu chuẩn, bạn sẽ phải xóa toàn bộ và xây dựng lại từ đầu để loại bỏ một mục duy nhất.
Bloom vs. Cuckoo: Chọn “vũ khí” phù hợp
Việc chọn đúng công cụ phụ thuộc hoàn toàn vào vòng đời dữ liệu của bạn. Đây là cách tôi cân nhắc chúng khi đánh giá kiến trúc:
| Tính năng | Bloom Filter | Cuckoo Filter |
|---|---|---|
| Hiệu quả bộ nhớ | Tốt hơn Cuckoo khi bạn cần tỷ lệ dương tính giả <1%. | Tốt hơn cho mức độ chịu lỗi cao hơn (>3%). |
| Hỗ trợ xóa | Không. | Có. |
| Tốc độ | Nhanh như chớp (tra cứu hash O(k)). | Nhanh, nhưng tra cứu chậm lại khi bộ lọc đầy quá 80%. |
| Sự đơn giản | Ổn định và cực kỳ dễ hiểu. | Cài đặt phức tạp hơn một chút. |
Nếu tập dữ liệu của bạn là tĩnh—như danh sách các địa chỉ IP bị chặn—Bloom là lựa chọn hoàn hảo. Nếu bạn liên tục thêm và xóa các mã hàng (SKU), Cuckoo rõ ràng là người chiến thắng.
Triển khai thực tế với Redis
Redis nguyên bản không hỗ trợ sẵn những tính năng này, nhưng Redis Stack bao gồm module RedisBloom. Nó xử lý tất cả các phép toán phức tạp cho bạn. Bạn có thể khởi tạo một instance thử nghiệm chỉ với một lệnh Docker duy nhất:
bash
docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest
Triển khai Bloom Filter
Tôi thường khởi tạo một bộ lọc với tỷ lệ sai sót 1% và sức chứa 1 triệu mục để bắt đầu:
bash
# BF.RESERVE {key} {error_rate} {capacity}
BF.RESERVE product_filter 0.01 1000000
Việc thêm và kiểm tra các mục chỉ mất vài micro giây:
bash
# Thêm một ID
BF.ADD product_filter "prod_99"
# Kiểm tra xem ID có tồn tại không
BF.EXISTS product_filter "prod_99" # Trả về 1 (Đã tìm thấy)
BF.EXISTS product_filter "random_bot_id" # Trả về 0 (Chặn nó!)
Triển khai Cuckoo Filter
Cú pháp của Cuckoo gần như tương tự, nhưng hãy chú ý đến lệnh DEL:
bash
CF.RESERVE user_filter 1000000
CF.ADD user_filter "user_123"
CF.DEL user_filter "user_123" # Đây chính là siêu năng lực của nó.
Mẹo nhỏ khi chuẩn bị dữ liệu
Việc nạp hàng triệu ID hiện có vào Redis có thể rất tẻ nhạt. Tôi thường phải làm sạch các bản xuất CSV khổng lồ từ các tệp dump SQL cũ trước khi import chúng. Khi không muốn viết script Python cho một tác vụ chỉ dùng một lần, tôi sử dụng toolcraft.app/vi/tools/data/csv-to-json. Nó xử lý mọi thứ ngay trên trình duyệt. Đây là một cách nhanh chóng để định dạng dữ liệu cho các script nạp Redis mà không lo lắng về việc dữ liệu của bạn bị gửi lên server của bên thứ ba.
Xây dựng hệ thống phòng thủ tốt nhất
Logic là then chốt. Trong backend của bạn—dù là Node, Python hay Go—hãy coi bộ lọc như một nhân viên bảo vệ. Đừng để request lọt qua cửa chính trừ khi bộ lọc bật đèn xanh:
python
def get_product(product_id):
# 1. Nhân viên bảo vệ: Kiểm tra Bloom Filter
if not redis.execute_command('BF.EXISTS', 'product_filter', product_id):
return None # Chặn đứng cuộc tấn công tại đây!
# 2. Làn đường ưu tiên: Kiểm tra cache Redis tiêu chuẩn
product = redis.get(f"product:{product_id}")
if product:
return product
# 3. Kho hàng: Truy vấn Database như giải pháp cuối cùng
product = db.query("SELECT * FROM products WHERE id = %s", (product_id,))
if product:
redis.set(f"product:{product_id}", product)
return product
Sau khi triển khai giải pháp này, tải của database đã giảm 92% trong đợt tấn công bot tiếp theo. Đây là một sự thay đổi nhỏ về kiến trúc nhưng mang lại lợi ích khổng lồ về sự ổn định. Bảo vệ hệ thống của bạn không phải lúc nào cũng là ném thêm RAM vào vấn đề. Đôi khi, bạn chỉ cần một cấu trúc dữ liệu tốt hơn.

