三高系统设计学习案例:查询接口优化

三高系统设计学习案例:数据大盘查询接口优化

用途说明:这是一个学习用的典型案例,用来理解业界常见的高并发、高性能、高可用方案。不是你的经历,面试中不要说成是自己做的。 面试中的正确用法:被问到“接口慢怎么优化”“高并发查询服务怎么设计”时,说“我的排查思路是……,常见的方案有……”;讲到具体经历时,只讲你真实做过的部分(比如智能体平台批量查询的令牌桶限流)。 文中的数字都是示例数字,用来说明量级,不是真实数据。


1. 案例背景

某公司有一个运营数据大盘,首页有十几个图表:今日交易额、订单数、各城市排名、近 7 天趋势等。前端打开首页会同时调用十几个接口,后端从 MySQL 里实时查询和计算。

随着业务增长,出现了三个问题: 1. 慢:首页打开要 7 到 8 秒,个别图表接口超过 10 秒。 2. 扛不住:每天早上 9 点运营集中打开大盘,接口超时、数据库 CPU 飙到 90% 以上。 3. 会连带出问题:大盘查询和下单业务共用同一个数据库,高峰期把数据库打满,连带影响了下单。

目标:P99 延迟降到 300 毫秒以内;支撑早高峰 10 倍的流量;大盘出问题时不能影响核心业务。


2. 第一步:定位问题(先找到时间花在哪里)

原则:先测量,再优化。不要凭感觉改代码。

2.1 看全局:链路追踪和监控

先看 APM 监控(比如 SkyWalking、公司内部的链路追踪系统),找到一次慢请求的完整调用链,看每一段花了多少时间:网关、应用内处理、数据库、缓存、下游接口。

这一步的结论(示例):80% 的时间花在数据库查询上,另外有几个图表接口是串行调用三四个下游服务。

2.2 看应用内部:Arthas

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 查询问题)。

2.3 看数据库:慢查询日志和 EXPLAIN

-- 开启慢查询日志,记录超过 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。


3. 第二步:数据库层优化(成本最低,先做)

3.1 索引优化

原 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 条件里有一边没有索引。

3.2 消灭 N+1 查询

原代码:先查出 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())));

3.3 冗余字段(反范式)

如果每次都要关联用户表、商品表取名称,可以在订单表里冗余常用字段(比如城市名称),避免多表关联。代价:数据重复,源头变化时要同步更新。适合变化不频繁的字段。

3.4 深分页

-- 慢:要先扫描 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 毫秒。但高峰期数据库压力仍然很大,因为每次打开大盘都要实时聚合。


4. 第三步:架构层优化(解决根本问题)

4.1 把实时计算改成预计算(数据岗的核心思路)

根本问题:大盘的图表本质上都是聚合查询,每次打开都从明细数据里实时算一遍,用户越多算得越多,而且算出来的结果几乎都一样。

方案: - 准实时指标(今日交易额、订单数):用 Flink 消费订单的 binlog(通过 Canal 或 Flink CDC),按分钟聚合,结果写进 Redis 或 OLAP 引擎。接口直接读结果,不再扫明细。 - 历史趋势(近 7 天、近 30 天):用离线任务每天预算好,写入结果表。 - 灵活的多维分析:把明细同步到 Doris 这类 OLAP 引擎,对高频查询建物化视图,查询时自动命中预聚合结果。

为什么这是最有效的一步:把“每次查询都计算”变成“数据变化时计算一次”,查询成本和用户数基本无关了。

代价:数据有分钟级的延迟,要在页面上标注“数据更新时间”;多了一条数据链路,要做实时数据和离线数据的对账。

4.2 读写分离与资源隔离

问题:大盘查询和下单业务共用一个数据库,查询把数据库打满会连累下单。

方案: - 大盘只读从库(或者干脆只读 OLAP 和 Redis),主库只服务交易写入。 - 主从延迟的问题:从库数据可能落后几百毫秒。对大盘来说可以接受;对“下单后马上查订单”这种场景,要强制读主库。

