Skip to content

这是一次真实接口优化的复盘。系统是一个固定资产管理系统,工作台是登录后的第一屏——今日统计、我的资产、待办审批、预警数字、最近操作,功能不复杂,但压测数据很难看:20 QPS 就饱和,P99 干到 1.7 秒

这篇按当时的排查顺序写:先定位瓶颈在哪,再讲动了哪三刀,最后看数据。没有黑科技,全是朴素手段,但每一步的取舍值得说清楚——尤其是「为什么这个查询不能扔进异步线程」和「批量化引入的时序 bug」,都是踩过才知道的。


一、先定位:别猜,数 SQL

优化接口的第一原则:先搞清楚它到底干了多少事,而不是上来就加缓存

打开代码,这个接口的结构出乎意料地简单粗暴:

java
@GetMapping("/workbench/data")
public R<Map<String, Object>> workbenchData() {
    // 先查当前用户的待办业务 ID
    List<String> businessIds = workbenchMapper.selectUserPendingBusinessIds(userId);

    result.put("todayStats", workbenchMapper.selectTodayStats(userId));       // 6 个标量子查询嵌 1 条 SQL
    result.put("myAssets", workbenchMapper.selectMyAssets(userId));
    result.put("myApplications", workbenchMapper.selectMyApplications(userId));
    result.put("pendingApprovals", ...);                                       // 依赖上面的 businessIds
    result.put("pendingInventories", workbenchMapper.selectPendingInventories(userId));
    result.put("pendingReturns", workbenchMapper.selectPendingReturns(userId)); // 三表 JOIN
    result.put("maintenanceAlert", ...);                                       // 全表 COUNT
    result.put("repairAlert", ...);                                            // 多表 COUNT
    result.put("recentOperations", ...);                                       // 巨型 SQL,见第四节
    result.put("idleAlert", ...);                                              // 全表 COUNT
    result.put("borrowOverdueAlert", ...);                                     // 三表 JOIN COUNT
    return R.ok(result);
}

数一遍:一个请求,串行跑了 12 个数据库查询。里面有几种典型的重货:

查询问题
最近操作记录最重:扫操作日志表,对每一行做十几个 JSON 提取,还有按行执行的相关子查询(每行反查业务表拿单号、反查流程表拿审批状态)
今日统计1 条 SQL 里嵌 6 个标量子查询,跨 4 张表
4 个预警 COUNT全表扫描/多表 JOIN 的聚合——但它们只是看板数字
待归还三表 JOIN,而且和借用超期 COUNT 逻辑高度重叠(重复计算)

画出来是这样:

工作台接口串行 12 查询示意图

核心结论一句话:总延迟 = 12 项相加。压测 20 QPS 饱和,不是哪个查询特别慢,而是单请求太重——每个请求都要在连接池里排队占用连接这么久,30 个并发用户一上来,池子(20 个连接)直接排队。

定位清楚了,改造思路自然出来:并行跑、砍掉不必要的实时计算、把最重的拆掉


二、第一刀:串行改并行

最朴素的一刀:12 个查询里大部分互不依赖,用 CompletableFuture 并行提交,总延迟从「相加」变成「取最慢的一个」。

java
// 专用线程池:8 线程,CallerRunsPolicy 兜底(池满时任务退回请求线程执行,不丢任务)
ExecutorService wbPool = new ThreadPoolExecutor(8, 8, 60, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(32), r -> new Thread(r, "wb-parallel"),
        new ThreadPoolExecutor.CallerRunsPolicy());

CompletableFuture<Object> f1 = CompletableFuture.supplyAsync(
        () -> workbenchMapper.selectMyAssets(userId), wbPool);
// ... 7 项无依赖查询全部并行

但这里有一个容易踩出安全事故的坑

数据权限查询不能进异步线程

4 个预警 COUNT 挂着 @DataPermission 注解——按登录人的数据权限自动过滤数据(管理员看全部,普通员工只看自己部门的)。这个拦截器靠 ThreadLocal 从请求线程拿当前登录用户。

