缓存基础

缓存的基本思想其实很简单,就是我们非常熟悉的 空间换时间 这一经典性能优化策略的运用。所谓空间换时间,也就是用更多的存储空间来存储一些可能重复使用或计算的数据,从而减少数据的重新获取或计算的时间。

说到空间换时间,除了缓存之外,这里再列举几个常见的:

  • 索引:索引是一种将数据库表中的某些列或字段按照一定的排序规则组织成一个单独的数据结构,虽然需要额外占用空间,但可以大大提高检索效率,降低数据排序成本。
  • 数据库表字段冗余:将经常联合查询的数据冗余存储在同一张表中,以减少对多张表的关联查询,进而提升查询性能,减轻数据库压力。
  • CDN(内容分发网络):将静态资源分发到多个边缘节点以实现就近访问,进而加快静态资源的访问速度,减轻源站服务器以及带宽的负担。

缓存的分类有哪些?他们在你开发中你都是如何使用的?

日常开发中,缓存基本分为三种:

类型 位置 典型代表 核心特点
本地缓存 应用进程内 Caffeine、Guava Cache、Ehcache 快,但无法共享
分布式缓存 独立服务 Redis、Memcached 共享,但有网络开销
多级缓存 本地 + 分布式 J2Cache、JetCache 兼顾速度与共享,但复杂度高
  • 本地缓存

    本地缓存位于应用内部,其最大的优点是应用存在于同一个进程内部,请求本地缓存的速度非常快,不存在额外的网络开销。

    常见的单体架构图如下,我们使用 Nginx 来做负载均衡,部署两个相同的应用到服务器,两个服务使用同一个数据库,并且使用的是本地缓存

    本地缓存示意图

    本地缓存的方案有哪些

    • Guava Cache:API 友好,支持过期、容量限制,内部思想类似 ConcurrentHashMap。
    • Caffeine:Guava 的升级替代品,Caffeine 在各个方面比如性能都要更加优秀,一般建议使用其来替代 Guava 。并且, Guava 和 Caffeine 的使用方式很像!
    • Ehcache:更重,但能嵌入 Hibernate/MyBatis 做多级缓存,还能持久化到磁盘。
    • Spring Cache:注解优雅,代码会看着很干净和优雅,但容易有问题(缓存穿透、内存溢出),适合简单场景。
  • 分布式缓存

    我们可以把分布式缓存看作是一种内存数据库的服务,它的最终作用就是提供缓存数据的服务。

    分布式缓存脱离于应用独立存在,多个应用可直接的共同使用同一个分布式缓存服务,使用的同一份缓存。缓存服务可以部署在一台单独的服务器上,并且,单独的分布式缓存服务的性能、容量和提供的功能都要更加强大。

    如下图所示,就是一个简单的使用分布式缓存的架构图。我们使用 Nginx 来做负载均衡,部署两个相同的应用到服务器,两个服务使用同一个数据库和缓存。

    分布式缓存

    分布式缓存方案有哪些:

    分布式缓存的话,比较老牌同时也是使用的比较多的还是 Memcached 和 Redis。不过,现在都是直接用 Redis。

  • 多级缓存

    方案有很多,但是本地缓存 + 分布式缓存 的多级缓存方案是最常用的多级缓存实现方式。

    本地缓存和分布式缓存虽然都属于缓存,但本地缓存的访问速度要远大于分布式缓存,这是因为访问本地缓存不存在额外的网络开销。

    多级缓存方案中,第一级缓存(L1)使用本地内存(比如 Caffeine)),第二级缓存(L2)使用分布式缓存(比如 Redis)。

    多级缓存

    读取缓存数据的时候,我们先从 L1 中读取,命中后直接返回;L1 未命中时再去 L2 读取。如果 L2 命中,应先将数据回填到当前实例的 L1,再返回结果,避免后续每次 L1 未命中都重复访问 L2。如果 L2 也没有此数据,再去数据库查询,查询成功后将数据写入到 L1 和 L2 中。这样可以减少 L2 的读次数,降低 L2 的压力。

    多级缓存开源实现推荐:

    • J2Cache:基于本地内存和 Redis 的两级 Java 缓存框架。
    • JetCache:阿里开源的缓存框架,支持多级缓存、分布式缓存自动刷新、 TTL 等功能。

Redis 基础

Redis 为什么快?为什么要使用 Redis?Redis 都能用来做什么?

Redis 内部做了非常多的性能优化,比较重要的有下面 4 点:

  1. 纯内存操作 (Memory-Based Storage) :这是最主要的原因。Redis 数据读写操作都发生在内存中,访问速度是纳秒级别,而传统数据库频繁读写磁盘的速度是毫秒级别,两者相差数个数量级。
  2. 高效的 I/O 模型 (I/O Multiplexing & Single-Threaded Event Loop) :Redis 使用单线程事件循环配合 I/O 多路复用技术,让单个线程可以同时处理多个网络连接上的 I/O 事件,避免了多线程模型中的上下文切换和锁竞争问题。虽然是单线程,但结合内存操作的高效性和 I/O 多路复用,使得 Redis 能轻松处理大量并发请求
  3. 优化的内部数据结构 (Optimized Data Structures) :Redis 提供多种数据类型,其内部实现采用高度优化的编码方式(如 ziplist, quicklist, skiplist, hashtable 等)。Redis 会根据数据大小和类型动态选择最合适的内部编码,以在性能和空间效率之间取得最佳平衡。
  4. 简洁高效的通信协议 (Simple Protocol - RESP) :Redis 使用的是自己设计的 RESP (REdis Serialization Protocol) 协议。这个协议实现简单、解析性能好,并且是二进制安全的。客户端和服务端之间通信的序列化/反序列化开销很小,有助于提升整体的交互速度。

