最近排查了一起生产环境的严重性能问题:系统突然卡顿、内存占满、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 万,String、HashMap$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.lambda2(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 压力有多大?只有把这些细节想清楚,才能避免 “一行代码拖垮整个系统” 的悲剧。