MySQL容器OOM排查实录:一行配置如何打爆8G内存
排障日志,建议收藏。先说结论:业务代码里“一行配置”加上“一个 JOIN”,在特定数据分布下能把 MySQL 容器的内存瞬间打爆。这一篇不写虚的,只还原完整排查链路。
1. 故障现场:凌晨跑批把 MySQL 容器打到 OOM
先说下环境,避免后面命令对不上号。服务器是麒麟 V10 SP3(Kylin Linux Advanced Server release V10),内核 5.10,Docker CE 20.10.x,数据库跑的是官方 mysql:8.0.32 镜像,容器内存限制 8G。宿主机 32G 内存,之前这套组合已经稳定跑了快半年,属于那种“部署完就没人管”的典型系统。
故障发生在某天凌晨 1 点 20 分左右。业务监控先报警,不是数据库挂掉,而是“订单查询接口平均响应时间超过 30 秒”。值班同事登录服务器时,发现 MySQL 容器已经在反复重启,docker ps 里状态那一列是 Restarting (137) 12 seconds ago。退出码 137 是个非常明确信号:进程被 SIGKILL 干掉了,而杀掉它的不是 MySQL 本身,是操作系统。再加上容器还在自动重启,基本可以确定是内存被系统回收了。
几个关键现场数据随手记录一下:
这里有个很容易踩的坑:docker inspect 里的 OOMKilled=true 只代表容器被 cgroup 限制触发杀掉了,不代表就是宿主机内存真的用完。但在这次故障里,free 显示 used 27G,加上 buff/cache 3.2G,宿主机已经相当紧张,所以第一反应是“宿主机内存整体不够”,排查方向差点被带偏。
容器被杀了又自动重启,重启之后 MySQL 会走崩溃恢复流程,这个时间段内业务连接全部失败。实际影响比单次 OOM 更严重——这是一个“杀掉—重启—恢复—再被杀”的循环,直到我们手动介入。
2. 从内核日志到慢查询:锁定“那一行”的完整排查链路
2.1 第一层:先分清是宿主 OOM 还是容器 cgroup OOM
处理这类问题,我习惯先问一个问题:杀进程的是谁?
如果是宿主机物理内存耗尽,内核的 OOM Killer 会挑一个进程杀掉,日志在 dmesg 或者 /var/log/messages 里,特征是能看到 Out of memory: Killed process 字样。如果是容器被 cgroup 限制杀掉,在 cgroup v2 的 memory.events 文件里能看到 oom_kill 计数。
这次两边都有迹象:
注意:麒麟 V10 SP3 默认用的是 cgroup v2,路径和 CentOS 7 的 cgroup v1 不一样,很多人上来就找 /sys/fs/cgroup/memory/memory.oom_control,找不到就以为系统有问题,其实只是路径变了。Docker 18.09 及更早版本对 cgroup v2 支持不完整,如果你用的是老版本 Docker,建议优先升级到 20.10+,否则容器内存限制在很多情况下根本不生效。
从日志看,内核在 01:18:56 杀掉了 PID 31245,也就是 mysqld 进程。同时 cgroup 的 memory.events 里 oom 计数为 1。两边的记录对应上了,说明是容器实际内存使用超过了 8G 限制,触发了内核回收。
2.2 第二层:从 MySQL 内部翻出“主谋 SQL”
确认是 MySQL 容器内存超限之后,下一步就是找谁吃了内存。MySQL 8.0 的 performance_schema 里记录了每一条正在执行的 SQL,但这有一个前提:performance_schema 必须开启,而且 events_statements_history_long 这个 consumer 得打开。如果没有提前开启,就只能靠慢查询日志和 error log 了。
这次比较幸运,performance_schema 是开着的。容器重启之后,MySQL 恢复了,用 docker exec 进容器,查一下当前到底在跑什么:
但因为容器重启了,历史会话已经断了,这条查出来只是当前正在执行的查询,意义不大。真正的关键在慢查询日志。查看 my.cnf 配置的时候发现,慢查询日志平时没开。这是一个值得反思的点:生产库真的应该把 slow_query_log=ON 和 long_query_time=1 打开,不然出了问题无从下手。
好在 MySQL 8.0 有一个满足需求的机制:performance_schema.events_statements_history_long 在内存里保留最近的语句历史,默认能存 1000 条(由 performance_schema_max_statement_classes / performance_schema_events_statements_history_long_size 控制),重启前最后执行的语句还在里面。
出来结果让人倒吸一口气——有一条 SQL 的 ROWS_EXAMINED 是 13,845,329,也就是接近一千四百万行,TIMER_WAIT 达到了 87 秒。这条 SQL 的 SQL_TEXT 长这样:
业务方说这是“每日工单处理量统计”的定时任务,原来只查当天数据,很快。但故障前一天发版后,这条 SQL 被改成了这样——为了统计所有“长期未关闭”的工单,他们往配置表 t_sys_config 里加了一行配置:config_key = 'infinity_due_time',config_value = '9999-12-31 23:59:59',语义是“没有到期时间就默认永远有效”。这正是标题里的“一行 infinity”。
2.3 第三层:为什么加了这行配置就把内存打爆
从表面看,这条 SQL 并不复杂。但问题出在 a.due_time = b.config_value 这个 JOIN 条件上。MySQL 优化器在处理这种“子查询返回常量”的情况下,会把 '9999-12-31 23:59:59' 当成一个常量传播到主查询中,然后基于索引统计信息来估算“等值匹配能得到多少行”。
t_work_order 表有约 1300 万行,due_time 上有索引。问题在于,之前业务逻辑里根本不会出现 9999-12-31 23:59:59 这个值,索引统计里这个值是“消失的极值”。新功能上线时,开发用一条 UPDATE 把所有历史未关闭工单的 due_time 批量更新成了这个极值,一更新就是 6 万行。索引里的数据分布立刻发生了严重倾斜:有的时间值只对应几行,但 9999-12-31 23:59:59 对应了 6 万行。
MySQL 8.0 的索引统计是基于随机采样估算的(innodb_stats_persistent_sample_pages 默认 20 页),这 20 页里恰好抽到全部是普通值,没有极值,优化器就会认为 due_time = '9999-12-31 23:59:59' 命中行数极少,于是选择了索引范围扫描甚至全表扫描。实际执行计划 EXPLAIN 结果也证实了:
type=ALL,全表扫描一千三百多万行;Extra 里 Using temporary 意味着排序和分组要建临时表;Using join buffer (hash join) 说明 JOIN 无法走索引,需要走 hash join 在内存里构建哈希表。三个标签凑在一起,就是 OOM 的地基。
3. 内存是被谁吃掉的:排序缓冲、临时表与优化器误判
3.1 一条 GROUP BY 查询的内存去向
很多 DBA 有个误区,以为 MySQL 只有 innodb_buffer_pool_size 是吃内存的大户,查询顶多就是 CPU 慢一点。这是错的。一条复杂的 GROUP BY / ORDER BY 查询,内存消耗可能超过你的想象,主要在这三个地方:
第一是 sort_buffer_size。虽然默认值只有 256KB,但它是每个排序操作都会分配的。当优化器决定对 1300 万行做排序时,sort buffer 会按需增长到排序所需的大小,如果排序涉及的行宽很大,单个线程占用几 GB 瞬时内存不是开玩笑。
第二是内部临时表。GROUP BY owner 这个操作,MySQL 会在内存中创建一张临时表来聚合。这张表默认存到 tmp_table_size(默认 16MB)或 max_heap_table_size(默认 16MB)的限制,超过后转为磁盘临时表。但转磁盘临时表并不代表内存就释放了——字段如果是 TEXT/BLOB 类型,MySQL 在排序时会先把内容指针放到临时表里,排序后再回表读取,这个过程中的内存峰值非常高。
第三是 hash join 的内存。MySQL 8.0 默认的 join 算法在无法使用索引时会退化为 hash join,需要把 join 一侧的所有行加载到内存构建哈希表。这个行为受 join_buffer_size 影响,但注意 hash join 的构建侧内存并不严格受限于这个参数,极端情况下会持续增长。
这次查询的问题是:GROUP BY a.owner + ORDER BY cnt DESC,加上 t_work_order 表里还带了 title、content 这种 TEXT 字段。实际执行时,优化器先全表扫描取 1300 万行,构建 hash join 的哈希表,再对结果集做分组排序,多个内存模块叠加,峰值轻松突破 6GB。而容器一共只有 8G,mysqld 本身的 buffer pool 占了 4G,连接线程、元数据锁、临时结构再吃点,直接就触发 cgroup 的 OOM 判定。
3.2 为什么“一行数据”能让优化器彻底翻车
这个案例最有意思的地方在于:不是 SQL 写错了,也不是没有索引,而是数据分布剧烈变化后,统计信息完全失真。
MySQL 的索引统计基于采样,SHOW INDEX FROM t_work_order 看到的 Cardinality 是一个近似值。如果某个值突然占据了大量行,而采样时没有采到它,优化器就会严重低估该等值条件的选择性。对于 due_time = '9999-12-31' 这种极值条件,在没有直方图(ANALYZE TABLE ... UPDATE HISTOGRAM ON due_time;)的默认情况下,优化器只能靠默认规则猜测。它以为能过滤掉 90% 以上的数据,实际上这个条件命中了全部 1300 万行中需要处理的一大部分。
数据倾斜 + 统计失真,这是 OLTP 系统里最常见的 OOM 导火索之一,和用不用容器没有本质关系。但容器环境把这个问题的杀伤力放大了无数倍:裸机 MySQL OOM 时只影响 MySQL,而容器 OOM 会直接杀容器主进程,触发重启策略,把“内存超限”变成“服务中断 + 崩溃恢复”的双重事故。
3.3 容器内存模型里几个反直觉的点
排障过程中有几个细节,我觉得值得单独写出来。
page cache 也会占用容器内存配额。你在容器里的 free -h 看到的内存使用量,和 cgroup 统计的 memory.current 不一定是同一个数。cgroup 会把进程读写文件产生的 page cache 也计入容器内存,MySQL 跑批量查询时大量读取表数据,page cache 会快速膨胀,即使这些内存在宿主机层面可以回收,但在 cgroup 统计上已经算到容器头上了。所以有时候你看到 mysqld 的 RSS 并不高,但容器还是 OOM,就是这个原因。
另外,cgroup v2 的内存限制是“软性异步回收 + 硬性同步回收”的机制。当 memory.current 超过 memory.high 时,内核会尝试异步回收;超过 memory.max 时,就会触发 OOM Kill。Docker 默认只设置了 memory.max,memory.high 通常不设,导致的行为是:容器一直用到 max 边界附近才被被动回收,没有提前降速的缓冲阶段。生产环境建议给容器加一个偏保守的 memory.high 值,让内核提前介入回收。
4. 修复方案与多层防护:从 SQL 改写到大查询熔断
4.1 当场止血
发现主谋 SQL 之后,第一件事就是把它杀掉,然后停掉定时任务。MySQL 8.0 里可以用:
如果 MySQL 已经处于假死状态,KILL 命令可能也卡住,这种情况下直接重启容器会更快,但需要承担崩溃恢复的时间成本。这次是幸运的,MySQL 在两次重启之间有一段恢复期,我们有足够窗口执行 KILL。
接下来紧急处理配置:由于是“一行配置”引发的问题,直接把那条 config_key='infinity_due_time' 配置临时禁用?不行。问题不在配置本身,而在使用配置的方式。真正要做的是让业务 SQL 不再把这个极值当成普通 JOIN 条件来用。当时我们做的第一步是在应用配置中心把定时任务关掉,避免它继续产生大查询。
4.2 SQL 与索引层的根治方案
技术上,我给了开发两个方案。
方案一(改动最小):去掉 JOIN,直接在应用代码里读取配置值,然后把 due_time = '9999-12-31 23:59:59' 变成正常的时间范围判断,例如 due_time >= DATE_SUB(NOW(), INTERVAL 365 DAY),并且给 due_time 和 owner 建一个联合索引:
方案二(推荐):明确“长期未关闭”不应该通过时间极值来表示,在业务表里加一个 is_closed TINYINT 标志位,统计时走 is_closed = 0,并建对应的索引。用标志位代替极值时间,逻辑更清晰,也避免了“9999-12-31”这种魔法值出现在统计条件里。
这次我们采用了方案二,原因很直接:只要极值还在 SQL 条件里,哪怕这次索引建好了,未来某天数据量涨上去,类似的问题还会复发。标志位方案从根上消除了“极值条件”这个隐患。
同时给所有跑批查询加上执行时间上限,防止任何一条 SQL 失控后再拖垮整个实例:
MySQL 8.0 支持优化器 Hint,MAX_EXECUTION_TIME 单位为毫秒,超过 30 秒直接让数据库终止这条语句,而不是无限制跑下去。
4.3 数据库参数层的防御策略
SQL 改完之后,我从 MySQL 参数层面补了一圈防线,重点是限制单条 SQL 可占用的内存上限。
max_heap_table_size和tmp_table_size:原来都是 16MB,这次调成max_heap_table_size=64M、tmp_table_size=64M,并保持两者一致。这样即使出现大 GROUP BY,内存临时表超过 64MB 就会落盘,避免无限膨胀。range_optimizer_max_mem_size:默认 8M,建议设为 16M。它限制范围查询优化时占用的内存,如果超过阈值,优化器会放弃部分 range 优化,避免因大量条件组合导致的内存暴涨。sort_buffer_size不建议全局调大。这个参数是“每连接每排序”分配的,全局调大很容易弄巧成拙。保持默认 256KB,让它按需增长即可。真正的防御是控制并发,不要让几百个连接同时做大排序。information_schema_stats_expiry=0:让 MySQL 在查询information_schema时实时统计,避免优化器长期使用旧统计信息。这个参数在生产上值得开,代价是元数据查询稍慢,但换来的统计准确度值得。innodb_stats_persistent_sample_pages从 20 调到 40。这是优化器统计采样的页数,采样越多,统计越准确,但ANALYZE TABLE耗时也会上升,需要根据表大小权衡。
参数层面的调整不能让“僵尸 SQL”起死回生,但可以在下一条失控 SQL 出现时把它限制在可控范围内。真正治本的是 SQL 改写和索引设计。
4.4 容器资源层的保护
容器侧的防护,我建议从三个方面做:
- 内存限额要留冗余。MySQL 容器的
memory限制如果设得跟innodb_buffer_pool_size + 常驻内存差不多,就是给自己挖坑。一般建议 buffer pool 占容器内存的 50%-60%,剩下 40% 留给连接线程、排序、临时表、page cache。这次 8G 容器里 buffer pool 配了 4G,平时够用,但查询出现峰值时立刻见底。 - 禁止或限制 swap。容器环境里建议把 swap 关掉,或者把
swappiness调低到 10。否则内存不足时系统会疯狂换页,MySQL 在 swap 上性能惨不忍睹,而且 SSD 的磨损也不划算。 - 重启策略改为
always之外,还要加一个健康检查。Docker 的--restart只能保证进程重启,但 MySQL 服务可能处于“进程活着但 SQL 跑不动”的状态,健康检查才能屏蔽流量。如果用 K8s,这就是 liveness/readiness probe 的作用。
麒麟 V10 上还有一个细节:如果系统开启了 SELinux 且策略较严,容器内的进程可能无法正常分配共享内存,表现为 mysqld 启动时报错或者内存分配异常。排查时先 getenforce 看一眼,如果是 enforcing,且业务允许,可以临时降级为 permissive 验证问题。这不是说让你关安全,而是提供一个排查方向。
4.5 把这次排查命令整理成“战斗脚本”
经历过这次之后,我养成了一个习惯:把 OOM 排查的命令整理成脚本,故障时直接跑,避免慌乱中漏掉关键信息。大致分三类:
- 宿主机层:
free -h、dmesg -T | grep -i oom、cat /sys/fs/cgroup/*/memory.events - 容器层:
docker stats --no-stream、docker inspect --format '{{.State.OOMKilled}}' <id>、docker inspect --format '{{.HostConfig.Memory}}' <id> - MySQL 层:
performance_schema.events_statements_history_long、information_schema.processlist、SHOW ENGINE INNODB STATUS\G
值得一提的是,MySQL 8.0 的 SHOW ENGINE INNODB STATUS 在 OOM 之后往往已经看不到有用的信息了,因为实例重启后内存中的状态都被清空。这也是为什么靠历史语句表和慢查询日志来定位问题那么重要——它们才是“事故黑匣子”。
5. 复盘:容器 OOM 的几种死法,以及怎么快速区分
这次事故之后,我把生产环境常见的容器 OOM 做了个分类,每种情况对应的日志特征和排查入口完全不同,搞清楚这些能帮你省掉大量试错时间。
| 类型 | 触发条件 | 典型日志/特征 | 快速定位命令 |
|---|---|---|---|
| 宿主机内存耗尽 | 宿主机物理内存不足,内核 OOM Killer 启动 | dmesg 有 Out of memory: Killed process,容器退出码 137 |
dmesg -T | grep -i oom |
| cgroup 内存超限 | 容器使用超过 memory.max,被内核按 cgroup 限制杀掉 | docker inspect 的 OOMKilled=true,memory.events 中 oom_kill 计数增加,宿主 dmesg 不一定有记录 | cat /sys/fs/cgroup/.../memory.events |
| mysqld 内存管理失控 | buffer pool 配置过大,或性能_schema 等模块内存膨胀 | 实例缓慢,响应时间持续升高,进程 RSS 持续增长,达到限制后被杀 | performance_schema.memory_summary_by_event_name |
| 单条大查询瞬时冲击 | 排序、临时表、hash join 内存叠加 | 慢查询日志出现大 SQL,容器在查询执行期间被杀 | events_statements_history_long + 慢查询日志 |
在这几种死法里,单条大查询瞬时冲击是最隐蔽的,因为它在故障前没有明显的内存增长趋势,属于“瞬间跳到峰值”,普通的 docker stats 采集粒度(默认 1-2 秒)都不一定能抓到尖峰。这也是为什么我一直强调:监控不能只看容器整体内存,还要关注数据库内部的语句级别指标,比如 information_schema.processlist 里是否有长时间运行的查询。
对于这次遇到的“索引倾斜 + 极值条件 + 容器限制”组合拳,我的体会是:容器化让资源隔离变好了,但也把问题从“数据库变慢”升级成了“服务中断”。裸机 MySQL 遇到大查询最多是 CPU 打满,慢查询拖累别的业务;容器 MySQL 遇到大查询,内存一超直接整个服务被干掉,连恢复都要看崩溃恢复的脸色。
写在最后的几点经验
这次排障最费时间的环节其实不是定位 SQL,而是说服业务开发承认“那行配置有问题”。在他们看来,配置表加一行数据是天经地义的事,怎么想也想不到这会搞挂数据库。但从数据库视角看,任何一次发版改变线上数据的分布形态,都可能让优化器的历史统计信息失效,进而选出灾难性执行计划。
我现在给业务团队立了一条规矩:凡是涉及“长期有效”“永久生效”“无限制”这类语义的字段,绝对不允许用 9999-12-31、2147483647 这种极值来表示。业务上的“无限”,落到数据库里应当是一个普通值加一个状态位,让优化器可以用常规索引去处理。数据库不排斥未来,它只排斥没统计过的“奇异点”。
另外提醒一句:如果你也跑 MySQL 容器,建议现在就检查一下三件事。第一,慢查询日志是否打开;第二,performance_schema 的语句历史是否开启;第三,容器内存限额是否留了足够的冗余。这三件事不用 10 分钟就能确认,但它们决定了下一次 OOM 来的时候,你是花两小时定位问题,还是花两天恢复数据。