这也就是为什么要用 Redis

  • 访问速度快

    传统数据库数据保存在磁盘,而 Redis 基于内存,内存的访问速度比磁盘快很多。引入 Redis 之后,我们可以把一些高频访问的数据放到 Redis 中,这样下次就可以直接从内存中读取,速度可以提升几十倍甚至上百倍。

  • 高并发

    一般像 MySQL 这类的数据库的 QPS 大概都在 4k 左右(4 核 8g),但是使用 Redis 缓存之后很容易达到 5w+,甚至能达到 10w+

  • 功能全面

    Redis 除了可以用作缓存之外,还可以用于分布式锁、限流、消息队列、延时队列等场景,功能强大!

    • 分布式锁:通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章:分布式锁详解。
    • 限流:一般是通过 Redis + Lua 脚本的方式来实现限流。如果不想自己写 Lua 脚本的话,也可以直接利用 Redisson 中的 RRateLimiter 来实现分布式限流,其底层实现就是基于 Lua 代码+令牌桶算法。
    • 消息队列:Redis 自带的 List 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。
    • 延时队列:Redisson 内置了延时队列(基于 Sorted Set 实现的)。
    • 分布式 Session:利用 String 或者 Hash 数据类型保存 Session 数据,所有的服务器都可以访问。
    • 复杂业务场景:通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景
      • Bitmap 适合做海量用户的布尔型统计,统计活跃用户、签到、布尔型标记等
      • ZSet 是有序集合,排行榜直接用 score 排序,Redisson 的延时队列也是用它,score 存到期时间,到点就取出来消费。
      • HyperLogLog 用 12KB 就能估算上亿级别的 UV,误差很小,适合做统计报表;但要精确计数或者要查具体是谁,就得用 Set 或 Bitmap。
      • Stream 做消息队列;Geo 做附近的人。Set 做去重和共同好友。等等

介绍下 Redis 的 LRU 回收算法?什么是 LFU 算法,为什么 Redis 要引入 LFU?Redis 有几种内存回收策略?

LRU 是操作系统里的那个,最近最少使用回收算法,是 Redis 应对 内存达到上限时 的核心内存淘汰策略,在不影响核心业务的前提下,释放闲置内存,保证 Redis 持续可用。

LRU 的核心思想是:如果一个数据最近被访问过,那么未来被访问的概率也高;反之,最近最少被访问的数据,未来被访问的概率低,内存满时优先淘汰这类数据。

理想的 LRU 算法需要维护一个「访问时间有序链表」

  • 新数据插入到链表头部;
  • 数据被访问时,移到链表头部;
  • 内存满时,删除链表尾部的「最近最少使用」数据。

但理想 LRU 存在问题,需要额外的链表存储访问顺序,内存开销大,而且链表移动损耗性能严重

Redis 采用的是近似 LRU 算法

  • Redis 为每个键值对维护一个 最后访问时间戳,存储在键的元数据中
  • 当内存达到 maxmemory 时,Redis 从所有键中随机抽取,淘汰样本池中「LRU 时间戳最旧」的键;

LFU(Least Frequently Used)最不经常使用,是一种淘汰最少访问次数的数据的算法。相比 LRU 关注“最后一次访问时间”,LFU 更关注的是“访问频率”。通过精简的计数方式记录 key 的访问频率。每个 key 都带有一个 8 位的计数器,这个计数在访问时按一定概率增加,并定期衰减,以避免计数膨胀。

Redis 引入 LFU,是为了弥补 LRU 无法识别热点数据频率的短板,防止高频数据因最近未访问而被淘汰。

最后,Redis 有 8 种内存淘汰策略,默认策略是 noeviction,即写入超出内存时直接返回错误。

image-20260316090438950

Redis 持久化有哪几种方式?介绍一下它们的流程?RDB 和 AOF 的优缺点对比?混合持久化方案是什么?

主要有两种:

  • RDB 快照
    • 定期将内存中的数据以二进制格式保存到磁盘。生成某个时间点的数据快照来持久化
      • 详细过程如下
        • RDB触发通常通过 save 或 bgsave 命令手动触发,或者根据配置文件中的 save 配置项自动触发
        • 然后 Fork 出一个子进程来专门做 RDB 持久化 (COW写时复制)
        • 子进程写数据到临时的 RDB 文件
        • 写完之后,用新 RDB 文件替换旧的 RDB 文件。
    • 性能高,备份和恢复速度快,特别适合做冷备份和容灾恢复,而且适合大规模数据备份,且对主进程影响较小
    • 但它的缺点是在两次快照之间如果发生故障,可能丢失较多数据
  • AOF 日志
    • 采用日志的方式,记录每个写操作命令。先执行 Redis 命令,再把数据写入 AOF 日志
      • 详细过程如下
        • 将数据写入 AOF 缓存 aof_buf 中,避免了每次写操作都直接操作磁盘
        • 随后根据配置策略决定何时写入磁盘:always 模式每次写入立即刷盘确保绝对安全;everysec 模式(默认)每秒批量刷盘一次,兼顾性能与可靠性;no 模式则依赖系统自动刷盘
    • 数据安全性高,即使发生故障,默认的每秒同步最多也只会丢失 1 秒内的数据,日志可读性强。而且 Redis 会定期重写 AOF 文件避免文件过大。
    • 但它的缺点是文件体积大,恢复速度慢,高负载下可能影响性能

AOF 混合持久化是在 AOF 重写的基础上做了一些改动。

  • 在重写开始时,Redis 会先使用 RDB 持久化函数将当前内存数据写入到新的 AOF 文件中,这部分数据以 RDB 格式存储。
  • 然后,在重写过程中,新的写入命令会以 AOF 格式追加到新的 AOF 文件中。这样,新的 AOF 文件就包含了 RDB 格式和 AOF 格式的数据。
  • 混合持久化结合了 RDB 和 AOF 的优点。RDB 格式的数据提供了较快的恢复速度,而 AOF 格式的数据提供了更好的数据安全性。这种方式既保证了数据的安全性,又提供了较快的恢复速度。

