Skip to main content

cc-query-performance-safety

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

معلومات المصدر

المستودع
doccker/cc-use-exp
آخر نشاط في المصدر
٢٧ مايو ٢٠٢٦ في ٠٩:٤٦
لغة SKILL.md المكتشفة
الصينية
النجوم
١٬٠٦٢
التفرعات
١١٩

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

مستكشف الملفات
3 ملفات

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
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);
عرض على GitHub
ملف SKILL.md هذا كبير جدا، لذلك يعرض SkillsMP القسم الاول فقط هنا. عرض على GitHub