4、高性能查询接口:分页查询设计、过滤与排序、字段选择与投影、避免N+1查询

大屏数据接口,说白了就是跟时间赛跑。

用户盯着屏幕,数据如果转半天不出来,那体验就全毁了。我这些年调过不少大屏接口,发现性能瓶颈往往不在数据库本身,而在查询设计上。今天咱们就聊聊怎么把查询接口做到极致。

分页查询设计:别让用户等太久

大屏数据动辄几十万条,你不可能一次全查出来。分页是基本功,但怎么分页有讲究。

传统 limit/offset 的问题

很多同学习惯这么写:

SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 100000;

嗯,这里要注意。offset 越大,数据库需要扫描的行数就越多。offset 100000 意味着数据库要先扫 100020 行,再扔掉前 100000 行。我在项目中遇到过,翻到第 500 页时接口直接超时了。

游标分页(Cursor-based Pagination)

我个人习惯用游标分页。原理很简单:记住上一页最后一条记录的 ID,下一页直接从它后面开始查。

-- 第一页
SELECT * FROM orders ORDER BY id LIMIT 20;

-- 第二页,假设上一页最后一条 id = 100
SELECT * FROM orders WHERE id > 100 ORDER BY id LIMIT 20;

这样不管翻到多少页,查询都只扫描 20 行。性能稳定得一匹。

核心要点:游标分页适合大数据量场景,但要求排序字段唯一且递增。如果按时间排序,可以用时间戳作为游标。

小技巧:前端传参时,把游标值加密一下。不然用户能猜到你的数据量,有安全风险。

过滤与排序:让数据库干它该干的活

过滤和排序,很多人喜欢在代码里做。你想想看,数据查出来到应用层再过滤,那数据库的索引不就白建了吗?

过滤条件下推

把过滤条件尽量写在 SQL 里,让数据库用索引去筛选。我曾经接手过一个项目,代码里用 Java 的 stream 做过滤,查了 10 万条数据只取 100 条。嗯,那接口响应时间可想而知。

-- 错误做法:查全部再过滤
SELECT * FROM orders;
// 然后在代码里 filter(status == "PAID")

-- 正确做法:让数据库过滤
SELECT * FROM orders WHERE status = 'PAID';

排序的索引利用

排序字段一定要有索引。没有索引的话,数据库会做 filesort,数据量一大就完蛋。

-- 建联合索引,覆盖过滤和排序
CREATE INDEX idx_status_create_time ON orders(status, create_time);

-- 查询时排序字段和索引顺序一致
SELECT * FROM orders 
WHERE status = 'PAID' 
ORDER BY create_time DESC 
LIMIT 20;

避坑指南:我曾经在排序字段上没建索引,结果大屏加载时数据库 CPU 直接飙到 100%。后来加了索引,响应时间从 8 秒降到了 0.2 秒。索引这东西,真不能省。

字段选择与投影:别查不需要的列

很多同学写 SQL 习惯用 SELECT *。在大屏场景下,这简直是灾难。

你想想看,大屏上可能只显示 5 个字段,但你查了 30 个字段回来。网络传输时间、内存占用、序列化开销,全都被放大了。

投影查询

只查需要的字段,这叫投影。我建议在接口设计时就明确返回哪些字段,不要偷懒。

-- 错误做法
SELECT * FROM orders WHERE status = 'PAID';

-- 正确做法
SELECT id, order_no, amount, create_time 
FROM orders 
WHERE status = 'PAID';

动态字段选择

有些大屏支持用户自定义显示字段,这时候可以用参数控制返回哪些列。但要注意,别让用户传字段名直接拼 SQL,会有 SQL 注入风险。

// 安全做法:白名单校验
Set<String> allowedFields = Set.of("id", "order_no", "amount");
String fields = request.getParameter("fields");
// 只从白名单里取字段拼接

数据说话:我曾经把一个接口从 SELECT * 改成只查 5 个字段,响应时间从 1.2 秒降到了 0.3 秒。网络传输量减少了 80%。

避免 N+1 查询:一个循环引发的血案

N+1 查询,说白了就是先查主表,再循环查子表。这是性能杀手,没有之一。

问题场景

假设你要查所有订单及其商品明细:

-- 第一步:查订单列表(1 次查询)
SELECT * FROM orders WHERE status = 'PAID';

-- 第二步:循环查每个订单的商品(N 次查询)
for (Order order : orders) {
    SELECT * FROM order_items WHERE order_id = ?;
}

如果有 1000 个订单,就要查 1001 次数据库。嗯,这接口不慢才怪。

解决方案:批量查询

把 N 次查询合并成 1 次,用 IN 查询搞定。

-- 第一步:查订单列表
SELECT * FROM orders WHERE status = 'PAID';

-- 第二步:收集所有订单 ID,一次查完
SELECT * FROM order_items 
WHERE order_id IN (1001, 1002, 1003, ...);

-- 第三步:在代码里做关联
Map<Long, List<OrderItem>> itemMap = items.stream()
    .collect(Collectors.groupingBy(OrderItem::getOrderId));

ORM 框架的坑

用 JPA 或 Hibernate 的同学要特别注意。懒加载(Lazy Loading)很容易引发 N+1。我建议在查询时直接用 @EntityGraphJOIN FETCH 提前加载关联数据。

@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
List<Order> findOrdersWithItems(@Param("status") String status);

避坑指南:我曾经在一个报表接口里用了 JPA 的懒加载,结果页面加载时发了 2000 多次 SQL。后来改成 JOIN FETCH,一次查询搞定,响应时间从 15 秒降到了 0.5 秒。ORM 虽好,但别滥用懒加载。

总结一下

高性能查询接口,核心就四点:

  • 分页:大数据量用游标分页,别用 offset
  • 过滤排序:条件下推给数据库,排序字段建索引
  • 字段选择:只查需要的列,别用 SELECT *
  • 避免 N+1:批量查询代替循环查询

这些说起来简单,但真正落地时细节很多。我建议你在项目里先抓一个慢接口做优化,把响应时间降下来,那种成就感是很爽的。

下一章咱们聊聊缓存策略,到时候会讲怎么用 Redis 给大屏接口加速。嗯,敬请期待。