存量系统性能优化实战:从慢SQL到Redis缓存,三句方法论破局

发布时间:2026/8/31 6:02:25
存量系统性能优化实战:从慢SQL到Redis缓存,三句方法论破局 嘿大家好又到了技术分享时间。最近在带一个老系统改造项目过程中踩了不少坑也和团队反复对齐过方案。最后复盘时发现真正让项目跑起来的不是某一个多高深的技术反而是三句听起来非常“不技术”的话解放思想实事求是团结一致向前看。可能有人会觉得这不是口号吗和技术项目有什么关系实际上这十二个字放在软件工程里恰好对应了三件最难做的事突破惯性思维、尊重客观数据、统一团队节奏。尤其是面对存量系统、历史遗留代码和复杂业务耦合时这三句话几乎可以当成排查问题和推进项目的“方法论”。本文就用一个真实的订单查询接口优化案例把这三句话拆解成可落地的技术动作从技术选型、性能摸底、慢 SQL 排查到缓存设计、团队协作和 CI/CD 流水线完整走一遍“存量系统改造”的闭环流程。无论你是刚接手老项目的初中级开发还是正在带团队做技术升级的技术负责人这篇文章都值得收藏备用。1. 背景存量系统改造中的三个典型困境先聊一个很多团队都遇到过的场景系统上线三五年了期间换过几波开发业务逻辑越来越复杂代码注释越来越少。某一天领导说这个模块性能太差高峰期接口超时率 30%你来看一下。接手之后你会发现问题远不止“接口慢”这么简单1.1 困境一不敢动老代码结构混乱Service 层几千行SQL 写在循环里没有人能说清楚某个方法被谁调用过。想优化一个接口可能要动到底层表结构风险极高。于是大家默认“能跑就行别乱动”。这种心态就是典型的思想不解放——被既有实现困住了认为“现状就是唯一方案”。1.2 困境二拍脑袋另一个常见问题是“凭经验猜”。接口慢了有人说是数据库慢有人说是第三方接口问题有人说是服务器带宽不够。大家争论不休但没有一个人拿出监控数据、调用链和慢日志来证明。这属于没有实事求是。没有数据支撑的排查本质上就是甩锅。1.3 困境三各干各的后端觉得前端传参有问题前端觉得后端接口慢运维觉得是应用代码写得不合理DBA 觉得是 SQL 写得太烂。每个人都在自己的视角里找原因没有人愿意先把问题定义为“团队问题”。这就是没有团结一致。技术问题一旦变成“谁的责任”问题就很难推进了。把这三点放在一起看你会发现存量系统改造最大的障碍不是技术难度而是团队认知不统一。所以下面这篇文章的实操部分会围绕一个具体案例展开并在每个阶段对应说明“解放思想、实事求是、团结一致向前看”在工程上的落地方式。2. 解放思想先破后立重新审视技术方案解放思想在技术项目里翻译过来就是不要被“以前就是这么写的”绑架敢于提出新的方案并基于事实去验证它。2.1 识别“历史遗留”的真正含义很多人一听到“历史遗留代码”就头大但其实这个词是中性的。它只代表“过去在特定业务背景、团队水平、技术条件下做出的取舍”不代表“完全错误”。比如订单表数据量到了 5000 万查询越来越慢。这时候直接骂“当初为什么不做分表”没有意义因为三年前业务量根本没这么大做了分表反而是过度设计。真正该做的是先接受现状然后思考现在业务特点是什么瓶颈在哪里哪些技术手段可以在不改表结构的情况下先优化哪些改造必须做2.2 技术选型上的“破”与“立”存量系统改造中最忌讳两件事全量重写推翻老系统重来周期长、风险大、业务中断频繁一般不建议。完全不改一味打补丁问题越积越多最终无法维护。更合理的思路是 Martin Fowler 提出的绞杀者模式Strangler Fig Pattern在旧系统外围逐步构建新功能将旧功能一条条“绞杀”迁移最终完成整体替换。放到订单查询这个场景里就是先不动表结构而是在应用层加缓存、优化 SQL、增加索引先把接口响应时间从 3 秒降到 300 毫秒。如果后续业务继续增长再考虑分库分表或引入搜索引擎。这就是“解放思想”——不一定非要推倒重来但也不能固步自封。方案没有绝对的新旧只有合适与否。2.3 如何提出“可落地的破局方案”破局方案的提出需要先回答四个问题问题说明现状是什么接口逻辑、SQL 执行计划、调用链、监控指标痛点是什么慢在哪个环节数据库应用网络收益最高且风险最低的方案是什么先加索引还是先加缓存方案失败如何回滚是否保留开关缓存是否可穿透这四个问题都有答案之后方案才具备评审基础。3. 实事求是用监控数据取代“我觉得”实事求是对应到工程实践里就是一切优化必须以数据为基础。没有数据就没有发言权。很多团队优化性能失败不是因为技术不行而是因为连问题的“坐标”都没定位对。下面我们看完整的数据排查链路。3.1 监控系统先行Prometheus Grafana第一步先把应用的基础监控数据建起来。以 Spring Boot 项目为例引入 Actuator 和 Micrometerdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency dependency groupIdio.micrometer/groupId artifactIdmicrometer-registry-prometheus/artifactId /dependency配置文件application.yml中暴露 Prometheus 端点management: endpoints: web: exposure: include: health,info,prometheus metrics: tags: application: order-service启动后访问/actuator/prometheus能看到 JVM、HTTP 请求耗时、线程池等指标。然后通过 Prometheus 采集这些指标在 Grafana 中配置监控面板。这一步的核心目的是回答“接口到底有多慢”“QPS 峰值是多少”“GC 有没有问题”。3.2 链路追踪SkyWalking 或 Zipkin监控只能告诉我们“系统的某台机器出问题了”但不能直接告诉我们“这个请求到底卡在哪个服务、哪段代码”。这时候需要链路追踪工具。以 SkyWalking 为例Java 项目通过 Agent 方式接入java -javaagent:/path/to/skywalking-agent.jar \ -Dskywalking.agent.service_nameorder-service \ -Dskywalking.collector.backend_service127.0.0.1:11800 \ -jar order-service.jar接入后可以在 SkyWalking UI 中看到每个请求的完整调用链请求经过 Controller → Service → DAO每一步耗时多少。还能看到 SQL 执行耗时和第三方 HTTP 调用耗时。这是“实事求是”的第二步把定位问题的层级从“系统”下沉到“代码行”。3.3 慢 SQL 日志与执行计划分析如果是数据库层面的问题慢查询日志是最直接的证据。MySQL 中开启慢查询日志SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;然后查看慢日志mysqldumpslow -s at /var/log/mysql/slow-query.log拿到慢 SQL 之后通过EXPLAIN分析执行计划EXPLAIN SELECT * FROM t_order WHERE user_id 12345 ORDER BY create_time DESC LIMIT 20;重点看type、key、rows三列type如果是ALL说明是全表扫描大概率需要加索引key为NULL说明没有命中任何索引rows数值越大说明扫描行数越多查询成本越高。到这里我们就能把问题从“感觉慢”变成“确切的慢点”。4. 团结一致向前看把个人经验变成团队能力团结一致向前看在工程上的意思是避免互相甩锅建立统一的工作流和协作机制让问题解决过程可复制。4.1 统一问题定义从“谁的问题”到“共同的问题”存量系统性能不达标不是某一个人的问题。前端传参不合理会影响接口性能后端 SQL 写得差会影响接口性能数据库索引缺失也会影响接口性能。所以团队约定拿到性能问题第一步不是问“谁写的”而是先按标准流程采集三类数据监控指标、链路追踪、慢 SQL 日志。这套流程跑通之后再讨论“谁改哪一部分”。4.2 用 Code Review 保持代码质量基线在优化过程中团队最容易犯的错是“只优化不管代码质量”。为了赶进度交付了一堆难以维护的临时方案。所以 Code Review 必须作为硬性要求。建议每个 PR 至少包含以下信息检查项说明变更目的这个 PR 解决什么问题性能影响是否影响接口响应时间、数据库压力兼容性老接口、老数据是否受影响可回滚性如果出问题怎么回滚测试覆盖是否包含单元测试或集成测试4.3 CI/CD 流水线让验证自动化优化代码如果没有自动化验证上线后出了问题很难定位是优化引入的还是原本就存在的。所以团队还应该把持续集成流水线跑起来。GitLab CI 中的简化示例stages: - test - build - deploy test-job: stage: test script: - mvn clean test tags: - maven-runner build-job: stage: build script: - mvn clean package -DskipTests - docker build -t registry.example.com/order-service:${CI_COMMIT_SHORT_SHA} . tags: - docker-runner deploy-job: stage: deploy script: - kubectl set image deployment/order-service order-serviceregistry.example.com/order-service:${CI_COMMIT_SHORT_SHA} environment: name: staging only: - main这个流水线做的事情很简单每次代码合并到主分支先跑测试再构建镜像最后部署到 staging 环境。这样一来优化操作不再是某个人手工操作、口头告知而是通过流水线自动完成且全程留痕。这其实就是“团结一致向前看”的工程化落地——把个人能力沉淀为团队的基础设施。5. 完整实战案例订单查询接口从 3 秒到 200 毫秒前面讲完了方法论下面进入完整案例。这个案例会覆盖一个订单查询接口从“问题发现”到“优化完成”的全过程。5.1 环境与版本说明本文示例基于以下环境组件版本JDK1.8 / 11Spring Boot2.7.xMySQL5.7 / 8.0Redis5.0Maven3.6版本需要根据你的项目实际情况调整本文重点演示配置思路和优化方法。5.2 项目结构假设我们有一个简化版订单服务核心结构如下order-service ├── pom.xml ├── src/main/java/com/example/order │ ├── OrderApplication.java │ ├── controller/OrderController.java │ ├── service/OrderService.java │ ├── mapper/OrderMapper.java │ ├── entity/Order.java │ └── config/RedisConfig.java └── src/main/resources ├── application.yml └── mapper/OrderMapper.xml5.3 问题现象订单列表接口超时严重假设线上有一个接口GET /api/order/list?userId12345page1size20这个接口的逻辑大致是根据用户 ID 分页查询订单列表然后遍历每个订单查询关联的订单明细最后返回。听上去很简单但线上的响应时间达到了 3 秒左右高峰期大量超时。我们先模拟原有代码// 文件路径src/main/java/com/example/order/service/OrderService.java Service public class OrderService { Autowired private OrderMapper orderMapper; public ListOrderVO listOrders(Long userId, Integer page, Integer size) { // 第 1 步分页查询订单列表 ListOrder orderList orderMapper.selectOrdersByUserId(userId, page, size); ListOrderVO result new ArrayList(); // 第 2 步循环查询每个订单的明细 for (Order order : orderList) { ListOrderDetail details orderMapper.selectDetailsByOrderId(order.getId()); OrderVO vo new OrderVO(); vo.setId(order.getId()); vo.setAmount(order.getAmount()); vo.setDetails(details); result.add(vo); } return result; } }对应的 Mapper 接口// 文件路径src/main/java/com/example/order/mapper/OrderMapper.java Mapper public interface OrderMapper { ListOrder selectOrdersByUserId(Param(userId) Long userId, Param(page) Integer page, Param(size) Integer size); ListOrderDetail selectDetailsByOrderId(Param(orderId) Long orderId); }对应的 XML!-- 文件路径src/main/resources/mapper/OrderMapper.xml -- mapper namespacecom.example.order.mapper.OrderMapper select idselectOrdersByUserId resultTypecom.example.order.entity.Order SELECT id, user_id, order_no, amount, status, create_time FROM t_order WHERE user_id #{userId} ORDER BY create_time DESC LIMIT #{page}, #{size} /select select idselectDetailsByOrderId resultTypecom.example.order.entity.OrderDetail SELECT id, order_id, product_name, product_price, quantity FROM t_order_detail WHERE order_id #{orderId} /select /mapper5.4 第一步用数据定位问题根据前面说的“实事求是”原则先不要改代码先收集证据。首先确认表数据量SELECT COUNT(*) FROM t_order; -- 结果约 5000 万 SELECT COUNT(*) FROM t_order_detail; -- 结果约 1.2 亿再看慢日志发现下面这条 SQL 频繁出现SELECT id, user_id, order_no, amount, status, create_time FROM t_order WHERE user_id ? ORDER BY create_time DESC LIMIT ?, ?;用EXPLAIN分析EXPLAIN SELECT id, user_id, order_no, amount, status, create_time FROM t_order WHERE user_id 12345 ORDER BY create_time DESC LIMIT 0, 20;结果中type ALLrows 820000说明这条 SQL 走了全表扫描。再看t_order_detail的关联查询EXPLAIN SELECT id, order_id, product_name, product_price, quantity FROM t_order_detail WHERE order_id 1;结果中type ALLrows 300000说明明细表也是全表扫描。5.5 第二步加索引优化 SQL第一个优化点是索引。根据 SQL 条件最核心的查询条件是两个t_order.user_id用于等值查询t_order.create_time用于排序。所以给t_order建联合索引ALTER TABLE t_order ADD INDEX idx_user_id_create_time (user_id, create_time);为什么不用两个独立索引因为 MySQL 在同一个查询中通常只会选择区分度最高的一个索引另一个索引不生效排序还是可能走filesort。联合索引可以同时满足等值查询和排序需要。给t_order_detail的查询条件order_id建索引ALTER TABLE t_order_detail ADD INDEX idx_order_id (order_id);字段长度、区分度都需要结合实际评估但这里两个字段都是bigint类型建索引成本相对可控。5.6 第三步消除 N1 查询改为批量查询索引加完后单条 SQL 的查询速度提升了但代码里还存在 N1 查询问题。什么叫 N1 查询就是先执行 1 次查询拿到订单列表N 条然后循环执行 N 次订单明细查询。如果一页 20 条订单那就需要查询 1 20 次 SQL。随着页大小增大数据库压力成倍增长。优化方式把“循环查明细”改成“一次性查出所有明细”。新的 Mapper 方法ListOrderDetail selectDetailsByOrderIds(Param(orderIds) ListLong orderIds);对应 XMLselect idselectDetailsByOrderIds resultTypecom.example.order.entity.OrderDetail SELECT id, order_id, product_name, product_price, quantity FROM t_order_detail WHERE order_id IN foreach collectionorderIds itemorderId open( separator, close) #{orderId} /foreach /selectService 层改造public ListOrderVO listOrders(Long userId, Integer page, Integer size) { ListOrder orderList orderMapper.selectOrdersByUserId(userId, page, size); if (orderList.isEmpty()) { return Collections.emptyList(); } // 一次性取出所有订单 ID ListLong orderIds orderList.stream() .map(Order::getId) .collect(Collectors.toList()); // 批量查询明细 ListOrderDetail details orderMapper.selectDetailsByOrderIds(orderIds); // 按订单 ID 分组 MapLong, ListOrderDetail detailMap details.stream() .collect(Collectors.groupingBy(OrderDetail::getOrderId)); ListOrderVO result new ArrayList(); for (Order order : orderList) { OrderVO vo new OrderVO(); vo.setId(order.getId()); vo.setAmount(order.getAmount()); vo.setDetails(detailMap.getOrDefault(order.getId(), Collections.emptyList())); result.add(vo); } return result; }这样 SQL 从 1 N 次变成了 2 次接口响应时间明显下降。5.7 第四步引入 Redis 缓存索引和批量查询解决了“数据库压力”的问题但如果同一个用户频繁刷新列表页每次都查数据库依然不划算。这时候可以引入 Redis 缓存。在pom.xml中加入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency配置 Redis 连接spring: redis: host: 127.0.0.1 port: 6379 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0改造 Service 层缓存逻辑Service public class OrderService { private static final String ORDER_LIST_CACHE_KEY order:list:; Autowired private OrderMapper orderMapper; Autowired private StringRedisTemplate stringRedisTemplate; Autowired private ObjectMapper objectMapper; public ListOrderVO listOrders(Long userId, Integer page, Integer size) { String cacheKey ORDER_LIST_CACHE_KEY userId : page : size; // 1. 先查缓存 String cachedValue stringRedisTemplate.opsForValue().get(cacheKey); if (cachedValue ! null) { try { return objectMapper.readValue(cachedValue, new TypeReferenceListOrderVO() {}); } catch (Exception e) { // 缓存反序列化失败忽略继续查数据库 } } // 2. 查数据库 ListOrderVO result loadFromDb(userId, page, size); // 3. 回填缓存设置过期时间 try { String json objectMapper.writeValueAsString(result); stringRedisTemplate.opsForValue().set(cacheKey, json, 5, TimeUnit.MINUTES); } catch (Exception e) { // 缓存写入失败不能影响主流程 } return result; } private ListOrderVO loadFromDb(Long userId, Integer page, Integer size) { ListOrder orderList orderMapper.selectOrdersByUserId(userId, page, size); if (orderList.isEmpty()) { return Collections.emptyList(); } ListLong orderIds orderList.stream() .map(Order::getId) .collect(Collectors.toList()); ListOrderDetail details orderMapper.selectDetailsByOrderIds(orderIds); MapLong, ListOrderDetail detailMap details.stream() .collect(Collectors.groupingBy(OrderDetail::getOrderId)); ListOrderVO result new ArrayList(); for (Order order : orderList) { OrderVO vo new OrderVO(); vo.setId(order.getId()); vo.setAmount(order.getAmount()); vo.setDetails(detailMap.getOrDefault(order.getId(), Collections.emptyList())); result.add(vo); } return result; } }这里需要强调一个关键点缓存只是加速手段不是数据正确性的兜底方案。因此缓存过期时间不宜过长且写操作时一定要同步更新或清理缓存避免数据不一致。5.8 优化效果对比通过加索引、批量查询、Redis 缓存三步优化最终压测结果大致如下优化阶段平均响应时间数据库 QPS原始版本3200 ms高加索引后850 ms中批量查询后300 ms较低加入缓存后180 ms低实际数据受机器配置、数据量、并发情况影响但优化思路是通用的先保证查询本身高效再引入缓存。不要跳过索引优化直接上缓存因为缓存一旦大量失效数据库会被击垮。6. 常见问题与排查思路在存量系统优化过程中团队经常会遇到下面几个问题。问题现象常见原因解决思路加了索引但没生效查询条件里有函数运算或隐式类型转换使用EXPLAIN查看是否命中索引去除查询列上的函数运算缓存命中率低key 设计不合理粒度太细按业务维度设计 key避免把用户 ID 等高频变动字段拼进 key缓存穿透严重查询的数据本身不存在对空结果进行短期缓存或使用布隆过滤器缓存雪崩大量 key 同一时间过期过期时间加随机值避免同一时刻大批量失效接口偶发超时慢 SQL 偶发出现或 Full GC 频繁通过链路追踪定位偶发超时请求检查 JVM GC 日志优化上线后数据不一致缓存更新策略不对采用先更新数据库再删除缓存的策略并配合短暂延迟双删Maven 依赖冲突多个模块引入了不同版本的 Redis 客户端使用mvn dependency:tree排查依赖树统一版本这里面最关键的一条经验是所有缓存方案都要考虑“如果缓存挂了会怎样”。缓存服务不可用时必须保证请求可以降级到数据库而不是直接报错。7. 最佳实践与工程建议7.1 索引设计不要盲目加索引索引能提速但也会降低写入性能占用更多磁盘空间。所以索引设计的核心原则是基于真实查询场景设计而不是“把所有字段都加上”把等值查询字段放在联合索引前面范围查询字段放后面关注区分度区分度太低的字段比如status只有几个值不适合单独建索引每个表索引数量尽量控制在 5 个以内。7.2 缓存设计明确边界和降级策略缓存不是银弹。设计缓存时需要考虑数据的一致性要求有多高热点 key 和过期策略如何设计缓存重建会不会引发并发问题缓存中间件不可用时如何降级。7.3 监控与告警先有监控再谈优化很多团队是出现问题之后才想到搭监控这其实是本末倒置。监控应该是最先建设的基础设施。至少需要覆盖JVM 内存和 GC 情况接口 QPS、响应时间、错误率数据库慢查询、连接池使用率缓存命中率、Redis 内存使用率。7.4 代码质量优化不等于重写对存量系统做优化时尽量控制变更范围。能通过配置解决的不要改代码能通过小范围重构解决的不要大范围重写。每次变更提交前问自己三个问题这个变更的风险点在哪里如果线上出问题怎么快速回滚这个变更是否可以通过监控数据验证收益7.5 团队协作把脏活累活标准化存量系统改造中最脏最累的活其实是“梳理代码逻辑”和“梳理数据关系”。这些工作没有太高的技术含量但很消耗时间。建议团队形成文档沉淀机制核心接口的调用关系图核心表结构和索引清单历史优化记录和回滚方案常见问题排查手册。这些文档的价值会在半年后、一年后体现出来——当团队有新成员加入时不需要从零开始啃代码。8. 总结与学习路线写到这里这个主题已经接近尾声。回顾整篇文章核心其实就一句话技术项目能否成功看的往往不是技术本身而是团队有没有形成“大胆假设、小心求证、协同推进”的做事方式。解放思想对应的是技术方案设计阶段的“破局”能力敢于跳出旧框架思考但不盲目重写实事求是对应的是排查问题阶段的“取证”能力一切优化以监控数据、调用链、执行计划为依据团结一致向前看对应的是落地阶段的“协作”能力通过 Code Review、CI/CD、文档沉淀让个人能力变成团队能力。如果在实际项目中你能围绕这三条原则建立自己的工作流那么不管是老系统优化、新技术引入还是团队协作效率提升都不会走偏。对于已经有一定基础、想要继续深入的同学接下来的学习路线可以按这个顺序走深入 MySQL 索引与执行计划学会用EXPLAIN分析各种复杂 SQL学习 Redis 高可用方案包括主从复制、哨兵、Cluster 模式学习链路追踪与服务网格理解分布式系统下的问题定位方法关注代码质量与自动化测试减少存量系统改造时的回归风险实践 CI/CD 与容器化部署让优化方案能安全、快速地上线。如果本文对你有帮助可以收藏备用。后续我也会继续更新关于数据库优化、缓存设计和团队协作工具链的文章欢迎关注交流。

相关新闻