MySQL容器反复OOMKilled?一个Infinity参数如何让优化器全表扫描
先说明一下背景:银河麒麟 V10 上跑 MySQL 容器,业务高峰期容器不定时被杀,状态一会儿 OOMKilled 一会儿 Exited(137),Docker 自动重启后又能撑一阵子。这种"薛定谔的可用"最折磨人。我花了差不多一个下午加一个晚上才把根因捞出来——不是内存配小了,不是连接数打满,也不是慢查询堆积,而是业务 SQL 的绑定参数里有一个 Infinity,一行看起来人畜无害的"无穷大"。
这个排错过程其实很有代表性:容器 OOM 的现场非常容易误判,日志也分散在宿主内核、cgroup、Docker 事件和 MySQL 自身日志四层,少看一眼都会把方向带偏。所以我把完整的排查链路、根因原理和修复加固方案整理出来,给同样在国产系统上跑容器数据库的朋友一个参考。
1. 故障现场:容器反复 OOMKilled,服务间歇性瘫痪
1.1 第一现场:容器状态与重启记录
先说环境。宿主机是银河麒麟 V10 SP2,内核 4.19 系列,Docker 20.10,MySQL 用的是官方 8.0.32 镜像,容器加了 -m 4g(内存上限 4G),数据目录挂载在宿主机,生产环境跑了半年一直挺稳。
出问题那天的现象很典型:监控面板上 MySQL 实例的 RSS 内存曲线像过山车——缓慢爬到接近 4G,然后瞬间跌到零,再过一两分钟又拉起来。业务的报错集中在 Lost connection to MySQL server、CR_SERVER_GONE_ERROR(错误码 2013)、Can't connect to MySQL server(错误码 2003)这几类。
第一件事不是看业务日志,而是看容器本身的状态:
输出基本就是 running true 137,State.OOMKilled 是 true,退出码 137。这两个信号放在一起,几乎可以直接断定:容器的进程是被内核 OOM Killer 杀掉的,而不是 MySQL 自己崩溃退出。注意 137 = 128 + 9,即 SIGKILL,凡是看到 137 先往内存方向想,别急着查 MySQL error log 的 crash 信息。
1.2 最开始的误判方向:以为只是内存配置不足
我承认,第一反应也是"4G 是不是太小了"。因为这个库每天有上千万行交易流水,InnoDB buffer pool 给的是 2G,按常规经验 4G 容器装 MySQL 8.0 确实偏紧。当时差点就直接改 -m 8g 重启完事。
还好没这么干。因为有一个细节不对劲:这个问题是突然出现的,不是一步步恶化。配置没动过、数据量没有明显突增、连接数也没有上涨,如果是单纯的容量不足,内存曲线应该是缓慢爬坡,而不是以某天为分界突然开始被杀。第二个不对劲的地方是:容器被杀的时间窗口集中在每天上午 10 点到 11 点,下午也有小高峰,偏偏凌晨没事。这说明存在一个"特定业务触发"的规律,而容量不足不会挑时间段。
1.3 抓现场:关闭容器自动重启,锁定复现窗口
排查的第一步,我把容器的 --restart=always 给去掉了。理由很简单:OOM 之后自动重启会把现场覆盖掉,尤其是容器里的 /proc、内核日志、临时文件全都恢复正常,你再回头看就是一个"健康"的 MySQL,什么痕迹都找不到。
改成手动启动之后,第二天上午 10 点 20 分左右,容器再次被打死。这次我不急着拉起来,先让现场保持"尸体"状态,然后从上到下逐层取证。过程中注意一点:排查期间建议把业务流量摘掉或切到只读备份,避免反复 OOM 造成数据页损坏。MySQL 8.0 在崩溃恢复上做得不错,但没必要冒这个险。
2. 排查链路:从内核日志到慢日志的层层递进
2.1 dmesg 与系统日志:OOM Killer 留下的痕迹
第一层证据在宿主机内核日志里。容器被杀的瞬间,内核会留下非常明确的 OOM 记录:
符合预期的输出大概是这样的(时间、进程号已脱敏):
两个关键信息点:
Memory cgroup out of memory说明触发点是 cgroup 内存限制,而不是宿主机物理内存耗尽。所以你看宿主机free -h可能还有十几个 G 空闲,这很正常,不要被误导。total-vm:8.2G与anon-rss:3.6G之间的差距也值得注意。total-vm是进程虚拟内存映射,很大很唬人,但真正算进 cgroup 的是anon-rss(匿名内存)和一部分page cache。也就是说进程实际只用了 3.6G,但容器的 4G 上限里还有其他开销(mysqld 之外还有 init、慢日志缓冲、Docker 层),所以在边界上被挤死了。
麒麟 V10 上还可以追加确认:
memory.oom_control 里的 oom_kill_disable 是 0 表示允许 OOM Killer 动手,under_oom 在故障瞬间会变成 1。这一层能让你确认"确实是容器被限流了",但还回答不了"为什么突然吃这么多内存"。
2.2 MySQL 侧日志:什么在吃内存
第二层证据在 MySQL 的 error log 和慢日志里。先看 error log:
MySQL 8.0 在崩溃前后一般会留下类似这样的片段:
有 Out of memory 关键字,可惜它并不是根因,只是结果——内存不够了,InnoDB 想做啥都做不成。不过这里有个经验:不要因为看到 Out of memory 就停止排查,MySQL 的错误信息只告诉你"内存不够用了",没告诉你"谁用掉的"。
接着查慢日志。因为 MySQL 是容器跑的,慢日志大概率也是写到挂载目录或 stdout。我们在 my.cnf 里确认过 slow_query_log=ON、long_query_time=2、log_queries_not_using_indexes=ON,所以理论上超过 2 秒的查询都会留痕。结果一翻,发现问题了:故障时段有好几条大查询,每一条跑了 200 到 400 秒不等,Rows_examined 全是几百万甚至上千万。
但这些查询的 SQL 文本看起来并不复杂,也没有明显的笛卡尔积,甚至条件里还有金额范围、商户 ID、日期。这引起我的警惕——SQL 文本正常并不代表执行计划正常。
2.3 EXPLAIN 的意外发现:一行 infinity 的"威力"
从慢日志里捞出一条最典型的查询(已脱敏):
按照平时经验,这个 SQL 只需要走 (merchant_id, settle_date, txn_amt) 联合索引做范围扫描,金额条件能过滤掉绝大多数行。但实际执行计划让我大跌眼镜:
输出关键片段:
注意到没有:possible_keys 里有索引,但优化器一个都没用,直接 Table scan on txn_record,扫了 1900 多万行。我当时的第一反应是统计信息过期了,但 ANALYZE TABLE 跑完再 EXPLAIN 依旧如此。直到我通过 performance_schema.events_statements_history_long 把这条语句的绑定参数抓出来,才发现问题:
参数显示:
那一刻,所有线索都接上了。txn_amt <= inf 这个条件等价于 txn_amt <= 无穷大,对任何数值都成立。优化器的条件过滤性评估直接给了 100%(恒真),于是它思考的路径变成了"反正金额条件过滤不掉任何行,不如全表扫,然后用 GROUP BY 建临时表去分组"。执行计划就从"范围扫描 + 快速聚合"退化成"全表扫描 + 临时表 + 排序",Rows_examined 翻了百倍不止。
3. 根因深挖:为什么一个 infinity 能让优化器"发疯"
3.1 先弄清楚 infinity 是怎么进到 MySQL 的
先说这个 inf 是从哪儿来的。顺着业务代码查,发现是前端做"交易金额区间筛选"时,最大金额输入框留空,业务后端为了图省事,在拼查询条件时把这个空值转换成了 float('inf')(Python)或者说 Double.POSITIVE_INFINITY(Java),然后通过 ORM 绑定参数传给了 MySQL。
IEEE 754 双精度浮点数是支持 +inf、-inf、NaN 这三类特殊值的,MySQL 的 DOUBLE 类型也原生兼容它。你用下面这条语句就能复现:
输出:
MySQL 在解析 1e999 时发现超出 DOUBLE 表示范围,不直接报错,而是向上溢出成 inf。这就是问题能落地的机制:MySQL 的 DOUBLE 可以存 infinity,比较运算也遵循 IEEE 754 语义。所以 txn_amt <= inf 在 MySQL 眼里就是一个恒真条件,没有任何过滤性。
3.2 优化器对恒真条件的处理逻辑
MySQL 优化器有一个"条件化简"阶段,职责之一是把逻辑上无意义的谓词识别出来并移除。常量化简(constant condition)会做这几件事:
WHERE 1=1直接移除;WHERE col IS NOT NULL AND col IS NULL识别为永远为假(impossible WHERE),直接返回空结果;WHERE col <= MAX_DOUBLE_OR_INF这类条件,如果值是常量且包含inf,会被认为恒真。
但这里有个容易忽略的细节:它只是移除了条件本身,并没有重新评估这个条件原本能提供的"索引可用性"。正常情况下 txn_amt <= 具体值 可以走 idx_merchant_date_amt 做范围扫描,过滤出几千行;一旦改成 <= inf,条件被移除后,索引里那个"金额范围"元素就失去了匹配机会,优化器能依赖的只有 merchant_id 和 settle_date。而 IN 列表配合日期字段,在优化器看来过滤度远远不够,它最终选择全表扫描。
更隐蔽的问题是 GROUP BY。原 SQL 里带了 GROUP BY merchant_name, channel_code, pay_type,在全表扫描的基础上,这个 GROUP BY 无法利用索引做有序分组(因为这些字段不构成索引的最左前缀),于是 MySQL 必须建内部临时表。1900 万行数据要一一映射到分组桶里,内存临时表立刻膨胀。
3.3 内存临时表、排序缓冲与 cgroup 上限的关系
接下来是内存为什么会被打爆。MySQL 8.0 的内存临时表默认使用 Temptable 引擎,相关参数有 tmp_table_size(默认 16MB)、max_heap_table_size(默认 16MB)、temptable_max_ram(默认 1GB)、temptable_max_mmap(默认 1GB)。
很多人以为超过 tmp_table_size 就会把临时表写到磁盘,内存占用就降下来了。这只说对了一半。Temptable 引擎在超过 temptable_max_ram 之后,会优先把部分数据映射到内存映射文件(mmap),mmap 占用的匿名映射空间照样计入容器的 cgroup 内存,而且它不会立刻归还给内核。只有在超过 temptable_max_mmap 之后,才真正落盘到 tmpdir 的物理文件。
换句话说,一个大查询在 GROUP BY 阶段,可以合法地吃掉 1G 的 RAM 内存 + 1G 的 mmap 映射空间,而 MySQL 并不会觉得有问题。再加上排序用的 sort_buffer_size(如果是多路归并,每个归并轮次都要额外分配内存)、连接线程自身的栈空间,单条查询撑到 1.5G 到 2G 是很现实的事。
但真正致命的还不是单条查询,是并发放大。故障时段恰好是业务报表导出高峰,同一个接口被调用了几次,等于好几条这样的大查询同时进入执行阶段。MySQL 的线程内存是各自独立的:A 查询在跑全表扫描,B 查询在建临时表,C 查询在做排序,D 查询在等磁盘 IO,它们内存互不共享。最后 4G 容器的内存账本大致是:
| 内存去向 | 估算占用 |
|---|---|
| InnoDB Buffer Pool(固定 2G) | 2048MB |
| 连接线程栈(默认 1MB × 当前活跃连接) | 150MB 左右 |
| 大查询临时表 + mmap | 每条 500MB ~ 1.2G |
| 排序缓冲 | 每条 64MB ~ 256MB |
| 其他(日志、缓存、Docker 层开销) | 100MB+ |
一到高峰,两三条大查询同时存活,4G 直接见底,cgroup OOM Killer 开枪。注意这里我没有提到 max_connections=200 带来的静态内存,因为 MySQL 8.0 的连接线程是相对轻量的,真正的重头戏从来都是 buffer pool 和大查询的临时结构。
3.4 还有一类隐藏问题:函数计算与类型转换
排查中我还顺手验证了另一个疑惑——如果绑定参数是字符串 "Infinity" 而不是 IEEE 754 的 inf,情况会不会不一样?
实测结论是更糟。当应用层传的是字符串 "Infinity",MySQL 在绑定参数阶段会做隐式类型转换,把字符串转成 DOUBLE。"Infinity" 转完之后恰好也是 inf,但这次它还会往错误日志里写一条 Warning (Code 1265): Data truncated for column 或者 Incorrect DOUBLE value: 'Infinity',具体取决于版本和 sql_mode。
更麻烦的是,如果这个字段本身是 VARCHAR,而 SQL 里写的是 txn_amt <= ?,字段类型和参数类型不匹配,MySQL 会选择把字段转成 DOUBLE 再比较。这意味着 txn_amt 列上即使有索引,也因为函数/类型转换而完全失效,连"范围扫描"的念想都没了。这种场景下,一条 inf 参数不只是让条件恒真,还顺手把索引废了,两条路都堵死。
4. 修复与加固:从业务代码到数据库到容器的三层处置
4.1 业务侧:入参校验把 infinity 挡在门外
根因已经确认,第一刀必须砍在业务代码上。这是所有修复里成本最低、收益最直接的。
业务侧的做法应该是:查询条件里的"不限上限"不要用无穷大来占位,而是动态拼 SQL。原本:
改成:
核心原则一句话:用什么值表达"没有限制",都不如直接让这个条件不存在。因为优化器对"条件本身"和"条件恒真"的代价评估并不是等价的,与其让优化器去化简一个假条件,不如从源头不生成它。
如果因为框架限制确实无法动态拼 SQL,那就必须做入参白名单校验:当 amount_max 是 inf、NaN、1e999、或者超过 9.9E15 这种明显不合理的值时,直接抛参数异常或者改写为 txn_amt >= 0。千万别把荒谬值传给数据库再期待它"聪明地"处理。
4.2 SQL 侧:改写查询与索引调整
业务代码改完需要回归测试,但数据库侧不能只等业务上线。我这边同时做了两层兜底。
第一层:把同类报表查询改成有界查询。报表接口本来就该带分页或 LIMIT,至少在应用层强制最终的返回行数。如果业务上确实要全量导出,那也得分批跑(比如按 5 分钟一个时间片循环拉取),而不是一次性 GROUP BY 上千万行。
第二层:调整索引设计,让优化器在"金额条件恒真"的情况下也不至于选择全表扫描。(merchant_id, settle_date, txn_amt) 这个联合索引对当前 SQL 是有效的,但它有个盲区:当 txn_amt 条件被剥离后,剩下的过滤条件只有 merchant_id IN (5个值) 和 settle_date = 一天,优化器认为单商户单日的数据量可达几十万,不足以让它放弃全表扫描。
所以我把索引改成了 (settle_date, merchant_id),让日期作为最左前缀。这样即便金额条件被移除,优化器仍然可以通过 settle_date = '2024-06-11' AND merchant_id IN (...) 拿到一个相对窄的范围,Rows 从 1900 万降到接近百万级,配合 GROUP BY 还是能接受。如果后续业务继续扩大,还可以考虑把大商户单独拆分区表或汇总表,这属于架构层面的优化,这次先不急。
执行计划对比:
| 场景 | 索引使用 | 扫描行数 | 执行耗时 |
|---|---|---|---|
| 正常金额值(如 500000) | idx_merchant_date_amt 范围扫描 | 约 3000 行 | 0.2s |
| inf 参数 + 原索引 | 无(Table scan) | 1946 万行 | 386s |
| inf 参数 + 新索引 | idx_date_merchant 范围扫描 | 约 96 万行 | 12s |
| 业务修复后(无金额条件) | idx_date_merchant 范围扫描 | 约 96 万行 | 11s |
即使业务侧修复没完全上线,数据库侧的新索引也能把最坏情况从"打爆内存"降级为"慢但可用",这是可接受的兜底。
4.3 容器与实例侧:内存水位控制
业务和 SQL 都改完后,容器侧也做了调整。这里不是简单地把 -m 4g 改成 8g 就完事,而是重新核算了内存预算。
MySQL 容器内内存分配,我通常用这个粗略公式:
原来的问题是 innodb_buffer_pool_size=2G 占了 4G 容器的一半,余量看似有 2G,但大查询的临时表按并发叠起来很容易突破。调整后:
几个参数变动的理由说一下:
tmp_table_size和max_heap_table_size从 16M 提到 96M,表面看是放宽了,实际上是为了让中小查询能在内存里完成聚合,避免频繁落盘导致 IO 抖动。真正防爆的是max_execution_time,它让超过 60 秒的查询直接报错,避免大查询常驻。sort_buffer_size和join_buffer_size保持小值,这是 per-thread 内存,宁可多一次磁盘归并也不让它在内存里积压太多,并发场景下必须保守。innodb_buffer_pool_size降到 1.5G,牺牲一点缓存命中率,换取容器内存水位更健康。
容器启动参数也做了微调:
注意这里有三个细节:
--memory-swap=6g和--memory=6g相等,等于禁用了 swap,避免 MySQL 进程被换到 swap 后性能横向崩塌。容器内开了 swap 反而会让问题从"OOM 快速暴露"变成"卡死慢响应",后者更难排查。--oom-kill-disable=false,保持内核能杀进程。OOM Kill 虽然粗暴,但至少可用性还能通过重启恢复;如果把 OOM Kill 禁掉,MySQL 会直接 hang 住,业务连不上、kill 不掉,更麻烦。restart=on-failure:3,限制最大重启次数,防止无限重启把问题掩盖。排查期最好还是手动启动。
4.4 验证:压测与复现测试
修完之后必须做验证,而且不能只靠"看起来好了"。我的做法是三步:
第一步,构造复现 SQL。用之前的 inf 参数重新执行一次,确认在新索引下执行计划走的是 idx_date_merchant,Rows 明显下降,且 max_execution_time 生效后超过 60 秒直接被拒掉。
计划输出里 actual time 应该在百毫秒到秒级,并且没有 Table scan on txn_record 这种字样。
第二步,压测。用一个简单的并发脚本模拟 20 个线程同时执行上面的查询,观察容器内存曲线的波峰。修完后峰值稳定在 4G 以内,不再触发 OOM。
第三步,观察一个完整的业务高峰周期(至少 24 小时)。确认没有新的 OOM 记录,慢日志里大查询数量明显下降,业务报错消失。这一步不能省,很多问题在低峰期不显形,必须跨一个业务周期才算数。
5. 复盘总结:这类内存异常以后怎么快速定位
5.1 监控指标清单:全链路要看哪些点
这次排错最大的教训是"容器 OOM 不能只盯着容器看",必须把宿主内核、cgroup、Docker 事件、MySQL 日志、慢日志、执行计划、绑定参数串成一条链路。我列一张清单,下次遇到类似问题按顺序查:
| 层级 | 查看内容 | 关键命令/工具 |
|---|---|---|
| Docker 层 | 容器状态、OOMKilled、ExitCode、重启次数 | docker inspect、docker events --since 30m |
| 宿主内核 | OOM Killer 记录、cgroup 内存使用 | dmesg -T | grep -i oom、/sys/fs/cgroup/memory/... |
| 系统层 | 物理内存、swap、磁盘 IO | free -h、iostat -x 1、top |
| MySQL error log | 崩溃前最后的错误/警告 | docker logs、<datadir>/*.err |
| 慢日志 | 大查询、Rows_examined、扫描行数 | mysqldumpslow -s t |
| 执行计划 | 是否走索引、扫描行数、临时表 | EXPLAIN FORMAT=TREE、EXPLAIN ANALYZE |
| 参数绑定 | 是不是传了 inf、NaN、超大值 | performance_schema.events_statements_history_long、general_log |
| 业务代码 | 查询条件的默认值、"不限"的占位方式 | 代码审查、接口入参日志 |
这套链路里最容易漏掉的是倒数第二个——绑定参数。慢日志里看到的 SQL 文本通常是 txn_amt <= ? 这种参数化形式,? 的具体值不会直接显示。如果不知道从 performance_schema 或者 general log 里把参数值抓出来,你很可能在"为什么走了全表扫描"这一步卡很久。
5.2 排查 OOM 的黄金命令组合
把这次实际用到的命令整理一下,方便直接复制:
在 MySQL 8.0 里,如果想让绑定参数直接显示在 general log 中,也可以临时开 general_log,但生产环境不建议长时间开,因为日志量会非常大,只适合在定位高峰期临时开几分钟。
5.3 从这次故障里总结的几条规范
最后说几条踩完坑之后的沉淀,未必多高深,但每一条都是真金白银换的:
第一,数据库查询条件永远不要用 inf / NaN 这类非业务值做占位。如果你的代码里出现 float('inf')、Double.POSITIVE_INFINITY、1e999、"Infinity" 这类东西,基本可以认定为设计味道不好。表达"不限"的正确方式就是动态 SQL 里不出现该条件,或者数据库层面对这类值做等价改写。
第二,大查询一定要有兜底手段。max_execution_time、LIMIT、分页、批处理,至少占一样。一个没有时间限制、没有行数限制、还带 GROUP BY + ORDER BY 的报表查询,本质上就是一颗定时炸弹,遇到数据量波动或者参数异常就会爆。
第三,容器内存规划要考虑"最坏并发"而不是"日常均摊"。我见过很多 MySQL 容器,buffer pool 给得很大,余量算得很极限,结果一遇到大查询并发就 OOM。buffer pool 再大,命中率最多帮你省磁盘 IO,给不了你大查询的临时表那么大的内存缺口。宁可 buffer pool 小一点,也要给临时表、排序、连接缓冲留出可呼吸的空间。
第四,在国产操作系统(麒麟 V10 这类)上排查容器问题,别默认所有路径都和 CentOS 完全一致。比如某些工具、内核参数的可调项、/sys/fs/cgroup 的布局可能不同,dmesg 的权限要求也可能更严格。排查时优先用 Docker 自身提供的信息(docker inspect、docker stats)和 MySQL 日志,少依赖宿主机上的第三方工具,这样能少踩很多环境差异的坑。
最后再说一个实际操作中的细节:排查这种不定时 OOM,我强烈建议先把容器的 restart 策略关掉,并且在监控面板上把"容器重启次数"作为一个独立告警项。自动重启是可用性的保护伞,也是根因排查的隐形敌人。只有当容器死在当场、现场完整保留时,你才有机会沿着日志一路挖到那个看起来人畜无害的 inf。