把它扔进异步线程池,ThreadLocal 里的上下文是空的:轻则报错,重则过滤条件静默失效——普通员工看到全公司的数据。这不是性能问题,是数据泄露事故。

所以编排原则是两条:

  1. 登录上下文相关的取值(userId、userName、dataScope)全部在请求线程先取好,异步任务里只用现成变量;
  2. 带数据权限的查询不进异步池,留在请求线程执行——配合下一刀的缓存,让它们在热路径上基本不跑。

优化后的编排长这样:

优化后查询编排示意图


三、第二刀:预警数字加 60 秒缓存

4 个预警 COUNT(维保到期、维修待处理、闲置、借用超期)是全表/多表聚合,每次请求都现算是 P99 的主要贡献者之一。但它们是什么性质的数据?看板数字——用户不会拿它对账,晚一分钟没有任何人能感知到。

于是给它们加一层进程内 TTL 缓存,60 秒过期:

java
public synchronized Map<String, Object> getOrLoad(String key, Supplier<Map<String, Object>> loader) {
    Entry e = cache.get(key);
    if (e != null && System.currentTimeMillis() - e.at < TTL_MS) {
        return e.value;   // 60 秒内直接命中,SQL 一次都不跑
    }
    Map<String, Object> v = loader.get();   // 过期后在请求线程回源
    cache.put(key, new Entry(v, System.currentTimeMillis()));
    return v;
}

实现就 30 行,一个 ConcurrentHashMap。这里有几个刻意做的取舍:

  • 不引 Redis、不引 Caffeine:为一个看板数字引入新依赖不值得,30 行代码零依赖最干净;
  • 双实例各缓存各的:系统部署了两个后端实例,各自缓存各自的。最坏情况两个实例的数字短暂不一致——看板场景完全可接受;
  • 60 秒 TTL:预警数字的变化频率本来就是小时级的,60 秒延迟和实时没有体感差别。

这一刀的通用逻辑是:缓存换来的代价是数据延迟,能接受多久的延迟,决定了这个数据能不能缓存、缓存多久。看板数字和库存数字的答案完全不同。


四、第三刀:巨型 SQL 瘦身

现在剩最重的那块:最近操作记录。

原来的 SQL 是一条巨型查询:取最近 10 条操作日志,每一行内嵌四类相关子查询——反查领用单号、反查维保单号、反查借用单号、反查流程审批状态。相关子查询的执行方式是「外层每返回一行,子查询就执行一遍」:10 行日志 × 4 类反查 = 最多 40 次额外查询,全都藏在这一条 SQL 里。

拆法很直接,按「计算发生在哪」分两类:

  • 行内计算(这一行自己的字段加工:CASE WHEN 转中文、时间格式化)→ 留在 SQL;
  • 跨行反查(拿这行的 ID 去别的表查东西)→ SQL 只返回 ID,Java 侧批量 IN 反查后回填

巨型 SQL 拆分为基础查询加批量反查示意图

40 次零散查询变成 1 + 6 次(1 次基础查询 + 6 个批量反查),批量 IN 每次都走索引,这是数据库最喜欢的工作方式。

拆的过程中踩了两个坑,都值得记下来:

坑一:雪花 ID 的精度丢失。业务单据的 ID 是雪花 ID(19 位 Long),SQL 里转成数字返回,过 JSON 传到前端 JavaScript 就丢精度了(JS 的 Number 只能安全表示 2^53)。原来巨型 SQL 里有个不起眼的 CAST(... AS CHAR),拆分时差点漏掉——现在统一在 SQL 里把所有 ID 转 CHAR 再返回。

坑二:批量化引入的时序依赖。这个 bug 是单测抓到的(写测试这件事又一次救了自己)。审批类的操作记录,它对应的业务 ID 藏在流程信息里——要先查流程表才知道 businessId 是什么,然后才能拿它去反查业务单号。我第一版把「批量反查单号」放在了「查流程」之前,结果所有审批行的单号永远是 null。原 SQL 每行自己发子查询,天然没有这个问题;批量化把「先查 A 再查 B」的顺序依赖引入了,而顺序恰恰是写错最隐蔽的地方。

