ThinkPHP 8.x 中实现只读计算字段需定义访问器 getCacheHitRateAttr 实时计算,并在 setCacheHitRateAttr 中抛出异常;同时设 $readonly = ['cache_hit_rate'] 限制批量赋值,修改器兜底单属性赋值。

怎么让模型字段变成只读的计算属性(ThinkPHP 8.x)
ThinkPHP 模型本身不支持「只读计算字段」的原生声明,所谓 getXXXAttr 是读时动态计算,但默认仍允许写入(比如赋值后调用 save() 会尝试写进数据库),这容易掩盖逻辑错误。
正确做法是:在模型中显式拦截写操作,并配合访问器实现只读语义:
- 定义访问器
getCacheHitRateAttr做实时计算(如从缓存统计表或 Redis 中取命中次数/总次数) - 同时定义修改器
setCacheHitRateAttr,里面直接throw new RuntimeException('cache_hit_rate is read-only'); - 若用的是 ThinkPHP 8.0+ 的「只读字段」特性,需额外在模型中设置
protected $readonly = ['cache_hit_rate'];—— 但注意:该配置仅对批量赋值(data()/allowField())生效,对单个属性赋值($model->cache_hit_rate = ...)无约束,必须靠修改器兜底
缓存命中率监控该查哪几个数据源(Redis + DB 双校验场景)
单纯读 Redis 的 INFO stats 里的 keyspace_hits/keyspace_misses 不够——那是整个实例维度,无法对应到业务模型层。真正要监控的是「某类模型查询的缓存使用有效性」。
推荐组合方式:
立即学习“PHP免费学习笔记(深入)”;
- 在模型的
select查询前,用think\Cache::tag('user_model_query')打标,命中时计数 +1;未命中且最终从 DB 查出后,也记录一次 miss 计数(用 Redis 的INCR命令,key 形如cache:hit:user_model:202405) - 避免在访问器里实时查 Redis 统计值(性能杀器),改用定时任务每小时聚合一次,写入一张轻量
cache_monitor表,字段含model_name、date、hit_count、miss_count - 如果用了
think-swoole长连接,注意 Redis 连接复用下INFO结果可能被多个协程共享,不能直接拿它算单次请求命中率
为什么 getCacheHitRateAttr 返回值经常不准
常见原因是缓存键设计没和模型查询条件对齐,导致「以为命中了,其实用的是旧缓存」或「根本没走缓存」。
排查要点:
- 检查模型里是否启用了
useGlobalScope(false)或手动调用了withoutGlobalScope(),这会让全局缓存作用域失效,导致访问器读到空值 - 确认缓存 key 是否包含
$this->toArray()全量数据(尤其有时间戳、随机数字段时),会导致 key 不稳定,命中率虚低 - ThinkPHP 的
Cache::remember()默认不过期,如果业务要求「5 分钟内结果可接受延迟」,必须显式传300,否则第一次缓存永不过期,后续更新完全不触发重算
评估缓存有效性时最容易忽略的边界点
不是看平均命中率数字,而是看「缓存雪崩窗口内的抖动幅度」和「冷热分离是否真实生效」。
实操建议:
- 不要只统计
hit/(hit+miss),加一个「连续 miss 超过 3 次」的告警维度——这往往意味着缓存预热失败或 key 生成逻辑突变 - 对高频模型(如
UserModel),把缓存策略拆成两级:getById用永久缓存(带版本号),getList用短时缓存(60s),二者命中率必须分开统计,混在一起会掩盖问题 - 上线新缓存逻辑后,第一周必须对比「开启缓存」和「强制 bypass 缓存」两组 SQL 查询耗时分布(用
DB::listen()记录),否则你以为的“提升”,可能是 DB 自身负载波动造成的假象
缓存有效性从来不是静态配置出来的,是靠错峰采样、分层打标、异常归因一点点对出来的。别信一次性的命中率报表。



















