Bloom filter với Redis: chống cache penetration trong Spring Boot
1. Tổng quan
Cache penetration xảy ra khi client liên tục yêu cầu những key không tồn tại. Cache không có dữ liệu, nên mọi request đều rơi xuống database. Bài này giải thích vì sao Bloom filter là lá chắn rẻ và hiệu quả cho tình huống đó. Bạn cũng sẽ biết cách triển khai nó trên Redis bằng Redisson trong một ứng dụng Spring Boot 3.x.
Đọc xong, bạn sẽ biết cách chọn tham số cho filter, nạp dữ liệu ban đầu, tích hợp filter vào luồng đọc và viết test kiểm chứng hành vi của nó.
2. Cache penetration là gì?
Giả sử bạn có một ProductService dùng mô hình cache-aside quen thuộc:
public Optional<Product> findById(long id) {
RBucket<Product> bucket = redisson.getBucket("product:" + id, JsonJacksonCodec.INSTANCE);
Product cached = bucket.get();
if (cached != null) {
return Optional.of(cached);
}
Optional<Product> fromDb = productRepository.findById(id); // truy vấn DB
fromDb.ifPresent(p -> bucket.set(p, Duration.ofMinutes(30)));
return fromDb;
}
Luồng này ổn với dữ liệu có thật. Giờ hãy xem điều gì xảy ra khi ai đó gửi hàng nghìn request với id = -1, id = 999999999 hoặc các id ngẫu nhiên:
- Redis trả về
nullvì không có key. - Code xuống DB, DB cũng không tìm thấy.
- Không có gì được ghi vào cache, nên request tiếp theo lặp lại đúng quy trình trên.
Kết quả là cache trở nên vô dụng và DB gánh toàn bộ tải. Đây có thể là một bug phía client, một crawler, hoặc một cuộc tấn công có chủ đích.
Cách chữa phổ biến đầu tiên là cache giá trị rỗng (null object) với TTL ngắn. Cách này hiệu quả khi tập key xấu nhỏ. Nó thất bại khi kẻ tấn công dùng id ngẫu nhiên: mỗi id chỉ xuất hiện một lần, và bạn phải lưu hàng triệu key rỗng vô nghĩa. Đây là lúc Bloom filter phát huy tác dụng.
3. Bloom filter hoạt động thế nào
Bloom filter là một cấu trúc dữ liệu xác suất gồm hai thành phần:
- Một mảng
mbit, ban đầu toàn bộ là 0. khàm hash, mỗi hàm ánh xạ một phần tử tới một vị trí trong mảng.
Hai thao tác chính của nó như sau:
- add(x): tính
khash củaxvà bật các bit tương ứng lên 1. - contains(x): tính
khash củax. Nếu bất kỳ bit nào bằng 0,xchắc chắn chưa từng được thêm. Nếu tất cả đều bằng 1,xcó thể đã được thêm.
Tính chất quan trọng nhất là không có false negative, chỉ có false positive. Khi filter trả lời “không có”, bạn có thể tin tuyệt đối và trả 404 ngay mà không cần chạm vào cache hay DB. Khi filter trả lời “có thể có”, bạn đi tiếp luồng bình thường. Tỷ lệ sai nhỏ này hoàn toàn chấp nhận được.
Kích thước filter được tính từ số phần tử dự kiến n và tỷ lệ false positive mong muốn p:
m = -n * ln(p) / (ln 2)^2
k = (m / n) * ln 2
Ví dụ với n = 1_000_000 sản phẩm và p = 0.01 (1%):
m ≈ 9_585_059 bit ≈ 1.14 MB
k ≈ 7 hàm hash
Chỉ khoảng 1 MB RAM trên Redis là đủ để bảo vệ một triệu id. Con số này nhỏ hơn rất nhiều so với việc lưu chính các id đó vào một Set.
4. Thiết lập Redisson
Redisson cung cấp RBloomFilter, được cài đặt bằng bitmap thuần của Redis. Vì vậy nó chạy được trên mọi phiên bản Redis mà không cần module bổ sung.
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.45.1</version>
</dependency>
Starter này tự đọc cấu hình kết nối từ các thuộc tính spring.data.redis.*:
spring.data.redis.host=localhost
spring.data.redis.port=6379
Sau bước này, bạn có thể inject một bean RedissonClient vào bất kỳ đâu trong ứng dụng.
5. Khởi tạo Bloom filter
Hãy tạo filter dưới dạng một bean để toàn ứng dụng dùng chung:
@Configuration
public class BloomFilterConfig {
public static final String PRODUCT_FILTER = "bf:product:ids";
@Bean
public RBloomFilter<Long> productIdFilter(RedissonClient redisson) {
RBloomFilter<Long> filter = redisson.getBloomFilter(PRODUCT_FILTER);
boolean created = filter.tryInit(1_000_000L, 0.01);
if (!created) {
// Filter đã tồn tại trên Redis, giữ nguyên cấu hình cũ
log.info("Bloom filter {} đã tồn tại, size={} bit, k={}",
PRODUCT_FILTER, filter.getSize(), filter.getHashIterations());
}
return filter;
}
private static final Logger log = LoggerFactory.getLogger(BloomFilterConfig.class);
}
Đoạn code trên làm những việc sau:
getBloomFilter(...)chỉ tạo một đối tượng proxy và chưa ghi gì vào Redis.tryInit(1_000_000L, 0.01)tínhm,ktheo công thức ở mục 3, rồi lưu cấu hình vào một hash phụ trên Redis. Phương thức trả vềfalsenếu filter đã được khởi tạo trước đó, chẳng hạn khi instance khác của ứng dụng đã chạy trước.getSize()vàgetHashIterations()cho bạn biết filter thực tế đang dùng bao nhiêu bit và bao nhiêu hàm hash.
Với các tham số trên, log sẽ in ra như sau:
Bloom filter bf:product:ids đã tồn tại, size=9585058 bit, k=7
Lưu ý: Khi filter đã tồn tại,
tryInitsẽ không thay đổi cấu hình. Nếu bạn đổiexpectedInsertionstrong code mà không xóa filter cũ, cấu hình mới sẽ bị bỏ qua mà không có cảnh báo nào.
6. Nạp dữ liệu ban đầu
Một filter rỗng sẽ trả lời “không có” cho mọi id, kể cả id có thật. Vì vậy bạn bắt buộc phải nạp toàn bộ id hiện có trước khi bật kiểm tra.
@Component
public class ProductFilterWarmUp implements ApplicationRunner {
private final ProductRepository productRepository;
private final RBloomFilter<Long> productIdFilter;
public ProductFilterWarmUp(ProductRepository productRepository,
RBloomFilter<Long> productIdFilter) {
this.productRepository = productRepository;
this.productIdFilter = productIdFilter;
}
@Override
public void run(ApplicationArguments args) {
if (productIdFilter.count() > 0) {
return; // đã nạp bởi instance khác
}
List<Long> ids = productRepository.findAllIds();
ids.forEach(productIdFilter::add);
log.info("Đã nạp {} id vào Bloom filter", ids.size());
}
private static final Logger log = LoggerFactory.getLogger(ProductFilterWarmUp.class);
}
Trong đó, ProductRepository.findAllIds() chỉ lấy cột id, ví dụ @Query("select p.id from Product p") nếu bạn dùng JPA. count() trả về số phần tử ước lượng dựa trên số bit đang bật, nên nó đủ tốt để kiểm tra filter đã có dữ liệu hay chưa.
Với hàng triệu bản ghi, bạn nên đọc theo trang (pagination hoặc Stream có fetch size) thay vì List. Mỗi lệnh add là một round-trip tới Redis, nên hãy cân nhắc chạy warm-up bằng một job riêng thay vì làm chậm quá trình khởi động ứng dụng.
7. Tích hợp vào ProductService
Giờ hãy đặt filter vào trước cache trong luồng đọc:
@Service
public class ProductService {
private final RedissonClient redisson;
private final RBloomFilter<Long> productIdFilter;
private final ProductRepository productRepository;
public ProductService(RedissonClient redisson,
RBloomFilter<Long> productIdFilter,
ProductRepository productRepository) {
this.redisson = redisson;
this.productIdFilter = productIdFilter;
this.productRepository = productRepository;
}
public Optional<Product> findById(long id) {
if (!productIdFilter.contains(id)) {
return Optional.empty(); // chắc chắn không tồn tại
}
RBucket<Product> bucket = redisson.getBucket("product:" + id, JsonJacksonCodec.INSTANCE);
Product cached = bucket.get();
if (cached != null) {
return Optional.of(cached);
}
Optional<Product> fromDb = productRepository.findById(id);
fromDb.ifPresent(p -> bucket.set(p, Duration.ofMinutes(30)));
return fromDb;
}
@Transactional
public Product create(Product product) {
Product saved = productRepository.save(product);
productIdFilter.add(saved.id());
return saved;
}
}
Product ở đây là một record đơn giản record Product(Long id, String name, BigDecimal price). Hãy cùng xem từng bước:
contains(id)trảfalsenghĩa là id chắc chắn không có trong hệ thống. Request bị chặn ngay với chi phí một lệnh Redis, không tốn truy vấn DB nào.- Nếu filter trả
true, request đi tiếp luồng cache-aside như cũ. Khoảng 1% id không tồn tại vẫn lọt qua do false positive. Lượng nhỏ này DB xử lý dễ dàng. - Trong
create, id mới được thêm vào filter. Nếu quên bước này, sản phẩm vừa tạo sẽ “biến mất” với người dùng.
Về thứ tự ghi, nếu add chạy trong transaction nhưng commit thất bại, filter sẽ chứa một id không tồn tại. Hệ quả chỉ là thêm một false positive, hoàn toàn vô hại. Trường hợp ngược lại mới đáng lo: nếu bạn chỉ add sau khi commit, sẽ có một khoảng thời gian ngắn mà sản phẩm đã có trong DB nhưng filter vẫn trả “không có”. Đó là một false negative do chính bạn tạo ra. Vì vậy, hãy ưu tiên thêm vào filter trước hoặc cùng lúc với ghi DB.
8. Kiểm thử hành vi của filter
Để test trên Redis thật, hãy thêm Testcontainers:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
Test dưới đây kiểm chứng hai tính chất cốt lõi: không có false negative, và tỷ lệ false positive nằm gần giá trị đã cấu hình.
@Testcontainers
class ProductBloomFilterTest {
@Container
static GenericContainer<?> redis =
new GenericContainer<>("redis:7.4-alpine").withExposedPorts(6379);
static RedissonClient client;
static RBloomFilter<Long> filter;
@BeforeAll
static void setUp() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(6379));
client = Redisson.create(config);
filter = client.getBloomFilter("test:product:ids");
filter.tryInit(5_000L, 0.01);
LongStream.rangeClosed(1, 5_000).forEach(filter::add);
}
@AfterAll
static void tearDown() {
client.shutdown();
}
@Test
void givenAddedIds_whenContains_thenAlwaysTrue() {
boolean allPresent = LongStream.rangeClosed(1, 5_000).allMatch(filter::contains);
assertThat(allPresent).isTrue();
}
@Test
void givenUnknownIds_whenContains_thenFalsePositiveRateIsLow() {
long falsePositives = LongStream.rangeClosed(5_001, 10_000)
.filter(filter::contains)
.count();
double rate = falsePositives / 5_000.0;
assertThat(rate).isLessThan(0.02);
}
}
Test thứ nhất khẳng định mọi id đã thêm đều được nhận ra. Test thứ hai đếm số id chưa từng thêm mà filter vẫn báo “có thể có”. Với p = 0.01, tỷ lệ đo được thường dao động quanh 1%. Ngưỡng 2% giúp test không bị flaky.
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
9. Phương án thay thế: lệnh Bloom native của Redis
Nếu bạn dùng Redis Stack hoặc Redis 8 trở lên (đã tích hợp sẵn các kiểu dữ liệu xác suất), bạn có thể dùng trực tiếp lệnh BF.*:
redis-cli BF.RESERVE bf:product:ids 0.01 1000000
redis-cli BF.MADD bf:product:ids 1 2 3
redis-cli BF.EXISTS bf:product:ids 2
redis-cli BF.EXISTS bf:product:ids 42
OK
1) (integer) 1
2) (integer) 1
3) (integer) 1
(integer) 1
(integer) 0
Bản native có vài ưu điểm. Mỗi lệnh BF.EXISTS chỉ tính hash một lần phía server. BF.MADD cho phép thêm nhiều phần tử trong một lệnh. Ngoài ra, filter có thể tự mở rộng (scaling) khi vượt quá dung lượng. Ngược lại, RBloomFilter của Redisson chạy được trên Redis “trơn”, kể cả các dịch vụ managed không hỗ trợ module. Bạn hãy chọn theo hạ tầng mình đang có.
10. Lỗi hay gặp và khi nào không nên dùng
Lưu ý: Bloom filter cổ điển không hỗ trợ xóa. Tắt một bit có thể làm “mất” phần tử khác dùng chung bit đó, tạo ra false negative.
Một số điểm bạn cần nhớ:
- Xóa dữ liệu: khi sản phẩm bị xóa, id của nó vẫn nằm trong filter. Điều này an toàn vì nó chỉ trở thành false positive. Tuy vậy, nếu tỷ lệ xóa cao, filter sẽ “bẩn” dần. Giải pháp là định kỳ build lại filter vào một key mới (ví dụ
bf:product:ids:v2) rồi chuyển đổi bằngRENAME. Một lựa chọn khác là dùng Cuckoo filter (CF.*), cấu trúc có hỗ trợ xóa. - Vượt dung lượng dự kiến: nếu thực tế có 5 triệu phần tử trong khi filter được khai báo cho 1 triệu, tỷ lệ false positive sẽ tăng vọt. Hãy theo dõi
count()so vớigetExpectedInsertions()và build lại trước khi chạm ngưỡng. - Quên nạp dữ liệu hoặc nạp chưa xong: một filter rỗng hoặc thiếu dữ liệu sẽ chặn cả request hợp lệ. Bạn có thể dùng một feature flag để chỉ bật kiểm tra khi warm-up đã hoàn tất.
- Nhiều đường ghi dữ liệu: nếu dữ liệu được tạo qua batch job, import CSV hoặc service khác, mọi đường ghi đó đều phải cập nhật filter. Cách an toàn hơn là cập nhật qua CDC hoặc event.
- Không nên dùng khi: tập key nhỏ (vài nghìn phần tử, một Redis Set là đủ), key không thể liệt kê trước (ví dụ kết quả tìm kiếm tự do), hoặc hệ thống không chấp nhận độ trễ đồng bộ giữa DB và filter.
Kết hợp Bloom filter với cache giá trị rỗng TTL ngắn là cách phòng thủ nhiều lớp hợp lý. Bloom filter chặn phần lớn id rác, còn null cache xử lý số ít false positive bị gọi lặp lại.
11. Kết luận
Cache penetration biến cache thành vô dụng khi request nhắm vào key không tồn tại. Bloom filter giải quyết vấn đề này với chi phí rất thấp: khoảng 1 MB cho một triệu id, một lệnh Redis cho mỗi lần kiểm tra, và đảm bảo không có false negative.
Trong bài này, bạn đã thấy cách tính kích thước filter và khởi tạo nó bằng Redisson RBloomFilter. Bạn cũng đã nạp dữ liệu ban đầu, đặt filter trước cache trong ProductService, và viết test kiểm chứng hành vi với Testcontainers. Cuối cùng, hãy luôn lên kế hoạch build lại filter định kỳ và giữ mọi đường ghi dữ liệu đồng bộ với nó.