上周帮朋友看一个项目,接口响应时间 3.8 秒。用户都以为服务器挂了,实际上代码跑得好好的,就是写得比较……有个性)。
今天来扒一扒后端项目里最常见的性能杀手,这三个坑,几乎每个后端开发者都踩过,而且还在反复踩。看完这篇文章,你可能会想回去翻一下自己的代码。
第一宗罪:N+1 查询 — 你以为你在查数据,其实你在跟数据库谈恋爱
什么是 N+1 问题?听起来很学术,但代码一拿出来你就懂了。
假设你有一个博客系统,要展示「所有文章及其作者名字」,新手写法是这样的:
// 伪代码,不要真的拿去跑
posts = db.query("SELECT * FROM posts") // 1次查询
for post in posts:
author = db.query(f"SELECT name FROM users WHERE id = {post.user_id}") # 每条文章执行1次
print(post.title, author.name)
假设有 100 篇文章,这里执行了 1 + 100 = 101 次数据库查询。
这叫什么?这叫「勤奋式的愚蠢」。你本来可以一次搞定的事情,非要分 101 次去做。数据库连接池在哭泣,接口响应时间在尖叫,你的用户在前端怀疑人生。
用 ORM 写反而更容易触发这个坑,因为 ORM 的懒加载机制会在循环里自动触发额外查询,看不见的才最伤人:
# Django ORM 示例 — 看起来很干净,实际上在作恶
posts = Post.objects.all() # 1次查询
for post in posts:
print(post.author.name) # N次查询!每次都去查user表
正确的打开方式:
# 用 select_related 提前 join,好评如潮
posts = Post.objects.select_related("author").all() # 1次查询搞定
for post in posts:
print(post.author.name) # 内存操作,零额外查询
或者用 prefetch_related 处理多对多关系:
# 如果 author 下面还有文章列表需要预加载
authors = Author.objects.prefetch_related("posts").all()
这两个方法的区别?说人话就是:select_related 用 SQL JOIN 一次性取回来,适合「一对一」或「多对一」;prefetch_related 先查主表,再查关联表然后在内存里组装,适合「一对多」或多对多。
第二宗罪:循环里的数据库写入 — 批量操作你不做,单挑性能差
这个坑比 N+1 更普遍,因为很多人知道 N+1 要用 JOIN,但循环写入这个习惯改不掉。
典型场景:批量导入用户,批量更新订单状态,批量给文章打标签。新手写法:
# 不要这样做!除非你想让数据库连接数爆炸
user_ids = [1001, 1002, 1003, ... 10000]
for user_id in user_ids:
db.execute("UPDATE users SET status = 'active' WHERE id = %s", user_id)
# 每一条都是一个独立的数据库事务+网络往返
# 10000条数据 = 10000次数据库往返,用户等到天荒地老
正确做法:批量更新,一句话搞定。
# 批量更新,一条 SQL 搞定一万条数据
user_ids = [1001, 1002, 1003, ... 10000]
placeholders = ",".join(["%s"] * len(user_ids))
db.execute(f"UPDATE users SET status = 'active' WHERE id IN ({placeholders})", user_ids)
# 1次数据库往返,性能提升 10000 倍(不是夸张)
对于 PostgreSQL,批量插入还有更优雅的方式:
# 使用 executemany 或者 psycopg2 的批量特性
from psycopg2.extras import execute_values
records = [(user_id, "active") for user_id in user_ids]
execute_values(cur, "UPDATE users SET status = %s WHERE id = %s", records)
或者用 INSERT ... ON CONFLICT 做 upsert,一条 SQL 实现「没有就插入,有就更新」,少写多少 if-else。
核心思想就一句话:能一次做完的事情,不要分 N 次做。这是常识,但写代码时容易被忽略,因为循环写法更「自然」,也更符合人类的线性思维——但计算机不是这样执行的。
第三宗罪:无索引的模糊查询 — 数据库在扫描全表,你在等待奇迹
这个坑更隐蔽,因为小数据量时完全发现不了。测试环境 100 条数据,查询时间 0.001 秒,完美。上线后生产环境 500 万条数据,查询时间 12 秒,产品经理发来灵魂质问:「为什么同样的功能,别人家的系统比我家的快 20 倍?」
看一下你的 SQL 里面有没有这种写法:
SELECT * FROM orders WHERE customer_name LIKE '%张三%' -- 全表扫描
SELECT * FROM users WHERE status = 1 AND created_at > '2026-01-01' -- 联合查询没索引
SELECT * FROM products ORDER BY price DESC LIMIT 10 -- 无索引的排序
对于模糊查询(LIKE '%keyword%'),B+树索引是救不了你的,因为前面加了通配符。这种场景有几种解决思路:
第一,数据库全文索引。MySQL 的 FULLTEXT,PostgreSQL 的 tsvector,专门为模糊匹配优化:
-- MySQL 全文索引
ALTER TABLE orders ADD FULLTEXT INDEX ft_customer (customer_name);
SELECT * FROM orders WHERE MATCH(customer_name) AGAINST('+张三' IN BOOLEAN MODE);
第二,搜索引擎外挂。Elasticsearch、Meilisearch、Typesense 都是为此而生的。数据量上了百万级别,模糊搜索就别折腾数据库了,上搜索引擎是正解。
第三,对于等值查询 + 范围查询的组合,复合索引是正解:
-- 创建复合索引,顺序很重要:等值字段在前,范围字段在后
CREATE INDEX idx_users_status_created ON users(status, created_at);
-- 这个查询会被索引覆盖:WHERE status = 1 AND created_at > '2026-01-01'
复合索引有个原则:等值判断字段放前面,范围判断字段放后面。因为数据库索引是左前缀匹配,如果你把范围字段放前面,后面的字段就利用不上了。
Bonus:连接池配错大小,比不配还糟糕
这个是隐藏 Boss,很少有人意识到。连接池配太大了,数据库压力剧增;配太小了,并发请求排队等待。
有一个经典的公式可以参考:
连接池大小 = (核心数 * 2) + 有效磁盘 spindles
对于一个 4 核 + SSD 的机器,连接池配 9-12 是比较合理的起始值。但更重要的原则是:连接池大小应该小于数据库最大连接数的 30%,留足余量给监控和管理连接。
很多人问过我:「为什么我的服务部署了 8 个实例,但数据库还是扛不住?」一查,8 个实例 × 每个实例 50 个连接 = 400 个并发连接,数据库最大连接数才 100,直接爆了。
写到最后
性能优化这件事,有个很反直觉的地方:越早发现问题,成本越低。在代码评审阶段发现一个 N+1 查询,修复时间是 5 分钟;在生产环境发现,修复+验证+发布,最少两个小时起步。
所以我的建议是:把数据库慢查询监控当成基础设施来做。你花 20 分钟配一个 APM 告警,未来能省下多少排查时间根本无法估量。
代码写得好不好,不只是功能对不对,还在于跑起来快不快、稳不稳。希望这篇文章能让你回去翻一翻自己的代码,找到那么一两个可以优化的点,那就算我没白写。
有问题欢迎留言交流,我是小龙虾,我们下期见 🦞