Redis缓存深度解析:20%数据应对80%请求

本节总结redis,将深度探讨redis作为缓存的场景

redis基本用途

从前面的学习可以知道,redis最主要的用途,基本分为三个方面

1:存储数据(内存数据库)

  • 把 Redis 当作数据库来用,数据主要存在内存,配合持久化(RDB/AOF)保证数据不丢。
  • 适合对读写延迟要求极高的场景,如用户会话、商品库存、排行榜等。

2:缓存(redis最常用的场景)

  • 作为 MySQL 等慢速磁盘数据库的前置缓存,拦截大部分读请求,降低数据库压力。
  • 这是 Redis 在互联网公司最普遍的用法。

3:消息队列

  • 利用 ListPub/SubStream 等数据结构实现简单的消息队列功能。
  • 注意:Redis 作为消息队列是 "轻量级" 的,在可靠性、持久化和复杂路由上不如专业 MQ(如 Kafka、RabbitMQ)。

redis作为缓存

一般来说 的设备可以作为的设备的缓存

最常见的就是使用内存作为硬盘的缓存,这也就是redis的定位

这里硬盘也可以作为网络的缓存

一般来说浏览器从服务器上拉取资源的时候,有时候html,文字,视频等体积大但是又不轻易改变的,浏览器会缓存到本地磁盘,然后后面再拉取的时候先看本地磁盘有没有缓存

用户可以通过ctrl+F5进行刷新缓存,避免旧版本的前端代码导致bug

这里什么时候刷新缓存浏览器自己也有策略的,可以自行查略资料

缓存的设计核心思想:二八定律

虽然空间小,但是速度快,百分之20的数据就可以应对百分之80的请求

  • 互联网业务中,大部分请求都集中在一小部分 "热点数据" 上(如热门商品、热门新闻)。
  • 我们只需要把这 20% 的热点数据放到高速缓存(Redis 内存)中,就能拦截 80% 的请求,极大提升系统性能。
  • 这也是为什么缓存空间不需要太大,却能发挥巨大作用的原因。
    通常来说,我们会使用redis作为mysql的缓存

mysql作为关系型数据库

硬件层面

  • 数据存储在硬盘上,硬盘 I/O(尤其是随机访问)速度远慢于内存。

  • 每次查询都要进行磁盘读写,这是性能的主要瓶颈。

软件层面

  • 如果查询不走索引,需要全表扫描,大大增加 I/O 次数。

  • 关系型数据库需要对 SQL 进行解析、校验、优化等一系列工作,增加了 CPU 开销。

  • 复杂查询(如联合查询)需要进行笛卡尔积、排序等操作,效率更低。

每一次请求都需要消耗一些硬件资源(cpu,内存,硬盘,网络)等,如果达到上限很可能就会出现故障

MySQL 等关系型数据库的并发承载能力有限,当请求量激增时,很容易成为系统瓶颈,甚至宕机。

提高并发的两个思路

  • 开源(Scale Out)

    • 引入更多机器,构成数据库集群(如 MySQL 主从、分库分表)。
    • 通过水平扩展,把压力分散到多台机器上。
  • 节流(Cache)

    • 引入缓存(如 Redis),把频繁读取的热点数据保存到缓存中。
    • 后续查询时,先查缓存,缓存命中就直接返回,不再访问 MySQL。
    • 这是互联网公司最常用、最有效的 "节流" 方案。

redis应该存储哪些数据???

定期缓存

  • 日志记录:将用户的访问数据(如搜索引擎的查询词)以日志形式记录下来。
  • 离线统计:使用大数据工具(如 Hadoop、Hive)对日志进行分析,统计出访问频率最高的前 20% 数据,即 "热点词"。(这里看自己的业务场景,一天还是一周统计一次)这里的工作主要是大数据工程师
  • 同步服务器:根据统计出的热点词,提前从数据库中取出对应的搜索结果或广告数据,写入 Redis。
  • 定时更新:通过python/shell脚本写的定时任务(如每天、每月)重复上述流程,更新缓存中的热点数据。完成统计热词的过程,找到对应的搜索结果,同步到缓存服务器上,写入到redis
  • 优点
    • 过程可控,缓存中的数据是固定的,方便排查问题。
    • 能有效应对已知的、稳定的热点,大幅降低数据库压力。
  • 缺点
    • 实时性不足:无法应对突发的热点(如春晚、突发事件),新的热点可能因未被统计而直接打到数据库。

