最近网上“6的嘞”这个词特别火,代码写得清爽、SQL 连索引都不走偏、接口响应时间从秒级降到毫秒级,身边的同事就会来一句“6的嘞”。但后端的“6”不是喊出来的,而是一步一步优化出来的。这篇文章从一个常见的订单查询接口入手,梳理一套可以直接落地的性能优化方法,包含六个实战技巧:缓存、批量处理、异步并发、索引优化、内存治理、日志异步化。无论你是刚工作不久的后端新人,还是已经在维护高并发服务的开发同学,都可以拿这套思路去对照自己的项目,看看瓶颈到底出在哪一层。
1. 背景:从“慢接口”到“6的嘞”
1.1 慢接口的常见表现
一个接口变慢,通常不是单一原因造成的。最直观的表现是响应时间很长,用户侧看到转圈,监控面板上的 TP99 持续上升。从应用层排查,可能是循环查数据库、重复查缓存、远程服务串行等待;从数据层排查,可能是 SQL 没走索引、扫描行数过大、排序走了文件排序;从基础设施层排查,可能是 CPU 飙高、GC 频繁、网络带宽打满。很多团队一开始会习惯性加机器,但如果代码里有明显的 N+1 查询,加机器也只是把慢放大到更多节点上。
典型的业务场景是订单列表页。前端要展示订单号、用户昵称、商品名、商品图片、物流状态等等。这些数据分散在订单表、用户表、商品表、物流服务中。如果处理方式是在循环里逐条查询用户和商品,那么每页 20 条订单,就可能产生 40 次甚至更多次数据库查询,再加上一次远程物流调用,接口很难快起来。“6的嘞”不是说这种代码六,而是说这种代码糟糕得让人无语。
1.2 六个优化方向
性能优化要先分方向,再动手。后端接口的性能模型可以拆成计算、IO、内存、并发四个维度。计算层面要考虑算法复杂度;IO 层面要减少数据库查询和远程调用次数;内存层面要控制对象创建和 GC 压力;并发层面要把串行等待变成并行处理。本文的六个实战技巧分别对应这几类问题:缓存先行解决重复 IO,批量处理解决 N+1 查询,异步并发解决串行等待,索引优化解决 SQL 扫描过大,内存治理解决对象分配和 GC 压力,日志异步化解决同步日志对业务线程的阻塞。
在开始改代码之前,还有一件更重要的事:先量化慢在哪里。最理想的方式是接入链路追踪,把一次请求拆成数据库耗时、Redis 耗时、远程调用耗时、业务计算耗时。没有监控数据就凭感觉优化,很可能把时间花在不重要的节点上。比如一个接口 90% 时间都在等待远程服务返回,那么优化本地 SQL 索引只能带来微弱的提升。先测量、再定位、最后优化,是这篇文章贯穿始终的原则。
2. 环境准备与版本说明
2.1 技术栈与版本选择
为了把六个技巧讲清楚,后面所有代码示例都围绕一个 Spring Boot 项目展开。示例技术栈如下:
| 组件 | 版本建议 |
|---|---|
| JDK | 8 或 11 |
| Spring Boot | 2.x |
| MyBatis-Plus | 3.5.x |
| MySQL | 5.7+ / 8.x |
| Redis | 5.x+ |
| Maven | 3.6+ |
版本需要根据你的项目实际情况调整。如果当前项目已经是 Spring Boot 3.x,那么很多javax.*包会变成jakarta.*,Redis 连接方式和部分配置类路径也会变化。本文重点演示配置思路和优化思路,不保证所有代码在你的版本中一字不差地运行。遇到版本差异时,优先查看官方迁移文档。
示例工程的依赖以 Maven 管理,核心依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>注意,MyBatis-Plus 的版本要与 Spring Boot 版本兼容。如果你用的是 Spring Boot 2.x,可以使用mybatis-plus-boot-starter;如果是 Spring Boot 3.x,则需要使用mybatis-plus-spring-boot3-starter。
2.2 示例工程结构
后面出现的关键代码,可以按照下面的结构放入工程中:
src/main/java/com/example/order/ ├── controller/OrderController.java ├── service/OrderService.java ├── service/UserCacheService.java ├── mapper/OrderMapper.java ├── entity/OrderDO.java ├── entity/UserInfo.java ├── entity/ProductInfo.java └── config/AsyncConfig.java代码示例中会出现OrderDO、UserInfo、ProductInfo这些简单实体类,为了节省篇幅不再贴重复的 getter/setter。如果你复制到本地,需要补全字段定义。文章里的核心代码都会标明放哪个文件里,方便对照。
3. 六个实战优化技巧
3.1 技巧一:缓存先行
先看一个最常见的场景:订单列表接口每次请求都要查询用户昵称、头像、商品名称、商品图片。这些数据的特点是读多写少,短期内基本不变。如果每次都从数据库或者远程接口取,不仅响应慢,还会给数据库和下游服务增加压力。这时候缓存先行是最直接的优化手段。
缓存不是简单加一层 Redis 就结束,还要考虑 key 的设计、过期时间、穿透、击穿和雪崩。先说基础用法。下面是UserCacheService的简化实现,用于从缓存中读取用户信息,缓存不存在时回源数据库:
@Service public class UserCacheService { private static final String USER_KEY_PREFIX = "user:info:"; private final StringRedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper; public UserCacheService(StringRedisTemplate stringRedisTemplate, ObjectMapper objectMapper) { this.stringRedisTemplate = stringRedisTemplate; this.objectMapper = objectMapper; } public UserInfo getUserInfo(Long userId) { String key = USER_KEY_PREFIX + userId; try { String json = stringRedisTemplate.opsForValue().get(key); if (json != null) { return objectMapper.readValue(json, UserInfo.class); } UserInfo user = loadFromDb(userId); if (user != null) { stringRedisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(user), 30, TimeUnit.MINUTES); } return user; } catch (JsonProcessingException e) { throw new IllegalStateException("用户信息序列化失败", e); } } private UserInfo loadFromDb(Long userId) { // 这里实际是调用 userMapper.selectById(userId) return userMapper.selectById(userId); } }这个实现有几个地方需要注意。key 使用user:info:前缀,避免与业务内其他缓存冲突。过期时间设置为 30 分钟,适合用户昵称、头像这类低实时性数据。如果数据更新频繁,可以在更新数据库后主动删除缓存,再由下一次请求回源重建,做到最终一致。如果缓存查询返回空字符串,说明是空值缓存,可以单独判断,防止每次查询都打到数据库。
缓存穿透是指查询一个不存在的 key,缓存和数据库都没有数据,请求每次都穿透到数据库。解决思路是缓存空值或使用布隆过滤器。缓存击穿是指某个热点 key 过期瞬间,大量并发请求同时回源。解决思路是加互斥锁,或者用“逻辑过期”模式。缓存雪崩是指大量 key 同一时间过期,导致数据库压力激增。解决思路是给过期时间加随机扰动。对于订单查询这类场景,至少要处理好缓存空值和过期时间随机化,避免突发流量打挂数据库。
3.2 技巧二:批量处理,干掉 N+1 查询
数据库交互次数是影响接口性能的核心因素。很多后端代码在初学时都会写成循环查库:
List<OrderVO> orderVOList = new ArrayList<>(); for (OrderDO order : orderList) { UserInfo user = userService.getById(order.getUserId()); ProductInfo product = productService.getById(order.getProductId()); orderVOList.add(buildOrderVO(order, user, product)); }这段代码的问题在于,N 条订单会触发 2N 次用户和商品的单条查询。订单列表一页 20 条,就意味着 40 次数据库查询。数据量大时,数据库的连接资源会被迅速耗尽,接口耗时呈线性增长。这种问题在代码评审中非常常见,也是最值得优先修复的。
优化思路是:先取出全部订单,再收集所有需要的 userId 和 productId,最后用批量查询一次性取回数据,在内存中组装。改造后的核心逻辑如下:
List<Long> userIds = orderList.stream() .map(OrderDO::getUserId) .distinct() .collect(Collectors.toList()); List<Long> productIds = orderList.stream() .map(OrderDO::getProductId) .distinct() .collect(Collectors.toList()); Map<Long, UserInfo> userMap = userService.listByIds(userIds).stream() .collect(Collectors.toMap(UserInfo::getId, Function.identity())); Map<Long, ProductInfo> productMap = productService.listByIds(productIds).stream() .collect(Collectors.toMap(ProductInfo::getId, Function.identity())); List<OrderVO> orderVOList = orderList.stream() .map(order -> buildOrderVO(order, userMap.get(order.getUserId()), productMap.get(order.getProductId()))) .collect(Collectors.toList());这里的listByIds是 MyBatis-PlusIService提供的批量查询方法,底层会生成SELECT ... WHERE id IN (...)。优化后,数据库查询次数从 2N 次降到了 2 次,性能提升非常明显。要注意的是,IN 查询的集合大小需要控制。MySQL 对 IN 列表的长度没有硬性限制,但过长的 IN 列表会导致 SQL 解析变慢,也会让索引选择变得不可控。一般建议单批不超过 500 到 1000 个 id,如果超过就拆成多批查询,最后合并 Map。
批量处理不仅能用在数据库查询上,也适用于 Redis 的 pipeline、远程服务的批量接口。在设计接口时,如果发现业务逻辑里有“循环查下游”的模式,都可以先思考能不能改成“先收集、再批量、后组装”的方式。这是从根上减少 IO 次数最有效的手段之一。
3.3 技巧三:异步化与并发编排
订单列表接口除了查询数据库,还可能调用物流信息、营销标签、库存状态等远程服务。如果这些服务是串行调用的,那么一个接口的总耗时就是所有下游服务耗时的总和。假设每个远程服务平均耗时 50ms,三个服务串行就需要 150ms,加上数据库查询,接口很容易超过 500ms。更合理的做法是让这些互不依赖的调用并行执行。
JDK 8 的CompletableFuture是处理异步编排的常用工具。先定义一个业务线程池,避免直接使用公共的ForkJoinPool:
@Configuration public class AsyncConfig { @Bean("bizExecutor") public ThreadPoolTaskExecutor bizExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setThreadNamePrefix("biz-async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }接着在业务代码中用CompletableFuture.supplyAsync并行获取用户、商品、物流信息:
CompletableFuture<UserInfo> userFuture = CompletableFuture.supplyAsync( () -> userCacheService.getUserInfo(order.getUserId()), bizExecutor); CompletableFuture<ProductInfo> productFuture = CompletableFuture.supplyAsync( () -> productCacheService.getProductInfo(order.getProductId()), bizExecutor); CompletableFuture<LogisticsInfo> logisticsFuture = CompletableFuture.supplyAsync( () -> logisticsClient.queryByOrderId(order.getId()), bizExecutor); UserInfo user = userFuture.get(2, TimeUnit.SECONDS); ProductInfo product = productFuture.get(2, TimeUnit.SECONDS); LogisticsInfo logistics = logisticsFuture.get(2, TimeUnit.SECONDS);使用get(timeout, unit)是必须的,因为异步任务不能无限期等待。如果某个下游服务恰好超时,这里会抛出TimeoutException。实际项目中要单独捕获异常,给用户一个降级后的默认值,不要让订单列表页整体失败。比如物流信息取不到时,可以返回“暂无物流信息”,而不是把整个接口拖垮。
异步优化还要注意线程池参数。核心线程数、最大线程数、队列容量、拒绝策略需要根据下游服务的平均耗时和机器资源计算。不要盲目调大线程数,过大的线程数反而会增加上下文切换成本。拒绝策略建议使用CallerRunsPolicy,当线程池队列满时,让提交任务的线程自己执行,起到天然限流的作用,同时避免任务被静默丢弃。如果使用@Async注解,还需要注意方法自调用不会触发异步代理,因此更推荐直接用线程池包装业务逻辑,或者把异步方法放到另一个 Bean 中。
3.4 技巧四:索引设计与慢 SQL 治理
前端做了缓存、业务层减少了查询次数,数据层仍然可能是瓶颈。以订单查询为例,如果表结构简单,SQL 是SELECT ... FROM order WHERE user_id = ? ORDER BY create_time DESC LIMIT 20,在没有索引的情况下,MySQL 会走全表扫描,还要把结果集排序后才能取出前 20 条。当订单表数据达到千万级别时,这条 SQL 的延迟会变得无法接受。
给订单表添加一个联合索引:
ALTER TABLE `order` ADD INDEX idx_user_create_time (`user_id`, `create_time`);添加之后,查询条件user_id = ?可以快速定位用户订单,create_time作为联合索引第二列,也正好满足排序需求,避免filesort。我们可以在执行前使用EXPLAIN验证:
EXPLAIN SELECT id, user_id, product_id, create_time FROM `order` WHERE user_id = 1001 ORDER BY create_time DESC LIMIT 20;执行计划里应该能看到key=idx_user_create_time,type至少是ref,而不是ALL。rows会明显减少,Extra中不再出现Using filesort,说明索引确实生效了。
索引设计有几个容易踩的坑。第一,最左前缀原则:联合索引(user_id, create_time)只有在条件包含user_id时才能充分利用,如果只查询create_time,索引可能失效。第二,不要在索引列上做函数运算,比如WHERE DATE(create_time) = '2025-01-01'会让索引失效,应该写成范围条件create_time >= ? AND create_time < ?。第三,注意隐式类型转换,例如user_id是 bigint,却传入了字符串,MySQL 可能无法高效使用索引。
索引不是越多越好。每个索引都会占用磁盘空间,还会拖慢写入速度。实际项目中,建议通过慢查询日志找到最耗时的 SQL,再针对这些 SQL 设计索引。上线索引时也要避开业务高峰期,因为 MySQL 8 之前的部分 DDL 操作会锁表。如果表数据量很大,可以用在线 DDL 工具,或者在维护窗口执行。
3.5 技巧五:内存与 GC 优化
接口慢不一定都是 IO 问题,也可能是因为内存分配压力大、GC 频繁。比如一次性从数据库查出几十万条数据到内存,再逐条处理,会直接导致年轻代迅速被占满,Minor GC 频繁发生。更严重的是,如果业务代码中存在无意持有的对象引用,还会引发内存泄漏,最终导致 Full GC 甚至 OOM。
内存优化的第一步是控制单次加载的数据量。列表接口必须分页:
Page<OrderDO> page = new Page<>(current, 20); LambdaQueryWrapper<OrderDO> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(OrderDO::getUserId, userId) .orderByDesc(OrderDO::getCreateTime); IPage<OrderDO> orderPage = orderMapper.selectPage(page, wrapper); List<OrderDO> orderList = orderPage.getRecords();分页可以结合索引优化,让数据库只需要返回当前页数据,而不是把所有候选行都加载到应用层。第二步是只查询需要的字段。很多实体类字段很多,比如商品详情包含富文本内容、用户信息包含备注字段,这些字段在列表页根本用不到。可以使用 MyBatis-Plus 的select方法指定查询列:
LambdaQueryWrapper<OrderDO> wrapper = new LambdaQueryWrapper<>(); wrapper.select(OrderDO::getId, OrderDO::getUserId, OrderDO::getProductId, OrderDO::getCreateTime);减少字段读取有两个好处:一是数据库回表的数据量变小,二是应用层创建的对象变小。对象越小,每次 Young GC 能清理的对象越多,GC 压力自然下降。
代码层面还要注意循环体内不要创建大对象,能提到循环外创建的集合尽量提出来。使用基本类型代替包装类型可以减少拆箱和装箱,但要看业务场景,不要为了优化牺牲可读性。另一个容易踩坑的是ThreadLocal。线程池中的线程是复用的,如果在线程里往ThreadLocal中写入了数据,使用完没有清理,下一次复用同一条线程时可能会读到脏数据,更重要的是 ThreadLocal 中的对象会一直被子线程持有,造成内存泄漏。正确做法是在 finally 块中执行remove(),确保每个请求结束后及时释放。
现在的 JVM 其实已经非常智能,不要做无意义的微优化。真正的内存优化核心是少加载、少创建、及时释放。比如 Excel 导出、报表计算这类批量任务,可以分批读取、分批写入,而不是一次性把全量数据塞进内存。
3.6 技巧六:日志异步化与可观测性
日志系统看起来不影响业务逻辑,但在高并发场景下,同步日志会产生巨大的性能损耗。每打印一条日志都会涉及格式化、磁盘 IO、行锁等待。当 QPS 比较高时,日志写入会成为不可忽视的瓶颈,甚至阻塞业务线程。解决方案是把日志写入改为异步模式,使用 Logback 的AsyncAppender。
以logback-spring.xml为例,关键配置如下:
<configuration> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>30</maxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [%X{traceId}] %logger{36} - %msg%n</pattern> </encoder> </appender> <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender"> <queueSize>2048</queueSize> <discardingThreshold>0</discardingThreshold> <neverBlock>true</neverBlock> <appender-ref ref="FILE"/> </appender> <root level="INFO"> <appender-ref ref="ASYNC_FILE"/> </root> </configuration>配置里queueSize是异步队列长度,neverBlock=true表示当队列满了之后,新的日志事件不会阻塞业务线程,而是直接丢弃。这在保护业务可用性上有帮助,但代价是可能丢日志。对日志完整性要求高的系统,不建议设置neverBlock=true,而是调整队列大小,或者把丢弃策略做成可监控的。另外,日志格式里使用了%X{traceId},这是从 MDC 中读取链路编号。如果我们没有在代码中向 MDC 写入 traceId,这个格式化串会输出空值。
为了在异步线程中打印 traceId,尽量在线程池提交任务时把主线程的 MDC 上下文传递到子线程。一个简单思路是定义一个包装 Runnable,在任务执行前设置 MDC,执行后清理。如果项目中已经在用 SkyWalking、Zipkin 等链路追踪组件,可以使用它们提供的线程包装器,避免自己重复实现。异步日志只是可观测性的基础,更完整的做法是记录每个接口的耗时、下游调用状态、数据库慢查询日志,把这些信息汇总到监控大盘,才能及时发现性能问题。
4. 联合优化后的压测验证
4.1 优化前后的效果差异
六个技巧讲完之后,还需要回答一个问题:这样优化后,接口到底能快多少?性能优化的效果不能靠脑补,必须用压测数据说话。建议用 JMeter 或 wrk 在相同条件下分别对优化前的接口和优化后的接口压测,至少压测 10 分钟以上,观察平均 RT、TP99、TPS、错误率。
按照经验,如果一个接口原本存在 N+1 查询并且缺少索引,把批量查询和索引优化落地后,数据库耗时往往能下降一个数量级。如果远程调用占大头,再加上异步化,RT 会有明显改善。但具体数字取决于表数据量、机器配置、下游服务耗时以及并发度。不要直接参考别人的压测报告,也不要相信任何“一劳永逸”的优化方案。关键是每次只改一个变量,压测后对比,这样才能知道哪一个技巧真正起作用。
4.2 如何复现这套优化
复现这套优化,可以按以下步骤操作:
- 先搭建 Spring Boot 工程,导入示例依赖,准备订单表、用户表、商品表,插入足够多测试数据。
- 编写一个最原始的订单列表接口,包含循环查询用户和商品、串行调用物流服务、同步打印日志,并压测记录基线数据。
- 依次应用缓存、批量处理、异步化、索引、分页查询和异步日志,每应用一步压测一次。
- 用 EXPLAIN 分析 SQL 执行计划,用 Arthas 或 JProfiler 观察 GC 和线程池状态。
- 最后对比优化前后的报告,确认改进项没有引入新的问题。
压测环境最好和生产环境保持相近的配置,但不要直接在线上压测。如果没有测试环境,可以考虑把压测流量打到预发环境,并控制在较小范围内。优化上线时还要制定回滚方案,因为数据库索引变更和缓存策略改动具备一定不可控性。
5. 常见问题与排查思路
5.1 典型问题汇总
后端性能优化过程中,几乎每个技巧都有一个对应的“坑”。下面是常见问题的汇总:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 加了缓存后数据和数据库不一致 | 缓存更新策略不对 | 先更新数据库,再删除缓存;必要时延迟双删 |
| 大批量 IN 查询执行很慢 | 集合过大导致 SQL 过长 | 分批查询,每批 500 个以内,再合并结果 |
| 异步任务没有返回结果 | 线程池队列满了或任务被丢弃 | 使用有界队列,记录拒绝策略,合理设置参数 |
| EXPLAIN 显示 index 但查询仍慢 | 查询产生了随机 IO 或回表过多 | 使用覆盖索引,减少SELECT * |
| 索引明明加了却不生效 | 查询条件发生了隐式类型转换 | 检查字段类型和传入参数类型 |
| 异步日志出现丢失 | 队列满且 neverBlock=true | 调大队列,关闭 neverBlock,或记录丢弃计数 |
| GC 频繁且 CPU 高 | 单次查询加载数据量过大 | 分页查询,指定查询字段,避免大对象堆积 |
| ThreadLocal 数据串到下一次请求 | 线程复用没有清理 | finally 中调用 remove |
这些问题的共同特点是不会直接导致接口报错,但会让性能在不知不觉中劣化。排查时不能只看接口的返回值,还要关注耗时分布、数据库慢查询、GC 曲线和线程池状态。
5.2 排查清单
如果你接手一个慢接口,不知道从哪里入手,可以按照下面的清单顺序排查:
- 先看接口整体 RT 和 TP99,确定是平均慢还是少数请求慢。
- 通过链路追踪查看耗时占比,是数据库慢、Redis 慢还是远程调用慢。
- 开启 MySQL 慢查询日志,找到对应 SQL,用 EXPLAIN 看执行计划。
- 看应用日志中是否有大量异常重试和超时日志。
- 查看 GC 日志,判断是否频繁 GC。
- 查看线程池活跃线程数,判断是否存在线程阻塞。
- 查看 Redis 慢日志和命中率,判断缓存是否真正生效。
- 最后再看代码逻辑,重点检查循环中的数据库调用、远程调用和对象创建。
按照这个顺序排查,可以避免一开始就在无关紧要的参数上浪费时间。
6. 最佳实践与工程建议
6.1 上线前必须确认的事
性能优化改动上线前,除了常规代码评审,还要特别关注几个点。第一,缓存上线要考虑缓存穿透和雪崩,提前设置好空值缓存、过期时间随机化以及监控报警。第二,批量查询不能无限放大 IN 列表,如果 SQL 太长,要拆分批次。第三,异步线程池的拒绝策略必须明确,不能默认丢弃任务。第四,数据库索引变更要评估锁表时间,最好使用在线 DDL 工具或选择业务低峰期执行。第五,日志异步化后要确认日志不丢失是最低要求还是可接受丢弃,如果是监管要求严格的业务,不要开启neverBlock=true。
这些建议背后都指向同一个原则:性能优化不能以牺牲数据一致性和可观测性为代价。系统跑得快很重要,但跑得稳更重要。优化上线后应至少观察一周,重点关注接口耗时、错误率、数据库连接数、Redis 内存增长和 GC 频率。出现异常波动时,第一时间回滚到上一个稳定版本,不要在现场反复调参。
6.2 长期可维护性
性能优化不是一次性的活动,而是长期的工程习惯。在代码层面,应该通过 Code Review 拦截明显的 N+1 查询,制定团队内部的接口开发规范。比如查询列表接口必须分页,循环内禁止查询数据库,远程调用必须设置超时时间,缓存 key 必须有统一前缀。在监控层面,可以建立接口基线,当某个接口的 TP99 连续超过阈值时自动告警,而不是等用户反馈才发现问题。
在项目演进过程中,数据量会增长、依赖服务会变化,今天有效的优化方案明年可能就不适用了。因此建议关键性能指标尽量沉淀为自动化测试。比如压测脚本可以放在 CI/CD 流水线中,每次发布前跑一轮烟雾压测,如果新版本比上一版本慢超过 20%,就自动拦截发布。这样既能让性能优化可持续,也能避免团队因为某个指标波动来回扯皮。
7. 总结与学习路线
7.1 本文掌握了什么
通过这篇文章,我们一起走完了一个订单查询接口的完整优化过程。核心知识点包括:用缓存降低重复 IO,用批量查询干掉 N+1,用异步并发减少串行等待,用联合索引减少 SQL 扫描,用分页和对象复用降低 GC 压力,用异步日志解除高并发下的日志阻塞。这些技巧不是互相独立的,实际项目里往往要组合使用。优化的顺序也值得注意,一般建议先通过监控定位瓶颈,再针对瓶颈做改造,而不是一次性堆上所有技术。
7.2 下一步学什么
如果这篇文章里的内容你都已经掌握,下一步可以继续深入更底层的性能工具和方法。数据库方向可以学习 MySQL 执行计划、覆盖索引、索引条件下推、分库分表;Java 应用方向可以学习 JVM 内存模型、Arthas 在线诊断、JMH 基准测试;分布式方向可以学习 Redis 集群、消息队列削峰填谷、分布式链路追踪。性能优化是一个需要长期积累的能力,最好的方式是拿一个自己的项目做实验,记录优化前的数据和优化后的数据,慢慢形成稳定的判断力。看再多文章,都不如亲手压测一次来得直观。
如果这篇文章对你有帮助,可以收藏备用。也欢迎在评论区分享一下你在优化接口时遇到过最“坑”的问题,看看有没有让你也想说一句“6的嘞”的解法。