迁移前须创建InnoDB引擎的schema_lock表并实现抢占式锁,通过INSERT...SELECT原子操作加锁、shutdown函数释放,并在CI/CD中配置串行执行。

迁移执行前先检查 schema_lock 表是否存在
Laravel 本身不内置数据库迁移锁,多人同时运行 php artisan migrate 时,可能因并发写入 migrations 表或修改结构导致死锁、重复执行、表损坏。真正起作用的是你手动加的防护逻辑,不是框架默认行为。
常见错误现象:SQLSTATE[HY000]: General error: 1205 Deadlock found when trying to get lock,或者某次迁移只执行了一半、migrations 表里多出两条相同 batch 记录。
- 上线前务必确认数据库中是否存在
schema_lock(或类似名称)表;没有就建一个:CREATE TABLE `schema_lock` ( `locked` TINYINT(1) NOT NULL DEFAULT '0', `locked_at` TIMESTAMP NULL, `locked_by` VARCHAR(255) NULL ) ENGINE=InnoDB;
- 该表必须是 InnoDB 引擎,MyISAM 不支持行级锁和事务,起不到保护作用
- 不要用
SELECT ... FOR UPDATE直接锁整张migrations表——它会被其他迁移命令绕过,且影响正常部署流程
在 AppServiceProvider 的 boot() 里加轻量级抢占式锁
不能依赖 Artisan 命令启动后再判断,因为 migrate 命令初始化阶段就会读取迁移文件、连接数据库,锁必须在任何迁移操作前生效。
使用 INSERT ... SELECT ... FROM DUAL WHERE NOT EXISTS 实现原子抢占,比 SELECT + INSERT 两步更安全:
- 在
app/Providers/AppServiceProvider.php的boot()方法开头插入:if (app()->runningInConsole() && in_array('migrate', $_SERVER['argv'] ?? [])) { $locked = DB::insert("INSERT INTO schema_lock (locked, locked_at, locked_by) SELECT 1, NOW(), ? FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM schema_lock WHERE locked = 1)", [gethostname()]); if (!$locked) { $this->warn('Migration lock held by another process. Aborting.'); exit(1); } } - 注意:MySQL 8.0+ 支持
DUAL,MariaDB 也兼容;低版本 MySQL 可改用(SELECT 1)子查询替代 - 别用
cache或文件锁——部署环境常为无共享文件系统,Redis 缓存也可能跨实例失效
迁移成功后必须显式释放 schema_lock
锁不是自动消失的。如果进程崩溃、超时中断、或人为 Ctrl+C,锁会一直挂着,后续所有迁移都会被拦住。
释放动作不能放在 migration 文件里(太晚),也不能只靠 finally(Artisan 异常退出时不一定触发)。最稳妥的方式是注册 register_shutdown_function:
- 在上面那段抢占逻辑之后追加:
register_shutdown_function(function () { if (app()->runningInConsole() && in_array('migrate', $_SERVER['argv'] ?? [])) { DB::statement("DELETE FROM schema_lock WHERE locked = 1"); } }); - 这个函数会在脚本终止时执行,包括 fatal error、exit()、正常结束三种情况
- 不要用
DB::transaction()包裹整个迁移流程——迁移命令本身已含事务控制,再套一层反而增加死锁风险 - 释放语句用
DELETE而非UPDATE,避免残留脏数据
CI/CD 环境要额外防重跑和跨节点冲突
GitLab CI、GitHub Actions 这类并行流水线,可能多个 job 同时拉代码、同时执行 migrate,光靠单机锁没用。
关键点在于:锁必须是全局唯一的,且由调度层统一协调。
- 把迁移步骤设为「串行任务」:GitLab CI 中加
resource_group: migrations;GitHub Actions 用concurrency键限制同一组 job 并发 - 禁止在多个 deploy 节点上都配置自动迁移——只允许主 deploy 节点执行,其他节点跳过:
if [[ $(hostname) == "deploy-prod-01" ]]; then php artisan migrate; fi
- 若用 Kubernetes,可在 Job manifest 中加
parallelism: 1和completions: 1,确保仅一个 Pod 执行 - 别信“加个 sleep 随机延迟就能错开”——概率性方案在高频率部署下迟早出事
真正难的不是写几行 SQL 加锁,而是让锁在各种异常路径下都不漏、不僵、不误杀。比如 SIGKILL 无法被捕获,这时候只能靠外部监控定期清理陈旧锁记录——这事得单独写个运维脚本,别指望 PHP 自己兜底。











