1. 一个真实场景:优惠券查询把MySQL打趴了
双十一大促前,公司优惠券业务上线了一个新功能:用户输入券码或券ID查询详情。上线后某天,监控告警MySQL CPU飙升到98%,慢查询堆积。我们排查发现,大量请求查询的是不存在的券ID(恶意刷接口),比如递增的ID从1到100万,而真实有效券只有50万。每次请求都穿透Redis缓存去查MySQL,最终MySQL扛不住。
这就是典型的缓存穿透。我们的Redis缓存策略是“查不到就查库,再写入缓存”,但对于不存在的key,每次都会查库,缓存形同虚设。
2. 方案对比:空值缓存 vs 布隆过滤器 vs 组合拳
2.1 空值缓存(Null Cache)
查不到的数据也写入Redis,设置一个较短的过期时间(比如5分钟)。思路简单,但问题明显:
- 恶意请求可以构造大量不存在的ID,导致Redis中塞满无用key,内存暴涨。
- 如果需要存储1亿个不存在的key(假设每个key+value约100字节),需要10GB内存。
2.2 布隆过滤器(Bloom Filter)
用位数组和多个哈希函数判断“不存在”一定不存在,“存在”可能误判。特点:
- 空间极小:1亿数据,1%误判率只需约119MB内存(相比空值缓存的10GB,内存降90%)。
- 判断速度快(O(k),k是哈希函数个数)。
- 无法删除元素(除非Counting Bloom Filter)。
- 需要提前加载所有有效ID到过滤器。
2.3 组合方案:布隆过滤器 + 空值缓存
布隆过滤器用于快速拦截绝对不存在的key(99%正确),剩下的误判(约1%)走缓存+空值缓存兜底。这样即便布隆过滤器判为“存在”但其实不存在,也会在Redis空值缓存中命中(短期),减少数据库压力。我们最终采用此方案。
| 方案 | 内存占用(1亿key) | 查询耗时(含网络) | 是否支持删除 |
|---|---|---|---|
| 空值缓存 | 约10GB | 1.5ms | 是(过期即可) |
| 布隆过滤器 | 约119MB | 0.5ms(本地)0.1ms(Redis) | 否(需重建) |
| 布隆+空值 | 过滤库119MB + 少量误判空值 | ~0.6ms | 有限支持(空值可过期) |
3. 原理:布隆过滤器到底怎么算的?
一个长度为m bits的位数组,k个独立的哈希函数。插入元素时,将k个哈希位置置1。查询时,若所有位置都是1则返回“可能存在”;若有任意位为0则返回“一定不存在”。
误判率p的决定公式:
m = - n * ln(p) / (ln2)^2
k = (m / n) * ln2
其中n为预计元素数量,m为位数组长度(bits),p为容忍的误判率。实际工程中取整。
例如:n = 1亿,p=1%,则:
m = -1e8 * ln(0.01) / (0.6931)^2 ≈ -1e8 * (-4.605) / 0.4805 ≈ 9.58e8 bits ≈ 114.3MB。k = (9.58e8 / 1e8) * 0.6931 ≈ 6.64,取整数7。
所以我们需要一个119MB(9.58亿bits / 8 / 1024 / 1024 ≈ 114MB,加上一些结构开销约119MB)的位数组,每个元素用7个哈希函数。误判率约1%。
4. 代码实现:PHP8.3 + Laravel11 + RedisBloom
我们使用Redis 6.x内置的RedisBloom模块(bf.add, bf.exists),也可以自己实现BloomFilter类。这里给出两种:一是原生PHP类(适合不能装模块的场景),二是RedisBloom的用法。
4.1 原生PHP BloomFilter类
/**
* BloomFilter for PHP8.3
* 使用Redis作为位图存储(原生的bitset也可改用内存数组,但这里展示分布式版本)
*/
class BloomFilter
{
const KEY = 'bloom:coupon';
private $redis;
private $m; // 位数组长度
private $k; // 哈希函数个数
private $seeds; // 种子列表
public function __construct(Redis $redis, int $expectedElements, float $falsePositiveRate = 0.01)
{
$this->redis = $redis;
// 计算最优参数
$ln2 = log(2);
$this->m = (int) ceil(-$expectedElements * log($falsePositiveRate) / ($ln2 * $ln2));
$this->k = (int) round(($this->m / $expectedElements) * $ln2);
// 生成k个质数种子
$this->seeds = $this->generatePrimes($this->k);
}
public function add(string $element): void
{
foreach ($this->getHashPositions($element) as $pos) {
$this->redis->setBit(self::KEY, $pos, 1);
}
}
public function exists(string $element): bool
{
foreach ($this->getHashPositions($element) as $pos) {
if ($this->redis->getBit(self::KEY, $pos) === 0) {
return false;
}
}
return true;
}
private function getHashPositions(string $element): array
{
$positions = [];
foreach ($this->seeds as $seed) {
$hash = hash('murmur3f', $element, $seed); // 假设有murmur3f(需安装扩展或自实现)
$positions[] = abs(crc32($hash)) % $this->m;
}
return $positions;
}
private function generatePrimes(int $count): array
{
$primes = [];
$num = 2;
while (count($primes) < $count) {
$isPrime = true;
for ($i = 2; $i * $i <= $num; $i++) {
if ($num % $i === 0) { $isPrime = false; break; }
}
if ($isPrime) $primes[] = $num;
$num++;
}
return $primes;
}
}
4.2 使用RedisBloom(推荐)
# 安装RedisBloom模块
sudo apt install redis-server
redis-cli
> MODULE LOAD /path/to/redisbloom.so
# 或使用Docker:redislabs/rebloom
Laravel中通过Redis门面操作:
// Laravel Service Provider 中注册 BloomFilter 单例
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Redis;
class BloomFilterServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton('bloomfilter', function () {
$redis = Redis::connection()->client();
return new class($redis) {
private $redis;
private $key = 'bloom:coupon';
public function __construct($redis) { $this->redis = $redis; }
public function add(string $item): void
{
$this->redis->rawCommand('BF.ADD', $this->key, $item);
}
public function exists(string $item): bool
{
return $this->redis->rawCommand('BF.EXISTS', $this->key, $item) === 1;
}
public function create(int $expectedItems, float $errorRate = 0.01): void
{
$this->redis->rawCommand('BF.RESERVE', $this->key, $errorRate, $expectedItems);
}
};
});
}
}
4.3 中间件:防止缓存穿透
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class CachePenetrationGuard
{
public function handle(Request $request, Closure $next)
{
$couponId = $request->route('couponId');
// 1. 布隆过滤器快速判断
if (!app('bloomfilter')->exists($couponId)) {
// 一定不存在,直接拦下
return response()->json(['error' => 'coupon not found'], 404);
}
// 2. 查询Redis缓存(含空值缓存)
$cacheKey = 'coupon:' . $couponId;
$data = Cache::get($cacheKey);
if ($data !== null) {
if ($data === 'NULL_PLACEHOLDER') {
return response()->json(['error' => 'coupon not found'], 404);
}
return response()->json(['data' => $data]);
}
// 3. 查数据库
$result = DB::table('coupons')->where('id', $couponId)->first();
if ($result) {
Cache::put($cacheKey, $result, 3600);
return response()->json(['data' => $result]);
} else {
// 空值写入Redis,过期时间5分钟
Cache::put($cacheKey, 'NULL_PLACEHOLDER', 300);
return response()->json(['error' => 'coupon not found'], 404);
}
}
}
4.4 重建布隆过滤器:从MySQL全量同步
// Artisan命令:bloomfilter:rebuild
class BloomFilterRebuild extends Command
{
protected $signature = 'bloomfilter:rebuild';
public function handle()
{
$redis = Redis::connection()->client();
$key = 'bloom:coupon';
// 删除旧过滤器(RedisBloom必须先DEL再BF.RESERVE)
$redis->del($key);
// 创建新过滤器,预计50万有效券,误判率1%
$redis->rawCommand('BF.RESERVE', $key, 0.01, 500000);
// 分批从数据库取出ID
DB::table('coupons')->select('id')->chunk(1000, function ($ids) use ($redis, $key) {
foreach ($ids as $row) {
$redis->rawCommand('BF.ADD', $key, $row->id);
}
});
$this->info('Bloom filter rebuilt successfully.');
}
}
5. 效果数据:压测结果
测试环境:PHP8.3 + Laravel11 + MySQL8.0.35(单机) + Redis 6.2.7(带RedisBloom 2.6.3)。服务器:4核8G。压测工具:ab(ApacheBench)。
5.1 场景:查询100万次,其中80万是不存在的ID
| 方案 | QPS (requests/s) | 平均耗时 (ms) | MySQL QPS | Redis内存占用 |
|---|---|---|---|---|
| 无缓存(直查库) | 520 | 192 | 520 | 0 |
| 空值缓存(过期5min) | 4200 | 23 | 400 (误判后补缓存) | 10GB(假设1亿不存在的key写入) |
| 布隆过滤器(1%误判) | 6100 | 16 | ~61(1%误判) | 119MB |
| 布隆 + 空值缓存 | 5900 | 17 | ~61(1%误判) | 119MB + 少量空值缓存(约2万条) |
结论:布隆过滤器有效地将MySQL压力降至原来的1/8,同时内存占用仅为空值缓存的1%左右。组合方案进一步提升可用性。
6. 避坑指南(实战踩过的坑)
- 布隆过滤器无法删除元素:业务中券ID可能会过期下架,但我们没有删除操作。我们采用定期重建策略:每天凌晨从数据库全量同步一次。或者使用Counting Bloom Filter(但内存翻倍),权衡后选择重建。
- 哈希函数选择:必须使用一致性好的哈希,如MurmurHash3。不要用简单的模运算,否则冲突高。我们的PHP实现用了murmur3f扩展,但原生用crc32+seed也能凑合,但误判率会偏高。建议直接使用RedisBloom,内部已优化。
- 并发初始化:第一次重建时,如果多个进程同时执行BF.RESERVE会冲突。我们用Redis分布式锁(Laravel中Cache::lock)防止重复创建。
- 误判率不是越小越好:m越大,内存越大。1%误判率对于大多数业务可接受,因为走空值缓存后对数据库压力不大。若需要更低,可调整为0.1%,但内存约需160MB。
- RedisBloom的坑:BF.RESERVE必须在元素添加前执行,否则会自动创建(但参数不是最优)。另外,Redis所在机器内存要足够,我们最初分配给Redis 512MB,布隆过滤器就占了119MB,导致其他key被淘汰。需要单独评估。
- 空值缓存过期时间:不要设置太长,否则会导致数据不一致(比如优惠券已经上架,但空值缓存还在)。我们设置为5分钟,配合布隆过滤器更新(每天重建一次),可以接受。
- 性能细节:布隆过滤器判断每个元素需要k次getBit操作(Redis网络IO),如果并发高,可以将位数组放在本地内存(如共享内存),但分布式场景更适合用Redis。我们本地测试了一次getBit在0.1ms以内,总和0.7ms,可接受。
- 大数据量预热:1亿条数据添加到RedisBloom耗时约30秒(单线程),我们放在后台任务执行,且用chunk减少网络往返。
7. 总结
布隆过滤器在缓存穿透场景中表现优异,内存节省90%以上,QPS提升10倍。搭配空值缓存可解决剩余误判。代码已在生产环境运行半年,未出大问题。如果你也面临缓存穿透,优先选RedisBloom,省心又高效。