ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot存量系统性能改造:异步导出、深分页优化与结构化日志

Spring Boot存量系统性能改造:异步导出、深分页优化与结构化日志 1. 这个随记在记什么一次迭代的三个真实需求先说背景。我手上维护着一个公司内部的数据管理后台技术栈不算新Spring Boot 2.x 加 MyBatis Plus前端是 Vue 2 那套经典组合数据库用的 MySQL 5.7。系统跑了两年多功能堆得不少但代码质量和性能问题也攒了一堆。这次迭代不是从零开发新项目而是在存量系统上做一轮还债式的改造。三个需求是这样的。第一运营同学反馈后台的订单导出功能经常超时数据量一上来页面直接转圈十几秒然后报 504。导出逻辑是同步的几万条数据一次性查出来塞进内存再拼 CSV 返回给前端数据库连接被占着不动Tomcat 线程池也被拖垮。第二列表页的查询越来越慢尤其是按时间范围翻页的时候用户点第 50 页之后基本要等 3 到 5 秒严重影响日常操作效率。第三线上出了几次问题排查的时候发现日志文件里全是 INFO错误信息混在一起没有 traceId没有业务上下文定位一个问题得像大海捞针一样翻半天。这三个需求看着各不相干但本质上都指向同一个问题系统在数据规模和业务复杂度上升之后原有的简单实现撑不住了。这篇随记我就把整个迭代过程拆开来讲包括方案设计时的取舍、具体落地时的代码细节、以及最后踩过的坑。如果你也在维护类似的内部系统或者正准备对老项目做性能改造这篇内容应该能帮你少走不少弯路。顺便说一下这篇文章不是教科书式的教程更像是我个人的开发笔记。代码片段我会贴关键的但不会把整个项目都贴出来重点是讲清楚每一步的思考过程和验证方法。毕竟知道怎么写很重要知道为什么要这么写更重要。2. 方案设计里的取舍同步改异步、翻页优化、日志结构化的决策过程2.1 导出功能为什么要从同步改成异步任何改动我习惯先问一个问题当前方案的问题根源在哪改动的目的是什么。订单导出原来的流程是前端发起请求 - 后端接收请求 - 直接查询数据库 - 拼装 CSV - 返回文件流。这个流程在数据量小的时候没问题几百条数据秒出。但订单表到了几十万行之后问题就开始暴露了。第一查询本身很重。导出的筛选条件和列表页是一样的但导出要把所有匹配的数据都查出来而不是只查一页。一次导出可能涉及几万甚至十几万行还要 join 用户表、商品表、物流表全量数据加载到内存GC 压力一下就上去了。第二同步请求的等待时间太长。HTTP 请求长时间挂着网关、负载均衡、浏览器都有超时限制用户看到的就是请求失败。第三数据库连接和线程被长时间占用并发一高其他正常请求也跟着遭殃。所以我的方案是改成异步任务前端点击导出后后端只创建一个导出任务立即返回任务已创建后台用专门的任务线程池去处理处理完成后把文件放到临时存储前端轮询任务状态拿到结果后下载。这个改动表面上只是多了一层任务机制实际上解决了超时、资源占用、并发冲突三个问题。关键点在于任务状态的设计。我用一个简单的状态机PENDING排队中- PROCESSING处理中- SUCCESS成功- FAILED失败外加一个 CANCELLED已取消状态。每个任务记录创建时间、开始时间、结束时间、创建人、查询参数、失败原因。这样用户能看到任务进度出错也能追责。2.2 翻页慢的根因深分页和索引失效订单列表页的翻页查询原来的 SQL 长这样SELECT * FROM orders WHERE create_time BETWEEN 2023-01-01 AND 2023-06-30 ORDER BY id DESC LIMIT 50000, 20;这是典型的深分页问题。MySQL 的 LIMIT 50000, 20 并不是只取 20 条而是先读取 50020 条然后丢弃前 50000 条。越往后翻扫描的行数越多性能自然断崖式下降。我一开始以为加个索引就能解决但仔细分析后发现如果只给 create_time 加索引MySQL 仍然需要先找到所有符合时间范围的数据再排序取 offset索引的作用有限。真正有效的做法是延迟关联也就是先查出需要的 ID再用 ID 回表取完整数据SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders WHERE create_time BETWEEN 2023-01-01 AND 2023-06-30 ORDER BY id DESC LIMIT 50000, 20 ) tmp ON o.id tmp.id ORDER BY o.id DESC;子查询里只查主键 ID索引覆盖之后扫描的数据量很小再和原表做 join 取完整行回表次数只有 20 次。实测下来第 500 页的查询时间从 3 秒多降到了 200 毫秒以内效果非常明显。不过这个方案也有局限就是只对按主键排序的情况友好。如果排序字段不是 id比如按订单金额排序延迟关联的效果会打折扣。这种情况下更彻底的做法是改成翻页游标模式前端把上一页最后一条记录的 id 传给后端后端用WHERE id ? ORDER BY id DESC LIMIT 20取下一页。这样不管翻多深性能都是稳定的。我这次因为产品没有跳到指定页的需求就改成了游标翻页顺带把前端的翻页组件也调了一下。2.3 日志改造为什么值得做从能看到能查第三个需求是日志结构化。之前系统的日志是裸奔的logger.info(用户下单成功 orderId)logger.error(xx异常)没有统一的格式没有 traceId关键是线上排查问题时根本没法把一次请求的多个日志串起来。我的方案是引入 traceId 贯穿整个请求链路。在 Spring Boot 里通过 OncePerRequestFilter 实现请求进来时生成一个 UUID 作为 traceId放到 MDCMapped Diagnostic Context里日后再从 MDC 取出来输出到日志中。这样同一个请求的所有日志都能通过 traceId 搜索到前后端调试对接时也能用响应头里的 traceId 做关联。同时我把日志格式改成了 JSON 结构每个日志条目包含 timestamp、level、traceId、serviceName、className、message、businessContext 等字段。这样日志不仅能给人看还能被日志平台解析做告警和聚合统计分析。比如统计某个接口的 P99 耗时、错误率都可以直接用日志数据算不用额外埋点。这个改动可能短期内看不到直接的业务价值但它的收益是防患于未然式的。后面在这个系统上做监控告警的时候直接基于这批结构化日志就能接入省了大功夫。3. 实操落地从建表到代码一步步写出来的过程3.1 异步导出的完整实现任务表的建表语句是这样的CREATE TABLE export_task ( id bigint(20) NOT NULL AUTO_INCREMENT, task_no varchar(32) NOT NULL COMMENT 任务编号, type varchar(32) NOT NULL COMMENT 导出类型如 ORDER_EXPORT, params text COMMENT 查询参数JSON格式, status varchar(20) NOT NULL DEFAULT PENDING COMMENT 状态PENDING/PROCESSING/SUCCESS/FAILED/CANCELLED, file_path varchar(255) DEFAULT NULL COMMENT 生成文件的相对路径, file_name varchar(255) DEFAULT NULL COMMENT 下载时的文件名, error_msg varchar(1000) DEFAULT NULL COMMENT 失败原因, create_by varchar(50) DEFAULT NULL COMMENT 创建人, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, start_time datetime DEFAULT NULL, end_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_task_no (task_no), KEY idx_status_create_time (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;任务表本身不复杂但有几个细节要注意。参数用 text 存 JSON是因为不同的导出类型参数结构不一样用单独列反而难扩展。task_no 是业务编号和自增主键分离方便对外暴露给前端轮询。状态和时间字段一起记后续做超时清理任务的管理都靠它们。后端接口接收请求后第一步校验参数第二步生成 task_no第三步把任务插入数据库第四步调用线程池执行。这里要注意任务提交线程池后线程池内部执行时要把任务状态从 PENDING 改成 PROCESSING处理完成后再改成 SUCCESS 或 FAILED。如果线程池拒绝任务要捕获 RejectedExecutionException把任务状态改成 FAILED 并写入错误信息。文件生成用的方式是分批查询加流式写入避免一次性加载全量数据到内存// 伪代码展示核心思路 try (OutputStream os new FileOutputStream(tempFile); BufferedWriter writer new BufferedWriter(new OutputStreamWriter(os, StandardCharsets.UTF_8))) { // 先写表头 writer.write(订单号,用户,金额,状态); writer.newLine(); // 分批查询每次取5000条 long lastId 0L; while (true) { ListOrder batch orderMapper.selectPageByCursor(lastId, 5000, queryParams); if (batch.isEmpty()) { break; } for (Order order : batch) { writer.write(buildCsvLine(order)); writer.newLine(); } lastId batch.get(batch.size() - 1).getId(); } }分批查询用的是游标方式每一批返回最后一条 ID 作为下一批的起点这样每次查询都是固定性能的不会像 offset 翻页那样越查越慢。5000 这个值是我测试后选的折中方案太小会导致数据库查询次数变多太大会导致单次加载数据量偏大。实际测试中5000 行每批内存占用稳定在 200MB 以下GC 压力可以接受。3.2 列表页游标翻页的接口设计游标翻页的接口参数和传统分页不太一样。传统分页传 pageNum 和 pageSize游标翻页传 cursor上一页最后一条记录的 id和 pageSize。如果 cursor 为空代表从头开始查。public PageResultOrderVO listOrders(Long cursor, Integer pageSize, OrderQuery query) { // 默认页码大小 if (pageSize null || pageSize 100) { pageSize 20; } // 游标为null时从最大id开始查按id倒序 if (cursor null) { cursor Long.MAX_VALUE; } ListOrder orders orderMapper.selectPageByCursor(cursor, pageSize, query); // 构造返回结果 Long nextCursor orders.isEmpty() ? null : orders.get(orders.size() - 1).getId(); boolean hasMore orders.size() pageSize; return new PageResult(orders, nextCursor, hasMore); }注意这里有个细节取hasMore时判断条件是orders.size() pageSize而不是orders.size() 0。因为如果用大于 0 判断永远都返回 true前端会一直显示还有下一页。用 pageSize时如果最后一页刚好是满的前端会多请求一次拿到空列表后停止这是正确的。Mapper 的 SQL 用而不是来定位游标避免上一页最后一条记录被重复返回。如果排序方向变了比如从正序改成倒序游标的比较符号也要跟着变这块最容易出错。前端那边也需要配合调整。原来的 el-pagination 组件要改成加载更多或者上一页/下一页的形式因为游标翻页天然不支持跳页。好在我们的产品页面就是按时间倒序列表这个改动在体验上没有损失。3.3 结构化日志的落地细节日志改造我用的 Spring Boot 自带的 Logback通过 logback-spring.xml 配置 encoder输出 JSON 格式。核心配置如下appender nameJSON_FILE classch.qos.logback.core.rolling.RollingFileAppender file${LOG_DIR}/app.json.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePattern${LOG_DIR}/app.json.log.%d{yyyy-MM-dd}.%i.gz/fileNamePattern maxHistory30/maxHistory timeBasedFileNamingAndTriggeringPolicy classch.qos.logback.core.rolling.SizeAndTimeBasedFNATP maxFileSize200MB/maxFileSize /timeBasedFileNamingAndTriggeringPolicy /rollingPolicy encoder classnet.logstash.logback.encoder.LogstashEncoder includeMdctrue/includeMdc customFields{serviceName:data-admin}/customFields /encoder /appender用 LogstashEncoder 的好处是不用手动拼 JSON它会把日志的 level、logger、message、MDC 里的 traceId 自动序列化。includeMdc必须打开否则 traceId 就丢了。traceId 的生成和传递我在过滤器里处理Component public class TraceIdFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 从请求头获取traceId没有则生成 String traceId request.getHeader(X-Trace-Id); if (traceId null || traceId.isEmpty()) { traceId UUID.randomUUID().toString().replace(-, ); } MDC.put(traceId, traceId); response.setHeader(X-Trace-Id, traceId); try { chain.doFilter(request, response); } finally { MDC.remove(traceId); } } }这里有个很重要的细节MDC.remove(traceId)必须放在 finally 里。Tomcat 的线程池是复用的如果线程执行完不清理 MDC下个请求复用这个线程时会带着上一个请求的 traceId日志就全串了。这个坑我踩过排查了半天才发现是 MDC 没有清理导致 traceId 错乱。4. 盘点那些真实踩过的坑排查过程记录4.1 导出文件用 Excel 打开乱码文件生成逻辑写完后我本地测试一切正常结果运营同学反馈下载的 CSV 文件用 Excel 打开后中文全乱码。排查了一圈问题出在编码和 BOM 上。CSV 文件我用的是 UTF-8 编码写入但 Excel 默认用 ANSIGBK打开 UTF-8 文件就会乱码。解决方案有两个一是把文件编码改成 GBK但这会牺牲其他平台的兼容性二是写入 UTF-8 BOMByte Order Mark让 Excel 识别文件是 UTF-8 编码。我选了后者写入时在文件头部加\uFEFF前缀writer.write(\uFEFF); // 写入UTF-8 BOM writer.write(订单号,用户,金额,状态); writer.newLine();这一个字符就能让 Excel 正确识别编码其他文本编辑器也不受影响。这种小细节文档里一般不会写但实际使用中就是会有人碰到。4.2 并发导出导致的内存溢出导出功能上线第一天下午三点左右运维报警说服务内存占用居高不下GC 频繁CPU 负载飙升。翻监控发现同一时间有七八个导出任务在跑每个任务都是分批查询加文件写入理论上不应该占用太多内存。但问题出在文件写入之前的那个环节——查询结果集太大。仔细检查代码才发现我虽然做了分批查询但查询条件里有 joinMyBatis 执行时返回的 ResultSet 是全部加载到内存里的。MyBatis Plus 的分页查询在 join 场景下会先查出所有关联行再在内存里做分页导致数据量剧增。解决方法是把 join 查询拆成两步先查出符合条件的订单 ID 列表只查 id不需要 join然后再按 ID 批量查询完整信息。这样第一阶段的内存占用只和 ID 列表有关第二阶段按 ID 分批查每批几百条内存压力小很多。或者更简单粗暴的做法是加一个全局的导出并发限制比如同时最多 3 个导出任务超出的排队等待。我两个都做了因为运营确实有集中导出的场景不能依赖他们自觉错峰。4.3 深分页优化后排序错乱延迟关联和游标翻页都改完之后测试人员反馈了一个诡异的问题第 1 页和第 2 页之间出现了重复数据而第 3 页又少了一条数据。原因是游标翻页的排序不稳定。我原来的查询是ORDER BY id DESC游标传的是上一页最后一条记录的 id。按道理不会出问题。但测试环境的数据里有一些 id 不是严格递增的是历史数据迁移时手动插入的id 大小和数据的时间顺序不一致。如果排序字段和游标字段不一致就会出问题。这个问题的教训是游标翻页要求排序字段必须和游标字段完全一致或者同序。如果业务上必须按 create_time 排序那游标就得用(create_time, id)组合游标即WHERE create_time ? OR (create_time ? AND id ?)。这样排序才能稳定。我最后改成了组合游标虽然 SQL 复杂了一点但正确性是第一位的。4.4 日志文件撑爆磁盘日志改造上线后跑了两周突然收到磁盘空间告警。登录服务器一看app.json.log 已经涨到了 30 多 GB。原因有两层第一层是我把 INFO 级别的日志全量输出而系统里有些接口的日志量特别大比如每次查询都打印完整 SQL 和参数这些日志在正常时候没有任何排查价值第二层是我虽然配置了按天滚动和文件大小切割但 30 天的保留策略太长了加上 maxFileSize 200MB 的切割粒度每天会产生很多压缩文件累积起来磁盘空间扛不住。事后调整了三个地方把 SQL 日志降到 DEBUG 级别线上默认不开启为访问量最大的几个接口单独配置采样日志比如只记录 1% 的请求保留策略从 30 天改成 7 天同时加大单文件切割阈值减少小文件数量。改完之后日均日志量从 2GB 降到了 200MB 左右问题解决。5. 这次开发里沉淀下来的工作习惯5.1 先做最小闭环验证再铺开以往我开发功能时习惯把所有逻辑写完再一起测试结果每次都有各种问题交织在一起定位起来特别麻烦。这次我换了一种方式先写一个最小的垂直切片即从接口到数据库打通一条最简链路跑通后再横向扩展。比如异步导出我先只做一种导出类型一个最简单的查询条件手动调接口验证任务状态流转没问题、文件能正常生成再去接真实业务数据。这种方式的优点很明显每一步的改动范围小出了问题能立刻定位到刚加的代码不会出现找不到是哪个环节坏了的情况。5.2 把可观测性当成功能的一部分来设计以前我总觉得日志、监控、告警是上线之后再说的事情这次迭代彻底改变了我的想法。可观测性应该在设计阶段就和功能一起考虑。比如设计导出任务表时我就把状态、时间、参数、失败原因都设计进去了这样后续做任务管理后台时直接复用数据不用再补。设计接口时我会顺手写清楚关键日志的输出点这样日志上线第一天就是完整的而不是等出问题后再补。具体到这次迭代我在每个关键节点都加了日志任务创建时记录参数处理开始时记录任务号和处理线程完成时记录耗时和文件大小失败时记录异常堆栈和上下文。这些日志在排查问题时帮了大忙尤其是 4.2 节的内存溢出问题就是靠日志里的任务并发数和处理时长定位出来的。5.3 接口约定要给前端留好余地这次游标翻页的接口设计我一开始只返回了数据列表和下一页游标没考虑前端需要知道还有没有下一页。结果前端同学来问怎么判断列表是否到底了。我只好紧急在接口里加了一个 hasMore 字段。这就是前期设计时只想着后端需要什么没想着前端需要什么。后来我有了一个习惯接口设计完成后自己先站在前端的角度走一遍使用流程看返回的数据能不能支撑页面上的所有交互。如果前端要根据返回值做判断或渲染那返回值就必须包含足够的信息。这种换位思考能少很多前后端联调时的返工。写在最后一点个人的体会这次迭代前前后后大概花了三周代码量不大但涉及的面很广从异步任务到 SQL 优化再到日志治理都有。回头看最有价值的部分不在于写了多少代码而在于每个方案背后都逼着我去想清楚了一个问题当前方案的瓶颈到底在哪改动的本质是什么。比如说同步导出改成异步本质是把请求-响应模型的瓶颈从连接超时转移到了任务调度的可靠性上深分页优化核心不是加索引技术而是理解 MySQL 执行计划里的扫描代价日志结构化不是为了好看而是为了让系统在出问题时可以被高效地追溯。我写这篇随记还有一个动机很多开发者习惯把技术分享聚焦在高大上的架构和框架上忽略了日常开发里的这些基础工作。但恰恰是这些基础工作决定了系统能跑多稳、出问题时能多快恢复。希望这篇内容对你有一些帮助哪怕只是其中一个小技巧也值了。
返回列表