ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL大SQL导入不求快:nice+ionice+pv限速,让生产环境无感

MySQL大SQL导入不求快:nice+ionice+pv限速,让生产环境无感

接手过生产环境MySQL数据初始化的人,应该都体验过那种“导入一时爽,线上火葬场”的焦虑。早年某公司有一台多人共享的宿主机,我直接执行了一条几个G的存量SQL导入,结果同一宿主机上其他服务的磁盘IO延迟被打到几百毫秒,业务告警刷了一整屏。后来才总结出来一套相对稳妥的组合手段:让导入进程以“最低CPU优先级 + 空闲IO优先级”运行,同时用管道工具把喂给MySQL的数据速率压到指定范围,并且这套操作要真正作用在Docker容器里的导入进程上。这就是标题那件事。这篇文章把这套方案的底层原理、命令组合、常见坑位完整过一遍,适合正在做数据迁移、历史库初始化、生产环境导入SQL的运维、DBA和后端同学参考。

1. 为什么要把SQL导入做成“低优先级 + 空闲IO + 限速”?

1.1 一条大SQL直接导入,到底在抢什么资源?

很多人对“导入SQL”的印象是:执行一下,等结果就行。但一条包含大量INSERT、UPDATE、临时表排序的SQL文件,在数据库里撕开的资源缺口是非常大的。

首先是CPU。每条SQL都要经过解析、优化、执行三个环节,大批量INSERT还会涉及索引维护、B+树分裂、排序归并。一个全量初始化SQL跑起来,经常能把多个CPU核心踩到接近100%。其次是IO。InnoDB写入数据页要刷盘,binlog要顺序写,undo日志要写,临时表可能还要落盘。磁盘操作几乎是全速的。最后是内存。大量数据进入Buffer Pool,会把原本服务线上热点数据的数据页挤出去,后续线上查询又要把这些页重新读回内存,产生连锁的IO放大。

如果这台机器上只有你一个人在跑,那全速导入完全没问题。但生产环境的宿主机上往往还跑着其他应用、其他容器、监控脚本、日志采集进程,一旦导入进程把CPU和IO全部吃满,其他服务的响应时间会立刻劣化。我们真正需要避免的,不是导入“慢”,而是导入“凶”。

1.2 “低优先级 + 空闲IO + 指定速率”分别解决了什么问题?

这三个约束,其实是在系统内核调度层面给导入进程“定性”。

最低CPU优先级,对应的是让出CPU调度权。Linux用nice值调节进程的静态优先级,数值范围是-20到19,数值越大优先级越低。把导入进程的nice拉到19,本质上就是在告诉CPU调度器:这个进程可以干活,但只有在没有其他重要进程需要CPU的时候再调度它。

空闲IO优先级,对应的是ionice命令的idle类别。它让导入进程只在磁盘IO队列完全空闲的时候才发起IO请求。只要有任何其他进程在访问磁盘,导入进程的IO就自动退避,这是真正的“夹缝中求生存”。

指定速率,对应的是管道限速。即使CPU和IO优先级都调低了,如果read完文件后一瞬间把300MB数据全部塞给MySQL,MySQL仍然会把大块数据写入Buffer Pool并触发刷盘。所以还需要在数据进入MySQL之前加一道限速阀门,把每秒喂入的数据量控制在预设范围内。

1.3 这套方案适合什么场景,不适合什么场景?

适合场景很明确:生产环境、共享宿主机、业务高峰期不能停、磁盘和CPU资源有限但必须做导入、导入文件体积大(几百MB到几十GB)。

不适合的场景也不少。如果是一次性的临时开发库导入,完全没必要加这些限制,直接导入反而更快;如果MySQL运行在独立物理机上且磁盘没有业务竞争,也可以不用限速,毕竟限速会显著拉长窗口时间;如果SQL文件已经在离线环境经过脱敏,且业务方明确允许停服维护,那直接跑全量更省事。

一句话:限制资源不是目的,让导入对在线业务“不可感知”才是目的。

2. 先摸清工具底细:nice、ionice、pv、docker exec

2.1 nice到底能限制到什么程度?

Linux的进程调度器用的是CFS(完全公平调度器),nice值并不代表“占用CPU的一定百分比”,而是代表调度权重相对低。权重越小,进程获得CPU时间片的概率越小。

比如说一台机器上同时有两个CPU密集进程,一个nice=0,一个nice=19,那么两者大约按照95%和5%的比例瓜分CPU(在不同内核版本上比例略有差异)。如果系统空闲CPU很多,一个nice=19的进程照样可以把核数用完,因为它不会主动挂起,只是“谦让”而已。

