当设计或修改 REST API 响应结构、处理 API 返回值时触发。防止 API 设计缺陷导致的字段错位、类型歧义等问题。
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.
当代码涉及循环内查询、批量 ID 查询、IN 子句、BFS/递归遍历、嵌套 service 调用时触发。防止 N+1 查询、IN 子句过长、递归内存炸裂等性能陷阱。
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.
설치 방법 보기설치하기 전에 에이전트에게 전달되는 지시 내용을 확인할 수 있습니다.
当代码涉及数据库或远程服务的批量查询时,防止 N+1、IN 子句过长、递归无界等高频性能陷阱。
与
multi-tenant-safety配合:本 skill 关注「查询效率」,租户隔离遵循后者。
场景: for / stream / forEach 内部直接调用 repo.findById(...) 或同等单条远程调用
每次循环触发一次 SQL/HTTP,N 个元素就是 N 次往返。100 元素 = 100 次 SQL,1000 元素就是 1000 次。本地开发数据少看不出来,生产环境直接拖垮接口。
只要在 service / handler 方法里看到以下任一模式,立即怀疑 N+1:
// ❌ 模式 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」三步走:
// ✅ 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());
}
| 单条方法 | 必须配套 | 用途 |
|---|---|---|
findById | findAllById / findByIdIn | 已知主键集合 |
findByTenantIdAndId | findByTenantIdAndIdIn | 多租户场景批量 |
findByFooId | findByFooIdIn | 已知外键集合 |
新增带 IN 的方法时必须和单条方法成对出现,避免上层不得不写 N+1。
最坑的一种 N+1 ——convertToDTO() / toResponseDto() / enrichEntity() 这类辅助方法看起来"一个函数搞定一切",但内部可能藏着 4-5 次 findById,循环里调一次就是 N×K 次 SQL。
// ❌ 看起来无害,实际触发 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:两阶段处理(推荐,适合"先筛选再展示")
// ✅ 阶段 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 转换函数。完整版给详情接口,轻量版给列表/查重接口:
// ✅ 列表/查重:只填实际需要的字段
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 的列表接口)
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();
}
代码评审时只要看到以下模式立即怀疑:
convertToDTO(...) / toResponseDto(...) / to...DTO(...) / enrich...(...)findById( / getOne( / getReferenceById( / findOne(,是否在循环或 stream 链中findXxx 调用次数 × 列表大小是否能接受convertToDTOs(List<T>) 版本场景: 拿到一个大 list 直接传给 WHERE id IN (...),没有上限保护
max_allowed_packet(默认 16M)超限会 failIN 单次最多 1000 个参数// ❌ 5000 个 ID 直接塞进 IN
List<User> users = userRepository.findByTenantIdAndIdIn(tenantId, allIds);
固定 IN_BATCH_SIZE = 500,分批查询后合并:
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 的分批:
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 ( / findByIdIn / findAllById)IN_BATCH_SIZE 常量是否统一在 500 或更小场景: 邀请树、组织架构、文件夹、评论树等递归结构,按 depth 限制但不限 totalNodes
depth ≤ 3 看似安全,但每层节点数可能爆炸:层 1=1000 → 层 2=100×1000=10万 → 层 3 可能百万级。全部加载到内存就是 OOM。
// ❌ 只限 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 截断 + 接口语义文档化:
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_DEPTH 和 MAX_TOTAL_NODESlog.warn 留痕(生产排查依据)场景: 同一个接口内调用 getDetail() + countMembers() + getMembers(),每个内部都跑一次完整 BFS / 大表查询
每个 public service 方法封装了一套查询,单独看都合理;组合调用时同样的 BFS 跑 3 次。前端把它当作"查 1 个接口",后端实际是 3 倍 SQL。
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,上层只跑一次:
public TeamDetailVO getMyTeamDetail(Long tenantId, Long userId) {
// 单次 BFS 拿到全量队员
List<User> members = teamMemberService.collectDescendants(tenantId, user);
int memberCount = members.size();
Long totalPoints = pointsRepository.sumBySourceUserIds(tenantId,
members.stream().map(User::getId).toList());
return TeamDetailVO.builder()
.memberCount(memberCount)
.totalPoints(totalPoints)
.members(toVO(members))
.build();
}
// countMembers / listMembers 标 @Deprecated 或直接删除,避免外部继续踩坑
countXxx + listXxx」并存且都跑完整查询@Deprecated 的旧方法是否在合理迭代内删除场景: 新增 findByXxx 方法,但 xxx 列没有索引;或新增 sourceUserId IN (...) 聚合,但该列只被 SELECT 用过
SUM/IN 1000 行也许还能撑,5000 行直接全表扫描,CPU 飙到 100%。索引在小表上看不出差异,生产数据规模下决定接口生死。
每次新增 Repository 查询方法,对照实体类的 @Index 注解 + Flyway 迁移脚本:
// ✅ 实体上声明索引
@Entity
@Table(name = "points_pending", indexes = {
@Index(name = "idx_pending_source_user", columnList = "tenant_id, source_user_id"),
@Index(name = "idx_pending_confirmed_at", columnList = "tenant_id, confirmed_at"),
})
public class PointsPending { ... }
-- ✅ Flyway 显式建索引(实体注解不会自动建索引到已存在的表)
CREATE INDEX idx_pending_source_user ON points_pending (tenant_id, source_user_id);
findByXxx 时,xxx 列是否有索引(联合索引含 tenant_id)EXPLAIN 验证,不是猜)idx (tenant_id, status, created_at))deleted_at)需要时也要加入索引场景: 用了 @Cacheable 但参数包含 Pageable 或大对象,导致 cache key 几乎不命中;或缓存了租户敏感数据但 key 没带 tenantId
// ❌ key 包含 Pageable,每次翻页都是新 key
@Cacheable("products")
public Page<Product> list(Pageable pageable, String keyword) { ... }
// ❌ 跨租户缓存污染
@Cacheable("user")
public User findById(Long id) { ... }
// ✅ key 显式列出稳定字段 + 缓存粒度匹配业务
@Cacheable(value = "products", key = "#tenantId + ':' + #keyword + ':' + #page")
public List<Product> list(Long tenantId, String keyword, int page) { ... }
// ✅ 租户隔离的缓存 key
@Cacheable(value = "user", key = "#tenantId + ':' + #id")
public User findById(Long tenantId, Long id) { ... }
@Cacheable 的 key 是否稳定(不含 Pageable、Date.now 等不稳定参数)@CacheEvict| 查询模式 | 可接受 SQL 数 | 警戒线 |
|---|---|---|
| 单实体查询接口 | 1–3 | > 5 |
| 列表查询(N 个外键 join) | 1 + 外键种类数 | 与 N 相关即不可接受 |
| BFS/递归(≤ MAX_TOTAL_NODES) | depth × 1 + IN 批次数 | 与节点数线性相关 |
| 聚合统计 | 1(SUM)/ 分批数 | 全量加载到内存计算 |
完整的 Java / Go / Python / Node.js 实现见 references/multi-lang-examples.md。
> 📋 本回复遵循:`query-performance-safety` - [章节名]
아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.
로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.
当设计或修改 REST API 响应结构、处理 API 返回值时触发。防止 API 设计缺陷导致的字段错位、类型歧义等问题。
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.
当设计或修改 REST API 响应结构、处理 API 返回值,或生成 Excel/CSV/PDF/对账文件等下游产物时触发。防止 API 设计缺陷导致的字段错位、类型歧义,以及生成产物时关键字段缺失但静默成功的问题。
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.
网关/代理/WAF/CDN 中间件的安全关键词匹配实现规范,防止纯子串匹配误判正常响应内容中的技术术语(如 Cloudflare、502、error)
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.
当 API/任务可能执行超过 10 秒(批量数据处理、远程 API 批量调用、全表扫描、跨租户聚合)时触发。防止同步接口被网关 30s 超时切断、用户重复点击触发并发、状态缓存内存泄漏等问题。提供异步任务状态机标准模板。
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.
当用户操作 .sh、Dockerfile、Makefile、.yml、.yaml 文件,或在 Markdown 中编写 bash 代码块时触发。提供 Bash 编写规范。
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.
Bash 脚本与系统命令规范。禁止行尾注释,强制使用 tee/heredoc 写入,提升命令执行的可维护性。
한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.