Skip to content

Redis 最佳实践

概述

Redis 在 PHP 项目中通常用于缓存、会话存储、消息队列和实时数据处理。合理使用 Redis 可以显著提升应用性能,但不当使用也会引入内存泄漏、数据不一致和性能瓶颈。

核心原则

  • Redis 是缓存层,不是持久存储的唯一方案
  • 合理设置过期时间,避免内存无限增长
  • 使用 Pipeline 减少网络往返
  • 永远为 Key 设置前缀

基础概念

Redis 使用场景分类

场景数据类型过期策略持久化需求
数据缓存String/Hash短期(分钟~小时)不需要
会话存储Hash中期(30min~2h)需要
排行榜Zset按周期可选
消息队列List无过期可选
限流计数String/Key短期(秒~分钟)不需要
分布式锁String短期(秒)不需要

语法与代码

连接管理最佳实践

php
<?php
declare(strict_types=1);

// 推荐: 使用单例/工厂管理 Redis 连接
class RedisManager
{
    private static ?Redis $instance = null;

    public static function connect(array $config = []): Redis
    {
        if (self::$instance !== null) {
            // 检查连接是否仍然有效
            try {
                self::$instance->ping();
                return self::$instance;
            } catch (RedisException) {
                self::$instance = null;
            }
        }

        $defaults = [
            'host'      => '127.0.0.1',
            'port'      => 6379,
            'timeout'   => 2.0,
            'read_timeout' => 5.0,
            'password'  => '',
            'database'  => 0,
            'prefix'    => 'app:',
            'serializer' => Redis::SERIALIZER_NONE,
        ];
        $config = array_merge($defaults, $config);

        $redis = new Redis();
        $redis->connect($config['host'], $config['port'], $config['timeout']);
        $redis->setOption(Redis::OPT_READ_TIMEOUT, $config['read_timeout']);

        if (!empty($config['password'])) {
            $redis->auth($config['password']);
        }

        if ($config['database'] > 0) {
            $redis->select($config['database']);
        }

        $redis->setOption(Redis::OPT_PREFIX, $config['prefix']);
        $redis->setOption(Redis::OPT_SERIALIZER, $config['serializer']);

        self::$instance = $redis;
        return $redis;
    }
}

Pipeline 批量操作

php
<?php
declare(strict_types=1);

// 推荐: 批量操作使用 Pipeline
function batchSet(Redis $redis, array $items, int $ttl = 3600): void
{
    $redis->multi(Redis::PIPELINE);
    foreach ($items as $key => $value) {
        if ($ttl > 0) {
            $redis->setex($key, $ttl, $value);
        } else {
            $redis->set($key, $value);
        }
    }
    $redis->exec();
}

function batchGet(Redis $redis, array $keys): array
{
    $redis->multi(Redis::PIPELINE);
    foreach ($keys as $key) {
        $redis->get($key);
    }
    $results = $redis->exec();

    $data = [];
    foreach ($keys as $i => $key) {
        $data[$key] = $results[$i] !== false ? $results[$i] : null;
    }
    return $data;
}

// 使用示例
$redis = RedisManager::connect();

// 批量设置
batchSet($redis, [
    'user:1001' => json_encode(['name' => '张三']),
    'user:1002' => json_encode(['name' => '李四']),
    'user:1003' => json_encode(['name' => '王五']),
]);

// 批量获取
$users = batchGet($redis, ['user:1001', 'user:1002', 'user:1003']);

缓存穿透防护

php
<?php
declare(strict_types=1);

class CacheWithProtection
{
    private Redis $redis;
    private PDO $db;
    private int $nullCacheTtl = 60; // 空值缓存60秒

    public function __construct(Redis $redis, PDO $db)
    {
        $this->redis = $redis;
        $this->db = $db;
    }

    /**
     * 带防穿透的缓存查询
     */
    public function get(string $key, callable $dbQuery): mixed
    {
        // 1. 查 Redis
        $cached = $this->redis->get($key);
        if ($cached !== false) {
            if ($cached === 'NULL_PLACEHOLDER') {
                return null; // 空值缓存
            }
            return json_decode($cached, true);
        }

        // 2. 查数据库
        $data = $dbQuery();

        // 3. 写入缓存
        if ($data !== null) {
            $this->redis->setex($key, 3600, json_encode($data));
        } else {
            // 缓存空值,防止穿透
            $this->redis->setex($key, $this->nullCacheTtl, 'NULL_PLACEHOLDER');
        }

        return $data;
    }

    /**
     * 布隆过滤器防穿透(需安装 RedisBloom 模块)
     */
    public function existsInBloomFilter(string $filter, string $value): bool
    {
        // 使用 Redis 命令: BF.EXISTS filter value
        $result = $this->redis->rawCommand('BF.EXISTS', $filter, $value);
        return (bool) $result;
    }
}

缓存击穿防护

php
<?php
declare(strict_types=1);

class CacheBreakdownProtection
{
    private Redis $redis;
    private string $lockPrefix = 'lock:cache:';
    private int $lockTtl = 10;

    public function __construct(Redis $redis)
    {
        $this->redis = $redis;
    }

