Cache::get()与Cache::set()分离操作在高并发下必然引发冲突,导致重复查库、TTL覆盖、脏缓存;必须用原子写入(如['EX'=>ttl]或Cache::remember)并配合分布式锁(同DB、finally释放、限重试)。

Cache::get() + Cache::set() 三步操作必然引发冲突
直接写 if (!$data = Cache::get($key)) { $data = Db::find($id); Cache::set($key, $data, 3600); } 是高并发下缓存错乱的头号来源。它把“判断是否存在”“查库”“写缓存+设过期”拆成三步,中间任何环节被并发请求插入,就会导致:多个请求同时查库、重复写入、TTL 被覆盖、甚至留下永不过期的脏缓存。
ThinkPHP 5.1/6.x 的 Cache::set() 默认不带原子过期,Cache::set($key, $val, 3600) 实际是先 SET 再 EXPIRE 两命令,网络抖动或 Redis 延迟时极易分离。必须显式用数组参数:
-
Cache::set($key, $value, ['EX' => $ttl])—— 底层调用SETEX,原子完成 - 或更推荐
Cache::remember($key, $ttl, function () use ($id) { return Db::find($id); })—— 自动处理空值、异常、原子写入
Redis 分布式锁不是 setnx 就完事
很多人以为 Cache::handler()->set($lockKey, 1, ['nx', 'ex' => 5]) 加了锁就安全了,但漏掉三个致命点:锁 key 和缓存 key 不在同一个 Redis DB、锁没进 finally 释放、重试没上限。
ThinkPHP 5.1 的 Cache::handler() 返回的是驱动实例,但它的 set() 方法是否真正执行 SET key val NX PX ms,取决于底层 Redis 客户端(Predis 或 phpredis)和配置。确认以下几点:
立即学习“PHP免费学习笔记(深入)”;
- 锁 key 必须和缓存 key 同库同前缀,例如缓存用
user:123,锁必须用lock:user:123,且配置中'select' => 0显式指定 DB - 锁释放必须放在
try...finally块里,哪怕Db::find()抛异常、序列化失败、网络超时,都得执行Cache::handler()->delete($lockKey) - 等待锁最多循环 3 次,每次
usleep(50000)(50ms),避免堆积放大雪崩
多级缓存(APCu + Redis)不同步会读到旧值
启用 APCu 作本地缓存、Redis 作分布式缓存后,ThinkPHP 不会自动帮你同步两级。更新数据时只删 Cache::store('redis')->delete($key),APCu 里的副本还在,后续请求从本地内存拿到的就是脏数据。
必须手动双删,且顺序不能错:
- 先删 APCu:
Cache::store('apcu')->delete($key) - 再删 Redis:
Cache::store('redis')->delete($key) - 如果用
Cache::tag()打标,注意它只是逻辑分组,Cache::tag('user')->clear()会批量删 Redis 中所有匹配 tag 的 key,可能误伤其他业务,加剧雪崩
主从架构下读写分离导致缓存读到过期数据
Redis 主从部署后,ThinkPHP 默认的 Cache::get() 会走从节点(readwrite 模式下读操作优先从库),但主从同步有延迟。刚在主库写入新缓存,从库还没同步完,读请求就命中了旧值或空值——这不是 bug,是默认行为。
关键不是禁用从库,而是按场景选读路径:
- 对一致性要求高的读(如订单详情、用户余额),强制走主节点:
Cache::store('redis_master')->get($key),需额外配一个只连 master 的 store - 对最终一致可接受的读(如文章列表、商品分类),继续走从库,但 TTL 必须加随机偏移,比如
$ttl = 3600 + rand(1, 300),避免大量 key 同时失效触发回源风暴 - 别依赖
think cache:clear命令清理分布式缓存——它只清当前配置的那台 Redis,其他节点照旧,线上必须用redis-cli -h host -p port FLUSHDB手动逐台执行



