实时生成

不提前加载数据,而是在用户请求时按需加载

  1. 先查 Redis。
  2. 如果命中,直接返回。
  3. 如果未命中,从数据库查询,并将结果写入 Redis,供下次使用。

有时候不是达到机器的内存上限,而是你自己配置文件当中运行redis使用的内存上限

这种 "写回" 模式会导致 Redis 内存占用越来越高,最终达到 maxmemory 上限。为了解决这个问题,Redis 引入了内存淘汰策略(经典面试题)

LFU 确实能更精准地识别长期热点,但它需要额外的空间存储每个 key 的访问计数,且对 "突发热点" 不敏感。例如,一个突发的新热点可能因为历史访问次数少而被错误地淘汰。因此,LRU 是更通用、更稳妥的选择,而 LFU 只在特定场景下更优。

具体采用哪个策略结合具体场景分析

策略 全称 核心逻辑 适用场景
volatile-lru Least Recently Used 设置了过期时间的 key 中,淘汰最近最少使用的。 大部分缓存场景,优先淘汰冷数据。
allkeys-lru Least Recently Used 所有 key 中,淘汰最近最少使用的。 最常用,符合 "二八定律",保留热点数据。
volatile-lfu Least Frequently Used 设置了过期时间的 key 中,淘汰最近访问次数最少的。 需要精准识别长期热点的场景。
allkeys-lfu Least Frequently Used 所有 key 中,淘汰最近访问次数最少的。 对访问频率敏感的场景。
volatile-random Random 设置了过期时间的 key 中,随机淘汰。 不推荐,无策略可言。
allkeys-random Random 所有 key 中,随机淘汰。 不推荐。
volatile-ttl Time To Live 设置了过期时间的 key 中,淘汰即将过期的。 对数据时效性敏感的场景。
noeviction 默认 不淘汰,新写入操作直接报错。 不适合缓存场景,会导致服务不可用。

缓存预热(Cache Preheating)

  • 定期生成:离线统计热点数据,主动写入 Redis(不涉及 "预热")。
  • 实时生成:用户请求时按需加载,先查 Redis,未命中再查 MySQL,查到后写入 Redis。
  • 冷启动问题:Redis 刚接入时是空的,所有请求都会打到 MySQL,压力极大。

缓存预热的作用

  • 把 "定期生成" 和 "实时生成" 结合,提前把热点数据导入 Redis,解决冷启动问题。
  • 随着时间推移,新的热点数据会通过淘汰策略覆盖旧数据。

主动填充缓存 的手段,它解决的是 "实时生成" 模式下的冷启动空窗期问题

缓存穿透(Cache Penetration)

  • 定义:查询的 key 在 Redis 和 MySQL 中都不存在,且会被反复查询,导致数据库压力过大。(意思就是导致查询还是一直打给mysql,导致承受巨大压力,redis相当于没用)
  • 典型原因
    • 业务设计不合理(如缺少参数校验,非法 key 被反复查询)。
    • 开发 / 运维误操作(如误删数据库数据)。
    • 黑客恶意攻击(用大量不存在的 key 扫库,是最常见的原因)。
  • 解决方案
    • 缓存空值:把不存在的 key 写入 Redis,value 设为非法值(如 "")。
    • 布隆过滤器:在查询前判断 key 是否存在,过滤不存在的 key。(我的c++部分有讲解布隆过滤器)
    • 优点:空间效率和查询效率极高。
    • 缺点:存在一定的误判率(判断存在的 key 可能真不存在,但判断不存在的 key 一定不存在)。
    • 生产优化
      1. 提前把数据库中所有合法 key 初始化到布隆过滤器。
      2. 新增数据时同步更新布隆过滤器。
      3. 定期重建布隆过滤器,避免误判率过高。

缓存雪崩(Cache avalanche)

  • 定义:短时间内 Redis 上大规模 key 失效,缓存命中率骤降,所有请求打到 MySQL,导致数据库宕机。
  • 两种典型场景
    • Redis 宕机 / 集群大量节点宕机(最严重的情况)。
    • 大量 key 设置了相同的过期时间,同时失效。
  • 解决方案
    • 加强监控报警,保证 Redis 集群可用性。
    • 设置过期时间时,添加一个随机偏移量 (如 EX 3600 + random(600)),让 key 在 3600~4200 秒之间随机过期,避免同时失效。

应对 Redis 宕机的高可用方案