    /**
     * 使用互斥锁防止缓存击穿
     */
    public function getWithLock(string $cacheKey, callable $dbQuery, int $cacheTtl = 3600): mixed
    {
        // 1. 查缓存
        $cached = $this->redis->get($cacheKey);
        if ($cached !== false && $cached !== 'NULL_PLACEHOLDER') {
            return json_decode($cached, true);
        }

        // 2. 缓存失效,获取锁
        $lockKey = $this->lockPrefix . $cacheKey;
        $lockToken = uniqid((string) mt_rand(), true);
        $acquired = $this->redis->set($lockKey, $lockToken, ['nx' => true, 'ex' => $this->lockTtl]);

        if ($acquired) {
            try {
                // 双重检查 — 防止其他进程已重建缓存
                $cached = $this->redis->get($cacheKey);
                if ($cached !== false && $cached !== 'NULL_PLACEHOLDER') {
                    return json_decode($cached, true);
                }

                // 查数据库并重建缓存
                $data = $dbQuery();
                if ($data !== null) {
                    $this->redis->setex($cacheKey, $cacheTtl, json_encode($data));
                } else {
                    $this->redis->setex($cacheKey, 60, 'NULL_PLACEHOLDER');
                }
                return $data;
            } finally {
                // 释放锁
                $this->releaseLock($lockKey, $lockToken);
            }
        }

        // 3. 未获取锁 — 短暂等待后重试
        usleep(100000); // 100ms
        return $this->get($cacheKey);
    }

    private function releaseLock(string $lockKey, string $token): void
    {
        $script = <<<'LUA'
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
end
return 0
LUA;
        $this->redis->eval($script, [$lockKey, $token], 1);
    }

    private function get(string $key): mixed
    {
        $cached = $this->redis->get($key);
        if ($cached !== false && $cached !== 'NULL_PLACEHOLDER') {
            return json_decode($cached, true);
        }
        return null;
    }
}

实战示例

缓存策略实现

php
<?php
declare(strict_types=1);

class CacheManager
{
    private Redis $redis;
    private string $prefix = 'cache:';
    private int $defaultTtl = 3600;

    public function __construct(Redis $redis)
    {
        $this->redis = $redis;
    }

    /**
     * Cache-Aside: 先查缓存,未命中查数据库
     */
    public function remember(string $key, callable $query, ?int $ttl = null): mixed
    {
        $cacheKey = $this->prefix . $key;
        $cached = $this->redis->get($cacheKey);

        if ($cached !== false) {
            return $this->unserialize($cached);
        }

        $data = $query();

        if ($data !== null) {
            $this->redis->setex($cacheKey, $ttl ?? $this->defaultTtl, json_encode($data));
        }

        return $data;
    }

    /**
     * Write-Through: 写入数据库同时更新缓存
     */
    public function writeThrough(string $key, mixed $value, callable $dbWrite, ?int $ttl = null): void
    {
        $dbWrite($value);
        $cacheKey = $this->prefix . $key;
        $this->redis->setex($cacheKey, $ttl ?? $this->defaultTtl, json_encode($value));
    }

    /**
     * Write-Behind: 延迟写入数据库(异步)
     */
    public function writeBehind(string $key, mixed $value, callable $dbWrite): void
    {
        $cacheKey = $this->prefix . $key;
        $this->redis->setex($cacheKey, 3600, json_encode($value));

        // 将写入操作放入队列,由消费者异步处理
        $this->redis->rPush('queue:write_behind', json_encode([
            'key' => $key,
            'value' => $value,
            'callback' => $dbWrite(...),
        ]));
    }

    /**
     * 刷新缓存
     */
    public function forget(string $key): void
    {
        $this->redis->del($this->prefix . $key);
    }

    private function unserialize(string $data): mixed
    {
        return json_decode($data, true);
    }
}

内存管理策略

php
<?php
declare(strict_types=1);

class RedisMemoryManager
{
    private Redis $redis;

    public function __construct(Redis $redis)
    {
        $this->redis = $redis;
    }

    /**
     * 获取内存使用报告
     */
    public function getMemoryReport(): array
    {
        $info = $this->redis->info('memory');
        return [
            'used_memory'      => $info['used_memory_human'],
            'used_memory_peak' => $info['used_memory_peak_human'],
            'used_memory_rss'  => $info['used_memory_rss_human'],
            'maxmemory'        => $info['maxmemory'] ?: '0',
            'maxmemory_policy' => $info['maxmemory_policy_human'] ?? 'noeviction',
            'fragmentation_ratio' => $info['mem_fragmentation_ratio'],
        ];
    }

    /**
     * 清理过期 Key
     */
    public function cleanExpiredKeys(): int
    {
        // SCAN 查找所有带 TTL 的键
        $iterator = null;
        $cleaned = 0;

        // 通过 DBSIZE 和 info 估算过期键数量
        $info = $this->redis->info('keyspace');
        $expires = $this->redis->info('stats');
        $expiredCount = $expires['expired_keys'] ?? 0;

        echo "已过期键总数: {$expiredCount}\n";
        echo "注意: Redis 会自动清理过期键,无需手动处理\n";

        return $cleaned;
    }

