MySQL容器OOMKilled排查:一行LIMIT无限大如何打爆8G内存?
周二下午四点,监控平台开始刷屏:跑在麒麟 V10 服务器上的 MySQL 容器又 OOMKilled 了。这已经是三天里的第三次,前两次我手动把容器一拉、业务一恢复就没再管,直到这次连报表服务和后台调度一起报错,我才意识到这个 OOM 不是偶发。查到最后,真凶不是 Buffer Pool 配置、不是连接数、不是宿主机内存不够,而是业务代码里的一行 LIMIT 18446744073709551615——一个在 MySQL 里常被当作"无限大 limit"的写法,也就是标题里那句"一行 infinity"。这趟排查走了不少弯路,也踩了几个有意思的坑,我把完整过程整理成一篇排查实录,希望能帮同样在容器里跑 MySQL 的同行省点时间。
1. 故障现场:容器反复 OOMKilled,业务大面积超时
1.1 一次"普通"重启之后,问题又来了
环境先说清楚:银河麒麟高级服务器操作系统 V10 SP3,内核 4.19,x86_64,Docker 19.03,MySQL 用的是官方 8.0.25 镜像,容器内存限制 8G,数据目录在宿主机挂载,容器配置了 restart=always。
前两次 OOM 发生的时候,我的处理方式很粗暴:docker restart <container>,然后 docker stats --no-stream 看一眼内存回落到 3G 左右,业务方反馈恢复,这事儿就算过去了。现在回头看,这个操作治标不治本,只是把问题往后推了推。
第三次出事是周二的 16:00,和前几天的时间点一模一样。这次告警不是一个一个蹦,而是一屏一屏刷:报表服务超时、调度任务批量失败、网关层开始报 MySQL server has gone away。我登录服务器 docker ps 一看,容器正处在 restarting 状态,docker inspect 里 OOMKilled 字段已经是 true。到这个程度,已经不是重启一下能解决的事了。
1.2 从内核日志确认 OOM 不是"假警报"
有人可能会问,会不会是容器被物理机杀进程误伤了?确认这个很简单,直接看内核日志:
输出里能看到类似这样的关键行:
anon-rss 已经到了 7.5G,加上其他内存开销,直接把容器 8G 的 cgroup 上限打爆了。order=0 说明是一次普通的内存分配请求,并不是什么特殊的大块分配,也就是说进程是"一点一点吃上去"或者"某一瞬间整体抬升"导致的。看时间戳,16:00:12,和监控告警时间吻合。
还有一个细节:容器被 OOM 杀死后,因为 restart=always,Docker 会自动拉起一个新容器。所以业务会有一小段中断窗口,然后自动恢复。这个自动拉起机制在低峰期是救命稻草,在高峰期反而会掩盖问题的严重性,容易让团队麻痹。
1.3 时间规律:每个工作日下午四点
连续几次都在 16:00 左右触发,让我确定这背后一定有一个规律性任务。找业务方一确认,16:00 确实有一个"报表数据预生成"定时任务,每天跑一次,逻辑是从订单汇总表取一段时间的数据,一次性导出成 Excel 文件。
前两次我重启后就完事了,没有细查定时任务。后来复盘时才意识到,这个任务并不是刚上线的,而是已经跑了好几个月。为什么以前没事,最近才开始 OOM?只有一种可能:数据量涨到了某个临界点,把某个本来勉强能扛住的路径击穿了。
这个怀疑,在后面的排查中得到了验证。
2. 第一轮排查:常规检查全部正常,问题不在"静态配置"
2.1 连接数、BufferPool、页淘汰状态检查
很多人遇到 MySQL 内存问题,第一反应是看配置。我当时也一样,先把常规嫌疑项过了一遍:
innodb_buffer_pool_size = 4G,占容器内存 8G 的一半,看起来合理,没有超配。Threads_connected在 180 左右波动,max_connections = 500,远没到连接数瓶颈。SHOW ENGINE INNODB STATUS里的 Buffer Pool 命中率、脏页比例、pending IO 都正常。performance_schema内存统计里也没有明显异常的memory/innodb或memory/sql消耗。
这里插一句,容器环境的 MySQL 配置要特别注意一个误区:不能按照物理机 64G 内存的思路去配 Buffer Pool。容器 cgroup 限制的是整个进程的内存,Buffer Pool 只是其中一部分,连接线程、排序缓冲、临时表、结果集缓存都会占内存。如果 innodb_buffer_pool_size 顶到容器内存的 70% 以上,平时看着还行,一到有大查询的瞬间就容易 OOM。但我们这个案例里 Buffer Pool 只占一半,配置本身是健康的。
2.2 翻了慢日志,却什么都没抓到
检查配置没问题之后,我顺手翻了慢查询日志,结果更迷惑了:slow_query_log 是开着的,long_query_time=10,但这几天的慢日志里几乎没有超过 10 秒的查询,只有几条正常的统计 SQL。
为什么故障 SQL 没进慢日志?这里有个容易忽略的点:MySQL 的慢日志记录的是"执行完成"的查询,如果查询在执行过程中因为 OOM 触发内存分配失败,mysqld 可能根本来不及把这条 SQL 写进慢日志,进程就直接被内核 kill 了。所以慢日志没记录不代表没有大查询,只能说明有大查询"死得很突然"。
既然事后看不出来,那就只能等下一次事发时"抓现行"。我做了两步准备:一是把 general log 打开,记录所有 SQL;二是把 performance_schema 里 events_statements 的采集打开,方便按语句类型聚合统计。general log 在高并发下有点性能损耗,但对这个故障排查来说,值得。
2.3 先排除"是不是宿主机物理内存不够"
还有同事提出:是不是宿主机物理内存不够,导致容器被无辜牵连?
我特意看了一眼宿主机:64G 物理内存,上面跑了 5 个 MySQL 容器、几个中间件,cgroup 限制总计 30G 左右,当时 free -g 显示物理内存空闲还有十几 G。也就是说,宿主机层面内存充足,杀死进程的是容器的 cgroup 内存限制,不是物理机 OOM。
这里也提醒一下:Docker 容器的 --memory 参数限制的是 cgroup memory.limit_in_bytes,这个值管的是 RSS + page cache 等匿名页。MySQL 进程内分配的排序缓冲、临时表、线程栈都会计入。如果宿主机没有开启 swap 或者 cgroup 里 memory.swappiness 为 0,一旦进程瞬时申请内存超过限制,内核会优先在 cgroup 内找"大头进程"下手,mysqld 这种单一进程占内最大的就成了首选目标。
3. 抓到真凶:下午四点那条 SQL,LIMIT 用了"无限大"
3.1 general log 里的一条 1.8 亿次幂数字
打开 general log 后的第二天,16:00 一过,我直接去查日志,果然抓到一条和定时任务完全对得上的 SQL。简化后的样子大概是这样:
关键就在最后一行:LIMIT 18446744073709551615。这个数字可能很多人看着眼生,但它是 MySQL 里一个很特殊的存在——它是 UNSIGNED BIGINT 的最大值,在 MySQL 官方文档里,当 LIMIT 写这个值的时候,语义等同于"不限制返回行数,取到末尾为止"。很多分页插件和导出工具会用这个值来表示"全量查询",业界也常把它叫做 "infinity limit"。
所以说,标题里的"一行 infinity"不是指数据表里某个字段存了 Infinity,而是代码里这一行 LIMIT 写法上的"无限大"。
3.2 EXPLAIN 的结果让人倒吸一口凉气
拿到 SQL 之后,我先在测试库上跑了一下 EXPLAIN,结果如下:
| 表 | type | rows | Extra |
|---|---|---|---|
| t_order_summary | ALL | 4,283,119 | Using temporary; Using filesort |
| t_order_detail | ref | 1 | NULL |
t_order_summary 全表扫描,预估 428 万行;t_order_detail 走的是 ref 索引查找。问题比预想的还严重:这个查询不是只查几条数据,而是把 428 万行全部捞出来,再按 create_time 做全局排序,返回全量结果。
而 b.detail_content 是一个 TEXT 字段,平均长度大约 2.1KB。也就是说,排序和结果集物化过程中,内存里要装的不是几个字段的元组,而是带大字段的 428 万行。这是一个非常可怕的内存消耗模型。
3.3 这是"一行 infinity",但不是数据里的 Infinity
排查过程中我一度走偏过。因为我一开始听到"infinity",第一反应也是去查数据表里有没有存了 Infinity 字符串或者异常大的数值。我甚至跑了这样的 SQL:
结果一条记录都没有。这个排除动作本身不算白费,它让我确认了问题不在"业务数据内容",而在"SQL 写法本身"。真正引发 OOM 的,就是那行 LIMIT 18446744073709551615。
它是怎么出现在代码里的?业务方是一个数据导出功能,开发同学图省事,直接在 SQL 模板里写死了"全量导出":
还有一种常见写法是在 Java 代码里传 new Page<>(1, Integer.MAX_VALUE),MyBatis 或分页插件渲染出来就是类似的效果。不管是哪种,本质都是同一个问题:用"一个极端大的数"来模拟"不分页"。
4. 为什么一个 LIMIT 能把 8G 内存打穿
4.1 有 LIMIT 和没有 LIMIT,排序算法完全不一样
很多人会有一个错觉:LIMIT 18446744073709551615 和没有 LIMIT 不是一样吗?都是全量查。对于结果集来说确实一样,但对于 MySQL 内部的排序实现来说,天差地别。
当 SQL 里同时存在 ORDER BY 和 LIMIT n 时,如果 n 比较小,MySQL 优化器会走"优先队列"排序:维护一个大小为 n 的堆,遍历数据时只保留当前最小的 n 个元素,内存占用是 O(n),非常克制。比如 LIMIT 20,堆里只有 20 条记录,哪怕底表有 400 万行,内存也能稳稳控制住。
但当 LIMIT 是一个极大值(或者没有 LIMIT)时,优化器不会选择优先队列,而是走标准的 filesort 全量排序:把 400 多万行的排序键和附加字段全部读入 sort_buffer,如果超了 sort_buffer_size,再分裂成多个 chunk 写入临时文件,最后做多路归并。
问题就在这里:全量排序的内存峰值,取决于"有多少数据同时参与排序",而不是"最终要返回多少行"。这个 SQL 的排序记录里还带着 detail_content 这个大 TEXT 字段,内存消耗进一步放大。
4.2 有 TEXT/BLOB 的大字段时,内存翻倍不是开玩笑
MySQL 8.0 的 filesort 有一种优化叫"addon fields":为了排序后不用再回表查其他列,优化器会把 SELECT 里需要的所有列一起放进排序记录里。对定长字段这没什么问题,但对 TEXT/BLOB 这种变长大字段,它会按实际长度把内容拷贝进排序记录。
t_order_detail.detail_content 平均 2.1KB,428 万行就是大约 8.6GB 的裸数据。虽然 MySQL 不会一次性把所有行都载入内存,它会按 sort_buffer_size 分块,但问题在于:
- 每个 chunk 排序完成后写入临时文件,归并阶段还需要把 chunk 的数据重新读入内存做多路合并;
- 如果
tmp_table_size或max_heap_table_size设置在内存能够容纳的范围内,临时表本身也会优先在内存中创建; - 结果集最终通过 JDBC 驱动传回应用端时,如果应用没有配置流式读取,MySQL Connector/J 会尝试把整个结果集物化到应用层,应用服务的内存也会跟着暴涨。
这几条路径叠加在一起,8G 容器的内存上限根本不够看。更关键的是,排序的内存是 短期瞬时峰值,不是持续占用,所以监控上看到的曲线往往是"平时稳定在 4G,突然几秒内冲到 8G 然后被杀",这也解释了为什么常规的 SHOW PROCESSLIST 根本来不及抓到现场。
4.3 麒麟 V10 + 容器环境的放大器作用
这个案例发生在麒麟 V10 环境里,有不少人问是不是麒麟系统有什么坑。我的结论是:这不是麒麟的锅,但容器 + 国产操作系统这个组合确实会放大问题的严重性,主要体现在三个方面。
第一,MySQL 官方镜像使用 glibc 的默认 malloc 内存分配器。在高并发、大内存操作之后,glibc malloc 释放的内存可能不会立即归还给操作系统,而是留在进程堆里作为缓存。这意味着,一次大查询撑到 8G 被 kill 之前,即使中间有些内存已经"释放",RSS 也可能居高不下,让后续查询可用内存越来越少。
第二,容器环境通常不配置 swap,或者 cgroup 层面的 swap 分配很小。物理机内存不足时,系统可以先换页到磁盘争取时间;容器里一旦触发 cgroup 限制,内核没有任何缓冲,直接 OOM killer。
第三,vm.overcommit_memory 默认是启发式模式(0),进程申请虚拟内存时内核基本不校验物理容量,等到真的写入时才发现物理内存不足。在容器里面,这就是"申请成功 -> 物理不够 -> 被点名处决"的标准流程。
所以在容器里排 MySQL OOM,不能只看 SQL 和 Buffer Pool,还要把"内存分配器行为""cgroup 统计口径""资源配置"这些因素一起纳入考量。
5. 修复:代码改一行,参数兜底三条,监控补齐
5.1 现场止血:优先保住数据库
定位到 SQL 之后,第一时间不是改代码,而是先止血。我当时做了三个动作:
第一,把正在运行的异常连接杀掉。SHOW FULL PROCESSLIST 找到对应连接后执行 KILL QUERY 26296,让数据库从"半死不活"的状态里立刻释放出来。
第二,给 t_order_summary.create_time 加了一个普通索引。虽然这个 SQL 的 WHERE 条件只用了 >=,范围过滤后还有数百万行,没法彻底解决排序问题,但至少能让优化器少走一段回头路。
第三,执行 SET GLOBAL max_execution_time = 8000;。这个参数的作用是给所有只读 SELECT 语句设置最大执行时间,超时自动报错,防止一个失控查询无限跑下去。注意它只对非事务的 SELECT 生效,存储过程和触发器等内部的查询不适用,别把它当万能药。
这三个动作做完,容器内存曲线立刻回到稳定区间,业务方反馈接口不再超时。
5.2 代码层:全量导出必须走游标或分页
止血只是暂时的,真正的修复在代码层。我给业务开发团队提了三个方案,按复杂度从低到高排列:
方案一:把"全量导出"改成 MyBatis 流式查询。MySQL JDBC 驱动有一个特殊约定:当 fetchSize 设置为 Integer.MIN_VALUE,并且 ResultSet 是 FORWARD_ONLY 时,驱动会从"一次性把结果全拉回客户端"变成"服务端逐行流出"。这是一行代码可以生效的改动,但对连接占用时间比较久,需要配合数据库连接的超时配置。
方案二:改成 keyset 分页。即每次只取 1000 条,按 create_time, id 排序,取完一批后用最后一条的 create_time 和 id 作为下一批的起点,通过 WHERE (create_time, id) < (?, ?) 实现精准游标。这个方案对数据库最友好,也能避免深度分页常见的 offset 浪费。
方案三:两段式导出。第一段只查 ID 列表,第二段再按 ID 批量(每次 500-1000 个)查询详情字段,最后在应用内存里组装。这个方案适合数据量特别大、且详情字段很多不需要排序的场景。
我在代码评审规范里还加了一条硬性要求:SQL 和代码中禁止出现 Integer.MAX_VALUE、Long.MAX_VALUE、18446744073709551615 这类"无限大"写法,凡是用来表达"全量"的,必须走分页或流式接口。这条规则看着教条,但能防住很多随手写出来的事故。
5.3 监控与告警:确保下次能提前发现
这次故障给我的一个深刻教训是:没有内存曲线的告警,完全靠"业务报错后去看 dmesg"是发现不了 OOM 前兆的。我事后补了三层监控:
第一,容器指标接入 Prometheus。用 cAdvisor 采集每个容器的内存使用率,把告警阈值设置在限制值的 80% 和 90% 两级。80% 是提醒,90% 是紧急,确保在 OOM 前有缓冲时间介入。
第二,MySQL 侧启用 performance_schema 的语句聚合。通过 sys.statement_analysis 或 events_statements_summary_by_digest 按 SQL digest 统计 rows_examined、sort_merge_passes、tmp_disk_tables 等指标,每周推送一份 top SQL 给研发团队,让"坏 SQL"在变成 OOM 之前就被看见。
第三,把 slow_query_log 的阈值从 10 秒降到 2 秒,并定期巡检。虽然前面说过 OOM 时慢日志可能来不及记录,但降阈值之后,很多"亚健康"查询会被提前暴露出来。
6. 复盘:数据库 OOM 的时候,别急着骂 DBA
6.1 故障处置时间线回顾
整个排查过程,从第一次出现 OOM 到定位根因,用了大约三天。时间线拉出来看,很多时间都浪费在了"惯性排查"上:
| 时间点 | 动作 | 结果 |
|---|---|---|
| 第一天 | 重启容器,观察内存回落 | 治标不治本 |
| 第二天 | 检查 BufferPool、连接数、慢日志 | 全部正常,陷入迷茫 |
| 第二天晚 | 排查数据内容是否有 Infinity 字符串 | 排除数据问题 |
| 第三天 | 开启 general log,等待现场 | 抓到 LIMIT 18446744073709551615 |
| 第三天 | EXPLAIN 定位全表扫描 + filesort | 根因确认 |
| 第四天 | 杀异常连接、加索引、设执行时间上限 | 止血成功 |
| 之后 | 代码改分页、补监控告警 | 长期修复 |
这个顺序其实暴露了一个问题:当外部现象是"容器 OOM"时,第一反应总是往数据库配置和系统资源上找原因,而不是直接去抓那条最异常的 SQL。如果一开始就打开 performance_schema 等着,或者直接开 general log,第一天就能定位到问题所在。
6.2 一句话教训与扩展思考
这次故障给我的最大教训是:任何"全量"需求的实现方式,决定了系统的内存天花板。数据量小的时候,全表查询、全量导出、无限 LIMIT 都能跑得动;但数据量一旦跨过某个阈值,这些"捷径"就会以最剧烈的方式反噬系统。
类似的模式在其他中间件里也很常见:Kafka 里某个 consumer group 没有正确配置 batch.size 和 max.poll.records,积压量上来之后直接把堆内存打爆;ES 里一个不带 size 上限的 _search 请求,会把协调节点内存吃光。这些问题的共同点是:单看某一行参数都不起眼,但"大数据量 + 全量获取 + 内存无界"三个条件凑齐,就是一颗定时炸弹。
个人而言,我现在每次 code review 看到 LIMIT 后面跟一长串数字,或者看到 Integer.MAX_VALUE 出现在分页参数里,都会下意识去查一下目标表的数据量。一个几百毫秒内看似无害的查询,在数据量千倍增长后,可能就是生产环境最致命的那一行代码。这次麒麟 V10 上的 MySQL 容器 OOM 排查实录,就当给团队留一份经验,也希望看文章的人别在同样的坑里再摔一遍。