MySQL容器OOM排查记:一个infinity参数打穿LIMIT上限
如果你在麒麟 V10 上用容器跑 MySQL,某天凌晨突然收到数据库连接数掉零的告警,上去一看容器已经 Exited,重启后以为没事,结果第二天同样时间点又崩一次——那大概率跟这次的问题一样:不是磁盘满,不是主从断裂,而是一行看起来很不起眼的 infinity 参数,最终被拼接成了 LIMIT 18446744073709551615,把 MySQL 的排序内存和临时表空间直接打穿,容器触发 OOM,mysqld 被内核干掉。下面把这起排查实录整理出来,给碰过类似问题或者准备在国产化环境部署 MySQL 容器的人一个参考。
1. 问题现场:容器重启与 MySQL 连接全断
1.1 现象描述
告警时间是在夜里 1 点 40 分左右,监控页面显示 mysql 容器状态从 running 变成了 exited,业务方反馈基础服务里所有需要查库的接口全部 5xx。我登录服务器后第一眼看到的并不是什么异常,宿主机负载不高,磁盘使用率也不到 60%,free -h 显示物理内存还剩 10 多个 G。这时候我下意识以为是不是有人误操作停容器,于是 docker ps -a 一看,容器退出码是 137,不是正常的 0。
退出码 137 是典型的被强制终止信号(SIGKILL),在容器环境里最常见的就是 OOM Killer。不过当时我还是先把容器拉起来恢复业务,docker start mysql 后等了大概两分钟,MySQL 起来,接口陆续恢复。这种做法只保住了眼前服务,根因并没有排除,所以第二天凌晨同一个时间点,告警又来了。
第二次我学乖了,先从宿主机把现场资料保留下来,再决定要不要重启。这里有个很关键的习惯:不要急着删容器,也不要急着 docker restart,先看一眼退出码、系统日志、容器内存统计,这些是排查 OOM 的第一手证据。如果你直接 restart,很多内核层的输出可能就被后续日志冲掉了。
1.2 第一反应:先恢复服务,再查根因
对于线上数据库这类核心组件,先把业务拉起来没有错,但必须边恢复边留证据。我当时按这个顺序操作:
docker inspect 输出里 OOMKilled 字段如果是 true,说明容器确实是被 cgroup 内存限制杀掉。当时这个字段返回的正是 true,同时 dmesg 日志里也能看到类似 Out of memory: Killed process 25314 (mysqld) 的信息。所以基本可以确定是容器内内存耗尽,接下来要回答的问题是:一个正常的 MySQL 实例,内存为什么会突然涨到容器上限?
这一阶段的时间线我也简单梳理了下,方便后续复盘:
| 时间 | 现象 | 初步判断 |
|---|---|---|
| 01:40 | 容器状态 exited,退出码 137 | 疑似 OOM 强杀 |
| 01:41 | 业务接口大量 5xx,数据库连接超时 | 依赖 MySQL 的服务全部受影响 |
| 01:50 | 手动启动容器,服务恢复 | 属于治标,未定位根因 |
| 次日 01:45 | 同样问题再次出现 | 说明有固定触发源,不是偶发 |
2. 排查路径:宿主机、容器、MySQL 三层逐步收网
2.1 宿主机 OOM 日志:关键字是 MySQL
拿到 OOMKilled=true 之后,第一件事是看宿主机内核日志。在麒麟 V10 这种基于 4.19 内核的系统里,OOM 事件通常会同时出现在 dmesg 和 /var/log/messages 中。我执行了:
日志里能看到很关键的一行:
这里要注意,total-vm 是虚拟内存大小,不能直接当成实际物理内存占用;真正决定 OOM 的是 anon-rss,也就是匿名页的常驻内存,显示约 4GB。同时我看了宿主机 free -h,内存明明还有剩余,这就说明问题并不是宿主机物理内存不足,而是容器 cgroup 限制住了内存。
再往下翻日志,能看到 OOM Killer 遍历了 cgroup 里的进程,最后选择了 mysqld 下手。原因很简单:在同一个 memory cgroup 里,mysqld 的内存占用最大、增长最快,内核认为杀掉它能最快释放内存。
这一步也顺带确认了宿主机本身没有内存泄漏,也不存在其他进程抢占内存的问题。
2.2 容器内存统计:cgroup 限制被击穿
麒麟 V10 上的 Docker 默认使用 cgroup v1,容器的内存控制文件在 /sys/fs/cgroup/memory/docker/<容器ID>/ 下。我用以下命令查看:
结果如下:
memory.limit_in_bytes是4294967296,也就是 4GB;memory.usage_in_bytes在故障发生后回落到 1GB 左右,但日志里记录的峰值已经超过 4.3GB;memory.oom_control显示oom_kill_disable 0,说明容器的 OOM Killer 没有被禁用。
这里顺带说一句,如果容器里跑的是 Java 或 Go 服务,大家往往更关注堆内存和 GC;但 MySQL 是 C++ 写的服务,内存模型更直接,大量内存来自 InnoDB Buffer Pool、排序缓冲区、临时表、线程栈和各类内部缓存。一旦某个查询需要处理的数据量远超预期,内存会以“肉眼可见”的速度涨上去。
我还打开了 Docker 的事件流,后面如果再复现,docker events 会输出容器被 kill 的事件记录,可以辅助确认精确时间点。
2.3 MySQL 日志与慢查询:定位到那一行 infinity
容器日志里其实已经露出了马脚:
能看到类似这样的报错:
第一眼看到 No space left on device 时我差点被带偏,以为是 /tmp 满了。后来查了磁盘和 tmpfs 挂载,发现磁盘剩余空间还有很多,其实是 MySQL 在创建临时文件时因为内存或文件系统限制触发了写入失败。这个现象在内存排序过大、需要大量落盘时经常出现。
真正定位到 SQL,靠的是慢查询日志和 PROCESSLIST 快照。我提前开启了慢查询日志:
然后在日志里看到一条语句:
18446744073709551615 是 MySQL 里 LIMIT 子句能接受的最大无符号整数。MySQL 文档里明确说,如果 LIMIT 写成这个值,等价于“不限制行数”。我当时就问了下业务同学,他们第一反应是“我们从来没传过这种数啊”,后来查应用代码才发现,某个内部管理后台的任务扫描逻辑里,有人写了一个“不限量”的配置项,代码里直接用 float("inf") 表示,ORM 在拼接 SQL 时把 inf 兜底转换成了这个最大值。
这条 SQL 本身的执行计划也很糟糕。通过 EXPLAIN 看到:
type是ALL,走的全表扫描;key为空,没有使用任何索引;Extra里有Using filesort,意味着ORDER BY create_time DESC需要额外排序;rows估算到了千万级别。
于是问题脉络清晰了:一个要返回“所有行”的排序查询,加上一张快千万行的大表,直接把 MySQL 的内存吃穿。
3. 一行 infinity 为什么能把内存吃光
3.1 罪魁祸首:被解释成无限 LIMIT 的分页参数
很多人看到 LIMIT 18446744073709551615 会觉得很夸张,但其实不少 ORM 框架在“不限制返回条数”的场景下都会生成类似的 SQL。比如 Python 的 SQLAlchemy 里,如果你把 .limit(None) 传给查询,它一般不会拼 LIMIT 子句;但如果你给了一个特别大的数,比如 float("inf") 转换后变成字符串 "inf",某些驱动会报错,而另一些驱动或框架会帮你做一次“安全转换”,直接把非数字值替换成无符号整型最大值。
业内更常见的坑是前端分页组件传入 -1、0 或一个超大的 999999999999,后端没有对分页参数做边界校验,最终拼进 SQL。这次的问题本质是一样的:一个“无穷大”的业务语义,在数据库里就是一次全量扫描和全量排序。
ORDER BY 加上超大 LIMIT 的杀伤力在于:优化器知道你要返回所有排序后的行,它不会走“优先队列排序只保留前 N 条”的优化路径,而是老老实实把所有满足条件的行读出来,排序,再返回。如果表中数据量不大,这倒也能扛住;可一旦达到千万行级别,排序缓冲就会频繁溢出到临时表,而临时表如果一开始还能放在内存,后续数据量继续膨胀,就会在内存和磁盘之间反复折腾,内存峰值也随之而来。
3.2 MySQL 内存模型:排序缓冲、Join Buffer 和临时表
要理解这次 OOM,必须简单过一遍 MySQL 的内存结构。MySQL 的内存基本分两类:
一类是全局的,比如 innodb_buffer_pool_size,它负责缓存 InnoDB 的数据页和索引页。另一类是会话级或操作级的,包括 sort_buffer_size、join_buffer_size、read_buffer_size、tmp_table_size、`max_heap_table_size``,这些内存都是按连接或按查询临时分配的。
打个比方:InnoDB Buffer Pool 是仓库,平时把常用的货(数据页)放在仓库里;sort_buffer_size 是分拣台,每个需要排序的连接都会申请一块分拣台空间;临时表是临时货架,放中间结果。正常情况下,分拣台只处理一小批货,货架满了就往磁盘搬。可当查询要求返回“所有行”时,问题就变成了:所有货都要过一遍分拣台,而且临时货架要能容纳全部中间结果。
当时容器限制是 4GB,InnoDB Buffer Pool 虽然只占 256MB,但故障时刻活跃连接里可能有多个连接同时在执行类似的大排序查询。每个连接的 sort_buffer_size 默认 256KB 听起来不大,如果同时有几百个连接在跑,这个量就上来了;更关键的是内部临时表,MySQL 默认内存临时表超过 tmp_table_size 后会转成磁盘临时表,理论上不会一直涨内存,但大量连接同时创建大临时表时,内存的瞬时需求和内存碎片会叠加。
另外,这条 SQL 还带了 WHERE status = 1,如果 status 列上选择性差,MySQL 会将大量符合条件的行都读出来送入排序。也就是说,存储引擎层、Server 层的排序和临时表,再加上连接缓冲,几股流量汇在一起,很快就撞上 4GB 上限。
3.3 容器与系统内存分配:为什么 OOM 杀的是 mysqld
很多人会有疑问:明明 MySQL 有 tmp_table_size 和 sort_buffer_size 限制,临时表也可以落盘,为什么还会 OOM?
这是因为 MySQL 的内存参数很多是“单次分配”的上限,而不是“整个进程”的总预算。当一条 SQL 需要处理千万行时,排序缓冲会被反复填满、刷出,内部临时表也会根据数据量增长;更麻烦的是,如果排序过程中数据量估算出错,MySQL 会在内存里创建行数很多的临时表结构,直到达到 tmp_table_size 上限才转为磁盘表。这个过程中如果同时有多个查询并发,每个查询都在各自的线程里吃内存,进程总内存自然就会飙升。
而容器通过 cgroup 给进程组设定了一个硬上限,当 cgroup 内内存使用量超过 memory.limit_in_bytes,内核会调用 cgroup OOM Killer,从该 cgroup 内选择一个 oom_score 最高的进程杀掉。mysqld 因为是内存占用最大的进程,成了第一目标。这里有个反直觉的点:如果你把容器的 --oom-kill-disable 设为 true,进程可能不会被杀,但容器会一直卡在内存分配失败的状态,甚至把宿主机拖死。 所以正常情况下不要对数据库容器禁用 OOM Killer,除非你有非常完善的进程级守护方案。
在麒麟 V10 上,还有一个容易踩的坑:系统默认的 vm.overcommit_memory=0,也就是内核允许一定程度的 overcommit,MySQL 申请内存时可能不会立即失败,而是先拿到虚拟内存,真正写数据触发缺页时才分配物理内存,这会让 OOM 到来得更突然。排查时可以用 cat /proc/sys/vm/overcommit_memory 确认。
4. 修复方案与复盘:从参数校验到监控告警
4.1 应用侧:禁止 infinity 进入 SQL
根因虽然定位在 SQL,但问题的源头在应用层。第一道防线必须是参数校验,绝对不能把 float("inf")、"Infinity"、nan、负数或超过业务阈值的值传给数据库。
我建议在服务和 DAO 层各加一层限制。DAO 层的例子如下:
如果是 ORDER BY 类查询,必须要求调用方显式传入 limit,不允许默认 None 或 inf。对于真正需要全量导出的场景,建议改成游标分批拉取,或者直接用消息队列把任务拆成多个小批次,每次处理几千条。这里不是性能洁癖,而是为了避免“一个查询把所有数据捞出来”这种操作模式出现在生产环境。
另外,可以在网关或 SQL 审计层面对关键字做拦截。MySQL 里 LIMIT 18446744073709551615 这种特征非常明显,直接用字符串匹配就能识别;更完整的做法是把慢查询日志接入日志分析系统,出现类似 SQL 时实时告警。
4.2 数据库侧:优化 SQL 与内存参数
应用层修复后,数据库侧也不能放任不管。首先给这条 SQL 涉及的表补上索引:
执行计划里 WHERE status = 1 ORDER BY create_time DESC 就能用上联合索引,既过滤 status,又通过索引的有序性避免 filesort。这条 SQL 之后即使 LIMIT 被错误放大,也不会因为排序吃掉大量内存,因为有索引后只需要按索引顺序扫描,读多少返多少。
内存参数方面,我做了如下调整,原则是“给单查询降额、给整体限流”:
这里要说明一下,sort_buffer_size 等参数不是越小越好。调小后,如果业务里确实有大排序,MySQL 会更多地使用磁盘临时文件,单查询耗时变长,但至少不会把内存瞬间打爆。对于有 DBA 团队的环境,一定要基于压测结果调参,不能直接拷贝别人的配置。
4.3 容器侧:给 MySQL 设置合理的内存上限
在容器化部署 MySQL 时,容器的内存上限必须小于宿主机可用内存,同时要比 MySQL 预期的峰值留出余量。我这次的容器参数是:
--memory-swap 4g 表示容器最多使用 4GB 物理内存,不允许多余部分落到 swap。如果只设 -m 4g 而不设 --memory-swap,Docker 默认会创建同等大小的 swap,也就是容器最多能吃 8GB,这会让 MySQL 在内存不足时“感觉还能撑”,实际上更危险。
后来又加上了 --pids-limit 和只读根文件系统等安全加固,但这些属于锦上添花,最关键的还是内存限制不能与数据库自身的 buffer pool 配置冲突。如果你给 InnoDB Buffer Pool 配了 3G,容器上限却只有 4G,那别的会话缓冲稍微一涨,容器必然 OOM。
另外,如果使用 Docker Compose,可以在 docker-compose.yml 里这样写:
4.4 监控与压测:避免再次踩坑
这次故障之后,我把 MySQL 容器和实例的监控补全了。采集指标主要分三层:
- 容器层:
docker stats、cAdvisor 采集的内存使用量、内存 limit 的百分比; - 操作系统层:宿主机
dmesg的 OOM 事件、/var/log/messages中的内存记录; - MySQL 层:
performance_schema内存统计、SHOW GLOBAL STATUS里的内存相关变量、慢查询数、活跃会话数。
具体到工具链,可以用 mysqld_exporter 配合 Prometheus 和 Grafana。mysqld_exporter 默认会采集 performance_schema 中的内存指标,比如 memory_summary_global_by_event_name。如果没有这套监控,至少要在故障复现时手动执行:
这样能快速定位哪个连接或哪个线程占用了大量内存。
压测也不能省。上线前用 sysbench 准备一张千万行大表,模拟超大 LIMIT 和 ORDER BY 查询,观察容器内存曲线。只要复现出内存快速爬升,就说明问题没根除。
5. 常见问题与排查技巧速查
5.1 容器 OOM 和系统 OOM 怎么快速区分
很多人一看到 OOM 就认为是宿主机内存不够,其实是两回事。容器 OOM 是 cgroup 限制导致的,宿主机的 free -h 往往还有大量空闲;系统 OOM 是整机物理内存不足,影响范围更大,甚至 SSH 都会卡。判断方法很简单:
| 判断维度 | 容器 OOM | 系统 OOM |
|---|---|---|
| 容器退出码 | 137,OOMKilled=true |
不一定,可能 137 也可能其他信号 |
宿主机 free -h |
剩余内存还有很多 | 剩余内存很少或为 0 |
dmesg 场景 |
在 cgroup 内杀掉某个进程 | 整机触发 oom-killer,多个进程受影响 |
| 其他容器 | 通常不受影响 | 同一台宿主机上的所有容器都可能受影响 |
5.2 如何从 MySQL 侧定位内存占用大户
如果 MySQL 还没完全 OOM,只是内存吃紧,可以执行下面几条 SQL:
同时观察 SHOW FULL PROCESSLIST,重点看 State 字段。如果大量会话卡在 Sending data、Sorting result、Creating sort index,多半和排序、临时表有关。
5.3 麒麟 V10 上的几个注意点
麒麟 V10 是基于 4.19 内核的国产系统,整体对 Docker 和 cgroup v1 支持很成熟,但在排查时还是有几个差异需要留意:
- Docker 日志不在传统路径,优先用
journalctl -u docker和docker logs; /var/log/messages里也可能有内核日志,但/var/log/kern.log不一定存在;- 查看 cgroup 文件路径时,如果容器使用的是 cgroup v2,路径会变成
/sys/fs/cgroup/docker/...而不是/sys/fs/cgroup/memory/docker/...,需要先用mount | grep cgroup确认; - 麒麟系统可能默认没有
sysstat工具,sar等命令需要额外安装;排查时用top、free、ps这些基础命令足够。
5.4 排查命令速查表
最后整理一份我当时用到的命令列表,方便以后直接抄:
| 目标 | 命令 |
|---|---|
| 查看容器退出码和是否 OOM | docker inspect mysql --format '{{.State.ExitCode}} {{.State.OOMKilled}}' |
| 查看内核 OOM 日志 | dmesg -T | grep -i -E "oom|killed" |
| 查看容器 cgroup 内存限制 | cat /sys/fs/cgroup/memory/docker/$(docker inspect -f '{{.Id}}' mysql)/memory.limit_in_bytes |
| 查看容器当前内存使用 | cat /sys/fs/cgroup/memory/docker/$(docker inspect -f '{{.Id}}' mysql)/memory.usage_in_bytes |
| 查看实时内存竞争 | top -o %MEM |
| 查看 MySQL 进程内存 | ps -eo pid,rss,vsz,cmd | grep mysqld |
| 查看慢查询日志 | tail -f /var/log/mysql/slow.log |
| 查看当前线程内存占用 | SELECT * FROM sys.memory_by_thread_by_current_bytes; |
| 定位超大 LIMIT SQL | 在慢查询日志中搜索 18446744073709551615 |
这次故障让我最深的体会是:业务代码里的一个“不限量”,在数据库看来就是让它一次干完所有活。上线前真的要对所有查询做限量兜底,别让 infinity、-1、999999999999 这种值有机会拼进 SQL。而且 OOM 排查不要只盯着应用日志,容器层和内核层的日志才是第一现场。如果你也在麒麟 V10 上跑 MySQL 容器,建议提前把上述命令和监控配好,免得到时候手忙脚乱。