本文目录导读:

在 PHP 应用中平滑迁移数据是一项需要精细操作的任务,核心目标是不丢数据、不停机(或极短停机)、可回滚。
以下是针对不同规模和数据量的平滑迁移策略,从简单到复杂:
第一阶段:评估与准备(必做)
- 数据量评估:表多大?100万行和1亿行的策略完全不同。
- 拆解任务:尽量用纯 SQL 处理,减少 PHP 层的复杂逻辑(除非需要复杂的业务转换)。
- 备份:这是铁律,迁移前必须做全量备份和 Binlog 备份。
第二阶段:迁移策略(核心)
策略 A:停机维护(适合小数据 / 允许短停)
如果数据量在百万级以下,或者业务允许深夜停机,这是最简单可靠的方式。
- 流程:开启维护模式(PHP 中间件拦截请求) -> 导出旧数据 -> 转换数据 -> 导入新表 -> 切换新代码上线 -> 解除维护。
- PHP 侧:新建
maintenance.php在框架入口文件中拦截请求,返回 503。
策略 B:双写 + 回放(平滑迁移经典方案,推荐)
适用于100万 - 5000万行,需要持续服务(几乎不停机)。
核心逻辑:新老并存,影子写入,定时回放,最终切换。
-
代码兼容期(双写)
- 修改 PHP 写入逻辑:业务写入时,同时写入旧表(A)和新表(B)。
- 关键点:这一步需要确保事务性,Doctrine / Eloquent 支持,使用事务包裹两个写入;如果不支持,可以使用消息队列异步双写,但需接受短暂不一致。
- 读取仍然走旧表(保证业务稳定)。
-
数据快照与回放
- 使用
mysqldump或同步工具(如阿里 DTS)将旧表 A 的全量数据导入新表 B(注意忽略重复主键)。 - 脚本回放:从开始双写的那一刻起,A 表中的增量数据(新增/修改/删除)需要同步到 B。
- 编写 PHP CLI 脚本或使用 Binlog 订阅(如 Canal 或 Debezium)监听 A 的 Binlog,将变化应用到 B。
- 验证:比对 A 和 B 的主键数量、校验和(
CHECKSUM TABLE)。
- 使用
-
切换读取(灰度)
- 修改 PHP 读取逻辑,通过配置中心(如 Apollo)或 DB 开关,将读取流量从 A 切换到 B。
- 建议先切换 10% 流量,观察错误日志和慢查询,再逐步提升到 100%。
-
撤销双写
- 确认 B 稳定后,修改 PHP 代码,移除向 A 写入的逻辑。
- 至此,迁移完成。
策略 C:借助工具(商业/云方案)
如果数据量在亿级,建议不要自己用 PHP 处理,直接用云厂商的工具。
- DTS (Data Transmission Service):例如阿里云、腾讯云的 DTS,它可以一边同步旧库,一边切换。
操作:创建同步任务,将 A 库同步到 B 库,DTS 会追平延迟,然后你在 PHP 层直接修改连接串指向 B 库即可(DTS 通常会保证最终一致性)。
- pt-online-schema-change(Percona Toolkit):适合在 MySQL 原表上进行 DDL(增删改字段)的平滑操作,它通过创建临时表、触发器实现,不影响外部访问。
第三阶段:PHP 代码层面的注意事项
这是最容易踩坑的地方,提供以下建议:
-
使用 Repository 模式(必需)
- 不要在 Controller 或 Service 里直接写
DB::table('old_table')。 - 始终通过
UserRepository或OrderRepository来访问数据。 - 迁移时,只需要修改 Repository 内部的数据源,业务代码不用动。
- 不要在 Controller 或 Service 里直接写
-
SQL 兼容性自查
- 新表的字段类型、索引是否与旧代码的 WHERE/ORDER BY 完全兼容?
- 例如旧表用的是
int(10),新表迁移到bigint,或者把旧表的varchar改成了json,PHP 层的数据类型转换逻辑要跟上。
-
严格处理 NULL 和默认值
- 新表如果加了非空约束,而旧数据中有空字符串,迁移脚本必须处理。
- 在 PHP 数据转换脚本中,写一个验证方法:
private function validateRow(array $row): bool { // 检查新表必填字段 if ($row['status'] === null) return false; // 长度检查 if (mb_strlen($row['name']) > 200) return false; return true; }
-
分批处理(避免内存溢出)
- 不要在 PHP 脚本中一次性
SELECT *。 - 使用
chunkById(Laravel) 或LIMIT/OFFSET+WHERE id > last_id ORDER BY id ASC来游标分页。 - 代码示例(伪代码):
$lastId = 0; while (true) { $rows = $oldModel->where('id', '>', $lastId)->orderBy('id')->limit(1000)->get(); if ($rows->isEmpty()) break; foreach ($rows as $row) { $newData = transform($row); // 你的转换逻辑 $newModel->insert($newData); } $lastId = $rows->last()->id; }
- 不要在 PHP 脚本中一次性
-
设置超长时间与内存限制
- 如果是 CLI 脚本:
set_time_limit(0); ini_set('memory_limit', '512M');(其实更推荐memory_limit=-1但要注意保护)。 - 如果是 Laravel 队列:设置
--timeout=3600。
- 如果是 CLI 脚本:
第四阶段:回滚预案(容灾)
- 状态机:在配置中心(如
config表或 Redis)存放migration_status(migrating,stable)。 - 开关控制:在 PHP 中写一个中间件,读取该状态。
- 如果从新库读取报错(
SQLSTATE[HY000]),自动降级到旧库读取(Try-Catch 切换)。 - 写操作如果新库失败,必须回滚旧库,并记录
failed_jobs表。
- 如果从新库读取报错(
- 回滚步骤:
- 立即关掉新库读流量(改开关回旧库)。
- 如果代码已删除双写逻辑,请保留旧表数据至少一周,以便重建迁移。
PHP 侧的实施清单
- 在
Repository层引入DataSource策略(读旧/写新 或 双写)。 - 使用
Redis或config表作为迁移开关,配置热更新(不用重启 PHP-FPM)。 - 编写 CLI 脚本进行分批数据搬运,并打印进度条(
$i/$total)。 - 校验:
SELECT COUNT(*)两个库对比。 - 默认情况下,读旧写新 -> 双写 -> 读新写新,也就是分三步走。
如果你能提供具体场景(只是加索引?换数据库引擎?字段类型变更?),我可以给出更贴近实际需求的 SQL 或 PHP 代码片段。