02 | 高性能架构 —— 缓存策略、数据库优化、读写分离、分库分表
性能是系统的生命线。当用户等待超过 3 秒,53% 的移动用户会放弃访问(Google 2016 年研究数据,随网络条件改善用户预期可能更高)。
开篇自测
- 缓存穿透、缓存击穿、缓存雪崩分别是什么?你知道各自的应对方案吗?
- 读写分离后,主从延迟导致用户刚写入的数据读不到,该如何解决?
- 分库分表后,跨表的 JOIN 查询和全局排序该怎么办?
一、性能优化的全局视角
1.1 性能瓶颈在哪里
一个典型的 Web 请求链路如下:
用户浏览器 --> CDN --> 负载均衡 --> 应用服务器 --> 缓存 --> 数据库
(1) (2) (3) (4) (5) (6)
各环节典型耗时:
(1) DNS 解析 + TCP 建连:50-200ms
(2) CDN 命中返回静态资源:1-10ms
(3) 负载均衡转发:< 1ms
(4) 业务逻辑处理:10-100ms
(5) 缓存读取:1-5ms
(6) 数据库查询:5-500ms
从数据可以看出,数据库往往是最大的瓶颈。一次复杂的 SQL 查询可能耗时数百毫秒,而 Redis 缓存只需要 1 毫秒。因此,高性能优化的第一原则是:尽量减少对数据库的直接访问。
1.2 性能优化的三板斧
面对性能问题,有三种通用的解决思路:
| 策略 | 方法 | 适用场景 |
|---|---|---|
| 缓存 | 将热点数据存入内存 | 读多写少的场景 |
| 异步 | 将耗时操作放到后台执行 | 非实时性要求的操作 |
| 并行 | 将大任务拆分为多个小任务并发执行 | 可拆分的计算或 IO 操作 |
本章重点讨论缓存和数据库相关的优化手段。
二、缓存策略深度解析
2.1 多级缓存架构
在一个成熟的系统中,缓存通常不止一层:
请求到达
|
+------------------+
命中? | 浏览器缓存 | 第一级:离用户最近
| (HTTP缓存: |
| Cache-Control/ |
| Expires/ETag) |
+------------------+
未命中 |
+------------------+
命中? | CDN 缓存 | 第二级:边缘节点
| (静态资源/页面) |
+------------------+
未命中 |
+------------------+
命中? | 接入层缓存 | 第三级:Nginx 本地缓存
| (Nginx Proxy |
| Cache) |
+------------------+
未命中 |
+------------------+
命中? | 应用级缓存 | 第四级:进程内缓存
| (Guava/Caffeine) |
+------------------+
未命中 |
+------------------+
命中? | 分布式缓存 | 第五级:Redis/Memcached
+------------------+
未命中 |
+------------------+
| 数据库 | 最终数据源
+------------------+
层次越靠前,访问速度越快,但数据实时性越差。 系统设计时需要根据业务特点选择合适的缓存层级。
2.2 缓存读写模式
Cache-Aside(旁路缓存)——最常用的模式:
读流程:
1. 先查缓存
2. 缓存命中 -> 直接返回
3. 缓存未命中 -> 查数据库 -> 将结果写入缓存 -> 返回
写流程:
1. 更新数据库
2. 删除缓存(而非更新缓存)
为什么是"删除"而非"更新"缓存?
- 避免并发写导致的数据不一致
- 缓存的值可能是经过计算的,更新成本高
- 有些数据更新后不一定会被立即读取,更新缓存是浪费
Read-Through / Write-Through(读穿/写穿):
+----------+ +----------+ +----------+
| 应用 | ----> | 缓存层 | ----> | 数据库 |
+----------+ +----------+ +----------+
缓存层封装了对数据库的读写,应用只与缓存交互。
- Read-Through:缓存未命中时,缓存层自动加载数据
- Write-Through:写操作同时写缓存和数据库(同步)
Write-Behind(异步写回):
写操作只写缓存,缓存层异步批量写入数据库。
优点:写入性能极高
缺点:数据可能丢失(缓存宕机时未持久化的数据会丢)
适用:允许少量数据丢失的场景(如计数器、日志)
2.3 缓存三大灾难及应对
缓存穿透:查询一个不存在的数据,缓存永远不命中,每次都打到数据库。
攻击者故意请求 id=-1 的数据
-> 缓存中没有
-> 数据库中也没有
-> 缓存不会被填充
-> 下次请求继续穿透
应对方案:
方案 1:缓存空值(设置较短 TTL,如 30 秒)
方案 2:布隆过滤器(在缓存之前拦截不存在的 Key)
请求 --> [布隆过滤器] --> 一定不存在? --> 直接返回空
|
可能存在
|
--> [缓存] --> [数据库]
缓存击穿:某个热点 Key 过期的瞬间,大量并发请求同时打到数据库。
10:00:00 热点商品缓存过期
10:00:01 1000 个并发请求同时查缓存 -> 全部未命中
10:00:01 1000 个请求同时查数据库 -> 数据库压力暴增
应对方案:
方案 1:互斥锁(只允许一个线程回源,其他等待)
方案 2:热点数据永不过期 + 后台异步刷新
方案 3:提前续期(在过期前主动刷新缓存)
缓存雪崩:大量缓存 Key 同时过期,或者缓存服务整体宕机。
应对方案:
1. 过期时间加随机值,避免同时过期
TTL = base_ttl + random(0, 300) // 基础TTL + 0~300秒随机
2. 缓存高可用:Redis Sentinel 或 Redis Cluster
3. 多级缓存:本地缓存兜底
4. 限流降级:当数据库压力过大时,返回默认值或排队等待
2.4 缓存一致性问题
在 Cache-Aside 模式下,“先更新数据库,再删除缓存”仍然可能出现短暂的不一致:
时间线:
T1: 线程A 更新数据库(value=新值)
T2: 线程B 读缓存(命中,返回旧值) <-- 短暂不一致
T3: 线程A 删除缓存
T4: 线程B 读缓存(未命中)-> 查数据库 -> 获得新值
这种不一致窗口通常只有几毫秒,大多数业务可以接受。
如果业务对一致性要求极高,可以使用以下增强方案:
- 延迟双删:更新数据库后删缓存,等待一小段时间后再删一次
- 订阅 Binlog:通过 Canal 等工具监听 MySQL Binlog,异步更新缓存
- 版本号机制:缓存数据带版本号,写入时只接受更高版本的数据
三、数据库优化
3.1 SQL 与索引优化
索引是数据库性能优化的第一把钥匙。一条没有走索引的查询,在千万级数据表上可能耗时数秒甚至数十秒。
常见的索引优化原则:
| 原则 | 说明 |
|---|---|
| 最左前缀匹配 | 联合索引 (a,b,c) 支持 a、a+b、a+b+c 的查询,不支持 b+c |
| 覆盖索引 | 查询的字段全部在索引中,无需回表 |
| 索引下推 | MySQL 5.6+ 在存储引擎层利用索引条件过滤行,减少传给 Server 层的数据量 |
| 避免索引失效 | 对索引列做函数运算、隐式类型转换等会导致索引失效 |
| 控制索引数量 | 索引过多会降低写入性能,一般单表不超过 5-6 个 |
慢查询排查流程:
1. 开启慢查询日志(slow_query_log)
2. 找出耗时最长的 TOP N 查询
3. 使用 EXPLAIN 分析执行计划
4. 关注 type(扫描方式)、rows(扫描行数)、Extra(额外信息)
5. 根据分析结果优化索引或改写 SQL
EXPLAIN 关键字段解读:
+------+--------+-------+---------+------+----------+
| type | 含义 | 性能 | 示例 | rows | Extra |
+------+--------+-------+---------+------+----------+
| ALL | 全表扫描| 最差 | 无索引 | 100万| - |
| index| 索引扫描| 较差 | 全索引 | 100万| Using idx|
| range| 范围扫描| 中等 | BETWEEN | 1000 | Using idx|
| ref | 索引查找| 良好 | 等值匹配 | 10 | Using idx|
| const| 常量查找| 最优 | 主键查找 | 1 | - |
+------+--------+-------+---------+------+----------+
3.2 连接池优化
数据库连接的创建和销毁是昂贵的操作。合理配置连接池参数至关重要:
核心参数:
- 最小空闲连接数 (minIdle):保持一定数量的空闲连接,避免冷启动
- 最大连接数 (maxActive):限制总连接数,防止打满数据库
- 连接超时 (connectionTimeout):获取连接的最大等待时间
- 空闲超时 (idleTimeout):空闲连接的最大存活时间
经验公式(HikariCP Wiki 推荐,基于 PostgreSQL 经验):
最大连接数 = ((核心数 * 2) + 有效磁盘数)
例:4 核 CPU + 1 块 SSD
最大连接数 = (4 * 2) + 1 = 9
注意:这是单个数据库实例的建议值,实际还需根据应用实例数量分配。
四、读写分离
4.1 基本架构
读写分离的核心思想是:写操作走主库,读操作走从库,从而将读压力分散到多个从库上。
应用服务器
/ | \
写请求 读请求 读请求
| | |
v v v
+--------+ +------+ +------+
| Master | | Slave| | Slave|
| (主) | | (从1)| | (从2)|
+--------+ +------+ +------+
| ^ ^
+----------+--------+
Binlog 同步
4.2 主从延迟问题
MySQL 主从复制是异步的,主库写入数据后,从库需要一定时间才能同步到。这个延迟通常在毫秒到秒级,但在高负载时可能达到分钟级。
典型场景:用户修改了头像,页面刷新后却看到的还是旧头像(因为读请求打到了还未同步的从库)。
解决方案:
| 方案 | 实现方式 | 适用场景 |
|---|---|---|
| 强制走主库 | 对写后立即读的场景,标记走主库 | 用户自己的写后读 |
| 延迟读取 | 写入后短暂延迟再读(前端 loading) | 对实时性要求不高 |
| 半同步复制 | 主库等待至少一个从库确认后才返回 | 对一致性要求较高 |
| 缓存过渡 | 写入时同步更新缓存,读优先读缓存 | 热点数据 |
| GTID 方案 | 记录写操作的 GTID,读从库时等待该 GTID 回放完成 | 技术实力较强的团队 |
4.3 读写分离的中间件选型
| 中间件 | 类型 | 特点 |
|---|---|---|
| MySQL Router | 官方代理 | 轻量,与 MySQL 集成好 |
| ProxySQL | 代理层 | 功能丰富,支持查询缓存 |
| ShardingSphere | SDK/代理 | 同时支持分库分表和读写分离 |
| 应用层路由 | 代码级 | 灵活但侵入性强 |
五、分库分表
5.1 什么时候需要分库分表
分库分表不是银弹,它带来的复杂度远超想象。 在采用分库分表之前,应该优先考虑以下方案:
- 优化 SQL 和索引
- 引入缓存层
- 读写分离
- 垂直拆分(按业务拆库)
当单表数据量超过 2000 万行(经验法则,实际取决于行大小和查询复杂度——窄表可能 1 亿行仍高效,宽表可能 500 万行就需考虑),或者单库的并发写入超过 5000 QPS 时,才需要认真考虑水平分表。
5.2 分库分表策略
垂直拆分:按业务维度拆分
拆分前(单库):
+----------------+
| 用户表 |
| 订单表 |
| 商品表 |
| 日志表 |
+----------------+
拆分后(多库):
+--------+ +--------+ +--------+ +--------+
| 用户库 | | 订单库 | | 商品库 | | 日志库 |
| 用户表 | | 订单表 | | 商品表 | | 日志表 |
+--------+ +--------+ +--------+ +--------+
水平拆分:按数据维度拆分
拆分前(单表 1 亿行):
+----------------------+
| orders |
| id: 1 ~ 100000000 |
+----------------------+
拆分后(4 张分表):
+------------------+ +------------------+
| orders_0 | | orders_1 |
| id % 4 == 0 | | id % 4 == 1 |
+------------------+ +------------------+
+------------------+ +------------------+
| orders_2 | | orders_3 |
| id % 4 == 2 | | id % 4 == 3 |
+------------------+ +------------------+
5.3 分片键(Sharding Key)的选择
分片键的选择直接决定了分库分表的效果:
| 考量维度 | 说明 |
|---|---|
| 数据均匀 | 分片键的取值应尽量均匀分布,避免热点 |
| 查询友好 | 大多数查询应包含分片键,避免跨分片查询 |
| 业务关联 | 相关联的数据尽量落在同一分片,减少跨分片 JOIN |
常见分片策略:
-
哈希取模:
shard_id = hash(sharding_key) % shard_count- 优点:数据分布均匀
- 缺点:扩容时需要数据迁移
-
范围分片:按时间或 ID 范围分片
- 优点:扩容方便,只需新增分片
- 缺点:可能产生热点(新数据集中在最新分片)
-
一致性哈希:将分片和数据都映射到一个哈希环上
- 优点:扩容时只需迁移少量数据
- 缺点:实现复杂
5.4 分库分表带来的挑战
挑战 1:跨分片查询
- 问题:SELECT * FROM orders WHERE status=1 ORDER BY create_time LIMIT 10
该查询无法定位到具体分片,需要查询所有分片后合并排序
- 方案:冗余索引表、搜索引擎(ES)辅助查询
挑战 2:分布式事务
- 问题:一个业务操作涉及多个分片的数据修改
- 方案:XA 事务、TCC、Saga、消息最终一致性(详见第 6 章)
挑战 3:全局唯一 ID
- 问题:自增 ID 在多个分片间会冲突
- 方案:Snowflake 算法、号段模式、UUID(详见第 6 章)
挑战 4:扩容
- 问题:从 4 个分片扩展到 8 个分片,需要迁移数据
- 方案:成倍扩容(4->8->16)、一致性哈希、双写迁移
5.5 分库分表中间件
| 中间件 | 模式 | 特点 |
|---|---|---|
| ShardingSphere-JDBC | SDK | 轻量,无需额外部署 |
| ShardingSphere-Proxy | 代理 | 对应用透明,多语言支持 |
| Vitess | 代理 | YouTube 开源,云原生友好 |
| TiDB | NewSQL | 兼容 MySQL,自动分片 |
六、综合实战:电商商品系统的性能优化
6.1 场景描述
一个电商平台的商品系统,面临以下性能挑战:
- 商品总量 5000 万,日均 PV 5 亿
- 商品详情页 P99 延迟要求 < 200ms
- 大促期间流量是日常的 10 倍
6.2 优化方案
用户请求
|
+-------------+
| CDN 层 | 静态资源(图片/CSS/JS)直接返回
+-------------+
|
+-------------+
| Nginx 层 | 页面级缓存(完整HTML),TTL=3s
+-------------+
|
+-------------+
| 应用服务器 | 本地缓存(Caffeine),热点商品
+-------------+
|
+-------------+
| Redis 集群 | 分布式缓存,商品详情 JSON
+-------------+
|
+------+------+
| |
+---------+ +---------+
|商品主库 | |商品从库 | 读写分离
|(写入) | |(读取) |
+---------+ +---------+
各层策略:
| 层次 | 策略 | 命中率目标 |
|---|---|---|
| CDN | 静态资源长期缓存,版本化文件名 | 95% |
| Nginx | 商品页缓存 3 秒,大促时延长到 10 秒 | 60% |
| 本地缓存 | TOP 1000 热门商品,TTL=5s | 30% |
| Redis | 全量商品缓存,TTL=30min | 99% |
| 数据库 | 最终兜底,承载 < 1% 的请求 | - |
经过多级缓存后,到达数据库的请求量从 5 亿降至不到 500 万,数据库压力下降了 99%。
思考题
-
在缓存一致性方案中,“先删缓存再更新数据库”和”先更新数据库再删缓存”各有什么问题?为什么业界普遍推荐后者?
-
如果你的业务同时有”按用户 ID 查订单”和”按商品 ID 查订单”两种高频查询,分片键该如何选择?有没有两全其美的方案?
结尾自测
-
Cache-Aside 模式的写流程为什么是”更新数据库 + 删除缓存”而非”更新数据库 + 更新缓存”?
- 答:避免并发写导致的数据不一致;缓存值可能需要计算,更新成本高;数据更新后不一定会被立即读取,更新缓存可能浪费资源。
-
缓存穿透的两种核心解决方案是什么?
- 答:缓存空值(短 TTL)和布隆过滤器。
-
MySQL 主从延迟导致读到旧数据,最常用的解决方案是什么?
- 答:写后读强制走主库;或者写入时同步更新缓存,读操作优先读缓存。
-
水平分表时,为什么不推荐使用 UUID 作为分片键?
- 答:UUID 无序且随机,会导致数据分布不均匀,且无法做范围查询。此外 UUID 较长,存储和索引效率低。
-
多级缓存架构中,越靠近用户的缓存层有什么特点?
- 答:访问速度越快,但数据实时性越差、存储容量越小。需要根据数据的更新频率和一致性要求选择合适的缓存层级。
下一章预告:性能做好了还不够,系统还要能扛住故障。下一章我们将深入高可用架构,聊聊冗余设计、故障转移、限流熔断以及 CAP/BASE 理论。
购买课程解锁全部内容
面试晋升必学:11 章掌握系统设计
¥29.90