本文目录导读:

在PHP项目中解决缓存击穿问题,互斥锁是经典且有效的方案,核心思想是:当缓存失效时,只允许一个线程去重建缓存,其他线程等待。
以下是几种常见的互斥锁实现方案,并附有代码示例:
基于文件锁 (flock) 的互斥锁
适用于单机部署,简单但可能影响性能。
<?php
function getDataWithFileLock($key) {
$cacheKey = 'your_prefix:' . $key;
$cache = new Redis(); // 假设使用Redis作为缓存
// 1. 尝试获取缓存
$data = $cache->get($cacheKey);
if ($data !== false) {
return json_decode($data, true);
}
// 2. 获取文件锁(互斥)
$lockKey = '/tmp/lock_' . md5($cacheKey) . '.lock';
$fp = fopen($lockKey, 'w+');
if (flock($fp, LOCK_EX | LOCK_NB)) { // 非阻塞尝试获取锁
// 3. 双重检查:可能等待期间缓存已被重建
$data = $cache->get($cacheKey);
if ($data !== false) {
flock($fp, LOCK_UN);
fclose($fp);
return json_decode($data, true);
}
// 4. 构建数据
$data = buildExpensiveData(); // 例如从数据库查询
// 5. 设置缓存(建议设置过期时间)
$cache->setex($cacheKey, 3600, json_encode($data));
// 6. 释放锁
flock($fp, LOCK_UN);
fclose($fp);
return $data;
} else {
// 7. 无法获取锁,等待后重试
usleep(100000); // 等待100毫秒
return getDataWithFileLock($key); // 递归重试
}
}
// 使用
$result = getDataWithFileLock('user_profile_123');
基于Redis的SETNX互斥锁(推荐)
适用于分布式部署,Redis提供了原子操作。
<?php
class CacheMutex {
private $redis;
private $lockPrefix = 'lock:';
private $lockTimeout = 5; // 锁超时秒数
private $retryDelay = 100000; // 微秒
private $maxRetries = 10;
public function __construct($redis) {
$this->redis = $redis;
}
public function getWithMutex($key, callable $callback, $ttl = 3600) {
$lockKey = $this->lockPrefix . $key;
$data = $this->redis->get($key);
// 缓存命中
if ($data !== false && $data !== null) {
return json_decode($data, true);
}
// 尝试获取锁
$retryCount = 0;
while ($retryCount < $this->maxRetries) {
// SET NX EX 原子操作
$locked = $this->redis->set($lockKey, time() + $this->lockTimeout,
['nx', 'ex' => $this->lockTimeout]);
if ($locked) {
try {
// 双重检查:可能等待期间缓存已被重建
$data = $this->redis->get($key);
if ($data !== false && $data !== null) {
return json_decode($data, true);
}
// 执行数据构建
$result = $callback();
// 设置缓存
if ($result !== null) {
$this->redis->setex($key, $ttl, json_encode($result));
}
return $result;
} finally {
// 释放锁(使用Lua脚本确保原子性)
$this->releaseLock($lockKey);
}
}
// 等待后重试
$retryCount++;
usleep($this->retryDelay);
}
// 达到最大重试次数,降级处理
return $this->fallback($key);
}
private function releaseLock($lockKey) {
// 使用Lua脚本:只有当前进程持有的锁才释放,防止误删
$script = "
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
";
$this->redis->eval($script, [$lockKey, time() + $this->lockTimeout], 1);
}
private function fallback($key) {
// 降级策略:返回旧缓存或默认值
$oldData = $this->redis->get($key);
if ($oldData !== false) {
return json_decode($oldData, true);
}
return null; // 或者返回默认数据
}
}
// 使用
$cacheMutex = new CacheMutex($redis);
$data = $cacheMutex->getWithMutex(
'user_profile_123',
function() use ($db) {
// 这里是数据构建逻辑
return $db->query('SELECT * FROM users WHERE id = 123');
},
3600
);
基于Memcached的互斥锁
<?php
function getDataWithMemcachedMutex($key, $memcached, $db) {
$cacheKey = 'data:' . $key;
$lockKey = 'lock:' . $key;
$lockTimeout = 5;
// 1. 尝试获取缓存
$data = $memcached->get($cacheKey);
if ($memcached->getResultCode() === Memcached::RES_SUCCESS) {
return $data;
}
// 2. 尝试获取锁
$locked = $memcached->add($lockKey, '1', $lockTimeout);
if ($locked) {
try {
// 双重检查
$data = $memcached->get($cacheKey);
if ($memcached->getResultCode() === Memcached::RES_SUCCESS) {
return $data;
}
// 3. 构建数据
$data = $db->query("SELECT * FROM table WHERE id = ?", [$key]);
// 4. 设置缓存
$memcached->set($cacheKey, $data, 3600);
return $data;
} finally {
// 5. 释放锁
$memcached->delete($lockKey);
}
} else {
// 6. 未获取到锁,等待后重试
usleep(200000); // 200ms
return getDataWithMemcachedMutex($key, $memcached, $db); // 递归重试
}
}
更优雅的实现:使用 Redis 的分布式锁管理
<?php
use Luracast\Config\Config;
use Illuminate\Support\Facades\Cache;
class MutexManager {
private $redis;
public function __construct() {
$this->redis = Cache::store('redis')->getRedis();
}
public function getWithMutex($key, callable $callback, $ttl = 60) {
$lockKey = "mutex:$key";
$lockValue = uniqid('', true);
$lockTTL = 10; // 锁持有时间
// 使用 RedLock 算法(功能简化版)
$locked = $this->redis->set(
$lockKey,
$lockValue,
['NX', 'EX' => $lockTTL]
);
if ($locked) {
try {
$data = Cache::get($key);
if ($data !== null) {
return $data;
}
$data = $callback();
Cache::put($key, $data, $ttl);
return $data;
} finally {
// 释放锁,只释放自己的锁
$script = "
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
";
$this->redis->eval($script, [$lockKey, $lockValue], 1);
}
}
// 未获取到锁,等待后重试
usleep(100000); // 100ms
return $this->getWithMutex($key, $callback, $ttl);
}
}
最佳实践建议
锁的超时设置
- 设置合理的超时时间:防止持有锁的进程崩溃导致死锁
- 锁时间应远大于数据构建时间:避免锁过期后多个进程同时构建数据
双重检查模式
// 在获取锁后,再次检查缓存
$locked = $this->redis->set($lockKey, '1', ['nx', 'ex' => 5]);
if ($locked) {
// 再次检查缓存(双重检查)
if ($cache->exists($cacheKey)) {
// 其他线程已经重建了缓存,直接返回
return $cache->get($cacheKey);
}
// 开始构建数据...
}
降级策略
// 达到最大重试次数后的降级
public function fallback($key) {
// 1. 返回旧缓存
$oldData = $this->cache->get($key);
if ($oldData) return $oldData;
// 2. 返回默认数据
return ['status' => 'maintenance', 'message' => '数据加载中...'];
// 3. 或者使用本地缓存(例如文件)
$localCache = $this->getLocalCache($key);
}
避免锁竞争优化
// 使用公平锁或梯度等待时间 $waitTime = min(200000, 100000 * ($retryCount + 1)); usleep($waitTime); // 逐渐增加等待时间
注意事项
- 锁的粒度:尽量细粒度,只对热点数据加锁
- 避免永久锁:所有锁必须有超时机制
- 锁竞争监控:如果大量请求等待锁,可能需要考虑:
- 使用更短的锁过期时间
- 使用读写锁(读多写少场景)
- 考虑使用其他方案(如限流、多级缓存)
选择适合你项目架构的方案,分布式系统推荐使用Redis的SETNX,单机部署可以使用文件锁。