全栈手账 订阅 RSS

缓存策略与性能优化

系统性能优化的实战手册:从缓存分层、更新策略讲到穿透/击穿/雪崩的应对,再到后端接口、SQL 索引、前端加载的优化手段,并给出性能指标口径与瓶颈定位方法,强调「先测量、后优化」。

读了 22 分钟 更新于 11 月 3 日

一、优化方法论:先测量,后优化

1.1 没有测量的优化都是猜测

文本
现象:页面慢      ↓猜测:加缓存!加索引!      ↓结果:可能慢在图片太大、可能慢在 N+1 查询、可能慢在第三方接口

正确顺序:

  1. 量化现状:接口耗时分布(P50/P95/P99)、慢查询日志、前端加载瀑布图
  2. 定位瓶颈:找到占比最大的那一段(80/20 原则)
  3. 针对性优化:只改瓶颈
  4. 复测验证:用同样的指标确认收益
  5. 回归:确认没有把问题转移到别处

1.2 优化的三个层次

层次 手段 收益/成本比
架构层 加缓存、异步化、读写分离 ★★★★★
代码层 消除 N+1、减少循环调用、批量处理 ★★★★☆
细节层 微调参数、压缩、合并请求 ★★☆☆☆

优先做架构层。把 500ms 的接口优化到 450ms(细节层),不如给高频只读数据加一层缓存直接降到 20ms。


二、缓存分层

2.1 多级缓存结构

文本
浏览器缓存(强缓存/协商缓存)      ↓CDN 边缘缓存      ↓Nginx 代理缓存      ↓应用本地缓存(进程内,如 LRU)      ↓分布式缓存(Redis / Memcached)      ↓数据库(MySQL + 自身 buffer pool)
层 命中耗时 特点 适用
浏览器 ~0ms 完全不请求服务器 静态资源(带 hash)
CDN 10–30ms 就近返回 图片、JS/CSS、公开静态数据
Nginx ~1ms 反向代理层 几乎不变的页面片段
本地缓存 <1ms 进程内,无网络 极少变更的配置、字典
Redis 1–3ms 共享、容量大 热点数据、会话、计数器
数据库 5–50ms 权威数据 最终数据源

原则:越靠近用户的缓存越快,但一致性越弱。选层要看数据的变更频率与一致性要求。

2.2 本地缓存的坑

进程内缓存(如 Node 的 Map + TTL)在多实例部署时会出现各实例数据不一致:

文本
实例 A 缓存了旧值  →  实例 B 更新了数据库  →  请求打到 A 读到旧数据

缓解方式:

  • 只在真正不变的数据上用(字典、配置)
  • 或用短 TTL(5–30s)容忍短暂不一致
  • 或用消息广播失效(复杂度上升)

三、缓存更新策略

3.1 三种模式

模式 做法 优点 缺点
Cache Aside(旁路) 读:先读缓存,未命中查库并回填;写:先更新库,再删缓存 简单、可控 有短暂不一致窗口
Read/Write Through 缓存层代理数据库读写 业务代码简单 需缓存组件支持
Write Behind 先写缓存,异步批量落库 写性能高 有丢数据风险

生产中最常用的是 Cache Aside。

3.2 为什么是「先更新库,再删缓存」

顺序 风险
先删缓存,再更新库 删除后、更新前有请求进来,把旧值又回填进缓存 → 长期脏数据
先更新库,再删缓存 极端并发下仍可能短暂不一致,但概率远低,且下一次读会修正

3.3 延迟双删(应对极端并发)

文本
1. 更新数据库2. 删除缓存3. 延迟 500ms~1s 再删一次缓存

第二次删除是为了清掉「在第 1、2 步之间读到旧值并回填」的数据。代价是复杂度上升,仅在强一致要求场景使用。

3.4 更可靠的方案:订阅数据库变更

通过 binlog(Canal / Debezium)订阅数据变更来失效缓存,彻底摆脱业务代码遗漏:

文本
数据库变更 → binlog → 订阅程序 → 删除 Redis 对应 key

优点:不依赖业务代码正确性;缺点:引入中间件,运维复杂度上升。


四、缓存三大问题

4.1 缓存穿透(查不存在的数据)

现象:请求查询一个数据库里也没有的数据,缓存永远不命中,每次都打到数据库。常见于恶意扫描(id=-1、随机 ID)。

三种解法:

javascript
// 方案一:缓存空值(最简单)const cached = await redis.get(key)if (cached === '__NULL__') return null          // 命中空值缓存const data = await db.findById(id)await redis.set(key, data ?? '__NULL__', 'EX', data ? 3600 : 60)  // 空值短 TTLreturn data
javascript
// 方案二:布隆过滤器(数据量大时)// 启动时把所有存在的 ID 装入布隆过滤器if (!bloom.mightContain(id)) return null        // 一定不存在,直接返回
方案 优点 缺点
缓存空值 实现简单 会占用缓存空间;需短 TTL
布隆过滤器 内存占用小 有误判(可能误判存在);删除困难
参数校验 拦住非法 ID(负数、超长) 只能挡明显非法的

4.2 缓存击穿(热点 key 过期)

现象:一个超热点 key 到期的瞬间,大量并发请求同时打到数据库。

解法:

javascript
// 方案一:互斥锁(只让一个请求回源)const lockKey = `lock:${key}`if (await redis.set(lockKey, '1', 'EX', 10, 'NX')) {  try {    const data = await db.findById(id)    await redis.set(key, JSON.stringify(data), 'EX', 3600)    return data  } finally {    await redis.del(lockKey)  }} else {  await sleep(50)  return getWithCache(key, id)      // 重试读缓存}
javascript
// 方案二:逻辑过期(不设 TTL,值里带过期时间)// 读到「逻辑已过期」时,异步刷新,当前请求先返回旧值if (Date.now() > payload.expireAt) {  refreshAsync(key, id)             // 后台异步更新,不阻塞当前请求}return payload.data
方案 适用
互斥锁 对一致性要求较高,可接受短暂等待
逻辑过期 可用性优先,能容忍短时旧数据

4.3 缓存雪崩(大量 key 同时失效)

现象:一批 key 在同一时刻集体过期(如统一在凌晨批量预热且 TTL 相同),导致数据库瞬时被打爆。

解法:

javascript
// 方案一:TTL 加随机抖动const ttl = 3600 + Math.floor(Math.random() * 600)   // 3600~4200 秒await redis.set(key, val, 'EX', ttl)
javascript
// 方案二:热点数据永不过期 + 后台定时刷新// 方案三:Redis 高可用(哨兵/集群),避免整个缓存层挂掉

另外:缓存服务整体宕机就是最严重的雪崩,需要:

  • Redis 做主从 + 哨兵或集群
  • 应用侧有降级:Redis 不可用时直接查库(限流保护)
  • 数据库前面加限流

五、缓存使用实践

5.1 Key 命名规范

文本
业务:模块:标识          →  order:detail:1024业务:列表:筛选条件       →  product:list:cat1:page1锁                       →  lock:order:1024计数器                   →  counter:article:88:views

规则:可读、可分类、便于批量清理(SCAN order:detail:*)。

5.2 存什么、不存什么

适合缓存 不适合缓存
热点读、冷写的数据 高频写的数据
计算开销大的结果 强一致要求(余额、库存精确值)
字典/配置 大对象(单个 >100KB 要谨慎)
会话、验证码 一次性数据

5.3 序列化选择

方式 体积 可读性 速度
JSON 中 ✅ 好 中
MessagePack 小 ❌ 快
Protobuf 最小 ❌ 最快

小项目用 JSON 足够;只有在缓存体积或序列化开销成为瓶颈时才考虑二进制格式。

5.4 批量与 Pipeline

javascript
// ❌ 循环单次请求:100 次网络往返for (const id of ids) await redis.get(`user:${id}`)// ✅ mget:一次往返const values = await redis.mget(ids.map((id) => `user:${id}`))// ✅ pipeline:批量执行非 GET 命令const pipeline = redis.pipeline()ids.forEach((id) => pipeline.hgetall(`user:${id}`))const results = await pipeline.exec()

六、后端接口性能优化

6.1 消除 N+1 查询(最高频问题)

javascript
// ❌ N+1:查 20 条订单,再循环查 20 次用户const orders = await orderRepo.find({ take: 20 })for (const o of orders) {  o.user = await userRepo.findById(o.userId)     // 20 次查询}// ✅ 批量关联:2 次查询const orders = await orderRepo.find({ take: 20 })const users = await userRepo.findByIds(orders.map((o) => o.userId))const userMap = new Map(users.map((u) => [u.id, u]))orders.forEach((o) => (o.user = userMap.get(o.userId)))

识别方法:开慢查询日志或 SQL 计数中间件,若一次请求产生几十条相似 SQL,基本就是 N+1。

6.2 减少返回数据量