在实际应用中,我通常会同时开启 RDB + AOF,这样既能保证数据完整性,又能兼顾恢复效率。RDB 作为主备份,AOF 作为增量日志,两者配合使用,既保证了数据安全,又不会对性能造成太大影响。

Redis 缓存的三种问题是什么?对应的解决方案介绍一下

缓存穿透

  • 缓存穿透是指查询一个不存在的数据,由于缓存中没有,所以每次请求都会打到数据库上,导致数据库压力过大。
  • 解决缓存穿透的关键在于防止无效请求直接访问数据库。
    • 布隆过滤器,它可以在很小的内存空间内,快速判断一个元素是否存在于集合中。
    • 缓存空值,当查询到数据库不存在的数据时,我们可以在缓存中存储一个空值,并设置一个较短的过期时间。
    • 参数校验是预防缓存穿透的第一道防线,在请求到达缓存层之前就过滤掉明显无效的请求。

缓存击穿

  • 大量请求同时访问同一个热点数据,当这个热点数据的缓存过期,所有请求都会同时去查询数据库,导致数据库压力瞬间增大。
  • 解决缓存击穿的目的是让热点缓存不过期
    • 避免大量请求同时访问数据库。使用互斥锁,当缓存失效时,第一个请求获取锁并去数据库查询数据,其他请求等待或返回默认值。
    • 热点数据永不过期。设置永不过期,通过后台定时任务来更新缓存。
    • 提前更新缓存。我们可以在缓存即将过期时,提前异步更新缓存。

缓存雪崩

  • 大量缓存同时失效或缓存服务宕机,导致所有请求直接穿透到数据库,造成数据库瞬时压力激增
  • 解决方案
    • 缓存过期时间随机化,在基础TTL的基础上增加一个随机偏移量,避免集中过期
    • 多级缓存架构,比如本地缓存作为第一层缓存,分布式缓存作为第二层缓存。如果Redis宕机,本地缓存仍能支撑部分流量,避免数据库被瞬间击垮。
    • 熔断降级与限流机制,缓存层异常或数据库压力过大时,系统可以自动降级
    • 高可用缓存集群,部署高可用的Redis集群,避免单点故障。

说一下 Redis 的三种缓存缓存读写策略?

  1. Cache Aside Pattern 旁路缓存模式

    目前最常用、最经典的缓存使用策略之一,比较适合读请求比较多的场景

    • 应用程序在读取数据时,先查 Redis 缓存。

      • 如果缓存命中,直接返回;

      • 如果未命中,则去查数据库,并将结果回填到缓存中,供后续请求使用。

    • 在写入数据时,先更新数据库,再删除缓存,而非更新缓存。

    数据库和缓存数据强一致下,更新 DB 的时候同样更新 Cache,不过我们需要加一个锁

    但是一般,使用旁路缓存模式,都是基于最终一致性考虑的

  2. Read/Write Through Pattern 读写穿透

    在这种模式下,应用程序将Cache 视为唯一的、主要的存储。所有读写都必须经过缓存。

    所有的读写请求都直接打向 Cache,而 Cache 服务负责将此数据读取和写入 DB

    • 读:先从cache中读取数据,命中直接返回。从cache中读取不到,则先从DB加载写入到cache后返回响应。
    • 写:先查cache,cache中不存在,直接更新DB。cache中存在,则先更新cache,然后cache服务自己找机会去更新DB

    极少使用,Redis 定位是缓存,而不是数据库

  3. Write Behind Pattern 异步缓存写入

    以极致写性能为目标的缓存策略,也是由 Cache 服务来负责 Cache 和 DB 的读写。

    读:

    • 与 读写穿透 相同:直接从缓存读取,因为两种模式下,缓存都是主要数据源

    写:

    • 应用将数据写入 Cache,然后立即返回。
    • Cache 服务将这个写操作放入一个队列中。
    • 通过一个独立的异步线程/任务,将队列中的写操作批量地、合并地写入 DB。

    数据库不是实时更新的,数据更新和持久化存在延迟,要求数据最终一致性,它用一致性换性能。

Redis 给缓存数据设置过期时间有什么用?Redis 是如何判断数据是否过期的呢?

内存是有限且珍贵的,如果不对缓存数据设置过期时间,那内存占用就会一直增长,最终可能会导致 OOM 问题。通过设置合理的过期时间,Redis 会自动删除暂时不需要的数据,为新的缓存数据腾出空间。

而且很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在 5 分钟内有效,用户登录的 Token 可能只在 1 天内有效。如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多。

Redis 自带了给缓存数据设置过期时间的功能,除了字符串类型有自己独有设置过期时间的命令 setex 外,其他方法都需要依靠 expire 命令来设置过期时间 。另外,persist 命令可以移除一个键的过期时间。

而且 Redis 判断数据的过期情况的底层是通过一个叫做过期字典的 Hash 表来实现的,它保存数据过期的时间。

过期字典的键指向 Redis 数据库中的某个 key,过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。

在查询一个 key 的时候,Redis 首先检查该 key 是否存在于过期字典中,如果不在就直接返回,在的话需要判断一下这个 key 是否过期,过期直接删除 key 然后返回 null。

Redis 过期 key 删除策略了解么?

如果假设你设置了一批 key 只能存活 1 分钟,那么 1 分钟后,Redis 是怎么对这批 key 进行删除的呢?

