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 602. 监控指标
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;
}
}