缓存策略与性能优化
系统性能优化的实战手册:从缓存分层、更新策略讲到穿透/击穿/雪崩的应对,再到后端接口、SQL 索引、前端加载的优化手段,并给出性能指标口径与瓶颈定位方法,强调「先测量、后优化」。
一、优化方法论:先测量,后优化
1.1 没有测量的优化都是猜测
现象:页面慢 ↓猜测:加缓存!加索引! ↓结果:可能慢在图片太大、可能慢在 N+1 查询、可能慢在第三方接口正确顺序:
- 量化现状:接口耗时分布(P50/P95/P99)、慢查询日志、前端加载瀑布图
- 定位瓶颈:找到占比最大的那一段(80/20 原则)
- 针对性优化:只改瓶颈
- 复测验证:用同样的指标确认收益
- 回归:确认没有把问题转移到别处
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)。
三种解法:
// 方案一:缓存空值(最简单)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// 方案二:布隆过滤器(数据量大时)// 启动时把所有存在的 ID 装入布隆过滤器if (!bloom.mightContain(id)) return null // 一定不存在,直接返回| 方案 | 优点 | 缺点 |
|---|---|---|
| 缓存空值 | 实现简单 | 会占用缓存空间;需短 TTL |
| 布隆过滤器 | 内存占用小 | 有误判(可能误判存在);删除困难 |
| 参数校验 | 拦住非法 ID(负数、超长) | 只能挡明显非法的 |
4.2 缓存击穿(热点 key 过期)
现象:一个超热点 key 到期的瞬间,大量并发请求同时打到数据库。
解法:
// 方案一:互斥锁(只让一个请求回源)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) // 重试读缓存}// 方案二:逻辑过期(不设 TTL,值里带过期时间)// 读到「逻辑已过期」时,异步刷新,当前请求先返回旧值if (Date.now() > payload.expireAt) { refreshAsync(key, id) // 后台异步更新,不阻塞当前请求}return payload.data| 方案 | 适用 |
|---|---|
| 互斥锁 | 对一致性要求较高,可接受短暂等待 |
| 逻辑过期 | 可用性优先,能容忍短时旧数据 |
4.3 缓存雪崩(大量 key 同时失效)
现象:一批 key 在同一时刻集体过期(如统一在凌晨批量预热且 TTL 相同),导致数据库瞬时被打爆。
解法:
// 方案一:TTL 加随机抖动const ttl = 3600 + Math.floor(Math.random() * 600) // 3600~4200 秒await redis.set(key, val, 'EX', ttl)// 方案二:热点数据永不过期 + 后台定时刷新// 方案三: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
// ❌ 循环单次请求: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 查询(最高频问题)
// ❌ 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 异步化与并行
// ❌ 串行:三个独立查询依次执行,总耗时 = 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 连接池
const pool = mysql.createPool({ connectionLimit: 10, // 按数据库 max_connections 与实例数计算 waitForConnections: true, queueLimit: 0, acquireTimeout: 10000,})常见错误:连接池设得很大(如 100),多实例部署后总连接数超过数据库上限,导致连不上。
估算:实例数 × connectionLimit < 数据库 max_connections × 0.8
七、数据库性能优化
7.1 索引
-- 联合索引遵循最左前缀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 验证
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 慢查询
# my.cnfslow_query_log = 1long_query_time = 1slow_query_log_file = /var/log/mysql/slow.log# 汇总分析mysqldumpslow -s t -t 10 /var/log/mysql/slow.log # 按总耗时排序前 107.4 大数据量处理
| 问题 | 手段 |
|---|---|
| 分页越翻越慢 | 游标分页(WHERE id < lastId)替代 LIMIT offset |
| 单表过大 | 归档历史数据、按时间分区、分表 |
| Count 很慢 | 用近似值或维护计数表 |
| 批量插入慢 | 批量 INSERT ... VALUES (...),(...),分批 500–1000 条 |
| 全表更新 | 分批更新 + 限速,避免锁表与主从延迟 |
八、前端性能优化
8.1 核心加载链路
DNS → TCP/TLS → HTML → CSS/JS → 渲染 → 图片| 环节 | 优化手段 |
|---|---|
| DNS | dns-prefetch |
| 连接 | preconnect 关键域名 |
| HTML | 服务端渲染或静态化,减少白屏 |
| CSS/JS | 代码分割、按需加载、gzip/brotli |
| 渲染 | 关键 CSS 内联,非关键资源异步加载 |
| 图片 | WebP/AVIF、懒加载、设置宽高避免布局抖动 |
<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 压测基本流程
# 用 ab / wrk / k6 做压力测试wrk -t4 -c100 -d30s --latency http://127.0.0.1:8080/api/products// 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,性能会断崖式下降(磁盘比内存慢几个数量级)
# 观察是否有 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 有观察,避免换页抖动
十二、总结
性能优化可以归结为四句话:
- 先测量再优化——没有数据支撑的优化是在赌运气。
- 优先动架构——加缓存、异步化、消 N+1,收益远大于微调参数。
- 缓存的难点不是「加」,而是「失效」——穿透、击穿、雪崩分别对应三种失效场景,各有成熟解法。
- 看分位数不看平均值——P95/P99 才是真实用户体验,平均值会掩盖尾部灾难。
最后一条经验:优化要留痕。记录下「优化前后同一接口的 P95 变化」,既能量化收益,也能防止未来的改动悄悄把它退化回去。
完