java
// 错误顺序(第一版):flow 还没查,审批行业务 ID 还是空的
Map<Long, String> allocationCodes = fetchCodeMap(allocationIds, ...);   // ← 审批行的 ID 还没归集进来
Map<Long, String> flowByTaskId = fetchFlowByTaskId(flowTaskIds);

// 正确顺序:先查 flow → 归集审批行的业务 ID → 再批量反查单号
Map<Long, String> flowByTaskId = fetchFlowByTaskId(flowTaskIds);
collectApprovalBusinessIds(flowByTaskId, allocationIds, maintenanceIds, ...);
Map<Long, String> allocationCodes = fetchCodeMap(allocationIds, ...);

五、连接池:并行化的隐形天花板

并行化有一个隐藏代价:单请求的峰值并发连接数上升了。原来一个请求同时只占 1 个连接,现在最多 8 个。如果连接池只有 20,30 个并发用户理论峰值要 240 个连接——池子成了新的排队点。

所以并行化之前先核对了连接池配置(生产 maxPoolSize=20),并把它当成容量账来算:内部系统真实用户并发低,8×并发数远小于池容量,没问题;但压测 30 并发时池必然排队——这个预判在后面的数据里能直接看出来。

并行化不是免费的,它只是把「串行排队」换成了「并行抢池」。收益取决于你的池子有没有余量。


六、数据说话

部署后同口径重测(同一接口、同一并发档位、各压 20 秒):

档位指标优化前优化后变化
c=10吞吐19 QPS22 QPS+16%
c=10平均延迟523ms451ms-14%
c=10P99871ms717ms-18%
c=30吞吐20 QPS32 QPS+60%
c=30平均延迟1464ms924ms-37%
c=30P991678ms1234ms-26%

0 错误 0 超时,接口返回的数据逐块核对过(单号回填、预警数字都正常)。

诚实解读这组数据:c30 吞吐 +60%、平均延迟砍掉三分之一,20 QPS 的天花板抬到了 32——提升是真实的。但它没有达到「总延迟=最慢一个查询」的理论值,原因就是第五节那笔账:30 个并发用户 × 单请求最多 8 条并行连接,远超连接池的 20,排队吃掉了并行化的一部分收益;另外最近操作记录的基础查询本身仍是最重的单查询。

所以第二轮的方向也清楚了:先对重查询跑 EXPLAIN 拿证据,按证据补索引;最近操作记录的基础查询再瘦身一轮。优化是迭代,不是一次性手术。


七、复盘三条经验

  1. 优化前先数清楚接口干了多少事。12 个查询里有 4 个是看板数字,根本不需要毫秒级实时——先把「不必要的实时」砍掉,比任何算法优化都立竿见影。
  2. 并行化之前先查 ThreadLocal 类的隐式依赖。数据权限、事务上下文、MDC 日志追踪都靠 ThreadLocal,扔进异步池就断。取值前置到请求线程、带依赖的查询留原地,是通用解法。
  3. 批量化会引入顺序依赖,单测必须跟上。把按行子查询改成批量反查,等于把「每行自带上下文」改成「共享一个执行顺序」——顺序写错一行代码,结果就是整类数据悄悄变空。这次时序 bug 就是单测抓的,没有测试它就直接上线了。

最后说一句这个案例和秒杀系统的区别:秒杀是把「每秒几万次」压到数据库能接住,靠的是分层削峰;工作台是「每次请求本身太重」,靠的是并行编排和缓存。一个是流量维度的问题,一个是单请求维度的问题——真实系统里两个都会遇到,手段完全不同,别混用。

相关的知识点:缓存延迟与一致性的取舍展开在缓存与数据库一致性;容量评估和过载保护(连接池排队就是最朴素的「过载自限流」)的体系化讲法在接口限流防刷