1. 先想清楚一个核心问题:项目里已经有了 MySQL,为什么还要用 Redis
我接触过不少团队,尤其是刚起步的小项目,经常会有这样的争论:我们的数据量也不算大,MySQL 完全扛得住,为什么要引入 Redis 这一层?多一个组件就多一份运维成本,多一份出故障的可能。这个疑问本身没有错,但它混淆了一个概念——MySQL 和 Redis 并不是替代关系,而是分工关系。
把两者放在一起对比,本质上是在问两个完全不同的问题:
- MySQL 解决的是"数据能不能可靠地保存下来,在并发访问时能不能保证一致和完整"。
- Redis 解决的是"高频读写时,访问速度能不能跟得上业务节奏"。
打个比方,MySQL 像是保险柜里的账本,每一笔进出都记录得清清楚楚,出了任何问题都能追诉,但它不适合所有人随时翻看。Redis 像是前台的一块白板,把最常用的信息写在上面,大家一眼就能看到,速度极快,但白板上的内容随时可能被擦掉重写。
我见过很多团队的项目结构是这样的:用户登录后,把 session 存在 MySQL 里,每次请求都去查一遍用户表;首页的配置数据每次请求都从数据库读取;商品详情页的访问量一大,数据库连接立刻打满,CPU 飙升。这些场景不是 MySQL 不行,而是使用方式不对。MySQL 擅长的是稳定存储和复杂查询,而不是扛住每秒几万次的重复读请求。
引入 Redis,最常见的动机就是缓存。把热点数据从 MySQL 里取出来,放到 Redis 里,下次请求直接打 Redis,MySQL 的压力瞬间大幅下降。但这只是 Redis 最基础的应用方式,它在项目里能干的事远不止缓存:分布式锁、计数器、消息队列、排行榜、限流、延时队列,这些在真实项目里都是非常成熟的使用场景。
适合看这篇文章的人:准备系统学习 MySQL 和 Redis 的后端开发者、正在做技术选型或者项目架构设计的工程师、面试前想梳理两者底层原理和项目实践的同学。我会从底层原理讲起,再做横向对比,最后把项目中最常踩的坑和完整的排查思路分享出来。
2. MySQL 的底层逻辑:一张表是怎么支撑起整个业务系统的
2.1 一条查询语句在 MySQL 里到底经历了什么
客户端发来一条 SQL,比如SELECT * FROM users WHERE id = 5,在 MySQL 内部,这条语句要依次经过连接器、分析器、优化器、执行器,最后才真正去存储引擎里取数据。
连接器负责认证和权限检查;分析器做词法分析和语法分析,把 SQL 拆解成 MySQL 能理解的内部结构;优化器决定这条 SQL 用什么索引、什么连接顺序来执行,这步决定了查询是秒回还是全表扫描;执行器调用存储引擎接口,真正去磁盘上捞数据。
这里面的核心,也是面试里最爱问的一个点,就是索引的数据结构——B+树。
为什么不用红黑树,不用哈希表,而偏偏选 B+ 树?因为 MySQL 的数据是存在磁盘上的,磁盘 IO 是最大的瓶颈。B+ 树是多路平衡查找树,它的特点是:
- 树的高度很低,一般三层到四层就能存储千万级数据。
- 每个节点可以存很多个 key,一次磁盘 IO 就能读取大量索引信息。
- 叶子节点通过指针相连,非常适合范围查询。
举一个非常直观的例子:一棵三层高的 B+ 树,第一层 root 节点常驻内存,第二层大概能存几千个节点,第三层叶子节点可以存几百万甚至上千万条记录的主键。也就是说,从输入 ID 到定位到实际记录的磁盘位置,通常只需要二到三次磁盘 IO。这就是为什么 MySQL 用 B+ 树而不是其他数据结构——它用最低的磁盘访问次数,换来最高的数据检索效率。
2.2 InnoDB 是怎么保证数据不丢的:事务与日志
InnoDB 是 MySQL 默认的存储引擎,它最重要的承诺就是事务的 ACID 特性。原子性、一致性、隔离性、持久性,这四个特性每一个背后都有具体的机制支撑。
原子性靠 undo log 实现,事务执行过程中如果出错了,可以用 undo log 回滚到事务开始前的状态。持久性靠 redo log,事务提交时先把变更写入 redo log(顺序写磁盘,速度很快),即使数据库突然宕机,重启后也能通过 redo log 把已提交的事务恢复出来。
这里要特别提一下 redo log 的循环写机制。MySQL 并不会每次事务提交都把数据页刷到磁盘,而是先写 redo log。想象一下,你在一家餐厅吃饭,服务员先把订单写在便利贴上,然后才慢慢去后厨下单。如果餐厅突然停电,只要便利贴还在,就能重新下单。这就是 WAL(Write-Ahead Logging)策略:日志先行,数据后写。所以就算你的 MySQL 跑了十几年,它也不会因为突然断电而丢失已提交的数据,奥秘就在这。
2.3 常被忽视的写放大问题与索引维护成本
很多人只知道 MySQL 读取数据快,却忽略了写入操作的成本。对一张表来说,每插入一条记录,不只是往表里塞一条数据,还要维护每一个二级索引。假设你的一张表建了 5 个索引,那么插入一条记录,可能意味着要更新 5 棵 B+ 树。索引不是越多越好,因为每多一个索引,写入的负担就多一分。
另外,B+ 树的节点在持续写入过程中会不断分裂和合并,如果主键是随机字符串,那么数据页会出现频繁的页分裂,遇到这种情况,写入性能会明显下降。这也是为什么在 InnoDB 里强烈建议使用自增整数主键,而不是随机 UUID。从这个角度看,MySQL 是一个"读写都有成本"的系统,它把数据的可靠性放在第一位,速度上的牺牲是换来稳定性的必要代价。
3. Redis 的快不是玄学:内存模型、事件循环与数据结构设计
3.1 一切快的基础:数据放在内存里
MySQL 的数据最终在磁盘上,Redis 的数据从头到尾都在内存里。磁盘的随机读延迟是毫秒级别,而内存的访问延迟是纳秒级别,中间差了几个数量级。这就是 Redis 为什么单条命令能做到微秒级响应最根本的原因。
但是内存意味着不便宜、也意味着断电即失。Redis 有两种持久化机制,RDB(快照)和 AOF(追加日志),目的都是让你在重启后能把内存里的数据恢复回来。RDB 是定时把内存数据做一次全量快照,恢复速度快,但可能丢失最后一次快照之后的数据;AOF 是把每一条写命令追加到日志文件,数据安全性更高,但文件体积大,恢复速度相对慢。生产环境里最常见的做法是两者结合:RDB 做备份和快速恢复,AOF 做数据兜底。
这里要澄清一个常见的误解:Redis 持久化不是为了让数据像 MySQL 那样可靠的。Redis 持久化文件坏了、丢了,只能说明缓存数据丢失,如果业务把核心数据只放在 Redis 里,那是用错了工具。Redis 的定位一直是"高性能的辅助存储",而不是"唯一的数据源"。
3.2 单线程为什么反而快:非阻塞 IO 与事件驱动
这是面试必考题。Redis 在 6.0 之前,核心命令执行是单线程的,为什么单线程反而比多线程更快?
核心原因有三个:
- 数据在内存中,执行速度极快,多线程切换上下文的开销反而可能超过命令执行本身。
- Redis 采用 IO 多路复用机制,通过 epoll 同时监听成千上万个 socket 连接,谁有数据来了就处理谁,不会阻塞在等待网络数据上。
- 单线程意味着没有锁竞争,没有死锁问题,数据结构也更简单,比如哈希表的扩容可以渐进式地做,不需要像多线程环境那样处处加锁。
可以用一个形象的场景来理解:一个超级快的服务员同时服务 100 张桌子,如果他每接一单就跑到后厨催菜再回来,时间全花在路上了。更好的做法是他只在座位上等待客人的需求,后厨做好菜会自动通知他。Redis 就是这个"只接收通知、不被任何一件事卡住"的服务员。
Redis 6.0 引入了多线程来分摊网络 IO 读写,但核心命令执行仍然保持单线程,原因就是保证命令执行的原子性,同时避免多线程带来的复杂度和不确定性。
3.3 五种基础数据类型对应什么项目场景
很多初学者背了 Redis 的五种数据类型——String、Hash、List、Set、ZSet——但不知道它们在实际项目中各自能干什么。我直接结合实际场景说:
| 数据类型 | 底层实现 | 典型业务场景 |
|---|---|---|
| String | SDS 动态字符串 | 缓存热点数据、计数器、分布式锁、Session 共享 |
| Hash | 哈希表 + ziplist | 存储对象信息,比如用户资料、购物车商品明细 |
| List | 双向链表 + quicklist | 消息队列、时间线列表、最新评论堆栈 |
| Set | 哈希表 + intset | 去重、共同好友、随机抽奖、标签系统 |
| ZSet | 跳表 + 哈希表 | 排行榜、延时队列、漏斗限流 |
举一个 ZSet 的典型例子:排行榜需求。如果要实时维护一个"用户积分 Top 50",用 MySQL 每次排序代价很高,数据量大时还会慢。用 ZSet,积分作为 score,用户 ID 作为 member,ZADD插入或更新积分,ZREVRANGE直接取 Top 50,查询时间复杂度是 O(logN),百万级用户也能毫秒级返回。
这正是 Redis 的核心价值所在:把特定场景下的读写路径缩短到极致。它不是万能的,但用它擅长的数据结构去解决匹配的业务问题,效果是最明显的。
4. MySQL 与 Redis 的对照:它们之间的差异到底体现在哪些维度
4.1 核心维度对比表
理清了两者的底层机制,这一节用一张表做横向对比,方便在技术选型时直接查阅:
| 对比维度 | MySQL | Redis |
|---|---|---|
| 主要定位 | 持久化数据源、关系型存储 | 缓存、高性能辅助存储 |
| 存储介质 | 磁盘(最终落盘) | 内存为主,磁盘做持久化 |
| 数据模型 | 关系型二维表,支持复杂 SQL | 键值对 + 多种数据结构 |
| 读写速度 | 单机几万 QPS,依赖索引和配置 | 单机可达十万到百万 QPS |
| 数据持久性 | ACID 事务保证,RDBMS 级别的可靠 | 默认可能丢数据,需开启 RDB/AOF 保证 |
| 数据一致性 | 强一致(取决于隔离级别) | 默认弱一致,需要业务端弥补 |
| 扩展方式 | 主从复制、分库分表 | 主从、哨兵、集群分片 |
| 查询能力 | 支持复杂聚合、联表、子查询 | 仅按 key 操作,无复杂 SQL |
| 应用场景 | 业务数据主存储 | 缓存、分布式锁、计数器、排行榜、队列 |
这张表的核心信息是:MySQL 是一个完整的数据系统,Redis 是高性能的数据工具。在做技术选型时,先问自己的问题不是"谁更好",而是"我要的数据是否需要持久化?是否必须强一致?是否需要进行复杂查询?"
如果你的答案是需要,那么主数据源选 MySQL;如果答案是"允许数据有一定延迟和丢失,但访问频率极高、必须极快响应",那就把 Redis 放在前面。
4.2 实际项目中我见过的几种典型选型错误
选型失误很容易发生在两个极端。
一种是把所有数据都塞进 Redis。某个项目把订单状态直接写进 Redis,不做任何持久化配置,也没有主从备份,结果一次重启丢了大量订单数据,只能靠用户自己发现后再人工补录。这就是把工具当成了保险柜,Redis 不是不能存业务数据,而是你要清楚它默认是"内存优先,持久化靠配置"的。必须有完整的 RDB + AOF 方案,并且明确知道数据丢失的容忍窗口。
另一种是项目里只用 MySQL,即使是查询一个固定不变的配置项也每次都走数据库。这种问题在数据量小的时候不明显,一旦业务增长,数据库压力上来了才开始准备缓存,此时业务已经出现了明显的事故。
4.3 从数据生命周期看两者最合理的关系
一个比较成熟的架构视角是:数据存在 MySQL 里,热点在 Redis 里。
也就是说,MySQL 是事实源,它保存数据的最终状态;Redis 是加速层,它保存那些被高频访问的数据副本。这个关系能成立的前提是:你可以接受 Redis 里的数据和 MySQL 之间存在一个极短时间窗口的不一致(在 Cache Aside 模式下这个窗口通常是几毫秒)。如果你完全无法接受任何不一致,那你需要的是事务、锁或者分布式事务,而不是缓存。
对于大多数互联网业务来说,这个短暂的不一致窗口是完全可接受的。比如用户修改头像后,极端情况下过了几百毫秒才在其他设备上生效。这种体验对用户来说几乎没有感觉,但系统的负载能力和响应速度却会因此产生质的变化。
5. 项目落地时最常踩的四个缓存坑,每一个都让人凌晨爬起来加班
5.1 缓存一致性:数据库和缓存到底听谁的
最经典的缓存读写策略是 Cache Aside,中文一般叫旁路缓存。它的核心逻辑是:
- 读操作:先读缓存,命中就直接返回;不命中就查 MySQL,查到后写入 Redis,再返回。
- 写操作:先更新 MySQL,然后删除 Redis 里的缓存。
这里有一个很多人一开始不理解的设计:为什么更新数据的时候不直接更新 Redis,而是删除缓存?
回答这个问题之前,先看一个直接更新缓存的例子。两个并发请求同时操作同一条数据,A 请求把数据库值改为 100,B 请求把数据库值改为 200,两个请求都更新 Redis。如果执行的先后顺序是 A 更新数据库 → B 更新数据库 → B 更新缓存 → A 更新缓存,那么缓存里的最终值是 100,而数据库里是 200,数据不一致了。并发场景下这种错乱很难完全避免,而删除缓存相比更新缓存,天然地避免了"后写覆盖先写"的问题,因为读取侧会把最新数据库值重新装载进缓存。
在 Cache Aside 模式下,唯一可能出问题的场景是:A 更新数据库成功,但删除缓存失败。这时候缓存里还是旧值,会导致一段不确定时间内的数据不一致。于是有了延迟双删策略:先删除缓存,再更新数据库,休眠一小段时间再删除一次缓存。第二次删除是为了处理并发读请求在"缓存已删、数据库未更新"的窗口里把旧数据重新装入缓存的情况。
5.2 缓存穿透:查了一个根本不存在的数据
缓存穿透是指请求的数据在缓存和数据库里都不存在。比如通过一个不存在的用户 ID 查询用户详情,每次请求都会直接穿透 Redis 打到 MySQL 上,如果被恶意利用,瞬间就能把数据库压垮。
应对手段有三个层次:
第一层是缓存空值。查不到数据时,在 Redis 里放一个空对象或者特定标记,设置一个较短的过期时间,比如 60 秒。这样后续相同请求会命中空缓存,不会穿透到数据库。
第二层是使用布隆过滤器。启动时把所有可能存在的 key 加载到布隆过滤器里,请求进来先判断 key 是否存在,不存在直接返回。布隆过滤器有一定的误判率,但它的优点是占用空间极小,一个亿级 key 的过滤器也才占用几十 MB 内存。
第三层是参数校验和限流。接口层做基础的数据合法性校验,比如 ID 必须为正整数,格式不合法直接拒绝。
5.3 缓存击穿:热点 key 在过期瞬间被大流量打穿
缓存击穿和穿透听起来很像,但本质完全不同。击穿是指一个极其热门的 key(比如某个明星的微博详情、双十一的活动页配置)在缓存过期的瞬间,大量请求同时涌入,全部打到了数据库上。数据库一瞬间扛不住这么大的并发。
常见的解决方案有几种:
互斥锁方案:在缓存过期后,不是所有请求都直接去查数据库,而是先尝试获取一个分布式锁,只有拿到锁的线程去查数据库并重建缓存,其他线程等待一段时间后重新读取缓存。这个方案能有效防止数据库被打爆,但会带来一定的请求延迟。
逻辑过期方案:缓存里不设置物理过期时间,而是存一个逻辑过期时间字段。每次读取时判断逻辑过期时间,如果过期了,就返回旧数据,同时开启一个异步线程去更新缓存。这个方案用户体验最好,但实现复杂度更高,需要单独维护逻辑过期字段。
5.4 缓存雪崩:大面积 key 同时过期引发的连锁反应
缓存雪崩是指大量缓存 key 在同一时间段内集中过期,导致这些请求全部落到数据库。通常发生在批量设置缓存时使用了相同的过期时间,比如所有商品缓存统一设置 3600 秒,结果整点一到,几千个 key 同时失效。
处理办法也很直接:
- 过期时间加随机值,比如基础过期时间上再加 0 到 300 秒的随机时间,打散过期点。
- 采用多级缓存架构,本地缓存(比如 Caffeine)作为第一层挡在前面,Redis 作为第二层,MySQL 在最底层。即使 Redis 的这一批 key 集中过期,本地缓存还能兜一部分。
- 如果数据库确实扛不住,可以再配合限流和熔断,保证数据库不会被瞬间击垮。
这三个坑——穿透、击穿、雪崩,往往是面试题,但在真实项目里我真的都在凌晨处理过。处理它们的核心思路其实一致:在缓存失效、数据不存在这种异常路径上,给数据库加一层防护。很多人只关注正常路径上的缓存命中率,完全没有考虑异常路径,这才是事故发生的根源。
6. 一条完整的核心链路复盘:登录、Redis、MySQL 如何协同工作
讲了这么多,用一个小型电商项目的"用户登录 + 商品详情"链路,把这些概念全部串起来。
用户请求登录接口时,后端做的事情可能是:
- 校验用户名、密码、验证码。
- 验证通过后,把用户 ID、角色信息写入 Redis,设置过期时间,生成一个 sessionId 或者 token。
- 后续请求带着 token 过来,服务端直接从 Redis 里查 session,不再去 MySQL 查用户表。
- 如果 Redis 里 session 过期或者不存在,再重新走登录流程。
这里 Redis 的价值是:用户会话状态是高频访问而且可以容忍丢失后重新登录的数据,放在 Redis 里是最合适的。如果放在 MySQL 里,每次请求都要做一次磁盘查询,还要面对分布式环境下的数据同步问题。
再看商品详情页。请求进来,服务端先去 Redis 查商品详情缓存,如果存在直接返回,不存在就查 MySQL,查出来再写回 Redis,设置过期时间。如果商品详情被修改了,管理员在后台更新 MySQL 里的商品信息后,同时删掉 Redis 里的缓存,让下一次读取重新拉取最新数据。
这个看似简单的流程里,每一个步骤其实都在回答一个设计问题:哪份数据放哪个存储,放多久,以及数据更新时如何保持一致?
我经常建议团队用一张表格来梳理系统中的每一个数据对象:
| 数据对象 | MySQL 是否为主存储 | Redis 是否作为缓存 | Redis 过期策略 | 缓存更新方式 |
|---|---|---|---|---|
| 用户账号 | 是 | 偶尔缓存用户基础信息 | 30 分钟 | 更新后删除缓存 |
| 用户 Session | 否 | 是 | 30 分钟滑动过期 | 每次访问刷新 |
| 商品详情 | 是 | 是 | 基础 1 小时 + 随机 | 后台更新后删缓存 |
| 商品库存 | 是 | 是(预扣) | 不主动过期 | 秒杀场景需特殊设计 |
| 排行榜 | 否 | 是 | 5 分钟 | 定时任务刷新 |
把每个数据对象都明确归属和策略之后,MySQL 和 Redis 的职责就非常清晰了:MySQL 负责"最终正确",Redis 负责"高效读取",两者通过一套约定的更新策略协同工作。
7. 再分享几个实际项目里非常有用的 Redis 用法
除了缓存,Redis 在一些具体场景里的表现远超 MySQL,这里补充几个我亲手实践过的用法。
分布式锁。在集群环境下,多个服务实例同时执行某个任务(比如定时任务幂等、秒杀扣库存)时,需要确保只有一个实例在执行。可以用 Redis 的SET key value NX EX seconds命令实现简单的分布式锁。只有能成功设置 key 的实例才算拿到锁,执行完业务后删除 key 释放锁。在生产环境中要注意给锁设置合理的过期时间,防止持有锁的实例挂了导致死锁;还要注意 value 的唯一性,删除锁时只能删自己加的锁,避免误删别人的锁。
滑动窗口限流。用 ZSet 记录每个用户的请求时间戳,每次请求到来时把时间戳加入 ZSet,然后用ZREMRANGEBYSCORE移除窗口外的记录,用ZCARD统计窗口内的请求数量,超过阈值就拒绝请求。这个方案虽然不如令牌桶实现优雅,但用来拦截恶意请求和突发流量完全够用。
消息队列的轻量替代。用 List 的LPUSH和BRPOP可以实现一个非常简单的可靠消息队列,BRPOP是阻塞读取,队列为空时会暂停等待,避免了轮询带来的 CPU 消耗。对于中小型项目,如果不想引入 MQ 的运维负担,这个方案非常实用。
原子计数器。String 类型的INCR命令是原子操作,非常适合记录网站的访问量、点赞数、限流中的计数器。因为它是原子性的,天然避免了并发场景下的数据竞争问题,不需要像操作 MySQL 那样加锁。
这些用法在真正的业务中都很实用,但它们不是银弹,每个方案都有自己的边界和陷阱。比如 std 分布式锁没有锁续期机制,长时间业务执行可能导致锁提前过期,此时需要用 Redisson 的看门狗机制或者手动续期方案。
8. 关于入门的路线和学习的建议
如果这是一篇收藏指南,我建议你重点收藏这几节:第 2 节的索引与事务知识是 MySQL 的核心,第 3 节的数据结构与单线程模型是 Redis 的核心,第 5 节的四个缓存坑是实战经验。面试和实际工作中最常被考察的内容,也基本都集中在这几块。
在实际动手层面,我建议搭一套本地的 MySQL 8.0 + 最新稳定版 Redis,用 Docker 一条命令就能拉起来。然后在本地初始化一些测试数据,手动执行几条慢 SQL,用EXPLAIN看执行计划;再装一个 Redis Desktop Manager 类的可视化工具,把第 5 节提到的穿透、击穿、雪崩场景都手动模拟一遍,亲眼看看缓存失效瞬间发生了什么。这比看十篇文章都印象深刻。
从 MySQL 到 Redis,本质上是从"保证数据不丢、正确"到"让数据访问更快"的思维转变。搞懂这两个系统各自的边界和设定,你会发现项目架构里很多看似复杂的问题,其实都是一道"数据的归属与流转"的选择题。