直接用$article->read_count++会因并发导致计数丢失,因PHP非原子操作引发“读-改-写”竞态;应改用Db::name('article')->where('id',$id)->inc('read_count')->update()实现数据库行级锁原子递增。

为什么直接在文章详情页加 $article->read_count++ 会出问题
因为并发请求下,多个用户几乎同时访问同一篇文章,PHP 读取当前 read_count 值(比如是 100),各自加 1 后都写回 101,实际只涨了 1 次。这不是代码写错了,是典型的「读-改-写」竞态条件。
ThinkPHP 的 ORM 默认不带原子递增语义,save() 或 update() 都是先查后改,无法规避这个问题。
- 别用
$article->read_count+++save(),哪怕加了where(['id' => $id])也无效 - 别依赖前端 JS 发起计数请求——容易被脚本批量刷、无 referer 也能调
- 缓存层(如 Redis)若没设过期或没做去重,反而放大刷量风险
用 Db::raw() + inc() 实现数据库原子递增
最稳的方式是绕过 ORM 的对象生命周期,直连 SQL 的 UPDATE ... SET read_count = read_count + 1。ThinkPHP 提供了安全封装:
use think\facade\Db;
Db::name('article')->where('id', $id)->inc('read_count')->update();
这行代码生成的 SQL 是原子的,MySQL 会锁住该行(行级锁),确保并发更新不丢失。注意不是 setInc()(已废弃),也不是 exp() 手拼字符串(有注入风险)。
立即学习“PHP免费学习笔记(深入)”;
- 必须用
inc(),不能用setField('read_count', 'read_count + 1')—— 后者会被当字符串处理 - 如果表用了软删除(
delete_time),记得加where('delete_time', null) - 该操作不触发模型事件(如
afterUpdate),如有日志或同步需求,需额外处理
如何防机器刷量:IP + UA + 时间窗口三重校验
纯原子递增只能保数据不丢,不能防刷。真实场景中,同一个 IP 在 1 小时内重复刷同一篇文章,大概率是脚本。建议在递增前加轻量校验:
// 缓存 key 示例:read_limit_{$ip}_{$aid}
$cacheKey = 'read_limit_' . request()->ip() . '_' . $id;
if (cache($cacheKey)) {
return; // 已计数,跳过
}
// 原子递增成功后再设缓存(有效期 1 小时)
Db::name('article')->where('id', $id)->inc('read_count')->update();
cache($cacheKey, true, 3600);
这里用的是 ThinkPHP 内置缓存(默认 File,生产建议切 Redis)。关键点:
- 缓存 key 必须含 IP 和文章 ID,不能只按 IP 限——否则用户看两篇文章就卡住
- UA 可选加在 key 里(如
$ua = md5(request()->header('user-agent'))),但移动端 UA 变化频繁,慎用 - 不要用 session 校验——API 场景无 session,且分布式部署 session 不共享
Redis 计数异步落库的适用边界
高流量站点(如首页文章 PV > 5000/分钟)才需要 Redis 缓存计数再定时写回 DB。否则增加复杂度,还可能因进程崩溃丢数据。
若决定上 Redis,记住两个硬约束:
- Redis 的
INCR本身是原子的,但「读 Redis → 写 DB → 清 Redis」这一整套不是,必须用 Lua 脚本或事务包住 - DB 回写不能用
replace into或insert on duplicate key update,ThinkPHP 不原生支持,得手写Db::execute() - 务必设置 Redis key 过期时间(如
SETEX read_count_{$id} 86400 0),否则内存无限涨
真正难的不是怎么写,而是怎么监控——比如 Redis 计数比 DB 高出 20%,说明回写延迟或失败,得配 Prometheus + Grafana 看趋势。



















