AI 摘要
AI
正在生成摘要...

本文记录一次真实的线上性能优化经历。为保护项目和业务信息,文中的平台统一称为“考试学习平台”,表名、类名、接口路径、服务器配置和业务标识均已做泛化或脱敏处理。文中部分数据为压测模型和容量评估结果,不代表所有生产环境都能直接复用。

一、背景:看起来只是“查询和发卷”,为什么会成为性能问题?

考试学习平台的核心链路大致分为两类:

  • 读链路:学生查看可参加的考试、进入考试、考后回顾;
  • 写链路:管理员创建考试后,为参与者生成个人试卷。

系统采用多实例部署,数据库为关系型数据库。业务高峰期可能有上万名在线用户,试卷题目和选项数据规模已经达到千万级甚至更高。

这类系统有一个很容易被忽略的特点:一次业务操作背后往往不是一条 SQL,而是一棵调用树。如果每一层都继续查询数据库,单个请求的 SQL 数量会随着考试题目数、题目选项数和参与人数快速增长。

本次优化没有一开始就盲目增加机器,而是先沿着调用链回答三个问题:

  1. 一次请求到底执行了多少次 SQL?
  2. 这些查询是否可以合并成少量、可走索引的批量查询?
  3. 在并发场景下,哪些动作应该由一个线程完成,哪些动作可以由多个线程竞争执行?

下面的对比数据采用脱敏后的代表性压测区间,主要用于说明优化方向和数量级变化。由于真实请求参数、数据库缓存命中率和并发模型会影响最终结果,实际项目中仍应以执行计划、压测报告和线上监控为准。

最终,本次实际落地和重点设计的范围包括:考试列表查询优化、考试详情 N+1 优化、发卷流程梳理、异步发卷与学生实时进入考试的并发协调、补齐部分考试类型的预发卷,以及发卷模板查询复用。

二、第一部分:读链路优化

2.1 学生端考试列表:拆分复杂权限 SQL

2.1.1 原始问题

学生端考试列表不仅要查询考试基本信息,还要判断当前学生是否有权限参加考试。平台支持多种开放方式,例如公开考试、指定部门、指定人员和指定区域。

原实现把多种权限判断全部写进一条主查询,并通过多个 OR 子查询连接。部门层级判断还依赖字符串路径匹配函数。这样的 SQL 有几个问题:

  • 主查询承担了考试信息、权限判断和统计信息等多种职责;
  • 多个 OR 分支让优化器更难选择稳定的执行计划;
  • 字符串集合匹配通常无法像普通等值条件一样充分使用索引;
  • SQL 随业务规则继续增加,后续维护和排查都比较困难。

这里最重要的判断不是“SQL 越长越高级”,而是:权限计算和考试列表展示实际上是两个不同阶段,可以拆开处理。

2.1.2 优化思路:先求可访问考试 ID,再查考试列表

将权限判断单独抽成一个查询,使用 UNION ALL 汇总不同开放方式能够访问的考试 ID,再由主查询通过 IN 过滤。

抽象后的逻辑如下:

SQL
SELECT DISTINCT exam_id
FROM (
    -- 公开考试
    SELECT exam_id FROM exam
    WHERE open_type = 'PUBLIC'

    UNION ALL

    -- 指定部门
    SELECT exam_id FROM exam_department
    WHERE user_id = :userId

    UNION ALL

    -- 指定人员
    SELECT exam_id FROM exam_user
    WHERE user_id = :userId

    UNION ALL

    -- 指定区域
    SELECT exam_id FROM exam_region
    WHERE user_id = :userId
) accessible_exam;

主查询只负责考试本身的筛选、学生历史成绩汇总、分类和排序:

SQL
SELECT
    e.id,
    e.name,
    e.total_score,
    e.pass_score,
    COALESCE(history.frequency, 0) AS frequency,
    COALESCE(history.max_score, 0) AS max_score
FROM exam e
LEFT JOIN exam_history_summary history ON history.exam_id = e.id
WHERE e.deleted = 0
  AND e.end_time >= CURRENT_TIMESTAMP
  AND e.id IN (:accessibleExamIds)
ORDER BY e.create_time, e.id DESC;

应用层的职责也变得清晰:

JAVA
List<String> accessibleExamIds = examMapper.selectAccessibleExamIds(userId);
if (accessibleExamIds == null || accessibleExamIds.isEmpty()) {
    return Collections.emptyList();
}

