14个全链路性能问题 × 从发现到修复到验证 × 覆盖前端/后端/数据库/基础设施。每个问题都有完整的发现→排查→原理→修复→验证流程,以电商小程序为业务场景贯穿全程。
运营同学在周会上反馈:小程序首页跳出率异常升高。技术团队用 Lighthouse 跑了一遍首页:
打开 DevTools → Network 面板,刷新首页,查看资源加载瀑布流:
| Name | Size | Time | Waterfall |
|---|---|---|---|
| index.html | 12 KB | 200ms | |
| vendor.js | 1.2 MB | 2.8s | |
| app.js | 380 KB | 1.2s | |
| main.css | 89 KB | 800ms | |
| lodash.js | 540 KB | 1.5s | |
| moment.js | 310 KB | 900ms |
分析发现的问题:
1. vendor.js 1.2MB 未拆分,加载 2.8s,阻塞渲染
2. lodash 全量引入 540KB,实际只用了 3 个函数
3. moment.js 310KB,包含全部 locale 文件
4. CSS/JS 未加 async/defer,全部阻塞渲染
5. 没有 gzip/brotli 压缩,没有开启 CDN
FCP 慢的本质:关键渲染路径上的资源太重
浏览器从拿到 HTML 到渲染出第一个内容(FCP),要经历:① 下载 HTML → ② 下载 CSS/JS(阻塞渲染)→ ③ 解析编译 JS → ④ 构建 DOM + CSSOM → 渲染树 → 绘制
如果 JS 太大(vendor.js 1.2MB),下载和编译都要好几秒,FCP 自然慢。关键渲染路径上的每个阻塞资源都会拖慢首屏。
技术原理:浏览器渲染流程 — HTML 解析 → 遇到 CSS 会阻塞渲染(等 CSSOM)→ 遇到没有 async/defer 的 JS 会同时阻塞解析和渲染。关键渲染路径上的资源越多越重,FCP 越慢。
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| FCP | 3.2s | 0.8s | ↓ 75% |
| LCP | 4.8s | 1.6s | ↓ 67% |
| TTI | 6.1s | 2.3s | ↓ 62% |
| vendor.js 体积 | 1.2MB | 280KB (gzip) | ↓ 77% |
| Lighthouse 评分 | 38 | 92 | ↑ 142% |
| 首页跳出率 | 58% | 22% | ↓ 62% |
用 Arthas 在线追踪接口调用链,找到耗时最长的环节:
分析链路:
1. checkStock() 耗时 1850ms — 是主要瓶颈
2. validateCoupon() 耗时 890ms — 远程调用慢
3. 追踪 checkStock 内部:getStockFromCache 就占了 1700ms
4. 根因:Redis 连接池太小,高峰期获取连接排队等 1700ms
连接池耗尽原理:Redis 连接池 maxActive=8,高峰期 50 个并发请求同时竞争连接。每个请求必须等前面的释放连接才能拿到,排队等待时间 1700ms。
技术原理:Redis 连接池(Lettuce/Jedis)配置过小时,当并发请求超过连接数,后续线程会阻塞等待连接释放。如果连接池 maxActive=8,而 QPS=320,平均每个请求占用连接 5ms,那理论最大吞吐量 = 8 / 0.005 = 1600 QPS。但如果个别慢请求(大 key)占着连接不放,实际吞吐量远低于理论值。
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| P99 RT | 3.2s | 180ms | ↓ 94% |
| P95 RT | 2.1s | 120ms | ↓ 94% |
| checkStock 耗时 | 1850ms | 35ms | ↓ 98% |
| Redis 连接池利用率 | 100%(满) | 45% | ↓ 55% |
| 下单成功率 | 78% | 99.5% | ↑ 27% |
SELECT * FROM product WHERE category_id = ? AND status = 1 ORDER BY sales_count DESC 单次执行 2.8s。
| id | select_type | table | type | possible_keys | key | rows | Extra |
|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | milk_tea_product | ALL | idx_category | NULL | 1,580,000 | Using where; Using filesort |
type: ALL — 全表扫描,没有走索引key: NULL — MySQL 优化器选择了不使用索引rows: 1,580,000 — 扫描了全表 158 万行Using filesort — 额外的文件排序,效率极低idx_category 索引,但 ORDER BY 用了 sales_count,索引无法覆盖排序,MySQL 选择全表扫描 + filesort
filesort 原理:索引 idx_category_id 只能快速定位 category_id 匹配的行,但 ORDER BY sales_count 排序无法利用该索引。MySQL 只能把所有匹配行取出到内存(或临时文件)中,再做一次排序 —— 这就是 filesort。
技术原理:MySQL 索引是 B+ 树结构。查询条件用 category_id,排序用 sales_count,这两个字段不在同一个索引上,MySQL 无法利用索引的有序性完成排序,只能做 filesort。解决方案是创建联合索引 (category_id, status, sales_count),让索引同时覆盖 WHERE 过滤和 ORDER BY 排序。
EXPLAIN 修复后:
• type: range — 走了索引范围扫描
• key: idx_cat_status_sales — 使用了新索引
• rows: 320 — 只扫描 320 行(原来 158 万行)
• Extra: Backward index scan — 利用索引逆序,无需 filesort
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| SQL 执行时间 | 2.8s | 12ms | ↓ 99.6% |
| 扫描行数 | 1,580,000 | 320 | ↓ 99.98% |
| filesort | 是 | 否 | 消除 |
分析结果:
1. 只缓存了 200/10000 的商品 — 缓存覆盖率只有 2%
2. 缓存代码逻辑:只有缓存命中时返回,未命中时直接查 DB 但不回写
3. 部分缓存 key 没设过期时间,旧数据永远占着内存
缓存三大问题原理:
• 缓存穿透:查询不存在的数据(如传入不存在的商品ID),缓存中没有,每次都打到 DB。防护:布隆过滤器或缓存空值。
• 缓存击穿:热点 key 过期的瞬间,大量并发请求同时打到 DB。防护:互斥锁或永不过期+异步刷新。
• 缓存雪崩:大量 key 同时过期(如启动时批量设了相同 TTL),DB 瞬间承压。防护:TTL 加随机值,错开过期时间。
本次问题属于:缓存回写缺失 + 覆盖率低。大量请求根本没走缓存。
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| Redis 缓存命中率 | 23% | 96% | ↑ 317% |
| 缓存 key 数量 | 200 | 10,200 | ↑ 5000% |
| MySQL QPS(读) | 4,500 | 320 | ↓ 93% |
| MySQL CPU | 85% | 18% | ↓ 79% |
| 商品详情页 RT | 450ms | 25ms | ↓ 94% |
分析:
1. 堆内存只有 512M,高峰期不够用
2. Eden 区 256M,每秒新对象约 500MB → 每 0.5s 填满一次 Eden
3. 每次 Young GC 暂停 40ms → 接口 P99 有 40ms 毛刺
4. 对象晋升到 Old 区过快 → 即将触发 Full GC
房间整理类比:你的桌面(Eden 区)很小,随手放东西(新对象创建),桌面满了就要快速扫一眼:有用的收进抽屉(Survivor 区),没用的扔垃圾桶(Young GC),整个过程 1 秒搞定。抽屉里很久没清理的东西最终搬进储物间(Old Gen)。但储物间也满了,你要彻底翻箱倒柜整理整个房间(Full GC),要 10 分钟,整理期间谁也不能用这个房间(STW)。桌面越小,清理越频繁,别人等得越久。
技术原理:JVM 堆分为 Eden + Survivor + Old。新对象分配在 Eden,Eden 满了触发 Young GC(STW)。如果堆太小或分配速率太高,GC 频率就高。G1 GC 的 STW 时间与存活对象数量成正比,频繁 GC 意味着频繁暂停业务线程。
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| Young GC 频率 | 2次/s | 0.1次/s | ↓ 95% |
| GC STW 时间 | 40ms/次 | 15ms/次 | ↓ 63% |
| Full GC 频率 | 2次/h | 0次(连续72h无FGC) | 消除 |
| 接口 P99 RT | 850ms | 120ms | ↓ 86% |
| RT 毛刺 | 频繁(每秒) | 基本消除 | 消除 |
java.util.concurrent.RejectedExecutionException。
线程池耗尽原理:Tomcat 线程池 maxThreads=200,其中 156 个线程被阻塞在等待支付网关响应(超时 30s)。真正能处理新请求的线程只剩 44 个,新的请求进来就排队溢出,触发拒绝策略返回 503。
技术原理:Tomcat 用线程池处理 HTTP 请求。当所有线程都被阻塞在慢操作(同步调用外部支付网关)上,新请求进不来。解决方案不是简单增大线程池(线程也是资源),而是把同步阻塞调用改成异步非阻塞,让线程不被长时间占用。
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| 503 错误率 | 12% | 0.01% | ↓ 99.9% |
| Tomcat 活跃线程 | 200/200(满) | 45/200 | ↓ 78% |
| 支付调用线程占用 | 156 线程×30s | 独立线程池×3s | 隔离 |
| 高峰期吞吐量 | 80 QPS | 520 QPS | ↑ 550% |
DOM 节点过多导致卡顿原理:12,000 条订单 = 12,000 个 DOM 节点。每次滚动时浏览器要重新计算这些节点的位置(Layout Reflow)。但用户屏幕实际只能显示 ~20 条,其余 11,980 个节点完全不可见却在拖累渲染性能。
虚拟列表原理:只渲染可视区域内的 DOM 节点(~20 个),加上上下各 5 个缓冲节点,总共只有 30 个 DOM 节点。滚动时动态替换可视区域内容,DOM 总数恒定,性能恒定。
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| DOM 节点数 | 84,000 | 30 | ↓ 99.96% |
| 帧率(FPS) | 1.2 FPS | 60 FPS | ↑ 4900% |
| 首次渲染时间 | 4.5s | 120ms | ↓ 97% |
| 内存占用 | 580MB | 65MB | ↓ 89% |
| 滚动流畅度 | 严重卡顿 | 丝滑 | — |
CDN 回源率高的原理:CDN 边缘节点本应缓存静态资源,用户就近获取。但如果源站返回的响应头设置了 Cache-Control: no-cache 或 TTL 太短,边缘节点不敢缓存,每次请求都要回源到 Nginx,CDN 等于白部署了。
技术原理:CDN 节点根据源站返回的 Cache-Control 头决定是否缓存。no-cache 表示每次都要验证,no-store 表示完全不缓存。静态资源(JS/CSS/图片)应该设置长期缓存 + 文件名 hash 实现缓存更新。
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| CDN 命中率 | 15% | 98% | ↑ 553% |
| 静态资源加载时间 | 800ms | 35ms | ↓ 96% |
| 源站带宽利用率 | 85% | 8% | ↓ 91% |
| 源站 QPS | 5,000 | 300 | ↓ 94% |
client_max_body_size 10m,请求体超过限制直接被 Nginx 拒绝。即使改大限制,单次上传 100MB 在弱网环境下也很容易超时。
瓶颈分析:
1. Nginx client_max_body_size 10m → 100MB 直接被拒绝
2. 即使放大限制,单次上传 100MB 在 4G 网络下要 25s+,容易超时
3. 上传失败后从头重传,没有断点续传
4. 没有上传进度反馈,用户体验差
分片上传原理:整文件上传 100MB,任何一个分片失败就要从头来。分片上传把 100MB 拆成 20 个 5MB 分片,每个分片独立上传,失败只需重传那一片。还可以多分片并发上传,大幅缩短总时间。
技术原理:大文件分片上传将文件切成固定大小的块(如 5MB/片),每片独立上传,失败只需重传该分片。所有分片上传完成后,服务端合并文件。还可以用文件内容 hash 做秒传(文件已存在则跳过上传)。
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| 上传成功率 | 30%(常超时) | 99.9% | ↑ 233% |
| 100MB 上传时间 | 超时失败 | 25s(并发分片) | 可用 |
| 失败恢复 | 从头重传 | 只重传失败分片 | — |
| 进度展示 | 无 | 精确到分片级 | — |
| 秒传支持 | 无 | hash 匹配秒传 | — |
WebSocket connection to 'wss://...' failed: 错误。
WebSocket 断连原理:WebSocket 是长连接,但如果 60 秒内没有数据传输,中间的代理/负载均衡器(Nginx)会认为连接已经空闲,主动断开。解法:客户端定时发心跳包(ping),保持连接活跃。
技术原理:Nginx 作为反向代理转发 WebSocket 连接时,proxy_read_timeout 默认 60s。如果这段时间内没有数据从上游服务器读取到,Nginx 会关闭连接。解决方案:① 增大超时时间 ② 客户端/服务端定期发送心跳 ping/pong 保活 ③ 客户端实现指数退避重连。
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| 平均连接时长 | 3 分钟 | 12+ 小时 | ↑ 24000% |
| 断连频率 | 20次/小时 | 0.1次/小时 | ↓ 99.5% |
| 消息到达率 | 85% | 99.9% | ↑ 17% |
| 重连延迟 | 5-30s | 1-2s(退避重连) | ↓ 80% |
SELECT * FROM product WHERE id = ? 的单条查询。
N+1 查询原理:先查 20 条订单(1 次 SQL),然后循环中每条订单各查一次商品详情(20 次 SQL),总共 21 次。改为批量查询:收集所有 product_id,一条 SELECT * FROM product WHERE id IN (...) 查出全部,再在内存中关联,只需 2 次 SQL。
技术原理:ORM 框架(JPA/MyBatis)默认懒加载关联对象,访问关联属性时触发额外 SQL。在循环中访问 N 条记录的关联对象,就产生 N 条额外 SQL。修复方式:JOIN 查询、批量查询、MyBatis 的 association/collection 嵌套映射、JPA 的 @EntityGraph。
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| SQL 执行次数 | 21 条 | 2 条(或 1 条 JOIN) | ↓ 90-95% |
| 接口 RT | 800ms | 45ms | ↓ 94% |
| DB 连接占用时间 | 630ms | 15ms | ↓ 98% |
| 网络往返次数 | 21 次 | 1-2 次 | ↓ 90-95% |
| 服务 | 操作 | 耗时 | 状态 |
|---|---|---|---|
| order-service | createOrder() | 12ms | ✓ |
| order→pay (跨机房) | callPayment() | 1,850ms | ⚠ 不稳定 |
| pay-service | process() | 35ms | ✓ |
跨机房网络抖动原理:上海和北京机房之间走的是公网链路,经过多个路由跳转。公网延迟受拥塞、丢包影响,不像同机房内网稳定(<1ms)。高峰期公网带宽紧张时,延迟从 25ms 飙到 1850ms,丢包率 3%。解法:专线/VPN + 超时降级 + 本地缓存。
技术原理:跨机房走公网/VPN,网络质量受运营商链路影响。高延迟+丢包导致 TCP 重传,RT 进一步恶化。解决方案:① 专线/高速通道(稳定低延迟)② 同机房部署 ③ 重试+超时+降级策略 ④ 异步化调用。
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| 支付调用 P99 RT | 680ms | 45ms | ↓ 93% |
| RT 波动范围 | 25ms~1850ms | 8ms~65ms | 稳定 |
| 丢包率 | 3% | 0% | 消除 |
| 支付超时率 | 5% | 0.01% | ↓ 99.8% |
日志 IO 瓶颈原理:每个请求打印 10+ 行完整请求体/响应体日志,高峰期 300 QPS × 10 行 = 每秒 3000 行写入。磁盘写入速度跟不上,iowait 飙到 45%,所有依赖磁盘的操作(包括数据库写入)都被拖慢。解法:异步日志(AsyncAppender)+ 采样 + 分级。
技术原理:日志框架(Logback/Log4j2)默认同步写入磁盘。高频写入产生大量小 IO 操作,机械磁盘(或云盘)随机写入性能有限。同步日志意味着业务线程要等 IO 完成,iowait 高 = CPU 在等磁盘 = 整体吞吐量下降。
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| CPU iowait | 25.8% | 3.2% | ↓ 88% |
| 磁盘写入速率 | 48.5MB/s | 2.1MB/s | ↓ 96% |
| 日志量/天 | 50GB | 3GB | ↓ 94% |
| 接口 P99 RT | 350ms | 85ms | ↓ 76% |
| 磁盘利用率 | 92% | 35% | ↓ 62% |
| Process | Memory | CPU | 趋势 |
|---|---|---|---|
| 电商管理后台 | 2,540 MB | 45% | ↑ 持续增长 |
| 刚打开时 | 180 MB | 5% | 正常 |
| Constructor | Added | Deleted | Delta | Retained Size |
|---|---|---|---|---|
| WebSocket | +186 | 0 | +186 | 280 MB |
| Event Listener | +12,400 | 0 | +12,400 | 85 MB |
| closure | +3,200 | 150 | +3,050 | 42 MB |
| Object (orderData) | +8,500 | 0 | +8,500 | 65 MB |
前端内存泄漏原理:SPA 不刷新页面,每次路由切换/数据更新都在分配内存。如果组件销毁时没有清理定时器、事件监听器、闭包引用,这些内存永远无法被 GC 回收。长时间运行后内存持续增长,最终页面卡死。排查工具:Chrome DevTools Memory 面板 → Heap Snapshot 对比。
技术原理:JavaScript 使用垃圾回收(GC)自动管理内存。但如果有引用链从根对象(window/global)可达,GC 就不会回收。常见泄漏:① 全局变量/闭包引用 ② 未移除的事件监听器 ③ 未关闭的 WebSocket/setInterval ④ DOM 引用未释放 ⑤ 组件销毁时未清理定时器和监听器。
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| 内存占用(3小时后) | 2,540 MB | 210 MB | ↓ 92% |
| 内存增长趋势 | 每小时+500MB | 稳定(±20MB) | 稳定 |
| WebSocket 实例数 | 186(泄漏) | 1(单例) | 修复 |
| Event Listener 数 | 12,400(泄漏) | 15(正常) | 修复 |
| 页面流畅度(8小时后) | 卡死需刷新 | 流畅如初 | — |