SQL没有limit 1读取一条findFirst竟让 800 万数据涌入内存导致系统崩溃

22 阅读4分钟

 最近排查了一起生产环境的严重性能问题:系统突然卡顿、内存占满、GC 疯狂触发,最终定位到一行看似简单的查询代码 —— 本意只查一条数据,却因框架特性和代码疏忽,导致 800 万条数据被全量加载到内存,直接击穿了 JVM 内存防线。这起事故暴露的不仅是代码规范问题,更是对框架底层逻辑认知的缺失,值得所有后端开发者警醒。   

​编辑

1、jmap排查

当时jmap信息如下:

num #instances #bytes class name
----------------------------------------------
1: 37113601 1187635232 java.util.HashMap$Node
2: 15040706 1182342352 [C
3: 15019160 360459840 java.lang.String
4: 2480527 357034288 [Ljava.util.HashMap$Node;
5: 2470008 197600640 com.conca.organizationstructure.entity.Department
6: 2553532 122569536 java.util.HashMap
7: 2470276 79048832 java.sql.Timestamp
8: 61715 73731600 [B
9: 2499332 39989312 java.lang.Integer

  • 老年代内存使用率瞬间拉满,Full GC 每秒触发多次,GC 耗时从毫秒级飙升至秒级;
  • 堆内存快照(Heap Dump)分析显示:Department实体类实例数超 247 万,StringHashMap$Node等对象数亿级,直接占满 12GB 堆内存。

当时断定内存占满,肯定有一个线程从mysql数据读取了247万Department表,排查到这个表就行了。

2、jstack排查

当时jstack打印完毕之后,查看从mysql读取数据的进程只找到一个进程,信息如下:

线程名称:XNIO-1 task-3" - 线程ID:92
Thread CPU time:
-Thread Time(CPU) :56901ms -占用率(cpu):1.54% -Thread Time(CPU) /sec :165ms -占用率(cpu)/sec :1.92%
Per thread allocations:
-Allocated Bytes:21121864208 B -占用率:7.87% -Allocated Bytes / sec:67740544 B -占用率 / sec:95.14%
Stack:
java.lang.Thread.State: RUNNABLE
at java.io.FilterInputStream.read(FilterInputStream.java:133)
at com.mysql.cj.protocol.FullReadInputStream.readFully(FullReadInputStream.java:64)
at com.mysql.cj.protocol.a.SimplePacketReader.readHeader(SimplePacketReader.java:63)
at com.mysql.cj.protocol.a.SimplePacketReader.readHeader(SimplePacketReader.java:45)
at com.mysql.cj.protocol.a.TimeTrackingPacketReader.readHeader(TimeTrackingPacketReader.java:52)
at com.mysql.cj.protocol.a.TimeTrackingPacketReader.readHeader(TimeTrackingPacketReader.java:41)
at com.mysql.cj.protocol.a.MultiPacketReader.readHeader(MultiPacketReader.java:54)
at com.mysql.cj.protocol.a.MultiPacketReader.readHeader(MultiPacketReader.java:44)
at com.mysql.cj.protocol.a.ResultsetRowReader.read(ResultsetRowReader.java:71)
at com.mysql.cj.protocol.a.ResultsetRowReader.read(ResultsetRowReader.java:42)
at com.mysql.cj.protocol.a.NativeProtocol.read(NativeProtocol.java:1576)
at com.mysql.cj.protocol.a.result.ResultsetRowsCursor.fetchMoreRows(ResultsetRowsCursor.java:239)

  • locked <184ba352> (a com.mysql.cj.jdbc.ConnectionImpl)
    at com.mysql.cj.protocol.a.result.ResultsetRowsCursor.hasNext(ResultsetRowsCursor.java:156)
    at com.mysql.cj.protocol.a.result.ResultsetRowsCursor.next(ResultsetRowsCursor.java:176)
    at com.mysql.cj.protocol.a.result.ResultsetRowsCursor.next(ResultsetRowsCursor.java:51)
    at com.mysql.cj.jdbc.result.ResultSetImpl.next(ResultSetImpl.java:1724)
  • locked <184ba352> (a com.mysql.cj.jdbc.ConnectionImpl)
    at com.alibaba.druid.filter.FilterChainImpl.resultSet_next(FilterChainImpl.java:917)
    at com.alibaba.druid.filter.FilterAdapter.resultSet_next(FilterAdapter.java:1918)
    at com.alibaba.druid.filter.FilterChainImpl.resultSet_next(FilterChainImpl.java:913)
    at com.alibaba.druid.proxy.jdbc.ResultSetProxyImpl.next(ResultSetProxyImpl.java:881)
    at com.alibaba.druid.pool.DruidPooledResultSet.next(DruidPooledResultSet.java:68)
    at com.jfinal.plugin.activerecord.ModelBuilder.build(ModelBuilder.java:53)
    at com.jfinal.plugin.activerecord.ModelBuilder.build(ModelBuilder.java:42)
    at com.jfinal.plugin.activerecord.dialect.Dialect.buildModelList(Dialect.java:135)
    at com.jfinal.plugin.activerecord.Model.find(Model.java:732)
    at io.jboot.db.model.JbootModel.lambdafindfind2(JbootModel.java:1020)
    at io.jboot.db.model.JbootModel$$Lambda$327/1514767876.run(Unknown Source)
    at io.jboot.db.SqlDebugger.run(SqlDebugger.java:47)
    at io.jboot.db.model.JbootModel.find(JbootModel.java:1018)
    at com.jfinal.plugin.activerecord.Model.find(Model.java:742)
    at com.jfinal.plugin.activerecord.Model.find(Model.java:757)
    at com.jfinal.plugin.activerecord.Model.findFirst(Model.java:780)
    at com.conca.organizationstructure.service.dao.DepartmentDao.findMaxLevelDepartment(DepartmentDao.java:539)

当时看到这个进程,第一感觉不是这里问题,这里调用的是findFirst方法,只读取第一条,按道理不会读取大量的记录才对啊。

又找了半天,对比了几个jstack文件,发现都是落在这个代码块上。查看一下代码吧,看看到底怎么回事。

3、业务代码

/**
     * 获取最大部门级别
     * @return
     */
    public Department findMaxLevelDepartment(String switchboardId, Integer max){
        //设置查询条件
        List<Object> paras = new ArrayList<Object>();

        StringBuffer sql = new StringBuffer();
        sql.append(" select * from department where swit_id = ? and `status` = TRUE and length(level)=?");
        paras.add(switchboardId);
        paras.add(max);

        return (Department)getDao().findFirst(sql.toString(), paras.toArray());
    }

select * from department where swit_id = ? and status = TRUE and length(level)=? SQL从业务角度可以命中800万条记录。调用了框架的getDao().findFirst()方法。

4、getDao().findFirst()代码

/**
     * Find first model. I recommend add "limit 1" in your sql.
     * @param sql an SQL statement that may contain one or more '?' IN parameter placeholders
     * @param paras the parameters of sql
     * @return Model
     */
    public M findFirst(String sql, Object... paras) {
        List<M> result = find(sql, paras);
        return result.size() > 0 ? result.get(0) : null;
    }

protected List<M> find(Config config, Connection conn, String sql, Object... paras) throws Exception {
		try (PreparedStatement pst = conn.prepareStatement(sql)) {
			config.dialect.fillStatement(pst, paras);
			ResultSet rs = pst.executeQuery();
			List<M> result = config.dialect.buildModelList(rs, _getUsefulClass());	// ModelBuilder.build(rs, getUsefulClass());
			DbKit.close(rs);
			return result;
		}
	}

@SuppressWarnings({"rawtypes", "unchecked"})
	public <T> List<T> build(ResultSet rs, Class<? extends Model> modelClass, Function<T, Boolean> func) throws SQLException, ReflectiveOperationException {
		List<T> result = new ArrayList<T>();
		ResultSetMetaData rsmd = rs.getMetaData();
		int columnCount = rsmd.getColumnCount();
		String[] labelNames = new String[columnCount + 1];
		int[] types = new int[columnCount + 1];
		buildLabelNamesAndTypes(rsmd, labelNames, types);
		while (rs.next()) {
			Model<?> ar = modelClass.newInstance();
			Map<String, Object> attrs = ar._getAttrs();
			for (int i=1; i<=columnCount; i++) {
				Object value;
				if (types[i] < Types.BLOB) {
					value = rs.getObject(i);
				} else {
					if (types[i] == Types.CLOB) {
						value = handleClob(rs.getClob(i));
					} else if (types[i] == Types.NCLOB) {
						value = handleClob(rs.getNClob(i));
					} else if (types[i] == Types.BLOB) {
						value = handleBlob(rs.getBlob(i));
					} else {
						value = rs.getObject(i);
					}
				}
				
				attrs.put(labelNames[i], value);
			}
			
			if (func == null) {
				result.add((T)ar);
			} else {
				if ( ! func.apply((T)ar) ) {
					break ;
				}
			}
		}
		return result;
	}

JBoot/ActiveRecord 的findFirst并非优化查询逻辑,而是先通过find方法全量加载结果集到内存,再截取第一条 —— 当满足条件的数据有 800 万条时,所有数据都会被解析为Department对象存入 List。

内存雪崩:800 万Department实例 + 关联的字符串、HashMap 等对象,直接耗尽堆内存,触发持续 Full GC,系统失去响应。

5、开发者想当然问题

开发者想当然认为findFirst会自动帮 SQL 加限制,实则框架仅做 “查全量→取第一条” 的逻辑;

开发者想当然认为findFirst接受数据的时候,只接受第一条,直接就返回,不再接受后面的数据。没有想到接受全部数据  List result = find(sql, paras);,然后result.get(0)。

6、紧急修复:SQL 中强制加 LIMIT 1

/**
     * 获取最大部门级别
     * @return
     */
    public Department findMaxLevelDepartment(String switchboardId, Integer max){
        //设置查询条件
        List<Object> paras = new ArrayList<Object>();

        StringBuffer sql = new StringBuffer();
        sql.append(" select * from department where swit_id = ? and `status` = TRUE and length(level)=? LIMIT 1");
        paras.add(switchboardId);
        paras.add(max);

        return (Department)getDao().findFirst(sql.toString(), paras.toArray());
    }

7、思考一个额外处理方法

 select * from department where swit_id = ? and status = TRUE and length(level)=? SQL不增加limit 1,而是java接受数据的时候处理。

@SuppressWarnings({"rawtypes", "unchecked"})
	public <T> List<T> build(ResultSet rs, Class<? extends Model> modelClass, Function<T, Boolean> func) throws SQLException, ReflectiveOperationException {
		List<T> result = new ArrayList<T>();
		ResultSetMetaData rsmd = rs.getMetaData();
		int columnCount = rsmd.getColumnCount();
		String[] labelNames = new String[columnCount + 1];
		int[] types = new int[columnCount + 1];
		buildLabelNamesAndTypes(rsmd, labelNames, types);
		while (rs.next()) {
			Model<?> ar = modelClass.newInstance();
			
			...
			if (func == null) {
				result.add((T)ar);
			} else {
				if ( ! func.apply((T)ar) ) {
					break ;
				}
			}
            接受第一条,直接返回,关闭游标
		}
		return result;
	}

上面这个方法能解决内存占用问题,但是还是会消耗mysql资源。

sql增加limit1节约mysql资源和sql处理时间。mysql处理limit比java处理优化limit性能优势明显。

8、总结:细节里的魔鬼,认知决定成败

这起事故的核心不是 “代码写错了”,而是 “对框架逻辑的认知不足 + 开发规范缺失”:
永远不要想当然认为框架会帮你做优化,必须精读核心方法的注释和源码;
数据库查询的 “LIMIT”“分页” 不是可选优化,而是必要的安全措施;
测试环境必须模拟生产级数据量,才能暴露这类内存问题。
作为后端开发者,我们写的每一行 SQL、每一次框架调用,都要清楚 “底层发生了什么”—— 数据库返回多少数据?内存中会创建多少对象?GC 压力有多大?只有把这些细节想清楚,才能避免 “一行代码拖垮整个系统” 的悲剧。