perf(identity): 主身份解析先查身份行短路 + 接入无身份计数观测
build / build-push (push) Successful in 57s

- resolveMainIdentity / resolveMainIdentityByUser 求值顺序反转:先取身份行,
  为空立即 null,不再先走 resolveSchoolId(沿 sys_group 祖先链无缓存、6~8 次 SQL);
  批量版只对确有身份行的用户解析主校,跳过空页。有身份用户结果与查询次数不变。
- IdentityService 新增进程级 nullIdentityLookups,getCurrent 落 null 分支自增,
  经 getNullIdentityLookupCount() 暴露;CacheRegistryController 在 /cache/registry
  输出 identityCurrentNullLookups,作为「是否给 null 加哨兵缓存」的唯一数据源。
- IdentityServiceTest 补 4 用例锁住短路契约与计数,旧 noGroup 用例补桩身份行。
This commit is contained in:
chenxing
2026-09-11 10:29:33 +08:00
parent 2106ba3d00
commit 877e800f82
3 changed files with 118 additions and 13 deletions
@@ -5,6 +5,7 @@ import com.yunhai.userbase.config.CacheConfig;
import com.yunhai.userbase.config.CacheNames;
import com.yunhai.userbase.config.CacheNames.Meta;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.service.impl.IdentityService;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -32,6 +33,9 @@ import java.util.TreeSet;
* × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats);</li>
* <li><b>unregisteredRuntimeCaches</b>:运行时出现但注册表没有 = 有人硬编码缓存名(应清零);</li>
* <li><b>declaredButUnusedCaches</b>:注册表声明但运行时从未出现 = 声明了没人用(启动初期属正常)。</li>
* <li><b>identityCurrentNullLookups</b>:无身份身份查询累计次数(进程级)。identityCurrent 对 null 不缓存
* (unless 拦截),故「无身份请求数」可直接读出;与埋点里 identity/current 的调用数相除即得空值占比,
* 是判断是否值得为 null 加哨兵缓存的唯一依据。Redis 侧缓存无命中率输出,此计数是其替代观测面。</li>
* </ul>
* 降级态显式标注 degraded=true(Redis 探活失败切 Caffeine),不误报「缓存全丢」——
* 此时 Redis 侧缓存名不可见属预期,恢复后自动切回。<br>
@@ -44,6 +48,10 @@ public class CacheRegistryController {
@Autowired
private CacheManager cacheManager;
/** 无身份查询计数来源(identityCurrent 对 null 不缓存,计数即无身份请求数)。 */
@Autowired
private IdentityService identityService;
/** Redis 降级兜底实例(Redis 侧缓存名的运行时来源,且只在降级期承载条目)。 */
@Autowired
@Qualifier("caffeineCacheManager")
@@ -91,6 +99,7 @@ public class CacheRegistryController {
result.put("caches", caches);
result.put("unregisteredRuntimeCaches", unregistered);
result.put("declaredButUnusedCaches", unused);
result.put("identityCurrentNullLookups", identityService.getNullIdentityLookupCount());
return result;
}
@@ -31,6 +31,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
/**
@@ -60,6 +61,19 @@ public class IdentityService {
@Autowired
private ApplicationEventPublisher eventPublisher;
/**
* 无身份({@link #getCurrent} 走到 null)请求累计计数。<br>
* 与 {@code unless="#result == null"} 呼应:null 不入缓存,故该分支每次请求都会执行,
* 计数即「无身份请求数」——与埋点里 identity/current 的调用数相除即得空值占比,
* 是判断是否值得为 null 加哨兵缓存的唯一依据(对账接口 {@code /cache/registry} 输出)。
*/
private final AtomicLong nullIdentityLookups = new AtomicLong();
/** 无身份身份查询累计次数(进程级累计,重启归零)。 */
public long getNullIdentityLookupCount() {
return nullIdentityLookups.get();
}
/**
* 用户全部可选身份(正常态),用于「切换身份」页渲染。
* 排序:主身份优先 → sortNo 升序 → id 升序(「未切换默认第一个」的确定性保证)。
@@ -101,7 +115,8 @@ public class IdentityService {
if (mainRel != null) {
return toBoaRoleInfo(mainRel.getRoleId(), mainRel.getSchoolId());
}
// 连主身份都没有(测试期初数据尚未建立):返回 null
// 连主身份都没有(测试期初数据尚未建立):返回 null(unless 拦下不入缓存,故此处每次请求都到)
nullIdentityLookups.incrementAndGet();
return null;
}
@@ -144,42 +159,65 @@ public class IdentityService {
* 单用户主身份(替代 sys_user_role_rel.is_main 物化字段的读时计算,语义与原「主校首行置 1」一致):<br>
* 主组(MainGroupResolver,isMain=1 取 id 最小,无主兜底 id 最小)→ resolveSchoolId(主组) →
* 该校正常态身份行中 sortNo 最小、并列取 id 最小的一条。<br>
* 无组关系 / 主校无身份行 → null(getCurrent 兜底与 UserCenterService 共用)。
* 无组关系 / 主校无身份行 → null(getCurrent 兜底与 UserCenterService 共用)。<br><br>
* 求值顺序:先取身份行,空则立即 null——身份行是单表带索引的廉价查询,而 resolveSchoolId 沿
* sys_group 祖先链逐级 findById(全项目无组缓存);无身份用户在旧顺序下把整条组链走完仍注定 null,
* 新顺序把这条路径由 6~8 次 SQL 降到 2 次。有身份用户的结果与查询次数不变,只是先后互换。
*/
public SysUserRoleRel resolveMainIdentity(Integer userId) {
List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL);
if (rels == null || rels.isEmpty()) {
return null;
}
SysUserGroupRel mainGroup = mainGroupResolver.resolve(userId).orElse(null);
if (mainGroup == null) {
return null;
}
Integer mainSchoolId = sysGroupService.resolveSchoolId(mainGroup.getGroupId());
return pickMainIdentity(mainSchoolId, sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL));
return pickMainIdentity(mainSchoolId, rels);
}
/**
* 批量主身份解析:列表页身份名列 + 导出用,全程 2 次组查询 + 1 次身份行 IN 查询(无 N+1)。<br>
* 返回 userId → 主身份行;无主组 / 主校无身份行的用户不出现。
* 批量主身份解析:列表页身份名列 + 导出用,全程 1 次身份行 IN 查询 + 2 次组查询(无 N+1)。<br>
* 返回 userId → 主身份行;无主组 / 主校无身份行的用户不出现。<br>
* 求值顺序同 {@link #resolveMainIdentity}:先取身份行,一个都没有则直接返回、完全不碰组链;
* 只对确有身份行的用户解析主校——resolveSchoolIds 会全量加载 sys_group 建内存表,跳过空页收益明显。
*/
public Map<Integer, SysUserRoleRel> resolveMainIdentityByUser(List<Integer> userIds) {
Map<Integer, SysUserRoleRel> result = new HashMap<>();
if (userIds == null || userIds.isEmpty()) {
return result;
}
Map<Integer, List<SysUserRoleRel>> roleRelsByUser = sysUserRoleRelRepository
.findByUserIdInAndStatus(userIds, StatusEnum.NORMAL).stream()
.filter(r -> r.getUserId() != null)
.collect(Collectors.groupingBy(SysUserRoleRel::getUserId));
if (roleRelsByUser.isEmpty()) {
return result;
}
Map<Integer, SysUserGroupRel> mainGroupMap = mainGroupResolver.resolveByUserIds(userIds);
if (mainGroupMap.isEmpty()) {
return result;
}
Map<Integer, Integer> schoolByGroup = sysGroupService.resolveSchoolIds(
mainGroupMap.values().stream().map(SysUserGroupRel::getGroupId).collect(Collectors.toList()));
Map<Integer, List<SysUserRoleRel>> roleRelsByUser = sysUserRoleRelRepository
.findByUserIdInAndStatus(userIds, StatusEnum.NORMAL).stream()
.collect(Collectors.groupingBy(SysUserRoleRel::getUserId));
List<Integer> groupIds = mainGroupMap.entrySet().stream()
.filter(e -> roleRelsByUser.containsKey(e.getKey()))
.map(e -> e.getValue().getGroupId())
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<Integer, Integer> schoolByGroup = groupIds.isEmpty()
? Collections.emptyMap()
: sysGroupService.resolveSchoolIds(groupIds);
for (Map.Entry<Integer, SysUserGroupRel> e : mainGroupMap.entrySet()) {
List<SysUserRoleRel> rows = roleRelsByUser.get(e.getKey());
if (rows == null || rows.isEmpty()) {
continue;
}
Integer mainSchoolId = schoolByGroup.get(e.getValue().getGroupId());
if (mainSchoolId == null) {
continue;
}
SysUserRoleRel main = pickMainIdentity(mainSchoolId,
roleRelsByUser.getOrDefault(e.getKey(), Collections.emptyList()));
SysUserRoleRel main = pickMainIdentity(mainSchoolId, rows);
if (main != null) {
result.put(e.getKey(), main);
}
@@ -26,6 +26,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
@@ -75,13 +77,27 @@ class IdentityServiceTest {
// ===================== 单用户 resolveMainIdentity =====================
/** 无组关系 → null */
/** 有身份行但无组关系 → null */
@Test
void resolveMainIdentity_noGroup_returnsNull() {
when(sysUserRoleRelRepository.findByUserIdAndStatus(1, StatusEnum.NORMAL))
.thenReturn(Collections.singletonList(roleRel(10, 1, 16, 0)));
when(mainGroupResolver.resolve(1)).thenReturn(Optional.empty());
assertNull(identityService.resolveMainIdentity(1));
}
/**
* 无身份行 → 立即 null,且完全不触碰组链。
* 这是重排求值顺序的核心契约:无身份用户在旧顺序下走完整条 sys_group 祖先链(6~8 次 SQL)仍返回 null。
*/
@Test
void resolveMainIdentity_noIdentityRows_shortCircuitsWithoutGroupQueries() {
when(sysUserRoleRelRepository.findByUserIdAndStatus(1, StatusEnum.NORMAL))
.thenReturn(Collections.emptyList());
assertNull(identityService.resolveMainIdentity(1));
verifyNoInteractions(mainGroupResolver, sysGroupService);
}
/** 主校无身份行(身份行全在其它学校)→ null */
@Test
void resolveMainIdentity_mainSchoolHasNoRows_returnsNull() {
@@ -136,6 +152,48 @@ class IdentityServiceTest {
assertTrue(identityService.resolveMainIdentityByUser(Collections.emptyList()).isEmpty());
}
/** 批量:全无身份行 → 不触碰组链(resolveSchoolIds 会全量加载 sys_group 建内存表) */
@Test
void resolveMainIdentityByUser_noRows_skipsGroupResolution() {
when(sysUserRoleRelRepository.findByUserIdInAndStatus(Arrays.asList(1, 2), StatusEnum.NORMAL))
.thenReturn(Collections.emptyList());
assertTrue(identityService.resolveMainIdentityByUser(Arrays.asList(1, 2)).isEmpty());
verifyNoInteractions(mainGroupResolver, sysGroupService);
}
/** 批量:只为确有身份行的用户解析主校,无身份行用户的主组不进 resolveSchoolIds */
@Test
void resolveMainIdentityByUser_onlyResolvesSchoolsForUsersWithRows() {
Map<Integer, SysUserGroupRel> mainGroupMap = new HashMap<>();
mainGroupMap.put(1, groupRel(16, 1));
mainGroupMap.put(2, groupRel(20, 1)); // 用户 2 有主组但无身份行
when(mainGroupResolver.resolveByUserIds(Arrays.asList(1, 2))).thenReturn(mainGroupMap);
when(sysUserRoleRelRepository.findByUserIdInAndStatus(Arrays.asList(1, 2), StatusEnum.NORMAL))
.thenReturn(Collections.singletonList(roleRel(30, 1, 16, 1)));
Map<Integer, Integer> schoolByGroup = new HashMap<>();
schoolByGroup.put(16, 16);
when(sysGroupService.resolveSchoolIds(Collections.singletonList(16))).thenReturn(schoolByGroup);
Map<Integer, SysUserRoleRel> result = identityService.resolveMainIdentityByUser(Arrays.asList(1, 2));
assertEquals(1, result.size());
assertEquals(Integer.valueOf(30), result.get(1).getId());
verify(sysGroupService).resolveSchoolIds(Collections.singletonList(16));
}
// ===================== getCurrent 无身份计数 =====================
/** 无身份用户 getCurrent 返回 null,并计入无身份计数(是否加哨兵缓存的判断数据源) */
@Test
void getCurrent_noIdentity_countsNullLookup() {
when(sysUserCurrentRoleRepository.findByUserId(1)).thenReturn(Optional.empty());
when(sysUserRoleRelRepository.findByUserIdAndStatus(1, StatusEnum.NORMAL))
.thenReturn(Collections.emptyList());
assertNull(identityService.getCurrent(1));
assertEquals(1L, identityService.getNullIdentityLookupCount());
verifyNoInteractions(mainGroupResolver, sysGroupService);
}
// ===================== getCurrent 缓存注解契约 =====================
/**