门店分组树枚举统计接口 JVM 内存分配偏高的优化

门店分组树需要展示每个分组字段下,各枚举值对应的门店数量。

例如前端选择了门店状态、经营类型和门店版本,接口需要返回:

符合当前查询条件的门店总数
每个分组字段包含哪些枚举值
每个枚举值对应多少家门店

这个场景真正需要的数据很少:门店 ID 和本次参与统计的分组字段。

但原实现复用了门店列表完整查询,导致 SQL 返回大量与统计无关的日期、金额、图片、人员和扩展字段。两段 JVM 内存分配火焰图都显示,这条查询链路产生了明显的日期转换警告、字符串解码和 MyBatis 映射开销。

这篇文章只记录该业务场景与 JVM 高分配之间的关系,以及对应的代码优化。

1. 业务调用链

接口入口是:

GroupTreeController#getFieldEnum

原调用链如下:

GroupTreeController.getFieldEnum
  -> GroupTreeFacade.getFieldEnum
  -> GroupTreeFacade.queryStoreInfo
  -> StoreProfileInfoServiceImpl.getStoreList
  -> KdbStoreProfileInfoMapper.getStoreList

getFieldEnum 主要完成三件事:

  1. 根据门店查询条件获取统计范围。
  2. 查询分组字段对应的枚举配置。
  3. 统计每个枚举值对应的门店数量。

问题出在第一步。

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 参数。

但这不适合作为当前问题的主要修复。

原因是:

  1. Warning 可能暴露真实的日期类型或精度问题。
  2. JDBC 配置影响整个数据源,不应为了一个统计接口扩大影响范围。
  3. 即使关闭 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 对象创建

对应的优化集中在三点:

  1. 新增 getStoreGroupFieldList,只查询分组统计需要的字段。
  2. 动态列名必须通过 ColumnTemplateFieldEnum 白名单。
  3. 门店数量使用一次预聚合,并缓存反射字段元数据。

这个场景的关键并不是调整 JVM 参数,也不是关闭 JDBC Warning,而是让分组统计接口使用符合自身数据需求的查询模型。