资讯动态

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

发布时间:2026/10/22 16:05:19 来源:尧图企业网站定制
接手过生产环境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数据全部塞给MySQLMySQL仍然会把大块数据写入Buffer Pool并触发刷盘。所以还需要在数据进入MySQL之前加一道限速阀门把每秒喂入的数据量控制在预设范围内。1.3 这套方案适合什么场景不适合什么场景适合场景很明确生产环境、共享宿主机、业务高峰期不能停、磁盘和CPU资源有限但必须做导入、导入文件体积大几百MB到几十GB。不适合的场景也不少。如果是一次性的临时开发库导入完全没必要加这些限制直接导入反而更快如果MySQL运行在独立物理机上且磁盘没有业务竞争也可以不用限速毕竟限速会显著拉长窗口时间如果SQL文件已经在离线环境经过脱敏且业务方明确允许停服维护那直接跑全量更省事。一句话限制资源不是目的让导入对在线业务“不可感知”才是目的。2. 先摸清工具底细nice、ionice、pv、docker exec2.1 nice到底能限制到什么程度Linux的进程调度器用的是CFS完全公平调度器nice值并不代表“占用CPU的一定百分比”而是代表调度权重相对低。权重越小进程获得CPU时间片的概率越小。比如说一台机器上同时有两个CPU密集进程一个nice0一个nice19那么两者大约按照95%和5%的比例瓜分CPU在不同内核版本上比例略有差异。如果系统空闲CPU很多一个nice19的进程照样可以把核数用完因为它不会主动挂起只是“谦让”而已。实际操作上有个坑非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 管道限速的主角pvLinux下做限速最推荐的工具是pvPipe 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 daemondaemon再通过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和ionicePID$(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-setutf8mb4 --max_allowed_packet128M 目标库 /tmp/xxx.sql这里有几个细节值得说。--default-character-setutf8mb4是为了防止UTF8中文乱码尤其是文件里没有SET NAMES语句的时候这个参数是保命用的。--max_allowed_packet128M是为了避免单条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-setutf8mb4 目标库如果容器里没有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-setutf8mb4 目标库这种写法里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 CONTAINERmydb SQL_FILE./xxx.sql DB_NAMEtarget_db MYSQL_ROOT_PASSWORDyour_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-setutf8mb4 \ --max_allowed_packet128M \ $DB_NAME /tmp/sql_import_stdout.log 21 # 这里保存的是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-commandSET import_marker1再用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调度器是noneionice没用怎么办现代内核和云环境中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 autocommit1; SET unique_checks0; SET foreign_key_checks0;注意unique_checks和foreign_key_checks在导入完成后要记得恢复为1别偷懒。4.4 速率太低导致超时断开怎么解决当限速速率设置得非常低比如每秒几KBMySQL服务端可能在等待客户端传输SQL的间隙触发net_read_timeout默认是30秒。表现就是导入跑了很长时间后突然报错提示Lost connection to MySQL server during query。解决办法是在MySQL服务端临时调大超时时间SET GLOBAL net_read_timeout600; SET GLOBAL net_write_timeout600;也可以在导入命令中加--init-commandSET SESSION net_read_timeout600; SET SESSION net_write_timeout600这两个参数属于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或IOnice19和ioniceidle的导入进程就会一直被排在后面出现“看似没有任何进展”的局面。遇到这种情况我会先看是不是暂时的退避如果持续时间超过几十秒就要考虑适当放宽限制。比如把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这些状态恢复回去毕竟我们追求的是“无感导入”不是“留下一堆后遗症”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