直接在afterSave等模型事件中发HTTP请求同步ES极危险,因事务未提交时ES已写入,一旦回滚将导致状态不一致;正确做法是模型事件仅投递异步任务ID,由think-queue执行实际同步。

直接在模型事件(如 afterSave、afterDelete)里调 HTTP 同步 ES 或写另一个库,90% 以上会出状态不一致问题——事务回滚了,ES 或备份库却已写入,根本没法对账。
为什么 afterSave 里发 HTTP 请求是危险操作
ThinkPHP 的模型事件默认运行在数据库事务上下文中。一旦你在这里用 curl_exec 或 Http::post() 推数据到 Elasticsearch、另一个 MySQL 实例或 API,就等于把外部 I/O 绑死在事务生命周期里:
- 事务还没提交,ES 已收到并索引成功 → 主库后续 rollback,ES 留下脏数据
- 网络超时或 ES 拒绝写入(如 mapping 冲突),PHP 抛异常触发 rollback,但部分前置同步可能已完成(比如日志表已写)
- 高并发下多个
afterSave并行发请求,ES refresh 延迟叠加,搜索结果短暂不可见或重复
这不是 ThinkPHP 的 bug,而是所有 ORM 在事务 + 外部调用场景下的共性陷阱。
正确做法:模型事件只投递任务 ID,交给 think-queue 异步执行
把「触发同步」和「执行同步」彻底拆开。模型事件只做最轻量的事:生成任务、写进队列表、立即返回。
立即学习“PHP免费学习笔记(深入)”;
- 在
app/model/User.php的afterSave中只写:SyncToEsJob::dispatch(['table' => 'user', 'id' => $this->id]); -
SyncToEsJob类的handle()方法里才真正查数据、构造文档、调elasticsearch-php的index() - 确保该 Job 设置
$tries = 3和$backoff = 60,避免瞬时失败导致丢数据 - 别在 Job 里再用
Db::connect('backup_db')—— 队列进程启动时连接已初始化,复用即可;动态连会耗尽连接数
Logstash 同步 MySQL 到 ES 的关键配置点
如果选 Logstash 做增量管道,模型事件完全不用碰,但必须守住几个硬约束:
-
schedule别用*/5 * * * *(每 5 分钟),改成*/30 * * * *(每 30 秒),否则延迟太高;但要监控 MySQLbinlog扫描压力,尤其大表 -
jdbc插件的statement必须用单调字段分页,例如:SELECT * FROM user WHERE id > :sql_last_value ORDER BY id LIMIT 5000——updated_at在手动更新场景下会跳变,导致漏同步 - 软删除字段(如
is_deleted = 1)必须显式写进 SQL 条件,Logstash 不懂业务逻辑:WHERE is_deleted = 0 AND id > :sql_last_value -
elasticsearch输出插件里加document_id => "%{id}",否则同一条记录多次变更会生成新文档,ES 存储翻倍
跨库同步时 Db::connect() 的坑与解法
ThinkPHP 多库同步最容易栽在连接配置上。错误写法:Db::connect(['hostname' => '192.168.1.11']) —— 这会绕过连接池,每次新建连接,很快打满 MySQL max_connections。
- 必须在
config/database.php的connections下预定义完整连接,键名如'backup_db',且不能和'mysql'冲突 - 字符集必须显式声明:
'charset' => 'utf8mb4',否则从 utf8mb4 库同步到 gbk 库,中文直接截断或乱码 - 分批写入必须用
insertAll(),禁用foreach + insert()—— 单条插入在 10 万行数据下慢 30 倍以上,且容易锁表 - checkpoint 表(记录上次同步
max(id))必须和目标库在同一个物理实例,否则跨库事务无法保证原子性
真正的难点不在代码怎么写,而在同步边界是否清晰:时间戳字段是否真能代表变更顺序?软删除是否被所有下游系统识别?Logstash 的 sql_last_value 是存在文件还是数据库里?这些细节错一个,数据就 quietly diverge。



