手段 说明
只查需要的列 SELECT id, name 而非 SELECT *
列表不返回大字段 详情页才返回正文、富文本
分页限制 单页最大 100 条
字段裁剪 通过 DTO 控制输出,不直接返回实体

6.3 异步化与并行

javascript
// ❌ 串行:三个独立查询依次执行,总耗时 = a + b + cconst a = await getStats()const b = await getBanners()const c = await getNotices()// ✅ 并行:总耗时 = max(a, b, c)const [a, b, c] = await Promise.all([getStats(), getBanners(), getNotices()])

不适合并行的:有依赖关系的调用;共享连接池且池很小时(并行反而排队)。

6.4 耗时操作异步化

操作 处理方式
发短信/邮件 丢队列,立即返回
生成报表/导出 异步任务 + 轮询/通知
图片处理 后台队列
第三方回调通知 队列 + 重试

目标:接口只做必要的事,把非必要操作移出请求链路。

6.5 连接池

javascript
const pool = mysql.createPool({  connectionLimit: 10,        // 按数据库 max_connections 与实例数计算  waitForConnections: true,  queueLimit: 0,  acquireTimeout: 10000,})

常见错误:连接池设得很大(如 100),多实例部署后总连接数超过数据库上限,导致连不上。

估算:实例数 × connectionLimit < 数据库 max_connections × 0.8


七、数据库性能优化

7.1 索引

sql
-- 联合索引遵循最左前缀ALTER TABLE orders ADD INDEX idx_user_status_time (user_id, status, created_at);-- ✅ 能用到索引WHERE user_id = 1WHERE user_id = 1 AND status = 2WHERE user_id = 1 AND status = 2 ORDER BY created_at DESC-- ❌ 用不到(跳过了最左列)WHERE status = 2

索引失效的典型场景:

写法 问题
WHERE DATE(created_at) = '2026-01-01' 对列做函数运算,索引失效 → 改用范围查询
WHERE phone LIKE '%123' 前置通配符无法走索引
WHERE user_id = '1'(列是 int) 隐式类型转换可能失效
WHERE status != 1 不等值通常走全表
ORDER BY 与索引顺序不符 产生 filesort

7.2 用 EXPLAIN 验证

sql
EXPLAIN SELECT * FROM orders WHERE user_id = 1 AND status = 2 ORDER BY created_at DESC LIMIT 20;

关注这几列:

列 期望
type ref / range / const(避免 ALL 全表扫描)
key 实际用的索引名(不能为 NULL)
rows 预估扫描行数(越小越好)
Extra 避免 Using filesort、Using temporary

7.3 慢查询

ini
# my.cnfslow_query_log = 1long_query_time = 1slow_query_log_file = /var/log/mysql/slow.log
bash
# 汇总分析mysqldumpslow -s t -t 10 /var/log/mysql/slow.log      # 按总耗时排序前 10

7.4 大数据量处理

问题 手段
分页越翻越慢 游标分页(WHERE id < lastId)替代 LIMIT offset
单表过大 归档历史数据、按时间分区、分表
Count 很慢 用近似值或维护计数表
批量插入慢 批量 INSERT ... VALUES (...),(...),分批 500–1000 条
全表更新 分批更新 + 限速,避免锁表与主从延迟

八、前端性能优化

8.1 核心加载链路

code
DNS → TCP/TLS → HTML → CSS/JS → 渲染 → 图片
环节 优化手段
DNS dns-prefetch
连接 preconnect 关键域名
HTML 服务端渲染或静态化,减少白屏
CSS/JS 代码分割、按需加载、gzip/brotli
渲染 关键 CSS 内联,非关键资源异步加载
图片 WebP/AVIF、懒加载、设置宽高避免布局抖动
html
<link rel="preconnect" href="https://cdn.example.com"><link rel="dns-prefetch" href="https://api.example.com"><img src="a.webp" loading="lazy" width="800" height="600" alt="">

8.2 关键性能指标

指标 含义 目标
LCP 最大内容绘制 < 2.5s
INP 交互响应 < 200ms
CLS 布局偏移 < 0.1
TTFB 首字节时间 < 500ms

这三个是 Google Core Web Vitals,直接对应真实用户体验。

8.3 常见前端性能问题

现象 原因 处理
首屏慢 包体积大 路由懒加载 + 按需引入
滚动卡 长列表全量渲染 虚拟滚动
输入卡顿 高频事件未防抖 防抖/节流
内存增长 定时器/监听未清理 组件卸载时清理
图片慢 未压缩 / 无 CDN WebP + CDN + 懒加载

九、性能指标口径

9.1 平均值会骗人

文本
接口耗时:10 次 50ms + 1 次 5000ms平均 = 500ms   ← 看起来还行P95  = 5000ms  ← 5% 的用户体验极差

必须看分位数:

指标 含义
P50 中位数,代表「典型体验」
P95 95% 的请求快于此值,代表「多数人的最差体验」
P99 尾部延迟,反映极端情况

9.2 常用容量指标

指标 说明
QPS 每秒请求数
TPS 每秒事务数
并发数 同时在处理的请求数
错误率 5xx / 超时占比
缓存命中率 缓存层健康度的核心指标

十、压测与瓶颈定位

10.1 压测基本流程

bash
# 用 ab / wrk / k6 做压力测试wrk -t4 -c100 -d30s --latency http://127.0.0.1:8080/api/products
javascript
// k6 脚本示例import http from 'k6/http'import { check } from 'k6'export const options = {  vus: 50,              // 并发用户  duration: '30s',}export default function () {  const res = http.get('http://127.0.0.1:8080/api/products')  check(res, { 'status 200': (r) => r.status === 200 })}

10.2 瓶颈定位顺序

现象 大概率瓶颈 排查手段
CPU 打满 计算密集 / 死循环 top → 定位进程 → 火焰图
内存持续增长 内存泄漏 / 缓存无上限 ps 观察 RSS 趋势,抓堆快照
QPS 上不去但 CPU 空闲 IO 等待 / 锁竞争 / 连接数不足 看数据库慢查询、连接池等待
数据库连接超时 连接池耗尽 增加池大小或优化慢查询
磁盘 IO 高 大量写日志 / 全表扫描 iostat 定位

10.3 小内存机器的特别提醒

在 1–2 GB 内存的机器上,最容易踩的坑是进程数开太多:

  • Web 进程池、数据库连接池、缓存实例共同争抢内存
  • 一旦触发 Swap,性能会断崖式下降(磁盘比内存慢几个数量级)
bash
# 观察是否有 Swap 抖动vmstat 1        # si/so 列持续非 0 说明在换页free -h         # Swap used 持续高位需警惕

应对:减少 worker 数、调小连接池、限制缓存容量。


十一、检查清单

缓存

  • 热点只读数据已加缓存,并设了合理 TTL(含随机抖动)
  • 更新策略为「先更新库,再删缓存」
  • 已防缓存穿透(空值缓存或布隆过滤器)
  • 热点 key 已防击穿(互斥锁或逻辑过期)
  • Key 命名规范统一,可批量清理
  • 批量操作用 mget/pipeline,不在循环里单次请求

后端

  • 无 N+1 查询(单请求 SQL 数量可控)
  • 独立查询已并行化(Promise.all)
  • 列表接口不返回大字段
  • 耗时操作(短信/导出/图片)已异步化
  • 连接池大小 = 实例数 × 池大小 < 数据库上限
  • 接口有超时控制

数据库

  • 高频查询字段有索引,联合索引符合最左前缀
  • 无索引失效写法(列上函数、前置通配符、隐式转换)
  • 关键查询已用 EXPLAIN 验证
  • 慢查询日志已开启并有定期分析
  • 深分页已改用游标分页
  • 批量操作分批执行(500–1000 条/批)

前端

  • 路由懒加载、UI 库按需引入
  • 传输开启 gzip/brotli
  • 静态资源长缓存、index.html 不缓存
  • 图片 WebP + 懒加载 + 指定宽高
  • 高频事件已防抖/节流

度量

  • 性能指标按 P50/P95/P99 统计,不看平均值
  • 有压测基准,优化前后可对比
  • 缓存命中率有监控
  • 内存与 Swap 有观察,避免换页抖动

十二、总结

性能优化可以归结为四句话:

  1. 先测量再优化——没有数据支撑的优化是在赌运气。
  2. 优先动架构——加缓存、异步化、消 N+1,收益远大于微调参数。
  3. 缓存的难点不是「加」,而是「失效」——穿透、击穿、雪崩分别对应三种失效场景,各有成熟解法。
  4. 看分位数不看平均值——P95/P99 才是真实用户体验,平均值会掩盖尾部灾难。

最后一条经验:优化要留痕。记录下「优化前后同一接口的 P95 变化」,既能量化收益,也能防止未来的改动悄悄把它退化回去。


完

前端、后端、部署、客服都是我一个人。写这里的东西都是真踩过的坑,不是抄来的教程。