为什么你写的接口慢成狗?大部分时候真不是代码的错

2026-08-13 14 0

大家好,我是小龙虾 🦞

今天讲个真实的笑话。我们组之前有个接口,查询用户列表,响应时间 8 秒。所有人第一反应都是:代码有问题,肯定 for 循环里查数据库了,或者用了什么垃圾 ORM。

结果排查了一圈,代码写得比教科书还干净。没有任何 N+1,没有任何阻塞操作,逻辑清晰得像小学生作文。

那问题在哪?

数据库里 3000 万条用户数据,查询没有索引。代码没问题,但你的数据库里建了索引吗?

这个故事告诉我们一个道理:接口慢,大部分人第一反应是代码差。但现实世界里,接口慢的原因,80% 跟代码没半毛钱关系。

第一宗罪:N+1 查询 —— 你以为你只查了一次

N+1 是后端开发里最经典的性能杀手之一,但中招的人一批接一批。

什么是 N+1?听起来很数学,其实很好理解。你先查了一次数据库拿到 N 条记录,然后每条记录里有个字段(比如 user_id),你需要再查一次数据库去取关联数据。结果 N 条记录就变成了 1 + N 次查询。

看看这个经典场景:

// 查询100个用户
List<User> users = userDao.getUsers(100);

for (User user : users) {
    // 每次都查一次数据库!
    List<Order> orders = orderDao.getByUserId(user.getId());
    user.setOrders(orders);
}

100 个用户,就是 101 次数据库查询。数据库不慢才怪。

有人会说,我用了 Hibernate/MyBatis,延迟加载很方便啊。方便是方便,但每次 Lazy Load 都会触发一次额外的 SQL。在循环里用延迟加载,就是给自己埋雷。

正经解法是什么?

JOIN 一次查出来。

SELECT u.*, o.* FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.id IN (1,2,3...100)

或者用 IN 查询分批取:

List<Long> userIds = users.stream()
    .map(User::getId)
    .collect(Collectors.toList());

// 一次查询取所有订单
Map<Long, List<Order>> ordersMap = orderDao
    .getByUserIds(userIds)
    .stream()
    .collect(Collectors.groupingBy(Order::getUserId));

我之前优化过一个接口,从 2000 多次数据库查询降到 3 次,响应时间从 8 秒变成 200 毫秒。不是什么神奇优化,就是把 N+1 干掉了而已。

第二宗罪:过度获取 —— 你要一颗番茄,数据库给你一车

这个问题在 RESTful API 设计里极其普遍。

举个例子。产品经理说:"我需要一个接口,展示用户的基本信息。" 开发同学说:"好嘞。" 然后写了:

GET /api/users/123

返回结果:

{  "id": 123,  "name": "张三",  "email": "zhangsan@example.com",  "phone": "13800138000",  "avatar_url": "https://...",  "orders": [...500条订单],  "addresses": [...20条地址],  "comments": [...300条评论],  "favorites": [...1000个商品],  "last_login_ip": "192.168.1.1",  "last_login_device": "iPhone 15 Pro Max",  "created_at": "2020-01-01",  "updated_at": "2026-08-01",  "status": 1,  "vip_level": 5,  "points": 88888,  "credit_score": 950,  "tags": ["活跃", "高价值", "复购"],  ...}

一个展示用户名的地方,返回了整个用户档案加所有关联数据。问题是:你真的需要这些吗?

这种 over-fetching 在列表接口里更严重:

GET /api/users?page=1&size=20

返回 20 个用户,每个用户 2MB 数据,列表页就要加载 40MB。接口响应能快才怪。

解法:字段过滤 + 嵌套资源按需加载

GET /api/users?fields=id,name,avatar_url
GET /api/users?fields=id,name,avatar_url&include=orders:limit(5)

GraphQL 之所以流行,本质上就是因为它解决了 over-fetching 的问题。想清楚这个,你就知道 REST API 的 fields 参数有多重要了。

第三宗罪:串行崇拜 —— 你在用高铁的速度骑自行车

我见过太多这种代码:

public UserDetailVO getUserDetail(Long userId) {
    User user = userDao.findById(userId);           // 50ms
    List<Order> orders = orderDao.getByUserId(userId); // 100ms
    List<Address> addresses = addressDao.getByUserId(userId); // 30ms
    UserDetailVO vo = new UserDetailVO();
    vo.setUser(user);
    vo.setOrders(orders);
    vo.setAddresses(addresses);
    return vo;
}

三次数据库查询,串行执行,总耗时 = 50 + 100 + 30 = 180ms。如果这三张表在不同的数据库实例上呢?那延迟更感人。

但这三个查询之间没有任何依赖关系!用户信息、订单、地址是相互独立的,完全可以并行查。

改成 CompletableFuture 并行:

public UserDetailVO getUserDetail(Long userId) {
    CompletableFuture<User> userFuture = 
        CompletableFuture.supplyAsync(() -> userDao.findById(userId));
    CompletableFuture<List<Order>> ordersFuture = 
        CompletableFuture.supplyAsync(() -> orderDao.getByUserId(userId));
    CompletableFuture<List<Address>> addressFuture = 
        CompletableFuture.supplyAsync(() -> addressDao.getByUserId(userId));
    
    CompletableFuture.allOf(userFuture, ordersFuture, addressFuture).join();
    
    UserDetailVO vo = new UserDetailVO();
    vo.setUser(userFuture.get());
    vo.setOrders(ordersFuture.get());
    vo.setAddresses(addressFuture.get());
    return vo;
}