实际操作上有个坑:非root用户只能调大nice值,也就是只能让进程优先级更低。普通用户执行nice -n 19没问题,但如果写成nice -n -5,系统会直接拒绝,提示权限不足。在Docker容器里如果以非root用户身份运行命令,也要注意这个问题。

另外,nice只影响CPU调度,不影响IO调度。很多教程让人只执行一个nice -n 19 mysql < file.sql,实际上对IO风暴基本没用。这也是为什么必须叠加ionice的原因。

2.2 ionice的idle类别,到底是怎么让出IO的?

ionice是Linux的IO调度工具,它有三种调度类别:

  • real-time(类别1):最高优先级的IO,会抢占所有其他进程的IO带宽。
  • best-effort(类别2):默认类别,根据优先级级别(0到7)分配带宽。
  • idle(类别3):只有系统IO队列完全空闲时才会被执行。

我们需要的正是idle,用ionice -c3表示。当一个进程以idle类别发起IO时,只要磁盘上还有任何一个其他进程在读写,它的IO请求就会被无限期搁置,等到没有别人用了再动。

这里有一个极其重要的前提:ionice能否生效,取决于IO调度器。传统Linux内核里有CFQ调度器,它是支持ionice的。但现在的云服务器、容器宿主机大量使用SSD和NVMe磁盘,很多发行版默认把IO调度器设置成了none或者mq-deadline,其中none本质上不参与排队调度,ionice设置下去毫无作用。

所以上线前第一件事,先确认宿主机磁盘的IO调度器:

cat /sys/block/sda/queue/scheduler

如果输出none,说明ionice很可能不起作用。这时候就要换个思路:要么改用cgroup的blkio权重限制,要么把希望完全寄托在pv限速上。这个细节我在第四章还会展开讲。

2.3 管道限速的主角:pv

Linux下做限速,最推荐的工具是pv(Pipe Viewer)。它的常见用法出现在管道中间,比如pv xxx.sql | mysql,但它的真正代表作是-L参数,也就是限速。

pv -L 500k xxx.sql | ...

这条命令表示以每秒500KB的速率输出xxx.sql的内容到管道。注意pv的单位,500k在pv的语境里是1024进制,也就是512000字节每秒。也可以直接写成pv -L 1024000,更明确不搞换算。

除了限速,pv另一个价值是进度反馈。当你执行一个几十GB的导入时,屏幕上能看到当前速度、已经传输的数据量、剩余时间估算,这比静默等待MySQL返回结果要舒服得多。没有pv的时候也能限速,可以用mbuffer或者throttle,或者干脆写一个python脚本,每次读取固定大小的块然后sleep固定时间,但没必要,装个pv最省事。

2.4 Docker exec进程的优先级继承,很多人写错了

这是整套方案最容易被误解的地方。

很多人会把命令写成:

nice -n 19 ionice -c3 docker exec -i mydb mysql ...

这个写法前面修饰的是docker这个命令行客户端进程,而不是容器内的mysql进程。docker exec实际的工作路径是:CLI通过socket把请求发给docker daemon,daemon再通过containerd和runc在容器PID命名空间启动一个进程。CLI进程本身只是等待结果,真正的数据接收者、SQL执行者是容器内的进程。

所以,在宿主机上给docker exec加nice和ionice,等于是在给“发号施令的门卫”降优先级,而真正干活的“工人”完全没被限制。

正确的做法有两种:

第一种,直接在容器内执行带nice和ionice的命令:

docker exec -i mydb sh -c "ionice -c3 nice -n 19 mysql ... < /path/to/file.sql"

第二种,在宿主机上找到容器内目标进程的PID,然后直接对PID执行renice和ionice:

PID=$(docker top mydb | awk '/mysql/{print $2; exit}') renice -n 19 -p "$PID" ionice -c3 -p "$PID"

第二种方式有个好处:即使容器镜像里没安装ionice,也可以用宿主机的工具直接调节一个已经存在的进程,前提是有权限操作宿主机上的进程。还有一点要注意,mysqld服务端进程本身不应该被降级,我们只调节导入用mysql客户端的进程,别一时手滑把整个实例的优先级给调了。

3. 实操:组合命令与完整导入脚本

3.1 准备工作:确认环境、校验文件、查看磁盘状态

动手之前先把底摸清,避免执行到一半才发现工具缺失。

首先确认容器名和SQL文件位置:

docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}" ls -lh xxx.sql

确认SQL文件的字符集,最好用file命令看编码,或者用编辑器打开前几行确认有没有SET NAMES语句。导入时建议显式指定字符集,避免中文乱码。

