Просмотр исходного кода

perf(T02): 消除 quickLookup / listSourceTypes 的 N+1 查询

两处读接口此前会按指标数 / sourceType 数逐次回查数据库:

- quickLookup:每个 definitionId 调一次 changeService.resolvePrevious(1 次 SQL),
  40 个指标 = 40 次查询。改为复用开头已加载的 rows 内存求解上一期
  (新增 resolvePreviousInMemory,语义与 resolvePrevious 严格一致:
  同来源 + 数值非空 + reportDate 严格早于本期,取最晚一条),
  查询数从 1+N 降为 1。
- listSourceTypes:每个 sourceType 再查 2 次(periodCount / latestReportDate),
  即 1+2N。改为一次取回 (sourceType, reportDate) 后内存计算,
  查询数从 1+2N 降为 1;同日去重后得 periodCount,取最大值得 latestReportDate。

单测补充:quickLookup 内存求解上一期(多期数据取到正确的上一期而非最早一期)、
本期日期未知时不猜上一期、listSourceTypes 单查询算准 periodCount/latestReportDate,
并断言 valueMapper.selectList 只被调用 1 次(锁死无 N+1)。
Sisyphus 2 недель назад
Родитель
Сommit
9c91647fdb

+ 62 - 29
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java

@@ -13,13 +13,10 @@ import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.math.BigDecimal;
-import java.math.RoundingMode;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.Comparator;
 import java.util.Date;
-import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -146,12 +143,9 @@ public class IndicatorTrendService {
                     .collect(Collectors.toList());
             if (sorted.isEmpty()) continue;
             IndicatorValue latest = sorted.get(0);
-            // 找上一期
-            java.sql.Date latestDate = latest.getReportDate();
-            IndicatorValue previous = null;
-            if (latestDate != null) {
-                previous = changeService.resolvePrevious(subjectId, defId, latest.getSourceType(), latestDate);
-            }
+            // 上一期:内存求解(等价于 changeService.resolvePrevious,但复用已加载的 rows,
+            // 避免逐指标查库的 N+1 —— 40 个指标就是 40 次查询)
+            IndicatorValue previous = resolvePreviousInMemory(e.getValue(), latest);
             // change 对象(恒非空)
             IndicatorChangeService.ChangeResult cr;
             if (previous == null || latest.getNumericValue() == null || latest.getNumericValue().compareTo(BigDecimal.ZERO) == 0
@@ -251,43 +245,82 @@ public class IndicatorTrendService {
         if (subjectId == null) {
             return items;
         }
+        // 一次取出 (sourceType, reportDate),periodCount 与 latestReportDate 全部内存计算,
+        // 避免 1+2N 查询(每个 sourceType 2 次 COUNT/MAX)
         List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
                 .eq(IndicatorValue::getSubjectId, subjectId)
                 .isNotNull(IndicatorValue::getSourceType)
                 .isNotNull(IndicatorValue::getReportDate)
-                .select(IndicatorValue::getSourceType));
-        Set<String> types = new LinkedHashSet<>();
+                .select(IndicatorValue::getSourceType, IndicatorValue::getReportDate));
+
+        // type → 去重日期集合(保序)
+        Map<String, Set<java.sql.Date>> datesByType = new LinkedHashMap<>();
+        // type → 最晚日期
+        Map<String, java.util.Date> latestByType = new LinkedHashMap<>();
         for (IndicatorValue v : rows) {
             String t = v.getSourceType();
-            if (t != null && !t.trim().isEmpty()) {
-                types.add(t.trim());
+            java.util.Date d = v.getReportDate();
+            if (t == null || t.trim().isEmpty() || d == null) {
+                continue;
+            }
+            t = t.trim();
+            if (d instanceof java.sql.Date) {
+                datesByType.computeIfAbsent(t, k -> new LinkedHashSet<>()).add((java.sql.Date) d);
+            }
+            java.util.Date cur = latestByType.get(t);
+            if (cur == null || d.after(cur)) {
+                latestByType.put(t, d);
             }
         }
-        for (String t : types) {
+
+        for (String t : datesByType.keySet()) {
             Map<String, Object> m = new LinkedHashMap<>();
             m.put("sourceType", t);
             m.put("label", sourceTypeLabel(t));
-            // periodCount / latestReportDate:同 sourceType 且 report_date 非空的 distinct count + max
-            long periodCount = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
-                    .eq(IndicatorValue::getSubjectId, subjectId)
-                    .eq(IndicatorValue::getSourceType, t)
-                    .isNotNull(IndicatorValue::getReportDate)
-                    .select(IndicatorValue::getReportDate))
-                    .stream().map(IndicatorValue::getReportDate).distinct().count();
-            Date latest = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
-                    .eq(IndicatorValue::getSubjectId, subjectId)
-                    .eq(IndicatorValue::getSourceType, t)
-                    .isNotNull(IndicatorValue::getReportDate)
-                    .orderByDesc(IndicatorValue::getReportDate)
-                    .select(IndicatorValue::getReportDate))
-                    .stream().map(IndicatorValue::getReportDate).filter(Objects::nonNull).findFirst().orElse(null);
-            m.put("periodCount", periodCount);
+            m.put("periodCount", datesByType.get(t).size());
+            java.util.Date latest = latestByType.get(t);
             m.put("latestReportDate", latest != null ? new SimpleDateFormat("yyyy-MM-dd").format(latest) : null);
             items.add(m);
         }
         return items;
     }
 