常用的过期数据的删除策略就下面这几种:

  1. 惰性删除:只会在取出/查询 key 的时候才对数据进行过期检查。这种方式对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
  2. 定期删除:周期性地随机从设置了过期时间的 key 中抽查一批,然后逐个检查这些 key 是否过期,过期就删除 key。相比于惰性删除,定期删除对内存更友好,对 CPU 不太友好。
  3. 延迟队列:把设置过期时间的 key 放到一个延迟队列里,到期之后就删除 key。这种方式可以保证每个过期 key 都能被删除,但维护延迟队列太麻烦,队列本身也要占用资源。
  4. 定时删除:每个设置了过期时间的 key 都会在设置的时间到达时立即被删除。这种方法可以确保内存中不会有过期的键,但是它对 CPU 的压力最大,因为它需要为每个键都设置一个定时器。

Redis 采用的是那种删除策略呢?

Redis 采用的是 定期删除+惰性删除 结合的策略,这也是大部分缓存框架的选择。定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,结合起来使用既能兼顾 CPU 友好,又能兼顾内存友好。

Redis 的定期删除过程也是随机的,周期性地随机从设置了过期时间的 key 中抽查一批,所以并不保证所有过期键都会被立即删除。并且,Redis 底层会通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 时间的影响。

另外,定期删除还会受到执行时间和过期 key 的比例的影响:

  • 执行时间已经超过了阈值,那么就中断这一次定期删除循环,以避免使用过多的 CPU 时间。
  • 如果这一批过期的 key 比例超过一个比例,就会重复执行此删除流程,以更积极地清理过期 key。相应地,如果过期的 key 比例低于这个比例,就会中断这一次定期删除循环,避免做过多的工作而获得很少的内存回收。

Redis 7.2 版本的执行时间阈值是 25ms,过期 key 比例设定值是 10%。

expire.c 中定义了每次随机抽查的数量,Redis 7.2 版本为 20,也就是说每次会随机选择 20 个设置了过期时间的 key 判断是否过期。

在 Redis 中,定期删除的频率是由 hz 参数控制的。hz 默认为 10,代表每秒执行 10 次,也就是每秒钟进行 10 次尝试来查找并删除过期的 key。

为什么定期删除不是把所有过期 key 都删除呢?

这样会对性能造成太大的影响。如果我们 key 数量非常庞大的话,挨个遍历检查是非常耗时的,会严重影响性能。Redis 设计这种策略的目的是为了平衡内存和性能。

为什么 key 过期之后不立马把它删掉呢?这样不是会浪费很多内存空间吗?

因为不太好办到,或者说这种删除方式的成本太高了。假如我们使用延迟队列作为删除策略,这样存在下面这些问题:

  1. 队列本身的开销可能很大:key 多的情况下,一个延迟队列可能无法容纳。
  2. 维护延迟队列太麻烦:修改 key 的过期时间就需要调整其在延迟队列中的位置,并且还需要引入并发控制

Redis 单线程模型了解吗?描述一下 Redis 的线程模型。为什么单线程还这么快?

对于读写命令来说,Redis 一直是单线程模型。不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作,Redis 6.0 版本之后引入了多线程来处理网络请求

Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型,这套事件处理模型对应的是 Redis 中的文件事件处理器。由于文件事件处理器是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。

文件事件处理器(file event handler)主要是包含 4 个部分:

  • 多个 socket(客户端连接)
  • IO 多路复用程序(支持多个客户端连接的关键)
  • 文件事件分派器(将 socket 关联到相应的事件处理器)
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

Redis 通过 IO 多路复用程序 来监听来自客户端的大量连接,而且 Redis 不是自己在监听每个连接,而是把监听工作委托给了操作系统内核。

I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗,这很像 NIO 中的 Selector 组件

Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈,但是执行命令仍然是单线程顺序执行

我们虽然经常说 Redis 是单线程模型,但实际还有一些后台线程用于执行一些比较耗时的操作:

  • 通过 bio_close_file 后台线程来释放 AOF / RDB 等过程中产生的临时文件资源。
  • 通过 bio_aof_fsync 后台线程调用 fsync 函数将系统内核缓冲区还未同步到到磁盘的数据强制刷到磁盘(AOF 文件)。

所以说,Redis 的线程模型是这样的

  • Redis 的核心网络 IO 和命令执行是单线程的,但 Redis 并不是完全单线程。 它内部还有多个后台线程和辅助线程。Redis 4.0 引入后台线程处理大 key 删除这种耗时擦澳洲,6.0 引入多线程 IO 处理网络读写,但命令执行始终是单线程。

Redis 单线程快的原因如下:

  • 纯内存操作本来就速度高,,读写不涉及磁盘 IO
  • 避免了多线程开销
  • IO 多路复用使得一个线程管理多个连接
  • 高效的数据结构

Redis 内存淘汰策略了解么?

Redis 的内存淘汰策略只有在运行内存达到了配置的最大内存阈值时才会触发,这个阈值是通过 redis.conf 的 maxmemory 参数来定义的。64 位操作系统下,maxmemory 默认为 0,表示不限制内存大小。32 位操作系统下,默认的最大内存值是 3GB。

8 种内存淘汰策略

  • 只淘汰设置了过期时间的 key

    策略 含义 淘汰算法
    volatile-lru 在设置了过期时间的 key 中,淘汰最近最少使用的 LRU
    volatile-lfu 在设置了过期时间的 key 中,淘汰使用频率最低的 LFU
    volatile-random 在设置了过期时间的 key 中,随机淘汰 随机
    volatile-ttl 在设置了过期时间的 key 中,淘汰剩余存活时间最短的 TTL
  • 淘汰所有 key

    策略 含义 淘汰算法
    allkeys-lru 在所有 key 中,淘汰最近最少使用的 LRU
    allkeys-lfu 在所有 key 中,淘汰使用频率最低的 LFU
    allkeys-random 在所有 key 中,随机淘汰 随机
  • 不淘汰

    策略 含义
    noeviction 不淘汰任何 key,内存满时写入直接报错