query.setAccessibleExamIds(accessibleExamIds);
query.setUserId(userId);
return examMapper.selectExamList(query);

2.1.3 查询次数和效果对比

以一次学生端考试列表请求为例,原实现的查询次数并不是简单的“1 条 SQL”。主查询中包含多种权限子查询,数据库需要在一次执行计划中反复处理不同开放类型的判断。按照应用侧可观察到的逻辑,可以拆解为:

场景 应用层显式查询 权限判断 业务数据查询 合计特征
优化前 1 次 Mapper 调用 4 个权限分支嵌在主 SQL 中 1 条复杂主查询 查询次数少,但单条 SQL 责任过重
优化后 2 次 Mapper 调用 1 条 UNION ALL 权限查询 1 条简化主查询 固定为 2 条边界清晰的查询

这里不能简单说“1 条变成 2 条所以变慢”。优化前虽然只有一次数据库调用,但一次调用中包含多个 OR、子查询、层级判断和历史成绩聚合;优化后把权限计算和列表查询拆开,使每条查询更容易使用对应索引,也更容易单独查看执行计划。

在压测中,列表接口的平均响应时间大致从 180~260 ms 降至 90~140 ms,P95 从约 400~550 ms 降至 220~320 ms。这类收益主要来自执行计划更稳定和复杂条件拆分,而不是单纯减少了 SQL 条数。

2.1.4 为什么选择 UNION ALL

各权限分支之间可能返回同一个考试,因此最终需要去重。但在每个分支内部没有必要提前去重,先使用 UNION ALL 汇总,最后统一 DISTINCT,通常比多个分支分别去重更容易控制成本。

当然,这并不是任何数据库、任何数据分布下的绝对结论。实际改造后仍需要通过执行计划和压测确认:

  • 权限关联表是否有用户、部门、区域和考试维度的索引;
  • IN 列表的长度是否可控;
  • 权限 ID 是否需要分页或临时表承载;
  • 考试历史统计是否需要进一步预聚合。

2.1.5 关于层级部门和字符串路径匹配

部门树判断中的字符串路径匹配是潜在风险点,但它不一定是当前最主要的瓶颈。若查询先通过用户主键把数据范围压缩到少量记录,实际影响可能可接受。

如果后续数据规模继续增长,可以逐步演进为:

  1. 应用层先计算用户所属部门及其子部门 ID;
  2. 使用部门 ID 列表做普通索引查询;
  3. 在更复杂的组织树场景中引入闭包表或部门路径表。

优化的原则是先解决高频、确定性的瓶颈,再处理需要较大数据模型改造的长期问题。

2.2 进入考试和考后回顾:消除多层 N+1

2.2.1 原始调用链

进入一场考试时,页面通常需要完整的考试结构:考试信息、大题、题目、选项、答案。原实现的调用关系类似:

TEXT
查询考试
  └─ 查询大题列表
       └─ 对每个大题查询题目
            ├─ 对每道题查询选项
            └─ 对每道题查询答案

假设一场考试有 5 个大题、150 道题、600 个选项,那么数据库访问次数会从“几条查询”膨胀为大量小查询。单次查询可能并不慢,但网络往返、连接占用、ORM 映射和事务上下文会叠加,最终表现为响应时间变长、数据库连接池紧张。

2.2.2 优化方案:少量批量查询 + Java 内存组装

本次没有把所有内容强行拼成一条超长 JOIN,而是采用 5 条边界清晰的查询:

TEXT
1. 查询考试基本信息                         → 1 行
2. 根据考试 ID 查询大题                      → 约 5 行
3. 根据大题 ID 集合批量查询题目               → 约 150 行
4. 根据题目 ID 集合批量查询选项               → 约 600 行
5. 根据考试 ID 查询答案                       → 约 150 行

关键 SQL 结构是 IN 加批量参数:

SQL
SELECT ...
FROM trainee_question
WHERE topic_id IN (:topicIds)
ORDER BY question_order;

SELECT ...
FROM trainee_question_option
WHERE question_id IN (:questionIds)
  AND student_id = :studentId
  AND exam_id = :examId
ORDER BY option_order;

查询完成后,在内存中建立索引:

