ThinkPHP模型save()时需手动比对旧值记录状态日志:先lock(true)查旧数据,再对比关键状态字段,确保事务一致性和避免并发脏读;日志表须含model_name、record_id、from_value、to_value、field_name、operator_type、operator_id、高精度时间及extra_data。
模型 save() 时怎么自动记录状态变更日志
ThinkPHP 模型本身不内置状态变更日志功能,必须手动在
前后比对字段值。核心思路是:读取原始数据(用
或缓存旧值),再和待更新数据对比关键状态字段(如
、
),有差异才写日志。
常见错误是直接监听
钩子——此时原始数据已不可见,无法判断“从什么变到什么”。必须在
或显式查一次旧记录。
推荐在模型的
调用前主动查旧数据:
,再和
中的状态字段比对
若用
静态方法,务必先
,不能依赖
—— 它只含本次传入字段,不含完整旧值
状态字段名要硬编码比对,避免用循环遍历所有字段,否则容易误判非状态类字段(如
)
用事件监听实现日志写入但不破坏事务一致性
想解耦逻辑?可以用
事件,但必须确保日志写入和主更新在同一个数据库事务里,否则会出现“状态已改但日志没写”或反之。
ThinkPHP 6+ 的事件默认不参与事务,需手动处理。最稳的方式不是靠事件,而是把日志逻辑写进模型的
封装方法里,统一控制事务边界。
立即学习
“
PHP免费学习笔记(深入)
”;
不要在事件回调里单独开事务(
),这会嵌套事务,MySQL 可能静默降级为 auto-commit
如果坚持用事件,得在业务层手动传入事务对象,或通过
包裹整个操作,包括日志写入
日志表建议和主表同库同引擎(如都用 InnoDB),避免跨库事务引发 XA 问题
记录“从 A 到 B”时怎么避免脏读和并发覆盖
两个请求几乎同时修改同一订单状态,可能出现日志记成“待付款→已发货”,实际却是“待付款→已取消→已发货”,中间状态丢失。根本原因是没加行锁或版本校验。
单纯靠 PHP 层比对旧值不够,数据库层面必须保证读取旧值那一刻该记录未被其他事务修改。
查旧数据时用
:例如
,生成
避免用
这类非锁查询,它不阻塞并发写入,比对结果可能已过期
如果业务允许,可加乐观锁字段(如
),在
条件中带上旧 version,update 失败即说明已被改过,需重试或告警
日志表结构设计要注意哪些字段才够用
只记
、
、
不够。漏掉关键上下文,后期排查时根本没法还原现场。
尤其要注意时间精度和来源标识——ThinkPHP 默认的
秒级时间,在高频操作下无法区分先后;不带模块/控制器信息,就分不清是后台改的还是 API 接口改的。
必加字段:
(如
)、
、
、
、
(如
)、
(
/
)、
时间用
存 float 或
,避免秒级时间冲突
加
字段(text/json),存当时附带的备注、IP、user_agent,别等出问题了再后悔没留
事情说清了就结束。状态日志最难的不是写代码,是想清楚“谁在什么时候、基于什么依据、改了哪个字段的什么值”——这个链条断一环,日志就变成干扰项。
save()find()statusorder_statusafter_updatebefore_updatesave()$old = static::find($id)$dataupdate()find()$this->dataupdated_timebefore_updatesave()Db::startTrans()Db::transaction()lock(true)Order::where('id', $id)->lock(true)->find()SELECT ... FOR UPDATEfindOrEmpty()versionwherefrom_statusto_statusoperator_iddate('Y-m-d H:i:s')model_name'order'record_idfrom_valueto_valuefield_name'status'operator_type'admin''api'operator_idmicrotime(true)DATETIME(6)extra_data