可以使用 config get maxmemory-policy 命令来查看当前 Redis 的内存淘汰策略。默认策略是 noeviction。LRU 看时间,LFU 看频率,都是近似实现;

Redis 数据类型

Redis 常用的数据类型有哪些?对应的应用场景有哪些?

Redis 中比较常见的数据类型有下面这些:

  • 5 种基础数据类型:

    • String(字符串)

      底层是 SDS(简单动态字符串),支持二进制安全、O(1) 获取长度、自动扩容。而其中INCR操作是原子操作,适合做计数器。

      应用场景如下

      • 缓存:最常用,存 JSON、序列化对象
      • 计数器:INCR 做文章阅读量、点赞数
      • 分布式锁:SETNX + EXPIRE(但推荐 Redisson)
      • 分布式 Session:序列化 Session 存 String
      • 限流:INCR + EXPIRE 做简单计数限流
    • List(列表)

      底层实现在 Redis 3.2 之前是 ziplist + linkedlist,之后是 quicklist(ziplist 组成的双向链表)

      应用场景如下

      • 最新列表:LPUSH + LTRIM 保留最新 N 条
      • 栈/队列:同一边 push/pop 是栈,两边是队列
    • Set(集合)

      底层实现是 intset(整数集合)或 hashtable。

      应用场景如下

      • 去重:文章标签、用户标签
      • 共同好友:SINTER 求交集
      • 抽奖:SRANDMEMBER 随机取,SPOP 抽奖不重复
      • 点赞/收藏:SADD 去重,SCARD 统计
      • 黑白名单:SISMEMBER 判断存在
    • Hash(散列)

      底层实现是 ziplist 或 hashtable。

      应用场景如下

      • 存对象:用户信息、商品信息
      • 购物车:HSET cart:1 商品ID 数量
      • 配置中心:一个 key 存多个配置项
    • Zset(有序集合)

      底层实现是 ziplist 或 skiplist + hashtable。

      应用场景如下

      • 排行榜:游戏积分、热搜、打赏榜
      • 延时队列:score 为到期时间戳,是 Redisson 延时队列底层
      • 范围查询:按时间/分数区间取数据
      • 带权重的队列:按优先级消费
  • 3 种特殊数据类型

    • HyperLogLog(基数统计)

      概率型数据结构,用极小内存估算基数。固定 12KB,误差 0.81%。不能取单个元素,只能估算总数,适合统计

      应用场景如下

      • UV 统计:网站独立访客
      • PV 去重:搜索关键词去重
      • 注册 IP 去重
    • Bitmap (位图)

      基于 String 的位数组

      应用场景如下

      • 签到打卡:每个用户一个 bit
      • 活跃统计:日活、月活
      • 布尔标记:是否领券、是否阅读
      • 布隆过滤器底层:位数组 + 哈希
    • Geospatial (地理位置)

      基于 ZSet,用 GeoHash 编码

      应用场景如下

      • 附近的xxx:GEORADIUS 查半径内的xxx
      • 算两点距离

除了上面提到的之外,还有一些其他的比如 Stream(消息队列), Bitfield(位域)等等。

String 还是 Hash 存储对象数据更好呢?二者上的区别和对比能讲清楚吗?

字段少、整体读写用 String;字段多、频繁改单字段用 Hash。

String Hash
存储方式 整个 JSON 一个 key 一个 key 多个 field
内存开销 key 少,但 value 大 field 多时 ziplist 省内存
编码 raw / embstr ziplist / hashtable

假设一个用户对象:

1
{ "id": 1, "name": "Tom", "age": 18, "city": "北京" }

String 存法

1
SET user:1 '{"id":1,"name":"Tom","age":18,"city":"北京"}'
  • 整个对象序列化成 JSON 字符串
  • 取值:GET user:1 → 反序列化
  • 改 age:取出整个 JSON → 改 age → 写回

Hash 存法

1
HSET user:1 id 1 name Tom age 18 city 北京
  • 每个字段单独存
  • 取值:HGETALL user:1 或 HGET user:1 age
  • 改 age:HSET user:1 age 19

字段少且值小时,Hash 的 ziplist 编码更省内存;但字段多或值大时,Hash 会转 hashtable,反而比 String 费内存。所以字段多的对象,用 Hash 不一定省内存。

一般情况下,字段少、整体读写用 String;字段多、频繁改字段用 Hash。而且从网络开销的角度上,只取或改单个字段时,Hash 网络开销更小;整体读写时,String 更简单。

如果频繁改单个字段,用 Hash,HSET 只改一个 field,原子且不会覆盖其他字段;String 要整体读出改再写回,并发下容易丢失更新。

String 在处理多层嵌套或复杂结构的对象时更方便,因为无需处理每个字段的独立存储和操作。

String 的操作通常具有 O(1) 的时间复杂度,因为它存储的是整个对象,操作简单直接,整体读写的性能较好。Hash 由于需要处理多个字段的增删改查操作,在字段较多且经常变动的情况下,可能会带来额外的性能开销。

在批量取多个对象时,String 的 MGET 更方便;Hash 的 HGETALL 只能取一个对象的全部字段。这也是选型要考虑的点。

总结:

  • 在绝大多数情况下,String 更适合存储对象数据,尤其是当对象结构简单且整体读写是主要操作时。
  • 如果你需要频繁操作对象的部分字段或节省内存,Hash 可能是更好的选择。

Redis 性能优化

Redis 如何使用批量操作减少网络传输?

一个 Redis 命令的执行可以简化为以下 4 步:

  1. 发送命令;
  2. 命令排队;
  3. 命令执行;
  4. 返回结果。