JAVA
Map<String, List<Question>> questionsByTopic = questions.stream()
    .collect(Collectors.groupingBy(Question::getTopicId));

Map<String, List<Option>> optionsByQuestion = options.stream()
    .collect(Collectors.groupingBy(Option::getQuestionId));

Map<String, List<Answer>> answersByQuestion = answers.stream()
    .collect(Collectors.groupingBy(Answer::getQuestionId));

然后遍历大题,将题目、选项和答案挂载回对象树。开始考试和考后回顾共用同一套组装方法,只通过参数控制是否隐藏正确答案:

JAVA
ExamFullView view = loadExamFull(examId, studentId);
if (hideAnswer) {
    view.getQuestions().forEach(q ->
        q.getOptions().forEach(option -> option.setCorrect(null)));
}
return view;

2.2.3 查询次数拆解:从随题目数量增长到固定 5 次

假设一场考试包含 5 个大题、150 道题、每题平均 4 个选项,原始调用链大致为:

TEXT
1                  查询考试基本信息
1                  查询大题列表
5                  每个大题查询题目
150                每道题查询选项
150                每道题查询答案
────────────────────────────────
约 307 次查询

这里的 307 次是按典型试卷结构估算的,实际次数会随大题数和题目数变化。它的增长公式近似为:

TEXT
优化前查询次数 ≈ 2 + 大题数 + 题目数 × 2

改造后变为固定的 5 次:

TEXT
1  查询考试基本信息
1  查询大题
1  根据大题 ID 集合批量查询题目
1  根据题目 ID 集合批量查询选项
1  根据考试 ID 查询答案
────────────────────────────
共 5 次查询

因此,在上述试卷规模下,数据库往返次数从约 307 次降到 5 次,降幅约为 98%。这不是把 307 条 SQL 拼成一条超大 SQL,而是把具有相同访问模式的查询合并为批量查询,再由应用层完成对象组装。

2.2.4 为什么不直接使用一条大 JOIN

一条 JOIN 看起来可以减少 SQL 数量,但在超大选项表上,JOIN 可能带来严重的中间结果集膨胀:考试、大题、题目、选项和答案之间存在一对多关系,多张表同时展开时会产生大量重复列和临时数据。

因此,这里采用“按层批量查询”的折中方案:

  • 查询次数从数量级上的 N+1 降为固定的少量查询;
  • 每条查询都保留清晰的过滤条件;
  • 通过 ID 集合控制数据范围;
  • 由 Java 在内存中完成组装,避免数据库生成庞大的笛卡尔式中间结果。

这类方案的前提是单份试卷规模可控,并且批量查询字段足够精简。不能无条件 SELECT *,也不能忽略超大试卷和极端并发下的内存上限。

2.2.5 索引是批量查询的基础

批量查询本身并不等于高性能。至少需要检查以下访问路径是否有索引:

SQL
-- 考试与大题
CREATE INDEX idx_topic_exam ON trainee_exam_topic(exam_id);
CREATE INDEX idx_question_topic ON trainee_question(topic_id);

-- 题目与选项
CREATE INDEX idx_option_question ON trainee_question_option(question_id);
CREATE INDEX idx_option_exam_student
    ON trainee_question_option(exam_id, student_id);

-- 答案
CREATE INDEX idx_answer_exam ON trainee_question_answer(exam_id);

索引是否有效,最终要以执行计划和真实数据分布为准。尤其是千万级表,新增索引前还要评估磁盘空间、写入代价、创建时间和线上变更方式。

三、第二部分:写链路优化——发卷性能

3.1 发卷流程现状:每个学生背后约百次 SQL

原有发卷流程大致如下:

TEXT
管理员创建考试
  ├─ 保存考试基本信息
  ├─ 保存防作弊配置
  └─ 根据开放类型处理参与者
       ├─ 部门考试:保存部门关系,学生开始考试时实时发卷
       ├─ 人员考试:保存人员关系,学生开始考试时实时发卷
       └─ 区域考试:异步遍历学生并逐人发卷

单个学生发卷时,又会经历:

TEXT
创建学生考试记录
  ├─ 查询考试信息
  ├─ 查询试卷模板
  ├─ 查询模板大题和题目
  ├─ 逐条插入学生大题
  ├─ 批量插入学生题目
  └─ 批量插入学生选项

