把数据放进缓存之前先加密一次,到底有没有必要?这个问题没有统一答案,取决于缓存里存的是什么内容、部署环境是否可信。如果缓存中保存了用户手机号、身份证号、支付token这类敏感信息,而缓存层又部署在相对不可控的环境(比如共享的Redis实例、可被下载的文件缓存目录),加密就是一道必要的防线。但引入加密后,原本简单的读写操作变成了读取、解密、修改、加密、写回的多步流程,在没有锁保护的情况下,多个请求同时执行这套流程,极易出现旧数据覆盖新数据的并发问题。所以加密和锁机制往往需要一起设计。

一、PHP缓存数据是否需要加密:先分清数据敏感等级
不是所有缓存都要加密。判断标准很简单:假设攻击者拿到了缓存中的原始数据,他能做什么?如果答案是能直接拿到用户隐私、能伪造登录态、能重放支付请求,那这类数据就必须加密。典型的需要加密的缓存内容包括session中保存的敏感字段、记住登录状态的token、接口返回的个人资料快照等。
PHP中实现缓存加密最常用的是openssl扩展的对称加密。AES-256-CBC或AES-256-GCM都是可靠的选择,其中GCM模式自带完整性校验,能防止密文被篡改,推荐优先使用。下面是一个可直接复用的加密解密工具类:
<?php
class CacheCrypt
{
private string $key;
private string $cipher = 'aes-256-gcm';
public function __construct(string $key)
{
// 密钥必须是32字节(256位)
$this->key = hash('sha256', $key, true);
}
public function encrypt(string $plain): string
{
$iv = random_bytes(12); // GCM推荐12字节IV
$tag = '';
$cipherText = openssl_encrypt(
$plain, $this->cipher, $this->key,
OPENSSL_RAW_DATA, $iv, $tag
);
// IV和认证标签必须随密文一起存储,否则无法解密
return base64_encode($iv . $tag . $cipherText);
}
public function decrypt(string $data): string
{
$raw = base64_decode($data);
$iv = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$cipherText = substr($raw, 28);
return openssl_decrypt(
$cipherText, $this->cipher, $this->key,
OPENSSL_RAW_DATA, $iv, $tag
);
}
}</code>需要注意几个细节:IV必须使用random_bytes随机生成,绝不能固定不变;密钥不要硬编码在代码里,建议从环境变量或独立的密钥管理服务读取;解密失败时openssl_decrypt返回false,调用方必须处理这种情况,避免把false当作数据继续使用。
二、为什么加密缓存更容易出并发问题
普通的字符串计数可以依赖Redis的原子操作(如INCR)保证安全,但加密缓存做不到这一点。因为数据被加密后,Redis无法理解密文内容,任何修改都必须经历完整的读、解密、改、加密、写回流程。假设两个请求同时读到同一份密文A,各自解密修改后分别写回B和C,后写入的会覆盖先写入的,一次修改就静默丢失了。这类问题在计数器、库存扣减、抽奖次数限制等场景下尤其致命。
更隐蔽的风险在于加密本身的不确定性。同一段明文用随机IV加密两次会得到完全不同的密文,这意味着你无法通过比较密文是否变化来判断数据有没有被别人改过,也无法用Redis的WATCH实现乐观锁。所以加密缓存的并发控制只能依赖显式锁,要么用Redis分布式锁,要么在文件缓存场景下用flock文件锁。
三、Redis分布式锁的实现与踩坑要点
Redis锁的标准做法是SET key value NX EX timeout,用NX保证只有一个客户端能加锁,用EX设置过期时间防止死锁。释放锁时必须用Lua脚本校验锁的持有者,避免误删别人的锁。完整示例:
<?php
class RedisLock
{
private Redis $redis;
public function __construct(Redis $redis)
{
$this->redis = $redis;
}
public function lock(string $name, int $timeout = 10): ?string
{
$token = bin2hex(random_bytes(16)); // 唯一标识锁的持有者
$deadline = microtime(true) + 3; // 最多等待3秒
while (microtime(true) < $deadline) {
$ok = $this->redis->set('lock:' . $name, $token, ['nx', 'ex' => $timeout]);
if ($ok) {
return $token;
}
usleep(100000); // 100ms后重试
}
return null; // 获取锁失败
}
public function unlock(string $name, string $token): void
{
// 用Lua保证校验和删除是原子操作
$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, ['lock:' . $name, $token], 1);
}
}使用时把加密读改写整个流程包在锁内。值得提醒的是锁超时时间的设置:如果业务逻辑执行时间可能超过锁的过期时间,锁会提前释放导致并发保护失效。简单场景可以接受这个限制,要求严格的场景需要引入看门狗机制定时续期,或者直接使用现成的redislock库。另外锁粒度尽量细,比如按用户ID加锁而不是全局一把锁,否则并发能力会大幅下降。
四、文件缓存的flock锁方案
如果项目用的是文件缓存或者APCu这类单机缓存,PHP内置的flock函数就够用了。它的原理是操作系统级的文件锁,同一时刻只有一个进程能持有排它锁。示例代码:
<?php
function updateEncryptedCache(string $file, callable $modifier, CacheCrypt $crypt): bool
{
$fp = fopen($file, 'c+');
if (!$fp) {
return false;
}
// 排它锁,阻塞等待
if (!flock($fp, LOCK_EX)) {
fclose($fp);
return false;
}
try {
$raw = stream_get_contents($fp);
$plain = $raw === '' ? '[]' : $crypt->decrypt($raw);
$data = json_decode($plain, true) ?: [];
// 在锁内完成业务修改
$data = $modifier($data);
$newCipher = $crypt->encrypt(json_encode($data, JSON_UNESCAPED_UNICODE));
// 截断后重写,确保不留旧数据尾巴
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, $newCipher);
fflush($fp);
return true;
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
}flock的优势是不需要额外组件、语义简单;缺点是只对本机文件有效,一旦部署多台服务器做负载均衡就失效了。此外ftruncate加rewind的顺序不能颠倒,否则会在文件头部留下空白字节。如果锁竞争激烈,可以把LOCK_EX换成LOCK_EX | LOCK_NB配合重试,避免进程长时间阻塞占满fpm进程数。
五、综合建议与性能权衡
加密和加锁都会带来开销。AES-256-GCM加解密本身很快,通常单次耗时在微秒级,真正的性能瓶颈往往在锁等待上。因此设计上有几个原则值得遵循:第一,只对真正敏感的数据加密,普通配置类缓存直接明文存放即可;第二,能用Redis原子命令完成的操作不要绕道加锁,比如计数器可以拆成独立的明文key,只在最终展示时才合并进加密数据;第三,读多写少的场景可以考虑读不加锁、写加锁的策略,配合版本号检测读到的数据是否过期,需要时重读一次即可。
总结来说,PHP缓存加密本身是值得做的安全加固,但它改变了数据的操作模式,必须配套引入读写锁机制。Redis分布式锁适合多机部署,flock适合单机文件缓存,两者都要注意锁超时、锁粒度和释放的正确性。把这些细节处理好,加密缓存既能守住数据安全,也不会成为系统的性能短板。