其中,第 1 步和第 4 步耗费时间之和称为 Round Trip Time(RTT,往返时间),也就是数据在网络上传输的时间。

Redis 中有一些原生支持批量操作的命令

  • MGET(获取一个或多个指定 key 的值)、MSET(设置一个或多个指定 key 的值)、
  • HMGET(获取指定哈希表中一个或者多个指定字段的值)、HMSET(同时将一个或多个 field-value 对设置到指定哈希表中)、
  • SADD(向指定集合添加一个或多个元素)

pipeline

对于不支持批量操作的命令,我们可以利用 pipeline(流水线) 将一批 Redis 命令封装成一组,这些 Redis 命令会被一次性提交到 Redis 服务器,只需要一次网络传输。

原生批量操作命令和 pipeline 的是有区别的,使用的时候需要注意:

  • 原生批量操作命令是原子操作,pipeline 是非原子操作。
  • pipeline 可以打包不同的命令,原生批量操作命令不可以。
  • 原生批量操作命令是 Redis 服务端支持实现的,而 pipeline 需要服务端和客户端的共同实现。

因此,pipeline 不适用于执行顺序有依赖关系的一批命令

Lua 脚本

Lua 脚本同样支持批量操作多条命令。一段 Lua 脚本可以视作一条命令执行,可以看作是 原子操作。也就是说,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰,这是 pipeline 所不具备的。

并且,Lua 脚本中支持一些简单的逻辑处理比如使用命令读取值并在 Lua 脚本中进行处理,这同样是 pipeline 所不具备的。

不过, Lua 脚本依然存在下面这些缺陷:

  • 如果 Lua 脚本运行时出错并中途结束,之后的操作不会进行,但是之前已经发生的写操作不会撤销,也就是说,不像事务那样能回滚
  • Redis Cluster 下 Lua 脚本的原子操作也无法保证了,原因同样是无法保证所有的 key 都在同一个 hash slot(哈希槽) 上

大量 key 集中过期问题如何应对和解决

大量 Key 在同一时刻失效,定期删除任务需要连续、高频地扫描并删除这些 Key,从而长时间占用主线程,导致其他客户端请求被阻塞或延迟。

而且过期 Key 被标记为已过期后,内存并不会立即释放。Redis 需要通过后台周期性扫描来回收。如果内存已满且淘汰策略是默认的 noeviction,在过期 Key 真正被清理并释放内存之前,新的写入请求会因为内存超限而被拒绝

解决思路是避免 Key 在同一时刻过期,将过期压力均摊到一段时间内。

  • 设置 TTL 时添加随机扰动
  • 按业务维度错峰,一般是根据 Key 的业务标识进行取模,分配到不同的过期时间段。
  • 业务低峰期主动清理,触发已过期 Key 的删除,提前释放内存

bigkey 是怎么产生的?大 key 问题如何发现和处理?

大 Key 是一个 Key 的 Value 太大或包含的元素太多

  • 程序设计不当,比如直接使用 String 类型存储较大的文件对应的二进制数据。
  • 对于业务的数据规模考虑不周到,比如使用集合类型的时候没有考虑到数据量的快速增长。
  • 未及时清理垃圾数据,比如哈希中冗余了大量的无用键值对

除了会消耗更多的内存空间和带宽,大 key 还会造成阻塞问题。

  • 工作线程阻塞:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
  • 网络阻塞:每次获取大 key 产生的网络流量较大
  • 客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis

对于发现,可以使用如下方法

使用 redis-cli 自带的参数进行快速扫描。

  • --bigkeys:快速定位每种数据类型中元素数量最多的 Key。
  • --memkeys:统计口径是内存占用而非元素个数

工具如 redis-rdb-tools 或 RDR,可以解析 RDB 文件,生成详细的内存占用报告。

使用 Redis 自带的 SCAN 命令,SCAN 命令可以按照一定的模式和数量返回匹配的 key。获取了 key 之后,可以利用 STRLEN、HLEN、LLEN 等命令返回其长度或成员数量。

发现大 Key 后,可以这样安全处理

  • 拆分大 key
  • 安全删除大 Key,使用UNLINK 是异步删除命令

hotkey 是怎么产生的?热 key 问题如何发现和解决?

如果一个 key 的访问次数比较多且明显多于其他 key 的话,那这个 key 就可以看作是 hotkey

处理 hotkey 会占用大量的 CPU 和带宽,可能会影响 Redis 实例对其他请求的正常处理。此外,如果突然访问 hotkey 的请求超出了 Redis 的处理能力,Redis 就会直接宕机。这种情况下,大量请求将落到后面的数据库上,可能会导致数据库崩溃。

因此,hotkey 很可能成为系统性能的瓶颈点,需要单独对其进行优化,以确保系统的高可用性和稳定性。

发现热key,可以使用如下方法

  • 使用 Redis 自带的 --hotkeys 参数来查找。前提是 maxmemory-policy 参数设置为 LFU 算法
  • 京东零售的 hotkey 这个项目不光支持 hotkey 的发现,还支持 hotkey 的处理。

如何解决热key

  • 热 Key 最有效的方案是本地缓存。比如秒杀商品信息、全局配置,直接放 Caffeine,请求不用打到 Redis。代价是要处理一致性,适合变化不频繁的数据。
  • Key 拆分是把一个热 Key 复制成 N 份,比如 item:1001:0 到 item:1001:9,读的时候随机选一个,压力就分散到多个节点了。适合读多写少的场景。

Redis 实际应用

Redis 通常如何实现分布式锁?对 Redisson 分布式锁了解多少?

Redis 实现分布式锁主要基于其单线程特性和原子操作,可以使用 原生 Redis 命令 和 借助 Redisson 的内置分布式锁