缓存雪崩最严重的情况是 Redis 宕机,生产中通常采用以下方案:

  1. Redis 集群 + 哨兵模式:实现自动故障转移,避免单点故障。
  2. 多级缓存:L1 缓存(本地内存,如 Caffeine)+ L2 缓存(Redis),Redis 宕机时,L1 缓存仍能扛住部分流量。
  3. 服务降级:Redis 宕机时,直接返回默认值或静态页面,避免请求打到数据库。

缓存击穿(Cache breakdown)

  • 定义 :缓存雪崩的特殊情况,针对热点 key突然过期,导致大量请求直接打到数据库,引发数据库宕机。
  • 特点:热点 key 访问频率高,影响更大,往往需要对服务器结构做较大调整。
  • 解决方案
    • 基于统计发现热点 key,设置永不过期。
    • 分布式锁:限制数据库的访问频率。
    • 服务降级:在特定情况下关闭不重要的功能,只保留核心功能。

"热点 key 永不过期" 不是真的不设置过期时间,而是采用逻辑过期

  1. 把热点 key 的过期时间存在 value 中(如 {"data": "...", "expire_at": 1740245678})。
  2. 业务查询时,先判断 expire_at 是否过期。
  3. 如果过期,用分布式锁 (如 Redis SETNX)保证只有一个请求去数据库更新缓存,其他请求直接返回旧数据。
  4. 这样既保证了数据的时效性,又避免了大量请求同时打到数据库。

分布式锁

在一个分布式系统中,也会涉及到多个节点访问同一个公共资源的情况,此时旧需要通过锁来做互斥控制,避免出现类似于"线程安全 "的问题,而Java的synchronized或者C++的std::mutex,这样的锁都只能在当前线程中生效 ,在分布式的这样多个进程多个主机的场景中就无能为力了,此时就需要使用分布式锁,来保证在任意顺序下执行逻辑都是ok的

分布式系统中,"查询剩余票" 和 "扣减剩余票" 这两个操作不是原子的,并发执行时会出现竞态条件(Race Condition)。

引入分布式锁是因为多个进程当中之间的执行顺序也有随机性,并且操作都不是原子的

如果不加分布式锁,此时可能就会出现超卖

  • 分布式锁是一个 / 一组单独的服务程序,提供 "加锁" 服务;
  • Redis 是实现分布式锁的典型方案(但不是唯一的,也可以用 MySQL、ZooKeeper 等);
  • 加锁后,保证只有一个服务器能执行 "查询 → 更新" 的原子操作,其他服务器会阻塞或放弃,从而避免超卖。

SETNX(Set if Not eXists)是 Redis 实现分布式锁的基础命令:

  • 功能:如果 key 不存在,就设置 value;如果 key 已存在,就返回失败;
  • 语义:"不存在就设置,存在就失败返回0",正好对应 "加锁成功 / 失败" 的逻辑。
  • 加锁 :用 SETNX 实现 "不存在则加锁",能拿到分布式锁的排他性;
  • 解锁 :用 DEL 实现,但存在程序崩溃导致解锁失败 的问题:
    • 即使把 DEL 放在 finally(Java)、RAII(C++)中,能解决进程内异常(如代码报错、逻辑崩溃);
    • 但无法解决进程外异常即分布式锁 (如服务器断电、内核崩溃、网络断连),此时 DEL 完全无法执行,Redis 中的锁 Key 会永久存在,导致其他服务无法加锁,形成死锁

为了打破死锁,必须给锁 Key 加过期时间(TTL),即使服务崩溃,锁也会在超时后自动删除,释放资源。

禁止 "SETNX + EXPIRE" 分两次执行,必须SET key value NX EX ttl

Redis 中非事务 / 非脚本的多条命令不具备原子性,可能出现 "加锁成功,但设置过期时间失败" 的极端情况:

  1. 服务 A 执行 SETNX lock:ticket 1 → 加锁成功;
  2. 服务 A 此时发生 GC 停顿 / 网络抖动 / 进程被杀死,EXPIRE lock:ticket 5 未执行
  3. 服务 A 后续崩溃,锁 Key 无过期时间,永久存在 → 死锁。

**问题一:**到这里很自然的会想到,如果执行业务逻辑的时间超过了超时时间呢?key-value被销毁了,那此时别的就会进来加锁,就可能超卖?那应该设多大的超时时间呢?

