为什么需要同时使用 MySQL + Redis?持久化与缓存的分工
一、从一个真实场景说起
假设你开发了一个平台的用户主页接口。用户打开页面,需要加载 TA 的关注数、粉丝数、最近发布的 20 条内容列表、获赞总数。
- 第一次上线时,你只用了 MySQL,一切正常。
- 三个月后,日活涨到 50 万。高峰时段,QPS(每秒请求数,衡量系统吞吐的核心指标)飙到 8000,平均响应时间从 50ms 暴涨到 2 秒。
- 监控面板上数据库 CPU 100%。
SHOW PROCESSLIST里几百条Sending data。
直觉告诉你:加缓存。 但问题随之而来:
- 为什么缓存能解决这个问题?
- 加了 Redis 之后,MySQL 是不是就变得可有可无了?
- 数据在 MySQL 里存一份、Redis 里存一份,两者到底怎么分工?
本文从存储本质出发,把这件事讲透。
二、MySQL 与 Redis:一对互补的搭档
先看一张核心对比表,快速建立认知:
| 维度 | MySQL | Redis |
|---|---|---|
| 存储介质 | 磁盘(HDD/SSD) | 内存 |
| 主要定位 | 数据的"老家" — 持久化存储 | 数据的"前台" — 高速缓存 |
| 数据模型 | 关系型(表、行、列、索引) | 键值 + 丰富数据结构 |
| 查询能力 | SQL,复杂 JOIN、聚合 | 键查找 + 有限集合操作 |
| 典型 QPS | 单机 5000 ~ 20000 | 单机 10 万+ |
| 持久化 | 天然强持久化 | RDB 快照 + AOF 日志(两种持久化方式各有取舍,可能丢少量数据) |
| 事务保证 | ACID(原子性、一致性、隔离性、持久性,InnoDB 引擎完整支持) | 有限事务(MULTI/EXEC 保证命令顺序执行,但不支持回滚) |
| 数据量级 | TB 级 | GB 级(受内存限制) |
| 扩展方式 | 读写分离、分库分表 | 主从复制、集群分片 |
一句话总结:MySQL 负责"记住",Redis 负责"快拿"。 这两者不是二选一的关系——几乎所有上了规模的后端服务,都是二者并用。
快速扫盲:如果你对下列术语还不太熟,这里给一句话解释:
- ACID:数据库事务的四个基本保证 —— 要么全做要么全不做(原子性)、数据规则不被破坏(一致性)、并发事务互不干扰(隔离性)、提交后数据不丢失(持久性)。
- RDB:Redis 默认的快照持久化方式,每隔一段时间把整个内存数据保存到磁盘文件,恢复速度快但可能丢失最后一次快照后的数据。
- AOF:Redis 的追加日志持久化,每收到一条写命令就记到日志文件里,数据更安全但文件会越来越大。通常生产环境两者结合使用。
- InnoDB:MySQL 最常用的存储引擎,支持事务、行级锁、崩溃恢复,是 MySQL 5.5 之后的默认引擎。
三、持久化 vs 缓存:本质区别
这是理解分工的关键。两层抽象,互不可替代。
3.1 什么是持久化?
持久化回答一个根本问题:“机器重启后,数据还在不在?”
MySQL 的 InnoDB 引擎通过以下机制保证持久化:
- Write-Ahead Logging(WAL,预写日志):每次修改数据时,先把"我做了什么改动"这句话记到 redo log(重做日志,相当于操作流水账)里,确认日志写成功了,再去内存中真正修改数据。即使还没来得及把内存数据刷到磁盘就崩溃了,重启后靠 redo log 回放就能恢复所有已提交的修改。
- Doublewrite Buffer(双写缓冲):InnoDB 的数据页大小是 16KB,而操作系统一次原子写入通常只有 512 字节或 4KB。如果一个 16KB 的页写到一半断电,这页数据就"断裂"了(partial page write,部分页写入失败)。双写缓冲的做法是:先把要写入的页整体拷贝到磁盘上一个连续的双写区,确认完整写入后,再逐个写入实际的数据文件。这样即使断电,也能从双写区恢复完整页。
- fsync 强制刷盘:
fsync是一个系统调用,让操作系统把文件缓冲区中的数据立刻写入物理磁盘,而不是先留着慢慢写。MySQL 参数innodb_flush_log_at_trx_commit = 1的含义是:每个事务提交时,必须等 redo log 通过 fsync 真正写入磁盘,才返回"提交成功"给客户端。这是性能最慢但最安全的配置。
即使数据库进程崩溃、操作系统崩溃、甚至机房断电 —— 只要磁盘没有物理损坏,已提交的数据就不会丢失。
3.2 什么是缓存?
缓存回答的是另一个问题:“下次访问能不能更快?”
Redis 将热数据放在内存中,设计上围绕"快"而非"稳":
- LRU / LFU 淘汰策略:内存是有限的,当写满时 Redis 必须踢掉一些老数据来腾空间。LRU(Least Recently Used,最近最少使用)优先淘汰那些最长时间没有被访问过的 key;LFU(Least Frequently Used,最不经常使用)优先淘汰访问次数最少的 key。具体用哪个由
maxmemory-policy参数决定。 - TTL 自动过期:TTL 即 Time To Live(存活时间)。Redis 的
EXPIRE命令可以给任意 key 设定一个倒计时,时间一到 key 自动删除,不需要手动清理。这避免了脏缓存永久驻留的问题。 - 无关系约束:没有外键、没有唯一性校验(除了 SET 类型),数据完整性由应用层保证。
- 允许丢失:RDB 快照默认每隔一段时间才存一次(可配置间隔),AOF 也有写入策略的折中(
everysec最多丢 1 秒数据)。
3.3 一张图看清关系
┌─────────────────┐
│ 客户端请求 │
└────────┬────────┘
│
┌───────▼───────┐
│ 命中缓存? │
└───────┬───────┘
┌────────────┴────────────┐
│ │
┌───▼───┐ ┌───▼───┐
│ YES │ │ NO │
└───┬───┘ └───┬───┘
│ │
┌──────────▼──────────┐ ┌──────────▼──────────┐
│ 从 Redis 读取 │ │ 从 MySQL 读取 │
│ 0.1ms ~ 1ms │ │ 10ms ~ 100ms │
└─────────────────────┘ └──────────┬──────────┘
│
┌────────────▼────────────┐
│ 结果回写 Redis │
│ (缓存预热 / 重建) │
└─────────────────────────┘
Redis 不是 MySQL 的替代品,而是立在 MySQL 前面的一道闸门。 热数据被闸门挡住直接返回,只有冷数据才穿透到数据库。
四、为什么不能只用一个?
4.1 只用 MySQL,不用 Redis?
回到文章开头的场景 —— QPS 8000,响应 2 秒。
- 磁盘 I/O 是天然瓶颈:即使 NVMe SSD(一种走 PCIe 通道的高速固态硬盘,比普通 SATA SSD 快 5~10 倍),每秒随机读也不过几十万次。而 8000 QPS 下,每个请求可能触发多次数据库查询,磁盘很快就忙不过来。
- 连接数爆炸:MySQL 每个连接是一条独立线程,8000 并发意味着 8000 个线程争抢 CPU 时间片和磁盘带宽,上下文切换开销远远超过实际工作。
- 复杂查询放大延迟:
SELECT COUNT(*)、ORDER BY、GROUP BY在百万级数据上动辄数百毫秒。如果不缓存结果,每次请求重新执行,延迟完全不可预测。 - 行锁竞争:写操作拿行锁(InnoDB 默认),高并发下大量请求排队等待锁释放,吞吐急剧下降。
结论:MySQL 单打独斗能跑,但绝对跑不快。
4.2 只用 Redis,不用 MySQL?
这是一个更危险的误区。很多人觉得"Redis 也有 RDB/AOF 持久化,是不是可以替代 MySQL?"
不能。 理由如下:
- RDB 是快照,不是实时持久化:默认配置下,RDB 每 900 秒(15 分钟)且至少 1 个 key 变化才触发保存。在这 15 分钟内宕机,所有变化永久丢失。
- AOF 有写入策略折中:
appendfsync everysec每秒调用一次 fsync,最多丢失 1 秒数据。即使激进地设为appendfsync always(每次写入都 fsync),性能也会骤降到磁盘速度,完全失去"快"的意义。 - 内存成本是磁盘的 10 倍以上:1TB 数据存在 MySQL 里只需要几百块的 HDD,存在 Redis 里需要至少 1TB 内存 —— 成本差了一个数量级。
- 缺少关系型查询能力:没有 JOIN、没有子查询、没有复杂聚合。Redis 做不到
SELECT * FROM orders WHERE user_id = 123 AND status = 'paid' ORDER BY create_time DESC LIMIT 20。 - 事务能力远不够用:MULTI/EXEC 只保证命令按序执行,不支持回滚;没有隔离级别概念;没有外键约束;不能保证跨 Key 的原子性(除非用 Lua 脚本)。
结论:Redis 的持久化是"尽力而为",MySQL 的持久化是"保证到底"。两者定位完全不同。
五、典型协作模式
当 MySQL 和 Redis 同时在线,最核心的问题就是:读写请求怎么走?数据怎么流转? 业界沉淀出了几种经典协作模式,最主流的是 Cache-Aside。
5.1 Cache-Aside(旁路缓存)— 最主流的模式
所谓"旁路",指的是缓存和数据库各自独立,应用代码同时管着两头。具体来说:
读操作的流程(四步走):
- 请求进来,先问 Redis:“这个 key 你有吗?”
- 有(命中)→ 直接返回,结束。
- 没有(未命中)→ 转身去 MySQL 查。
- 查到了 → 把结果写入 Redis(设好过期时间),再返回给客户端。
这里有一个容易被忽略的细节:如果 MySQL 里也查不到怎么办? 说明这个数据确实不存在。此时应该往 Redis 里写一个空值标记(比如字符串 "NULL"),并设一个较短的过期时间(如 60 秒)。这样下次再有人来问同一个不存在的 key,Redis 就能直接回答"没有",不用每次都去 MySQL 白白查一遍——这就是防止缓存穿透的经典手段。
写操作的流程(两步走):
- 先写 MySQL,等事务提交成功。
- 再删除 Redis 中对应的缓存 key(注意:是删除,不是更新)。
为什么是"删除"而不是"更新"? 这是一个经典的并发陷阱。假设两个请求 A 和 B 同时修改同一条数据:
- T1: A 更新 MySQL,title 改为 “A”
- T2: B 更新 MySQL,title 改为 “B”
- T3: B 更新 Redis,写入 “B”
- T4: A 更新 Redis,覆盖为 “A”
最终 MySQL 里是 “B”,Redis 里却是 “A”——永久不一致,而且没人会发现。如果改为删除,无论 A 和 B 谁先删谁后删,下一次读请求一定会从 MySQL 重新加载最新值重建缓存,不会出错。
Cache-Aside 的优缺点:
- 优点:实现极其简单,不依赖任何特殊中间件,缓存层和数据库层完全解耦。
- 缺点:需要应用层自行处理缓存逻辑(判断命中/未命中、回写、删除);首次请求或缓存过期后的第一次访问会慢(冷启动问题)。
5.2 计数器场景:Redis 原子递增 + 异步回写
点赞数、阅读量、视频播放量这类"写入极频繁、允许短暂数据偏差"的指标,是 MySQL 的噩梦。以点赞为例:
- 如果用 MySQL:
UPDATE content SET like_count = like_count + 1 WHERE id = 456,每次都要拿行锁,高并发下几百上千个请求排队等锁,TPS 骤降到几百。 - 如果用 Redis:
INCR content:456:likes,内存原子操作,单机轻松 10 万+ TPS,零锁竞争。
正确做法分两层:
- 实时层(Redis):用户每次点赞,直接
INCRRedis 里的计数器,立即返回。这一步追求极致的响应速度。 - 持久层(MySQL):通过一个异步任务(可以是后台定时任务,也可以借助消息队列),定期把 Redis 中的计数批量更新到 MySQL。这一步保证数据最终落地。
如果 Redis 在两次同步之间宕机,最多丢失这一小段时间内的计数增量——对于这类场景,这个代价完全可以接受。
5.3 排行榜场景:ZSET 天生就是干这个的
MySQL 里查"热门内容 Top 100"需要 SELECT ... ORDER BY like_count DESC LIMIT 100,在百万级数据上要进行全表排序,延迟动辄数百毫秒。
Redis 提供了一种叫 Sorted Set(ZSET,有序集合)的数据结构:每个元素绑定一个分数(score),Redis 内部用跳表(skiplist)自动维护排序,取 Top N 的时间复杂度仅为 O(log(N) + M),毫秒级完成。
做法也很直接:将内容 ID 作为成员、热度分作为分数写入 ZSET。查询排行榜时按分数倒序取指定范围即可。如果 ZSET 还没有数据(冷启动),则在 MySQL 中查出初始数据批量塞进去。
5.4 三种模式的适用场景对号入座
| 场景 | 用哪种模式 | 为什么 |
|---|---|---|
| 用户主页、文章详情(读多写少) | Cache-Aside | 简单够用,命中率高 |
| 点赞数、播放量(写极多) | 异步写回(Write-Behind) | 避免 MySQL 行锁成为瓶颈 |
| 实时排行榜 | ZSET 缓存 | MySQL 排序太慢,ZSET 天然优势 |
| 验证码、临时 Token | Redis 仅存储 | 时效短,丢了也无所谓,无需落 MySQL |
| 订单状态、账户余额 | MySQL 主存储 + Cache-Aside 查询缓存 | 涉及资金,以数据库为准,缓存只是加速查询 |
六、缓存策略深度拆解
6.1 Cache-Aside(旁路缓存)— 最常用
读:应用 → 查缓存 → 命中返回 / 未命中 → 查 DB → 回写缓存
写:应用 → 写 DB → 删除 / 更新缓存
优点:实现简单,缓存与数据库完全解耦。
缺点:首次请求慢(冷启动),需要应用层自行处理一致性问题。
适用场景:90% 的通用 Web 应用。
6.2 Read-Through / Write-Through(穿透读写)
读:应用只访问缓存层 → 缓存层自带 DB 查询能力(自动回填)
写:应用只写缓存层 → 缓存层同步写入数据库
优点:应用代码只与缓存层交互,逻辑极其简洁。
缺点:需要支持该模式的缓存中间件(如 Apache Ignite、Hazelcast),Redis 原生不具备此能力。
适用场景:对数据一致性要求较高的内部系统。
6.3 Write-Behind(异步写回)
写:应用只写缓存 → 立即返回 → 异步线程批量刷新到数据库
优点:写入性能极高(完全不等待磁盘)。
缺点:缓存宕机 = 数据永久丢失;实现复杂,需要可靠的消息队列做缓冲。
适用场景:点赞计数、PV/UV 统计(PV 即页面浏览量、UV 即独立访客数)、实时日志等允许最终一致的场景。
6.4 策略速查表
| 策略 | 写延迟 | 丢数据风险 | 实现复杂度 | 一致性等级 |
|---|---|---|---|---|
| Cache-Aside | 低 | 低 | 低 | 最终一致 |
| Read/Write-Through | 高(同步写 DB) | 极低 | 高(需专用中间件) | 强一致 |
| Write-Behind | 极低 | 中 | 中 | 最终一致 |
七、数据一致性:最难的那个问题
同一个数据在 MySQL 和 Redis 中各存一份,不一致是必然的。核心目标是:把不一致的时间窗口缩到最短。
7.1 先删缓存,还是先写数据库?
| 操作顺序 | 风险场景 |
|---|---|
| 先删缓存 → 再写 DB | A 删了缓存 → B 并发读,从 DB 读到旧值并回写缓存 → A 写新值到 DB → 缓存里永远是旧值 |
| 先写 DB → 再删缓存 | A 写新值到 DB → B 并发读,从缓存命中旧值(因为还没删) → B 读到旧值,但窗口极短且不会持久 |
结论:先写数据库、再删缓存 是更安全的选择。虽然仍有极小概率读到旧值(窗口通常 < 1ms),但不会造成持久性脏数据。
7.2 延迟双删
当对一致性要求更高时,可以引入"延迟双删"策略。它的核心思路是多删一次,兜住边界:
- 第一次删除:在更新数据库之前,先把 Redis 中的旧缓存删掉。这可以防止其他并发读请求在数据库被更新之前读到旧值并回写到缓存。
- 更新数据库:正常执行 UPDATE,事务提交。
- 第二次删除:等待一小段时间(通常 300~500 毫秒)后,再次删除 Redis 中的同一个 key。
为什么需要第二次删除?因为在"第一次删除"和"数据库更新完成"之间,可能存在一个短暂的窗口:其他读请求发现缓存为空,从 MySQL 读到了尚未更新的旧值,然后回写到了 Redis。第二次删除就是专门覆盖这个窗口的——它会把这些"刚刚被错误重建起来的旧缓存"再清理掉。
适用场景:对一致性要求较高、但并发量还没有大到需要引入 Canal 等重型方案的系统。代价是每次写入多了几百毫秒的延迟(等待第二次删除的间隔)和一次额外的 Redis 删除操作。
7.3 订阅 MySQL Binlog,异步刷新缓存
这是目前最彻底的方案 —— 以数据库为唯一真相源(Single Source of Truth,即"只听数据库的,它说什么就是什么"):
MySQL 数据变更
│
▼
Canal 伪装成 MySQL Slave,实时接收 Binlog 事件
│
▼
投递到消息队列(Kafka / RocketMQ)
│
▼
消费者消费事件,更新或删除对应的 Redis Key
- Binlog(Binary Log,二进制日志):MySQL 用来记录所有数据变更的流水日志。主从复制靠它,数据恢复也靠它。每一条 INSERT、UPDATE、DELETE 都会记录进去。
- Canal:阿里巴巴开源的工具,它伪装成 MySQL 的从库(slave),假装自己要同步数据,从而实时获取 binlog 中的每一条变更事件。拿到事件后,投递到消息队列供下游消费。
- 核心思想:所有缓存更新由 binlog 事件自动驱动,不再依赖业务代码里手动调
r.delete()。 - 优点:彻底解耦缓存与业务代码;即使代码忘了删缓存,binlog 监听器也会兜底。
- 缺点:架构复杂度显著增加,需要额外维护 Canal + MQ 两条链路。
八、常见误区与避坑指南
误区一:“Redis 有 RDB 和 AOF,可以替代数据库”
正解:Redis 的持久化是"尽力而为"而非"保证到底"。RDB 是定期快照,AOF everysec 最多丢 1 秒数据。更重要的是,Redis 没有 ACID 事务、没有外键、没有 SQL 查询能力。它是缓存,不是数据库。
误区二:“所有查询都应该缓存”
正解:以下数据不适合放进 Redis:
- 频繁增删改的数据(命中率极低,缓存成了摆设)
- 极少被访问的冷数据(白白占用昂贵的内存)
- 实时性要求极端严格的数据(缓存窗口延迟不可接受)
- 过大的对象(大 Key 会阻塞 Redis 单线程,导致其他请求排队)
误区三:“缓存永不过期就行”
正解:必须设置 TTL,三个理由:
- 内存有限,无限 TTL 迟早 OOM(Out of Memory,即内存耗尽,Redis 进程会被操作系统杀死)。
- MySQL 中的数据可能被旁路修改(如后台脚本直连 DB),缓存不感知。
- 业务逻辑迭代后,老缓存的数据结构可能已不兼容新版代码。
误区四:“Redis Cluster 解决一切”
正解:Redis Cluster 解决了吞吐和容量的扩展问题,但引入了不可忽视的限制:
- 跨 slot 的多 Key 命令不支持。Redis Cluster 将数据划分为 16384 个哈希槽(slot),每个 key 通过 CRC16 哈希算法映射到一个槽上,不同槽的数据分布在不同节点上。因此像
MGET key1 key2这种一次读多个 key 的命令,如果 key1 和 key2 落在不同槽上(不同节点上),就会返回错误CROSSSLOT。 - 事务只能在单个 slot 内生效
- 客户端必须感知 slot 迁移(
MOVED/ASK重定向) - 网络分区时可能发生脑裂
误区五:“雪崩和穿透是一回事”
正解:三种经典缓存故障,原因和现象完全不同:
| 故障类型 | 原因 | 表现 | 解法 |
|---|---|---|---|
| 缓存雪崩 | 大量 Key 在同一时刻过期 | 缓存集体失效,流量瞬间全部砸向 DB | 过期时间加随机偏移;限流降级 |
| 缓存穿透 | 请求的数据在 DB 中压根不存在 | 每次查询都穿透缓存打到 DB | 布隆过滤器(一种概率型数据结构,能快速判断"key 一定不存在",误判率可控);空值缓存 |
| 缓存击穿 | 单个热 Key 过期,并发请求涌入 | 瞬时大量请求打到 DB 同一行 | 互斥锁重建;永不过期 + 异步刷新 |
生产环境正确姿势速查
| 做这些 | 不要做这些 |
|---|---|
| 所有缓存 Key 设置合理的 TTL | 缓存 > 10KB 的大对象 |
| 过期时间加随机偏移(防雪崩) | 用 KEYS *(生产禁用!用 SCAN) |
| 写操作:先写 DB 再删缓存 | 并发写时更新缓存(而非删除) |
| 监控命中率(目标 > 90%) | 忽略 maxmemory 上限(不设内存上限的话,Redis 会无限制吃内存直到系统 OOM) |
| 用 Pipeline 批量操作(把多个命令打包一起发送,减少网络往返次数) | 单次请求执行过多 Redis 命令 |
九、总结
MySQL = 数据的"保险柜",负责持久存储、复杂查询、事务保证
Redis = 数据的"钱包",负责高速访问、计数统计、临时状态
两者不是竞争关系,而是分工协作:
MySQL 负责"记住",
Redis 负责"快拿"。
MySQL 是保险柜,Redis 是钱包 —— 重要的放保险柜,常用的放钱包。
读多写少的数据用 Cache-Aside(先查 Redis,未命中再查 MySQL)。
:写操作永远先写 MySQL 再删 Redis,用"删"而非"改"来避免并发不一致。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)