然后确认宿主机IO调度器:

cat /sys/block/sda/queue/scheduler

常见云主机的结果可能是[none]、mq-deadline或bfq。如果输出是bfq,那ionice的idle类别通常能生效;如果是none,也不要慌,至少还有pv限速兜底。

最后确认容器里有没有nice和ionice命令:

docker exec -i mydb which nice ionice

很多精简镜像只有nice没有ionice。ionice在util-linux包里,如果镜像基于Debian系,理论上可以用apt-get安装,但生产环境不建议临时往里装包,更稳妥的是用代码解析里说的第二种PID方式,或者把宿主机的ionice二进制复制进容器。复制二进制需要同时复制动态库依赖,操作起来稍微麻烦,不如直接用宿主机调PID。

3.2 最简可用版:docker cp + 容器内nice + ionice

假设容器内有ionice,且SQL文件已经准备好,完整命令分两步。

第一步把SQL文件复制进容器:

docker cp xxx.sql mydb:/tmp/xxx.sql

第二步进入容器执行导入:

docker exec -i mydb sh -c "cd /tmp && ionice -c3 nice -n 19 mysql -uroot -p'你的密码' --default-character-set=utf8mb4 --max_allowed_packet=128M 目标库 < /tmp/xxx.sql"

这里有几个细节值得说。

--default-character-set=utf8mb4是为了防止UTF8中文乱码,尤其是文件里没有SET NAMES语句的时候,这个参数是保命用的。--max_allowed_packet=128M是为了避免单条SQL过大时被MySQL以“packet too large”拒绝,这个值可以根据SQL实际情况调整。

ionice -c3 nice -n 19的写法是让最终启动的mysql进程同时带上空闲IO和最低CPU优先级。这两个命令的顺序其实无所谓,因为进程的调度属性是继承下来的,只要在最终命令前出现即可。

3.3 指定速率的实现方式

如果容器内有pv,最简单:在SQL路径前套一个pv限速即可。

docker exec -i mydb sh -c "cd /tmp && ionice -c3 nice -n 19 pv -L 500k /tmp/xxx.sql | mysql -uroot -p'你的密码' --default-character-set=utf8mb4 目标库"

如果容器里没有pv,还有两条路。

第一,在宿主机上用pv限速,管道喂给docker exec里的mysql。命令长这样:

ionice -c3 nice -n 19 pv -L 500k xxx.sql | docker exec -i mydb sh -c "nice -n 19 mysql -uroot -p'你的密码' --default-character-set=utf8mb4 目标库"

这种写法里,pv确实被限制在了宿主机的最低优先级和空闲IO,但容器内的mysql客户端只有nice限制,没有ionice限制。实际使用中,因为pv已经限速了,mysql客户端读管道的速度是跟着管道节奏走的,所以IO压力大头其实在pv侧。

第二,更干净的做法:先用宿主机管道限速,再利用宿主机调PID的方式,把容器内mysql进程的nice和ionice全部调好。这就是第三种完整方案。

3.4 完整推荐版:PID调节 + 管道限速 + 日志留痕

我目前最喜欢的写法,是把管道限速和PID调节组合起来,做成一个可复用的脚本。整体思路是:

  • 用pv在宿主机限速。
  • 后台启动docker exec导入。
  • 等一百毫秒左右,让容器内mysql进程稳定存在。
  • 在宿主机找到这个mysql进程的PID,执行renice和ionice。
  • 用pv的日志或重定向输出,方便事后检查。

脚本大概是这样的:

#!/bin/bash set -euo pipefail CONTAINER=mydb SQL_FILE=./xxx.sql DB_NAME=target_db MYSQL_ROOT_PASSWORD='your_password' echo "=== 开始限速导入到 $DB_NAME ===" # 第一步:启动导入进程到后台 ionice -c3 nice -n 19 pv -L 500k "$SQL_FILE" | docker exec -i "$CONTAINER" \ mysql -uroot -p"$MYSQL_ROOT_PASSWORD" \ --default-character-set=utf8mb4 \ --max_allowed_packet=128M \ "$DB_NAME" > /tmp/sql_import_stdout.log 2>&1 & # 这里保存的是docker exec的PID,不是容器内mysql的PID IMPORT_BG_PID=$! # 第二步:让容器内mysql进程稳定存在 sleep 1 # 第三步:找到容器内正在执行的mysql客户端进程并降优先级 MYSQL_PID=$(docker top "$CONTAINER" | awk '/mysql/ {print $2; exit}') if [ -n "$MYSQL_PID" ]; then renice -n 19 -p "$MYSQL_PID" ionice -c3 -p "$MYSQL_PID" echo "已调节容器内mysql进程: PID=$MYSQL_PID" fi # 第四步:等待导入结束 wait "$IMPORT_BG_PID" echo "=== 导入完成,退出状态: $? ==="

