2024年3月,我们一个电商详情页接口突然从50ms飙到500ms,MySQL CPU冲到95%。查日志发现大量请求查询不存在的商品ID(比如-1、999999999),这些ID既不在Redis也不在MySQL,每次请求都穿透到数据库。
这就是典型的缓存穿透:攻击者利用不存在的key绕过缓存,直接打数据库。当时我们用了最蠢的方案——在Redis里存空值(value='null',TTL=60s),但攻击者每秒换ID,Redis内存涨得飞快,空key占了30%内存。
我们评估了三种方案:
| 方案 | 原理 | 内存占用 | 误判率 | 实现复杂度 |
|---|---|---|---|---|
| 空值缓存 | 不存在的key存空值+短TTL | 高(每个空key占用几十字节) | 无 | 低 |
| 布隆过滤器 | 位数组+多个哈希函数判断存在性 | 低(10亿数据仅占1.2GB) | 有(可调,通常<1%) | 中 |
| 接口限流 | 限制单IP请求频率 | 低 | 无 | 低 |
最终选择布隆过滤器+空值缓存组合:布隆过滤器拦截99%的穿透请求,剩下的1%误判用空值缓存兜底。
布隆过滤器本质是一个位数组(bit array)和k个哈希函数。插入元素时,用k个哈希函数算出k个位置,把这些位置置为1。查询时,同样算k个位置,如果所有位置都是1,则元素可能存在;只要有一个位置是0,则元素一定不存在。
关键参数:
公式:m = -n * ln(p) / (ln(2)^2),k = (m/n) * ln(2)
举个例子:n=1000万,p=0.01,则m≈9585万bit≈114MB,k≈7。也就是说,用114MB内存+7个哈希函数,就能把误判率控制在1%以内。
<?php
/**
* 布隆过滤器 PHP实现
* 适用场景:小规模数据(百万级),单机部署
* PHP版本:8.3
*/
class BloomFilter
{
private int $m; // 位数组长度
private int $k; // 哈希函数个数
private array $bits; // 位数组(用字符串模拟)
public function __construct(int $expectedElements, float $falsePositiveRate = 0.01)
{
// 计算最优参数
$this->m = (int) ceil(-$expectedElements * log($falsePositiveRate) / (log(2) ** 2));
$this->k = (int) ceil(($this->m / $expectedElements) * log(2));
$this->bits = str_repeat("\0", (int) ceil($this->m / 8));
}
public function add(string $item): void
{
$hashes = $this->getHashes($item);
foreach ($hashes as $hash) {
$index = $hash % $this->m;
$byteIndex = (int) ($index / 8);
$bitIndex = $index % 8;
$this->bits[$byteIndex] = chr(ord($this->bits[$byteIndex]) | (1 << $bitIndex));
}
}
public function mightContain(string $item): bool
{
$hashes = $this->getHashes($item);
foreach ($hashes as $hash) {
$index = $hash % $this->m;
$byteIndex = (int) ($index / 8);
$bitIndex = $index % 8;
if ((ord($this->bits[$byteIndex]) & (1 << $bitIndex)) === 0) {
return false;
}
}
return true;
}
private function getHashes(string $item): array
{
$hashes = [];
// 使用murmurhash3和FNV-1a组合生成多个哈希
$hash1 = hexdec(substr(md5($item), 0, 8));
$hash2 = hexdec(substr(sha1($item), 0, 8));
for ($i = 0; $i < $this->k; $i++) {
$hashes[] = abs($hash1 + $i * $hash2);
}
return $hashes;
}
public function getMemoryUsage(): string
{
return strlen($this->bits) . ' bytes (' . round(strlen($this->bits) / 1024 / 1024, 2) . ' MB)';
}
}
// 使用示例
$bf = new BloomFilter(1000000, 0.01); // 100万元素,1%误判率
echo '内存占用: ' . $bf->getMemoryUsage() . PHP_EOL;
// 添加100万个商品ID
for ($i = 1; $i <= 1000000; $i++) {
$bf->add('product_' . $i);
}
// 测试误判率
$falsePositives = 0;
$testCount = 10000;
for ($i = 1000001; $i <= 1000000 + $testCount; $i++) {
if ($bf->mightContain('product_' . $i)) {
$falsePositives++;
}
}
echo '误判率: ' . ($falsePositives / $testCount * 100) . '%' . PHP_EOL;
?>
<?php
/**
* Redis布隆过滤器封装
* 依赖:Redis 6.0+,phpredis 6.0+
* 使用Redis的bitmap操作,支持分布式
*/
class RedisBloomFilter
{
private Redis $redis;
private string $key;
private int $m;
private int $k;
public function __construct(Redis $redis, string $key, int $expectedElements, float $falsePositiveRate = 0.01)
{
$this->redis = $redis;
$this->key = $key;
$this->m = (int) ceil(-$expectedElements * log($falsePositiveRate) / (log(2) ** 2));
$this->k = (int) ceil(($this->m / $expectedElements) * log(2));
}
public function add(string $item): void
{
$hashes = $this->getHashes($item);
$pipe = $this->redis->multi(Redis::PIPELINE);
foreach ($hashes as $hash) {
$offset = $hash % $this->m;
$pipe->setBit($this->key, $offset, 1);
}
$pipe->exec();
}
public function mightContain(string $item): bool
{
$hashes = $this->getHashes($item);
foreach ($hashes as $hash) {
$offset = $hash % $this->m;
if ($this->redis->getBit($this->key, $offset) === 0) {
return false;
}
}
return true;
}
private function getHashes(string $item): array
{
$hashes = [];
$hash1 = hexdec(substr(md5($item), 0, 8));
$hash2 = hexdec(substr(sha1($item), 0, 8));
for ($i = 0; $i < $this->k; $i++) {
$hashes[] = abs($hash1 + $i * $hash2);
}
return $hashes;
}
}
// 使用示例
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$bf = new RedisBloomFilter($redis, 'product_bf', 10000000, 0.01);
// 批量初始化(从数据库加载)
$products = [1, 2, 3, 100, 200]; // 实际从DB查询
foreach ($products as $id) {
$bf->add('product_' . $id);
}
// 查询
$id = 999999;
if (!$bf->mightContain('product_' . $id)) {
echo '一定不存在,直接返回404' . PHP_EOL;
} else {
echo '可能存在,查Redis或MySQL' . PHP_EOL;
}
?>
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Services\BloomFilterService;
class CachePenetrationGuard
{
private BloomFilterService $bloomFilter;
public function __construct(BloomFilterService $bloomFilter)
{
$this->bloomFilter = $bloomFilter;
}
public function handle(Request $request, Closure $next)
{
$productId = $request->route('id');
// 第一步:布隆过滤器拦截
if (!$this->bloomFilter->mightContain('product_' . $productId)) {
return response()->json(['error' => '商品不存在'], 404);
}
// 第二步:查Redis
$cacheKey = 'product_' . $productId;
$cached = \Illuminate\Support\Facades\Cache::get($cacheKey);
if ($cached !== null) {
if ($cached === 'NULL_VALUE') {
return response()->json(['error' => '商品不存在'], 404);
}
return response()->json($cached);
}
// 第三步:查MySQL
$product = \App\Models\Product::find($productId);
if (!$product) {
// 空值缓存,TTL 60秒
\Illuminate\Support\Facades\Cache::put($cacheKey, 'NULL_VALUE', 60);
return response()->json(['error' => '商品不存在'], 404);
}
\Illuminate\Support\Facades\Cache::put($cacheKey, $product, 3600);
return response()->json($product);
}
}
# 安装ab(Apache Bench)
sudo apt-get install apache2-utils
# 压测:模拟1000个并发,总共10万请求
# 测试不存在ID(穿透场景)
ab -n 100000 -c 1000 -k http://api.example.com/product/999999
# 测试存在ID(正常场景)
ab -n 100000 -c 1000 -k http://api.example.com/product/12345
#!/bin/bash
# 每10秒统计一次误判率
while true; do
# 从Redis获取布隆过滤器统计信息
total=$(redis-cli get product_bf_count || echo 0)
false_positive=$(redis-cli get product_bf_false_positive || echo 0)
if [ "$total" -gt 0 ]; then
rate=$(echo "scale=4; $false_positive / $total * 100" | bc)
echo "$(date '+%Y-%m-%d %H:%M:%S') 总请求: $total, 误判: $false_positive, 误判率: $rate%"
fi
sleep 10
done
压测环境:PHP 8.3 + Laravel 11 + Redis 7.2 + MySQL 8.0.35,4核8G云服务器,1000并发。
| 场景 | 方案 | 平均响应时间 | QPS | MySQL CPU | Redis内存 |
|---|---|---|---|---|---|
| 穿透攻击(不存在ID) | 无防护 | 487ms | 820 | 95% | 200MB |
| 穿透攻击 | 空值缓存 | 45ms | 5200 | 30% | 1.2GB(空key占30%) |
| 穿透攻击 | 布隆过滤器 | 5ms | 12000 | 5% | 200MB |
| 穿透攻击 | 布隆+空值缓存 | 6ms | 11500 | 3% | 210MB |
| 正常请求(存在ID) | 布隆+空值缓存 | 3ms | 15000 | 2% | 210MB |
结论:布隆过滤器方案将穿透请求的响应时间从487ms降到5ms,QPS从820提升到12000,MySQL CPU从95%降到5%。
坑1:布隆过滤器不支持删除
上线第二天,运营说某个商品下架了,但布隆过滤器里还有它的位。结果用户访问这个已下架商品,布隆过滤器返回可能存在,然后查Redis发现是空值缓存,返回404。虽然功能正常,但多了一次Redis查询。解决方案:商品下架时,同时删除Redis中的空值缓存,并让布隆过滤器自然过期(重建)。
坑2:哈希函数选择不当导致误判率飙升
一开始用了简单的md5+sha1组合,测试100万数据误判率只有0.8%,但上线后数据量到500万时,误判率突然升到5%。排查发现哈希函数碰撞严重。改用murmurhash3和FNV-1a组合后,误判率稳定在1%以内。
坑3:Redis的setBit在管道中批量操作时,注意不要超过Redis的maxmemory
初始化布隆过滤器时,一次性往Redis写入1000万个位,结果Redis内存飙到2GB,触发OOM。解决方案:分批次写入,每批10万个位,中间sleep 0.1秒。
坑4:布隆过滤器重建问题
如果数据量增长超过预期,布隆过滤器需要重建。我们设计了一个定时任务:每天凌晨4点,从MySQL全量导出商品ID,重建布隆过滤器。重建期间,降级为空值缓存方案。
坑5:误判率设置过低导致内存爆炸
有个同事把误判率设为0.0001(万分之一),结果1000万数据需要1.7GB内存。实际上1%的误判率对于缓存穿透场景完全够用,因为还有空值缓存兜底。
布隆过滤器不是银弹,但它解决缓存穿透问题非常有效。核心要点:
代码已上传GitHub:https://github.com/your-company/bloom-filter-demo
专注技术分享与实战