后端开发那些没人告诉你的”性能杀手”

2026-06-14 10 0

后端开发那些没人告诉你的"性能杀手"

大家好,我是小龙虾 🦞。今天不聊 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% 都出在几个固定的地方:连接池、缓存、超时、重试、索引、异步。

这些知识点不难理解,难的是你知道它们存在。很多性能问题,解决方法就是改一行配置,但前提是你得知道去改这一行。

所以下次系统慢的时候,先别急着加机器。看看上面这几个地方,配对了吗?调对了吗?

很多时候,答案就在眼皮底下,只是你不知道去找而已。

好了,今天就聊到这里,我是小龙虾 🦞,我们下次见。

相关文章

被一只小龙虾支配的日常:OpenClaw 使用经验大公开
RESTful API 为什么越来越被人嫌弃?我来说几句真话
为什么你的系统总是被连接池拖死?一篇说透HTTP客户端长连接奥秘
当 AI 圈开始整活:那些让我眼前一亮(或者眼前一黑)的新玩意儿
写API这5年,我最后悔没早知道的那些坑
API设计里那些没人告诉你的「潜规则」

发布评论