8.2 查询优化

R2DBC 查询优化全面实践:索引设计原则与覆盖索引策略、EXPLAIN ANALYZE 计划解读与共享缓冲命中分析、响应式 N+1 问题的 JOIN/IN/Buffer 批量解决方案、Flux.flatMap 并发度与连接池配比、游标分页与 OFFSET 分页对比。

索引设计

数据库索引是查询性能的第一道防线。eventd v2 的关键索引:

-- 单字段索引(等值查询)
CREATE INDEX idx_orders_user_id ON orders(user_id);

-- 复合索引:等值列在前,范围/排序列在后
CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC);

-- 部分索引(只索引活跃订单,减少索引大小)
CREATE INDEX idx_orders_pending ON orders(created_at)
    WHERE status = 'PENDING';

-- 表达式索引(JSONB 字段查询)
CREATE INDEX idx_events_payload_type ON events((payload->>'type'));

索引设计原则:

  1. WHERE 子句中的字段优先建索引,但低选择性的列单独建索引通常没用
  2. 复合索引字段顺序:先放等值条件列,再放范围或排序列。(status, created_at) 能同时服务 status = ? 和 ORDER BY created_at;反过来 (created_at, status) 对这类查询帮助有限。「区分度高的放前面」只在多个等值列之间作为次要参考
  3. 覆盖索引:查询字段都在索引中,避免回表
  4. 不要过度索引:写操作(INSERT/UPDATE)需要维护索引

EXPLAIN ANALYZE

PostgreSQL 的查询计划分析:

EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT * FROM orders
WHERE user_id = 1 AND status = 'COMPLETED'
ORDER BY created_at DESC
LIMIT 10;

输出解读:

[
  {
    "Plan": {
      "Node Type": "Index Scan",
      "Index Name": "idx_orders_user_id",
      "Actual Rows": 10,
      "Actual Loops": 1,
      "Actual Startup Time": 0.031,
      "Actual Total Time": 0.052,
      "Shared Hit Blocks": 4,
      "Shared Read Blocks": 0
    },
    "Planning Time": 0.084,
    "Execution Time": 0.076
  }
]

FORMAT JSON 的最外层是数组,指标在 Plan 里;顶层另有 Planning Time、Execution Time。

指标含义怎么判断
Actual Total Time该节点累计执行时间(毫秒)看占比最大的节点,绝对值要和查询目标比较
Actual Startup Time返回第一行前的时间与 Total 差距大说明在等数据
Shared Hit Blocks从共享缓冲池命中的块数高说明走内存,不代表索引一定好
Shared Read Blocks从磁盘读取的块数高说明发生物理 I/O
Rows Removed by Filter扫描后过滤掉的行数远大于 Actual Rows 说明索引选择差

如果看到 Seq Scan(全表扫描)但查询有 WHERE 条件,说明缺少索引或索引未命中。

N+1 问题

N+1 是 ORM/Repository 的常见病:

// 先查 1 次用户列表
userRepo.findByStatus("ACTIVE")              // 1 query
    .flatMap(user ->
        // 每个用户再查订单:N queries
        orderRepo.findByUserId(user.id())    // N queries
    )
    .collectList()
    .subscribe();

如果有 100 个活跃用户,会执行 101 次数据库查询。

解决方案 A:JOIN

@Query("""
    SELECT u.id AS user_id, u.name, o.id AS order_id, o.symbol
    FROM users u
    LEFT JOIN orders o ON u.id = o.user_id
    WHERE u.status = :status
    """)
Flux<UserOrderRow> findUserOrderRows(@Param("status") String status);

// 应用层把扁平行按用户聚合成对象
userRepo.findUserOrderRows("ACTIVE")
    .collectMultimap(UserOrderRow::userId)
    .subscribe(byUser -> { ... });

一次查询返回所有数据,应用层组装。返回类型必须是扁平行(一行一个用户-订单组合),不能直接声明成 UserWithOrders:Spring Data R2DBC 不会替你把多行折叠进同一个对象的集合字段。缺点是返回数据有冗余(用户字段重复)。

解决方案 B:批量 IN 查询

userRepo.findByStatus("ACTIVE")
    .collectList()
    .flatMap(users -> {
        List<Long> userIds = users.stream().map(User::id).toList();
        return orderRepo.findByUserIdIn(userIds)
            .collectMultimap(Order::userId)
            .map(ordersMap -> users.stream()
                .map(u -> new UserWithOrders(u,
                    List.copyOf(ordersMap.getOrDefault(u.id(), List.of()))))
                .toList());
    });

