MySQL容器OOM排障实录:一行infinity如何打爆4G内存
这期不聊架构设计,也不讲性能调优,就聊聊我最近在麒麟 V10 环境下被一只数据库容器折腾到深夜的排障经过。背景很简单:一套 MySQL 跑在 Docker 容器里,承接线上业务读写,某天下午突然收到一连串告警——数据库连接失败、服务不可用。登录服务器一看,MySQL 容器没了,查 docker ps -a 是 Exited (137)。137 这个退出码大家都熟,典型的被 OOM Kill 干掉。但我没想到的是,把容器拉起来之后,同样的现象在十几个小时内反复出现了三四次。直到我挖出那条罪魁祸首的 SQL,才发现这个 OOM 的根源,居然只是一行很简单、很不起眼的 “infinity”。
这篇文章我把整个过程,从告警、看日志、复现 SQL,再到定位到“一行 infinity”,最后怎么修复、怎么在容器和系统层面做加固,完整记录下来。适合正在搞数据库容器化、或者踩了 OOM 坑但不知道从哪下手的运维和开发同学参考。
1. 事故现象与第一现场
1.1 告警、退出码和一眼看到的“异常”
先说环境:两台麒麟 V10 服务器做 MySQL 主从,跑的是 MySQL 8.0.33,部署方式是 Docker,用 docker-compose 管理,容器内存限制是 4G。数据库本身是标准的主从架构,应用通过中间件读写分离。平时这套系统已经稳定跑了大半年,基本没出过什么幺蛾子。
出事那天的现象很典型:
- 监控平台弹出 MySQL 存活告警和连接失败告警;
- 登录服务器执行
docker ps -a,发现 MySQL 容器处于Exited状态,退出码 137; - 查看容器详情
docker inspect <container_id> | grep -i oom,OOMKilled字段为true; - 执行
dmesg -T | grep -iE 'oom|killed process',能看到内核日志里明确记录 mysqld 进程被 OOM Killer 杀掉。
这里要稍微解释下退出码 137 的含义。137 等于 128 + 9,128 表示信号相关退出,9 是 SIGKILL。也就是说进程是被强制杀死的,不是自己优雅退出。如果是 MySQL 自身崩溃,比如 InnoDB 检测到损坏,退出码一般是 1 或 2,日志里会有 mysqld got signal 之类的堆栈;但如果退出码直接是 137,而且 OOMKilled 又是 true,那基本可以确定是内存超限被杀。
所以,第一时间的判断思路已经有了:MySQL 被系统或者 cgroup 判定内存超限,触发 OOM Killer。但为什么内存会超限?这是后面要挖的核心。
我先做了最直接的止损操作:把容器重新拉起来,确认主库业务恢复,然后立刻去看 MySQL 错误日志和系统日志,结果发现错误日志里并没有明显的 SQL 报错,也没有 InnoDB 故障堆栈。这说明 OOM 是“突然”发生的,可能是瞬时内存飙升,而不是缓慢泄漏。
1.2 拉起容器后,我做的第一轮检查清单
恢复业务后,我先按常规思路做了这样一轮检查:
- 看 MySQL 慢查询日志,有没有执行时间特别长的 SQL;
- 看
performance_schema里面的内存占用统计,确认哪些模块吃内存; - 看当前连接数:
show status like 'Threads_connected';; - 看 InnoDB Buffer Pool 大小和实际占用,确认配置没有异常变化;
- 看 Docker 容器的
docker stats,观察刚启动后的内存曲线。
这轮检查做下来,初步结论是:Buffer Pool 配置正常,连接数正常,慢查询日志里只有几条常规的大查询,并没有明显异常。当时我几乎以为这只是一次偶发的内存抖动,便把容器监控挂上,准备继续观察。
事实证明,这种“表面正常”恰恰是最迷惑人的。真正的凶手隐藏在日志量更大的地方,需要开大日志开关才能看到。
2. 麒麟 V10 环境中的容器内存与 OOM 机制
2.1 容器 OOM 和主机 OOM 不是一回事
在继续排查之前,先把麒麟 V10 下容器内存限制的机制讲清楚。很多人以为容器内进程 OOM 和主机 OOM 是同一个概念,其实不是。在 Docker 场景里,如果容器设置了 --memory 限制,Docker 会给容器创建一个 cgroup,并把内存上限写进这个 cgroup。当容器内所有进程的总内存使用逼近这个上限时,负责该 cgroup 的 OOM Killer 会触发,并挑选该 cgroup 里的进程杀掉。
区分这两种情况有个非常实用的办法:
- 如果
dmesg里出现大量Out of memory: Killed process ... mysqld,而且同时能看到Memory cgroup out of memory字样,说明是容器 cgroup 限额触发的; - 如果只是
Out of memory: Killed process ...,但没有 cgroup 相关字样,那可能是整个宿主机内存不够了; docker inspect里的OOMKilled字段能直接确认是不是 cgroup 层面杀掉。
麒麟 V10 的内核本身就支持 cgroup,Docker 默认也能正常工作。但这台服务器上出现过比较隐蔽的问题:Docker 的 cgroup 驱动和系统初始化方式不一致,导致容器限额在某些重启场景下没有正确加载。这个我们后面再讲。
我这次遇到的,是前者:容器 cgroup 内存超限。4G 的内存限额被 MySQL 吃满了,于是 cgroup 的 OOM Killer 直接把 mysqld 杀了。问题随之而来:mysqld 平时也就 2G 左右,为什么会在某个瞬间冲到 4G 以上?
这里要引出 MySQL 内存占用的几个大户。
2.2 MySQL 内存大户:为什么 4G 限额也能被瞬间打爆
MySQL 的内存消耗不像很多人想的那样只有 InnoDB Buffer Pool。Buffer Pool 确实是大头,但它有上限,比如 innodb_buffer_pool_size=2G,那么它基本就固定在那里。真正让人防不胜防的,往往是那些“连接级”内存对象,它们按连接数量和单个操作动态分配,单次操作的数据量一大,内存就会像气球一样吹起来。
几个关键参数:
sort_buffer_size:每个会话执行排序操作时分配的内存,默认 256K 或 2M。如果排序的数据量很大,MySQL 实在装不下,会把中间结果写到磁盘临时文件里,正常情况下不会无限膨胀;join_buffer_size:每个会话做 JOIN 时使用的内存,同样是按连接分配的;tmp_table_size和max_heap_table_size:内存临时表的上限,超过之后自动转磁盘临时表;read_buffer_size、read_rnd_buffer_size:顺序扫描和随机读相关的缓冲区;max_connections:这个会放大所有连接级内存的倍数。
注意这些“会话级缓冲区”是每个连接都会预留的,而不是全局共享。假设系统最大连接数是 200,而 sort_buffer_size 被设置成 64M,理论最坏情况下光排序缓冲就可能吃掉 12G 内存,这还没算其他缓冲区。所以生产环境里,把这类参数设置得过于“豪华”,配合少量异常连接,就足以把容器限额直接打爆。
另一个隐蔽的大户是 performance_schema 和二进制日志缓存。在 MySQL 8.0 里,如果 performance_schema 开得很大,每个连接、每个语句、每个等待事件都会消耗内存,高并发场景下它占的内存也非常可观。
回到我这次事故:容器限额 4G,Buffer Pool 用 2G,连接数几十个,正常情况下预留内存足够。但异常 SQL 会出现一种情况:某个连接在执行排序或构造临时结果集时,一次性申请了远超单个会话预期的内存;如果同时有几个类似语句并发,内存就被快速堆满,OOM 就发生了。
2.3 麒麟 V10 下容易被忽略的容器限制问题
麒麟 V10 是基于 Linux 内核的国产操作系统,稳定性整体不错,但在容器场景有几个“坑”值得单独说。
第一个坑是 Docker 版本和内核特性不匹配。部分默认安装的 Docker 版本偏老,对一些新内核特性支持不完整,尤其是在 cgroup v2 逐渐普及的背景下,老版本 Docker 对 cgroup v2 的支持很弱。如果服务器已经启用了 cgroup v2,而 Docker 还停留在老版本,可能会出现内存限额不生效或者限额检测不准的情况。排查时可以用 mount | grep cgroup 看看挂载类型,如果是 cgroup2,就要确认 Docker 版本是否支持。
第二个坑是“安全模块”对容器内进程的限制。麒麟系统有些版本会启用额外的安全增强模块,对容器内外进程的内存映射、进程数等有额外约束。虽然看起来只是普通的 CPU 和内存监控,但极端情况下会影响容器的调度。这个一般不会直接触发 OOM,但会给排障增加不少“烟雾弹”。
第三个坑是我在后来加固时踩到的:如果 Docker 的 cgroup 驱动是 cgroupfs,而系统使用 systemd 管理服务,在某些重启场景下,容器资源限制可能没有按预期恢复。比较稳妥的做法是统一使用 systemd 作为 cgroup 驱动,避免两套体系管理同一个 cgroup。改完后需要重启 Docker,这个在低峰期操作。
3. 追根溯源:定位那一行 infinity
3.1 不是慢查询,那就要开通用日志
前两轮检查没找到原因,我决定加大观测力度。MySQL 的通用日志 general_log 默认关闭,因为它会把每一条 SQL 原样写进日志,线上开启会有一定的性能损耗。但既然问题反复出现,慢查询日志里又没线索,我已经顾不上那点损耗了,先定位问题优先。
我临时开启了 general_log,并做了最小化限制:
用 TABLE 而不是 FILE,是为了方便后续按时间、用户、连接来源查询。开完之后,我写了个小脚本每隔几秒查一次 mysql.general_log,同时盯着系统 dmesg,等下一次 OOM 出现。
等了大约一个多小时,容器再次被杀。这次我立刻查日志,发现 OOM 出现前的最后几条 SQL 里,有一条是这样子的:
咋一看毫无问题,特别正常的一条件查询。但问题是,当我尝试用离线数据手动执行这条 SQL 时,本地一执行就是十几秒不出结果,EXPLAIN 一看,走到了全量扫描,预估行数直接飙到上亿。
更关键的是,我当时是用查询参数的方式去模拟的,始终复现不出线上那么恐怖的内存涨幅。后来查应用端传来的实际参数值,才发现问题所在:调用方传入的时间参数居然是一个无穷大值,Python 里 float('inf'),传出来后变成 inf。
3.2 一个无穷大值,为什么能骗过 MySQL 优化器
这里就要讲到为什么“一行 infinity”有这么大杀伤力了。
MySQL 在执行 WHERE order_time > 某个值 这类条件时,优化器会先分析这个条件的取值范围。如果传入的是一个普通时间戳,比如 2025-01-01 00:00:00,优化器能基于索引统计信息估算出这个范围大概会匹配多少行,并决定走索引还是全表扫描。
但传入值是 inf 的时候,情况就变了。MySQL 在处理浮点数到日期时间类型的转换时,inf 会被当成一个“无限大”的值。优化器在做范围分析时,会认为这个下界非常大,理论上没有多少行能超过它,于是它选择使用 order_time 索引,并且采用索引逆序扫描的方式,从最大时间一直往前扫,直到找到 LIMIT 200 条为止。
问题就在这个“从最大时间往前扫”的动作。如果表里所有数据的时间都小于 inf,索引扫描会从表里最大的 order_time 开始,一路扫到底?实际上不是,它会更极端:优化器认为 order_time > inf 几乎不可能有匹配行,所以这个计划本应非常快。但 MySQL 内部对无穷大值和 datetime 比较的转换存在误差,导致索引范围被错误地等同于“全部行”,并且排序阶段没有走文件排序优化,而是把所有符合条件的行都拉进内存排序缓冲区,再取出 200 条。
结果就是:排序缓冲区被无限制地撑大。如果这张表有几千万行,而排序又恰好发生在内存里,那么一个连接就可能占用几个 G 内存。如果应用侧是连接池并发调用,同一时间跑来十几二十条这样的 SQL,4G 的容器限额瞬间就炸了。
3.3 我如何最终确认元凶:绑定参数类型和转换链路
找到那条 SQL 后,只靠 EXPLAIN 还不够,因为参数类型不同,优化器的选择会完全不一样。我必须确认应用传来的 inf 最终在 MySQL 里被解析成什么类型。
我把 MySQL 的 general_log 继续开着,然后请开发同学把应用日志里那段参数值完整发来。应用日志里显示如下:
这就很明确了。应用用的是 Python 的 MySQL 驱动,它在执行查询时,直接把 Python 的 float('inf') 绑定为 DOUBLE 类型参数。MySQL 在收到 DOUBLE 类型的 inf 后,执行计划里的 order_time > inf 实际上变成了 DOUBLE 和 DATETIME 之间的比较。
这种跨类型比较在 MySQL 中会触发隐式转换,而且 MySQL 对无穷大的处理并不完美。有些版本会把它转成 '0000-00-00' 或 '9999-12-31',有些版本会直接报错,但在 8.0.33 这个版本,它采取了一种比较“宽松”的处理方式,最后把范围分析搞成了“全表范围”。
我做了个对比实验,在测试库上分别跑两条 SQL:
第一个走索引,rows 预估值正常;第二个直接全表范围扫描,预估行数差不多是整表行数,而且 Extra 列里没有显示“Using index condition”,反而出现了明显的 Using temporary; Using filesort 标记。到这里,因果链已经闭环了:inf 参数 → 优化器误判范围 → 全表范围扫描加内存排序 → 内存暴涨 → 容器 OOM。
3.4 为什么“一行”就能引爆,而不是慢慢涨
很多人会困惑:就一条 SQL,怎么就把 4G 内存瞬间打爆了?
这里有个关键点:那条 SQL 不是只执行一次。应用端的连接池里可能有几十个并发连接,每次请求都往 MySQL 发同样的 SQL,只是参数略有不同。一旦开发上线时引入了这个 inf 默认值,所有走这条查询路径的请求都会带上 inf。换句话说,这条 SQL 不是“一条”,而是“被高并发不断重复执行的同一条”。
另外,排序缓冲区的分配是按连接来的。一个连接占 1G,20 个连接就是 20G,虽然实际执行中不一定每个都吃满,但只要一小部分连接同时执行,容器限额就会被突破。
还有个容易忽略的地方:当内存吃紧时,容器内部会发生内存回收、页面换入换出,这些操作本身也要消耗 CPU 和内存资源。MySQL 在内存压力下会尝试把排序结果写到磁盘,但写盘的临时文件同样消耗内存缓冲,最终陷入“越写越慢、内存越不够”的恶性循环,OOM Killer 自然就出现了。
4. 修复与加固:从源头到容器侧
4.1 业务代码修复:对 +inf 的校验策略
根因在应用侧,所以修复也要从源头开始。业务方最终给出的方案是:在配置解析阶段增加数值合法性校验,把解析出来的浮点数做一次“有限性检查”。
如果是 Python,可以用 math.isfinite 判断;如果是 Java,可以用 Double.isFinite。不管上游配置怎么传,只要不是有限数,就直接拒绝或回退到业务允许的最大时间戳 '9999-12-31 23:59:59'。
这里有个细节值得分享:回退到 '9999-12-31 23:59:59' 也要小心。对 MySQL 来说,这个值是 DATETIME 类型的上限,优化器能正常处理。但如果应用在拼接 SQL 时不小心把它当成字符串传进去,类型仍然是可控的。关键是“参数类型要稳定”。我建议代码里不仅做 isfinite 校验,还要把参数统一转成字符串或日期类型再传给 MySQL,不要直接传 float。
4.2 MySQL 参数调优:限排序、限连接、限执行时间
根因修了,但作为运维,我不能只依赖应用不发疯,服务器本身也要有“兜底”能力。这次事故之后,我在 MySQL 侧做了如下几个调整:
max_execution_time:给 SELECT 设置最大执行时间,超时自动终止。这个参数只对 SELECT 生效,立竿见影;max_allowed_packet:从默认的 4M 调整到 64M,同时限制单次 SQL 能带进来的数据包大小,避免异常参数被无限放大;sort_buffer_size:从 2M 调整回 1M。不要贪大,MySQL 会自动根据数据量扩展内存使用,限制初始值其实已经够用;join_buffer_size:默认 256K,没往上调,长期就是这个值;tmp_table_size:设置成 64M,超过后自动转磁盘临时表,主要是防止内存临时表膨胀;max_connections:从 200 降到 150,让所有连接级缓冲区的“理论上限”更可控。
当然,只靠调参数解决不了根本问题。为了拦截那些“优化器犯傻”的查询,我还在 MySQL 8.0 里开了资源组,把一些高风险业务账号的 CPU 和线程优先级降了,避免个别异常查询消耗整个实例的计算资源。
这里要特别说下 max_execution_time。如果使用连接池,且连接在会话开始时执行了 SET SESSION MAX_EXECUTION_TIME=5000,那么所有走这个连接的 SQL 都会带上 5 秒超时限制。要注意的是,它只对 SELECT 生效,而且 EXPLAIN 语句本身不受影响。我用它拦住那些因为优化器误判而长时间运行的查询,效果比调 Buffer Pool 直接得多。
4.3 容器与内核侧加固:内存限额、交换分区和文件系统
容器侧也是这次加固的重头戏。我重新规划了 MySQL 容器的资源 limit,做了这些事情:
第一,调整 Docker --memory 限制。原来给容器 4G,实际 Buffer Pool 2G 后剩下的空间太小,留不住突发内存请求。我加到 6G,然后把 --memory-swap 设置成和内存一致,也就是禁止容器使用 swap。很多人以为让容器用 swap 是好事,但在数据库场景下,swap 会拖慢查询性能,还可能导致 OOM 判断更迟钝。数据库宁可直接 OOM 重启,也不要靠 swap 硬撑。
第二,给容器设置 --pids-limit,限制容器内最大进程数。MySQL 本身不会疯狂 fork,但某些情况下可能会产生大量线程,限制进程数可以避免连带影响宿主机。
第三,设置 --oom-score-adj。这个参数可以调整单个容器进程被 OOM Killer 选中的优先级。MySQL 容器如果必须被 OOM,我希望它优先被杀,而不是把宿主机上的其他重要进程拖下水。默认 Docker 的 oom-score-adj 是 0,我明确设成了 500,这样在极端情况下,MySQL 容器会先出局,保护宿主机。
第四,日志和监控。我在宿主机上挂了 docker events 监控,并加上 Prometheus 采集容器内存指标。另外,把 /var/lib/docker/containers/<container_id>/ 下的 hostconfig.json 和 config.v2.json 做定期备份,方便将来排查容器配置变更历史。
这里又要提一句麒麟 V10 的坑:这个系统默认对 Docker 目录和容器运行目录会启用额外的安全扫描,某些场景下可能影响容器日志写入。如果发现容器日志反复丢失,先看看是不是系统安全模块把日志文件隔离了。这个不是 OOM 主因,但会严重影响排障速度。
4.4 在麒麟 V10 上落地监控告警
监控告警是我在这次事故后重点补的板块。以前我们只监控了宿主机内存和 MySQL 存活状态,没有把容器内存、连接数、慢查询数这些维度纳入统一告警,导致 OOM 发生后只能靠业务告警间接感知。
现在我在节点上部署了 node_exporter 和 cAdvisor,把容器的内存使用率、CPU、网络 IO 全部采集到 Prometheus,并在 Grafana 上建立了两条关键告警:
- 容器内存使用率超过 85% 持续 3 分钟,触发警告;
- 容器内存使用率达到 95% 持续 1 分钟,触发严重告警,责任人立即介入。
除此之外,我还加了 MySQL 侧的监控:每秒查询数、活跃连接数、线程连接数、临时表创建数量。为什么加临时表?因为这次事故里,SQL 走了 Using temporary; Using filesort,临时表的数量会快速上升。如果能在优化器“犯傻”的初期就发现临时表暴涨,我们就有机会提前干预,而不是等 OOM 杀进程。
在麒麟 V10 上部署这些监控组件时,有两点经验分享:
- 组件尽量用官方静态二进制或系统仓库包,避免依赖大量动态库,麒麟系统对第三方软件源的支持有限,从源码编译容易遇到依赖问题;
- 防火墙策略要放行 Prometheus 抓取端口,麒麟默认防火墙对非标准端口限制比较严格,别等监控没数据了才去查防火墙。
5. 常见问题与排查技巧实录
5.1 快速判断“到底谁被 OOM”的几条命令
整个排障过程中,我积累了一套“三板斧”式的快速判断命令,现在分享出来。
第一板斧:查容器退出状态和 OOM 标志。
第二板斧:查内核 OOM 日志。
第三板斧:查 cgroup 内存统计。
如果发现 memory.max_usage_in_bytes 已经达到容器内存限额,而 oom_control 里的 under_oom 标记为 1,那基本坐实了 cgroup OOM。
5.2 我踩过的两个隐蔽坑
排障中我踩过两个隐蔽坑,第一个是“Docker stats 显示的瞬时内存不准”。当时容器刚启动,我用 docker stats 看内存,显示只有 1.8G,误判为正常。其实 docker stats 默认显示的是几秒内的平均值,OOM 发生前那一两秒的峰值非常容易错过。后来我直接看 cgroup 的 memory.max_usage_in_bytes,才拿到真实峰值数据。
第二个坑是“别只看慢查询日志”。这次出问题的 SQL,单条执行时间并不算特别长,因为它走索引倒序扫描,可能一两秒就返回了。但它的内存消耗是瞬间的,并不反映在慢查询日志里。慢查询日志只能捕捉执行时间长的查询,对内存密集型短查询毫无办法。正确的做法是同时开启 performance_schema 相关等待事件统计,或者用 information_schema 里的内存表监控字段,从内存维度去找异常。
5.3 当 MySQL 日志没有明显报错时,从哪里找线索
很多人遇到 OOM 后只翻 MySQL error log,发现没有报错就无从下手。实际上,OOM 这种“被系统强杀”的场景里,MySQL 可能根本没来得及写错误日志,因为进程是被 SIGKILL 直接结束的。真正的第一现场应该在系统日志和内核日志里,也就是 dmesg 的输出。
如果 dmesg 权限受限,也可以看 /var/log/messages 或 /var/log/kern.log。另外,journalctl -k -f 在 OOM 发生时能实时输出内核日志,适合在线盯异常。
找 SQL 线索时,优先看这几个地方:
general_log:能记录所有 SQL,包括参数值,是定位参数问题的金标准;performance_schema.events_statements_history_long:能记录会话执行过的最近语句,比 general_log 更轻量;- 慢查询日志和错误日志:作为辅助,但别依赖它们找 OOM 原因。
6. 经验清单与一点反思
6.1 这次事故后我沉淀的排查清单
列一个比较完整的排查清单,后面再遇到“MySQL 容器 OOM”可以直接按顺序过一遍:
- 确认容器退出码和
OOMKilled标记; - 查 dmesg 确认是宿主机 OOM 还是容器 cgroup OOM;
- 查看 cgroup 内存峰值,确认内存超限幅度;
- 翻 MySQL 错误日志,确认有没有 InnoDB 异常;
- 开启 general_log,抓 OOM 前的 SQL 和参数;
- 用
EXPLAIN复现异常 SQL 的执行计划,对比正常参数; - 从应用日志反查参数来源,确认是不是有非法值混入;
- 修复代码层校验逻辑;
- 调整 MySQL 会话级内存参数和容器内存限额;
- 部署监控告警,防止复发。
6.2 对“一行数据引发 OOM”这件事的反思
这次事故的触发器是一个看似无害的 float('inf'),但它暴露出来的问题不只是参数校验缺失,而是“数据库、容器、应用”三层都没有对异常输入做兜底。
数据库层没有 max_execution_time,让异常 SQL 可以长时间运行;容器层只有 4G 限制,没有更细粒度的内存监控和自动告警;应用层信任了上游配置,没有对浮点数做有限性校验。三层防线都有漏洞,单一异常值就足以引发系统级故障。
我在排查过程中最大的感受是:排 OOM 问题,不能只盯着 MySQL 或者只盯着容器,而是要把执行链路拆开看。SQL 的执行过程、参数的传递过程、容器内存的分配过程,三者必须一起排查。任何一个环节出现裂缝,都可能导致 OOM。
6.3 最后的一点建议
如果你也维护着数据库容器,建议现在就去确认两件事:一是容器内存限额是否合理,二是 MySQL 关键会话参数是否偏大。别等到 OOM 报警响了再临时抱佛脚,那会儿你手上可能只有一堆“进程被杀”的日志,而真正的异常 SQL 早就被重启冲掉了。
另外,如果生产环境允许,尽量在测试环境复现一次“超限参数传入”的场景,哪怕只用一个几十万行的表,你也能直观地看到优化器在遇到异常值时的表现。这些经验,比理论分析要实在得多。