    /**
     * 分析大 Key
     */
    public function findBigKeys(int $threshold = 10240): array
    {
        $bigKeys = [];
        $iterator = null;

        $redis = $this->redis;
        while ($keys = $redis->scan($iterator, '*', 1000)) {
            foreach ($keys as $key) {
                $type = $redis->type($key);
                $size = 0;

                switch ($type) {
                    case Redis::TYPE_STRING:
                        $size = $redis->strlen($key);
                        break;
                    case Redis::TYPE_HASH:
                        $size = $redis->hLen($key) * 100; // 估算
                        break;
                    case Redis::TYPE_LIST:
                        $size = $redis->lLen($key) * 100;
                        break;
                    case Redis::TYPE_SET:
                        $size = $redis->sCard($key) * 100;
                        break;
                    case Redis::TYPE_ZSET:
                        $size = $redis->zCard($key) * 100;
                        break;
                }

                if ($size > $threshold) {
                    $bigKeys[$key] = ['type' => $type, 'size' => $size];
                }
            }
        }

        return $bigKeys;
    }
}

注意事项

常见陷阱

php
<?php
// 陷阱1: 不设过期时间导致内存泄漏
// 错误
$redis->set('temp:data', $value); // 永不过期

// 正确
$redis->setex('temp:data', 3600, $value); // 1小时后过期

// 陷阱2: 热点 Key 导致单节点压力过大
// 解决: 本地缓存 + Key 分片

// 陷阱3: Pipeline 中使用 write 操作时出错回滚
$redis->multi(Redis::PIPELINE);
$redis->set('a', '1');
$redis->incr('not_a_number'); // 类型错误
$results = $redis->exec(); // 部分成功,需检查返回值

// 陷阱4: SELECT 在持久连接中共享状态
$redis->select(1); // 可能影响其他请求
// 推荐: 不同逻辑使用不同前缀,而非不同数据库

持久连接的 SELECT 陷阱

使用 pconnect() 持久连接时,select() 切换数据库是全局的。如果多个请求使用同一个持久连接并调用 select(1),可能导致数据混乱。推荐使用 Key 前缀代替数据库切换

最佳实践

1. 生产环境配置

ini
; redis.conf 关键配置

# 内存限制
maxmemory 2gb

# 淘汰策略
maxmemory-policy allkeys-lru

# 持久化(根据场景选择)
# 仅缓存: 不开启持久化
# 缓存+轻量持久化: AOF appendonly yes
# 会话存储: RDB + AOF

# 网络安全
bind 127.0.0.1
requirepass your_strong_password
protected-mode yes

# 性能调优
tcp-backlog 511
timeout 300
tcp-keepalive 60

2. 监控指标

php
<?php
class RedisMonitor
{
    private Redis $redis;

    public function __construct(Redis $redis)
    {
        $this->redis = $redis;
    }

    public function getMetrics(): array
    {
        $info = $this->redis->info();
        return [
            'uptime'         => $info['uptime_in_seconds'],
            'connected_clients' => $info['connected_clients'],
            'used_memory'    => $info['used_memory_human'],
            'keyspace_hits'  => $info['keyspace_hits'],
            'keyspace_misses' => $info['keyspace_misses'],
            'hit_rate'       => $this->calcHitRate($info),
            'ops_per_sec'    => $info['instantaneous_ops_per_sec'],
            'blocked_clients' => $info['blocked_clients'],
        ];
    }

    private function calcHitRate(array $info): string
    {
        $hits = (int) ($info['keyspace_hits'] ?? 0);
        $misses = (int) ($info['keyspace_misses'] ?? 0);
        $total = $hits + $misses;
        return $total > 0
            ? round($hits / $total * 100, 2) . '%'
            : 'N/A';
    }
}

3. 错误处理与降级

php
<?php
class RedisWithFallback
{
    private ?Redis $redis = null;
    private bool $available = true;

    public function __construct(string $host, int $port)
    {
        try {
            $this->redis = new Redis();
            $this->redis->connect($host, $port, 1.0);
            $this->redis->ping();
        } catch (RedisException $e) {
            $this->available = false;
            error_log("Redis 不可用: " . $e->getMessage());
        }
    }

    public function get(string $key): mixed
    {
        if (!$this->available || $this->redis === null) {
            return null; // 降级: 直接查数据库
        }

        try {
            return $this->redis->get($key);
        } catch (RedisException $e) {
            $this->available = false;
            error_log("Redis 读取失败: " . $e->getMessage());
            return null;
        }
    }

    public function set(string $key, string $value, int $ttl = 3600): bool
    {
        if (!$this->available || $this->redis === null) {
            return false; // 降级: 缓存写入失败不影响主流程
        }

        try {
            return $this->redis->setex($key, $ttl, $value);
        } catch (RedisException $e) {
            $this->available = false;
            error_log("Redis 写入失败: " . $e->getMessage());
            return false;
        }
    }

    public function isAvailable(): bool
    {
        return $this->available;
    }
}

参考链接