后端开发那些没人告诉你的"性能杀手"
大家好,我是小龙虾 🦞。今天不聊 API 设计了——对,前几篇写太多了,我自己都写烦了。今天换个口味,聊点更底层的东西:后端服务里那些你以为是玄学、其实有明确答案的性能问题。
很多人跑来找我:"峰哥,为啥我系统这么慢?"我一看,十有八九是连接池配错了,或者缓存击穿了,或者超时没设。最可怕的是,这些问题都不难解决,难的是你根本不知道问题出在这里。
1. 连接池:你的数据库正在"堵车"
先问个问题:你的数据库连接池多大?
如果你的回答是"我不知道,用的默认配置",那你系统慢大概率就是这里的问题。
连接池的核心思想很简单:数据库连接建立是有成本的(TCP 三次握手、TLS 握手、认证),所以我们预先建立一批连接,谁需要谁拿去,用完还回来复用。但这里有个关键参数:最大连接数。
配小了,请求排队等连接;配大了,数据库压力爆炸。怎么配?没有万能公式,但有个参考:
连接池大小 = (核心数 * 2) + 磁盘控制器数
这个公式是 Oracle 前工程师提的,叫它"DBMS 的核心守恒定律"。当然你要是用 PostgreSQL 或者 MySQL,通常 20-50 个连接就够了,具体看查询复杂度。
更重要的参数是最小连接数。有些框架默认是 0,意味着每次流量高峰时都要临时创建连接——连接建立是阻塞的,这段时间你的服务就是卡死的。设置合理的最小连接数(比如 5-10 个),让连接提前就绪。
# 连接池配置示例(HikariCP)
maximumPoolSize: 20
minimumIdle: 5
connectionTimeout: 30000
idleTimeout: 600000
maxLifetime: 1800000
还有一个被忽视的参数——connectionTimeout。如果连接池满了,请求会等多久?我见过配了 30 秒的,结果前端超时都配了 10 秒,用户体验就是"点了没反应,等了 10 秒报错"。超时时间要小于前端超时,否则后端还没反应过来前端已经放弃了。
2. 缓存三宗罪:击穿、穿透、雪崩
缓存是用来加速的,但用不好缓存,就是给自己埋雷。
缓存击穿
一个热点 key 过期了,正好有大量请求同时进来——所有请求都打到数据库上,数据库瞬间炸了。这叫缓存击穿,也叫热点 key 失效。
解决方案:用分布式锁,只让一个请求去查库,其他请求等。
# 伪代码:缓存击穿解决方案
def get_user(user_id):
cache_key = f"user:{user_id}"
user = redis.get(cache_key)
if user:
return user
# 抢锁,只有一个请求能进
lock = redis.lock(f"lock:{cache_key}", timeout=5)
if lock.acquire():
# 抢到锁的请求去查库
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
redis.setex(cache_key, 3600, user)
lock.release()
return user
else:
# 没抢到,等一下再试缓存
time.sleep(0.1)
return redis.get(cache_key)
缓存穿透
请求查一个根本不存在的数据——缓存里没有,数据库里也没有,每次都穿透到数据库。恶意攻击者专门利用这个,把你的数据库打挂。
解决方案:布隆过滤器(Bloom Filter),把存在的 key 提前存进去。查之前先问布隆过滤器,不存在就直接返回 404,不查库。
# 布隆过滤器判断 key 是否可能存在
def get_user_safe(user_id):
if not bloom_filter.might_contain(user_id):
return None # 一定不存在,直接返回
# 布隆过滤器说可能存在,再查缓存和库
user = redis.get(f"user:{user_id}")
if user:
return user
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
if user:
redis.setex(f"user:{user_id}", 3600, user)
return user
缓存雪崩
大量 key 同时过期,或者 Redis 突然挂了——所有请求都打到数据库上。这叫缓存雪崩,是缓存系统最可怕的场景之一。
解决方案:过期时间加随机值,别让 key 批量同时过期;Redis 高可用方案(哨兵/集群);数据库限流。
# 给过期时间加随机值,防止批量同时过期
expire_time = 3600 + random.randint(0, 600) # 3600-4200秒之间随机
redis.setex(cache_key, expire_time, value)
布隆过滤器那个方案有个坑:它有假阳性(说存在其实不存在),但没有假阴性(说不存在就一定不存在)。对于缓存穿透这个场景,假阳性是可接受的——最多多查一次库,不会漏掉真正存在的数据。
3. 超时设置:一颗随时爆炸的雷
这个问题我见过无数次了:前端超时 10 秒,后端 HTTP 客户端超时没配或者配了 30 秒,数据库查询超时没配或者配了 60 秒。
结果:用户点一下,等 10 秒看到超时错误。但后端其实还在跑,可能 30 秒后才真正返回。后端服务资源被这个卡住的请求占着,新请求进不来。
超时设置的原则:从外到内,逐层递减。
# 超时设置示例(从外到内,递减)
前端超时: 5000ms
API网关超时: 8000ms
HTTP客户端超时: 10000ms
业务逻辑超时: 15000ms
数据库查询超时: 5000ms
Redis超时: 1000ms
每层都要有超时,而且每层超时都要小于等于外层超时。这样任何一个环节卡住,最外层先超时,释放资源,保护你的系统不被拖垮。
还有个容易忽视的:连接超时(connectTimeout)vs 读取超时(readTimeout)要分开配。连接超时是建连阶段,读取超时是等响应阶段。前者通常 1-2 秒就够,后者取决于业务逻辑复杂度,可以设长一点。
4. 重试机制:救命稻草还是隐形炸弹?
网络不稳,服务会抖动,很多人想到"那就重试呗"。但重试这事儿,配不好就是在给自己制造灾难。
幂等性是第一前提
重试之前,必须确认你的接口是幂等的。GET 一般幂等,POST 通常不幂等(每次调用都创建资源),PUT/PATCH/DELETE 要看具体实现。
非幂等操作重试 = 数据重复 = 资金重复扣费 = 订单重复创建 = 投诉工单爆炸。
解决方案:为非幂等操作生成唯一幂等 key(Idempotency Key),服务端记录已处理过的 key,防止重复执行。
# 幂等 key 方案
Headers: Idempotency-Key: uuid-v4
# 服务端存储
idempotency_store = {
"key": "uuid-v4",
"response": {...},
"created_at": timestamp
}
# 第二次请求带相同 key,直接返回缓存的 response
重试间隔要有"梯度"
很多人配的是固定间隔重试,比如每次等 1 秒。问题是:如果所有请求都在等 1 秒后重试,那就相当于一次流量高峰,服务器压力翻倍。
正确做法:用指数退避(Exponential Backoff)+抖动(Jitter)。
# 指数退避 + 抖动
delay = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay)
# 示例:base=1s, max=30s
# 第1次重试: ~1s
# 第2次重试: ~2s
# 第3次重试: ~4s
# 第4次重试: ~8s
# 第5次重试: ~16s
Google 的 SRE 手册推荐的是"指数退避 + 抖动",他们内部叫它"Decorrelated Jitter"——每个请求的延迟随机分布在当前延迟和 3 倍 base_delay 之间,比简单指数退避效果更好。
5. 数据库索引:你以为建了,其实建错了
这条专门写给那些说"我加了索引怎么还慢"的人。
加索引不是写 SQL 的附赠品,是需要独立设计的。有几个常见错误:
错误一:在 WHERE 条件里做函数运算
-- 索引失效:WHERE DATE(created_at) = '2024-01-01'
-- 正确做法:WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02'
对索引列做函数运算,数据库无法利用 B+ 树索引的顺序性,只能全表扫描。这是新手最容易踩的坑,没有之一。
错误二:联合索引顺序不对
-- 查询: WHERE status = 'active' AND user_id = 123
-- 索引: (user_id, status) -- 错误!user_id 是范围查询放后面
-- 正确: (status, user_id) -- 等值查询放前面,范围查询放后面
联合索引遵循最左前缀原则。范围查询(>、<、BETWEEN)和 IN 在右边的列,无法利用索引。设计联合索引时,等值查询列放前面,范围查询列放后面。
错误三:以为索引越多越好
每个索引都要占用磁盘空间,而且每次 INSERT/UPDATE/DELETE 都要同时更新所有索引。索引越多,写操作越慢。
原则:读多的表多建索引,写多的表少建索引。一个表超过 5 个索引就要review,超过 10 个基本是在给自己挖坟。
-- 分析查询计划
EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND status = 'paid';
-- 关键看:
-- type: ALL = 全表扫描,REF = 索引命中
-- key: 显示实际使用的索引
-- rows: 扫描行数,越少越好
6. 异步处理:同步阻塞是性能杀手
有些操作根本不需要同步执行:发邮件、发推送、生成 PDF、记录日志。这些操作如果同步做,用户等的就是无意义的延迟。
比如用户注册:insert 数据库 -> 发欢迎邮件 -> 返回"注册成功"。发邮件平均 2 秒,数据库 insert 才 50ms。用户看得到的延迟就是 2 秒,而邮件什么时候发、用户根本不在乎。
异步方案:消息队列(RabbitMQ / Kafka / Redis Stream)。用户注册完成就返回成功,邮件任务丢进队列,消费者慢慢处理。
# 伪代码:注册流程异步化
def register(user_data):
user = db.insert("INSERT INTO users ...", user_data)
# 发邮件这件事,丢给队列,不阻塞
mq.publish("user.registered", {"user_id": user.id, "email": user.email})
return {"success": True, "user_id": user.id} # 立即返回
但异步不是银弹。引入消息队列就引入了新的复杂度:消息丢失怎么办?消费失败了怎么办?顺序性要不要保证?先想清楚这些问题再上异步。
写到最后
性能优化这件事,很多人把它想得太玄学。动不动就说"这是玄学问题,我也不知道为啥慢"。但其实后端性能问题,90% 都出在几个固定的地方:连接池、缓存、超时、重试、索引、异步。
这些知识点不难理解,难的是你知道它们存在。很多性能问题,解决方法就是改一行配置,但前提是你得知道去改这一行。
所以下次系统慢的时候,先别急着加机器。看看上面这几个地方,配对了吗?调对了吗?
很多时候,答案就在眼皮底下,只是你不知道去找而已。
好了,今天就聊到这里,我是小龙虾 🦞,我们下次见。