上线前一天,测试环境一切正常。
数据迁移脚本也就一条 SQL。我那个同事看了一眼,觉得问题不大。
insert into user_archive
select *
from user_info
where create_time < '2024-01-01';
几百万条历史数据,从业务表迁移到归档表。执行时间也不算长,测试环境跑完,数据对得上,然后他很开心地提了上线。
结果第二天生产直接炸了。接口大量超时,数据库 CPU 拉满,部分业务写入失败。最后处理结果也很严重——因为这次事故影响比较大,他离开了公司。
事情过去以后,我复盘了一遍这个问题。很多人看到 insert into select,第一反应是:“这不就是复制数据吗?”但在线上环境,这条 SQL 的杀伤力,比很多复杂代码还大。
先看一下这个 SQL 到底干了什么。假设现在有一张订单表:
create table order_info (
id bigint primary key,
user_id bigint,
amount decimal(10,2),
status varchar(20),
create_time datetime
);
归档表:
create table order_history (
id bigint primary key,
user_id bigint,
amount decimal(10,2),
status varchar(20),
create_time datetime
);
迁移:
insert into order_history
select *
from order_info
where create_time < '2024-01-01';
看起来没毛病。但是数据库执行的时候,并不是简单复制文件。它会:
- 扫描
order_info
- 根据条件过滤数据
- 读取每一行数据
- 生成 insert 操作
- 写入目标表
- 记录 redo log / binlog
数据量小的时候,你感觉不到。数据量一上来,问题全部暴露。
第一个坑:一次性大事务
很多同学不知道,insert into select 默认就是一个事务。比如:
insert into order_history
select *
from order_info
where create_time < '2024-01-01';
假设迁移 500 万数据,数据库需要保证:要么 500 万全部成功,要么全部失败回滚。这意味着事务期间:
- undo log 增长
- redo log 增长
- 锁持有时间增加
- binlog 增长
线上最怕这种一次性大事务。我之前排查过一次类似问题,监控看到:
Threads_running: 85
History list length: 1289234
Innodb_buffer_pool_pages_dirty: 92%
业务接口开始大量超时。很多人第一眼会看接口代码,我一般先看数据库。因为这种指标组合,非常像有人在数据库里干了“大动作”。
查当前事务:
select
trx_id,
trx_started,
trx_mysql_thread_id,
trx_query
from information_schema.innodb_trx;
果然看到:
trx_id: 883921233
trx_started: 2026-09-28 10:22:11
trx_query:
insert into order_history
select ...
一个迁移任务跑了二十多分钟还没结束,业务线程全部陪它等。
第二个坑:执行计划根本没看
很多人写迁移 SQL,只看结果,不看它怎么执行。比如:
insert into user_archive
select *
from user_info
where user_id in (
select user_id
from user_temp
);
测试环境数据少,跑得飞快;生产环境直接慢。原因很简单:生产表几千万数据,如果索引不合适,可能变成全表扫描。
上线前至少应该看:
explain
select *
from user_info
where create_time < '2024-01-01';
比如看到:
type: ALL
rows: 38000000
Extra: Using where
这种结果,我基本不会让它直接在线上跑。3800 万行扫描,你觉得它只是复制数据,数据库觉得:“终于有人让我干一次全库体检了。”
第三个坑:影响线上写入
很多迁移任务喜欢晚上执行,觉得晚上没人用。但现在很多互联网业务不是这样。晚上可能还有:
数据库资源就那么多,迁移占 CPU,业务写入也需要 CPU;迁移占 IO,业务查询也需要 IO。最后大家一起排队。
那这种数据迁移应该怎么写?我个人习惯不会直接:
insert into archive
select *
from source;
而是拆。比如按照主键分批,Java 里一般会写一个小脚本控制。类似这样:
public void migrateOrder() {
long lastId = 0;
int batchSize = 5000;
while (true) {
List<OrderDTO> list =
orderDao.queryAfterId(lastId, batchSize);
if (list.isEmpty()) {
break;
}
orderArchiveDao.batchInsert(list);
lastId = list.get(list.size() - 1).getId();
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
这里几个点比较重要。
不要分页:
limit 500000,5000
这种越往后越慢,因为数据库需要扫描前面的数据。用:
where id > lastId
order by id
limit 5000
效率稳定很多。
另外批量插入也不要无限大。比如:
jdbcTemplate.batchUpdate(
"insert into order_history(id,user_id,amount,status) values(?,?,?,?)",
batchArgs
);
一次塞几十万条,一样危险。我一般控制在几千,具体多少看:
- 单条数据大小
- 网络情况
- redo log 压力
- 数据库配置
不要迷信某个固定数字。
还有一个容易忽略的问题:迁移一定要有校验,不是插进去就结束。比如源表:
select count(*)
from order_info
where create_time < '2024-01-01';
目标表:
select count(*)
from order_history
where create_time < '2024-01-01';
数量一致只是第一步。还应该抽样:
select id, amount, status
from order_info
where id in (10001,10002,10003);
对比归档表。否则最怕数据迁移成功了,但是字段错位,业务几年以后才发现。
最后说一句,insert into select 这条 SQL 本身没有错。很多线上事故,也不是因为某个人 SQL 写错了。问题在于:开发环境给了错误信号。几万条数据没问题,几千万条数据就是另外一个世界。
数据库不像 Java 方法,你调用一次,看见返回结果,就觉得完成了。数据库背后还有:事务日志、锁、磁盘、缓存、复制。所有这些东西,都会因为你这一条 SQL 被调动起来。
所以以后看到这种:
insert into xxx
select *
from xxx
别急着说简单。先问三个问题:数据多少?执行计划怎么样?出了问题怎么停?这三个问题没想明白,迁移脚本就不要直接上生产。