原生 Redis 命令 Redis 的 SET 命令的原子性,避免多线程 / 多节点并发抢占锁时出现竞态条件:

  • 加锁:使用 SET key value NX PX expireTime 命令
    • NX:保证互斥性,仅当 key 不存在时才设置
    • PX expireTime:设置锁的自动过期时间
    • value:通常设置为唯一标识(如 UUID + 线程 ID),用于后续解锁时校验归属,避免误删其他线程的锁。
  • 解锁:保证 先校验归属,再删除 的原子性,否则可能误删锁。
    • 用 Lua 脚本实现原子操作

Redisson 作为 Redis 官方推荐的 Java 客户端之一,提供了完整的分布式锁实现方案。

Redisson 分布式锁的设计遵循 Java 锁规范,实现了 JUC 的锁接口 java.util.concurrent.locks.Lock,使得开发者可以像使用本地锁一样使用分布式锁,大大降低了分布式系统的开发门槛,而且 Redisson 也像 JUC 的锁机制那样,支持可重入锁、公平锁、读写锁等多种锁类型,支持锁的续期和超时设置,还提供了锁的监控功能。

而且其分布式锁也通过 Lua 脚本 和 Redis 的原子操作,确保了在高并发场景下的数据一致性和系统可靠性。

Redisson 还内置了连接池管理、故障转移、负载均衡等企业级特性,而且还可以通过看门狗机制实现锁续期,使其成为生产环境中分布式锁的首选方案。

在架构设计上,Redisson 采用 Netty 作为网络通信框架,通过高效的异步 IO 模型实现与 Redis 服务器的交互。

Redisson 在 Redis 中使用 Hash 结构存储锁信息,其核心数据结构可通过以下代码体现:

1
2
3
4
// 锁存储结构示例
"lock_key" : {
"b983c153-7421-453a-9be9-0a26e264679a:1": "3" // UUID:线程ID -> 重入次数
}

而且针对 Redis 集群主从架构下 主节点宕机导致锁丢失 的问题,Redisson 实现了 RedLock 算法:

  • 同时向多个独立的 Redis 节点申请锁,一般至少三个
  • 只有超过半数节点加锁成功,且总耗时小于锁超时时间,才认为加锁成功;
  • 解锁时需向所有节点释放锁

Redis 可以做消息队列吗?什么时候能用 Redis 做消息队列?那么使用 Redis 做消息队列如何保证持久化啊?

Redis 可以做消息队列。List、Pub/Sub、Stream 都可以实现消息队列。

  • List 是 Redis 中最基础的数据结构之一,它通过 LPUSH 和 RPOP 命令实现了一个简单的消息队列。消息不持久化、不支持消息确认、不支持消息重试,可靠性难以保证。
  • Pub/Sub 是 Redis 提供的发布订阅模式,它允许消息的发送者和接收者完全解耦。适合实时通知的场景、支持多订阅者、发送接收完全解耦。和 List 一样,消息不持久化、不支持消息确认、不支持消息重试,可靠性难以保证。
  • Stream 是 Redis 5.0 引入的新特性,它提供了一个完整的消息队列实现。Stream 支持消息持久化,还支持消费者组、消息确认等高级特性。但是,由于 Redis 本身数据持久化存在时间空隙,仍然可能存在消息丢失。

Stream 消息队列的持久化依旧依赖 Redis 的两种持久化方式:RDB 和 AOF,一般开启混合存储的模式

而且除了 Redis 自身的持久化,Stream 还通过 消费者组 机制保证消费端的消息不丢

  • 消费者组的 last_delivered_id会被持久化,重启后可从上次的偏移量继续消费;
  • 而且消费者获取消息后,若未执行 XACK 确认,消息会进入 PEL 列表,PEL 会被持久化,重启后可重新投递

适合用 Redis 做消息队列的场景:

  • 轻量级消息传递,如简单通知、日志收集等;
  • 对消息可靠性要求不高,允许偶尔丢失;
  • 需要低延迟、高吞吐量的临时队列场景;
  • 已有 Redis 集群,想减少组件依赖时。

Redis 并非专业消息队列,不适合高并发、强事务、持久化要求极高的核心业务场景,这时候考虑选择 Kafka

什么样的数据可以存到 Redis 里?你是为了优化性能用到了 Redis 缓存,能说说你在什么场景下会选择使用缓存吗?

首先,缓存是一种提高数据读取性能的技术,它通过将数据存储在高速存储设备中,减少对低速存储设备的访问,从而提高系统的响应速度。

适合放 Redis 的数据,一般满足这些特征,如果没有这些特征就不太适合放到 Redis 中

  1. 读多写少的热点数据
  2. 时效性短且访问频繁的数据
  3. 高访问、高并发、低延迟要求
  4. 非强一致性数据、允许短暂不一致
  5. 数据体量不大、不能无限增长

在接口性能差、DB 查询慢的环境下加缓存,这种常用的多表关联查询,首页数据等内容,查询结果直接缓存,避免重复查库,减轻数据库压力

在高并发场景,需要扛流量的数据,放入 Redis 缓存。例如商品详情页,首页推荐,热门榜单,活动页面。DB 扛不住,必须用 Redis 挡流量。同理,频繁访问的公共数据、基础数据也可以放入

需要超低延迟的场景,例如验证码,限流,黑名单的处理,这些必须毫秒级响应,DB 做不到。

需要做聚合、计数、排行榜也适合放到 Redis 中,合理的数据结构能让 Redis 天然支持,用 DB 做性能就不太好

Redis 如何进行热点数据缓存的实践?如何缓存热点数据?如何保证缓存数据和数据库数据的一致性的?

首先,要确认什么样的数据可以认为是热点数据,例如用户高频触达数据,核心链路数据等这种,然后放到 Redis 中

  • 短时间内访问量极高
  • 读多写少,数据量小,允许短期不一致

