Skip to main content

cc-query-performance-safety

当代码涉及循环内查询、批量 ID 查询、IN 子句、BFS/递归遍历、嵌套 service 调用时触发。防止 N+1 查询、IN 子句过长、递归内存炸裂等性能陷阱。

Source facts

Repository
doccker/cc-use-exp
Last source activity
May 27, 2026 at 09:46
Detected SKILL.md language
Chinese
Stars
1,062
Forks
119

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
3 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
cc-query-performance-safety
description
当代码涉及循环内查询、批量 ID 查询、IN 子句、BFS/递归遍历、嵌套 service 调用时触发。防止 N+1 查询、IN 子句过长、递归内存炸裂等性能陷阱。
# 查询性能安全规范 当代码涉及数据库或远程服务的批量查询时,防止 N+1、IN 子句过长、递归无界等高频性能陷阱。 > 与 `cc-multi-tenant-safety` 配合:本 skill 关注「查询效率」,租户隔离遵循后者。 --- ## 陷阱 #1: 循环内调用单条查询 → N+1 **场景**: `for / stream / forEach` 内部直接调用 `repo.findById(...)` 或同等单条远程调用 ### 问题根因 每次循环触发一次 SQL/HTTP,N 个元素就是 N 次往返。100 元素 = 100 次 SQL,1000 元素就是 1000 次。本地开发数据少看不出来,生产环境直接拖垮接口。 ### 嗅探信号 只要在 service / handler 方法里看到以下任一模式,立即怀疑 N+1: ```java // ❌ 模式 1:显式 for for (Order o : orders) { User u = userRepository.findById(o.getUserId()).orElse(null); } // ❌ 模式 2:stream 链 orders.stream() .map(o -> userRepository.findById(o.getUserId())) .collect(...); // ❌ 模式 3:辅助方法被循环调用 for (Item i : items) { enrichItem(i); // 里面又 findById 一次 } // ❌ 模式 4:嵌套调用 List<X> list = repoA.findByY(y); for (X x : list) { x.setZ(repoB.findById(x.getZId()).orElse(null)); } ``` ### 正确做法 「**先批量拿 → 落到 Map → 循环里查 Map**」三步走: ```java // ✅ 1. 收集所有外键 ID List<Long> userIds = orders.stream() .map(Order::getUserId) .filter(Objects::nonNull) .distinct() .toList(); // ✅ 2. 一次 IN 查询,落到 Map Map<Long, User> userMap = userRepository .findByTenantIdAndIdIn(tenantId, userIds) .stream() .collect(Collectors.toMap(User::getId, u -> u)); // ✅ 3. 循环内只查 Map,零额外 SQL for (Order o : orders) { User u = userMap.get(o.getUserId()); } ``` ### Repository 必须提供的批量方法 | 单条方法 | 必须配套 | 用途 | |---------|---------|------| | `findById` | `findAllById` / `findByIdIn` | 已知主键集合 | | `findByTenantIdAndId` | `findByTenantIdAndIdIn` | 多租户场景批量 | | `findByFooId` | `findByFooIdIn` | 已知外键集合 | 新增带 IN 的方法时**必须**和单条方法成对出现,避免上层不得不写 N+1。 ### 隐式 N+1:循环内调 DTO 转换函数 最坑的一种 N+1 ——`convertToDTO()` / `toResponseDto()` / `enrichEntity()` 这类辅助方法看起来"一个函数搞定一切",但内部可能藏着 4-5 次 `findById`,循环里调一次就是 N×K 次 SQL。 ```java // ❌ 看起来无害,实际触发 24000 次 SQL(6000 商品 × 4 次内部查询) public Map<String, List<ProductDTO>> findDuplicates() { List<Product> products = productRepository.findByTenantId(tenantId); Map<String, List<ProductDTO>> groups = new HashMap<>(); for (Product p : products) { String key = normalize(p.getProductName() + p.getSpecification()); groups.computeIfAbsent(key, k -> new ArrayList<>()) .add(convertToDTO(p)); // ❌ 内部每次都查 category/supplier/image/price } return groups; } private ProductDTO convertToDTO(Product p) { return ProductDTO.builder() .categoryName(categoryRepository.findById(p.getCategoryId())...) // +1 .supplier(productSupplierRepository.findByProductId(p.getId())...) // +1 .imageUrl(productImageRepository.findByProductId(p.getId())...) // +1 .priceTiers(priceTierRepository.findByProductId(p.getId())...) // +1 .build(); } ``` #### 修复策略 **策略 A:两阶段处理**(推荐,适合"先筛选再展示") ```java // ✅ 阶段 1:用 entity 做筛选/分组,零额外查询 Map<String, List<Product>> grouped = new HashMap<>(); for (Product p : products) { grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(p); // entity,不转 DTO } // 只对真正要展示的(如重复组 ≥ 2)做后续处理 grouped.entrySet().removeIf(e -> e.getValue().size() < 2); // 阶段 2:批量预加载关联数据 Set<Long> categoryIds = grouped.values().stream() .flatMap(List::stream) .map(Product::getCategoryId) .filter(Objects::nonNull) .collect(Collectors.toSet()); Map<Long, String> categoryNameMap = categoryRepository.findAllById(categoryIds).stream() .collect(Collectors.toMap(ProductCategory::getId, ProductCategory::getCategoryName)); // 阶段 3:用 Map 转 DTO,零额外查询 Map<String, List<ProductDTO>> result = new HashMap<>(); grouped.forEach((k, list) -> { result.put(k, list.stream() .map(p -> toLightweightDTO(p, categoryNameMap)) .toList()); }); ``` **策略 B:场景化轻量 DTO 转换器**(推荐,结合策略 A) 不同场景用不同 DTO 转换函数。完整版给详情接口,轻量版给列表/查重接口: ```java // ✅ 列表/查重:只填实际需要的字段 private ProductDTO toLightweightDTO(Product p, Map<Long, String> categoryNameMap) { return ProductDTO.builder() .id(p.getId()) .skuCode(p.getSkuCode()) .productName(p.getProductName()) .specification(p.getSpecification()) .categoryId(p.getCategoryId()) .categoryName(p.getCategoryId() == null ? null : categoryNameMap.get(p.getCategoryId())) .status(p.getStatus()) .build(); // 不填 supplier/image/priceTiers,列表场景用不到 } // 详情:用完整 convertToDTO public ProductDTO getDetail(Long id) { return convertToDTO(productRepository.findById(id).orElseThrow()); } ``` **策略 C:批量版 convertToDTO**(适合必须返回完整 DTO 的列表接口) ```java public List<ProductDTO> convertToDTOs(List<Product> products) { // 一次性预加载全部关联 Set<Long> categoryIds = products.stream().map(Product::getCategoryId).filter(Objects::nonNull).collect(Collectors.toSet()); Set<Long> productIds = products.stream().map(Product::getId).collect(Collectors.toSet()); Map<Long, ProductCategory> categoryMap = categoryRepository.findAllById(categoryIds).stream() .collect(Collectors.toMap(ProductCategory::getId, c -> c)); Map<Long, List<ProductImage>> imageMap = productImageRepository.findByProductIdIn(productIds).stream() .collect(Collectors.groupingBy(ProductImage::getProductId)); // ... 其他关联 // 转换时只查 Map return products.stream() .map(p -> toDTOWithPreloaded(p, categoryMap, imageMap, ...)) .toList(); } ``` #### 嗅探信号 代码评审时只要看到以下模式立即怀疑: - 任意循环(for/stream/forEach)里调用 `convertToDTO(...)` / `toResponseDto(...)` / `to...DTO(...)` / `enrich...(...)` - 看似"一行搞定"的辅助方法,其实跨了多个 Repository - 接口响应时间随数据量线性增长,但单条数据看不到明显慢点 - 数据库 SQL 数 = 列表数 × 某个常数 ### 检查清单(陷阱 #1 全部场景) - [ ] service 方法里搜 `findById(` / `getOne(` / `getReferenceById(` / `findOne(`,是否在循环或 stream 链中 - [ ] 每个 list-aware 方法的 SQL 次数与列表大小是否解耦(理想是 O(1) 或 O(log N) 而非 O(N)) - [ ] enrichment 辅助方法是否被循环调用(隐式 N+1) - [ ] 跨 service 调用(A.getDetail() → B.findX())是否在循环里 - [ ] 查重 / 列表 / 统计接口:是否在循环里调用 DTO 转换 - [ ] DTO 转换函数内部 `findXxx` 调用次数 × 列表大小是否能接受 - [ ] 是否区分了"列表轻量 DTO" vs "详情完整 DTO" - [ ] 批量场景是否提供了 `convertToDTOs(List<T>)` 版本 --- ## 陷阱 #2: IN 子句不分批 **场景**: 拿到一个大 list 直接传给 `WHERE id IN (...)`,没有上限保护 ### 问题根因 - MySQL `max_allowed_packet`(默认 16M)超限会 fail - Oracle `IN` 单次最多 1000 个参数 - PostgreSQL 虽无硬限制但单条 SQL 解析与计划生成成本随参数线性增长 - 单条超长 SQL 也会绕过查询计划缓存 ### 错误示例 ```java // ❌ 5000 个 ID 直接塞进 IN List<User> users = userRepository.findByTenantIdAndIdIn(tenantId, allIds); ``` ### 正确做法 固定 `IN_BATCH_SIZE = 500`,分批查询后合并: ```java private static final int IN_BATCH_SIZE = 500; private List<User> findUsersByIds(Long tenantId, List<Long> ids) { List<User> result = new ArrayList<>(ids.size()); for (int i = 0; i < ids.size(); i += IN_BATCH_SIZE) { List<Long> batch = ids.subList(i, Math.min(i + IN_BATCH_SIZE, ids.size())); result.addAll(userRepository.findByTenantIdAndIdIn(tenantId, batch)); } return result; } ``` **聚合 SUM/COUNT/AVG 的分批**: ```java private long sumByBatches(Long tenantId, List<Long> ids) { long total = 0L; for (int i = 0; i < ids.size(); i += IN_BATCH_SIZE) { List<Long> batch = ids.subList(i, Math.min(i + IN_BATCH_SIZE, ids.size())); Long val = repository.sumByIds(tenantId, batch); if (val != null) total += val; } return total; } ``` > JPA 用户也可借助 `Lists.partition` (Guava) 或 `CollectionUtils.partition` (Apache Commons)。 ### 检查清单 - [ ] 所有传入 IN 的 list 是否经过分批(grep `IN (` / `findByIdIn` / `findAllById`) - [ ] 项目级 `IN_BATCH_SIZE` 常量是否统一在 500 或更小 - [ ] 聚合查询(SUM/COUNT)也已分批 - [ ] 入口参数(如批量删除接口)是否限制了最大 list 长度 --- ## 陷阱 #3: BFS/递归遍历无总节点上限 **场景**: 邀请树、组织架构、文件夹、评论树等递归结构,按 depth 限制但不限 totalNodes ### 问题根因 `depth ≤ 3` 看似安全,但每层节点数可能爆炸:层 1=1000 → 层 2=100×1000=10万 → 层 3 可能百万级。全部加载到内存就是 OOM。 ### 错误示例 ```java // ❌ 只限 depth,不限总数 public List<User> collectDescendants(Long tenantId, WxUser self) { Map<Long, WxUser> visited = new LinkedHashMap<>(); visited.put(self.getId(), self); List<Long> currentLevelIds = List.of(self.getId()); for (int depth = 1; depth <= MAX_INVITE_DEPTH && !currentLevelIds.isEmpty(); depth++) { List<WxUser> nextLevel = repo.findByInviterIdIn(tenantId, currentLevelIds); // 没有总数限制,nextLevel 可能 10w+ ... } } ``` ### 正确做法 加 `MAX_TOTAL_NODES` 硬上限 + `log.warn` 截断 + 接口语义文档化: ```java private static final int MAX_INVITE_DEPTH = 3; private static final int MAX_TOTAL_NODES = 5000; public List<User> collectDescendants(Long tenantId, WxUser self) { Map<Long, WxUser> visited = new LinkedHashMap<>(); visited.put(self.getId(), self); List<Long> currentLevelIds = List.of(self.getId()); for (int depth = 1; depth <= MAX_INVITE_DEPTH && !currentLevelIds.isEmpty(); depth++) { if (visited.size() >= MAX_TOTAL_NODES) { log.warn("BFS 节点达上限 {},截断: root={}, depth={}", MAX_TOTAL_NODES, self.getId(), depth); break; } List<User> nextLevel = repo.findByInviterIdIn(tenantId, currentLevelIds); List<Long> nextIds = new ArrayList<>(nextLevel.size()); for (User u : nextLevel) { if (visited.size() >= MAX_TOTAL_NODES) break; if (visited.putIfAbsent(u.getId(), u) == null) { nextIds.add(u.getId()); } } currentLevelIds = nextIds; } return new ArrayList<>(visited.values()); } ``` **何时改用流式 / 分页 / 异步**: - 节点数预期 > MAX_TOTAL_NODES 的 80% → 考虑改为分页/流式遍历,避免一次性载入 - 真的需要全量统计 → 用 SUM/COUNT 聚合 SQL,不要拉全量到内存再 .size()/.stream().mapToLong() ### 检查清单 - [ ] 递归/BFS 是否同时有 `MAX_DEPTH` 和 `MAX_TOTAL_NODES` - [ ] 截断时是否 `log.warn` 留痕(生产排查依据) - [ ] 接口注释/文档是否说明「最大返回节点数」语义 - [ ] 统计场景是否优先用聚合 SQL 而非全量加载 --- ## 陷阱 #4: 嵌套 service 调用造成隐式重复查询 **场景**: 同一个接口内调用 `getDetail()` + `countMembers()` + `getMembers()`,每个内部都跑一次完整 BFS / 大表查询 ### 问题根因 每个 public service 方法封装了一套查询,单独看都合理;组合调用时同样的 BFS 跑 3 次。前端把它当作"查 1 个接口",后端实际是 3 倍 SQL。 ### 错误示例 ```java public TeamDetailVO getMyTeamDetail(Long tenantId, Long userId) { int memberCount = teamMemberService.countMembers(tenantId, userId); // BFS #1 Long totalPoints = teamMemberService.sumPoints(tenantId, userId); // BFS #2(内部又调 collectDescendants) List<Member> members = teamMemberService.listMembers(tenantId, userId); // BFS #3 return ...; } ``` ### 正确做法 **提取共享底层方法返回 list**,上层只跑一次: ```java public TeamDetailVO getMyTeamDetail(Long tenantId, Long userId) { // 单次 BFS 拿到全量队员 List<User> members = teamMemberService.collectDescendants(tenantId, user);
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub