项目刚上线时,待办列表 50 毫秒就能返回。半年后,代码似乎没有明显变化,页面上的 Loading 却转了三秒。
前端看到的是一个请求用了三秒,后端看到的却是一段很长的旅程:先在网关排队,再等 Tomcat 线程,然后等数据库连接、执行 SQL、调用其他服务,最后还要把大量对象变成 JSON。
性能问题很少是 Spring Boot 突然"跑不动了"。更常见的原因是,原来只有一千条的数据变成了一千万条,原来每秒十个请求变成了一千个。那些早期几乎看不见的成本,被规模一点点放大。
三秒不是一个数字,而是一张账单
去餐厅吃饭花了一百元,不能只说"这顿饭很贵"。要看菜单,才能知道钱花在主菜、饮料还是服务费上。
接口的三秒也是一张总账:
text
网络传输
+ 网关排队
+ Tomcat 线程排队
+ Java 业务处理
+ 等待数据库连接
+ SQL 执行
+ 调用其他服务
+ JSON 序列化
= 前端看到的总耗时
浏览器网络面板通常只能看到请求总时间和部分网络阶段。它无法直接告诉我们,后端是在查数据库,还是在等库存服务。
所以性能优化的第一步不是"先加 Redis",而是把账单拆开。没有测量就开始优化,像没看菜单就要求服务员把最贵的菜换掉,很可能改错地方。
平均 100 毫秒,仍可能有人等了五秒
假设 99 个请求耗时 50 毫秒,最后一个请求耗时 5 秒。平均值约为 100 毫秒,看起来不算慢,但那位用户已经明显感到卡顿。
平均成绩会掩盖少数特别差的学生,平均耗时也会掩盖一批特别慢的请求。
监控中常见的 P50、P95 和 P99,可以这样理解:
- P50:一半请求比这个数字快;
- P95:95% 请求比这个数字快;
- P99:最慢的 1% 从这里开始。
如果 P50 是 80 毫秒,P99 是 4 秒,说明大多数用户正常,但系统在某些条件下会突然很慢。只盯平均值,很容易把他们忽略掉。
耗时还要与吞吐量和错误率一起看。接口变快了,却开始大量返回 500,不叫优化;应用扛住更多请求,却把数据库压垮,也只是把问题推给下一站。
数据库找数据,像在仓库里找一件商品
数据库通常是最值得先检查的地方。它既要保存大量事实,又要处理排序、事务和并发。
假设仓库只有一百件商品,管理员从头看到尾也很快;仓库有一千万件商品,再用同样方式就会非常慢。
下面这条 SQL 要找到某个用户最近的 20 条待办:
sql
SELECT id, title, completed, created_at
FROM todo
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 20;
没有合适索引时,数据库可能检查大量记录,再排序,最后只取 20 条。
索引像仓库目录。给 (user_id, created_at) 建立联合索引后,数据库可以先找到这个用户的区域,再按已经排好的时间顺序取前 20 条。
但有没有目录、数据库是否真的用了它,不能靠猜。EXPLAIN 像数据库给出的寻路计划,会告诉我们准备使用哪个索引、预计查看多少行,以及是否需要额外排序。
有索引,不等于一定走对了目录
一本书有目录,不代表任何问题都能立刻找到答案。按作者排列的目录,不能高效回答"标题里包含某个词的所有文章"。
数据库索引也有使用条件。常见的低效情况包括:
- 查询没有使用联合索引最前面的字段;
- 对索引字段做了函数计算;
- 模糊搜索以
%开头; - 字段类型不同,数据库需要临时转换;
- 排序方向与索引组织方式不匹配;
- 查询本来就要返回表中大部分数据。
SELECT * 也会增加成本。列表页只展示 ID、标题和状态,却顺便读取几千字的详情内容,就像每次取商品标签时都把整个包装箱搬到前台。
前端会为列表定义轻量类型,后端也应该只查询和返回当前页面真正需要的字段。列表与详情不必共享一份"大而全"的结果。
N+1 是为了 20 条数据跑了 21 次仓库
先查询 20 条待办,再逐条查询每条待办的创建者,会产生:
text
1 次查询待办列表
+ 20 次查询创建者
= 21 次数据库查询
这像工作人员先拿回 20 张订单,然后为了每张订单上的用户名,又单独跑一次仓库。每趟都不算慢,来回 20 次就会明显拖延。
这类问题叫 N+1 查询。列表有 N 条记录,除了第一次查询外,又额外执行 N 次关联查询。
解决方式可能是 JOIN、一次批量查询、预加载,或者直接改变返回模型。重点不是背某一种写法,而是观察一次接口到底发出了多少条 SQL。
ORM 可能把额外查询藏在对象属性后面。代码看起来只是读取 todo.getUser(),背后却又访问一次数据库。MyBatis 更显式,但在循环里反复调用 Mapper,同样会制造 N+1。
跳到第 5000 页,数据库仍要从前面数起
传统页码分页可能写成:
sql
LIMIT 20 OFFSET 99980;
它的意思不是直接跳到第 99981 条。数据库可能仍要找到并略过前面的 99980 条,再返回最后 20 条。
这像一本没有页码定位的长名单。用户说"给我第十万名之后的 20 个人",工作人员仍要从第一页一路数过去。
游标分页会换一种说法:"从上一次看到的最后一条继续往后取。"例如记住上一页最后一条记录的创建时间和 ID,再查询比它更早的数据。
无限滚动和"加载更多"很适合游标分页;需要直接跳到任意页的后台表格,则可能仍然需要页码分页。
前端交互会直接影响数据库成本。一个"跳到第 5000 页"的产品需求,不只是分页组件多显示一个输入框。
连接池像数量有限的办事窗口
Spring Boot 通常通过 HikariCP 管理数据库连接池。应用提前准备一定数量的连接,请求要执行 SQL 时先借一个,用完再归还。
假设只有 20 个数据库窗口,同时来了 100 个请求。前 20 个可以办理,剩下 80 个必须等待。
这时接口已经开始变慢,但慢 SQL 日志里可能什么都没有,因为请求还没拿到连接,SQL 根本没有执行。
连接池长期排队,常见原因包括:
- SQL 太慢,一个连接很久不归还;
- 事务范围太大,里面还等待外部 HTTP;
- 程序借了连接却没有正确释放;
- 突然到来的流量超过系统容量;
- 数据库本身已经达到处理上限。
把窗口从 20 个增加到 200 个不一定更快。后台只有十名工作人员时,开放 200 个窗口只会让所有人一起挤在里面。
线程池、数据库连接池和 HTTP 连接池都不是越大越好。上游放进来的并发,不能超过下游真正能处理的数量。
Redis 像放在前台的常用货架
某些数据每次都去数据库查询,像顾客每问一次菜单,服务员都跑到地下仓库找原件。
如果一份数据经常读取、很少变化,而且允许短时间不是最新,可以把它放在离前台更近的缓存中。
Redis 把数据保存在内存里,按照键快速查找。最常见的做法是先查缓存,缓存没有时再去数据库取:
#mermaid-svg-eEOqI7ChFvUQGWX4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-eEOqI7ChFvUQGWX4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eEOqI7ChFvUQGWX4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eEOqI7ChFvUQGWX4 .error-icon{fill:#552222;}#mermaid-svg-eEOqI7ChFvUQGWX4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eEOqI7ChFvUQGWX4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eEOqI7ChFvUQGWX4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eEOqI7ChFvUQGWX4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eEOqI7ChFvUQGWX4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eEOqI7ChFvUQGWX4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eEOqI7ChFvUQGWX4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eEOqI7ChFvUQGWX4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eEOqI7ChFvUQGWX4 .marker.cross{stroke:#333333;}#mermaid-svg-eEOqI7ChFvUQGWX4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eEOqI7ChFvUQGWX4 p{margin:0;}#mermaid-svg-eEOqI7ChFvUQGWX4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eEOqI7ChFvUQGWX4 .cluster-label text{fill:#333;}#mermaid-svg-eEOqI7ChFvUQGWX4 .cluster-label span{color:#333;}#mermaid-svg-eEOqI7ChFvUQGWX4 .cluster-label span p{background-color:transparent;}#mermaid-svg-eEOqI7ChFvUQGWX4 .label text,#mermaid-svg-eEOqI7ChFvUQGWX4 span{fill:#333;color:#333;}#mermaid-svg-eEOqI7ChFvUQGWX4 .node rect,#mermaid-svg-eEOqI7ChFvUQGWX4 .node circle,#mermaid-svg-eEOqI7ChFvUQGWX4 .node ellipse,#mermaid-svg-eEOqI7ChFvUQGWX4 .node polygon,#mermaid-svg-eEOqI7ChFvUQGWX4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eEOqI7ChFvUQGWX4 .rough-node .label text,#mermaid-svg-eEOqI7ChFvUQGWX4 .node .label text,#mermaid-svg-eEOqI7ChFvUQGWX4 .image-shape .label,#mermaid-svg-eEOqI7ChFvUQGWX4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-eEOqI7ChFvUQGWX4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eEOqI7ChFvUQGWX4 .rough-node .label,#mermaid-svg-eEOqI7ChFvUQGWX4 .node .label,#mermaid-svg-eEOqI7ChFvUQGWX4 .image-shape .label,#mermaid-svg-eEOqI7ChFvUQGWX4 .icon-shape .label{text-align:center;}#mermaid-svg-eEOqI7ChFvUQGWX4 .node.clickable{cursor:pointer;}#mermaid-svg-eEOqI7ChFvUQGWX4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eEOqI7ChFvUQGWX4 .arrowheadPath{fill:#333333;}#mermaid-svg-eEOqI7ChFvUQGWX4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eEOqI7ChFvUQGWX4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eEOqI7ChFvUQGWX4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eEOqI7ChFvUQGWX4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eEOqI7ChFvUQGWX4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eEOqI7ChFvUQGWX4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eEOqI7ChFvUQGWX4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eEOqI7ChFvUQGWX4 .cluster text{fill:#333;}#mermaid-svg-eEOqI7ChFvUQGWX4 .cluster span{color:#333;}#mermaid-svg-eEOqI7ChFvUQGWX4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-eEOqI7ChFvUQGWX4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eEOqI7ChFvUQGWX4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-eEOqI7ChFvUQGWX4 .icon-shape,#mermaid-svg-eEOqI7ChFvUQGWX4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eEOqI7ChFvUQGWX4 .icon-shape p,#mermaid-svg-eEOqI7ChFvUQGWX4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eEOqI7ChFvUQGWX4 .icon-shape .label rect,#mermaid-svg-eEOqI7ChFvUQGWX4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eEOqI7ChFvUQGWX4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eEOqI7ChFvUQGWX4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eEOqI7ChFvUQGWX4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 有
没有
读取请求
缓存里有吗?
直接返回
查询数据库
放入缓存
这种由应用负责读取缓存、回源数据库并重新放入缓存的方式,正式名称叫 Cache Aside。
更新数据时,常见做法是先更新数据库,再删除缓存。下一次读取会从数据库拿到新值并重新放入缓存。
选择删除而不是同时修改两份数据,是为了减少"数据库改成功、缓存却没改成功"留下永久旧值的机会。
缓存用数据新鲜度和额外复杂度换取速度,不是所有接口的默认答案。刚完成支付、必须立刻准确的余额,就比新闻热门榜单更难安全缓存。
前台货架也会带来新的麻烦
缓存解决了反复跑仓库的问题,却会带来三个常见场景。
第一种:顾客不断询问一个根本不存在的商品,前台当然没有,每次都继续去仓库查。这叫缓存穿透。可以短时间缓存"确实不存在"的结果,或在入口拦截明显非法的编号。
第二种:一件最热门商品的缓存刚好过期,几千名顾客同时让服务员去仓库找。这叫缓存击穿。可以让一名工作人员负责重新装货,其他人短暂等待或使用仍可接受的旧值。
第三种:前台大量商品在同一分钟过期,所有请求突然一起涌向仓库。这叫缓存雪崩。可以让过期时间带一点随机差异,并准备限流和降级。
缓存还可能显示旧数据。用户明明保存了新标题,数据库已经更新,缓存却仍返回旧标题,页面看起来像"保存成功后又变回去了"。
因此每个缓存都要回答三个问题:它什么时候失效,谁负责删除,短时间旧值能不能接受。
本地缓存和 Redis,像私人物品架与公共仓库
本地缓存存在当前 Spring Boot 实例的内存里,访问最快,不需要网络。但系统运行三个实例时,每个实例都有自己的一份,更新其中一份不会自动通知另外两份。
它像每位员工桌边的私人物品架:拿取非常方便,但大家看到的内容可能不同,员工离开后内容也随之消失。
Redis 是所有实例共同访问的独立服务,更像公共仓库。大家看到同一份数据,但每次读取要经过网络,也需要单独部署和监控。
少量稳定配置适合本地缓存;多个实例共享的业务数据更常使用 Redis。有些系统还会组合两级缓存,但缓存层数越多,追查"这个旧值到底从哪里来的"就越困难。
返回十万条数据,搬运本身就很慢
假设 SQL 只花 100 毫秒,却返回十万条记录。Java 要创建十万个对象,Jackson 要把它们逐个变成 JSON,网络要传输大量字节,浏览器还要解析并保存在内存中。
这像仓库找货只用一分钟,最后却调来十辆卡车搬运。问题已经不在"找得快不快",而在一次拿得太多。
后端应该在接口边界控制数据规模:
- 列表限制单次返回数量;
- 只返回页面需要的字段;
- 大文件使用流式传输或对象存储;
- 在合适场景开启 HTTP 压缩;
- 避免对象循环引用和过深嵌套。
前端虚拟列表只能减少 DOM 渲染,无法让十万条 JSON 不经过网络。需要多少数据,应该在发请求之前就想清楚。
调用其他服务,会把对方的等待带回来
一个订单接口可能先查数据库,再调用用户、库存和推荐服务。只要其中一站很慢,当前请求就会跟着等待。
每次远程调用都需要超时。没有超时,就像打电话后允许对方永远不回答,当前线程会一直占着线路。
重试也要谨慎。下游已经过载时,所有上游立即重试,只会增加更多请求,形成重试风暴。
合理重试通常限制次数,逐步增加等待时间,并且只重试那些短暂、可安全重复的操作。参数错误和余额不足,不会因为再请求三次就恢复正常。
非核心能力可以降级。例如推荐服务失败时,订单主体仍然返回,只是暂时没有推荐内容。降级不是假装一切成功,而是提前说明哪些部分可以暂时缺席。
限流像大厅入口的取号机
系统每秒只能稳定处理 500 个请求,却突然来了 5000 个。如果全部放进来,线程池、连接池和数据库可能一起被占满,最后所有请求都失败。
限流会在入口控制进入速度,像办事大厅只发有限数量的号码。它拒绝一部分超出容量的请求,是为了让已经进入的人仍能完成办理。
常见限流维度包括用户、IP、具体接口和全局请求量。被限制的请求通常返回 429,并提示客户端稍后再试。
前端收到 429 后不应立即无限重试,否则只是在入口反复排队。可以展示明确状态,或按照 Retry-After 等信息延迟请求。
限流看起来是在拒绝用户,实际是在流量超过能力时保护所有用户。
优化之前,先找到这张请求账单
定位慢请求,需要把不同位置的证据串起来:
- 网关记录请求总耗时和状态码;
- Spring 指标显示接口 P95、P99;
- Tomcat 指标显示线程是否排队;
- 连接池指标显示获取连接等待多久;
- 慢 SQL 与
EXPLAIN显示数据库做了多少工作; - 链路追踪显示每个下游调用用了多久。
requestId 或 traceId 把这些记录关联成同一次请求。
找到慢点后,优化也应该有明确结果:索引是否减少扫描行数,SQL 数量是否从 21 降到 2,P99 是否下降,数据库 CPU 是否改善。
真正有效的优化通常很具体:少查一次数据库、缩短一个事务、减少几个响应字段、为一个调用设置超时,或者给一条查询建立正确索引。
接口越用越慢,不是系统对用户产生了疲劳,而是早期被忽略的成本终于被规模放大。
下一篇会讨论比慢更危险的情况:用户因为迟迟没有看到结果,又点了一次按钮。第一次请求可能只是响应丢了,第二次却让同一个操作真的执行了两遍。