系统设计场景
开篇:面试官让你设计一个系统,该怎么答?
系统设计题是面试中最能拉开差距的环节。它不考你背了多少八股文,而是看你能不能在 10-15 分钟内把一个模糊需求变成一个可落地的技术方案。很多人一上来就画架构图,结果方向都是歪的。
这篇文章从容量评估说起,聊到限流降级的实战思路,再到系统设计题的通用答题框架,最后给一批高频场景题的完整拆解。每一块都尽可能结合真实的生产经验来讲。
一、容量评估方法论
选机器:4C8G 16 台还是 8C16G 8 台?
假设不考虑成本差异,16 台 4C8G 和 8 台 8C16G,总资源看上去一样,但实际运行起来差别很大。
更多小机器的优势在哪? 容错能力更好——挂掉一台只影响 1/16 的流量,而不是 1/8。发布的时候用金丝雀做滚动重启,重启 1/16 和重启 1/8 对线上业务的影响完全不一样。连接数方面,MySQL 给单机分配的连接数是固定的(一般也不让调,超了要么扩容要么就是有慢 SQL),机器越多总连接数就越多,并发能力自然更好。负载均衡也有更大的调度空间。
更大机器的优势呢? JVM 可以分配更多内存,GC 次数更少(虽然单次 GC 时长可能更长)。8G 内存一般给 JVM 分 4G,16G 一般给 8G。虽然 4G 也到了 G1 的最低门槛,但 G1 在 8G 上表现明显更优。扩容的时候加一台大机器就能扛很多流量,扩展速度更快。
总体来说,4C8G x 16 在并发处理、容错能力、负载均衡、连接数等方面更加友好,但需要留意单机瓶颈和 GC 频次、扩容效率的影响。而且用更多小机器更符合微服务架构的思路——想想当年阿里去 IOE,把 IBM 大型机换成 PC Server,就是这个道理。
| 维度 | 4C8G x 16 | 8C16G x 8 |
|---|---|---|
| 单机瓶颈 | 低 | 高 |
| 容错能力 | 更好 | 更差 |
| 负载均衡 | 发挥空间大 | 发挥空间小 |
| 连接数上限 | 更多 | 更少 |
| GC 时长 | 更短 | 更长 |
| G1 表现 | 也能用 | 更适合 |
| 扩容速度 | 更慢 | 更快 |
如果你的服务拆分得够细、职责清晰简单,对容错和负载均衡要求更高,那就选更多小机器。如果服务不够"微",单个应用需要大量内存和 CPU,那就考虑大机器。
系统指标什么范围算正常?
拿一台 4C8G 的 Java 应用来说,以下是经验值(不是业内通用标准,但踩过足够多的坑之后大致就是这个范围):
CPU 利用率:空闲或轻负载下 0-30%,中等负载 30-70%,最好不要长期超过 80%,一般建议控制在 70% 以下。
系统负载(Load):4 核 CPU 理想值应低于 4,建议维持在 50% 以下(即 2 以下)。长时间超过 75%(即 3)就该排查了。
磁盘利用率:和 CPU 内存关系不大,超过 80% 就要报警。日志多的应用 60% 就得关注。一定要配日志自动清理。
内存利用率:70-80% 可接受,长期接近或达到 100% 就可能有内存泄漏。
JVM 堆内存:8G 机器一般分 4G-6G 给 JVM,堆占用不要超过 80%,超过了要考虑内存泄漏。
GC 指标:YGC 不超过 1 次/分钟、时长控制在 50ms 以内。FGC 一周最好不超过 1 次、单次不超过 1 秒。
磁盘 I/O:取决于应用类型和数据存储方式,正常情况下读写应平稳,高 I/O 等待时间可能意味着磁盘性能瓶颈。
| 指标 | 正常 | 需关注 | 不可接受 |
|---|---|---|---|
| CPU 利用率 | <70% | 70%-90% | >=100% |
| Load | <2 | >3 | >4 |
| 磁盘利用率 | <80% | >80% | >=100% |
| 内存利用率 | <80% | >=80% | 100% |
| 堆内存占用 | <80% | >=80% | 100% |
| YGC 次数 | <1次/分钟 | >1次/分钟 | 10次/分钟 |
| YGC 时长 | <50ms | >200ms | >1s |
| FGC 次数 | <1次/周 | 1次/天 | 1次/小时 |
| FGC 时长 | <1s | >2s | >=5s |
如何预估系统 QPS
新业务上线或者大促备战,QPS 预估是必做的。方法论分六步走:
第一步,分析业务模型。 B 类和 C 类业务不一样,支付和购物车不一样。要弄清楚目标用户群规模、用户的典型行为模式(活跃时间、操作频率)。不同业务类型的 QPS 差异巨大。
第二步,参考历史数据。 已有系统看历史高峰流量数据,新系统参考类似系统的公开数据或行业标准。
第三步,估算日均请求量。 比如 1 万日活,每人每天 10 次请求,日均就是 10 万次。
第四步,计算峰值流量。 峰值通常是日均的 3 倍左右,考虑促销活动等特殊事件。假设峰值 30 万次集中在 2 小时高峰期,QPS 就是 300000 / (2 * 3600) = 42。
第五步,预估增长趋势。 考虑业务和用户增长,做好未来一段时间的资源规划。
第六步,考虑架构因素并留 buffer。 分布式架构、负载均衡、缓存、数据库优化都会影响处理能力。预估值上浮一定比例以备不时之需。
拿微博举个例子。公开数据显示 2023 年 Q3 月活 6.05 亿,按 30% 算日活大概 2 亿。假设每人每天 2 次互动(发微博、转评赞),日均 4 亿次请求。均匀分摊的话每秒约 4639 次(4 亿 / 24 / 3600)。
但一般业务都有峰值。设峰值是日常的 3 倍、持续 2 小时,日常 QPS 为 X:
X * (24-2) * 3600 + 2 * 3 * X * 3600 = 400000000解出 X 约 3968。即日常 QPS 约 4000,峰值 QPS 约 12000。
这里面有很多假设——日活比例、互动次数、峰值倍数、峰值时长。实际做预估也一样,总有些指标需要假设。假设越少结果越精确。如果所有指标都有具体值,那也不需要预估了。
二、服务降级与限流实战场景
为什么必须做限流?
有个兄弟的 leader 说过:为什么要限流?不应该服务好客户吗?不应该加机器吗?
客户第一没毛病,能让用户用得爽当然好。但这和限流不冲突。
限流恰恰是为了客户着想。 做了限流,最多一部分客户暂时无法访问,但系统不至于崩,稍后很快就能恢复。不做限流,系统直接崩了所有人都用不了。况且那些突然涌入的流量,你怎么确定是正常客户?灰黑产盯上你来 DDoS,不限流还无脑加机器服务他们?
突发流量无法预测。 系统最高 QPS 1000,压测能扛 1200,限流设 1200——常规操作。你经历过那种绝望吗?突发流量直接把服务器打挂,想扩容、想加机器,根本不给机会——机器起来就挂,起来就挂。加机器有啥用?完全没用,因为不给你加的机会。
限流是最后一道防线。 在极端情况下开启限流,你才有机会排查流量来源、做扩容。没有限流只能眼睁睁看着系统被打挂,别无他法。
还得考虑下游。 你不限流来了就硬扛,下游也得跟你一起扛。下游实在扛不住了求你限流,你总不能让人家也硬抗。
加机器确实是最有效的扛流量办法,我从不否认。机器资源拉满,机器数拉满,网络带宽拉满。但如果是这样,要高级开发有啥用?会写代码就行了,反正啥问题都不用考虑。做技术的还是得有点追求。
和外部机构交互怎么防止被拖垮?
在电商、支付场景中经常要和外部机构交互——快递公司、支付渠道、保险公司。你自己的可用性可以保障,但外部的 SLA 不可控。比如双十一,银行卡支付要走银联,很难保证银联系统能扛这么高的并发。
异步交互: 能异步就异步。天猫运费险的例子——用户下单后要给买家投保,投保必须成功(买家下单时已经看到有运费险了)。但保险公司扛不住大促量。运费险延迟几小时投保对业务无损(几小时内不可能发货收货退货),所以用定时任务或延迟消息异步处理,削峰填谷,失败可重试,不影响下单主流程。
文件交互: 异步的极端形式,在异步基础上加了批次。发货需快递单号,提前从快递公司批量申请存本地,发货时直接用,再批量回传。甚至不用接口,文件上传 FTP 让对方自己下载解析。
超时机制: 不是所有场景都能异步。高德聚合打车叫车时要实时对接多个平台,每个第三方设 2 秒超时,超了就断。还可以根据响应时间做动态负载均衡,把更多流量分给性能好的服务商。
限流降级熔断: 超时严重就上手段。限流——对方只能扛 100 QPS 就别给超过 100。降级——查用户名太慢就返回"陌生人"或"用户名加载中"。熔断——某个外部接口特别慢干脆不调了。大促当天降级银联,用户暂时没法用银联支付,对业务有损但保住了系统。恢复后再恢复调用。
业务取舍: 余额宝最初就是为了解决支付成功率低——大促期间银联等外部渠道成功率太低,很多人钱在银行。搞个余额宝让用户把钱转过来减少银行卡支付概率,技术问题用业务手段解决。
第三方接口不稳定怎么保护自己?
这种情况很常见,几个办法结合着用:
异步处理:先做收单,收单成功就返回上游,然后异步调第三方。失败根据收单记录重试。需要配合回调或反查机制——毕竟只告诉上游收单成功了,最终结果还不知道。看起来复杂,但很多第三方接口其实就是这么做的——先收单,处理完再回调,或者等你主动反查。这种做法能大幅提升整体吞吐量、减少对下游的强依赖。
超时机制:同步调用必须设超时。查询接口超时后返回默认值或上次查询结果;写操作麻烦一点,需要约定超时处理策略,引入幂等 + 重试机制。不管怎样超时机制是保命的。
熔断:借助 Hystrix、Sentinel 等工具,错误率或超时次数到阈值就停止请求一段时间,然后逐渐恢复流量。保护自己也避免给对方施加更大压力。
防止接口被恶意刷流量
除了限流,代码层面也要做足防护。恶意流量的特点是参数异常或非法,且大多是机器发出的。
参数防护:验证码(行为验证码如滑动拼图)、Token 口令(登录 Token 或页面访问时下发的一次性 Token,可定期刷新和限频)、验签防篡改、参数合法性校验(手机号 11 位、邮箱正则、列表限定最大数量)、行为逻辑关联性校验(组合下单前必须有加购行为)。
之前出过一个事——安全扫描扫到贷款分期试算接口,传了个巨大数字进来,后端没校验就开始算分期计划,直接把内存干爆了。所以入参限制多加点,@NotNull、@Min、@Max 多用。
机器流量识别(风控领域):人机识别(TLS 指纹、IP 信誉库、鼠标轨迹分析)、设备指纹(User-Agent、屏幕分辨率、Canvas 指纹)、请求时间间隔分析(正常用户有随机性,脚本是固定频率)、IP 地理位置检测(境外代理 IP)。借助算法能力可以挖掘出很多异常行为。
三、系统设计题的通用答题框架
拿到系统设计题不要上来就画图,按四步走:
- 厘清需求:核心功能是什么?用户量级、数据量级、读写比?有没有特殊约束?
- 容量估算:QPS 多少、存储多大、带宽多少?需要多少台机器?
- 架构设计:先画高层架构(客户端 - 网关 - 服务 - 存储),再逐步细化。
- 细节深挖:面试官会在某个点上追问,比如数据库怎么选、缓存怎么设计、一致性怎么保证。
拿"实现一个 RPC 框架"来说,本质上是考你对 Dubbo 的理解。拆解出来:通信协议、序列化协议、服务注册与发现、负载均衡、动态代理,再加上缓存、服务降级、泛化调用、优雅上下线、服务高可用、异步回调、服务治理等。把一个大问题拆解出来之后你会发现——每一块都是你见过的八股文。组装起来就是一个 RPC 框架。
四、常见场景面试题精选
外卖系统:一天千万单,买家卖家都要查
题干关键信息:一天 1000 万数据、只查最近 30 天、买家和商家都要查。
单表扛不住——一天 1000 万,一年 30 多亿。需要数据归档——30 天外的冷数据迁走。归档后仍然扛不住——热数据 3 亿条,还得分库分表。分表键是个难题——按买家 ID 分卖家就跨表,反过来也一样。缓存不太适合——数据量大、订单状态频繁变化、一致性难保障。
最终问题变成:如何基于 3 亿数据做高效查询,且支持多个分表键?
方案一用分布式数据库(TiDB、OceanBase、Spanner),建买家 ID + 时间和卖家 ID + 时间两组联合索引。
方案二传统分库分表:按买家 ID 分 64 张表(单表大概不到 1000 万),卖家维度冗余一份表通过数据同步保持准实时一致。卖家表只读不写,所有写操作走买家表。之所以按买家 ID 先分表,是为了避免数据倾斜——卖家的订单量差异极大(大商家 vs 小商家),买家相对均匀。
-- 买家维度主表
CREATE TABLE orders (
order_id BIGINT AUTO_INCREMENT PRIMARY KEY,
buyer_id BIGINT NOT NULL,
seller_id BIGINT NOT NULL,
order_date DATETIME NOT NULL,
amount DECIMAL(10, 2),
status ENUM('Pending', 'Completed', 'Cancelled', 'Refunded'),
INDEX idx_buyer_date (buyer_id, order_date)
);
-- 卖家维度冗余表(通过数据同步写入)
CREATE TABLE orders_seller (
order_id BIGINT AUTO_INCREMENT PRIMARY KEY,
buyer_id BIGINT NOT NULL,
seller_id BIGINT NOT NULL,
order_date DATETIME NOT NULL,
amount DECIMAL(10, 2),
status ENUM('Pending', 'Completed', 'Cancelled', 'Refunded'),
INDEX idx_seller_date (seller_id, order_date)
);短链服务:不重复、不被穷举、够短
Hash 函数(MD5、SHA)有碰撞风险。自增 ID 会被穷举。雪花算法生成的 ID 太长。要同时满足不重复、不被穷举、够短,行业常见做法是 MurmurHash + Base62 编码。
MurmurHash 是一种高效哈希算法,分布均匀、碰撞率低,广泛用于哈希表、分布式存储和数据去重。Base62 用 0-9、A-Z、a-z 共 62 个字符,不含 + / 这些 URL 不友好的字符。
String longUrl = "https://example.com/some/very/long/path";
int hash = MurmurHash3.murmurhash3_x86_32(longUrl.getBytes(), seed);
String shortLink = base62Encode(hash); // 输出类似 "8M0kX"完整短链服务还要考虑:存储用 Redis K-V(短链 key、长链 value)定期同步 MySQL 确保不丢,热门短链加本地缓存。跳转方式用 302 临时重定向(方便统计 PV/UV、支持修改、可随时拦截),不用 301(被浏览器缓存后脱离控制)。过期用 Redis TTL + 数据库有效期字段。唯一性给长链设唯一索引 + 生成时加分布式锁。防滥用做黑名单检测、审核、频率限制、接口鉴权。
黑名单网址过滤
哈希表查 O(1) 但不支持前缀匹配且吃内存。前缀树(Trie)支持前缀匹配但也吃内存。布隆过滤器用 bitmap 每地址 1 bit,省内存但有误判且不能删除。
最优方案是组合:布隆过滤器做第一层筛选,判断不存在直接放行;判断可能存在再去哈希表或 Trie 精确匹配。 因为黑名单是少数,大部分地址不在其中,布隆过滤器判断"不存在"是确定性的没有误判。少量"可能存在"的再做二次查询即可。
实际生产中不可能全放内存。真正的方案是本地缓存 + Redis + 数据库三级架构,缓存层用布隆过滤器,数据库用 Hash 索引兜底。
敏感词过滤
字符串匹配是最直观的——contains 或正则。适合敏感词少的小规模场景(SQL 注入过滤、数据脱敏),但效率低,时间复杂度接近 O(N*M)。
**前缀树(Trie)**将敏感词逐字符建树共享公共前缀。检测时从文本每个字符开始尝试匹配。适合敏感词有共同前缀的场景(如 adc、ak47、admin 共享前缀 a),插入和查询高效,空间上共享前缀也比较省。但构建初期成本高,无共同前缀时提升不明显。
**DFA(确定有限自动机)**为敏感词组构建状态机,每个状态知道下一个字符应该转移到哪个状态。到达接受状态就表示匹配到敏感词。适合复杂模式匹配和大规模内容过滤。开源框架 sensitive-word 就是基于 DFA 实现的,性能好功能全面,实际项目推荐直接用。
5000 万数据查手机号后四位
phone LIKE '%1234' 无法命中索引(不满足最左前缀匹配),5000 万数据全表扫描 10 秒起步。
方案是分段存储。冗余三个字段 phone_part1(前 3 位)、phone_part2(中间 4 位)、phone_part3(后 4 位),分别加索引。查后四位变成 phone_part3 = '1234',完全命中索引。典型的空间换时间,也是很多大厂在用的方案。之所以这么分,是因为大多数手机号查询都通过前三位 + 后四位来做。
5 分钟内登录 3 次失败就锁定
两个核心功能:滑动窗口限流 + 用户锁定。
用 Redis 有序集合(Sorted Set)实现滑动窗口——时间戳作 score,每次登录尝试加一条记录,清理 5 分钟外的记录,统计当前数量。超过限制就在 Redis 中设置 lock:<userId> key 并带过期时间。整个操作用 Lua 脚本保证原子性:
String luaScript =
"local window_start = ARGV[1] - 300000\n" +
"redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', window_start)\n" +
"local current = redis.call('ZCARD', KEYS[1])\n" +
"if current < tonumber(ARGV[2]) then\n" +
" redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1])\n" +
" return 1\n" +
"else\n" +
" redis.call('SET', 'lock:'..KEYS[1], 1, 'EX', tonumber(ARGV[3]))\n" +
" return 0\n" +
"end";查找附近的人
利用 Redis 的 Geospatial 数据类型。GEOADD 存储用户经纬度,GEORADIUS 按半径查询附近用户:
GEOADD user_location 121.57465 25.04100 user1
GEORADIUS user_location 121.57465 25.04100 1000 kmJava 代码里调用 Jedis 的 geoadd 和 georadius 方法,提取 GeoRadiusResponse 列表的 memberId 即可。方案简洁高效,Redis 原生支持,性能极好。
压测 600 没问题,上线 300 就挂
大多数时候压测能到 600 线上也能扛 600,但有些情况确实会翻车——我们就遇到过,比如数据倾斜问题和外部系统影响。
慢 SQL 与数据问题(很常见):压测可能是之前做的,线上数据不断变化。数据倾斜(某大 V 被频繁访问导致单分片过载或缓存频繁失效)、索引失效(数据量增长或查询条件分布变化导致全表扫描)、压测数据是自己造的很工整但生产数据更大更复杂,有些问题暴露不出来。
定时任务干扰(常见):压测在低峰期做,但生产高峰期有报表生成、数据归档在跑,抢 CPU、IO、数据库连接,还可能引发批量更新的锁竞争。
未做全链路压测(很常见):只压了单个服务没模拟依赖链路的真实负载。第三方 API 限速、支付风控性能瓶颈一上生产全暴露。
其他接口叠加(常见):压测只针对目标接口,但生产是所有接口同时跑。单接口 300 QPS 不高,多接口加一起可能就很大了。
冷启动问题(不常见):缓存未预热导致请求穿透到 DB(DB 的 RT 比缓存长得多)、JIT 编译器还没优化热点代码导致初始阶段吞吐量低、MQ 消费历史积压消息占用资源。做好优雅上下线能解决。
压测环境偏差(常见):硬件/配置差异(压测用更高配服务器或独立数据库实例,生产为共享集群或 K8s 容器化受资源限制)、网络拓扑差异(压测内网低延迟,生产跨机房公网)。
基础设施问题(常见):中间件是共享的,其他系统负载也影响你。线上还有 ELK 日志采集、SkyWalking 监控等额外开销。
@Scheduled 定时任务集群并发怎么解
Spring Boot 的 @Scheduled 默认每个实例都会执行。集群环境下所有节点都跑就有并发问题。
参考 xxl-job 的思路——基于数据库悲观锁(select for update),多个实例一起抢锁谁抢到谁执行。我们也可以用同样的思想:不管是数据库悲观锁还是 Redis/ZooKeeper 分布式锁,核心就是谁抢到锁谁执行。注意用非阻塞锁(tryLock 而非 lock),抢失败直接返回。
另一个方案是选主——用 ZooKeeper 做 master 选举,选上的执行,否则跳过。再追问只能换框架——XXL-JOB、Quartz 本身就解决了分布式调度问题。
五、更多实战场景
一次 RPC 调用客户端超时但服务端没超时
客户端报超时(比如超过 3000ms),服务端查日志 200ms 就返回了。这种事确实诡异但并不少见。
网络延迟或丢包(最主要):RT 除了服务端执行时长还有网络交互时长。存在网络问题时传输延迟或丢包会拖长总耗时。可以通过查看 TCP 重传率来判断。
超时配置不一致(最主要):客户端 2000ms 超时,服务端 3000ms 超时,客户端先超时了。
客户端资源瓶颈(次要):客户端自己 CPU/内存不足或正在处理大量并发请求,某些请求处理时间变长触发超时。
Token 被窃取怎么办
拿到 AccessToken 确实可以伪装登录,很多爬虫工具就是这么干的。
防窃取:传输阶段——不在 URL 中传 Token(通过 Header 传)、使用 HTTPS。存储阶段——Cookie 设 HttpOnly(禁止 JavaScript 读取防 XSS)和 Secure(仅 HTTPS 传输)。不要把 Token 存在 LocalStorage/SessionStorage 中。
Set-Cookie: session_id=abc123; Secure; HttpOnly; Path=/降低影响:短有效期(15 分钟到 1 小时)减少攻击窗口 + RefreshToken 机制。Token 绑定 IP 或设备指纹,异常来源直接拒绝。敏感操作要求二次验证。单个 Token 限频防自动化攻击。
两个不相关网站怎么实现单点登录
核心是 SSO。共享 Cookie 仅限同域名跨域不行。Token(JWT)跨域可以但有泄露风险。CAS 统一认证中心适合企业内部。OAuth2/OpenID Connect 基于标准协议接入成本更低。面试重点讲共享 Session + Redis 方案,Session 服务端存储不易泄露但通常还需 Token 或 Cookie 做关联,可以做二次校验。
公司间数据交互注意什么
安全性:协议加密(HTTPS/SFTP)、数据加密(AES 对称/RSA 非对称,密钥提前约定)、加签验签(通常对关键字段做带盐加签保证完整性和身份认证)、授权校验(API 密钥/OAuth/白名单)、文件完整性校验(文件 MD5 通过接口单独发送,接收方下载后比对)。金融场景可能用联盟链。
// AES 加密
String key = "1234567887654321";
AES aes = SecureUtil.aes(key.getBytes());
String encrypted = aes.encryptBase64(originalData);
// RSA 签名
RSA rsa = new RSA(privateKeyBase64, null);
String signature = rsa.signBase64(data, KeyType.PrivateKey);传输效率:专线(高带宽低延迟但贵)、数据压缩、图片等大文件用 OSS 传 URL 不传 BASE64。
合规性:涉及用户隐私数据没授权不能传第三方。可用隐私计算实现"数据可用不可见"。
存 IP 地址用什么类型
IPv4 推荐 32 位无符号整数。192.168.1.1 转成 3232235777,恒定 4 字节,比字符串 7-15 字节省很多,排序比较也快。MySQL 用 INET_ATON / INET_NTOA。
IPv6 推荐 BINARY(16)。128 位用整数存要拆多段太复杂,二进制恒定 16 字节简单高效。MySQL 用 INET6_ATON / INET6_NTOA。
给第三方提供接口注意什么
RPC 接口特别注意:入参出参实现 Serializable;出参带 success/respCode/respMsg;用包装类不用基本类型;不要用枚举(新增枚举项可能要求所有调用方升级,否则序列化异常);成功字段用 success 不用 isSuccess。
通用注意点:接口语义明确(金额单位元还是分?success 是调用成功还是处理成功?差别巨大,出了故障对方会说你定义不清晰)、错误用错误码不用异常、通用性设计(传密钥/租户 ID/产品码方便扩展)、版本管理或兼容性升级、自我保护(限流降级熔断 + 日志 + 边界值校验)。
银行系统选什么垃圾回收器
"实时性要求高"意味着低延迟、短 STW。根据 JDK 版本:
| JDK 版本 | 推荐 | 暂停时间 |
|---|---|---|
| >= JDK 12 | Shenandoah 或 ZGC | 通常 <10ms |
| >= JDK 11 | ZGC | 通常 <10ms |
| >= JDK 7 | G1(堆 4G 起步) | 10-100ms |
| < JDK 7 | Parallel Scavenge + CMS | 视情况 |
排除串行 Serial 系列。并行收集器关注吞吐量不适合低延迟。CMS 和 G1 引入三色标记法缩短 STW。G1 是整堆回收器一个就够,CMS 只管老年代还需搭配年轻代回收器。
业务量突然提升 100 倍怎么办
先搞清原因。被 DDoS 就上限流降级熔断保命。蹭到热点就临时扩容。业务自然增长就需要长期方案——本质是"如何设计一个高并发系统"。
登录拉黑 + 踢人下线
拉黑有三种方式:单独建黑名单表(和用户表解耦,可扩展拉黑时间/原因/开始结束时间,还能前置给风控用)、更新用户状态字段、用户表打标。用户量大建议用黑名单表。为提升性能,黑名单非常适合用布隆过滤器做缓存。
踢下线:找到用户的 Session 清空即可。Session 在 Redis 就删对应 key。用 sa-token 等框架直接调 kick 方法。
日志打印成为瓶颈
大厂大促经常遇到日志拖长接口 RT 的问题。优化手段:
- 控制输出量——没用的日志不打
- 调高日志级别——线上 WARN + ERROR 够了
- 打印前做级别检测——避免不必要的字符串拼接
- 异步日志——log4j/logback 都支持,配
neverBlock=true队列满时丢弃而非阻塞 - 日志文件滚动——rollingPolicy 限单文件大小
- 日志文件拆分——业务日志和异常日志分文件防互相阻塞
- 日志降级——大促时配置只输出 10% 采样,或运行时推送更高级别控制输出量,可细粒度到不同 logger
进入电梯断网后恢复为什么网慢
TCP 拥塞控制:断网期间超时重传让 TCP 认为发生拥塞,发送窗口(cwnd)被缩小。恢复后通过慢启动从低速率逐步增加,不会立刻恢复到断开前的速度,直到拥塞窗口逐渐扩大才能恢复传输速率。
网络重新协商:无线网络需要重新连接基站/AP、分配信道和带宽、可能重新获取 IP 地址(DHCP),这些过程都需要时间。
应用发布和 DDL 变更的顺序
一般先执行 DDL 变更(加索引、新增字段),再发布代码。如果反过来,代码里引用了新字段但 DDL 还没执行,会直接报找不到字段的错。
大部分公司不允许删除字段,DDL 变更必须向下兼容,所以一般不需要做开关来兼容。超大表的 DDL 不要在业务高峰期执行——虽然 MySQL 5.6 支持 Online DDL 不锁表了,但过程中会占用 CPU、内存和 IO,影响性能。
生产环境不要用 JPA 的 ddl-auto=update 自动更新表结构——复杂变更可能引发数据丢失或性能问题,变更历史无法追踪,某些变更(外键修改、大规模数据迁移)JPA 处理不了。
酒店价格千万级秒级变更
全国的酒店价格(千万级)需要在某个瞬间(比如 7 点)发生变动,怎么做到高性能准点更新?
任何分布式任务、分布式更新方案都很难做到秒级更新千万级数据。所以思路不应该是"7 点那一刻去改数据",而是"提前把数据准备好,7 点那一刻切过去"。
前提是预计算。 价格必须提前算好,不能在 7 点实时计算。
方案一:多版本价格。 这是我们在金融定价中心用过的方案。价格表里不做更新,只做新增。每条定价配置有 start_time 和 end_time。调价时不改旧记录,而是把旧记录的结束时间设为 7 点,再插入一条新记录生效时间为 7 点。查询时 WHERE 条件加上 start_time <= now() AND end_time > now(),时间一到自动切换到新价格。
| 字段 | 旧配置 | 新配置 |
|---|---|---|
| price | 199 | 299 |
| start_time | 2024-01-01 00:00:00 | 2024-04-01 19:00:00 |
| end_time | 2024-04-01 19:00:00 | null |
实际上我们做的比这复杂得多——还有价格优先级、分层定价、叠加互斥等逻辑,但核心思路都是提前配好、时间一到自动生效。
方案二:缓存失效。 如果价格在缓存中,把缓存的 TTL 设为 7 点过期。到时间 key 失效,下次查询就会回源到数据库取最新价格(前提是数据库已经提前更新好了)。需要加分布式锁防缓存击穿。
方案三:分布式批量更新。 如果必须在数据库中做更新,用分布式任务框架(SchedulerX 的 MapReduce 任务、xxl-job 的网格任务)让集群中更多服务器同时处理。结合分库分表降低单库压力,使用批量 UPDATE 减少连接并发数。
100M 内存判断一亿个整数是否存在
一亿个整数(范围 1-2 亿),用 List 或 HashSet 存需要 400M-800M,远超 100M 限制。
Bitmap 方案:用 java.util.BitSet 初始化 2 亿个 bit,每个整数只占 1 bit。bitSet.set(num) 存入,bitSet.get(num) 判断是否存在。2 亿 bit = 约 24MB,完全满足 100M 限制,时间复杂度 O(1)。
但 Bitmap 对稀疏整数浪费空间——如果只存 1、50001、910321 这几个数,要初始化 91 万个 bit 只有几个为 1。这时候可以用 RoaringBitmap:把 32 位整数拆成高 16 位和低 16 位,高 16 位相同的放在一个 container 中,低 16 位形成 bitmap。相当于 HashMap 下面挂 bitmap,对稀疏数据大幅降低内存占用。ElasticSearch、Hive、InfluxDB 都在用这个算法。
读取一千个文件:单线程还是多线程
文件读取是典型的 I/O 密集型操作,大部分耗时在 I/O 等待上。多线程效率更高——多个线程可以同时发起 I/O 请求,更好地利用 CPU 和磁盘的并行能力。
单线程的优点是实现简单、没有竞争和同步问题、不需要上下文切换。但所有操作都是串行的,I/O 等待时 CPU 在空转。
多线程的额外开销(线程创建销毁、上下文切换)在文件读取场景中很小可以忽略。线程安全问题可以通过分片解决——每个线程处理不同的文件集合,天然互不冲突。
JVM 调优实战:4C8G + 百万日登录
一天 100 万次登录,如果有高峰期(比如上午 10 点),峰值 QPS 可能到 200。登录服务的特点是创建大量小的请求/响应对象,朝生暮死。
堆内存:4C8G 机器,JVM 堆设为操作系统内存的一半即 4G,初始和最大设一样避免运行时频繁扩缩容。-Xms4G -Xmx4G
垃圾收集器:大量短生命周期对象意味着新生代 GC 频繁,需要 STW 时间短的收集器。4G 堆刚好到 G1 的最低门槛,选 G1:-XX:+UseG1GC -XX:MaxGCPauseMillis=200
G1 调优:设置并行 GC 线程数 4、并发 GC 线程数 2。年轻代初始大小从默认 5% 调到 20%(频繁创建对象需要更大的年轻代)。
-XX:ParallelGCThreads=4
-XX:ConcGCThreads=2
-XX:G1NewSizePercent=20
-XX:G1MaxNewSizePercent=50
-XX:G1HeapRegionSize=2m日志参数:必须加上 GC 日志和 OOM 时自动 dump,后续调优全靠这些数据。
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/dump.hprof
-Xlog:gc*:file=/path/to/gc.log:time,uptime:filecount=10,filesize=100M以上都是初始配置,实际需要根据压测结果不断调整。
堆外内存持续增长的排查思路
应用占用内存不断增长,但堆内存和元空间都没变化,大概率是堆外内存问题。常见场景:
ByteBuffer 未及时回收:ByteBuffer.allocateDirect(1024) 在堆外占 1K 内存,堆上只有一个指针大小的对象。堆内存分析时看到大量 ByteBuffer 对象虽然自身很小,但关联的堆外内存可能很大。
堆外缓存使用不当:Ehcache、MapDB、OHC 等框架使用堆外内存,如果缓存过期策略设置不合理,长期无法清理就会持续占用。
日志框架的内存映射:Memory Mapped File Appender 将日志先写入映射到内存的文件中,这部分是堆外内存,随日志增长而增长。
JNI 和本地代码:通过 JNI 调用本地库分配的内存不在 JVM 堆中。
线程栈:每个线程有自己的栈内存,线程数量增加栈内存也增加。
应用启动后前几分钟 Load/RT/CPU 飙高
这个问题比较典型也很常见,有时候还没等定位到就恢复了。本质上是 CPU 在启动阶段比较忙,处理不过来导致 RT 变大。
机器不够(常见但易忽略):金丝雀滚动发布过程中,100 台机器分 10 批发布,线上同时存活只有 90 台要承担 100 台的流量。现象是 RT 变长伴随整个发布过程,发布完就恢复。延长单批次发布时间能观察到有问题时长变长。解决方案是扩容,或发布前扩容发布后缩容。
初始化任务(常见):启动过程中从配置中心、注册中心拉配置,从数据库预加载资源做缓存预热,创建连接池、线程池,执行定时任务——这些预热过程需要网络交互和内存操作,消耗机器资源。可以用 jstack 或 Arthas 查看活跃线程是不是后台任务。解决方案是修改启动脚本让应用完成预热后再暴露服务、再放流量进来。
JIT 优化(典型):这个我们在真实生产环境遇到过。应用刚启动时代码需要解释执行(比编译执行慢),同时 JIT 开始做热点代码检测和编译,耗费大量 CPU 资源。解决思路是做流量预热(先给 10% 流量让 JIT 优化完成再逐步放开)、提升 JIT 优化效率、降低瞬时请求量。
三个问题的通用解法是小流量切流——刚启动的机器先给 10% 流量,持续一段时间后逐步放开。或者分更多批次让每次启动的机器更少、流量更均匀。
发布分 10 批,第一批发完后负载飙高后恢复
因为负载高之后很快恢复,一般可以排除代码问题。如果不是所有机器都有、不是每批都存在,可以排除网络和流量问题。因为只有刚发布的机器有这个问题。
两个常见原因:缓存冷启动(第一批请求触发缓存冷启动,未命中率高)和 JIT 优化(刚上线的代码进行热点检测和字节码生成)。
解决方案:缓存预热(Spring 启动时就预热)、小流量切流(先 10% 逐步放开)、分更多批次(每次启动更少机器)、提前扩容(先扩出一批保证发布过程中总机器数不减少)、限流(刚启动的应用限制流量)。
1TB 数据排序,只有 32G 内存
1TB 数据无法一次性加载到 32G 内存中排序,经典解法是外部归并排序——分块排序 + 多路归并。
分块排序:将 1TB 数据分成多个小块。32G 内存不能全用上(要留给操作系统和排序操作本身),假设每块用 30G 左右,分成 36 块。逐块读入内存排序(快排、归并、Timsort),写回磁盘生成 36 个有序临时文件。
多路归并:36 个文件无法同时全部读入内存。内存要分为输入缓冲区和输出缓冲区,假设各留 1G,则同时读取的文件数受限于 (32G - 1G) / 1G = 31 个。综合磁盘 IO 考虑,取 9 路归并。
具体过程:打开 9 个有序文件,每个预读数据到输入缓冲区。从每个缓冲区取第一个元素放入最小堆。弹出堆顶(当前最小值)写入输出缓冲区,从该元素来源的文件补充下一个元素到堆中。输出缓冲区满了就写入结果文件并清空。
9 个文件合并成 1 个,分 4 轮合并成 4 个有序文件,再做一轮归并得到最终结果。路数选择要平衡:路数越多磁盘 IO 次数越少(缓冲区小了换入换出频繁),但归并轮次越少。路数越少缓冲区越大磁盘 IO 效率越高,但归并轮次越多。
从 1TB 日志中找搜索量最高的 10 个关键词
暴力统计当然行,但面试官要的是分治思想。方案是哈希分片 + 大根堆。
哈希分片:选择均匀的哈希函数(如 MurmurHash),遍历日志文件,将每个搜索关键词通过哈希分散到固定数量的分片文件上(比如 1000 个)。相同关键词一定落到同一个分片,便于统计全局次数。
分片内统计:逐个读取分片文件,用哈希表统计词频,按次数降序排序保存为中间文件。
全局 Top10:用最大堆读取每个分片的第一个关键词(各分片最大值)。用最小堆维护当前 Top10。从最大堆取出全局最大候选,如果 Top10 堆未满直接加入,已满且大于堆顶则替换。如果小于等于堆顶且 Top10 已满则提前终止(后续只会更小)。
小结
系统设计没有标准答案,但有标准的思考路径:
- 容量评估给你定量的基础——知道系统要扛多少流量、需要多少资源,才能做出靠谱的架构决策。
- 限流降级给你兜底的底气——再完美的系统也会遇到意外,最后一道防线不能没有。
- 答题框架帮你把思路组织清楚——需求分析、容量估算、架构设计、细节深挖,按节奏走不容易跑偏。
最重要的一点:拆解问题。再复杂的系统设计题,拆开之后每一块可能都是你见过的知识点。通信协议、序列化、注册发现、负载均衡、缓存、分库分表、消息队列——这些都是你学过的东西。把它们组装起来,就是面试官想听的答案。
面试中系统设计题最忌讳的是两件事:一是上来就画图不问需求,二是说了半天全是理论没有具体方案。用数据说话(QPS 多少、存储多大、延迟要求多少),结合实际经验(我们生产环境遇到过什么问题、怎么解决的),这样的回答才有说服力。