这里要提醒一个实际操作中容易出现的问题:docker top输出的进程列表里,可能既有mysqld服务端进程,也有mysql客户端进程。如果容器里没有其他mysql客户端在跑,那awk匹配到的第一个mysql进程通常就是我们要限速的导入进程。但为了保险,最好筛选出命令行中含有具体SQL特征或用户名进程的PID,甚至可以临时在客户端启动参数里加上--init-command="SET @import_marker=1",再用docker top去匹配这个标识。不过一般来说,只要确认系统当前没有其他连接,第一个匹配就是对的。

3.5 速率定多少才能达到“指定速率”?

标题里说的“指定速率”,具体数值得看业务容忍度。我给一个参考估算方法。

先算出导入文件大小:

ls -lh xxx.sql

如果文件是5GB,设置限速为1MB/s,理论耗时约为5000秒,也就是一个半小时左右;如果业务低谷窗口只有两个小时,这个速度勉强可行。如果限速设为2MB/s,理论耗时约2500秒,接近42分钟。这里说的理论耗时没有考虑MySQL执行INSERT本身的消耗,实际耗时通常会是理论值的1.2倍到1.5倍,因为MySQL解析和执行也有额外时间。

我一般先用一个小文件做限速测试,观察同一宿主机上其他服务的磁盘延迟和CPU占用,再决定要不要把速率抬高一档。速率拉低不是目的,为了拿到一个业务无感的窗口,实际上很多场景下500KB/s就够用了。

限速还有一个隐藏好处:导入过程对InnoDB的锁等待窗口变长,但峰值压力远低于全速导入。如果你的SQL文件里包含几条超大INSERT语句,拆一下会更好,这个我放到第四章说。

4. 常见问题与坑位排查实录

4.1 容器里没有ionice,怎么办?

这个问题遇到的频率非常高。很多基础镜像连ps都是busybox版本,更不用说util-linux里的ionice。

我试过三种解决办法。

第一种,宿主机调PID,这是最通用的。先把导入跑起来,再找到容器内的mysql进程PID,用宿主机的renice和ionice对它进行调节。这个方案的局限在于启动到找到PID之间的窗口期很短,如果导入进程在1秒内就结束了,那基本抓不到;但大文件导入不可能1秒结束,实际用下来没什么问题。

第二种,把宿主机的ionice拷进容器。这要求容器里有root权限,且宿主机和容器动态库环境兼容。如果宿主机上的ionice是动态链接的,还要把依赖的libc库一起拷进去,比较啰嗦。静态编译的ionice就没这些问题,但多数发行版默认编译的是动态。可以先用ldd /usr/bin/ionice看一眼,只有少量依赖再考虑。

第三种,新建一个带util-linux的一次性容器,挂载同一个网络,连到mysqld实例去执行导入。这种方式会引入额外的连接配置,复杂程度也不低,不如前两种直接。

4.2 磁盘IO调度器是none,ionice没用怎么办?

现代内核和云环境中,NVMe和部分SSD设备的IO调度器是none,也就是没有传统意义上的排队调度,ionice设置idle类别可能完全无效。这种情况不要死磕ionice,改走cgroup blkio。

对Docker来说,有两个途径:

  • 在创建容器时使用--blkio-weight参数,取值范围是10到1000,权重越高,IO带宽分配越多。但注意它控制的是best-effort级别,没有“idle”语义。
  • 在cgroup v2下,通过io.weight和io.max可以对块设备进行更精细的限速。

实际操作里,如果调度器是none,我会优先把宝押在pv限速上,因为管道限速是用户态直接控制字节数,不管底层调度器如何都生效。然后再用cgroup或系统级限制做辅助,避免其他进程被拖慢。

还有一点,如果宿主机磁盘本身已经处在高IO压力下,ionice -c3会让导入进程几乎得不到任何IO,表现就是长时间不输出,像死了一样。这时候用iotop看,导入进程的磁盘读速可能为0,这是正常的退避行为,不代表卡死。

4.3 低速导入会不会放大事务锁问题?

会,而且这个坑很隐蔽。

一条大的INSERT语句,如果一次插入几百万行,MySQL内部会在一个事务里不断追加二级索引记录。你低速喂数据只是降低了数据到达的速度,但单个事务从开始到提交的墙钟时间被拉得很长。如果线上同时有业务在更新同一张表,锁等待时间会显著增加,极端情况下线上事务直接报锁等待超时。

