PHP项目缓存击穿如何设置互斥锁

wen PHP项目 26

本文目录导读:

PHP项目缓存击穿如何设置互斥锁

  1. 基于文件锁 (flock) 的互斥锁
  2. 基于Redis的SETNX互斥锁(推荐)
  3. 基于Memcached的互斥锁
  4. 更优雅的实现:使用 Redis 的分布式锁管理
  5. 最佳实践建议
  6. 注意事项

在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); // 逐渐增加等待时间

注意事项

  1. 锁的粒度:尽量细粒度,只对热点数据加锁
  2. 避免永久锁:所有锁必须有超时机制
  3. 锁竞争监控:如果大量请求等待锁,可能需要考虑:
    • 使用更短的锁过期时间
    • 使用读写锁(读多写少场景)
    • 考虑使用其他方案(如限流、多级缓存)

选择适合你项目架构的方案,分布式系统推荐使用Redis的SETNX,单机部署可以使用文件锁。

抱歉,评论功能暂时关闭!