小心 Laravel 中的 Model::increment

Laravel v5.4.18 中的一个提交,导致的 BUG,因为添加了错误的单测,导致没办法轻易修改,这里提醒大家,使用时需要谨慎,以免采坑。

commit

pr#35748

BUG 重现

1. increment extra 后再进行 save 操作,会执行两句SQL

我们先写一段没有 extra 数据的代码,进行测试

DB::enableQueryLog();
$model = UserExt::query()->find(101);
$model->increment('count');
$model->save();
dump(DB::getQueryLog());

通过测试得知,以上代码只会生成两段 SQL,分别是

select * from `user_ext` where `user_ext`.`id` = ? limit 1
update `user_ext` set `count` = `count` + 1, `user_ext`.`updated_at` = ? where `id` = ?

然后让我们修改测试代码

DB::enableQueryLog();
$model = UserExt::query()->find(101);
$model->increment('count', 1, [
    'str' => uniqid()
]);
$model->save();
dump(DB::getQueryLog());

这时,会生成以下三段 SQL

select * from `user_ext` where `user_ext`.`id` = ? limit 1
update `user_ext` set `count` = `count` + 1, `str` = ?, `user_ext`.`updated_at` = ? where `id` = ?
update `user_ext` set `str` = ?, `user_ext`.`updated_at` = ? where `id` = ?

且第二段和第三段 SQL 中,str 的值是一致的。这个问题的主要原因,便是 extra 里的数据不会被同步到 original 中,就导致第二次 save 计算 dirty 的时候,出现了BUG。

2. getChanges 表现不一致

经过第一个 BUG 的重现,那么第二个问题也就很容易想到了,就是 getChanges 方法。

让我们继续编写代码测试

$model = UserExt::query()->find(101);
$model->increment('count');
dump($model->getChanges());

以上代码会输出以下数据,可见还是符合预期的

array:1 ["count" => 4
]

让我们继续修改代码,在 increment 前增加一次赋值

DB::enableQueryLog();
$model = UserExt::query()->find(101);
$model->str = uniqid();
$model->increment('count');
dump($model->getChanges());
dump(DB::getQueryLog());

会得到以下输出

array:2 ["count" => 7
  "str" => "5febf2dc798ed"
]

看似没有问题,但让我们检查一下 SQL

select * from `user_ext` where `user_ext`.`id` = ? limit 1
update `user_ext` set `count` = `count` + 1, `user_ext`.`updated_at` = ? where `id` = ?

却发现,并没有修改 str 的数据,那显然 getChanges 与预期不符。

实际上,increment 在设计上,并没有想要修改前面 setter 的数据,但这种情况下,我们 getChanges 便也不能把 str 算进来。

让我们继续修改代码

DB::enableQueryLog();
$model = UserExt::query()->find(101);
$model->str = uniqid();
$model->increment('count');
dump($model->getChanges());
$model->save();
dump($model->getChanges());
dump(DB::getQueryLog());

两次 getChanges 输出如下

array:2 ["count" => 9
  "str" => "5febf3d6418e8"
]
array:2 ["str" => "5febf3d6418e8"
  "updated_at" => "2020-12-30 03:28:22"
]

可见两次 getChanges 中,str 的值是一致的。

save 的时候会把 updated_at 算进来,而 increment 的时候是不会算 updated_at,这里至少行为一致,可以作为后续的优化项。

输出的 SQL 如下

select * from `user_ext` where `user_ext`.`id` = ? limit 1
update `user_ext` set `count` = `count` + 1, `str` = ?, `user_ext`.`updated_at` = ? where `id` = ?
update `user_ext` set `str` = ?, `user_ext`.`updated_at` = ? where `id` = ?

写在最后

Hyperf 是基于 Swoole 4.5+ 实现的高性能、高灵活性的 PHP 协程框架,内置协程服务器及大量常用的组件,性能较传统基于 PHP-FPM 的框架有质的提升,提供超高性能的同时,也保持着极其灵活的可扩展性,标准组件均基于 PSR 标准 实现,基于强大的依赖注入设计,保证了绝大部分组件或类都是 可替换可复用 的。

