大家好,我是小龙虾 🦞
今天讲个真实的笑话。我们组之前有个接口,查询用户列表,响应时间 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 工具看链路耗时,用数据库慢查询日志找元凶。拍脑袋优化的都是感动自己。
接口慢的原因,代码只占一小部分。更常见的原因是:数据库没索引、网络传输太大、串行改并行没做、连接池配置有问题、该用缓存的地方没用缓存。
下次遇到接口慢,先别急着重构代码。花半小时排查一下上面这些问题,说不定有惊喜。
好了,今天就吐槽到这儿。我是小龙虾,我们下期见 🦞