高性能
开篇:什么是"高性能"?
去迪士尼玩过的朋友一定有这个体验:每个项目入口处都有一块小牌子,写着"预计等候时间 45 分钟"。你看到这个时间就会心里盘算 -- 45 分钟可以接受吗?要不要换个项目?
对于一个网站来说,用户做的判断和这一模一样。页面加载超过 3 秒,一半的用户就会离开。后端接口响应超过 1 秒,前端体验就会明显变差。
高性能的本质,就是让系统用最短的时间、最少的资源,处理尽可能多的请求。
要聊高性能,我们得先把几个核心指标搞清楚。
一、性能指标详解
1.1 RT -- 响应时间
RT(Response Time)是指从客户端发出请求到收到响应的总时间。当我们说一个网站"卡"或者"慢",说的就是它的 RT 太长了。
迪士尼是怎么计算排队时间的?其实很有意思:
- 入口处的工作人员随机给几位游客发一张小纸条,记录开始排队的时间
- 提醒游客玩完后在出口处把纸条交还给出口的工作人员
- 出口工作人员用"当前时间 - 开始排队时间"算出排队时长
- 收集多个数据后取平均值,更新入口处的等候时间牌
这个过程和 RT 的计算方法一模一样:请求开始时打一个时间戳,响应返回时再打一个,两者之差就是 RT。
RT 的组成也和排队体验类似:
迪士尼:排队时间 + 听讲解时间 + 准备时间 + 游玩时间
服务器:请求发送时间 + 网络传输时间 + 服务器处理时间其中任何一个环节变慢,都会导致整体 RT 上升。优化 RT 就是要找到最慢的那个环节,针对性地优化。
1.2 QPS -- 每秒查询数
QPS(Query Per Second)就是系统每秒能处理的请求数。有时候也叫吞吐量(Throughput)。
继续用迪士尼的例子 -- 一个项目"同时能容纳多少人游玩",就可以简单理解为它的 QPS。飞跃地平线每场可以容纳 87 人,每场 5 分钟,那么它的吞吐量大约是 87 / 300 = 0.29 人/秒。
QPS 和 RT 之间有一个基本的关系:
QPS = 并发数 / RT如果一个接口的 RT 是 100ms,一个线程每秒能处理 10 个请求。用 10 个线程并发,理论上 QPS 就是 100。
但这只是计算公式。想要实际提升 QPS,手段有很多:
- 降低 RT(优化代码、加缓存、减少 IO)
- 增加并发数(更多线程、更多 CPU 核心)
- 优化硬件(升级 CPU、增加内存、使用 SSD)
就像迪士尼想提升项目吞吐量,可以增加座位数、增加排队队列、新建平行通道,甚至直接扩建第二个同样的项目。
1.3 并发用户数
并发用户数指的是同一时刻与服务器正在进行交互的用户数量。
注意两个常见误区:
- 某系统有 100 万注册用户 -- 这是总用户数,不是并发用户数
- 某时刻有 10 万用户在线 -- 在线不等于并发,很多用户只是挂着页面没发请求
正确理解:晚上 8 点,有 5000 个用户正在同时请求商品详情页 -- 这 5000 才是并发用户数。
1.4 P90/P95/P99 -- 百分位数
只看平均值是远远不够的。假设 100 个请求中 99 个只用了 10ms,但有 1 个用了 10 秒,平均 RT 是 109ms,看起来还行。但那 1 个用户等了 10 秒的体验极差。
百分位数就是为了衡量这种"尾部延迟"的:
| 指标 | 含义 | 典型用途 |
|---|---|---|
| P50 | 50% 的请求在此时间内完成(中位数) | 衡量整体表现 |
| P90 | 90% 的请求在此时间内完成 | 识别高延迟部分 |
| P95 | 95% 的请求在此时间内完成 | 发现尾部延迟趋势 |
| P99 | 99% 的请求在此时间内完成 | 观察极端情况 |
举个具体的例子,假设有一组请求响应时间(单位 ms):
[100, 120, 130, 140, 150, 160, 170, 180, 190, 200, 210, 220, 230, 240, 250]- P90 = 225ms -- 90% 的请求不超过 225ms
- P95 = 235ms -- 95% 的请求不超过 235ms
- P99 = 245ms -- 99% 的请求不超过 245ms
实际工作中,我们通常关注 P99 -- 因为如果 P99 还在可接受范围内,说明绝大多数用户的体验都是好的。P99 飙高时,就需要排查那 1% 的慢请求到底慢在哪里(是 GC 停顿?是某个慢 SQL?还是网络抖动?)。
1.5 最佳线程数
迪士尼每开一个新场馆,都会有一个试运营阶段:逐步增加入场人数,观察场馆运行情况。人少的时候一切正常;人多到一个临界点后,排队变长、设备出问题、体验开始下降。
线程池调优的过程和这个一模一样 -- 通过压测逐步增加并发线程数,观察 QPS、RT、CPU 等指标:
- 起初:线程数增加 → QPS 上升,RT 基本不变,CPU 利用率正常
- 到达阈值:线程数再增加 → QPS 不再增长甚至下降,RT 开始上升,CPU Load 飙高
- 超过阈值:线程数继续增加 → 资源竞争激烈,系统濒临崩溃
QPS 不再增长的那个临界点,就是最佳线程数。
常用的估算公式:
# CPU 密集型任务
最佳线程数 = CPU 核数 + 1
# IO 密集型任务(更常见)
最佳线程数 = CPU 核数 × (1 + 等待时间/计算时间)
# 简化版
IO 密集型:2N ~ 4N(N 为 CPU 核数)IO 密集型可以设更多线程,因为线程大部分时间在等待 IO 完成,CPU 可以切换到其他线程继续干活。
二、性能优化的通用套路
性能优化的本质就是这几个方向:减少计算量、减少等待时间、减少重复工作。把这些方向具象化,就是下面这些手段。
2.1 缓存:空间换时间
这是最直接、效果最明显的优化手段。核心思想很简单 -- 把计算结果存起来,下次直接用,不用重新算。
| 缓存层级 | 示例 | 适用场景 |
|---|---|---|
| 浏览器缓存 | HTTP Cache-Control | 静态资源(JS/CSS/图片) |
| CDN | 阿里云 CDN | 静态页面、大文件下载 |
| 本地缓存 | Caffeine/Guava Cache | 配置信息、热点数据的本机副本 |
| 分布式缓存 | Redis/Memcached | 共享的热点数据(用户信息、商品详情) |
本地缓存和分布式缓存的区别:本地缓存在 JVM 内存中,速度极快(纳秒级),但每台机器各有一份,容量有限且一致性难保证。分布式缓存通过网络访问,速度稍慢(毫秒级),但数据共享、容量大。一般两者结合使用。
2.2 池化:减少创建销毁的开销
创建一个数据库连接大约需要 5-10ms(包括 TCP 三次握手、认证等),如果每个请求都新建一个连接,光建连接的时间就够呛了。
池化的思路是预先创建好一批资源放在"池子"里,用的时候借出去,用完了还回来。就像图书馆不会为每个读者买一本新书,而是把书放在架上,读者借阅、归还、下一个人继续借。
常见的池化技术:
- 数据库连接池 -- HikariCP(Spring Boot 默认)、Druid
- 线程池 -- ThreadPoolExecutor
- 对象池 -- Apache Commons Pool
- HTTP 连接池 -- Apache HttpClient 的 PoolingHttpClientConnectionManager
池化后不再需要频繁创建和销毁资源,性能提升立竿见影。
2.3 异步:别让 CPU 干等着
同步执行就像你去餐厅点餐,站在窗口等厨师做完才走开。异步执行就是点完餐拿个号,先去逛逛,做好了叫你。
在代码中,异步的典型应用:
// 同步串行:总耗时 = 100ms + 100ms + 100ms = 300ms
Result a = serviceA.call(); // 100ms
Result b = serviceB.call(); // 100ms
Result c = serviceC.call(); // 100ms
// 异步并行:总耗时 = max(100ms, 100ms, 100ms) = 100ms
CompletableFuture<Result> fa = CompletableFuture.supplyAsync(() -> serviceA.call());
CompletableFuture<Result> fb = CompletableFuture.supplyAsync(() -> serviceB.call());
CompletableFuture<Result> fc = CompletableFuture.supplyAsync(() -> serviceC.call());
CompletableFuture.allOf(fa, fb, fc).join();三个独立的 RPC 调用,从 300ms 降到 100ms,性能提升 3 倍。前提是这三个调用之间没有依赖关系。
2.4 批量:合并请求减少 IO
100 条数据逐条插入数据库,每次都要建立连接、执行 SQL、返回结果 -- 100 次网络往返。换成批量插入,一次搞定。
// 逐条插入:100 次网络往返
for (User user : users) {
userMapper.insert(user);
}
// 批量插入:1 次网络往返
userMapper.batchInsert(users);同样的思路适用于很多场景:
- Redis Pipeline -- 把多条命令打包一次发送
- HTTP 批量接口 -- 一次请求传多条数据
- 消息队列批量发送 -- 多条消息打包发送
2.5 压缩:减少传输量
数据在网络中传输,体积越小速度越快。常见做法:
- HTTP 响应启用 gzip/brotli 压缩,通常可以减少 60-80% 的传输量
- 序列化时使用 Protobuf 代替 JSON,体积更小、解析更快
- 数据库中大文本字段可以压缩后存储
压缩不是免费的 -- 它需要消耗 CPU 来做压缩和解压。所以需要根据场景权衡:如果瓶颈在网络带宽,压缩就很值得;如果瓶颈在 CPU,压缩反而可能帮倒忙。
三、池化技术深入
3.1 连接池调优
数据库连接池是最常见的池化应用。连接池大小的经验公式:
连接数 = (CPU 核数 x 2) + 有效磁盘数比如 4 核 CPU + 1 块 SSD,连接数设 9 到 10 就差不多了。设太大反而会因为线程竞争和上下文切换导致性能下降 -- 这个违反直觉但很重要。
3.2 线程池调优
线程池的核心参数:
| 参数 | 含义 | 类比 |
|---|---|---|
| corePoolSize | 核心线程数 | 正式员工数量 |
| maximumPoolSize | 最大线程数 | 正式 + 临时工总数 |
| workQueue | 任务等待队列 | 排队候客区 |
| keepAliveTime | 临时工空闲存活时间 | 没活干多久后辞退 |
| handler | 拒绝策略 | 排队区满了怎么处理新客人 |
调优的核心观测指标和判断逻辑:
| 现象 | 说明 | 调整建议 |
|---|---|---|
| 队列长 + 任务完成慢 | 处理能力不足 | 增加核心线程数或最大线程数 |
| 上下文切换频繁 | 线程数过多 | 减少线程数 |
| CPU 利用率过高 | 系统满负荷 | 减少线程数或优化业务逻辑 |
| 任务频繁被拒绝 | 线程池容量不足 | 增加线程数或增大队列容量 |
| 活跃线程数长期很低 | 线程数过多浪费资源 | 减少核心线程数 |
最大线程数一般设为核心线程数的 2 倍,或者对于可控场景直接和核心线程数相同。
常用的队列类型选择:
- ArrayBlockingQueue -- 有界队列,任务数可预估时使用
- LinkedBlockingQueue -- 可设有界/无界,任务数不确定时使用
- SynchronousQueue -- 零容量队列,适合高吞吐、任务短小的场景
拒绝策略选择:默认的 AbortPolicy(抛异常)适合大多数场景。如果任务不能丢失,用 CallerRunsPolicy(调用者线程执行),保证任务不丢但会拖慢调用者。
四、多级缓存架构
单一的缓存层往往不够。一个高性能系统通常会部署多级缓存,层层拦截请求:
每一层缓存都会拦截掉一大批请求。假设每层缓存的命中率是 90%:
| 层级 | 请求比例 | 100 万请求时的请求数 |
|---|---|---|
| 到达 CDN | 100% | 1,000,000 |
| 穿透到本地缓存 | 10% | 100,000 |
| 穿透到 Redis | 1% | 10,000 |
| 穿透到数据库 | 0.1% | 1,000 |
100 万个请求,最终只有 1000 个会打到数据库。这就是多级缓存的威力。
缓存的一致性挑战
缓存越多层,一致性越复杂。常见解决策略:
- TTL 过期 -- 给缓存设置合理的过期时间,过期后自动重新加载
- 主动失效 -- 数据更新时通过消息广播通知所有节点清除本地缓存
- 容忍短暂不一致 -- 对于非核心数据(推荐列表、阅读计数等),允许秒级的不一致
五、读写分离
5.1 基本原理
大多数互联网应用是读多写少的 -- 用户浏览商品详情的次数远多于下单次数。读写分离的思路就是让写操作走主库,读操作走从库,分散压力。
好处显而易见:
- 性能提升 -- 读写分开,互不影响
- 可扩展性 -- 读压力大就加从库
- 可用性 -- 主库挂了可以从从库中选一个提升为主库
5.2 读写分流方案
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 代码层分流 | AOP/注解 决定读写用哪个数据源 | 灵活但侵入性大 |
| 中间件分流 | ShardingSphere-JDBC 根据 SQL 语义自动路由 | 推荐,侵入性低 |
| 代理层分流 | MySQL Router / Atlas 做 SQL 代理 | 对应用透明,但多一层网络 |
5.3 主从延迟怎么办
读写分离最头疼的问题是主从延迟 -- 写入主库后数据还没同步到从库,从库读出来是旧数据。
正常情况下 MySQL 主从延迟非常小(通常不超过 1ms),但极端情况下可能出现秒级延迟。应对策略分三个层次:
第一层:分类处理 -- 不是所有读请求都怕延迟。历史订单查询、数据报表、非关键业务查询(评论列表等)可以容忍延迟,直接走从库。核心业务流程中的读(下单后查询订单状态等)走主库。
第二层:强制读主库 -- 对于不能接受延迟的读请求,直接读主库。ShardingSphere-JDBC 支持通过 Hint 强制路由。
第三层:兜底方案 -- 二次读取(从库没读到就去主库读一次),或者等待主库位点同步后再读。但这些方案复杂度高,一般第一层和第二层就够用了。
六、服务端性能优化实战清单
代码层面
| 优化项 | 做法 | 效果 |
|---|---|---|
| 单例模式 | IO 处理、DB 连接、配置解析等用单例 | 减少对象创建开销 |
| 批量操作 | 批量插入/更新代替逐条操作 | 减少网络往返和 DB 连接数 |
| 并行调用 | CompletableFuture 并行多个独立 RPC | 总 RT = max(各调用 RT) |
| NIO | 用 Netty 等 NIO 框架处理网络 IO | 提升并发吞吐 |
| 压缩传输 | 响应体 gzip 压缩、Protobuf 序列化 | 减少网络传输量 |
| 缓存结果 | 重复计算的结果缓存起来 | 避免重复 IO 和计算 |
锁优化
高并发场景下,锁是性能杀手。优化思路:
- 减少锁持有时间 -- 用同步代码块代替同步方法,只锁必须锁的代码
- 减少锁粒度 -- 用
ConcurrentHashMap(分段锁)代替Hashtable(全表锁) - 锁分离 -- 读写锁(
ReadWriteLock)让读操作不互斥 - 锁粗化 -- 大量短暂的加锁解锁合并成一次长锁,减少锁操作开销
- 锁消除 -- JIT 编译器可以自动消除不可能存在竞争的锁
SQL 优化
- 慢 SQL 是性能的头号杀手,优先治理
- 合理设计索引,避免全表扫描
- 避免
SELECT *,只查询需要的字段 - 大表考虑分库分表,降低单表数据量
七、布隆过滤器:缓存穿透的克星
在缓存架构中,有一个棘手的问题叫缓存穿透 -- 用户查询一个根本不存在的数据,缓存里没有,每次都穿透到数据库。恶意攻击者可以利用这一点,用大量不存在的 ID 发起请求,直接把数据库打垮。
布隆过滤器(Bloom Filter)就是为了解决这类问题而生的数据结构。它可以快速判断一个元素是否可能存在于一个集合中。
工作原理
布隆过滤器的底层是一个 bit 数组。添加元素时,用多个哈希函数把元素映射到数组的多个位置,将这些位置设为 1。查询时,检查这些位置是否都为 1:
- 如果有任何一个位置是 0 -- 一定不存在
- 如果所有位置都是 1 -- 可能存在(因为可能是其他元素的哈希冲突)
也就是说,布隆过滤器会有误判(假阳性),但不会漏判(假阴性)。
在缓存中的应用
把所有合法的 ID 加入布隆过滤器。请求到来时先查布隆过滤器:
大部分恶意请求(不存在的 ID)在布隆过滤器这一层就被拦截了,根本不会到达数据库。
使用示例
用 Guava 的布隆过滤器:
BloomFilter<String> filter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
1000000, // 预期元素数量
0.01 // 可接受的误判率
);
filter.put("user_1001");
filter.put("user_1002");
filter.mightContain("user_1001"); // true - 可能存在
filter.mightContain("user_9999"); // false - 一定不存在布隆过滤器的主要限制是不支持删除 -- 因为一个 bit 可能被多个元素共享,贸然设为 0 会影响其他元素的判断。如果业务需要删除,可以考虑计数布隆过滤器(把每个 bit 扩展为计数器)或者布谷鸟过滤器(Cuckoo Filter,Redis 8.0 新增的数据结构)。
小结
高性能优化可以用一张图来总结:
性能优化没有尽头,关键是找到当前系统的瓶颈在哪里。用二八法则来看 -- 80% 的性能问题往往集中在 20% 的代码上。先压测找瓶颈,再针对性优化,效果远好过盲目优化。
最后分享一条实战经验:不要过早优化,但要随时具备优化的意识。 先让系统跑起来、跑正确,然后在压测中发现瓶颈、定向优化。毕竟,跑得快但结果错的系统,还不如跑得慢但结果对的系统。