4.3 分库分表加 ES(数据量继续增长时)

场景:订单表到了几十亿行,单表查询和写入都变慢。

方案: - 水平拆分:按用户 ID 做哈希,拆成多个库、多张表(比如 4 个库、每库 64 张表)。按用户 ID 查询时直接定位到某张表。 - 新问题:按非分片键查询(比如按商家、按时间范围查)需要扫所有分表。解法:用 Canal 监听 binlog,把数据同步到 ES,复杂条件查询走 ES。 - 再一个新问题:ES 是近实时的,写入后默认约 1 秒才能查到。如果业务要求“写完马上能查到”:写入后的短时间内,先查 Redis 或主库里的最新数据;或者写入 ES 时让请求等到数据可查再返回(代价是写入变慢)。

面试要点:每引入一个组件都会带来新问题,要能讲清楚“为什么引入、带来了什么问题、怎么解决”。


5. 第四步:缓存(扛高并发的关键)

5.1 两级缓存

请求 → 本地缓存(Caffeine,每台机器各有一份,纳秒到微秒级)
     → 没命中 → Redis(集中缓存,约 1 毫秒)
     → 没命中 → 数据库或 OLAP
     → 查到后逐级回填

5.2 缓存的三大问题(必考)

问题 是什么 解决方案
穿透 查一个根本不存在的数据,缓存和数据库都没有,每次都打到数据库 缓存空值(设短过期时间);用布隆过滤器提前拦截不存在的 key
击穿 某个热点 key 过期的瞬间,大量请求同时打到数据库 互斥锁:只让一个请求去重建缓存,其他请求等待或返回旧值;或者“逻辑过期”:数据不设物理过期,后台异步刷新
雪崩 大量 key 同一时间过期,或者 Redis 整个挂了 过期时间加随机值打散;多级缓存兜底;Redis 用集群加哨兵保证高可用;数据库前加限流

5.3 热点 key

问题:早上 9 点所有人都看“全国今日交易额”,这一个 key 的请求全部落到 Redis 集群的同一个节点上,把这个节点打满。

方案:热点数据放本地缓存(最有效);或者把一个 key 复制成多份(比如 gmv_today_0 到 gmv_today_9),读的时候随机选一个,分散到不同节点。

5.4 缓存和数据库的一致性

常用方案(Cache Aside):读的时候先读缓存,没有再读数据库并写回缓存;更新的时候先更新数据库,再删除缓存。

为什么是删除而不是更新缓存:两个请求并发更新时,更新缓存的顺序可能和更新数据库的顺序不一致,导致缓存里留下旧值;删除就没有这个问题,下次读时自然加载最新值。

删除缓存失败怎么办:用 Canal 订阅 binlog,异步删除缓存,失败了放进消息队列重试;同时缓存一定要设过期时间,作为最后的兜底。

本地缓存怎么失效:每台机器各有一份,可以通过消息广播让所有机器删除;或者干脆把本地缓存的过期时间设得很短,接受几秒的不一致。


6. 第五步:应用层优化

6.1 串行改并行

一个图表接口要调用三个下游服务,原来是一个接一个调,总耗时是三者之和。改成并行,总耗时约等于最慢的那一个。

// 专用线程池:不要用公共线程池,避免和其他业务互相影响
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());

要点:用独立的有界线程池;每个任务都要有超时和失败兜底;一个数据源出问题,只让对应的图表显示“暂无数据”,整个页面照常打开。

6.2 前端请求合并

首页十几个图表接口,改成一个聚合接口一次返回(后端内部并行查询),减少网络往返和网关压力。

6.3 其他


7. 第六步:高可用(扛得住、不连累别人)

7.1 限流

在哪里限:网关层(按用户、按接口)和应用层(按接口)各限一层。 怎么限:令牌桶最常用,它允许一定的突发流量,又能把平均速率控制住。单机限流用 Guava 的 RateLimiter;集群限流用 Sentinel 或 Redis 加 Lua 脚本。

