HEMS 慢接口性能优化报告
本报告仅针对附件中提供的 5 个慢接口本身进行优化分析,不涉及架构大改造或大范围重构。
一、接口耗时总览
| 接口 | 耗时 | 接口路径 | 主要入口方法 |
|---|---|---|---|
| 查询站点分页 | 5515 ms | /hems/v1/container/page |
HomeServiceImpl.searchContainerPage |
| 查询站点统计 | 5548 ms | /hems/v1/container/getStatistics |
HomeServiceImpl.getContainerStatistics |
| 查询设备实时数据 | 1255 ms | /hems/v1/device/searchDeviceRealData |
SiteDeviceMonitorServiceImpl.getDeviceRealData |
| 查询时区 | 1064 ms | /hems/v1/container/getTimeZone |
DateUtils.getContainerTimeZone |
| 查询设备监测树 | 未记录 | /hems/v1/actionType/tree |
ActionTypeServiceImpl.getActionTypeTree |
二、共性瓶颈定位
这 5 个接口的慢,核心原因集中在 3 个点:
- 循环/遍历中调用 Feign/外部服务 ------ 单次请求本来不慢,但 155 个容器或 N 个设备串行调用就慢了。
- 全量拉取数据后内存处理 ------ 分页接口实际先把所有数据拉回来,在 Java 内存里做分页、过滤、统计。
- 时区/地址等静态信息没有有效缓存 ------ 每个容器/每次请求都重新查一次。
下面逐个接口拆解。
三、逐个接口分析与优化方案
3.1 /hems/v1/container/page --- 站点分页(5515ms)
当前实现路径
ContainerController.searchContainerPage → HomeServiceImpl.searchContainerPage → searchEngineerHomePage/searchUserHomePage
耗时瓶颈
-
全量查询后内存分页
- 虽然入参
pageSize=20,但代码中传给dmContainerService.searchUserKeyContainerList(search)的pageSize就是homeSearch.getPageSize()(即 20)。 - 但后续
buildHomeInfoList会对返回的容器列表逐个 调用buildHomeInfo,涉及大量 Feign 调用,整体处理时间与返回条数成正比。 - 如果工程师角色没有容器权限过滤,会一次性查询大量容器。
- 虽然入参
-
buildHomeInfo中循环调用外部服务- 每个容器调用
DateUtils.formatByContainer(container.getCreateDate(), ...) - 该方法内部调用
DateUtils.getContainerTimeZone(homeId)→bizContainerService.getOne(containerId),每个容器一次 Feign 调用。 - 20 个容器就 20 次 Feign 调用,这是分页慢的主要原因之一。
- 每个容器调用
-
fillAddressInfo中每个容器多次调用 geo 服务geoCountryService.getCitiesPO()geoCountryService.getCountryName()geoCountryService.getStateName()geoCountryService.getCityName()- 每个容器 4 次 Feign/服务调用。
-
buildHomeInfoList一次性查询所有通信棒设备bizDeviceService.listForTypes(null, singletonList(COMMUNICATION_ROD))查询全量通信棒设备,再按容器分组。- 如果通信棒设备很多,这一步也会慢。
优化方案(只改接口本身)
方案 A:给 formatByContainer 添加本地缓存,避免每个容器重复查时区
DateUtils.getContainerTimeZone 已经有 ThreadLocal 缓存,但只缓存当前线程最近一次查询。分页接口处理多个容器时,每个容器都是一次新的 Feign 调用。建议改为方法级本地缓存(Caffeine)或 Redis 缓存。
修改位置:DateUtils.getContainerTimeZone
java
// 增加 Caffeine 本地缓存(同一 JVM 内共享)
private static final Cache<String, String> CONTAINER_TIMEZONE_CACHE = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
public static String getContainerTimeZone(String containerId) {
if (StrUtil.isBlank(containerId)) {
return getDefaultTimeZoneId();
}
String cached = CONTAINER_TIMEZONE_CACHE.getIfPresent(containerId);
if (cached != null) {
return cached;
}
// ... 原有逻辑
String timeZoneId = resolveContainerTimeZone(containerId, bizContainerService, citiesMapper);
CONTAINER_TIMEZONE_CACHE.put(containerId, timeZoneId);
return timeZoneId;
}
时区是极稳定的静态数据,缓存 1 小时甚至更长都没问题。此改动收益最大,预计单接口可提速 30%~50%。
方案 B:fillAddressInfo 中批量缓存 geo 查询结果
目前 fillAddressInfo 每个容器分别查国家、省、市。可以在 buildHomeInfoList 开始前,先收集所有容器涉及的 cityCode,一次性批量查询 city/state/country 名称,再在内存中映射。
修改位置:HomeServiceImpl.buildHomeInfoList 和 fillAddressInfo
java
// buildHomeInfoList 中新增
Set<Integer> cityCodes = containerList.stream()
.map(this::extractCityCode)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Integer, String> cityNameMap = batchGetCityNames(cityCodes, lang);
Map<Integer, String> stateNameMap = batchGetStateNames(cityCodes, lang);
Map<Integer, String> countryNameMap = batchGetCountryNames(cityCodes, lang);
// fillAddressInfo 改为从 map 中取,不再逐个调用 Feign
如果 geo 服务本身支持批量查询最佳;如果不支持,可先加本地 Caffeine 缓存,key 为
cityCode:lang。
方案 C:通信棒设备查询加缓存或按需查询
buildHomeInfoList 当前查询所有通信棒设备。可以改为:
- 先查出返回的容器 ID 列表;
- 按
containerId IN (...)批量查询这些容器下的通信棒设备; - 或者对通信棒设备按容器维度做 Redis 缓存。
修改位置:HomeServiceImpl.buildHomeInfoList
java
// 改为根据返回的容器ID批量查询通信棒设备
List<String> containerIds = containerList.stream()
.map(DmContainerDTO::getContainerId)
.collect(Collectors.toList());
Map<String, List<DmDeviceDTO>> communicationRodMap = bizDeviceService
.listForTypesByContainerIds(containerIds, Collections.singletonList(DeviceTypeEnum.COMMUNICATION_ROD.getCode()));
需要
bizDeviceService或底层 Feign 新增按容器批量查询的能力。如果无法改底层,可先在HomeServiceImpl层对结果做本地缓存。
方案 D:分页接口返回字段瘦身
如果前端列表页不需要 addr、location、createDate 等字段,可以在 HomeSearch 中增加一个 simple 标志,分页列表只返回必要字段,详情接口再查完整信息。
3.2 /hems/v1/container/getStatistics --- 站点统计(5548ms)
当前实现路径
ContainerController.getStatistics → HomeServiceImpl.getContainerStatistics
耗时瓶颈
java
public List<HomeStatusStatistics> getContainerStatistics(String authType) {
HomeSearch homeSearch = new HomeSearch();
homeSearch.setPageNum(1);
homeSearch.setPageSize(10000);
List<HomeInfoDTO> homeList = searchContainerPage(homeSearch, authType).getList();
// ... 统计
}
这个方法直接复用了 searchContainerPage ,但统计只需要每个容器的状态,却走了完整构建 HomeInfoDTO 的流程(时区、地址、通信棒、联系人等)。这是统计接口比分页还慢的核心原因。
优化方案(只改接口本身)
方案 A:为统计接口单独写轻量查询
统计只需要:容器 ID + 状态。不需要地址、时区、联系人、容量等信息。
新增方法:
java
public List<HomeStatusStatistics> getContainerStatistics(String authType) {
// 1. 只查容器ID和状态,不走完整 buildHomeInfo
List<DmContainerDTO> containerList = queryContainerListForStatistics(authType);
if (CollectionUtil.isEmpty(containerList)) {
return buildEmptyStatistics(authType);
}
// 2. 批量查通信棒状态(可缓存)
Map<String, Integer> containerStatusMap = computeContainerStatusBatch(containerList);
// 3. 统计
Map<Integer, Long> statusCountMap = containerStatusMap.values().stream()
.collect(Collectors.groupingBy(status -> status, Collectors.counting()));
return buildStatistics(statusCountMap, authType);
}
这是最有效的优化,预计统计接口可从 5.5s 降到 500ms 以内。
方案 B:如果必须复用 searchContainerPage,则减少 pageSize 并加缓存
如果逻辑复杂必须复用,至少:
pageSize从 10000 改为合理的最大值;- 给
searchContainerPage加短时间的本地缓存(如 10 秒),统计接口直接命中缓存。
3.3 /hems/v1/container/getTimeZone --- 查询时区(1064ms)
当前实现路径
ContainerController.getTimeZone → DateUtils.getContainerTimeZone(homeId)
耗时瓶颈
一个单值查询接口耗时 1 秒以上,原因是:
DateUtils.getContainerTimeZone调用SpringUtil.getBean(BizContainerService.class)- 然后
bizContainerService.getOne(containerId)走一次 Feign 调用 - 再解析容器扩展属性找
timezone
整个过程就为了取一个几乎不变的字符串。
优化方案(只改接口本身)
方案 A:接口级 Redis 缓存(推荐)
在 ContainerController.getTimeZone 或 DateUtils.getContainerTimeZone 增加 Redis 缓存。
java
@GetMapping("/getTimeZone")
public ResponseBean<String> getTimeZone(@RequestParam("homeId") String homeId) {
String cacheKey = "hems:container:timezone:" + homeId;
String cached = redisUtil.get(cacheKey);
if (StrUtil.isNotBlank(cached)) {
return ResponseBean.success(cached);
}
String timeZone = DateUtils.getContainerTimeZone(homeId);
redisUtil.set(cacheKey, timeZone, 3600); // 缓存1小时
return ResponseBean.success(timeZone);
}
时区变更频率极低,缓存 1 小时非常安全。预计该接口可从 1s 降到 10ms 以内。
方案 B:在容器信息变更时同步刷新缓存
如果担心缓存不一致,可以在容器扩展属性更新接口中主动删除或更新该缓存 key。
3.4 /hems/v1/device/searchDeviceRealData --- 设备实时数据(1255ms)
当前实现路径
DeviceMonitorController.searchDeviceRealData → SiteDeviceMonitorServiceImpl.getDeviceRealData → buildSingleDeviceRealData
耗时瓶颈
java
public List<DeviceRealDataResultDTO> getDeviceRealData(DeviceRealDataSearch search) {
for (DeviceRealDataItemSearch deviceItem : deviceItemList) {
resultList.add(buildSingleDeviceRealData(homeId, deviceTypeId, deviceItem));
}
}
设备是串行处理的 ,每个设备调用 buildSingleDeviceRealData,其中:
- 如果是断路器/插座,调用
bizDeviceConfigService.monitorConfigsWithMerge()查开关状态; - 调用
actionTypeService.getActionTypeTree(queryDTO)构建整棵动作类型树并填充实时值。
对于附件中 devices 只有 1 个设备的情况,主要耗时在 getActionTypeTree 内部的:
bizDeviceService.getOne()查设备fillTreeActionItemValues中的bizDeviceConfigService.monitorDevicesConfigsWithMerge()批量查测点queryAllTypeEleStatistics中查分类电量统计,涉及历史曲线查询dmMeasurePointService.searchYcPointHis()
如果有多个设备,串行会成倍增加耗时。
优化方案(只改接口本身)
方案 A:多设备并行处理(推荐)
将 for 循环改为并行流或 CompletableFuture,同一 homeId 下多个设备的配置查询可以并行。
修改位置:SiteDeviceMonitorServiceImpl.getDeviceRealData
java
public List<DeviceRealDataResultDTO> getDeviceRealData(DeviceRealDataSearch search) {
// ... 参数校验
List<DeviceRealDataItemSearch> deviceItemList = search.getDevices();
return deviceItemList.parallelStream()
.map(deviceItem -> buildSingleDeviceRealData(search.getHomeId(), search.getDeviceTypeId(), deviceItem))
.collect(Collectors.toList());
}
注意:
parallelStream使用公共 ForkJoinPool,如果下游有 Feign 调用会阻塞公共线程池。建议注入自定义线程池:
java
@Resource(name = "applicationTaskExecutor")
private Executor asyncTaskExecutor;
public List<DeviceRealDataResultDTO> getDeviceRealData(DeviceRealDataSearch search) {
List<CompletableFuture<DeviceRealDataResultDTO>> futures = deviceItemList.stream()
.map(item -> CompletableFuture.supplyAsync(
() -> buildSingleDeviceRealData(search.getHomeId(), search.getDeviceTypeId(), item),
asyncTaskExecutor))
.collect(Collectors.toList());
return futures.stream().map(CompletableFuture::join).collect(Collectors.toList());
}
方案 B:批量查询动作类型树
已有 ActionTypeServiceImpl.getBatchActionTypeTree(BatchActionTypeQueryDTO) 方法,支持一次性查询多个设备的动作类型树。getSiteDeviceMonitor 已经在用,但 getDeviceRealData 没有复用。
修改 getDeviceRealData:
java
public List<DeviceRealDataResultDTO> getDeviceRealData(DeviceRealDataSearch search) {
// 1. 批量查询动作类型树
BatchActionTypeQueryDTO batchQueryDTO = new BatchActionTypeQueryDTO();
batchQueryDTO.setContainerId(search.getHomeId());
batchQueryDTO.setActionType(DEVICE_LIST);
batchQueryDTO.setDeviceIds(deviceIdList);
batchQueryDTO.setLevel(LEVEL);
batchQueryDTO.setNeedFill(true);
Map<String, ActionTypeTreeVO> treeMap = actionTypeService.getBatchActionTypeTree(batchQueryDTO);
// 2. 批量查询开关状态(断路器/插座)
Map<String, Map<String, MonitorPointDTO>> switchStatusMap = batchQuerySwitchStatus(search);
// 3. 组装结果
return deviceItemList.stream()
.map(item -> buildResult(item, treeMap.get(item.getDeviceId()), switchStatusMap))
.collect(Collectors.toList());
}
这样动作类型配置和测点值都只查一次,不再每个设备重复查询。这是最有效的优化。
方案 C:给 queryAllTypeEleStatistics 加缓存
该方法内部查询当天起始电量需要调历史曲线接口。如果前端不需要实时统计电量(如只需要展示原始测点值),可以在 getActionTypeTree 时传入 NeedFill=false 跳过该步骤。
3.5 /hems/v1/actionType/tree --- 设备监测树(未记录耗时)
当前实现路径
ActionTypeController.getActionTypeTree → ActionTypeServiceImpl.getActionTypeTree
耗时瓶颈
和 searchDeviceRealData 共用同一套逻辑。主要慢在:
fillTreeActionItemValues→queryAllTypeEleStatistics→ 历史曲线查询fillParamSettingsValue→deviceDfService.getDeviceParam()查设备参数- 特殊设备类型(PCS/BATTERY/METER/COMMUNICATIONROD)的额外查询
优化方案(只改接口本身)
方案 A:按需填充
ActionTypeQueryDTO 中已经有 needFill 字段。如果 /tree 接口某些场景只需要树结构不需要实时值,前端可以在入参中控制 needFill=false。
后端也可以根据 level 判断:如果 level=1 只展示一级菜单,很多实时值其实不需要填充。
方案 B:分类电量统计加缓存
queryAllTypeEleStatistics 查询的是当天累计电量,这类数据可以按 containerId 维度缓存 1~5 分钟。
java
private HashMap<String, TypeEleStatisticsDTO> queryAllTypeEleStatistics(String containerId, String traceId) {
String cacheKey = "hems:type-ele-statistics:" + containerId;
String cached = redisUtil.get(cacheKey);
if (StrUtil.isNotBlank(cached)) {
return JSON.parseObject(cached, new TypeReference<HashMap<String, TypeEleStatisticsDTO>>(){});
}
// ... 原有查询逻辑
redisUtil.set(cacheKey, JSON.toJSONString(result), 60); // 缓存60秒
return result;
}
方案 C:设备参数查询加缓存
fillParamSettingsValue 调用 deviceDfService.getDeviceParam() 查设备参数。设备参数变化不频繁,可以按 deviceId 缓存 1 分钟。
四、优化优先级与预期收益
| 优先级 | 优化项 | 影响接口 | 预期收益 |
|---|---|---|---|
| P0 | getContainerTimeZone 加缓存 |
page、getTimeZone、多个日期格式化场景 | 时区接口从 1s → 10ms;分页接口降 30%~50% |
| P0 | getContainerStatistics 单独写轻量查询 |
getStatistics | 从 5.5s → 500ms 以内 |
| P1 | searchDeviceRealData 改批量/并行 |
searchDeviceRealData | 从 1.2s → 200~400ms |
| P1 | fillAddressInfo 批量查 geo 或加缓存 |
page | 分页接口再降 20%~30% |
| P2 | queryAllTypeEleStatistics 加缓存 |
actionType/tree、searchDeviceRealData | 树接口明显提速 |
| P2 | 通信棒设备查询按需批量 | page、getStatistics | 减少全表扫描 |
五、最小改动集(推荐先实施这 4 项)
如果只改最少的代码,建议优先做这 4 项:
-
DateUtils.getContainerTimeZone加 Caffeine 本地缓存- 影响面小,收益巨大,所有涉及时区的接口都受益。
-
ContainerController.getTimeZone加 Redis 缓存- 单独接口,加缓存即可,立竿见影。
-
HomeServiceImpl.getContainerStatistics独立实现- 不再复用
searchContainerPage,只查状态必要字段。
- 不再复用
-
SiteDeviceMonitorServiceImpl.getDeviceRealData改为并行或批量- 复用已有的
getBatchActionTypeTree,减少重复查询。
- 复用已有的
六、涉及的关键文件
| 文件 | 说明 |
|---|---|
hemsUser/src/main/java/com/dsa/hems/common/utils/DateUtils.java |
时区查询加缓存 |
hemsUser/src/main/java/com/dsa/hems/home/controller/ContainerController.java |
时区接口加缓存 |
hemsUser/src/main/java/com/dsa/hems/home/service/HomeServiceImpl.java |
分页、统计接口优化 |
hemsUser/src/main/java/com/dsa/hems/device/service/impl/SiteDeviceMonitorServiceImpl.java |
设备实时数据并行/批量优化 |
hemsUser/src/main/java/com/dsa/hems/device/service/impl/ActionTypeServiceImpl.java |
分类电量统计、设备参数加缓存 |
七、验证建议
- 在修改后使用相同请求参数重放,记录各接口耗时。
- 重点观察
container/page和container/getStatistics的 Feign 调用次数是否显著减少。 - 监控 Redis/本地缓存命中率,确保缓存生效。
- 并行改造后观察线程池是否被占满,必要时调大
applicationTaskExecutor线程数。
如需我直接修改其中某个文件,告诉我优先改哪个,我可以直接动手。