用途说明:这是一个学习用的典型案例,用来理解业界常见的高并发、高性能、高可用方案。不是你的经历,面试中不要说成是自己做的。 面试中的正确用法:被问到“接口慢怎么优化”“高并发查询服务怎么设计”时,说“我的排查思路是……,常见的方案有……”;讲到具体经历时,只讲你真实做过的部分(比如智能体平台批量查询的令牌桶限流)。 文中的数字都是示例数字,用来说明量级,不是真实数据。
某公司有一个运营数据大盘,首页有十几个图表:今日交易额、订单数、各城市排名、近 7 天趋势等。前端打开首页会同时调用十几个接口,后端从 MySQL 里实时查询和计算。
随着业务增长,出现了三个问题: 1. 慢:首页打开要 7 到 8 秒,个别图表接口超过 10 秒。 2. 扛不住:每天早上 9 点运营集中打开大盘,接口超时、数据库 CPU 飙到 90% 以上。 3. 会连带出问题:大盘查询和下单业务共用同一个数据库,高峰期把数据库打满,连带影响了下单。
目标:P99 延迟降到 300 毫秒以内;支撑早高峰 10 倍的流量;大盘出问题时不能影响核心业务。
原则:先测量,再优化。不要凭感觉改代码。
先看 APM 监控(比如 SkyWalking、公司内部的链路追踪系统),找到一次慢请求的完整调用链,看每一段花了多少时间:网关、应用内处理、数据库、缓存、下游接口。
这一步的结论(示例):80% 的时间花在数据库查询上,另外有几个图表接口是串行调用三四个下游服务。
Arthas 是阿里开源的 Java 线上诊断工具,不用重启、不用加日志就能看到方法的执行耗时。常用命令:
# 看整体:CPU、内存、线程、GC 的实时情况
dashboard
# 找最忙的 3 个线程,看它们在干什么(CPU 飙高时用)
thread -n 3
# 追踪方法内部每一步的耗时,只记录耗时超过 1 秒的调用,记录 5 次
trace com.example.DashboardService getCityRank '#cost > 1000' -n 5
# 查看方法的入参和返回值,展开 2 层(排查“为什么慢在这组参数上”)
watch com.example.DashboardService getCityRank '{params, returnObj}' -x 2
# 生成火焰图,看 CPU 时间花在哪些方法上
profiler start
profiler stop --format html这一步的结论(示例):trace 显示 getCityRank 里一条 SQL 平均 4 秒;另一个接口在循环里逐条查数据库(典型的 N+1 查询问题)。
-- 开启慢查询日志,记录超过 1 秒的 SQL
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;找到慢 SQL 后用 EXPLAIN 看执行计划,重点看四个字段:
| 字段 | 看什么 | 不好的信号 |
|---|---|---|
| type | 访问方式 | ALL(全表扫描)、index(全索引扫描) |
| key | 实际用了哪个索引 | 为空,说明没用上索引 |
| rows | 预估扫描行数 | 远大于返回的行数 |
| Extra | 额外操作 | Using filesort(额外排序)、Using temporary(临时表) |
这一步的结论(示例):城市排名的 SQL 是 type=ALL、扫描 800 万行,还有 Using temporary; Using filesort。
原 SQL:
SELECT city_id, SUM(amount) AS gmv
FROM orders
WHERE DATE(create_time) = '2026-11-07' AND status = 'PAID'
GROUP BY city_id
ORDER BY gmv DESC
LIMIT 20;问题: 1. DATE(create_time) 在索引列上用了函数,索引失效。 2. 没有合适的联合索引,只能全表扫描。
优化:
-- 1. 去掉索引列上的函数,改成范围条件
WHERE create_time >= '2026-11-07 00:00:00' AND create_time < '2026-11-08 00:00:00'
AND status = 'PAID'
-- 2. 建联合索引:等值条件的列在前,范围条件的列在后;把要用到的列都放进来,形成覆盖索引
ALTER TABLE orders ADD INDEX idx_status_time_city_amount (status, create_time, city_id, amount);为什么这么建: - 等值在前、范围在后:联合索引遇到范围条件后,后面的列就不能再用来过滤了,所以 status 放前面。 - 覆盖索引:查询需要的列都在索引里,不用回表查整行,减少大量随机 IO。
常见的索引失效场景(面试高频):索引列上用函数或计算;隐式类型转换(字符串字段用数字比较);以 % 开头的 LIKE;不满足最左前缀;OR 条件里有一边没有索引。
原代码:先查出 20 个城市,再在循环里逐个查城市名称,一共 21 次数据库查询。
// 优化前:循环里查数据库
for (CityGmv item : list) {
item.setCityName(cityMapper.selectById(item.getCityId()).getName());
}
// 优化后:一次批量查询,再在内存里拼装
List<Long> ids = list.stream().map(CityGmv::getCityId).collect(Collectors.toList());
Map<Long, String> names = cityMapper.selectByIds(ids).stream()
.collect(Collectors.toMap(City::getId, City::getName));
list.forEach(item -> item.setCityName(names.get(item.getCityId())));如果每次都要关联用户表、商品表取名称,可以在订单表里冗余常用字段(比如城市名称),避免多表关联。代价:数据重复,源头变化时要同步更新。适合变化不频繁的字段。
-- 慢:要先扫描 100 万行再丢掉
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;
-- 快:记住上一页最后一个 ID,从它往后查
SELECT * FROM orders WHERE id > #{lastId} ORDER BY id LIMIT 20;这一步的效果(示例):城市排名 SQL 从 4 秒降到 300 毫秒。但高峰期数据库压力仍然很大,因为每次打开大盘都要实时聚合。
根本问题:大盘的图表本质上都是聚合查询,每次打开都从明细数据里实时算一遍,用户越多算得越多,而且算出来的结果几乎都一样。
方案: - 准实时指标(今日交易额、订单数):用 Flink 消费订单的 binlog(通过 Canal 或 Flink CDC),按分钟聚合,结果写进 Redis 或 OLAP 引擎。接口直接读结果,不再扫明细。 - 历史趋势(近 7 天、近 30 天):用离线任务每天预算好,写入结果表。 - 灵活的多维分析:把明细同步到 Doris 这类 OLAP 引擎,对高频查询建物化视图,查询时自动命中预聚合结果。
为什么这是最有效的一步:把“每次查询都计算”变成“数据变化时计算一次”,查询成本和用户数基本无关了。
代价:数据有分钟级的延迟,要在页面上标注“数据更新时间”;多了一条数据链路,要做实时数据和离线数据的对账。
问题:大盘查询和下单业务共用一个数据库,查询把数据库打满会连累下单。
方案: - 大盘只读从库(或者干脆只读 OLAP 和 Redis),主库只服务交易写入。 - 主从延迟的问题:从库数据可能落后几百毫秒。对大盘来说可以接受;对“下单后马上查订单”这种场景,要强制读主库。
场景:订单表到了几十亿行,单表查询和写入都变慢。
方案: - 水平拆分:按用户 ID 做哈希,拆成多个库、多张表(比如 4 个库、每库 64 张表)。按用户 ID 查询时直接定位到某张表。 - 新问题:按非分片键查询(比如按商家、按时间范围查)需要扫所有分表。解法:用 Canal 监听 binlog,把数据同步到 ES,复杂条件查询走 ES。 - 再一个新问题:ES 是近实时的,写入后默认约 1 秒才能查到。如果业务要求“写完马上能查到”:写入后的短时间内,先查 Redis 或主库里的最新数据;或者写入 ES 时让请求等到数据可查再返回(代价是写入变慢)。
面试要点:每引入一个组件都会带来新问题,要能讲清楚“为什么引入、带来了什么问题、怎么解决”。
请求 → 本地缓存(Caffeine,每台机器各有一份,纳秒到微秒级)
→ 没命中 → Redis(集中缓存,约 1 毫秒)
→ 没命中 → 数据库或 OLAP
→ 查到后逐级回填
| 问题 | 是什么 | 解决方案 |
|---|---|---|
| 穿透 | 查一个根本不存在的数据,缓存和数据库都没有,每次都打到数据库 | 缓存空值(设短过期时间);用布隆过滤器提前拦截不存在的 key |
| 击穿 | 某个热点 key 过期的瞬间,大量请求同时打到数据库 | 互斥锁:只让一个请求去重建缓存,其他请求等待或返回旧值;或者“逻辑过期”:数据不设物理过期,后台异步刷新 |
| 雪崩 | 大量 key 同一时间过期,或者 Redis 整个挂了 | 过期时间加随机值打散;多级缓存兜底;Redis 用集群加哨兵保证高可用;数据库前加限流 |
问题:早上 9 点所有人都看“全国今日交易额”,这一个 key 的请求全部落到 Redis 集群的同一个节点上,把这个节点打满。
方案:热点数据放本地缓存(最有效);或者把一个 key 复制成多份(比如 gmv_today_0 到 gmv_today_9),读的时候随机选一个,分散到不同节点。
常用方案(Cache Aside):读的时候先读缓存,没有再读数据库并写回缓存;更新的时候先更新数据库,再删除缓存。
为什么是删除而不是更新缓存:两个请求并发更新时,更新缓存的顺序可能和更新数据库的顺序不一致,导致缓存里留下旧值;删除就没有这个问题,下次读时自然加载最新值。
删除缓存失败怎么办:用 Canal 订阅 binlog,异步删除缓存,失败了放进消息队列重试;同时缓存一定要设过期时间,作为最后的兜底。
本地缓存怎么失效:每台机器各有一份,可以通过消息广播让所有机器删除;或者干脆把本地缓存的过期时间设得很短,接受几秒的不一致。
一个图表接口要调用三个下游服务,原来是一个接一个调,总耗时是三者之和。改成并行,总耗时约等于最慢的那一个。
// 专用线程池:不要用公共线程池,避免和其他业务互相影响
ExecutorService pool = new ThreadPoolExecutor(
20, 50, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500), // 有界队列,防止任务无限堆积导致内存溢出
new ThreadPoolExecutor.CallerRunsPolicy()); // 队列满了由调用线程执行,起到减速的作用
CompletableFuture<Long> gmv = CompletableFuture
.supplyAsync(() -> orderService.todayGmv(), pool)
.completeOnTimeout(-1L, 500, TimeUnit.MILLISECONDS) // 超时返回默认值(Java 9 以后)
.exceptionally(e -> -1L); // 出错返回默认值,不影响其他数据
CompletableFuture<Long> users = CompletableFuture
.supplyAsync(() -> userService.activeUsers(), pool)
.completeOnTimeout(-1L, 500, TimeUnit.MILLISECONDS)
.exceptionally(e -> -1L);
CompletableFuture.allOf(gmv, users).join(); // 等所有任务结束
return new Overview(gmv.join(), users.join());要点:用独立的有界线程池;每个任务都要有超时和失败兜底;一个数据源出问题,只让对应的图表显示“暂无数据”,整个页面照常打开。
首页十几个图表接口,改成一个聚合接口一次返回(后端内部并行查询),减少网络往返和网关压力。
在哪里限:网关层(按用户、按接口)和应用层(按接口)各限一层。 怎么限:令牌桶最常用,它允许一定的突发流量,又能把平均速率控制住。单机限流用 Guava 的 RateLimiter;集群限流用 Sentinel 或 Redis 加 Lua 脚本。
令牌桶和漏桶:令牌桶按固定速率往桶里放令牌,请求拿到令牌才能执行,桶里攒的令牌可以应对突发;漏桶以固定速率处理请求,不允许突发,适合需要平滑输出的场景。
下游服务的错误率或慢调用比例超过阈值(比如 1 分钟内超过 50%),就熔断:后续请求直接走降级逻辑,不再调用下游;过一段时间进入“半开”状态,放少量请求试探,成功了就恢复。
作用:防止一个下游服务变慢,导致调用方的线程全部卡在等待上,最后整个服务被拖垮(这叫雪崩效应)。
调用链上的超时要逐层递减:网关 3 秒,应用 2 秒,调用下游 500 毫秒。如果下游的超时比上游还长,上游已经放弃了,下游还在白白干活。
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 首页打开时间 | 7 到 8 秒 | 约 300 毫秒 |
| 单个图表接口 P99 | 超过 4 秒 | 约 50 毫秒(缓存命中)/ 约 200 毫秒(未命中) |
| 早高峰数据库 CPU | 90% 以上 | 30% 左右 |
| 对下单业务的影响 | 高峰期偶发超时 | 完全隔离,无影响 |
每一步贡献了多少(讲的时候能显出你懂原理):索引和 SQL 优化解决了单次查询慢;预计算解决了重复计算;缓存解决了高并发;并行化解决了串行等待;限流、熔断、隔离解决了互相连累。
第一层:「接口慢你会怎么排查?」 > 先看监控和链路追踪定位慢在哪一段;应用内部用 Arthas 的 trace 找到具体方法;数据库用慢查询日志加 EXPLAIN;然后按“数据库 → 架构 → 缓存 → 应用 → 高可用”的顺序优化,先做成本低的。
第二层:「为什么不一开始就加缓存?」 > 缓存只能掩盖慢查询,不能解决它。缓存一失效,慢查询照样把数据库打满,这就是缓存击穿和雪崩的根源。所以先把查询本身优化好,再用缓存扛并发。
第二层:「预计算和缓存有什么区别?」 > 缓存是把“查询结果”存起来,第一次还是要实时计算,而且失效后要重新计算;预计算是在数据变化时就把结果算好,查询时永远不需要实时聚合。大盘这种“读远多于写、结果大家都一样”的场景,预计算更彻底。
第三层:「预计算的数据和实时数据对不上怎么办?」 > 页面上标注数据更新时间;实时结果和离线结果定期对账,差异超过阈值告警;最终以离线结果为准。
第三层:「缓存删除失败导致脏数据怎么办?」 > 用 binlog 订阅异步删除,失败进消息队列重试;缓存设过期时间兜底;对一致性要求高的场景用延迟双删。
第三层:「熔断以后用户看到的是什么?」 > 对应的图表显示缓存里的旧数据或者“暂无数据”,页面其他部分正常。降级是按图表粒度做的,不是整页失败。
适合的场景: - 「如果让你优化一个很慢的查询接口,你会怎么做?」 - 「设计一个支撑高并发的查询服务」 - 「缓存怎么用?会遇到什么问题?」 - 「怎么保证服务的高可用?」
说法: > “我的思路是先测量再优化。比如一个大盘接口很慢,我会……(按第 2 到 7 节的顺序讲)”
和你真实经历的衔接:讲到限流时,可以说你真实做过的部分: > “我在美团做智能体平台时遇到过类似的问题:查头部商品的商家时,一次请求会放大成大量下游查询,我们用令牌桶把请求速率限制在每秒 5000 次,超出的排队,失败的有限重试,持续失败就降级。”
底线:方法论可以讲得很深,但不要把这个案例说成是你做的。面试官追问“你们当时数据库是什么配置”“这个项目几个人做的”,编出来的细节很容易露馅。
由 HiveGPT 托管 · 举报