跳到主要内容
预计阅读 27 分钟

02 | 高性能架构 —— 缓存策略、数据库优化、读写分离、分库分表

性能是系统的生命线。当用户等待超过 3 秒,53% 的移动用户会放弃访问(Google 2016 年研究数据,随网络条件改善用户预期可能更高)。


开篇自测

  1. 缓存穿透、缓存击穿、缓存雪崩分别是什么?你知道各自的应对方案吗?
  2. 读写分离后,主从延迟导致用户刚写入的数据读不到,该如何解决?
  3. 分库分表后,跨表的 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代理层功能丰富,支持查询缓存
ShardingSphereSDK/代理同时支持分库分表和读写分离
应用层路由代码级灵活但侵入性强

五、分库分表

5.1 什么时候需要分库分表

分库分表不是银弹,它带来的复杂度远超想象。 在采用分库分表之前,应该优先考虑以下方案:

  1. 优化 SQL 和索引
  2. 引入缓存层
  3. 读写分离
  4. 垂直拆分(按业务拆库)

当单表数据量超过 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-JDBCSDK轻量,无需额外部署
ShardingSphere-Proxy代理对应用透明,多语言支持
Vitess代理YouTube 开源,云原生友好
TiDBNewSQL兼容 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=5s30%
Redis全量商品缓存,TTL=30min99%
数据库最终兜底,承载 < 1% 的请求-

经过多级缓存后,到达数据库的请求量从 5 亿降至不到 500 万,数据库压力下降了 99%。


思考题

  1. 在缓存一致性方案中,“先删缓存再更新数据库”和”先更新数据库再删缓存”各有什么问题?为什么业界普遍推荐后者?

  2. 如果你的业务同时有”按用户 ID 查订单”和”按商品 ID 查订单”两种高频查询,分片键该如何选择?有没有两全其美的方案?


结尾自测

  1. Cache-Aside 模式的写流程为什么是”更新数据库 + 删除缓存”而非”更新数据库 + 更新缓存”?

    • :避免并发写导致的数据不一致;缓存值可能需要计算,更新成本高;数据更新后不一定会被立即读取,更新缓存可能浪费资源。
  2. 缓存穿透的两种核心解决方案是什么?

    • :缓存空值(短 TTL)和布隆过滤器。
  3. MySQL 主从延迟导致读到旧数据,最常用的解决方案是什么?

    • :写后读强制走主库;或者写入时同步更新缓存,读操作优先读缓存。
  4. 水平分表时,为什么不推荐使用 UUID 作为分片键?

    • :UUID 无序且随机,会导致数据分布不均匀,且无法做范围查询。此外 UUID 较长,存储和索引效率低。
  5. 多级缓存架构中,越靠近用户的缓存层有什么特点?

    • :访问速度越快,但数据实时性越差、存储容量越小。需要根据数据的更新频率和一致性要求选择合适的缓存层级。

下一章预告:性能做好了还不够,系统还要能扛住故障。下一章我们将深入高可用架构,聊聊冗余设计、故障转移、限流熔断以及 CAP/BASE 理论。

购买课程解锁全部内容

面试晋升必学:11 章掌握系统设计

¥29.90