Skip to content
Back to skills

Query Performance Safety

ASecurity

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

  • 1,035 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added May 28, 2026
ai-agentspythongojavasqlnodeperformance

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned May 28, 2026

npx -y skills add doccker/cc-use-exp --skill query-performance-safety --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Query Performance Safety?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Query Performance Safety
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/doccker-query-performance-safety/badge)](https://www.skillsdirectory.com/skills/doccker-query-performance-safety)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: query-performance-safety
description: 当代码涉及循环内查询、批量 ID 查询、IN 子句、BFS/递归遍历、嵌套 service 调用时触发。防止 N+1 查询、IN 子句过长、递归内存炸裂等性能陷阱。
---

# 查询性能安全规范

当代码涉及数据库或远程服务的批量查询时,防止 N+1、IN 子句过长、递归无界等高频性能陷阱。

> 与 `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。

### 检查清单

- [ ] service 方法里搜 `findById(` / `getOne(` / `getReferenceById(` / `findOne(`,是否在循环或 stream 链中
- [ ] 每个 list-aware 方法的 SQL 次数与列表大小是否解耦(理想是 O(1) 或 O(log N) 而非 O(N))
- [ ] enrichment 辅助方法是否被循环调用(隐式 N+1)
- [ ] 跨 service 调用(A.getDetail() → B.findX())是否在循环里

---

## 陷阱 #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);

    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 或直接删除,避免外部继续踩坑
```

### 检查清单

- [ ] 同一 controller 方法内是否多次调用同一底层重查询(grep 调用次数)
- [ ] 是否存在「`countXxx` + `listXxx`」并存且都跑完整查询
- [ ] 拆分子 service 时,被复用的底层查询是否上提为公共方法
- [ ] 标 `@Deprecated` 的旧方法是否在合理迭代内删除

---

## 陷阱 #5: 大表 WHERE / IN 缺失索引

**场景**: 新增 `findByXxx` 方法,但 xxx 列没有索引;或新增 `sourceUserId IN (...)` 聚合,但该列只被 SELECT 用过

### 问题根因

SUM/IN 1000 行也许还能撑,5000 行直接全表扫描,CPU 飙到 100%。索引在小表上看不出差异,生产数据规模下决定接口生死。

### 检查方法

每次新增 Repository 查询方法,对照实体类的 `@Index` 注解 + Flyway 迁移脚本:

```java
// ✅ 实体上声明索引
@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 { ... }
```

```sql
-- ✅ Flyway 显式建索引(实体注解不会自动建索引到已存在的表)
CREATE INDEX idx_pending_source_user ON points_pending (tenant_id, source_user_id);
```

### 检查清单

- [ ] 新增 `findByXxx` 时,xxx 列是否有索引(联合索引含 tenant_id)
- [ ] WHERE 条件命中索引(用 `EXPLAIN` 验证,不是猜)
- [ ] 范围查询字段放在联合索引最后(如 `idx (tenant_id, status, created_at)`)
- [ ] 软删除字段(`deleted_at`)需要时也要加入索引

---

## 陷阱 #6: 缓存 / 二级缓存反模式

**场景**: 用了 `@Cacheable` 但参数包含 `Pageable` 或大对象,导致 cache key 几乎不命中;或缓存了租户敏感数据但 key 没带 tenantId

### 错误示例

```java
// ❌ key 包含 Pageable,每次翻页都是新 key
@Cacheable("products")
public Page<Product> list(Pageable pageable, String keyword) { ... }

// ❌ 跨租户缓存污染
@Cacheable("user")
public User findById(Long id) { ... }
```

### 正确做法

```java
// ✅ 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 等不稳定参数)
- [ ] 多租户场景 key 是否包含 tenantId
- [ ] 写操作是否配套 `@CacheEvict`
- [ ] 缓存 TTL 是否与业务一致性要求匹配(强一致就别上缓存)

---

## 性能预算速查表

| 查询模式 | 可接受 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` - [章节名]
```

Files in this skill

  • SKILL.md12 KB
  • references/multi-lang-examples.md5.8 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.