解决方案:看门狗(Watch Dog)机制

  • 在 C++ 中启动一个后台线程 ,每隔 ttl/3 秒(如 5 秒锁,每隔 1.5 秒),检查当前服务是否还持有锁;
  • 如果持有,就用 SET key value NX XX EX ttl 命令原子续期XX:仅当 Key 存在时设置);
  • 若服务崩溃,后台线程停止,锁会在超时后自动释放,无死锁风险。

**问题二:**有可能别的服务器删除key-value呢???那锁直接没了,就可以进来加锁了,就可能超卖

解决方案:

"唯一标识 + Lua 脚本原子解锁"

  1. 加锁时 :给 value 设置一个唯一标识 (如 C++ 生成的 UUID、进程 ID + 线程 ID),避免锁混淆;示例:SET lock:ticket "serverA_123" NX EX 5
  2. 解锁时 :不能直接 DEL,而是用 Lua 脚本 原子判断 "锁的持有者是否是自己",只有匹配才删除:
Lua 复制代码
-- 解锁 Lua 脚本(C++ 中需嵌入)
-- KEYS[1] = 锁Key,ARGV[1] = 唯一标识
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

Lua 脚本在 Redis 中是原子执行的,避免 "判断持有者" 和 "删除" 的中间态。

为什么需要使用lua脚本,假设你的服务器是多线程,那就可以同时get,然后判断,然后get和del不是原子操作,就会导致a线程执行get,在执行del之前,b线程去get成功,但在bdel之前,别的服务器由于a线程释放锁了所以可以加锁,那么此时b去del就会导致锁被释放

归根结底:是因为get和del不是原子操作,所以必须使用lua脚本把命令组合起来,Lua 脚本中的所有命令,要么全部执行,要么全部不执行(Redis 执行脚本时不会被其他命令打断);

问题三:Redis 集群的 "主从延迟" 导致的锁丢失

  • 服务 A 在主节点加锁成功;
  • 主节点还没把 "锁 Key" 同步到从节点,主节点宕机;
  • 哨兵把从节点升级为主节点,服务 B 在新主节点加锁成功 → 多服务同时持有锁。那就会出现超卖

解决方案(两种选型)

  • Redlock 算法 :在多个独立的 Redis 主节点加锁,超过半数节点加锁成功才认为锁有效,同理解锁的时候也需要超过半数(Redis 官方方案,性能较低)
  • 生产主流选型 :接受 "极小概率的锁丢失",结合业务兜底(如数据库唯一索引、库存扣减校验),因为 Redlock 性能开销大,且实际业务中 "主从切换 + 锁丢失" 的概率极低。

Redlock (仅金融级场景)

部署 5 个独立的 Redis 集群(而非独立节点),每个集群作为 Redlock 的一个 "节点":

  1. 加锁时,向 5 个集群的主节点加锁;
  2. 超过半数加锁成功,才认为锁有效;
  3. 解锁时同理。
  • 效果:根治锁丢失问题;
  • 代价:部署维护成本极高,性能极低(加锁需要访问 5 个集群)。

你需要部署多个独立的主节点,也就是有集群,每个集群都独立,所以这种算法性能很低

一般来说优先使用业务兜底,你需要自己写类似if语句去判断,而不是单单去防止锁丢失,使用redlock性能较低

更多的是阅读源码,看看redis是如何处理的

相关推荐
阿寻寻1 小时前
【数据库】sql的update语句怎么使用?
数据库·sql
数据知道1 小时前
MongoDB 数组更新操作符:`$push`、`$pull`、`$addToSet` 管理列表数据
数据库·mongodb
加号32 小时前
windows系统下mysql主从数据库部署
数据库·windows·mysql
谁刺我心2 小时前
MySQL数据库从win导出成_db.sql复制到linux
数据库·mysql
知识分享小能手2 小时前
PostgreSQL 入门学习教程,从入门到精通,PostgreSQL 16 (Windows) 安装与核心语法实战指南(2)
数据库·学习·postgresql
清水白石0082 小时前
模板方法模式全解析:用抽象基类定义算法骨架,让子类优雅填充细节
数据库·python·算法·模板方法模式
@insist1232 小时前
软考-数据库系统工程师-计算机存储层次结构与性能优化核心知识点
大数据·jvm·数据库
脱发的老袁2 小时前
【数据库】Oracle手动清理归档日志
数据库·oracle
ZPC82102 小时前
docker 入门2
java·linux·数据库