缓解办法是拆SQL文件。把一条几百万行的INSERT拆成多个小批次,比如每10000行一个INSERT,每条语句结束后自动提交。拆完以后低速导入对锁的影响会小很多。如果SQL文件是mysqldump生成的,默认自带/*!40101 SET NAMES utf8mb4 */和禁用约束的语句,自己写文件的话,最好在开头手动加:

SET autocommit=1; SET unique_checks=0; SET foreign_key_checks=0;

注意,unique_checks和foreign_key_checks在导入完成后要记得恢复为1,别偷懒。

4.4 速率太低导致超时断开,怎么解决?

当限速速率设置得非常低,比如每秒几KB,MySQL服务端可能在等待客户端传输SQL的间隙触发net_read_timeout,默认是30秒。表现就是导入跑了很长时间后突然报错,提示Lost connection to MySQL server during query。

解决办法是在MySQL服务端临时调大超时时间:

SET GLOBAL net_read_timeout=600; SET GLOBAL net_write_timeout=600;

也可以在导入命令中加--init-command="SET SESSION net_read_timeout=600; SET SESSION net_write_timeout=600",这两个参数属于session级,加上后只对本次导入连接生效。

另外,如果是从宿主机管道往docker exec里喂数据,建议同时给docker exec加--env MYSQL_PWD=密码避免密码出现在命令行,但这只是安全习惯,跟超时问题无关。重点是确认连接空闲时间没有超过服务端阈值。

4.5 导入过程中怎么确认它真的在按预期工作?

我在实操中一般开两个窗口。

第一个窗口看pv的进度,如果看到输出速率稳定在设定值附近,说明限速生效。

第二个窗口连到MySQL里检查进程状态:

SHOW FULL PROCESSLIST;

如果能看到一条Query状态的语句,且Time在不断增长,就说明SQL正在被解析执行。再结合iotop -b -n 1确认导入进程的磁盘读写速率并没有达到全速,优先级调节就生效了。

还可以用pidstat -d 1 5按进程维度看IO统计,看IO等待时间是否较低。这些信息可以帮你判断是限制太死还是限制不够。

4.6 SQL文件里有USE语句,导错库了怎么办?

这是纯SQL文件内容导致的问题。mysqldump默认会在文件头部生成类似USE \target_db`;`的语句,如果导入命令里指定了目标库,最后起作用的可能是文件里的USE语句。

如果标题要求是“导入到指定MySQL数据库”,最稳妥的方法是先检查文件:

grep -i "^USE" xxx.sql

如果发现文件内部存在USE语句,要么去掉它,要么确保它指向的就是目标库。临时处理可以直接用sed过滤:

sed '/^USE/d' xxx.sql | pv -L 500k | docker exec -i mydb mysql ...

但注意,建表语句里很可能有数据库名限定或者外键引用,直接删USE不一定完全等价。更推荐的做法是在导入前花两分钟确认构建SQL的人到底写了哪个库名,别等导入完了才发现数据落错了地方。

4.7 优先级太低会不会导致导入进程饿死?

理论上存在这种可能。如果宿主机上其他进程持续占用CPU或IO,nice=19和ionice=idle的导入进程就会一直被排在后面,出现“看似没有任何进展”的局面。

遇到这种情况,我会先看是不是暂时的退避,如果持续时间超过几十秒,就要考虑适当放宽限制。比如把ionice从idle改成best-effort -n 7,即同类最低的best-effort,也能让导入进程在空闲时使用IO,同时不会饿死:

ionice -c2 -n 7 nice -n 19 mysql ...

所以实操中不要把方案焊死成“必须空闲IO”,而是要把它看成一个可以动态调节的资源旋钮。系统负载低的时候可以稍微激进一点,系统负载高的时候再退回idle。

个人在实际操作中的一个体会

这套方案真正让我觉得值钱的地方,不是某个单一命令,而是组合出来的效果。在一个人人都急着把导入跑完的环境里,主动把自己的任务降级成系统里的“三等公民”,看起来违背直觉,实际上换来了业务的长期平静。我最开始也是被线上告警教育之后才理解这件事。

最后说一个提高日常效率的小技巧:把整个导入流程封装成一个函数,放到个人的bashrc或者共用脚本库里,参数只留SQL文件路径、目标库名、限速值三个。以后碰上类似需求,一行命令跑起来,再也不用临时回忆那串长参数。每次导入完了,记得把超时参数和unique_checks、foreign_key_checks这些状态恢复回去,毕竟我们追求的是“无感导入”,不是“留下一堆后遗症”。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进