令牌桶和漏桶:令牌桶按固定速率往桶里放令牌,请求拿到令牌才能执行,桶里攒的令牌可以应对突发;漏桶以固定速率处理请求,不允许突发,适合需要平滑输出的场景。

7.2 熔断

下游服务的错误率或慢调用比例超过阈值(比如 1 分钟内超过 50%),就熔断:后续请求直接走降级逻辑,不再调用下游;过一段时间进入“半开”状态,放少量请求试探,成功了就恢复。

作用:防止一个下游服务变慢,导致调用方的线程全部卡在等待上,最后整个服务被拖垮(这叫雪崩效应)。

7.3 降级

7.4 隔离

7.5 超时设置

调用链上的超时要逐层递减:网关 3 秒,应用 2 秒,调用下游 500 毫秒。如果下游的超时比上游还长,上游已经放弃了,下游还在白白干活。

7.6 容量和压测


8. 效果(示例数字)

指标 优化前 优化后
首页打开时间 7 到 8 秒 约 300 毫秒
单个图表接口 P99 超过 4 秒 约 50 毫秒(缓存命中)/ 约 200 毫秒(未命中)
早高峰数据库 CPU 90% 以上 30% 左右
对下单业务的影响 高峰期偶发超时 完全隔离,无影响

每一步贡献了多少(讲的时候能显出你懂原理):索引和 SQL 优化解决了单次查询慢;预计算解决了重复计算;缓存解决了高并发;并行化解决了串行等待;限流、熔断、隔离解决了互相连累。


9. 面试官可能的追问(三层)

第一层:「接口慢你会怎么排查?」 > 先看监控和链路追踪定位慢在哪一段;应用内部用 Arthas 的 trace 找到具体方法;数据库用慢查询日志加 EXPLAIN;然后按“数据库 → 架构 → 缓存 → 应用 → 高可用”的顺序优化,先做成本低的。

第二层:「为什么不一开始就加缓存?」 > 缓存只能掩盖慢查询,不能解决它。缓存一失效,慢查询照样把数据库打满,这就是缓存击穿和雪崩的根源。所以先把查询本身优化好,再用缓存扛并发。

第二层:「预计算和缓存有什么区别?」 > 缓存是把“查询结果”存起来,第一次还是要实时计算,而且失效后要重新计算;预计算是在数据变化时就把结果算好,查询时永远不需要实时聚合。大盘这种“读远多于写、结果大家都一样”的场景,预计算更彻底。

第三层:「预计算的数据和实时数据对不上怎么办?」 > 页面上标注数据更新时间;实时结果和离线结果定期对账,差异超过阈值告警;最终以离线结果为准。

第三层:「缓存删除失败导致脏数据怎么办?」 > 用 binlog 订阅异步删除,失败进消息队列重试;缓存设过期时间兜底;对一致性要求高的场景用延迟双删。

第三层:「熔断以后用户看到的是什么?」 > 对应的图表显示缓存里的旧数据或者“暂无数据”,页面其他部分正常。降级是按图表粒度做的,不是整页失败。


10. 怎么用在面试里

适合的场景: - 「如果让你优化一个很慢的查询接口,你会怎么做?」 - 「设计一个支撑高并发的查询服务」 - 「缓存怎么用?会遇到什么问题?」 - 「怎么保证服务的高可用?」

说法: > “我的思路是先测量再优化。比如一个大盘接口很慢,我会……(按第 2 到 7 节的顺序讲)”

和你真实经历的衔接:讲到限流时,可以说你真实做过的部分: > “我在美团做智能体平台时遇到过类似的问题:查头部商品的商家时,一次请求会放大成大量下游查询,我们用令牌桶把请求速率限制在每秒 5000 次,超出的排队,失败的有限重试,持续失败就降级。”

底线:方法论可以讲得很深,但不要把这个案例说成是你做的。面试官追问“你们当时数据库是什么配置”“这个项目几个人做的”,编出来的细节很容易露馅。

由 HiveGPT 托管 · 举报