框架组件库除了常见的协程版的 MySQL 客户端Redis 客户端,还为您准备了协程版的 Eloquent ORMWebSocket 服务端及客户端JSON RPC 服务端及客户端GRPC 服务端及客户端OpenTracing(Zipkin, Jaeger) 客户端Guzzle HTTP 客户端Elasticsearch 客户端Consul、Nacos 服务中心ETCD 客户端AMQP 组件Nats 组件Apollo、ETCD、Zookeeper、Nacos 和阿里云 ACM 的配置中心基于令牌桶算法的限流器通用连接池熔断器Swagger 文档生成Swoole TrackerBlade、Smarty、Twig、Plates 和 ThinkTemplate 视图引擎Snowflake 全局ID生成器Prometheus 服务监控 等组件,省去了自己实现对应协程版本的麻烦。

Hyperf 还提供了 基于 PSR-11 的依赖注入容器注解AOP 面向切面编程基于 PSR-15 的中间件自定义进程基于 PSR-14 的事件管理器Redis/RabbitMQ 消息队列自动模型缓存基于 PSR-16 的缓存Crontab 秒级定时任务Sessioni18n 国际化Validation 表单验证 等非常便捷的功能,满足丰富的技术场景和业务场景,开箱即用。

本作品采用《CC 协议》,转载必须注明作者和本文链接
Any fool can write code that a computer can understand. Good programmers write code that humans can understand.
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
讨论数量: 3

file

朋友,你写了这么多真的很辛苦了,但是这个吧,我觉得它不是个bug。

incrementdecrement,这个东西就不是配合save()来使用的,只要使用了这个函数就会直接生成一个 field = field + 1 的 SQL,上方截图文档也描述的比较清楚,如果要顺带更新其他字段也提供了方式。

代码中可以 执行完 increment 后,如果想用 模型 的需求, 可以再查询一下同步一下。


DB::enableQueryLog();
$model = UserExt::query()->find(101);
$model->increment('count', 1, [
    'str' => uniqid()
]);
$model->save();
dump(DB::getQueryLog());

你文章中的这段代码,生成了一个查询和一个更改语句,但是那条语句并不是save产生的,因为模型值没有改变,save的时候对比没有变化,故只执行了select

4年前 评论
李铭昕 (楼主) 4年前
李铭昕 (楼主) 4年前
Adachi (作者) 4年前
李铭昕 (楼主) 4年前
李铭昕 (楼主) 4年前
李铭昕

file

@lengqy 大兄弟,咱别看翻译的文档。。。咱可以看看源码。。。

4年前 评论
Adachi 4年前

第一个问题,increment 后面跟着 save 的情况,本身就不合逻辑。你想要在 increment 的同时,更新其他字段,框架已经提供 extra 参数来达到这个目地了。这个时候非要再 save 一下,纯粹是多余操作。

第二个问题,接上条,你想要在 increment 的同时,更新其他字段,不用框架提供的 extra 参数,非要直接修改属性,修改属性了又不 save,怪框架没给你更新。

总结一下,该 save 的不 save, 不该 save 的时候偏要 save,应该是你使用的姿势不对。

============================================================================

吐槽结束,回到这个问题来看,我的观点是在 8.x 以前的版本中,这不算是一个 bug,但在 8.x 的版本中,算是一个 bug。我这样说的理由,是基于 increment 操作的语义。

模型的 fill 方法,或者修改属性,会导致 dirty 的状态发生变化;模型的 update 操作,导致 changed 状态发生变化。

那么在解决这个问题前,不妨先明确这几个问题。

  • increment 的操作是否包含 fill 的语义? 从官方的实现来看,是不包含的。
  • incrementextra 的操作是否包含 fill 额外字段的语义? 从官方的实现来看,是包含的。
  • increment 的操作是否包含 update 的语义? 从官方的实现来看, 8.x 以前的版本中不包含,但在 8.x 中是包含的。

increment 不包含被自增的字段的 fill 语义,包含fill 额外字段的语义,因此在 increment 后做 save 操作, dirty 检查时会检查到 extra 的字段,所以也会执行相关 SQL 语句。

8.x 以前的版本中,increment 不包含update 的语义,所以在 increment 后面做 changed 检查没有有意义,无论检查是什么结果,都不能拿过来作为逻辑判断依据。

但在8.x 的版本中,increment 包含update 的语义,执行 increment 后,应该要对 changed 检查结果负责。

所以,我的看法是 8.x 的升级导致破坏了 increment 操作的语义,从这个角度来看,新版本确实应该修复这个问题。

increment[v8.x] = increment[v5.5] + update[v5.5]
4年前 评论
slpi1 (作者) 4年前
李铭昕 (楼主) 4年前
李铭昕 (楼主) 4年前
李铭昕 (楼主) 4年前
slpi1 (作者) 4年前
李铭昕 (楼主) 4年前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!