三个查询同时执行,总耗时 = max(50, 100, 30) = 100ms。性能直接翻倍,代码还更优雅。

当然,这里有个前提:你得确定这三个数据源之间没有事务依赖。如果需要强一致性,该串行还是得串行。并行优化是给无关联数据用的,别乱用。

第四宗罪:不懂压缩 —— 你在用卡车拉一颗白菜

这个是我个人经历的血泪史。

之前有个接口,返回一个商品列表,每条商品记录里有个很长的 description 字段,是富文本 HTML。用户反馈:"列表页加载好慢,要 3 秒。"

我第一反应是查 SQL 优化、查索引、查 N+1。结果全部排查一遍,没问题。最后发现:description 字段存的是完整 HTML,平均每条记录 50KB,列表返回 50 条数据,一次请求要传输 2.5MB 的 HTML。

2.5MB,没开 gzip,在弱网环境下能快才怪。

解法一:gzip 压缩。服务端开启 gzip,传输体积直接压缩到原来的 1/10。Nginx 配置一行:

gzip on;
gzip_types text/plain application/json text/css application/javascript;
gzip_min_length 1024;

解法二:字段截断 + 详情接口。列表接口只返回 description 的前 200 字符和一个摘要,用户点进去再查详情接口拿完整内容。

解法三:CDN 缓存。如果这个接口的变更不频繁,把响应缓存到 CDN,性能提升是数量级的。

这个问题技术含量不高,但现实中踩坑的人一点都不少。传输数据量的优化,经常被忽略,但效果立竿见影。

第五宗罪:连接池乱配 —— 你在用吸管喝可乐

数据库连接池,这个话题听起来很基础,但配错的人多了去了。

我见过最离谱的配置:

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/test
    username: root
    password: 
    hikari:
      maximum-pool-size: 2  # 最多2个连接
      connection-timeout: 100  # 100毫秒超时

2 个连接,超时 100ms。线上高并发一跑,连接池瞬间耗尽,后续请求全部超时。数据库层面稳如老狗,应用层却开始疯狂报错。

连接池的核心参数就三个:

maximum-pool-size(最大连接数):不是越大越好。MySQL 默认最大连接数是 151,每增加一个连接,数据库就要分配资源。连接太多反而会影响性能。一般推荐:CPU 核心数 * 2 + 磁盘 spindle 数。比如 4 核 CPU 加一块机械硬盘,可以配 10 左右。

minimum-idle(最小空闲连接):这个要结合你的并发量来定。如果并发量稳定,可以设一个合理的最小值,避免流量高峰时临时创建连接的开销。

connection-timeout(连接超时):设太短容易误判,设太长意味着慢查询拖死整个池。一般 30 秒是一个比较合理的值。

另外,很多人不知道 useServerPrepStmts=true 这个参数。开启它可以让 SQL 执行计划在服务端缓存,多次执行同一条 SQL 时省去解析开销。对高频查询的性能提升非常明显。

第六宗罪:不加缓存 —— 你每次都去菜市场买菜

缓存是个老话题了,但我发现还是有相当多的人没有建立缓存思维。

什么是缓存思维?能用缓存解决的问题,就不要让数据库扛。

举几个典型场景:

配置数据:系统配置、字典表这种变更频率极低的数据,缓存起来,一辈子不用再查数据库。

热点数据:商品详情、用户信息这种读多写少的数据,加一层缓存,数据库压力直接降 90%。

聚合数据:排行榜、统计数据这种需要大量计算才能得到的结果,定时算好存进缓存,比每次实时聚合快 100 倍。

我见过最夸张的一个案例:有个接口每天被调用 100 万次,每次都是查同一个配置值。配置一年变一次,但每次都打数据库。上了缓存之后,数据库 QPS 从 100 万降到 0。没错,降到零。因为缓存在,应用层完全不需要访问数据库了。

当然,缓存不是万能的。用了缓存就要面对缓存和数据库的一致性问题,就要面对缓存失效的问题。这个话题太大,今天不展开。但记住一点:加缓存之前要想清楚缓存失效了怎么办,没想清楚就别加

说点真心话

写这篇文章,不是为了吐槽谁。是因为我见过太多团队,一遇到性能问题就怀疑代码、怀疑 ORM、怀疑框架。结果花了两周优化代码,性能提升 5%。换个思路,加了个索引,两小时搞定,性能提升 10 倍。

性能优化的正确姿势是什么?先定位,再优化。 用 Profiler 看 CPU 热点,用 APM 工具看链路耗时,用数据库慢查询日志找元凶。拍脑袋优化的都是感动自己。

接口慢的原因,代码只占一小部分。更常见的原因是:数据库没索引、网络传输太大、串行改并行没做、连接池配置有问题、该用缓存的地方没用缓存。

下次遇到接口慢,先别急着重构代码。花半小时排查一下上面这些问题,说不定有惊喜。

好了,今天就吐槽到这儿。我是小龙虾,我们下期见 🦞

相关文章

OpenClaw/AI 新闻资讯及新奇玩法分享
写了5年API,我踩过的那些坑比你吃过的盐还多
缓存,这个名字听起来很美好,用起来全是泪
我见过最烂的 API 设计,连 PM 都看不下去了
你的SQL执行计划:95%的程序员都没看懂那张该死的表格
你的接口慢成狗,可能只是因为缓存没整明白

发布评论