按照原实现估算,每个学生可能触发约百次 SQL。万人考试就会变成百万级 SQL 操作。问题并不只在数据库“算得慢”,还包括:

  • 大量数据库往返占用连接;
  • 线程长时间持有事务或连接;
  • 同一个模板被重复读取;
  • 管理员创建考试和学生开始考试可能同时写同一份数据。

3.1.1 单个学生的查询次数拆解

以固定组卷为例,原始流程中的查询次数可以近似拆解为:

操作 优化前次数 原因
查询考试信息 1 发卷前校验考试配置
查询试卷模板 1 获取模板基本信息
查询模板大题 约 5 每个大题单独查询
查询模板题目 约 150 每个大题下继续查询题目
查询参与者或关联数据 若干 视开放类型和实现路径而定
写入学生考试、大题、题目、选项 约 8 次及以上 大题逐条写入,题目和选项部分批量
合计 约 160~180 次数据库操作 典型试卷规模下的代表性区间

原方案文档按更完整的调用链统计,单个学生约 100 次以上 SQL;不同考试类型、题型和模板结构会使具体数字产生差异。因此这里更关注数量级:发卷成本会随着参与人数线性累加,而且每个学生都会重复读取同一份模板结构。

优化后的目标是:

操作 优化后次数 原因
批次查询考试信息 1 批次内复用
批次查询试卷模板 1 模板提升到批处理上下文
模板大题、题目查询 由模板查询一次完成 不再按学生重复查询
学生考试记录写入 1/人 每名学生一份独立记录
学生大题写入 1/人或批次内批量 取决于是否启用大题批量插入
学生题目、选项写入 2/人左右 保持已有批量写入方式
每人新增操作 约 4~6 次写入 只保留学生独有数据的生成和写入

也就是说,模板读取从“每个学生一次”变成“每个批次一次”,发卷过程中的固定成本和人均成本被拆开了。真正无法消除的是每名学生独有的试卷副本、题目顺序和选项顺序,这些数据不能简单共享。

所以,发卷优化的重点不是简单地“把线程数调大”,而是减少重复工作并保证并发下的数据唯一性。

3.2 去掉固定等待时间:用分布式令牌协调发卷

3.2.1 原始限制与并发问题

原逻辑对考试开始时间设置了一个固定的提前时间限制,例如必须距离考试开始还有一段时间才允许创建后立即发卷。这种限制容易出现两个问题:

  • 它是经验值,不一定符合所有考试场景;
  • 异步批量发卷尚未完成时,学生可能已经点击开始考试。

如果异步线程和学生请求都调用发卷方法,就可能出现同一学生被重复创建考试记录、题目和选项的问题。

3.2.2 核心设计

使用 Redis Set 作为“待发卷学生集合”:

TEXT
创建考试:将所有学生 ID 放入待处理集合

异步发卷线程:尝试从集合中删除学生 ID
学生开始考试:也尝试从集合中删除学生 ID

删除成功:当前请求获得发卷权,执行一次发卷
删除失败:说明其他请求正在处理,短暂轮询数据库等待结果

Redis 的 SREM 具有原子性,同一学生只有一个请求能够返回成功,从而将“谁负责生成试卷”变成一次明确的竞争。

伪代码如下:

JAVA
boolean acquired = redisTemplate.opsForSet()
    .remove("exam:pending:" + examId, userId) > 0;

if (acquired) {
    return distributePaper(examId, userId);
}

for (int i = 0; i < 5; i++) {
    Paper paper = paperRepository.findByExamAndUser(examId, userId);
    if (paper != null) {
        return paper;
    }
    Thread.sleep(200L);
}

// 兜底逻辑必须配合数据库唯一约束或幂等检查
return distributePaperIdempotently(examId, userId);

待处理集合需要设置过期时间,防止异常任务留下永久数据。进度计数可以使用独立的 Redis key 或数据库记录,但不能把“从集合删除成功”简单等同于“发卷已完成”,两者分别代表“获得处理权”和“处理成功”。

3.2.3 兜底逻辑为什么仍然要幂等

轮询超时后直接再次发卷并不能天然保证安全。网络抖动、进程暂停、数据库慢查询都可能导致“实际上已经写成功,但调用方暂时没查到”的情况。

因此,最终兜底必须至少具备一种保护:

  • 学生考试记录建立唯一约束;
  • 发卷入口先查询已有记录;
  • 使用数据库 UPSERT 或唯一键冲突后重新查询;
  • 将发卷状态设计为“处理中、成功、失败”,由状态机推进。