先收集所有用户 ID,再执行一次 IN 查询。总共 2 次查询。

解决方案 C:响应式批量(推荐)

userRepo.findByStatus("ACTIVE")
    .buffer(50)  // 每 50 个用户一批
    .flatMap(batch -> {
        List<Long> ids = batch.stream().map(User::id).toList();
        return orderRepo.findByUserIdIn(ids)
            .collectMultimap(Order::userId)
            .map(orders -> batch.stream()
                .map(u -> new UserWithOrders(u,
                    List.copyOf(orders.getOrDefault(u.id(), List.of()))))
                .toList());
    })
    .flatMapIterable(list -> list)  // List<UserWithOrders> → Flux<UserWithOrders>
    .subscribe();

buffer(50) 控制批量大小。PostgreSQL 单条语句最多接受 65535 个绑定参数(协议里参数个数是 16 位),而且 IN 列表过长会让计划膨胀、内存占用上升;分批是同时控制这两件事,不是为了绕开 32767 这个并不存在的上限。

flatMap 并发度与连接池

orderRepo.findByStatus("PENDING")
    .flatMap(order -> processOrder(order), 10)  // 并发度 10
    .subscribe();

并发度 10 意味着同时处理 10 条订单。如果 processOrder 涉及数据库写操作,需要确保连接池有足够连接:

ConnectionPool pool = new ConnectionPool(ConnectionPoolConfiguration.builder(factory)
    .maxSize(20)  // ≥ flatMap 并发度 + 其他路径的预留
    .build());

注意这不是 Reactor Netty 的 ConnectionProvider——那是 HTTP 客户端用的;R2DBC 连接池是 io.r2dbc.pool.ConnectionPool,两者不要混。池大小至少要覆盖 flatMap 并发度,再加其他查询和健康检查的余量;具体设多少取决于数据库能承受的连接数和压测结果,不存在 并发度 × 2 + 10 这种通用公式。

分页:OFFSET vs 游标

OFFSET 分页(简单但不稳定)

PageRequest page = PageRequest.of(100, 20);  // index 0-based:第 101 页,每页 20 条
orderRepo.findByStatus("PENDING", page);

生成 LIMIT 20 OFFSET 2000。问题:

  • 数据变化时,同一页可能重复或遗漏
  • OFFSET 大时性能差(需要扫描前 N 条)

游标分页(稳定高效)

@Query("""
    SELECT * FROM orders
    WHERE status = :status AND id > :lastId
    ORDER BY id
    LIMIT :pageSize
    """)
Flux<Order> findByStatusWithCursor(
    @Param("status") String status,
    @Param("lastId") Long lastId,
    @Param("pageSize") int pageSize
);

// 使用
Long lastId = 0L;
do {
    List<Order> page = orderRepo.findByStatusWithCursor("PENDING", lastId, 20)
        .collectList().block();
    // 处理 page
    lastId = page.isEmpty() ? null : page.get(page.size() - 1).id();
} while (lastId != null);

游标分页使用索引直接定位,O(log n),不随页数增长而变慢。适合无限滚动、数据导出等场景。上面的 block() 只适合离线批处理;在服务端接口里应把循环写成递归的 Flux.expand 或 Flux.generate,保持整条链非阻塞。

写入优化:批量插入

R2DBC Statement 的 add() 可以批量写入:

Statement stmt = conn.createStatement(
    "INSERT INTO events (type, payload) VALUES ($1, $2)");

for (Event event : events) {
    stmt.bind("$1", event.type())
        .bind("$2", event.payload())
        .add();
}

// 每个 add() 对应一个 Result,必须用 Flux.from 消费全部结果
Flux.from(stmt.execute())
    .flatMap(Result::getRowsUpdated)
    .reduce(0L, Long::sum);

省掉的主要是网络往返:一次发送多条绑定值,而不是每条一次往返。能快多少取决于单条 SQL 的延迟、批量大小和事务边界,必须用自己的数据测;「快 10–50 倍」不是可以照搬的常数。另外 add() 仍是多条独立的 INSERT,不会合并成 PostgreSQL 的 COPY 或单条多值 INSERT。

小结

优化手段方案效果
索引CREATE INDEX + 复合索引避免全表扫描
计划分析EXPLAIN (ANALYZE)定位慢查询原因
N+1JOIN / IN 查询 / buffer + flatMap减少查询次数
并发度flatMap(fn, concurrency)匹配连接池容量
分页游标(id > :lastId)稳定 + O(log n)
批量写入Statement.add()减少网络往返

下一节 8.3 连接健康检查 :连接池 metrics、健康端点、自动重连与故障检测。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南