在动手缓存前,要做一些提前的处理,避免缓存失效或引发新问题:

  1. 缓存粒度要适中:不缓存大而全的数据集,只缓存 高频访问的字段
  2. 缓存过期策略合理:对于核心热点数据,设置 相对较长的过期时间 + 主动更新
  3. 缓存更新策略匹配业务:写少读多,使用旁路缓存的模式,更新数据库 + 失效缓存,避免并发更新导致数据不一致;
  4. 缓存穿透 / 击穿 / 雪崩提前防控
  5. 非常热点的数据考虑单独隔离

对于通用的热点数据缓存,我们通常都会考虑 旁路缓存模式,读请求先查缓存,缓存未命中再查数据库,查库后回写缓存;写请求先更数据库,再删缓存,这样能够保证数据较好的一致性

  1. 首先,识别哪些数据是热点,通过访问频次统计识别

    在接口层加拦截器,统计每个 Key 的访问次数,设定阈值,达到阈值则标记为热点数据,进入 Redis 缓存

  2. 对可预知的热点数据,提前标记为热点,例如,秒杀活动前,将商品 ID 列表写入 Redis 的 Set 集合

  3. 然后设计一个合理的 Key,一般是业务域:数据类型:主键:版本,然后选择合适的数据结构,单条热点数据选 String,结构化数据选 Hash,热点列表根据排序需求选择 List 或者 ZSet

  4. 热点数据若等用户访问时才加载,首次请求仍会查 DB,需提前预热,一般是选择定时任务

  5. 然后缓存读写,使用旁路缓存

    读缓存是缓存优先,未命中则加锁查DB,回写缓存,这也能保证较好的一致性

    写缓存是先更 DB,再删缓存,避免并发更新导致数据不一致

  6. 对于超热点数据,考虑双层缓存,也就是 Redis + 本地缓存Caffeine

    整体流程如下,查本地缓存 → 命中则返回 → 未命中查 Redis → 命中则回写本地缓存 → 未命中查 DB → 回写 Redis + 本地缓存

  7. 做好缓存异常防控,例如防缓存击穿。做好缓存监控

保证缓存一致性是一个常见挑战。

在我的项目中,我主要采用了一种相对简单且常用的策略:在更新数据库的同时,让对应的缓存失效。具体来说,是先更新数据库再删除缓存

具体来说,就是在执行更新操作的 Service 方法上,使用 Spring Cache 的 @CacheEvict 注解。这样,当方法成功执行后,会自动删除指定的缓存Key。下次再有查询请求时,因为缓存不存在,就会去数据库读取最新数据并重新放入缓存。

如何基于 Redis 实现延时任务?

类似的问题:

  • 订单在 10 分钟后未支付就失效,如何用 Redis 实现?
  • 红包 24 小时未被查收自动退还,如何用 Redis 实现?

基于 Redis 实现延时任务的功能无非就下面两种方案:

  1. Redis 过期事件监听。
  2. Redisson 内置的延时队列。

Redis 过期事件监听存在时效性较差、丢消息、多服务实例下消息重复消费等问题,不被推荐使用。

Redisson 内置的延时队列具备下面这些优势:

  1. 减少了丢消息的可能:DelayedQueue 中的消息会被持久化,即使 Redis 宕机了,根据持久化机制,也只可能丢失一点消息,影响不大。当然了,你也可以使用扫描数据库的方法作为补偿机制。
  2. 消息不存在重复消费问题:每个客户端都是从同一个目标队列中获取任务的,不存在重复消费的问题。

HyperLogLog 适合什么场景?

HyperLogLog (HLL) 是一种非常巧妙的概率性数据结构,它专门解决一类非常棘手的大数据问题:在海量数据中,用极小的内存,估算一个集合中不重复元素的数量。

HLL 做的最核心的权衡,就是用一点点精确度的损失,来换取巨大的内存空间节省。它给出的不是一个 100%精确的数字,而是一个带有很小标准误差(Redis 中默认是 0.81%)的近似值。

基于这个核心权衡,HyperLogLog 最适合以下特征的场景:

  1. 数据量巨大,内存敏感: 这是 HLL 的主战场。比如,要统计一个亿级日活 App 的每日独立访客数。如果用传统的 Set 来存储用户 ID,一个 ID 占几十个字节,上亿个 ID 可能需要几个 GB 甚至几十 GB 的内存,这在很多场景下是不可接受的。而 HLL,在 Redis 中只需要固定的 12KB 内存,就能处理天文数字级别的基数,这是一个颠覆性的优势。
  2. 对结果的精确度要求不是 100%: 这是使用 HLL 的前提。比如,产品经理想知道一个热门帖子的 UV(独立访客数)是大约 1000 万还是 1010 万,这个细微的差别通常不影响商业决策。但如果场景是统计一个交易系统的准确交易笔数,那 HLL 就完全不适用,因为金融场景要求 100%的精确。

所以,HyperLogLog 具体的应用场景就非常清晰了:

  • 网站/App 的 UV(Unique Visitor)统计: 比如统计首页每天有多少个不同的 IP 或用户 ID 访问过。
  • 搜索引擎关键词统计: 统计每天有多少个不同的用户搜索了某个关键词。
  • 社交网络互动统计: 比如统计一条微博被多少个不同的用户转发过。

在这些场景下,我们关心的是数量级和趋势,而不是个位数的差异。

最后,Redis 的实现还非常智能,它内部会根据基数的大小,在稀疏矩阵(占用空间更小)和稠密矩阵(固定的 12KB)之间自动切换,进一步优化了内存使用。总而言之,当您需要对海量数据进行去重计数,并且可以接受微小误差时,HyperLogLog 就是不二之选