引言

在开发者的潜意识里,LIMIT 关键字就是 SQL 查询的安全带。 我们通常认为,只要加了 LIMIT,无论表多大,数据库都只会吐出少量数据,内存就是安全的。

然而,最近的一次线上 OOM(内存溢出)事故狠狠地打破了这个认知。 一个看似人畜无害的 UNION ALL + LIMIT 组合,竟然避开了doris 的优化器的下推机制,引发了生产环境的连锁崩溃。

本文记录了这次在受限环境下,如何抽丝剥茧定位 Bug 的过程。

一、高峰期的“幽灵”崩溃

环境背景:

  • 部署方式 Kubernetes
  • 网关: Springboot + zuul
  • 服务: Spring Boot + MyBatis
  • db: Apache Doris
  • 报错服务: 网关
  • java版本: jdk8, g1
  • 权限: 无root权限,只能查看基本日志

网关pod报错关键日志

netflix.zuul.exception.ZuulException: Filter threw Exception
postModifyResponseBodyFilter.run
...
Caused by: java.lang.OutOfMemoryError: Java heap space

报错日志关键字: oom

二、 真相误判:GC 的烟雾弹

面对 OOM,作为 Java 开发者的第一直觉往往是:“是不是流量太大,垃圾回收(GC)跟不上了?” 查看存活期间的 JVM 监控,发现 Old Gen(老年代)增长迅速。当时的判断是:CMS 回收器产生内存碎片,导致大对象分配失败。

  • 行动: 将 JVM 参数从 CMS 升级为 G1
  • 结果: 情况确实“有所改善”,Pod 存活时间变长了,报警频率降低了。
  • 反思(陷阱): 这其实是 G1 带来的假象。G1 对堆内存的管理确实更优秀,它延缓了崩溃,但并没有解决根本问题。
  • 真相: 这不是“垃圾回收”的问题,而是“垃圾产生”的问题。Doris 返回了海量数据(Live Objects),Java 正在处理这些数据,它们* 在 JVM 眼里是**“必须存活的对象”**。无论 GC 多么努力,它都无权回收正在被使用的业务数据。
  • 比喻: 浴缸的水龙头(数据源)全开,排水口(GC)再大,只要进水速度 > 排水速度,水漫金山是迟早的事。

三、 陷入僵局:gc优化后oom再次出现

GC 调优失败后,问题回到了原点。此时不仅面临技术难题,更面临流程壁垒:

  1. 无法 Debug: 线上不能挂断点。
  2. 无法使用arthas: 有限的权限

关键线索(已脱敏)

oom出现时, 总是伴随着如下类似的SQL语句:

SELECT col1, col2 FROM table_A WHERE ...
UNION ALL
SELECT col1, col2 FROM table_B WHERE ...
LIMIT 100;

推理验证

如下sql, 应该返回7条数据才对

SELECT col1, col2 FROM table_A WHERE col1=‘单独执行,返回5条数据的条件’ 
UNION ALL
SELECT col1, col2 FROM table_B WHERE col1=‘单独执行,返回6条数据的条件’ 
LIMIT 7;

但是在当时的doris版本返回了11条数据,而不是7条数据。 limit竟然没有对整个SQL进行limit

四、 原因分析:Doris 的解析 Bug

这是一个典型的 “水坝效应”

  1. Doris (源头): 由于 LIMIT 解析 Bug,返回了 11 条(甚至更多, t1 的全量扫描数据)非预期的全量扫描数据,数据量极大。
  2. App Service (管道): 业务服务采用流式输出,数据穿肠而过,内存占用尚在可控范围。
  3. Zuul Gateway (大坝): 网关开启了 postModifyResponseBodyFilter(响应体修改过滤器)。为了修改响应内容,Zuul 被迫将 App 传来的所有数据流截断并完整加载到内存中。
  4. 当 Doris 误传回的海量数据(也许几百 MB)遇到网关的高并发(几十个请求并发),28GB 的堆内存瞬间被数十个巨型 Response Body 填满。网关本想做个中间人,却因为试图缓存“洪水”而被冲垮。

强制让doris-sql最终的limit生效

select * from(
  SELECT col1, col2 FROM table_A WHERE col1=‘单独执行,返回5条数据的条件’ 
  UNION ALL
  SELECT col1, col2 FROM table_B WHERE col1=‘单独执行,返回6条数据的条件’ 
)t1
LIMIT 7;

原因分析-网关

为什么挂的是网关?

这是一个典型的**“水坝效应”**。

  1. Doris (源头): 由于 LIMIT 解析 Bug,返回了 11 条(甚至更多)非预期的全量扫描数据,数据量极大。
  2. App Service (管道): 业务服务采用流式输出,数据穿肠而过,内存占用尚在可控范围。
  3. Zuul Gateway (大坝): 网关开启了 postModifyResponseBodyFilter(响应体修改过滤器)。为了修改响应内容,Zuul 被迫将 App 传来的所有数据流截断并完整加载到内存中
  4. 当 Doris 误传回的海量数据(也许几百 MB)遇到网关的高并发(几十个请求并发),28GB 的堆内存瞬间被数十个巨型 Response Body 填满。网关本想做个中间人,却因为试图缓存“洪水”而被冲垮。

完整的故障链路:

  1. Doris 误判,对 Table A 进行全表扫描,吐出数百万行数据。
  2. App 服务无罪透传。
  3. Zuul 网关 为了做 JSON 包装,被迫将这几百 MB 数据加载到 RAM。

五、 总结与启示

架构层面的启示:

网关的脆弱性: 网关作为所有流量的入口,最忌讳处理“大报文”。在网关层做 ResponseBody 的修改(如统一包装、日志记录)是非常危险的操作,它会把“流”变成“块”,引入巨大的内存风险。

全链路防御: 仅仅在 App 层做防护是不够的。如果数据库失控,必须确保这种压力不会沿着链路传导并击穿最脆弱的环节(网关)。

这次排查最宝贵的经验不是修复了 SQL,而是在极端受限环境下的排查思路:

深度思考

  • 警惕“代码没变”的幻觉: 代码没变,但数据环境变了(数据量增长),依赖组件变了(Doris 版本差异),这就是最大的变量。
  • 网关是脆弱的水坝: 在微服务架构中,网关是所有流量的汇聚点。在网关层做“修改 Body”这种重内存操作(Buffer)是极高风险的架构反模式。
  • 防御性编程: 不要信任数据库优化器。在分布式系统中,显式地把 LIMIT 写在每一个子查询里,是保护系统的最后一道防线。