门店分组树枚举统计接口 JVM 内存分配偏高的优化
门店分组树需要展示每个分组字段下,各枚举值对应的门店数量。
例如前端选择了门店状态、经营类型和门店版本,接口需要返回:
符合当前查询条件的门店总数
每个分组字段包含哪些枚举值
每个枚举值对应多少家门店这个场景真正需要的数据很少:门店 ID 和本次参与统计的分组字段。
但原实现复用了门店列表完整查询,导致 SQL 返回大量与统计无关的日期、金额、图片、人员和扩展字段。两段 JVM 内存分配火焰图都显示,这条查询链路产生了明显的日期转换警告、字符串解码和 MyBatis 映射开销。
这篇文章只记录该业务场景与 JVM 高分配之间的关系,以及对应的代码优化。
1. 业务调用链
接口入口是:
GroupTreeController#getFieldEnum原调用链如下:
GroupTreeController.getFieldEnum
-> GroupTreeFacade.getFieldEnum
-> GroupTreeFacade.queryStoreInfo
-> StoreProfileInfoServiceImpl.getStoreList
-> KdbStoreProfileInfoMapper.getStoreListgetFieldEnum 主要完成三件事:
- 根据门店查询条件获取统计范围。
- 查询分组字段对应的枚举配置。
- 统计每个枚举值对应的门店数量。
问题出在第一步。
queryStoreInfo 调用了门店列表的 getStoreList。这个查询面向完整门店列表,会映射为 StoreProfileInfoPageVo,返回的字段包含:
- 门店基础资料。
- 人员和加盟商信息。
- 门店扩展信息。
- 闭店和重启信息。
- 工程、租赁等日期字段。
- 金额、图片和备注字段。
而分组枚举统计最终只读取 ColumnTemplateFieldEnum 中配置的少量分组字段。
也就是说,绝大部分查询结果进入 JVM 后没有被使用。
2. 火焰图中的主要表现
分析使用的是 JVM TLAB 内存分配火焰图:
profile_mem.alloc_tlab_bytes它反映的是对象分配速率,而不是对象是否长期存活。因此这里的问题主要是大量短生命周期对象造成的 GC 压力,不等同于传统意义上的堆内存泄漏。
两个时间段的热点都集中在同一条查询结果映射链路。
2.1 日期转换产生 SQLWarning
主要调用链是:
DefaultResultSetHandler.getRowValue
-> BaseTypeHandler.getResult
-> MysqlTextValueDecoder.decodeDatetime
-> LocalDateValueFactory.localCreateFromDatetime
-> ResultSetImpl.warningEncountered
-> SQLWarning.<init>
-> Throwable.fillInStackTrace门店完整列表查询包含多个日期字段。MySQL 驱动在把日期时间转换为 Java 日期类型时产生 SQLWarning,每个 Warning 都会创建完整异常栈。
这些日期字段并不参与分组统计,却因为复用了宽查询,在每一行结果中都被读取和转换。
2.2 字符串解码
另一条明显热点是:
ResultSetImpl.getString
-> StringUtils.toString
-> String.<init>(byte[], ..., Charset)门店完整列表包含大量字符串字段。JDBC 需要把 MySQL 返回的字节数据转换为 Java String。
字符串分配量与下面两个因素直接相关:
返回门店数 x 返回字符串字段数即使业务最终不读取这些字段,解码成本仍然已经发生。
2.3 MyBatis 自动映射
完整查询结果使用 StoreProfileInfoPageVo 接收。MyBatis 会对返回的每一列执行类型转换和属性赋值。
火焰图中对应的热点包括:
MetaObject.setValue
GeneratedMethodAccessor.invoke
DefaultResultSetHandler.applyAutomaticMappings结果集越宽、门店数量越多,自动映射产生的对象和反射调用就越多。
3. 问题根因
这个问题可以概括为:
分组枚举统计只需要少量字段
↓
queryStoreInfo 复用了完整门店列表查询
↓
数据库返回门店档案宽结果集
↓
JDBC 转换大量无关日期和字符串
↓
MyBatis 映射完整 StoreProfileInfoPageVo
↓
业务只读取其中几个分组字段根因不是 getFieldEnum 的返回对象太大,也不是枚举配置数量太多,而是统计场景和门店详情场景共用了同一个查询模型。
门店列表接口需要完整数据,getStoreList 本身不能直接改窄,否则会影响其他调用方。
因此优化要限定在 getFieldEnum 这条链路内。
4. 优化一:新增分组统计专用轻量查询
新增专用于分组统计的查询:
StoreProfileInfoService#getStoreGroupFieldList它只返回:
门店 ID
本次请求涉及的分组字段例如本次只统计经营类型和门店状态,查询列近似为:
SELECT
kspi.ID AS id,
kspi.TRADE_TYPE AS tradeType,
kspi.STORE_STATE AS storeState
FROM kdb_store_profile_info kspi
LEFT JOIN ...
WHERE ...
GROUP BY kspi.ID;原查询中的门店筛选条件、数据权限、关联表和 GROUP BY kspi.ID 保持不变,只缩小 SELECT 返回列。
这样既能保持统计口径,又能直接减少:
- 无关日期字段的 JDBC 转换。
- 无关字符串字段的字符集解码。
- 无关属性的 MyBatis 映射。
- 数据库到应用的结果集传输体积。
- JVM 中短生命周期对象的数量。
GroupTreeFacade#queryStoreInfo 的调用也从:
storeProfileInfoService.getStoreList(storeProfileInfoBo);调整为:
storeProfileInfoService.getStoreGroupFieldList(
storeProfileInfoBo,
groupFieldEnums
);原有门店列表查询不变,其他调用方不受影响。
5. 优化二:动态分组字段使用后端白名单
getFieldEnum 支持多个动态分组字段,因此 SQL 的 SELECT 列需要根据请求动态生成。
列名无法使用 #{} 绑定,只能通过 ${} 拼接。如果直接使用前端字段,会产生 SQL 注入风险。
项目中已有 ColumnTemplateFieldEnum,用于维护分组字段、数据库列和 VO 属性之间的映射:
TRADE_TYPE("kspi.trade_type", "tradeType"),
STORE_STATE("kspi.store_state", "storeState"),
STORE_VERSION("kspi.store_version", "storeVersion");Facade 先把请求中的分组字段转换为枚举白名单:
List<ColumnTemplateFieldEnum> groupFieldEnums = groupFieldList.stream()
.map(groupField -> Arrays.stream(ColumnTemplateFieldEnum.values())
.filter(field -> Objects.equals(field.getColumnField(), groupField))
.findFirst()
.orElse(null))
.filter(Objects::nonNull)
.distinct()
.toList();Mapper XML 只读取后端枚举中的固定值:
SELECT kspi.id AS id
<if test="groupFieldEnums != null and groupFieldEnums.size() > 0">
,
<foreach collection="groupFieldEnums" item="field" separator=",">
${field.columnField} AS ${field.fieldName}
</foreach>
</if>
FROM ...这里虽然使用 ${},但拼接内容来自后端枚举,不是前端原始参数。
这既满足动态字段查询,也把 SQL 列名限制在明确的业务白名单中。
6. 优化三:门店数量改为一次预聚合
查询结果缩小以后,Java 侧仍然需要统计每个分组字段下的枚举数量。
如果对每个枚举值都重新扫描全部门店,复杂度接近:
门店数 x 分组字段数 x 枚举值数当前实现改为一次预聚合:
Map<String, Map<String, Long>> storeCountMap = new HashMap<>();
for (StoreProfileInfoPageVo store : storeList) {
for (Map.Entry<String, String> entry : groupFieldNameMap.entrySet()) {
String fieldValue = getFieldValue(store, entry.getValue());
if (StrUtil.isBlank(fieldValue)) {
continue;
}
storeCountMap.computeIfAbsent(entry.getKey(), key -> new HashMap<>())
.merge(fieldValue, 1L, Long::sum);
}
}最终得到:
groupField -> enumCode -> storeCount组装返回值时直接从 Map 取数量,不再针对每个枚举值重复遍历门店列表。
同时缓存 StoreProfileInfoPageVo 的字段反射元数据:
private static final Map<String, Field> STORE_FIELD_MAP = buildStoreFieldMap();避免统计循环中重复调用:
StoreProfileInfoPageVo.class.getDeclaredField(fieldName)这部分不是火焰图中的主要根因,但属于同一条统计循环,可以进一步减少反射查找成本。
7. 为什么没有通过 JDBC 配置处理
火焰图中存在 SQLWarning,可以想到关闭 Warning 捕获或修改 JDBC 参数。
但这不适合作为当前问题的主要修复。
原因是:
- Warning 可能暴露真实的日期类型或精度问题。
- JDBC 配置影响整个数据源,不应为了一个统计接口扩大影响范围。
- 即使关闭 Warning,字符串解码和 MyBatis 宽结果映射仍然存在。
当前接口不需要这些日期字段,最直接的处理方式就是不查询它们。
门店完整列表中的日期类型问题可以单独排查,但不应阻塞 getFieldEnum 的轻量查询改造。
8. 优化前后的调用链
优化前:
getFieldEnum
-> getStoreList
-> 返回完整门店档案字段
-> JDBC 转换全部日期和字符串
-> MyBatis 映射完整 VO
-> 内存统计优化后:
getFieldEnum
-> 分组字段转换为 ColumnTemplateFieldEnum 白名单
-> getStoreGroupFieldList
-> 只返回 ID 和分组字段
-> 一次遍历预聚合
-> 组装枚举数量核心变化不是让 JVM 更快地回收对象,而是减少根本不应该进入 JVM 的数据。
9. 验证重点
改造后需要从业务结果和 JVM 指标两方面验证。
9.1 业务结果
使用相同查询条件对比:
totalStore是否一致。- 分组字段和枚举项是否一致。
- 每个枚举项的门店数量是否一致。
- 门店查询条件是否仍然生效。
- 数据权限范围是否一致。
- 自定义筛选是否一致。
9.2 JVM 分配
重新采集:
profile_mem.alloc_tlab_bytes重点观察:
GroupTreeFacade.queryStoreInfo的总分配占比是否下降。- 日期转换链路中的
Throwable.fillInStackTrace是否明显下降。 String.<init>(byte[], ..., Charset)是否下降。- MyBatis 结果映射和反射调用是否下降。
9.3 接口指标
在相近查询条件和流量下对比:
- 接口平均响应时间。
- P95、P99 响应时间。
- Young GC 次数和耗时。
- SQL 返回列数及结果集大小。
10. 总结
门店分组树枚举统计接口只需要门店 ID 和动态分组字段,但原实现复用了门店完整列表查询。
无关字段进入 JVM 后,依次触发:
日期转换 Warning
字符串解码
MyBatis 自动映射
完整 VO 对象创建对应的优化集中在三点:
- 新增
getStoreGroupFieldList,只查询分组统计需要的字段。 - 动态列名必须通过
ColumnTemplateFieldEnum白名单。 - 门店数量使用一次预聚合,并缓存反射字段元数据。
这个场景的关键并不是调整 JVM 参数,也不是关闭 JDBC Warning,而是让分组统计接口使用符合自身数据需求的查询模型。