+    /**
+     * 内存求解上一期观测(语义与 {@link IndicatorChangeService#resolvePrevious} 完全一致)。
+     *
+     * <p>rows 已是该 subject 下全部观测(quickLookup 开头一次查完),在这里内存过滤比
+     * 逐指标回查 indicator_values 便宜得多:40 个指标省 40 次 SQL。</p>
+     *
+     * @param defRows 同一 definitionId 的全部观测(未排序)
+     * @param latest  本期最新观测
+     * @return 上一期观测;无(本期日期未知 / 同来源无更早有效值)返回 null
+     */
+    private IndicatorValue resolvePreviousInMemory(List<IndicatorValue> defRows, IndicatorValue latest) {
+        if (defRows == null || latest == null || latest.getReportDate() == null) {
+            return null;   // 本期日期未知 → 无法确定"更早",不猜
+        }
+        // 严格早于本期:按原始时间戳比较(不截断到日),与 DB 侧 report_date < ? 的语义一致
+        final long latestMillis = latest.getReportDate().getTime();
+        final String sourceType = latest.getSourceType();
+        IndicatorValue previous = null;
+        for (IndicatorValue v : defRows) {
+            if (v == latest || v.getReportDate() == null || v.getNumericValue() == null) {
+                continue;
+            }
+            if (sourceType != null && !sourceType.trim().isEmpty()
+                    && !sourceType.equals(v.getSourceType())) {
+                continue;   // 只认同来源的更早期
+            }
+            if (v.getReportDate().getTime() >= latestMillis) {
+                continue;   // 严格早于本期
+            }
+            if (previous == null || v.getReportDate().after(previous.getReportDate())) {
+                previous = v;
+            }
+        }
+        return previous;
+    }
+
     /** legacy 路径(兼容老调用 scope=adjacent/first-vs-latest + limit 模式) */
     private Map<String, Object> changeRankingLegacy(Long subjectId, String reportType, String category,
                                                      String scope, String orderBy, Integer limit, Long familyId,

+ 100 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.java

@@ -1,9 +1,12 @@
 package com.etotem.cfc.service;
 
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.etotem.cfc.entity.IndicatorDefinition;
 import com.etotem.cfc.entity.IndicatorValue;
 import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
 import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.springframework.test.util.ReflectionTestUtils;
@@ -29,6 +32,12 @@ class IndicatorTrendServiceTest {
 
     @BeforeEach
     void setUp() {
+        // LambdaQueryWrapper.select(SFunction) 需要实体元数据(MP lambda cache)
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                IndicatorValue.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                IndicatorDefinition.class);
+
         service = new IndicatorTrendService();
         valueMapper = mock(IndicatorValueMapper.class);
         ReflectionTestUtils.setField(service, "valueMapper", valueMapper);
@@ -205,4 +214,95 @@ class IndicatorTrendServiceTest {
         assertEquals("指标C", items.get(2).get("name"), "deltaPct=null 恒定排最后");
         assertNull(items.get(2).get("deltaPct"), "基数为 0 的百分比必须为 null");
     }
+
+    /**
+     * quickLookup 的上一期必须从已加载的 rows 内存求解。
+     * 之前逐指标回查 indicator_values(resolvePrevious),40 个指标就是 40 次 SQL。
+     */
+    @Test
+    void quickLookup_resolvesPreviousInMemory_noExtraQueries() {
+        IndicatorDefinition d = def(1L, "双歧杆菌属", "higher_is_better", "%");
+        when(changeDefinitionMapper.selectList(any())).thenReturn(new ArrayList<>(Arrays.asList(d)));
+
+        // 三期观测:最新 12.8,上一期 10.5(percentageFloor 以下,Δ% 仍有效),首期 8.0
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-08-01", "8.0"));
+        rows.add(value(1L, "2026-09-15", "10.5"));
+        rows.add(value(1L, "2026-10-01", "12.8"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L, false, 8);
+        Map<?, ?> item = (Map<?, ?>) ((List<?>) data.get("items")).get(0);
+
+        // 上一期必须是 2026-09-15 的 10.5,而不是更早的 8.0
+        Map<?, ?> previous = (Map<?, ?>) item.get("previous");
+        assertNotNull(previous, "内存求解必须找到上一期");
+        assertEquals("2026-09-15", previous.get("reportDate"));
+        assertEquals("10.5", previous.get("value"));
+        // higher_is_better 从 10.5 涨到 12.8 → 改善
+        assertEquals("improved", ((Map<?, ?>) item.get("change")).get("trendCode"));
+        assertEquals("12.8", item.get("value"), "最新值必须是本期最新");
+        assertEquals(3, ((Number) item.get("pointsCount")).intValue());
+
+        // 一次 selectList 取全部观测,之后不再回查(无 N+1)
+        verify(valueMapper, times(1)).selectList(any());
+    }
+
+    /** 本期最新观测的日期为 null → 无法确定"更早",不猜上一期,趋势为 first_record */
+    @Test
+    void quickLookup_latestHasNullDate_noPrevious() {
+        IndicatorDefinition d = def(1L, "双歧杆菌属", "higher_is_better", "%");
+        when(changeDefinitionMapper.selectList(any())).thenReturn(new ArrayList<>(Arrays.asList(d)));
+
+        // 同定义的所有观测日期均为 null → 排序后最新的也是 null 日期
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(valueWithNullDate(1L, "12.8"));   // null 日期
+        rows.add(valueWithNullDate(1L, "9.0"));    // null 日期
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L, false, 8);
+        Map<?, ?> item = (Map<?, ?>) ((List<?>) data.get("items")).get(0);
+
+        // 本期日期未知 → 无法确定"更早",不猜上一期
+        assertNull(item.get("previous"));
+        assertNull(item.get("latestReportDate"));
+        assertEquals("first_record", ((Map<?, ?>) item.get("change")).get("trendCode"),
+                "本期日期未知时按首次记录降级");
+        verify(valueMapper, times(1)).selectList(any());
+    }
+
+    /** listSourceTypes 改为一次查询内存计算 periodCount/latestReportDate(原 1+2N) */
+    @Test
+    void listSourceTypes_computesPeriodCountAndLatestInOneQuery() {
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(sourceTypeRow("gut_flora", "2026-08-01"));
+        rows.add(sourceTypeRow("gut_flora", "2026-09-15"));
+        rows.add(sourceTypeRow("gut_flora", "2026-09-15"));   // 同日 → 去重
+        rows.add(sourceTypeRow("gut_flora", "2026-10-01"));
+        rows.add(sourceTypeRow("physical_exam", "2026-07-01"));
+        rows.add(sourceTypeRow("physical_exam", "2026-08-02"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        List<Map<String, Object>> out = service.listSourceTypes(3L);
+        assertEquals(2, out.size(), "两个 source_type 各一条");
+
+        Map<String, Object> gut = out.get(0);
+        assertEquals("gut_flora", gut.get("sourceType"));
+        assertEquals(3L, ((Number) gut.get("periodCount")).longValue(), "同日去重,3 个不同日期");
+        assertEquals("2026-10-01", gut.get("latestReportDate"));
+
+        Map<String, Object> physical = out.get(1);
+        assertEquals("physical_exam", physical.get("sourceType"));
+        assertEquals(2L, ((Number) physical.get("periodCount")).longValue());
+        assertEquals("2026-08-02", physical.get("latestReportDate"));
+
+        verify(valueMapper, times(1)).selectList(any());   // 不得按 sourceType 分别查库
+    }
+
+    private IndicatorValue sourceTypeRow(String sourceType, String date) {
+        IndicatorValue v = new IndicatorValue();
+        v.setSourceType(sourceType);
+        v.setReportDate(Date.valueOf(date));
+        return v;
+    }
 }