性能与容量场景
开篇:面试官问"如何估算 XX 的内存占用",该怎么答?
性能与容量类的面试题,核心考察的不是你能不能算出一个精确数字,而是你有没有定量分析的思维习惯。给你 40 亿个 QQ 号要去重、给你 5 亿条数据要放到布隆过滤器里、给你 3000 QPS 要估算机器数量——这些题目的本质都是一样的:把模糊的业务需求翻译成具体的资源需求。
这篇文章围绕 QPS/RT 估算、布隆过滤器与 Bitmap 的内存计算、以及一系列高频场景题展开,尽可能用真实的数字和计算过程来讲清楚。
一、QPS / RT 估算实战
3000 QPS、RT 200ms,需要几台机器?
要估算机器数量,需要算出单机吞吐量。
如果接口 RT 为 200ms,单线程一秒钟能处理的请求数是 1000 / 200 = 5 个。假设 Web 服务器是 Tomcat,默认 200 个线程,理论上单机吞吐量是 5 * 200 = 1000 QPS。
3000 QPS 需要 3000 / 1000 = 3 台机器。
但这个计算完全理想化了,忽略了 CPU、内存、Load 等硬件限制。随着并发量升高,各种资源使用率也在攀升。实际预估时需要在单机上做压测,根据压测结果来算。
最理想是 3 台,实际按 2-3 倍余量算,大致 6-9 台机器。
接口响应慢怎么排查?
性能优化的核心原则是:哪里亮了点哪里,首先得知道哪里亮了。
如果是分布式系统,接入了 SkyWalking 等链路追踪工具,可以直接通过 trace 查看具体哪个环节慢。如果没有,就用 Arthas 的 trace 命令:
# 查看接口内部调用耗时,只看超过50ms的请求
trace com.example.service.OrderService createOrder '#cost > 50' -n 3输出会展示方法内部每个调用的耗时,比如总耗时 265ms 中有 221ms 花在某个数据库查询方法上,那就是性能瓶颈。
定位到慢点之后,对症下药:
| 问题 | 解决方案 |
|---|---|
| 资源不足(CPU/内存) | 扩容 |
| 网络慢 | 同机房/单元化架构 |
| GC 频繁 | JVM 调优 |
| 慢 SQL | SQL 优化/加索引 |
| 数据量大 | 分库分表/分布式数据库 |
| 查询慢 | 加缓存 |
| 外部服务慢 | 异步调用 |
| 任务量大 | 多线程执行 |
| 锁冲突 | 乐观锁/减小锁粒度 |
| IO 操作慢 | NIO/AIO/Netty |
二、布隆过滤器内存估算
5 亿条数据放进布隆过滤器需要多大内存?
布隆过滤器的内存占用不仅取决于数据量,还取决于误判率。误判率越低,需要的内存越大。
内存估算公式(位数组大小 m 与元素数量 n 和误判率 p 的关系):
m = -(n * ln(p)) / (ln2)^2- m:位数组大小(单位 bit)
- n:元素数量
- p:预期误判率
以 5 亿数据、千分之一误判率为例:n = 500000000,p = 0.001
ln(0.001) = -6.9078
(ln2)^2 = 0.4805
m = 500000000 * 6.9078 / 0.4805 = 7187304890 bit转换为 GB:7187304890 / 8 / 1024 / 1024 / 1024 ≈ 0.836 GB,约 856 MB。
如果误判率放宽到百分之一,占用大概 0.557 GB(571 MB)。
总结:千分之一误判率下 5 亿数据约 856M,百分之一误判率约 571M。误判率每降低一个数量级,内存大约增加 40%。
三、Bitmap 应用场景估算
40 亿个 QQ 号如何去重?限制 1G 内存
40 亿个 unsigned int,直接存内存需要 4 * 4000000000 / 1024^3 ≈ 14.9 GB,即使有重复也远超 1G。
用 Bitmap 的话,一个数字只占 1 bit。QQ 号最大 10 位数即 9999999999,需要 10000000000 / 8 / 1024^2 ≈ 1192 MB。虽然超过 1G,但比 14.9G 好太多了。
如果 QQ 号范围更小(比如目前实际 QQ 号最大约 40 亿),那 Bitmap 只需要 4000000000 / 8 / 1024^2 ≈ 476 MB,完全在 1G 以内。
操作很简单:把 QQ 号 907607222 放进 Bitmap,就是把第 907607222 位设为 1。40 亿个号码全放进去后,所有为 1 的位置就是存在的号码,相同号码只设一次 1 就完成了去重。
Bitmap 的稀疏数据问题与 RoaringBitmap
Bitmap 有一个致命硬伤:对稀疏整数浪费空间。如果只存 1、50001、910321 三个数,要初始化 91 万多个 bit 只有 3 个为 1,空间利用率极低。
解决思路是压缩。RoaringBitmap 把 32 位整数拆成高 16 位和低 16 位,高 16 位相同的数放在一个 container 中,低 16 位形成 bitmap。相当于 HashMap 下面挂 bitmap,对稀疏数据大幅降低内存。
RoaringBitmap 的优点:支持 32/64 位数据的交并差运算、位运算速度快(基本 O(1))、API 与 java.util.BitSet 一致、强大社区(ElasticSearch、Kylin、Hive、InfluxDB 都在用)、自动根据数据量和稀疏程度在不同 container 类型间转换。
实际应用场景很多:用户画像系统中将分散在 ES、MySQL、Doris 中的用户数据通过 uid 灌入 Bitmap 做交并差集运算;推荐系统中为每个用户分配一个 Bitmap 记录已读文章 ID 做已读过滤。
四、常见场景面试题精选
骚扰电话号码如何存储和查询?
大量手机号标记为骚扰电话,核心问题是数据量大带来的查询效率。
数据库层:建表存手机号和标记时间,给手机号加唯一索引。3000 万左右的手机号有索引支撑,1 秒内出结果没问题。
CREATE TABLE spam_numbers (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
phone_number VARCHAR(15) UNIQUE NOT NULL,
marked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);分布式缓存层:骚扰号码相对稳定,非常适合用缓存。两个优化方向——只缓存高频号码(前 20% 热点,用 LFU 淘汰策略)、或者用布隆过滤器保存(用 Bitmap 大幅减少内存占用)。
本地缓存层:骚扰号码变化不频繁且基本只增不减,可以把热门号码缓存到 Caffeine 或 GuavaCache 中进一步提升性能。
一致性处理:缓存和数据库之间的一致性。骚扰号码"增多减少"的特点决定了方案很简单——本地缓存查不到就查 Redis,Redis 查不到就查数据库。只要保证新增的号码不被误判为不存在就行了。
内存还很多但频繁 FullGC?
好问题。几个思路:
你看到的可能不是 JVM 内存。 用 free 看到的是机器内存,JVM 内存通常只有机器内存的 1/2 到 2/3。要用 jstat -gc <pid> 或 jmap -heap <pid> 或 Arthas 看 JVM 的实际内存情况。
大量内存碎片。 老年代如果用标记清除算法会有很多碎片。虽然总内存够,但连续空间不够放新对象就会触发 FullGC。改用 G1 可以避免。
大对象进入老年代。 来一个比剩余空间还大的对象,老年代放不下就触发 FullGC。
元空间不足。 FullGC 不只是老年代不足触发,元空间满了也会。代码中大量使用反射、CGlib 等动态类加载就会把元空间打满。
隐式调用 System.gc()。 有些第三方库(RMI、JDK Management Beans)会偷偷调 System.gc(),强制触发 FullGC。排除其他原因后可以考虑这个方向。
Java 进程突然挂了,可能是什么原因?
先区分是真挂了还是假死。
假死看上去进程还在,但不响应任何请求。常见原因:死锁(线程互相等待对方资源)、活锁(线程不断重复操作但无进展)、无限等待(等待永远不会满足的条件)、IO 阻塞、长时间 GC 暂停。
真挂了的可能原因:OOM(检查有没有 OutOfMemoryError)、系统资源不足(CPU/磁盘打满)、宿主机挂了(容器/虚拟机所在的物理机异常)、进程被 kill 了(尤其是 kill -9)。
应用启动后前几分钟各项指标飙高
因为是启动后前几分钟的问题,分析前几分钟有什么特殊的就行。Load 和 CPU 飙高说明 CPU 忙,有很多事在处理和排队,进而导致 RT 增大。
机器不够:滚动发布过程中对外服务的机器数减少了,请求量不变但承载机器少了。现象是 RT 和 CPU 在整个发布过程中偏高,发布完就恢复。试着扩容几台就能缓解。
初始化任务:拉配置、预加载资源做缓存预热、创建连接池线程池、定时任务执行——这些预热过程消耗 CPU 和网络资源。可以用 jstack 或 Arthas 查看活跃线程。解决方案是完成预热后再暴露服务放流量。
JIT 优化:这个在真实生产环境遇到过。刚启动时代码解释执行(比编译执行慢),同时 JIT 做热点代码检测和编译消耗大量 CPU。解决思路是流量预热(先给 10% 流量)或提升 JIT 效率或降低瞬时请求量。
每天 100 万次登录,4C8G 机器 JVM 怎么调?
先问清楚有没有高峰期。均匀分摊一秒才 10 QPS 不需要特别调优。假设有高峰期,峰值 QPS 200。
登录服务的特点是产生大量小对象(请求参数/响应),朝生暮死,新生代会频繁创建和回收对象。
堆内存:机器 8G 给 JVM 一半即 4G。初始和最大设一样避免运行时频繁扩缩容:-Xms4G -Xmx4G
垃圾收集器:需要 STW 短的收集器。4G 堆刚好到 G1 门槛,选 G1:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:ParallelGCThreads=4
-XX:ConcGCThreads=2各区大小:年轻代初始从默认 5% 调到 20%(频繁创建对象需要更大年轻代),最大 50%:
-XX:G1HeapRegionSize=2m
-XX:G1NewSizePercent=20
-XX:G1MaxNewSizePercent=50必要日志:GC 日志和 OOM dump,后续调优全靠这些数据:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/dump.hprof
-Xlog:gc*:file=/path/to/gc.log:time,uptime:filecount=10,filesize=100M以上都是初始配置,需要根据压测结果持续调整。
应用发布和 DDL 变更怎么保证不出错?
一般先执行 DDL 再发布代码。如果反过来,代码引用了新字段但 DDL 没执行就报找不到字段。
大部分公司不允许删除字段,DDL 必须向下兼容,所以一般不需要开关来兼容(做了代码反而复杂)。
超大表 DDL 不要在业务高峰期执行——虽然 MySQL 5.6 支持 Online DDL 不锁表,但会占用 CPU、内存和 IO 影响性能。
生产环境绝对不要用 JPA 的 ddl-auto=update——复杂变更可能丢数据或引发性能问题,变更历史无法追踪,外键修改和大规模数据迁移它处理不了。
堆外内存持续增长但堆和元空间不变
如果应用占用的内存不断增长,但堆内存和元空间没有明显变化,大概率和堆外内存有关。
ByteBuffer 未及时回收:ByteBuffer.allocateDirect(1024) 在堆外占 1K 内存,堆上只有一个指针大小的对象。做堆内存分析时如果发现大量 ByteBuffer 对象,虽然它们自身占用很小,但关联的堆外内存可能非常大。
堆外缓存使用不当:Ehcache、MapDB、OHC 等框架使用堆外内存来提升存储空间并减少 GC 影响。如果缓存过期策略设置不合理,长期无法清理就会持续占用堆外内存。
日志框架的内存映射:很多日志框架支持 Memory Mapped File Appender(内存映射文件方式),将日志先写入映射到内存的文件中。这部分内存是堆外的,随日志增长而增长。除了应用自己的日志,很多框架或 Web 容器也有自己的日志缓存机制。
JNI 和本地代码:通过 JNI 调用本地库分配的内存不在 JVM 堆中,也不在元空间中体现。
线程栈:每个线程有自己的栈内存,线程数量增加栈内存也增加。这个容易被忽略。
不用大于号小于号判断两个正整数大小
这道题考的是基础功底和思维灵活性。
减法:最直观,a - b > 0 则 a > b。但面试官通常觉得太简单。
位运算:计算差值 diff = a - b,通过 (diff >> 31) & 1 获取符号位。符号位为 0 表示 diff 非负(a >= b),为 1 表示 diff 为负(a < b)。diff 为 0 则相等。
public static String compare(int a, int b) {
int diff = a - b;
int sign = (diff >> 31) & 1;
if (sign == 0) {
return diff == 0 ? "a == b" : "a > b";
}
return "b > a";
}右移 31 位把符号位移到最低位,& 1 提取出来。整数在内存中用补码表示,最高位 0 为正、1 为负。
还有加法方案(比较 a + b 和 2a、2b 的关系)、取模和除法方案等。如果面试官还不满意,可以提一个有趣的"睡眠排序"——每个数字开一个线程 sleep 对应的毫秒数,先醒来的就是更小的。当然这纯属面试段子,实际不可用。
不用 synchronized 和 Lock 实现线程安全的单例
几种方案都是利用了类加载机制:
饿汉模式:通过定义 static 成员变量,在类初始化时就创建实例。线程安全由 ClassLoader 的 loadClass 方法中的 synchronized 保证。
静态内部类:和饿汉原理一样,但更优雅——外部类加载时不会初始化内部类,只有调用 getInstance() 时才触发内部类加载和实例化,实现了懒加载。
枚举:反编译后发现每个枚举项也是 static final 修饰的,本质和饿汉一样。
CAS(真正的无锁方案):用 AtomicReference 的 compareAndSet 实现:
public class Singleton {
private static final AtomicReference<Singleton> INSTANCE
= new AtomicReference<>();
private Singleton() {}
public static Singleton getInstance() {
for (;;) {
Singleton singleton = INSTANCE.get();
if (singleton != null) return singleton;
singleton = new Singleton();
if (INSTANCE.compareAndSet(null, singleton))
return singleton;
}
}
}CAS 的好处是不用传统锁。缺点是 for 循环忙等待,如果一直 CAS 失败会浪费 CPU。但单例场景下只有第一次创建时存在竞争,后续直接返回已有实例,所以实际开销很小。
1TB 数据排序只有 32G 内存
经典的外部归并排序问题——分块排序 + 多路归并。
分块排序:32G 内存不能全用上,留一部分给操作系统和排序操作。假设每块用 30G,1TB 分成约 36 块。逐块读入内存,用快排或 Timsort 排序后写回磁盘,生成 36 个有序临时文件。
多路归并:36 个文件不能同时全部读入内存。内存分为输入缓冲区和输出缓冲区各 1G,能同时处理的文件数受限。综合磁盘 IO 考虑取 9 路归并。
具体过程:打开 9 个有序文件,每个预读数据到输入缓冲区。从每个缓冲区取第一个元素放入最小堆。弹出堆顶(当前最小值)写入输出缓冲区,从该元素来源文件补充下一个元素到堆中。输出缓冲区满了写入结果文件并清空。块读完了就关闭。
9 个文件合并成 1 个,分 4 轮变成 4 个有序文件,再做一轮归并得到最终结果。
路数选择要平衡:路数多则缓冲区小、磁盘换入换出频繁,但归并轮次少。路数少则缓冲区大、IO 效率高,但归并轮次多。
从 1TB 日志中找搜索量最高的 10 个关键词
方案是哈希分片 + 大根堆 + 统计。
哈希分片:用 MurmurHash 等均匀哈希函数,遍历日志将每个搜索关键词分散到 1000 个分片文件上。相同关键词一定落到同一个分片,便于统计全局次数。
分片内统计:逐个读取分片文件,哈希表统计词频,按次数降序排序保存中间文件。
全局 Top10:读取每个分片文件的第一个关键词(各分片最大值)放入最大堆。用最小堆维护当前 Top10。从最大堆取出全局最大候选——Top10 未满就加入,已满且大于堆顶就替换,小于等于堆顶且已满就提前终止(后续只会更小)。从候选所在分片读下一个关键词更新最大堆。
之所以找最大值要用最小堆维护 Top10,是因为最小堆的堆顶是当前 Top10 中最小的,方便和新候选比较决定是否替换。
读取一千个文件:单线程还是多线程
文件读取是典型的 I/O 密集型操作,大部分耗时在 I/O 等待上。结论:多线程效率更高。
单线程的优点是实现简单、没有竞争和同步问题、不需要上下文切换。但所有操作串行,I/O 等待时 CPU 空转。
多线程可以同时发起多个 I/O 请求,更好利用 CPU 和磁盘的并行能力。额外开销(线程创建销毁、上下文切换)在文件读取场景中很小可以忽略。线程安全问题通过分片解决——每个线程处理不同的文件集合,天然互不冲突。
酒店价格千万级秒级变更
千万级数据要在某个时刻(比如 7 点)全部变价,秒级更新几乎不可能。思路不是"7 点去改数据",而是"提前准备好,7 点自动切过去"。
多版本价格(推荐):价格表不做更新只做新增。每条定价有生效时间和结束时间。调价时给旧记录设结束时间,插入新记录设生效时间。查询时 WHERE 加时间条件,时间一到自动生效。
| 字段 | 旧配置 | 新配置 |
|---|---|---|
| price | 199 | 299 |
| start_time | 2024-01-01 | 2024-04-01 19:00 |
| end_time | 2024-04-01 19:00 | null |
缓存失效方案:价格缓存的 TTL 设为 7 点过期,到时间 key 失效就会回源数据库取最新价格。需加分布式锁防缓存击穿。
分布式批量更新:如果必须在数据库改,用分布式任务框架让多台机器同时处理,结合分库分表降低单库压力,用批量 UPDATE 减少连接并发。
应用启动后前几分钟 Load/RT/CPU 飙高
这个问题比较典型也很常见,有时还没定位到就恢复了。本质是 CPU 在启动阶段比较忙。Load 和 CPU 飙高导致 RT 增大——CPU 没资源处理响应,RT 自然就大了。
机器不够:金丝雀滚动发布过程中对外服务的机器减少了。100 台分 10 批发布,线上只有 90 台承担 100 台的量。现象是 RT 伴随整个发布过程偏高,发完就恢复。试着延长单批次发布时间能观察到有问题时长变长。解决方案是扩容,或发布前扩容发布后缩容。
初始化任务:从配置中心和注册中心拉配置、从数据库预加载资源做缓存预热、创建连接池和线程池、定时任务执行——这些预热过程消耗网络和内存资源。可以用 jstack 或 Arthas 查看活跃线程是不是后台任务。解决方案是修改启动脚本,完成预热后再暴露服务放流量进来。这里涉及优雅上下线的问题。
JIT 优化:这个在真实生产环境遇到过。刚启动时代码解释执行(比编译执行慢),同时 JIT 开始做频繁的热点代码检测和编译消耗大量 CPU。解决思路是做流量预热(先给 10% 流量让 JIT 优化完成再逐步放开)、提升 JIT 优化效率、降低瞬时请求量。
一般来说主要是以上三个原因。通用解法是小流量切流——刚启动的机器先给少量流量,持续一段时间后逐步放开。
发布分 10 批第一批发完后负载飙高
负载高后很快恢复,一般可以排除代码问题。观察是不是每台机器都有、每批都存在,如果只有刚发布的机器有,可以排除网络和流量问题。
两个常见原因:缓存冷启动(第一批请求触发缓存冷启动,未命中率高,大量请求穿透到数据库)和 JIT 优化(热点代码检测和字节码生成消耗 CPU)。
解决方案:
- 缓存预热——Spring 启动时就对缓存预热,避免启动后资源抢占和缓存击穿
- 小流量切流——刚启动的机器先给 10% 流量,持续一段时间后逐步放开
- 分更多批次——每次启动的机器更少,流量更均匀
- 提前扩容——50 台机器分 10 批,先扩 5 台变成 55 台,保证发布过程中始终有 50 台在线
- 限流——刚启动的应用单独限流
100M 内存判断一亿整数是否存在
一亿个整数(范围 1 到 2 亿),用 List 或 HashSet 存需要 400M-800M,超出 100M 限制。
用 java.util.BitSet 初始化 2 亿个 bit,每个整数只占 1 bit。bitSet.set(num) 存入,bitSet.get(num) 判断存在。
内存计算:200000000 / 8 / 1024 / 1024 ≈ 24 MB,完全满足 100M 限制,时间复杂度 O(1)。
如果数据稀疏(ID 分布不连续),初始化大数组浪费空间。可以用 RoaringBitmap 进一步压缩——高 16 位相同的数据放在同一个 container 中,低 16 位形成 bitmap,对稀疏数据大幅降低内存。
小结
性能和容量问题的核心是定量分析。不要凭感觉说"差不多够了",而是用公式算出来、用压测验证。
几个关键数字记住:
- Bitmap 中一个数字占 1 bit
- 布隆过滤器内存公式
m = -(n * ln(p)) / (ln2)^2 - 单线程吞吐量 = 1000ms / RT
- RoaringBitmap 对稀疏数据可以大幅压缩空间
- 外部归并排序解决内存不够的排序问题
有了这些基础工具,绝大多数容量估算问题都能拆解清楚。
定位性能问题的核心原则也很简单:先定位再优化。用 Arthas trace 找到慢点,再对症下药。不要上来就优化——你连瓶颈在哪都不知道,优化了也是白费力气。
容量评估和性能调优不是一次性的事,而是需要持续关注的。业务在增长,数据在变化,今天够用的方案明天可能就成了瓶颈。保持对数字的敏感度,定期回顾和调整,才是长久之计。