Redis 负责快速协调,数据库负责最终一致性和持久化约束,两者不能互相替代。

3.2.4 并发发卷前后对比

在没有协调机制时,同一学生可能同时触发两条发卷路径:

TEXT
异步任务:查询不存在 → 生成试卷 → 写入
学生请求:查询不存在 → 生成试卷 → 写入

两条路径都在查询时认为“还没有试卷”,所以即使平均响应时间不高,也可能产生重复数据或唯一键冲突。

加入 Redis 原子抢占后,流程变成:

TEXT
异步任务 ─┐
          ├─ SREM 竞争同一个学生令牌 ─ 只有一方进入发卷
学生请求 ─┘

代表性压测对比如下:

指标 优化前 优化后
同一学生并发发卷入口 可能为 2 个或更多 逻辑上只有 1 个获得处理权
重复写入/唯一键冲突 偶发,需要依赖异常处理 未观察到重复成功写入
学生等待策略 可能长时间等待异步任务 短轮询,超时后幂等兜底
一次发卷的协调查询 无统一协调 首次增加 1 次 Redis 原子操作

这里的优化并不是让每一次发卷都少一条 SQL,而是把并发冲突从“数据库写入后再发现”提前到“生成前竞争处理权”,降低重复计算和重复写入。

3.3 补齐部门考试和人员考试的预发卷

3.3.1 原始不一致

原流程只有区域类型考试会在创建时异步批量发卷,而部门类型和人员类型考试只保存关联关系。学生点击开始考试时,系统才实时生成个人试卷。

这会造成不同考试类型的行为不一致:

  • 区域考试把压力分散到创建后的异步任务;
  • 部门和人员考试把压力集中到考试开始瞬间;
  • 大量学生同时点击开始时,实时发卷与正常考试请求争抢数据库资源。

3.3.2 调整触发时机

更合理的触发点是考试审核通过后。审核通过意味着考试配置和参与范围基本确定,此时可以启动批量预发卷:

JAVA
if (approved(exam)) {
    switch (exam.getOpenType()) {
        case DEPARTMENT:
            executor.submit(() -> distributeForDepartments(exam));
            break;
        case USER:
            executor.submit(() -> distributeForUsers(exam));
            break;
        case REGION:
            executor.submit(() -> distributeForRegions(exam));
            break;
        default:
            throw new IllegalArgumentException("unsupported open type");
    }
}

审核通过后再发卷有两个好处:

  1. 参与者集合相对稳定,避免考试配置尚未完成时提前生成大量数据;
  2. 发卷压力提前释放,学生真正进入考试时只需要读取已生成的数据。

但这并不意味着完全取消实时发卷。预发卷任务可能失败、延迟或遗漏用户,因此学生端仍需保留“抢占式实时生成”能力,并通过前面的 Redis 令牌和数据库幂等保证安全。

3.3.3 预发卷带来的压力变化

预发卷不会减少最终需要生成的学生试卷数量,但会改变压力分布:

场景 优化前 优化后
部门/人员考试创建后 只保存关联关系 审核通过后启动批量发卷
学生开始考试时 可能同时触发大量实时发卷 大部分学生直接读取已生成试卷
数据库压力 集中在考试开始瞬间 提前分摊到审核后窗口
异常处理 主要依赖学生请求重试 异步任务统计失败,学生端保留兜底

在压测中,将“开始考试瞬间集中生成”改为“提前分批生成”后,开始考试接口的平均响应时间从约 3.0~3.5 秒 降到 900~800 ms。这个变化主要来自发卷写入被移出主请求,不代表数据库总写入量消失了。

3.4 消除发卷过程中的模板查询 N+1

3.4.1 问题定位

发卷方法每处理一个学生,就重新加载一次试卷模板。模板加载本身还包含大题和题目的多层查询:

TEXT
查询模板
  └─ 查询模板大题
       └─ 查询模板题目

如果 1000 名学生使用同一份固定模板,就会重复执行 1000 次相同的模板读取链路。这属于写链路中的 N+1,与学生进入考试时的 N+1 不同,但根因相同:循环体内重复调用了本可复用的查询。

3.4.2 将模板提升到批处理上下文

批量任务开始时先加载一次模板,然后把模板传给每个学生的发卷方法:

JAVA
PaperTemplate template = paperService.loadTemplate(paperId);

for (User user : users) {
    distributePaperWithTemplate(exam, user.getId(), template);
}

这样可以将模板读取从“每人一次”降为“每批次一次”。对于固定组卷,这几乎是直接收益;对于随机组卷,则需要区分可复用和不可复用的部分:

  • 试卷结构、题型规则和大题配置可以复用;
  • 每个学生独立抽取的题目不能直接复用;
  • 题目和选项乱序结果必须按学生独立生成;
  • 学生试卷副本和作答数据仍然必须独立保存。

模板复用不是把整份试卷对象无脑共享给所有线程。若对象会在发卷过程中被修改,应使用不可变对象、复制必要字段,或将“模板数据”和“学生试卷数据”明确分离,避免线程间相互污染。

3.4.3 模板查询次数对比

假设一个批次包含 1000 名学生,模板包含 5 个大题和 150 道题:

TEXT
优化前:
1000 × (1 次模板查询 + 5 次大题查询 + 5 次题目查询)
≈ 11000 次模板相关查询

优化后:
1 次批次模板加载
每名学生只执行自己的试卷生成和写入逻辑
≈ 1 次模板相关查询

这里的“约 11000 次”是按每个大题查询一次题目集合的实现方式估算;如果实际代码中题目查询还继续按题目拆分,次数会更高。无论具体数字是多少,优化前的共同特征都是“模板读取次数与学生数线性增长”,优化后则变为“与批次数量相关”。

在代表性压测中,1000 人批量发卷的总耗时由约 8~12 分钟 降至 5~8 分钟。耗时没有按查询次数同比例下降,是因为随机抽题、学生试卷写入和事务提交仍然存在;模板复用主要减少了重复读取和数据库连接占用。

四、这次优化中最值得保留的方法论

4.1 先画调用链,再讨论 SQL

单看一条 SQL,很难判断系统为什么慢。把请求展开成调用树后,重复查询、逐条插入和不必要的模板加载会非常明显。

4.2 批量化不是目的,减少重复工作才是目的

考试详情从多层 N+1 改成批量查询,发卷模板从逐人读取改成批次复用,本质上都是在减少重复工作。批量查询之后仍需关注 SQL 长度、参数数量、索引和内存边界。

4.3 Redis 适合做协调,不适合承担最终事实

Redis Set 可以高效完成抢占和去重,但试卷是否真正生成成功,最终仍应由数据库记录、唯一约束和状态字段确认。

4.4 异步化必须配套可观测性

异步发卷如果只有日志,没有进度和失败统计,管理员无法判断任务是否完成。后续应补充发卷总数、成功数、失败数、状态、开始时间和结束时间,并支持按考试查询。

4.5 性能优化必须同时验证正确性

本次优化覆盖以下测试:

  • 同一学生并发点击开始考试,最终只能生成一份试卷;
  • 异步发卷和实时发卷同时执行,不能重复写入;
  • 开始考试时正确答案必须隐藏,考后回顾时能够展示;
  • 不同开放类型的权限结果一致;
  • 固定组卷模板复用后,学生数据不能互相串联;
  • 预发卷失败时,学生端能够安全兜底;
  • 批量查询在空集合、超大集合和异常数据下行为正确。

五、总结:从“能用”到“能扛住高峰”

这次优化并没有依赖单一的技巧,而是同时调整了读写链路:

  1. 考试列表将复杂权限判断拆成独立阶段,降低主查询复杂度;
  2. 进入考试和考后回顾将多层 N+1 改为少量批量查询,再在内存中组装;
  3. 发卷流程识别出每人约百次 SQL 的重复成本;
  4. 通过 Redis 原子抢占协调异步发卷和学生实时发卷;
  5. 在审核通过后补齐部门和人员类型考试的预发卷;
  6. 将试卷模板查询提升到批处理上下文,避免逐人重复加载。

最重要的收获是:性能问题往往不是某一条 SQL 特别慢,而是业务代码把“本可以做一次的事情”放进了循环,把“本可以批量完成的事情”拆成了很多次调用。定位到重复工作之后,再根据数据规模选择批量查询、内存组装、缓存、异步化和分布式协调,优化才会真正落到业务瓶颈上。

评论