面试整理——Redis

Redis

一. 综合问题

位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,可称之为缓存。比如做一次内存寻址大概需要 100ns,而做一次磁盘的查找则需要 10ms,差了万倍。缓冲区则是一块临时存储数据的区域,用以弥补高速和低速设备通信时的速度差。

  • 缓存比较适合于读多写少的业务场景,并且数据最好带有一定的热点属性。
  • 缓存会给整体系统带来复杂度,并且会有数据不一致的风险。
  • 缓存通常使用内存作为存储介质,但是内存并不是无限的。
  • 缓存会给运维也带来一定的成本。

问:你为什么要在项目中使用Redis,如何使用的呢?redis 使用场景?⭐⭐⭐

你为什么要在项目中使用Redis?

  1. 高性能:磁盘读取通常在几毫秒,而内存则在几十微秒,速度可能差10倍100倍。
  2. 高并发:MySQL的并发量差不多在1000次/s,而Redis可以轻松达到10W级别,超过10W后,通过分片集群来扩展。
  3. 高可用:主节点挂掉从节点代替。

选择Redis的核心原因在于:

  • 性能:内存操作,单节点10万+ QPS,满足高并发场景。
  • 数据结构:String、Hash、Set等丰富结构,适配多样业务需求。
  • 原子性:单命令或Lua脚本保障操作原子性,简化并发控制。
  • 高可用:Cluster模式、持久化策略保障数据可靠性。

在项目中使用 Redis 通常是为了提高系统性能、降低延迟,并简化一些高并发场景下的数据存储和访问问题。Redis 是一个基于内存的高性能键值存储系统,能够提供非常快速的读写操作,适合用作缓存、消息队列、分布式锁、计数器等多种场景。

为什么要使用 Redis?

  1. 提高性能和降低延迟
    • 内存存储:Redis 是一个内存数据库,数据存储在 RAM 中,读写速度极快。这使得 Redis 特别适合缓存频繁访问的热点数据,减少数据库压力,提高整个系统的响应速度。
    • 适用于高并发:Redis 能够处理每秒数百万的请求,因此它是高并发场景中的理想选择。
  2. 缓存常用数据,减轻数据库负载
    • 使用 Redis 缓存数据库中的热点数据,可以减少数据库的读取压力,提升响应速度,尤其在高并发的应用中尤为重要。
    • 例如,对于用户信息、商品详情、统计数据等常用数据,可以通过 Redis 存储并定期更新。
  3. 支持持久化
    • Redis 提供了 RDBAOF 两种持久化方式,可以确保数据在发生故障时不会丢失,适用于需要可靠数据存储的场景。
  4. 提供数据结构支持
    • Redis 支持丰富的数据结构,如 StringListSetSorted SetHash 等,可以满足多种不同的业务需求。
    • 比如,使用 List 来实现队列,使用 Set 来去重,使用 Sorted Set 来实现排行榜等。
  5. 分布式特性
    • Redis 支持分布式部署,通过 Redis Cluster主从复制 提供水平扩展性和高可用性。
    • 适合大规模系统使用,支持分片和故障恢复,保证系统的稳定性和高可用性。
  6. 支持发布/订阅
    • Redis 支持 Pub/Sub 模式,允许系统之间进行实时的消息通知,适用于聊天系统、通知推送、日志收集等场景。
  7. 分布式锁和计数器
    • Redis 可以作为分布式锁的实现,确保在分布式环境下多个节点之间的互斥访问。
    • 还可以用于实现分布式计数器(如统计访问次数、计数器等)。

使用场景:

  1. 缓存:加速数据访问,降低数据库压力。
  2. Session存储:实现服务无状态化,支持水平扩展。
  3. 分布式锁:协调分布式系统,处理并发问题,但可能这里提到的是无锁化设计,即用原子操作代替锁。
  4. 原子操作实现无锁化:例如库存扣减,使用DECR原子操作,避免使用锁,提升性能。

详细:

  1. 缓存:常用于大型网站来加速数据访问和缓解后端数据库压力。Redis提供了键值过期时间设置,也提供了灵活控制最大内存和内存溢出后的淘汰策略。

    再想想,可能项目中还用到了Redis做缓存,比如商品详情页的信息,用户频繁访问,使用Redis缓存减轻数据库压力,同时设置过期时间,保证数据一致性。或者用Redis的List结构实现异步队列,处理订单创建后的后续操作,比如发送通知,这样解耦系统组件,提高响应速度。缓存热点数据,减少数据库的压力

    • 会话缓存(session cache),Redis提供持久化。
    • 全页缓存(FPC),即使重启了 Redis 实例,因为有磁盘的持久化,用户也不会看到页面加载速度的下降,这是一个极大改进,类似 PHP 本地 FPC
  2. 排行榜:几乎所有网站都需要各种规则的排行榜,或是依据热度排行,或是发布时间,或是结合各种复杂维度计算。Redis提供了列表和有序集合数据结构,合理的使用这些结构可以很方便的构建各种排行榜系统。如 ZRANGE user_scores 0 10 WITHSCORES 。用Sorted Set做排行榜之类的

  3. 计数器:如一些视频网站的播放数,电商网站的浏览数,对于数据的实时性有较高的要求,如果并发量很大时,传统关系型数据库很难做好这一工作。Redis天然支持计数功能,且性能优越。

  4. 社交网络:比如赞/踩、粉丝、共同好友/喜好、推送、下拉刷新等常见功能,由于社交网站访问量通常较大,且传统关系型数据库不太适合存储这种类型的数据,而Redis提供的数据结构比较容易实现这些功能。

  5. 消息队列:大型网站的必备基础组件,因为其具有业务解耦、非实时业务削峰等特性。Redis提供了发布订阅功能和阻塞队列功能,虽然相比专业的消息队列还有所欠缺,但可以满足大部分基础需求。

    或者用Redis的发布订阅功能实现消息通知;

  6. 分布式锁

    无锁化,可能是指利用Redis的特性避免使用锁机制,比如使用原子操作或者Lua脚本来保证操作的原子性。例如,使用Redis的INCR命令实现计数器,或者用SETNX实现分布式锁,避免竞态条件。

    还有,分布式锁的具体实现,比如用SET命令配合NX和PX参数,确保只有一个客户端能获得锁,处理完后再释放锁,避免并发问题。这可能是在分布式环境下协调不同服务实例的一种方式,但需要处理锁超时、误删锁等问题,通常用Lua脚本保证原子性。

  7. Session存储

    • 服务无状态指的是服务实例不保存客户端的会话信息,每个请求都包含处理所需的所有信息。这样在水平扩展时,可以方便地增加或减少实例,而不需要担心会话数据的问题。这时候,通常会使用外部存储(如Redis)来保存Session,这样服务实例就可以无状态化。
    • 在Session管理方面,用户登录后生成一个token,将用户信息存入Redis,并设置过期时间,每次请求携带token,服务端从Redis获取用户信息,这样服务实例不需要保存Session,实现了无状态。这样在扩容时,新增的实例可以直接处理请求,无需同步Session数据。

1. 缓存加速,降低数据库压力

目的与优势:
利用 Redis 高速的内存存储特性,将数据库中频繁访问的数据(如热门文章、用户信息、商品详情等)缓存起来,可以大幅降低数据库的压力,提高响应速度和系统吞吐量。

实现方式:
一般通过 SET/GETMSET/MGET 等命令进行数据的读写;同时会结合过期策略(EXPIRE、TTL)来保证缓存数据的时效性。

场景:商品详情页、热点数据查询

  • 问题:高频访问的数据库查询(如商品信息)导致响应延迟,数据库负载过高。
  • 解决方案:使用Redis缓存查询结果,设置合理过期时间(如30分钟)。
  • 效果:缓存命中率超90%,数据库QPS下降70%,接口响应时间从200ms降至20ms。
  • 技术细节:采用缓存穿透(空值缓存)、雪崩(随机过期)防护策略,结合旁路缓存模式更新数据。

2. Session集中管理,实现服务无状态化

背景:
在分布式系统中,为了实现各个服务节点之间的会话共享,传统的将 Session 存储在单台服务器内的方式难以满足扩展性需求。

解决方案:
将用户登录时产生的 Session 数据存储在 Redis 中,所有应用节点都从同一个 Redis 读取 Session 信息,从而使各个服务节点本身保持无状态(Stateless)。这种方式不仅便于横向扩展,还能保证用户在多节点之间的无缝切换。

典型命令:
使用 Spring Session 结合 Redis,即只需配置 spring.session.store-type=redis,系统便会将 HTTP Session 数据自动存储到 Redis 中。

场景:用户登录态管理

  • 问题:传统Tomcat Session存储在实例内存中,导致扩容时需Session粘滞或同步,扩展性差。
  • 解决方案:将Session序列化为JSON存入Redis,设置TTL(如30分钟),以Token(如JWT)为Key。
  • 效果:服务实例完全无状态,轻松扩容至数十节点,登录态查询时间稳定在5ms内。
  • 技术细节:采用Redis Cluster保障高可用,双写校验防止Session异常丢失。

3. 原子操作实现无锁化并发控制

场景:秒杀库存扣减

  • 问题:高并发下数据库行锁导致性能瓶颈,事务竞争激烈。
  • 解决方案:库存预热至Redis,通过DECR原子操作扣减,结果≥0方允许下单。
  • 效果:秒杀接口支撑10万+ QPS,扣减操作耗时<2ms,避免锁竞争及超卖问题。
  • 技术细节:Lua脚本封装“查询-扣减-返回结果”逻辑,保证原子性,异步同步库存至数据库。

4. 分布式锁协调资源访问

挑战:
在高并发环境下,对共享资源(例如订单生成、库存扣减)的访问必须保证原子性,传统的线程锁可能会带来阻塞和性能瓶颈。

Redis 分布式锁:
利用 Redis 的原子命令(例如 SETNX 或者带有过期时间的 SET key value NX EX seconds),可以实现跨节点的锁机制。

  • 这种锁的获取和释放可以通过 Lua 脚本来保证原子性,避免因锁释放不当而导致的误删或死锁问题。
  • 使用分布式锁后,即使是多台服务器同时发起并发操作,也能保证只有一个实例能对共享资源进行修改,从而达到“无锁化”设计中希望减少竞争与阻塞的效果。

扩展应用:
除了用于订单扣减等业务场景,还可用于业务中的其他需要同步控制的场合。

场景:定时任务防重复执行

  • 问题:多实例部署时,定时任务可能被多个节点触发,导致数据重复处理。
  • 解决方案:Redis的SET key uuid NX EX 30实现非阻塞锁,任务结束前通过Lua脚本验证释放。
  • 效果:任务精准单节点执行,锁操作耗时1ms,避免数据库唯一索引等悲观锁开销。

5. 实时排行榜与计数器

场景:直播打赏榜单

  • 问题:实时更新用户积分并排序,数据库ORDER BY效率低。
  • 解决方案:Redis Sorted Set存储用户ID及积分,ZINCRBY更新分数,ZREVRANGE获取Top N。
  • 效果:榜单更新延迟<10ms,支持百万级用户实时排名,接口响应时间稳定。

消息队列:
利用 Redis 的发布/订阅模式或基于 List/Stream 的消息队列机制,实现异步任务调度和服务解耦。

限流与计数器:
通过 Redis 的原子自增(INCR)等操作,可以轻松实现用户请求限流(例如根据 IP 或用户ID进行统计)以及计数器(如 PV、点赞数等)的功能。

排行榜和排序:
利用 Sorted Set 数据结构,可以高效构建游戏积分排行榜、文章点赞排行榜等应用场景,实现快速排序和分页查询。

问:简单描述下 Redis?支持哪些数据结构?有什么优点?⭐⭐⭐

  • 什么是Redis?

    • C语言开发的一个基于高性能键值对开源内存数据库,是一个非关系型数据库(NoSQL)。因为是纯内存操作,所以性能非常出色,每秒可以处理10W次读写操作。支持持久化存储,支持多种数据结构,能够满足不同场景下的需求,且在分布式环境中具备高可用性和高扩展性。
    • 协议:Redis 使用 TCP 协议,并且客户端和服务器之间的通信是通过 RESP(REdis Serialization Protocol)协议进行的。
  • 支持哪些数据结构?

    支持八种数据结构:字符串String,哈希Hash,数组List,集合Set,有序集合ZSet,位图BitMaps,基数统计HyperLogLig,地理信息定位GEO。

    1. String(字符串)
      • 最简单的数据类型,值可以是任意的字符串(如文本、数字等)。
      • 支持操作:SETGETINCRDECRMGET 等。
    2. List(列表)
      • 支持双向链表,按照插入顺序保存元素。
      • 支持操作:LPUSHRPUSHLPOPRPOPLRANGELSET 等。
    3. Set(集合)
      • 无序集合,不允许重复元素。
      • 支持操作:SADDSREMSMEMBERSSINTERSUNIONSDIFF 等。
    4. Sorted Set(有序集合)
      • 和 Set 类似,但每个元素都会关联一个权重(分数),可以根据分数进行排序。
      • 支持操作:ZADDZREMZRANGEZREVRANGEZINCRBY 等。
    5. Hash(哈希)
      • 用于存储键值对的集合,适合用于存储对象。
      • 支持操作:HSETHGETHGETALLHDEL 等。
    6. Bitmap(位图)
      • 用于高效存储和操作大规模的二进制位数据,常用于计数器、标志位等场景。
      • 支持操作:SETBITGETBITBITCOUNTBITOP 等。
    7. HyperLogLog
      • 用于做基数统计(即统计不同元素的数量),适合用于大规模的数据去重、唯一计数等。
      • 支持操作:PFADDPFCOUNTPFMERGE 等。
    8. Geo(地理空间)
      • 用于存储和处理地理位置信息,如经纬度。
      • 支持操作:GEOADDGEODISTGEORADIUS 等。
    9. Streams(流)
      • 用于消息队列和日志的场景,支持持久化消息、消费者组等功能。
      • 支持操作:XADDXREADXGROUPXACK 等。

    2

  • 优点?

    • 支持数据结构:Redis 支持丰富的数据类型(如字符串、列表、哈希、集合、有序集合等)。支持多种数据结构和算法,所以应用面广。例如,可以使用 Sorted Set 来实现排行榜,使用 List 来实现队列,使用 Hash 来存储对象等。
    • 高性能:将数据存放在内存,所以有高效的读写性能。Redis 可以处理每秒上百万次的请求,适合用作缓存、会话存储等高频访问的数据存储。
    • 支持持久化:通过快照(RDB)和日志(AOF)两种方式支持数据持久化。内存中的数据通过快照和日志的方式保存在硬盘,所以不易丢失
      • RDB(快照方式):定期将内存中的数据快照保存到磁盘上。
      • AOF(追加文件方式):通过记录每次写操作的日志,确保数据的持久化。
    • 原子性操作:Redis 支持原子性的操作,确保操作的原子性和一致性。
    • 支持发布/订阅:支持 Pub/Sub 模式,允许客户端间的消息通信。允许客户端订阅某些频道,并接收发布到这些频道的消息,适用于消息队列、通知推送等应用场景。
    • 支持事务:支持多命令的事务操作。
      • Redis 支持简单的事务操作,允许多个命令一次性执行,保证原子性。
      • 支持的命令包括:MULTIEXECDISCARDWATCH 等。
    • 轻量级与易用性
      • Redis 是一个轻量级的系统,配置和使用非常简单,且客户端支持多种编程语言(如 Java、Python、C++、Go 等)。
      • Redis 还具有简单的命令行客户端,可以直接通过命令进行交互,便于开发和调试。
    • 高可用性:通过主从复制、哨兵(Sentinel)和分片(Cluster)提供高可用性和扩展性。
      • 主从复制:支持数据的主从复制,提供高可用性。
      • Redis Sentinel:提供故障自动恢复和监控。
      • Redis Cluster:支持水平扩展,允许将数据分片到多个节点上,提高系统的处理能力。

问:Redis单线程为什么执行速度这么快?⭐⭐⭐

常见操作的响应时间:1秒=10^3毫秒=10^6微秒=10^9纳秒

  1. 打开一个站点:几秒。

  2. 数据库查询一条记录(有索引):十几毫秒。

  3. 从机械磁盘顺序读取1M数据:2~10毫秒。 对于 7200 转的机械硬盘,顺序读1MB数据的时间一般在这个范围内;随机读取则通常在 8~12 毫秒甚至更高。

  4. 从SSD磁盘顺序读取1M数据:0.3毫秒。由于 SSD 随机读取不需要移动磁头,随机和顺序读取相差不大。

    • NVMe SSD: 大约 0.1~0.5 毫秒

      SATA SSD: 可能在 1~2 毫秒左右

  5. 从内存连续读取1M数据:通常在 40~100 微秒之间。

    • 现代 DDR4 内存带宽很高(例如 25~50 GB/s),因此1MB数据的拷贝时间理想情况下可能在几十微秒内完成。
  6. 1.6G的CPU执行一条指令:0.6纳秒。

  7. CPU读取一次内存:100纳秒。

  8. 1G网卡,网络传输2Kb数据:20微秒。

  1. 纯内存操作,避免大量访问数据库,减少直接读取磁盘数据,redis将数据储存在内存里面,读写数据的时候都不会受到硬盘 I/O 速度的限制,所以速度快。
  2. 单线程操作,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗。
  3. 采用了非阻塞I/O多路复用机制,从而支撑起大量的网络请求,减少了线程切换时上下文切换和竞争。

总结:

Redis 的高性能来源于其单线程设计和高效的内存存储,同时避免了多线程的复杂性、锁的竞争和上下文切换的开销。通过事件驱动模型和非阻塞 I/O,Redis 能够在单线程中高效地处理大量请求,而内存存储和简洁的网络协议进一步提升了其执行速度。在高并发和低延迟场景下,Redis 仍能保持较高的性能表现。

  1. 避免了线程上下文切换的开销

    • 在多线程环境中,操作系统需要在多个线程之间切换,这个过程称为线程上下文切换。线程切换会带来性能损耗,因为需要保存和恢复线程的状态,包括寄存器、程序计数器和内存页的切换等。每次上下文切换都需要消耗一定的 CPU 时间。
    • Redis 采用单线程模型,所有操作在同一个线程中顺序执行,不需要进行上下文切换,因此避免了多线程竞争和上下文切换的开销。这使得 Redis 的执行速度非常快,尤其在高并发环境下,减少了线程切换的延迟。
  2. 避免了锁的竞争

    • 在多线程环境中,多个线程可能会并发地访问共享资源,为了保证数据的一致性和线程安全,常常需要使用锁(如互斥锁、读写锁等)。锁的竞争和管理会带来性能损耗。
    • 由于 Redis 使用单线程模型,不会发生线程间的资源竞争,也就不需要使用锁。因此,避免了锁带来的开销和复杂性,提升了执行效率。
  3. 事件驱动和非阻塞 I/O 模型

    • Redis 采用了 事件驱动 的模型,使用了 非阻塞 I/O(比如通过 epollselect 等高效的 I/O 多路复用机制)。在 Redis 中,所有 I/O 操作(如网络通信)都是非阻塞的,并且 Redis 使用一个单一的线程来处理所有的请求。
    • 事件驱动模型意味着 Redis 可以同时处理多个客户端的请求,但每个请求在单线程中依次执行,这避免了复杂的多线程管理。
    • 例如,多个客户端发送的请求会被放入一个事件循环队列中,Redis 会按顺序处理这些请求,执行 I/O 操作时不会阻塞其它请求,因此能高效地利用 CPU 时间。
  4. 操作是原子性的,简化了实现

    • Redis 的操作通常是非常简单和高效的,且每个命令都保证原子性。例如,命令如 SETGETINCR 等都是单一操作,它们在执行时不会被其他命令打断,操作的粒度非常小,能够高效完成。
    • 对 Redis 来说,命令的执行没有复杂的事务管理或者多线程的上下文切换问题,因此可以确保每个操作非常快速。
  5. 内存存储,减少 I/O 和磁盘操作

    • Redis 作为内存数据库,其数据存储在内存中,而不是磁盘。相比于传统的磁盘存储,内存的读写速度要快得多。
    • 由于 Redis 中大部分操作只涉及内存,不会频繁访问磁盘,减少了磁盘 I/O 操作,这进一步提升了性能。
  6. 高效的数据结构和算法

    • Redis 的数据结构设计非常高效,采用了多种优化算法,能够快速处理各种类型的数据请求。例如,哈希表、跳表(用于 Sorted Set)、链表等结构都经过优化,能够在常数时间复杂度(O(1))或者对数时间复杂度(O(logN))内完成操作,确保操作速度快。
    • Redis 内部的数据结构优化能够有效减少内存占用和提高数据访问速度,使得它在高并发的情况下仍然能够保持较高的性能。
  7. 高效的内存管理

    • Redis 使用自定义的内存分配器(基于 jemalloc),能高效地管理内存,减少内存碎片。这种内存分配器是专为高性能环境设计的,可以提供更好的内存访问速度和性能。
    • 由于 Redis 的数据结构存储在内存中,访问速度比传统数据库更快,极大地提升了响应速度。
  8. 简化的网络协议

    • Redis 使用的是 RESP(REdis Serialization Protocol) 协议,它比传统的 HTTP 或其他协议要简洁得多。由于协议本身非常轻量,Redis 可以更快速地解析和响应客户端请求。
    • 简单的协议格式也减少了网络带宽和延迟的消耗,有助于提升 Redis 的响应速度。

问:Redis是单线程?Redis的单线程特性有什么优缺点?Redis 是单线程的,如何提高多核 CPU 的利用率?Redis 6.0之前为什么不使用多线程?为什么6.0又引入了多线程?⭐⭐⭐

Redis是单线程?Redis的单线程特性有什么优缺点?

Redis 在大多数情况下执行命令时采用单线程模型。也就是说,所有客户端命令的执行都是在一个线程中顺序处理的,这使得它避免了线程间竞争、加锁、上下文切换等开销,从而实现了非常高的响应速度。

优点

  • 简单高效
    单线程模型使得 Redis 的内部数据结构操作不需要复杂的锁机制,从而避免了并发竞争带来的额外开销和死锁问题。
  • 原子性和一致性
    因为所有操作在同一线程中顺序执行,每个命令都是原子的,不需要担心中间状态被其他线程看到,这极大简化了开发和调试。
  • 低延迟
    避免多线程上下文切换和同步开销(无锁),能够在极低延迟下处理大量请求。

缺点

  • 不能利用多核 CPU
    单线程意味着在单个 Redis 实例中,命令的处理只能在一个 CPU 核心上执行。当遇到 CPU 密集型命令时,可能无法充分利用多核机器的优势。

  • 某些操作成为瓶颈

    如果某些命令(例如复杂的 Lua 脚本、大量数据遍历)耗时较长,可能会阻塞其他请求,影响整体响应能力。导致请求队列积压。这可能引发系统响应时间的抖动或延迟。

  • 不适合 CPU 密集型任务

    Redis 本身是为低延迟、高吞吐量的内存数据库设计的,因此对于 CPU 密集型任务,它可能表现不如其他多线程处理模型,特别是在复杂计算的场景下。

Redis的单线程,如何提高多核CPU利用率?

  1. 运行多个 Redis 实例:在多核服务器上,可以启动多个 Redis 实例(每个实例绑定一个 CPU 核心),然后通过分片或集群方式将数据分散到这些实例上,从而充分利用多核性能。
  2. Redis Cluster(分片):通过将数据分布到多个节点上,每个节点运行在不同的 CPU 核心上,从而达到多核并行处理的效果。
  3. Redis 6.0 多线程 I/O 模型:在 Redis 6.0 之前,命令执行始终是单线程的,但网络 I/O(读写 socket)部分是通过 I/O 多路复用实现的,也只能在单个线程中处理。Redis 6.0 引入了多线程 I/O 支持,专门用于处理客户端的网络读写,减少网络 I/O 成为瓶颈,从而提高整体吞吐量。注意:命令执行依然保持单线程设计,以确保数据结构操作的原子性和一致性。
    • 允许将 I/O 操作(如网络请求和响应的读取、写入)分配给多个线程,这样可以让 Redis 在多核 CPU 上更好地利用资源。

配置方式

  • 在 Redis 6.0 及以上版本中,您可以通过 io-threads 配置参数来启用 I/O 线程:

    1
    2
    io-threads 4
    io-threads-do-reads yes
  • 这使得 Redis 可以将网络请求的读写操作分配到 4 个线程上,从而在多核 CPU 上分担负载。

Redis 6.0之前为什么不使用多线程?

  1. 设计初衷:Redis 设计初期采用单线程模型是为了保证简单性、原子性和高效性。单线程可以避免加锁、上下文切换和并发控制复杂性,从而使得代码更简单、更稳定。
  2. 性能足够:对于大多数场景,单线程模型结合高效的数据结构和 I/O 多路复用(epoll 等)已经能提供足够的吞吐量(百万级请求每秒),所以没有迫切需要多线程来分担命令执行的压力。
    • 首先使用Redis时,CPU不是瓶颈,而是受制于内存和网络。
    • Redis为了提高性能,使用Pipeline(批量命令)每秒100万请求,CPU开销也并不高。
    • 单线程时,Redis内部维护成本低,不需考虑多线程安全性,并发读写增加了系统复杂度。多线程涉及到线程切换、加锁和解锁的开销、以及死锁的问题。
    • 惰性Rehash(渐进式Rehash)使Redis阻塞情况很少。
    • 一般的应用单线程Redis性能已能满足。

为什么6.0又引入了多线程?

  1. 瓶颈转移:
    • 性能仍有上限:单线程Redis,把数据放入内存,响应时间在100纳秒,对于比较小的数据包,比如8~10W qps(极限值)。对于超出极限的大项目,需要更大的QPS,则需要IO多线程(内部执行命令仍是单线程)。
    • 网络IO成为瓶颈:随着硬件性能提升和业务场景的发展,网络 I/O 部分开始成为瓶颈,尤其是在高并发情况下,处理成千上万的客户端连接会让单线程的 I/O 处理能力受到限制。
  2. Redis 6.0 引入了多线程 I/O 模型,用于处理网络读写操作(而非命令执行本身),这样可以更充分地利用多核 CPU,提高连接读写的并发性能,降低网络延迟。
    • 目的:解决在极高并发场景下,由于网络 I/O 阻塞导致的性能瓶颈;
    • 设计:保持命令执行的单线程特性以确保原子性,同时采用多线程来分担网络数据的读取和写入任务。

为什么不采用分布式架构Redis?

  • 缺点:服务数量多维护成本太高、Redis命令不适用于数据分区、数据分区无法解决热点的读写问题、数据倾斜问题、重新分配、扩容、缩容等问题。
  • 多线程任务分摊到Redis,同步IO读写负载,抗住更大的并发,利用CPU多核。

Redis的事件驱动模型与IO多路复用、epollpollselect 的区别

Redis 采用单线程事件驱动模型,通过 I/O 多路复用 技术实现高并发处理。尽管单线程,却能高效处理数万级 QPS,其核心在于:

  1. 纯内存操作:数据存储在内存,无磁盘 I/O 瓶颈。
  2. 非阻塞 I/O:避免线程阻塞,最大化 CPU 利用率。
  3. 事件驱动:通过事件循环(Event Loop)监听并分发事件。
  4. 多路复用器:使用 epoll(Linux)或 kqueue(BSD)高效管理大量连接。

epollpollselect 都是 I/O 多路复用 的技术,它们的作用是能够在单线程中同时处理多个 I/O 事件(例如多个网络连接),这些技术常用于提高 I/O 操作的效率,减少阻塞和等待的时间。它们的主要区别在于使用的数据结构和工作原理。

  • **select**:简单、老旧,限制文件描述符数量(通常为 1024),性能随着文件描述符数量增加而下降。
  • **poll**:克服了 select 的文件描述符数量限制,仍然是轮询式的,性能会随着文件描述符数量增多而下降。
  • **epoll**:现代的高效 I/O 多路复用技术,基于内核事件通知,适合高并发应用,性能最佳,支持大量并发连接。

Redis 使用 epoll 的原因

Redis 使用 epoll 来处理多个客户端请求,因为 epoll 相较于 selectpoll 在高并发环境下具有更高的性能,尤其是在连接数非常大的情况下。epoll 的内核事件通知机制和高效的 I/O 多路复用使 Redis 在高并发请求下能够保持低延迟和高吞吐量。

关键区别解析

  • 水平触发(LT):只要 FD 就绪,每次调用都会通知(可能重复触发)。
  • 边缘触发(ET):仅在 FD 状态变化时通知一次,需一次处理完所有数据。
  • epoll 优势
    1. 高效事件通知:仅返回就绪的 FD,无需遍历所有连接。
    2. 无 FD 数量限制:支持百万级并发连接(如 C10K 问题)。
    3. 零拷贝:通过 mmap 减少内核与用户空间数据拷贝。

多路复用技术允许单个线程监听多个文件描述符(File Descriptor, FD,就是内核为了高效管理打开的文件创建的索引)的就绪状态,核心实现包括 selectpollepoll

特性 select poll epoll
数据结构 位数组(fd_set)固定大小 链表(pollfd数组)可以动态增加 红黑树+就绪链表。使用一个内核事件表(内核级别的事件通知机制),采用红黑树和双向链表来存储FD和它们的状态。基于事件驱动的机制,性能较好,尤其在大量连接的情况下
最大FD数 1024(受 FD_SETSIZE 限制) 无限制 无限制
原理 select 会检查所有FD的状态,如果有FD准备好(可读、可写、异常),则返回。它是阻塞的,直到至少一个FD准备好。它通过轮询检查每个FD来判断其状态。 检查FD的状态,如果某个FD准备好,就会返回,并且不会像 select 那样限制最大FD的数量。poll 本身的工作原理与 select 类似,都是轮询检查每个文件描述符。 epoll 提供了 水平触发(Level Triggered)边缘触发(Edge Triggered) 两种工作模式。与 selectpoll 不同,epoll 是基于内核通知的,当文件描述符的状态发生变化时,内核会通知用户程序,而无需在用户空间进行轮询。
效率 O(n) 轮询 O(n) 轮询 O(1) 事件通知
触发方式 水平触发(LT) 水平触发(LT) 支持 LT/ET(边缘触发)
内存拷贝 每次调用需全量拷贝 fd_set 需拷贝 pollfd 数组 内核与用户空间共享内存(mmap)
适用场景 跨平台兼容,小并发 稍高并发,FD数较多 高并发,Linux 专属
缺点 性能瓶颈select 需要每次调用时扫描所有的FD,这意味着当FD数量较多时,性能会迅速下降。 文件描述符限制select 的最大FD数量通常是固定的(在 Linux 上默认是 1024),这限制了 select 可以监视的连接数。 性能瓶颈:尽管 poll 没有 select 的FD限制,但当FD数量增加时,性能仍然会退化。每次调用时都需要检查所有FD。 依然是线性扫描poll 仍然需要轮询所有FD,导致处理效率较低。 内存消耗较大:由于需要维护红黑树和链表等数据结构,epoll 相比 selectpoll 的内存消耗要大一些。 使用较复杂epoll 的接口较为复杂,需要开发者理解事件的触发机制和如何管理事件,编程模型相比 selectpoll 更加复杂。

Redis 事件驱动模型

Redis 采用 Reactor 模式,核心组件包括:

  1. 事件分发器(Dispatcher):通过多路复用器(如 epoll)监听 FD 事件。
  2. 事件处理器(Handler):处理就绪事件(如读、写、异常)。

Reactor 工作流程

  1. 注册事件:将客户端 Socket 注册到多路复用器,监听读事件。

  2. 事件循环

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    while (true) {
    events = epoll_wait(); // 阻塞等待事件就绪
    for (event in events) {
    if (event.isReadable()) {
    readData(); // 读取请求数据
    process(); // 执行 Redis 命令
    addWriteEvent(); // 注册写事件
    }
    if (event.isWritable()) {
    sendResponse(); // 发送响应
    }
    }
    }
  3. 非阻塞处理:每次事件处理快速完成,避免阻塞事件循环。

Proactor 模式对比

  • Reactor:通知应用何时可读写,由应用完成 I/O 操作。
  • Proactor:由内核完成 I/O 操作,通知应用直接处理结果。
  • Redis 选择 Reactor 的原因
    • 实现简单,适配现有 I/O 多路复用 API。
    • 更精细控制 I/O 过程,避免内核异步操作的复杂性。

Java NIO 实现机制

Java NIO 基于类似的 Reactor 模式,核心组件包括:

  1. Selector:多路复用器,对应 epoll/kqueue
  2. Channel:双向数据通道(如 SocketChannelFileChannel)。
  3. Buffer:数据缓冲区,支持非阻塞读写。

Java NIO 示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Selector selector = Selector.open();
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false);
serverChannel.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
selector.select(); // 阻塞等待就绪事件
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iter = keys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
iter.remove();
if (key.isAcceptable()) {
// 处理新连接
SocketChannel client = serverChannel.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
// 读取数据并处理
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
client.read(buffer);
process(buffer);
key.interestOps(SelectionKey.OP_WRITE);
} else if (key.isWritable()) {
// 写入响应
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer response = ByteBuffer.wrap("OK".getBytes());
client.write(response);
client.close();
}
}
}

Java NIO 与 Redis 的异同

特性 Redis Java NIO
线程模型 单线程 Reactor 多线程 Reactor(如 Netty)
多路复用器 epoll/kqueue Selector(依赖 OS 实现)
事件处理 单线程顺序执行所有命令 通常使用线程池处理业务逻辑
性能优化 无锁设计,纯内存操作 需避免 Buffer 拷贝与线程竞争

生产环境优化实践

  1. Redis 配置调优

    1
    2
    # 使用 epoll(Linux)
    io-threads 4 # Redis 6.0+ 支持多线程处理 I/O(非命令执行)
  2. Java NIO 优化

    • 使用 Direct Buffer 减少内存拷贝。
    • 分离 I/O 线程与业务线程(如 Netty 的 Boss/Worker 线程组)。
  3. 边缘触发注意事项

    • 必须一次读取全部数据,否则可能丢失事件通知。
    • 结合非阻塞 I/O 循环读取,直到 EAGAIN 错误。

问:Redis有哪些Java客户端?其中Jedis 与 Redisson 对比优缺点?⭐⭐

客户端包括:Redisson、Jedis、lettuce 等,官方推荐使用 Redisson。

  • Jedis 是 Redis 的 Java 实现的客户端,其 API 提供了比较全面的 Redis 命令的支持;
  • Redisson实现了分布式和可扩展的 Java 数据结构,和 Jedis 相比,功能较为简单,不支持字符串操作,不支持排序、事务、管道、分区等 Redis 特性。Redisson 的宗旨是促进使用者对 Redis 的关注分离,从而让使用者能够将精力更集中地放在处理业务逻辑上。
  1. Jedis
    • Jedis 是最常用的 Redis 客户端之一,基于 阻塞 I/O 模型,使用较为简单,支持 Redis 提供的大部分命令。
  2. Lettuce
    • Lettuce 是一个基于 非阻塞 I/O 的 Redis 客户端,支持同步和异步操作,适合高并发和异步应用场景。
  3. Redisson
    • Redisson 是一个功能强大的 Redis 客户端,基于 Lettuce,提供了很多高级功能,比如分布式锁、分布式集合、分布式队列等,适合对 Redis 的高级特性有需求的应用。
  4. Spring Data Redis
    • 作为 Spring 框架的一部分,Spring Data Redis 提供了对 Redis 的集成,支持通过 Spring 管理 Redis 客户端,并提供模板式的 API 以便与 Spring 生态系统兼容。

总结:

  • Jedis:适用于简单、高性能的应用场景,尤其在低并发和对 Redis 的使用需求较简单时,Jedis 作为经典客户端是一个非常不错的选择。它简单易用,性能优秀,但它使用的是阻塞 I/O,不适合高并发或需要异步操作的场景。
  • Redisson:适用于需要 Redis 高级特性或分布式架构的场景,如分布式锁、分布式集合等。Redisson 提供了更多的功能和灵活性,特别适合处理高并发、分布式系统中的复杂任务,但它的学习曲线较陡,性能相对略逊于 Jedis。

Jedis 与 Redisson 的对比

  1. Jedis
  • 特性
    • Jedis 是最经典的 Redis Java 客户端之一,主要基于 阻塞 I/O 模型,简单且易于使用,适合大部分 Redis 基本功能的应用。
    • 它提供了同步的 API,操作简单直观,且支持大部分 Redis 命令。
  • 优点
    • 简单易用:Jedis 提供了非常简单的 API,开发者可以快速上手,适合需要快速集成 Redis 的应用。
    • 广泛使用:Jedis 已经被广泛使用,在社区中有大量的支持和资源,遇到问题容易找到解决方案。
    • 高性能:Jedis 在单线程场景下性能表现非常好,特别适合低延迟、高吞吐量的场景。
  • 缺点
    • 阻塞 I/O 模型:Jedis 默认使用阻塞 I/O 模型,因此它的性能会受到线程阻塞的影响,在高并发的场景下可能出现性能瓶颈。每个连接在同一时刻只能处理一个请求,这可能导致资源利用率低。
    • 不支持异步操作:Jedis 本身没有内建异步的支持,虽然可以通过多线程或连接池来实现,但没有原生的异步支持。
    • 线程安全问题:Jedis 不是线程安全的,需要小心管理连接池,避免多个线程共享同一个 Jedis 实例。
  • 适用场景
    • 适合应用规模较小,连接请求量不太大的应用,特别是需要快速集成 Redis 的项目。
  1. Redisson
  • 特性
    • Redisson 是基于 Lettuce 的 Redis 客户端,支持 非阻塞 I/O 和异步操作。它提供了比 Jedis 更强大的功能,支持分布式数据结构(如分布式锁、队列、集合等)、分布式服务(如分布式 Executor)、缓存等高级特性。
    • Redisson 的 API 设计相对较复杂,但提供了更多的功能,可以处理更复杂的分布式任务。
  • 优点
    • 支持异步操作:Redisson 提供了丰富的异步 API,支持在高并发场景下异步执行 Redis 命令,适合异步编程模型。
    • 丰富的分布式功能:Redisson 提供了分布式锁(RLock)、分布式集合(RSet)、分布式队列(RQueue)、分布式 Map(RMap)等分布式数据结构,非常适合用于构建分布式应用。
    • 高层次抽象:Redisson 提供了比 Jedis 更高层次的抽象,例如它可以轻松地实现分布式锁、分布式延迟队列等复杂的分布式操作。
    • 支持同步、异步、反应式编程:Redisson 支持多种编程模型,包括同步、异步和基于响应式编程(Reactor)的编程模式,能够灵活应对不同场景。
    • 支持多种客户端类型:Redisson 支持 Redis 集群、哨兵模式以及单机模式,并且支持连接池。
  • 缺点
    • 功能较多,学习成本高:由于 Redisson 提供了丰富的功能,可能对不需要这些高级特性的开发者来说,使用起来会有一定的学习成本。
    • 性能开销:由于 Redisson 提供了大量的分布式数据结构和服务,它的性能可能略逊色于 Jedis,尤其是在没有使用到这些高级功能时。
    • 较大的依赖:Redisson 引入了更多的依赖,导致包体积较大,可能在一些轻量级应用中不适用。
  • 适用场景
    • 适合需要 Redis 高级特性(如分布式锁、分布式集合等)的应用,尤其是需要分布式解决方案或异步操作的场景。
    • 在高并发、大规模分布式系统中非常有用,特别是需要 Redis 实现分布式协调任务的场景。

对比表格

特性 Jedis Redisson
连接池支持 支持连接池 支持连接池
线程安全 需要显式使用连接池,非线程安全 线程安全
I/O 模型 阻塞 I/O 非阻塞 I/O
异步支持 不原生支持 支持异步操作,提供异步 API
分布式特性 支持分布式锁、分布式数据结构等高级特性
学习曲线 低,使用简单 高,功能丰富
性能 高效(适合简单的应用) 相对较慢(功能多,性能略逊)
适用场景 适合简单、高性能、低并发的场景 适合分布式系统,特别是需要高级特性的场景

问:1亿个key,其中有10w个key是以某个固定的已知的前缀开头的,如何将它们全部找出来?⭐⭐⭐

使用keys指令可以扫出指定模式的key列表。 如果这个redis正在给线上的业务提供服务,那使用keys指令会有什么问题? redis的单线程的。keys指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用scan指令,scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以了 ,但是整体所花费的时间会比直接用keys指令长。

  • 推荐使用 SCAN 命令,因为它是增量式扫描,能够避免 Redis 阻塞,性能更好,适合大规模的 key 查找。相比 KEYSSCAN 更加高效,尤其在高并发环境中。
  • 避免使用 KEYS 命令,除非是在开发环境中对少量数据进行调试。
  • 如果你有 Redis 集群,并且需要跨节点查询,可以使用 Redis 集群的 SCAN 来实现并行查询。
  1. 使用 KEYS 命令(不推荐)

    KEYS 命令会一次性返回所有匹配的 key,假如你的数据库中有 1 亿个 key,那么使用 KEYS 命令会导致 Redis 阻塞,直到返回所有结果。这种做法非常低效,尤其是在生产环境中不推荐使用,因为它会对 Redis 性能产生严重影响,可能导致应用卡死。

    代码示例:

    1
    2
    keys = r.keys('prefix:*')
    print(f"Found {len(keys)} keys with prefix 'prefix:'")

    缺点:

    • 性能差:对于 1 亿个 key,KEYS 命令会一次性将所有 key 加载到内存,可能导致 Redis 性能下降甚至阻塞。
    • 阻塞 RedisKEYS 命令会阻塞 Redis,直到命令完成,这可能会影响到其他操作。
  2. 使用 SCAN 命令

    SCAN 命令是 Redis 提供的用于遍历 key 的命令。与 KEYS 命令相比,SCAN 更加高效,因为它是增量迭代的方式,不会一次性阻塞 Redis 服务。

    • SCAN 命令不会一次性返回所有结果,而是以游标方式增量遍历整个 keyspace,不会阻塞 Redis 实例。
    • 利用 MATCH 参数可以只匹配特定前缀的 key,如 MATCH prefix*

    循环遍历

    • 从初始游标 0 开始,每次调用 SCAN 命令,获取部分匹配的 key 以及新的游标。
    • 当 SCAN 命令返回的游标再次为 0 时,说明遍历完成。

    收集结果

    • 将每次返回的匹配 key 收集起来,直到得到所需的 10 万个 key(或者遍历完所有 key)。

    性能优化

    • 使用 Pipeline 技术(在支持的客户端中),减少网络往返次数,提高 SCAN 的遍历效率。
    • 如果 key 数量非常多,可以考虑将收集到的 key 存入其他存储系统中做后续处理。

    代码示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    import redis.clients.jedis.Jedis;
    import redis.clients.jedis.ScanParams;
    import redis.clients.jedis.ScanResult;
    import java.util.ArrayList;
    import java.util.List;

    public class RedisScanExample {
    public static List<String> scanKeys(Jedis jedis, String prefix, int count) {
    String cursor = "0";
    List<String> keys = new ArrayList<>();
    ScanParams params = new ScanParams().match(prefix + "*").count(1000);
    do {
    ScanResult<String> scanResult = jedis.scan(cursor, params);
    keys.addAll(scanResult.getResult());
    cursor = scanResult.getCursor();
    } while (!cursor.equals("0") && keys.size() < count);
    return keys.subList(0, Math.min(keys.size(), count));
    }

    public static void main(String[] args) {
    Jedis jedis = new Jedis("127.0.0.1", 6379);
    List<String> keys = scanKeys(jedis, "prefix:", 100000);
    System.out.println("匹配到的 key 数量:" + keys.size());
    jedis.close();
    }
    }

    解释:

    • SCAN 命令的参数:
      • cursor:游标,用来标识当前遍历的进度。初次调用时,游标设置为 0
      • match:用于匹配 key 的模式,支持通配符 *
      • count:每次返回的结果数量,可以调整批次大小来控制内存使用和遍历速度。
    • SCAN 会返回两部分内容:
      • 新的游标 cursor,你将其传递给下次的 SCAN 调用。
      • partial_keys,这次返回的匹配的 key。

    优点:

    • 非阻塞SCAN 是增量遍历,不会一次性阻塞 Redis。
    • 内存友好SCAN 会分批返回,不会消耗过多的内存。
    • 高效:对于大规模数据,SCANKEYS 更高效。

    缺点:

    • 不保证顺序SCAN 是增量扫描,不能保证返回结果的顺序,如果需要保证顺序,你可能需要手动排序。
    • 可能重复扫描SCAN 是增量式的,可能会在不同的迭代中扫描到相同的 key。为了避免这种情况,需要去重(可以通过集合 set 来存储)。
  3. 使用 Redis 集群

    如果你的 Redis 数据量很大,并且分布在多个 Redis 实例中,可以考虑使用 Redis 集群 来分布式存储 key。这种方法的优势是将数据分散到多个节点,减少单个节点的负担。然而,Redis 集群并不支持全局的 SCAN 命令,需要在多个节点上分别进行扫描。你需要通过集群的方式并行查询各个节点,找到匹配的 key。

    代码示例:

    1. 获取所有节点:通过 jedisCluster.getClusterNodes() 得到集群中所有节点的连接池,然后遍历这些节点。为避免重复扫描(因为从节点的数据和主节点一致),只对主节点进行扫描。可通过 jedis.info("replication") 判断节点角色。
    2. SCAN 操作:在每个 master 节点上,使用 SCAN 命令(通过 Jedis 的 scan 方法)增量遍历 keyspace,利用 MATCH 参数只匹配特定前缀的 key。由于 SCAN 返回的游标不为 “0” 时表示未遍历完,故需要循环直到游标返回 “0”。
    3. 合并结果:将所有 master 节点扫描到的 key 合并到一个 List 中返回。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    public class ClusterScanExample {

    /**
    * 对 Redis 集群中所有 master 节点执行 SCAN,并返回匹配指定前缀的 key 列表。
    *
    * @param jedisCluster JedisCluster 实例
    * @param prefix 要匹配的 key 前缀,例如 "prefix:"
    * @param count 每次 SCAN 时返回的数量提示
    * @return 匹配的 key 集合
    */
    public static List<String> scanClusterKeys(JedisCluster jedisCluster, String prefix, int count) {
    List<String> allKeys = new ArrayList<>();
    String matchPattern = prefix + "*";
    ScanParams scanParams = new ScanParams().match(matchPattern).count(count);

    // 从 JedisCluster 获取所有节点的连接
    Map<String, JedisPool> nodeMap = jedisCluster.getClusterNodes();
    for (Map.Entry<String, JedisPool> entry : nodeMap.entrySet()) {
    // 只扫描 master 节点,避免重复扫描从节点
    try (Jedis jedis = entry.getValue().getResource()) {
    String info = jedis.info("replication");
    if (info.contains("role:slave")) {
    continue; // 如果当前节点是从节点,则跳过
    }
    String cursor = "0";
    do {
    ScanResult<String> scanResult = jedis.scan(cursor, scanParams);
    allKeys.addAll(scanResult.getResult());
    cursor = scanResult.getCursor();
    } while (!cursor.equals("0"));
    } catch (Exception e) {
    // 处理个别节点异常,不影响整体扫描
    e.printStackTrace();
    }
    }
    return allKeys;
    }

    public static void main(String[] args) {
    // 配置 Redis 集群节点地址
    Set<HostAndPort> clusterNodes = new HashSet<>();
    clusterNodes.add(new HostAndPort("127.0.0.1", 7000));
    clusterNodes.add(new HostAndPort("127.0.0.1", 7001));
    clusterNodes.add(new HostAndPort("127.0.0.1", 7002));
    // 可根据实际情况添加更多节点

    // 创建 JedisCluster 实例
    JedisCluster jedisCluster = new JedisCluster(clusterNodes);

    // 调用 scanClusterKeys 方法,查找以 "prefix:" 开头的 key
    List<String> keys = scanClusterKeys(jedisCluster, "prefix:", 1000);
    System.out.println("匹配到的 key 数量:" + keys.size());
    for (int i = 0; i < Math.min(10, keys.size()); i++) {
    System.out.println(keys.get(i));
    }

    try {
    jedisCluster.close();
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
    }

    优点:

    • 分布式:可以将数据分散到多个节点上,减少单个节点的压力。
    • 高可用性:Redis 集群通过分片保证了高可用性。

    缺点:

    • 复杂性:需要配置 Redis 集群,并通过客户端执行多节点的 SCAN
    • 开发成本:使用 Redis 集群会增加开发和维护的复杂性。
  4. 通过 Redis 分片和前缀方案优化

    在一些极端的场景下,可以考虑将 key 设计成多前缀的方式(例如,基于用户 ID 或其他分片键),这样可以减少每次查询时的 key 数量,从而提高查询效率。这种方法适用于你在设计时就有考虑到如何分布 key 的情况。

问:讲一下平时使用Redis遇到过哪些问题?如何解决的?tododododod


二. 底层数据结构

问:Redis支持哪些数据结构?Redis各种数据结构的使用场景、内部编码及底层实现、优缺点?⭐⭐⭐?

Redis支持:string(字符串)、hash(哈希)、list(列表)、set(集合)、zset(有序集合)、Bitmaps(位图)、HyperLogLog(做基数统计)、GEO(地理信息定位)等多种数据结构和算法。

String
  • 【应用场景】:

    • 缓存:Redis作为缓存层,MySql作为存储层,绝大部分数据都是从缓存中获取。缓存能起到加速读写和降低后端压力的作用。首先从Redis获取用户信息,若没有从Redis获取到用户信息,则需要从MySql中获取,并将结果写入Redis,并添加1小时过期时间。

    • 计数器

      1
      2
      3
      4
      5
      // 用户观看后增加一次视频播放数,当然计数系统还有防作弊、不同维度计数、数据持久化到底层数据源等
      long incrVideoCounter(long id){
      key = "video:playCount:" + id;
      return redis.incr(key);
      }
    • 共享Session:一个分布式Web服务将用户的Session信息保存在各自的服务器中,这样会造成一个问题,出于负载均衡的考虑,分布式服务器会将用户的访问均衡到不同的服务器上,用户每刷新一次访问可能会发现要重新登录,这是用户无法容忍的。为了解决这个问题,可以使用Redis将Session进行集中管理,每次用户更新或查询登录信息都直接从Redis获取。

    • 限速:很多应用出于安全的考虑,会在每次登录时让用户输入手机验证码,以确认是否本人操作。但为了使短信接口不被频繁访问,会限制用户每分钟获取验证码的频率

      1
      2
      3
      4
      5
      6
      7
      8
      9
      phoneNum = "138xxxxxxxx";
      key = "shortMsg:limit:" + phoneNum;
      // SET key value EX 60 NX 过期时间60秒+NX只做新增
      isExists = redis.set(key.1, "EX 60", "NX");
      if(isExists != null || redis.incr(key) <= 5){
      // 通过
      } else {
      // 限速
      }
    • 分布式锁

    • 分布式ID

  • 【内部编码】:最大512M

    • int:8个字节的长整型。
    • embstr:小于等于39个字节的字符串。
    • raw:大于39个字节的字符串。
  • 【底层实现】:Simple dynamic string(SDS)的数据结构:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    struct sdshdr{
    //记录buf数组中已使用字节的数量
    //等于SDS保存字符串的长度
    int len;
    //记录buf数组中未使用字节的数量
    int free
    //字节数组,用于保存字符串
    char buf[];
    }

    优点(都是C字符串的缺点弥补):

    • 不会出现字符串变更造成的内存溢出问题。
    • 获取字符串长度时间复杂度为1,C字符串没有长度信息(以 \0 来明确表示结尾),必须遍历字符串,时间复杂度为O(n)。
    • 空间预分配, 惰性空间释放free字段,会默认留够一定的空间防止多次重分配内存。
Hash

  • 【应用场景】:
    • 保存结构体信息,可部分获取不用序列化所有字段。如保存用户类,原生字符串每个属性都要对应一个键,序列化字符串需要序列与反序列的开销,而哈希类型只须一个键即可存放一个用户信息。
    • 保存一些键值对。
  • 【内部编码】:
    • ziplist(压缩列表):当哈希类型元素个数小于 hash-max-ziplist-entries 配置(默认为512个),同时所有值都小于 hash-max-ziplist-value 配置(默认为64字节)时,Redis使用ziplist作为哈希的内部实现,ziplist使用更加紧凑的结构实现多个元素的连续存储,所以相比hashtable会更节省空间。
    • hashtable(哈希表):当哈希类型无法满足ziplist的条件时,Redis会使用hashtable作为哈希的内部实现,这种情况ziplist的读写效率会下降,而hashtable读写时间复杂度为 O(1)
  • 【底层实现】:在数组+链表的基础上,进行了一些rehash优化。
    • Reids的Hash采用链地址法来处理冲突,然后它没有使用红黑树优化
    • 哈希表节点采用单链表结构
    • rehash优化 (采用分而治之的思想,将庞大的迁移工作量划分到每一次CURD中,避免了服务繁忙)
List
  • 【应用场景】:

    • 消息队列lpush + brpop 命令组合即可实现阻塞队列

    • 文章列表:缓存比如twitter的关注列表,粉丝列表等

    • 经典口诀:

      1
      2
      3
      4
      lpush + lpop = Stack(栈)
      lpush + rpop = Queue(队列)
      lpush + ltrim = Capped Collection(有限集合)
      lpush + brpop = Message Queue(消息队列)
  • 【内部编码】:

    • ziplist:当列表的元素个数小于 list-max-ziplist-entries 配置(默认512个),同时列表中每个元素的值都小于 list-max-ziplist-value 配置(默认64字节),Redis会选用ziplist来作为列表的内部实现来减少内存的使用。
    • linkedlist(链表):当列表类型无法满足ziplist的条件时,Redis会使用链表实现。
  • 【底层实现】:list的实现为一个双向链表,即可以支持反向查找和遍历。

Set
  • 【应用场景】:
    • 去重的场景

    • 求交集(sinter)、并集(sunion)、差集(sdiff)

    • 标签:实现如共同关注、共同喜好、二度好友等功能。

    • 集合类型一般有这几种应用场景:

      • sadd = Tagging(标签)

      • spop/srandmember = Random item(队列)

      • sadd + sinter = Social Graph(社交需求)

  • 【内部编码】:
    • intset(整数集合):当集合中的元素都是整数且元素个数小于 set-max-intset-entries 配置(默认512个)时,Redis会选用intset来作为集合内部实现,从而减少内存的使用。
    • hashtable(哈希表):集合无法满足intset的条件时,选用hashtable作为集合内部实现。
  • 【底层实现】:是一个value为null的HashMap,实际就是通过计算hash的方式来快速排重的,这也是set能提供判断一个成员是否在集合内的原因。
ZSet
  • 【应用场景】:
    • 排行榜
    • 实现延时队列
  • 【内部编码】:
    • ziplist(压缩列表):当有序集合的元素个数小于 zset-max-ziplist-entries 配置(默认128个),同时列表中每个元素的值都小于 zset-max-ziplist-value 配置(默认64字节),Redis会选用ziplist来作为列表的内部实现来减少内存的使用。
    • skiplist(跳跃表):当无法满足ziplist的条件时,Redis选用skiplist作为内部实现,因为此时ziplist的读写效率会下降。
  • 【底层实现】:内部使用HashMap跳跃表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。
    • 跳表:每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的
  1. Bitmap(位图) 布隆过滤器

应用场景:

  • 用于统计某些事件的发生情况(如每天是否登录、在线状态统计)。
  • 用于处理大规模的布尔数据,如检测某个值是否存在。

内部编码及底层实现:

  • 位数组(Bit Array):Redis 使用一个 bit 数组来存储每个元素的布尔状态。

优缺点:

  • 优点:

    • 内存占用极小,适用于存储大量布尔值。
    • 可以通过位运算(如 BITSETBITCOUNT)高效地处理布尔数据。
  • 缺点:

    • 只适合存储布尔类型的数据,灵活性较低。
  1. HyperLogLog(超日志)

应用场景:

  • 用于近似计算基数(如统计网站访问的唯一用户数量、计算商品的独立购买人数等)。
  • 在需要处理大规模去重数据时,节省内存。

内部编码及底层实现:

  • 概率算法:HyperLogLog 使用概率算法来估算基数,它通过哈希函数对输入进行映射,并计算最大的前导零位数来近似估计基数。

优缺点:

  • 优点:

    • 内存占用极小,通常只需要 12 KB 存储上百万个元素的基数估计。
    • 对于大规模的数据集,能够提供较为准确的基数估算。
  • 缺点:

    • 返回的结果是近似值,精度无法保证。
  • 不支持具体的元素值,只能估算数量。

  1. Geospatial(地理空间)

应用场景:

  • 实现基于位置的服务(例如,查找附近的商店、用户等)。
  • 用于计算地理位置的距离、排序等操作。

内部编码及底层实现:

  • 有序集合(Sorted Set):Redis 使用有序集合来存储地理位置信息,元素的分数表示经纬度的哈希值。

优缺点:

  • 优点:

    • 支持高效的地理位置查询和计算,如查找某个位置附近的其他位置。
    • 支持范围查询、距离计算等常见地理信息处理操作。
  • 缺点:

    • 只能存储坐标点,适合用于位置查询,其他类型的空间查询不适用。

问:Redis中的字符串类型是怎么实现的?Redis 如何存储一个 String 的?⭐⭐⭐?

通过一个抽象数据结构SDS来实现,包含三个重要属性:已使用字节数量,未使用字节数量,字节数组。存储时保留了C语言以 \0 为结尾的习惯以便能兼容C语言的函数。分配空间时,当长度小于1MB,会根据已使用空间分配相等大小的未使用空间以备扩展,大于1MB时只会分配1MB。

  • 假设要存储一个字符串 hello ,SDS会记录已使用字节长度为5,并分配相同大小的未使用字节空间(长度小于1MB时)所以也为5,并保留 \0 作为字符串结尾但不计入空间占用,所以此时SDS分配空间为11。

Simple dynamic string(SDS)的数据结构:

1
2
3
4
5
6
7
8
9
struct sdshdr{
//记录buf数组中已使用字节的数量
//等于SDS保存字符串的长度
int len;
//记录buf数组中未使用字节的数量
int free
//字节数组,用于保存字符串
char buf[];
}

优点(都是C字符串的缺点弥补):

  • 不会出现字符串变更造成的内存溢出问题。
  • 获取字符串长度时间复杂度为1,C字符串没有长度信息(以 \0 来明确表示结尾),必须遍历字符串,时间复杂度为O(n)。
  • 空间预分配, 惰性空间释放free字段,会默认留够一定的空间防止多次重分配内存。

Redis 字符串的底层实现:SDS(Simple Dynamic String)

Redis 使用 SDS(Simple Dynamic String) 作为字符串类型的底层数据结构。SDS 是一个自定义的数据结构,它比传统的 C 字符串更高效,具有以下优势:

  • O(1) 的长度计算:传统的 C 字符串需要每次计算字符串的长度,Redis 的 SDS 结构直接存储了字符串的长度,能够 O(1) 获取字符串的长度。
  • 自动扩展:SDS 在动态扩展时,会预留一定的空间,避免每次增长时都进行内存重新分配,减少了内存碎片。
  • 更高效的内存管理:SDS 还维护了额外的信息(如已使用的空间和剩余的空间),使得内存的分配和释放更加高效。避免内存碎片:SDS 预留一定的空间,减少了内存碎片问题。
  • 支持二进制安全:SDS 支持二进制数据,因此不仅支持文本字符串,也能存储任何二进制数据(例如图片、音频等)。

SDS 数据结构,包含了以下几个部分:

  • len:当前字符串的实际长度。当前使用的字符数(不包含结束符)。
  • alloc:分配的内存大小(即 SDS 的总大小)。通常会比实际使用的内存大,以优化内存扩展
  • flags:标识符,表示该字符串的数据类型。例如,STRING 表示普通字符串,RAW 表示原始数据等。
  • buf:实际存储字符串内容的字符数组(或二进制数据)。

SDS 结构的简化示意图:

1
2
3
+-----+-----+-----+-----+-----+-----+
| len | alloc | flags | buf |
+-----+-----+-----+-----+-----+-----+

SDS 的优势:

Redis 根据字符串的长度选择不同的编码方式来优化内存使用。

  • INT(整数编码):如果存储的是一个数字(如整数),Redis 会将其存储为整数值,并使用 INT 类型进行优化,避免使用额外的内存开销。

    • 对于一个小的整数(例如 10、20 等),Redis 会直接将其作为整数存储,而不使用 SDS 编码。
    • 整数编码通常用于存储小于 512 字节的整数,采用内存节省的方式。
  • embstr:小于等于39个字节的字符串。

  • RAW(原始编码):对于其他类型的字符串,Redis 会使用 RAW 编码,表示数据是一个普通的字符串类型。这个编码会使用 SDS 数据结构。

字符串类型的常见操作

  • SET:用于设置字符串值。
  • GET:获取字符串值。
  • INCR / DECR:对数字类型的字符串进行自增或自减操作。
  • MGET:同时获取多个键的值。
  • APPEND:将字符串追加到现有的值上。
  • DEL:删除指定的字符串。

问:SDS相比原生的char[]有什么优点?知道动态字符串sds的优缺点么?⭐⭐⭐?

  • 原生C字符串获取字符串长度需要遍历整个字符串,复杂度为O(n),而SDS则为O(1)。
  • 因为有预分配的空间以及惰性释放空间,所以可以避免重复内存分配
  • 因为有了长度控制,所以避免了C字符串常见的内存溢出问题

C字符串对于字符编码有要求,对于一些如图片、音频等格式的二进制编码未必能支持,而SDS虽然保留了空字符结尾但并不以它来判断字符串结尾,所以可以安全的存储一些特殊格式要求的二进制数据。

SDS(Simple Dynamic String)相比于原生的 char[] 的优点

Redis 使用的 SDS 是一种比传统的 char[] 更高效的动态字符串表示方式。以下是 SDS 相对于原生的 char[] 的一些优点:

  1. O(1) 的字符串长度计算
  • **原生 char[]**:在 C 语言中,字符串是由 char[] 数组表示的,通常以空字符 '\0' 结束。每次获取字符串的长度时,必须遍历整个数组,计算字符串的字符数,时间复杂度是 O(n),其中 n 是字符串的长度。
  • SDS:在 SDS 中,长度信息被直接存储在结构体的 len 字段中,因此每次获取字符串长度时,时间复杂度是 O(1),直接返回 len 的值。
  1. 更好的内存管理
  • **原生 char[]**:在 C 语言中,char[] 数组的大小是固定的,无法灵活扩展。如果需要调整数组的大小(如插入字符或删除字符),可能需要分配一个新的更大的数组,并进行拷贝,造成性能损失。而且,如果字符串长度大于分配的大小,通常需要重新分配并拷贝数据。
  • SDS:SDS 采用了动态扩展机制,当字符串长度增加时,会自动分配更大的空间,并且会预留一定的额外空间,以减少频繁的内存重新分配。当字符串长度减少时,SDS 可以缩小内存,避免内存浪费。此外,SDS 内部还记录了实际使用的内存大小和剩余的空间,避免了内存碎片的产生。
  1. 支持二进制安全
  • **原生 char[]**:传统的 C 字符串是基于 null 结束符(\0)的,因此不能正确处理包含空字符的二进制数据。char[] 只能处理文本数据。
  • SDS:SDS 允许存储任意类型的数据,包括文本和二进制数据。SDS 可以支持包含空字节(0x00)的数据,因为它并不依赖于终止符,而是通过长度来标记字符串的结束。
  1. 避免内存碎片
  • **原生 char[]**:如果在动态扩展字符串时没有适当的内存管理,可能会产生内存碎片。每次调整大小时,原生数组可能会经历内存的多次分配和释放,造成内存碎片。
  • SDS:SDS 在内存分配时,会预留一些空间,减少了频繁扩展时的内存分配开销。这样做不仅减少了内存碎片,还提高了内存使用效率。
  1. 提高了性能
  • **原生 char[]**:传统 char[] 数组没有预留空间,因此每次进行扩展时都需要重新分配内存,并将数据复制到新的内存区域。这会增加额外的性能开销。
  • SDS:SDS 通过预分配空间,减少了扩展时的性能开销。SDS 还使用了增量扩展的策略(通常是原大小的两倍),避免了频繁的内存分配和数据复制,从而提高了性能。

缺点:

  1. 内存额外开销
    • 每个 SDS 对象除了存储字符串本身外,还需要存储一些额外的元数据(如 lenallocflags 等),这些元数据会带来一定的内存开销。对于短小的字符串,这些开销可能会显得比较大。
    • 比如,一个简单的字符串需要在原有的字符数组外再多存储 4 字节的长度、4 字节的内存分配大小和一些标志位,这对于非常小的字符串来说,可能不是很划算。
  2. 动态扩展的复杂性
    • 当字符串的长度发生变化时,SDS 需要进行内存分配和数据拷贝,尽管这个过程已经优化为按需扩展,但在极端情况下(如大规模的字符串操作),仍然可能影响性能。
  3. 内存碎片问题
    • 虽然 SDS 使用了预留空间的方式来减少内存碎片,但仍然有可能在长期操作中产生一定的内存碎片,特别是在内存需求频繁波动的情况下。
  4. 适用于特定场景
    • 对于一些非常小的数据,SDS 可能带来的内存开销比直接使用 C 字符串(char[])更大,因此在某些小型、资源紧张的嵌入式系统中,可能不如传统的 char[] 更合适。

问:Redis的hash底层如何实现?rehash做了哪些优化?⭐⭐⭐?

Redis 的 hash 数据有两种底层编码实现:ziplist和hashtable。

字典由 dict.h 文件定义,其中ht是哈希表结构,0是正常使用的表,1则是渐进rehash时用来转移0的节点。

rehashidx用来在rehash过程记录正在转移的键,平时为-1。

dictht即哈希表结构,内部包含一个 dictEntry **table table即dictEntry二维数组,为了哈希冲突时能够串联所有冲突的节点。size即哈希表最大可寻址大小,即一维数组最大长度。dictEntry则是一个键值对结构。

在数组+链表的基础上,进行了一些rehash优化。

  • Redis的Hash采用链地址法来处理冲突,然后它没有使用红黑树优化
  • 哈希表节点采用单链表结构
  • rehash优化 (采用分而治之的思想,将庞大的迁移工作量划分到每一次CURD中,避免了服务繁忙)

一、Hash底层结构

Redis的Hash类型有两种底层编码,根据数据量动态切换以平衡内存与性能:

  1. ziplist(压缩列表)

    • 触发条件:同时满足以下两个条件时使用ziplist:
      • Hash中所有键值对的键和值的字符串长度 **均小于 hash-max-ziplist-value**(默认64字节)。
      • Hash中的键值对数量 **小于 hash-max-ziplist-entries**(默认512个)。
    • 特点:内存紧凑,通过连续内存块存储键值对,适合小数据量场景。
  2. hashtable(哈希表)

    • 触发条件:不满足ziplist条件时自动转换为hashtable。
    • 结构定义
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      // 哈希表结构(dict.h)
      typedef struct dictht {
      dictEntry **table; // 哈希桶数组(二维数组)
      unsigned long size; // 哈希表容量(数组长度,总为2^n)
      unsigned long sizemask; // 哈希掩码(= size-1,用于计算索引)
      unsigned long used; // 已存储的键值对数量
      } dictht;

      // 哈希表节点(链地址法解决冲突)
      typedef struct dictEntry {
      void *key; // 键
      union { void *val; ... } v; // 值(支持多种类型)
      struct dictEntry *next; // 单链表下一节点指针
      } dictEntry;

      // 字典(封装哈希表)
      typedef struct dict {
      dictht ht[2]; // 两个哈希表(ht[0]主表,ht[1]用于rehash)
      long rehashidx; // rehash进度(-1表示未进行)
      // ... 其他字段(如类型特定函数)
      } dict;
    • 核心机制
      • 链地址法:哈希冲突时,通过dictEntry->next形成单链表(Redis未采用红黑树优化,因单链表在内存操作中更高效)。
      • 哈希函数:基于MurmurHash2算法,确保键分布均匀。

二、Rehash优化:渐进式迁移

传统哈希表扩容时需一次性迁移所有数据,可能因数据量大导致长时间阻塞。Redis采用渐进式Rehash优化:将迁移分摊到每次CURD操作中,避免集中式迁移的性能抖动。

渐进式 rehash:
Redis 不会一次性完成所有数据的迁移,而是采用“分步 rehash”的方式。每次对字典的操作(如添加、查找、删除)时,都会顺带执行少量的 rehash 工作(比如迁移一到几个槽(bucket)的数据)。这样可以将 rehash 的开销分摊到每次普通操作中,避免长时间阻塞主线程。

双表机制:
在 rehash 期间,字典中同时存在两个哈希表(ht[0] 和 ht[1])。查询时,Redis 会同时在两个表中查找数据。这样既保证了数据的正确性,又允许 rehash 工作在后台逐步进行。

平滑迁移:
渐进式 rehash 保证了在系统高并发的情况下,rehash 操作不会造成明显的延迟波动,从而提高系统整体响应性能。

1. 触发Rehash的条件
  • 扩容:当负载因子 used/size ≥ 1 且允许扩容(dict_can_resize=1,或used/size > 5强制扩容)。
  • 缩容:当负载因子 used/size < 0.1,触发缩容以减少内存占用。
2. 渐进式Rehash流程
  1. 准备阶段

    • 分配ht[1],容量为第一个大于等于ht[0].used*2的2^n(扩容)或第一个大于等于ht[0].used的2^n(缩容)。
    • 设置rehashidx=0,标志开始迁移。
  2. 数据迁移阶段

    • 每次操作触发迁移:在客户端执行增删改查操作时,Redis额外迁移ht[0].table[rehashidx]对应的哈希桶(即该索引下的所有链表节点)。
    • 定时任务辅助迁移:若客户端长时间无请求,Redis通过定时任务迁移少量数据(每毫秒迁移1个桶)。
    • 迁移完成后,rehashidx++,直至所有桶迁移完毕。
  3. 完成阶段

    • 释放ht[0],将ht[1]设为新的ht[0]
    • 重置ht[1]为空白表,rehashidx=-1
3. 操作兼容性
  • 写操作:新数据直接写入ht[1]
  • 读操作:同时查询ht[0]ht[1],优先返回ht[0]中的数据。
  • 删除/更新:需同时在ht[0]ht[1]中处理。

对比传统Rehash

场景 传统Rehash Redis渐进式Rehash
迁移方式 一次性迁移所有数据 分批次迁移,每次迁移一个哈希桶
阻塞时间 长(数据量越大阻塞越久) 极短(每次迁移耗时可控)
内存占用 临时需要双倍内存 逐步释放旧表内存,内存压力平滑过渡
适用场景 低并发、小数据量 高并发、大数据量,要求低延迟和高可用性

问:zset底层怎么实现的?zset为什么使用跳跃链表而不用红黑树实现?⭐⭐⭐?

  1. skiplist的复杂度和红黑树一样,而且实现起来更简单。
  2. 在并发环境下红黑树在插入和删除时需要rebalance,性能不如跳表。
  • 【内部编码】:
    • intset(整数集合):当集合中的元素都是整数且元素个数小于 set-max-intset-entries 配置(默认512个)时,Redis会选用intset来作为集合内部实现,从而减少内存的使用。
    • hashtable(哈希表):集合无法满足intset的条件时,选用hashtable作为集合内部实现。
  • 【底层实现】:是一个value为null的HashMap,实际就是通过计算hash的方式来快速排重的,这也是set能提供判断一个成员是否在集合内的原因。

1. Set 底层实现原理

在 Redis 中,Set 是一个 无序集合,不允许元素重复。其底层实现依赖两种数据结构:

1.1 散列表(Hash Table)

  • 当集合中的元素较少时,Redis 使用哈希表(dict)来实现 Set
  • 特点:
    • 平均时间复杂度:**O(1)**。
    • 支持快速插入、删除和查找。
    • 需要解决哈希冲突,Redis 使用 链地址法

1.2 整数集合(IntSet)

  • 如果 Set 中的元素都是整数,且数量较少时,Redis 使用 整数集合(IntSet)
  • 特点:
    • 数据结构是一个 有序数组,用于存储整型数据。
    • 支持二分查找,插入时按序插入。
    • 空间占用小,非常高效。

底层切换策略:

  • 默认情况下,Set 使用 IntSet
  • 当:
    • 元素数量超过一定阈值。
    • 元素中包含非整数类型。
    • 发生冲突时。
    • 切换为 Hash Table

2. ZSet 底层实现原理

ZSet 是一个 有序集合,支持按分值排序。其底层数据结构是:

2.1 跳跃表(SkipList)

  • 跳跃表是一种多层链表,支持 快速查找、插入和删除,平均时间复杂度为 **O(log N)**。
  • 结构:
    • 每个节点包含两个属性:
      • score:排序依据。
      • value:实际值。
    • 不同层级的链表节点通过指针连接。

2.2 哈希表(Hash Table)

  • Redis 还维护一个 哈希表,以实现通过值快速查找分数。
  • 哈希表和跳跃表共同维护数据,保证 ZSet 的有序性与查找效率。

3. 为什么 ZSet 使用跳跃表而不是红黑树?

3.1 跳跃表的优点

  1. 实现简单
    • 跳跃表的实现代码比红黑树简单,维护成本低。
  2. 范围查找效率高
    • 跳跃表支持范围查询(如 ZRANGEZRANK),在跳跃表中从上层链表向下查找,效率高于红黑树。
  3. 内存分配更灵活
    • 跳跃表的节点分布在内存中,结构分散,支持链表扩展,而红黑树的节点结构固定,内存分配可能产生碎片。
  4. 插入、删除时旋转成本低
    • 红黑树在插入和删除时,频繁执行节点旋转和重新平衡,性能开销较大,而跳跃表只需调整链表指针。

3.2 跳跃表的劣势

  • 跳跃表的内存占用相对较高,因为需要维护多个链表结构。
  • 在极端情况下,跳跃表的查找性能可能退化到 **O(N)**,但通过随机层数分配,平均复杂度为 **O(log N)**,与红黑树相当。

总结:为什么 Redis 选择跳跃表?

  • 在 Redis 中,ZSet 的典型操作包括范围查询、排名查询等。跳跃表比红黑树更适合这些场景。
  • 跳跃表结构简单,实现成本低。
  • 内存分配更加灵活,支持分布式环境。
  • Redis 优先考虑高性能与简单实现,因此跳跃表成为理想选择。

Redis 的 zset(有序集合)是通过 跳跃表(skiplist)哈希表 结合实现的,而不是使用红黑树。

1. 跳跃表的简单性与效率

  • 实现简单:跳跃表的代码实现比红黑树简单很多。红黑树的插入、删除和调整操作需要维护复杂的平衡规则,而跳跃表只需要通过随机化索引来保持结构的平均效率。
  • 时间复杂度相似:跳跃表的时间复杂度为 O(log⁡n,与红黑树相同。Redis 追求极高的性能,跳跃表能够提供足够快的操作效率,而无需引入复杂的红黑树实现。在并发环境下红黑树在插入和删除时需要rebalance,性能不如跳表。

2. 跳跃表更适合范围查询

  • 跳跃表支持高效的 范围查询,例如 ZRANGEBYSCOREZRANGEBYLEX 操作。由于跳跃表是基于链表的结构,查找起点后,范围内的元素可以通过链表的线性遍历快速获取,逻辑简单高效。
  • 红黑树虽然也支持范围查询,但由于它是一种树结构,范围内的遍历需要进行中序遍历,指针跳转复杂,效率相对较低。

3. 内存使用与存储结构

  • 跳跃表的节点通过链表方式组织,存储紧凑,内存布局相对连续。相比之下,红黑树的节点是分散的,指针管理较多,可能带来额外的内存开销。
  • 跳跃表还允许通过随机级别分布来平衡性能和内存使用,使得结构在大规模数据场景下更加灵活。

4. 易于实现持久化和数据转储

  • 跳跃表的结构非常适合 Redis 的 RDB 持久化AOF(Append Only File)日志。其数据结构序列化简单,可以直接遍历链表输出所有节点。
  • 红黑树由于复杂的平衡逻辑和多指针结构,实现序列化和反序列化的成本更高。

5. 工程上的灵活性与扩展性

  • 跳跃表易于实现水平扩展,例如通过增加跳表层数来支持更大的数据规模
  • Redis 的 zset 不仅使用跳跃表存储数据,还结合了哈希表,用于快速定位特定元素。这种结合方式在跳跃表中实现相对简单,但在红黑树中实现则需要更复杂的设计。

总结

特性 跳跃表 红黑树
时间复杂度 O(log⁡n) O(log⁡n)
实现复杂度 简单,基于链表 较复杂,需维护平衡规则
范围查询效率 高效,链表结构遍历性能优越 较低,需通过中序遍历实现
内存开销 内存利用率较高 节点分散,指针管理开销大
序列化与持久化 简单,链表遍历即可 复杂,需要特殊处理指针和结构

Redis 选择跳跃表,是在满足性能需求的基础上,追求实现简单、代码可维护性高的结果。在 Redis 的使用场景中,跳跃表的性能完全足够,并且更适合于 Redis 的整体架构和设计理念。

问:一个 Redis 实例最多能存放多少的keys?List、Set、Sorted Set 他们最多能存放多少元素?⭐

Redis 最大键值对数量与数据结构容量限制

1. Redis 实例最多能存放多少 Keys?

  • 理论上限: Redis 的键数量上限是 2^32 - 1 = 4,294,967,295(约 42 亿个键)。
  • 配置参数: maxmemory 限制了 Redis 能使用的内存量,达到内存上限时 Redis 会根据 maxmemory-policy 进行数据淘汰。

实际影响因素:

  • 可用内存:Redis 使用的内存受物理内存和系统限制。
  • Key 长度与 Value 大小:键值的长度直接影响内存使用。
  • 服务器性能:大量键会增大操作延迟和内存消耗。

2. 数据结构容量限制

  1. List(列表)
    • 最大元素数量: 2^32 - 1 = 4,294,967,295
    • Redis 列表是基于 双向链表(quicklist) 实现的。
    • 实际影响因素:
      • 元素长度:单个元素不能超过 512MB。
      • 内存:受内存和 maxmemory 限制。
  2. Set(集合)
    • 最大元素数量: 2^32 - 1 = 4,294,967,295
    • Redis 的集合是基于 哈希表整数集合(intset) 实现的。
    • 实际影响因素:
      • 元素唯一性:Set 中不能有重复元素。
      • 内存使用:大量小元素占用内存大。
  3. Sorted Set(有序集合,ZSet)
    • 最大元素数量: 2^32 - 1 = 4,294,967,295
    • Sorted Set 基于 跳跃表(SkipList)哈希表(dict)
    • 实际影响因素:
      • 成员的 score 唯一,必须是浮点数。
      • 元素数量大时,插入和查找的性能为 **O(log N)**。

Redis 数据结构容量对比表

数据结构 最大容量(理论值) 实现机制 注意事项
Keys 4,294,967,295 全局哈希表 内存消耗与管理复杂
List 4,294,967,295 双向链表(quicklist) 单元素 ≤ 512MB
Set 4,294,967,295 哈希表 / IntSet 无重复元素
Sorted Set 4,294,967,295 跳跃表 + 哈希表 支持分数排序
Hash 4,294,967,295 哈希表 键值对存储

问:Redis 的内存占用情况怎么样?Redis 的内存用完了会发生什么?Redis如何压缩内存(内存优化)?

Redis 的内存占用情况

  • 对象开销:Redis 中存储的数据(键和值)不仅包括实际的数据内容,还包含对象元数据(如类型、编码、引用计数、LRU 信息等)。每个 Redis 对象都有固定的额外内存开销。
    • 举个例子:在 32 位系统上,如果存储 100 万个键值对,其中键为“0”到“999999”,值为字符串 “hello world”,实际占用内存可能达到 100MB。而如果将这些数据合并到一个 key 内(比如用一个 Hash 存储 100 万个字段),内存开销可能只需 16MB。这主要是因为每个单独的 key 都有相对较高的元数据开销,而将多个小数据项放入同一个数据结构中可以大幅减少这种重复开销。
    • 在 Memcached 上执行也是类似的结果,但是相对 Redis的开销要小一点点,因为 Redis 会记录类型信息引用计数等等。
  • 32 位 vs 64 位:在 64 位系统上,由于指针占用 8 个字节(而 32 位系统中只占 4 个字节),所以每个对象的元数据会更多;因此,同样的数据在 64 位系统上可能会消耗更多内存。不过,64 位系统能够支持更大的内存空间,这也是运行大型 Redis 服务器的必要条件。
  • 内存分配与碎片:Redis 默认使用 Jemalloc 作为内存分配器,虽然 Jemalloc 对碎片有较好的控制,但在高并发和频繁创建删除对象的场景下,仍可能产生内存碎片。可以通过命令 INFO MEMORY 来查看 Redis 的内存使用情况和碎片率。

Redis 的内存用完了会发生什么?

  • 写命令失败:当 Redis 使用的内存达到 maxmemory 配置的上限时,如果内存淘汰策略没有生效或者配置为 noeviction(默认情况下),所有写操作(例如 SET、LPUSH、XADD 等)都会返回错误信息,提示 “OOM command not allowed”。(但是读命令还可以正常返回)
  • 内存淘汰(Eviction):如果配置了合适的内存淘汰策略(如 allkeys-lruvolatile-lruallkeys-random 等),当内存使用达到上限时,Redis 会自动淘汰(驱逐)部分数据以释放空间。读操作则不受影响。淘汰策略的选择应根据具体业务需求决定:
    • allkeys-lru: 淘汰所有 key 中最近最少使用的。
    • volatile-lru: 仅淘汰设置了过期时间的 key。
    • volatile-ttl: 淘汰即将过期的 key。

Redis如何压缩内存(内存优化)?

  • 合并小数据到一个数据结构中:尽可能使用散列表(hashes)。由于每个 key 都有较大的元数据开销,尽量将多个小数据存储到同一个 key 里能显著节省内存。例如,在 web 系统中,将用户的多个属性(名称、邮箱、密码等)存储到同一个 Hash 内,而不是为每个属性创建单独的 key。这种方式利用 Hash 的紧凑编码(当字段数量和字段大小在阈值以下时,使用 ziplist/listpack 编码)能大幅降低内存占用。
  • 使用紧凑编码:Redis 对于 Hash、List、Sorted Set 等数据结构会根据实际数据量采用不同的内部编码:
    • Hash 类型: 当存储的数据较少且每个字段较短时,会使用 ziplist(或新版中的 listpack)编码,空间利用率更高。
    • 可通过配置参数(如 hash-max-ziplist-entrieshash-max-ziplist-value)来控制何时从紧凑编码转换为普通字典编码。
  • 精简键名和值:设计数据模型时,尽量使用简短的键名和值,减少不必要的字符串长度,从而降低整体内存开销。
  • 合理配置内存淘汰和过期策略:为不需要长期保存的数据设置合理的 TTL,并根据业务需求选择适当的淘汰策略,使得内存不断被释放、更新。
  • 内存碎片管理:Redis 提供命令 MEMORY PURGE 来尝试释放 jemalloc 内部碎片;同时,可以使用 MEMORY DOCTOR 分析内存使用情况,针对性调整配置。
  • 集群分片扩展:如果单个 Redis 实例的内存始终紧张,可考虑使用 Redis Cluster 等水平扩展方案,将数据分布在多台服务器上,降低单实例内存压力。

Redis 内存优化与压缩方法

  1. 数据结构优化
    • 字符串优化: 使用更短的键和值。
    • 哈希结构优化: 使用小字段组合在哈希表中存储对象。
    • 编码优化:list-max-ziplist-entrieshash-max-ziplist-entries 等参数控制小数据量时使用紧凑编码。
  2. 减少内存碎片
    • 定期运行 MEMORY PURGE 手动释放内存。
    • 调整 jemalloc 配置优化内存分配。
  3. 持久化优化:合理配置 RDB/AOF,避免内存占用过多。
  4. 淘汰策略与过期管理
    • 设置合理的过期时间和内存淘汰策略。
    • 避免使用大批量的无过期键。
  5. 压缩存储
    • 使用 MEMORY DOCTOR 诊断内存问题。
    • 使用 MEMORY USAGE <key> 查看具体键的内存消耗。
  6. 集群分片与扩展
    • 使用 Redis Cluster 进行水平扩展,分散内存负载。

Redis 内存管理命令示例

1
2
3
4
5
6
7
8
9
10
11
# 查看内存使用情况
INFO MEMORY

# 查看键的内存占用大小
MEMORY USAGE mykey

# 手动释放内存碎片
MEMORY PURGE

# 诊断内存问题
MEMORY DOCTOR

三. Redis高级特性

包括:慢查询、Pipeline、事务、Lua

问:Redis慢查询?⭐⭐

  • Redis客户端执行命令:
    1. 发送命令:1+4为Round Trip Time即RTT往返时间
    2. 排队
    3. 执行命令
    4. 返回结果
  • 批量操作,如mget、mset可以节约RTT,但大部分命令不支持批量操作,如执行n次hgetall命令。

作用:记录执行时间超过指定阈值的 Redis 命令,用于性能分析与优化。
核心机制

  • 配置参数
    • slowlog-log-slower-than:阈值(单位:微秒),默认 10 毫秒(10000 微秒)。
    • slowlog-max-len:日志最大长度(队列长度),默认 128,超出时删除旧记录。
  • 记录内容:仅记录命令执行时间(服务端耗时),不包含网络传输或排队时间。
  • 查看日志:通过 SLOWLOG GET [n] 命令查看最近的 n 条记录。
  • 使用场景:定位执行缓慢的命令(如复杂度高的 KEYS *、大范围 HGETALL 等)。

示例

1
2
3
4
5
# 设置阈值(1毫秒)和最大日志数(1000)
CONFIG SET slowlog-log-slower-than 1000
CONFIG SET slowlog-max-len 1000
# 查看最近2条慢查询
SLOWLOG GET 2

问:Redis Pipeline?⭐⭐⭐

作用:将多个命令打包发送,减少网络往返时间(RTT),提升吞吐量。
核心机制

  • 非原子性:命令按顺序执行,但执行期间可能被其他客户端命令插入。
  • 服务端处理:将命令缓存到内存队列,依次执行后一次性返回结果。
  • 性能提升场景:适合批量写入(如 MSET 的替代)、高网络延迟环境。
  • 注意事项
    • 单次 Pipeline 命令数量不宜过多,避免服务端内存压力。
    • 与事务(MULTI/EXEC)结合可实现原子性批量操作。

示例

1
2
3
4
5
6
# Python 示例(使用 redis-py)
pipe = r.pipeline()
pipe.set("key1", "value1")
pipe.get("key1")
pipe.incr("counter")
results = pipe.execute() # 返回 [True, "value1", 1]
  • Pipeline一般比逐条执行命令快,且网络延时越大越明显。
  • 原生批量操作是原子的,Pipeline则非原子。
  • 原生批量是一个命令对应多个key,Pipeline则可以多个命令。
  • 原生批量是Redis服务端实现,Pipeline则需要服务端和客户端共同实现。

问:Redis Lua?⭐⭐⭐

  • Lua是一种脚本语言,Redis 2.6版本后内嵌Lua环境,不需要单独安装Lua。
  • 使用Lua脚本的优点:
    1. 减少网络开销:可以把多个命令放在一个脚本中执行。
    2. 原子操作:整个脚本会当作一个整体执行,中间不会被其它命令插入。
    3. 复用性:客户端发送的脚本会永远存储在Redis,其它客户端也可以复用同一脚本完成相同逻辑。
  • 如使用Redis+Lua实现限流。

作用:在 Redis 服务端原子执行复杂逻辑,避免竞态条件。
核心机制

  • 原子性:脚本执行期间独占服务器,不会被其他命令打断。
  • 命令执行:通过 EVAL "script" numkeys key... arg... 或预加载后使用 EVALSHA
  • 错误处理
    • redis.call():命令执行错误会抛出异常,中断脚本。
    • redis.pcall():捕获错误并返回错误表,脚本继续执行。
  • 超时控制:默认 5 秒,可通过 lua-time-limit 调整(过长会阻塞其他客户端)。
  • 使用场景:实现 CAS(如秒杀扣库存)、复杂事务(组合多个命令)。

示例

1
2
3
4
5
6
7
8
-- 原子性递增并返回新值
local current = redis.call("GET", KEYS[1])
if not current then
current = 0
end
local new = current + tonumber(ARGV[1])
redis.call("SET", KEYS[1], new)
return new
1
2
# 执行脚本
EVAL "上述脚本内容" 1 "counter" 5

问:redis事务?⭐⭐⭐

事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。

Redis 事务提供了一种轻量级的命令队列机制,通过 MULTI/EXEC 实现命令批量执行和乐观锁功能,适用于简单业务场景。为实现更高的事务可靠性与分布式支持,推荐使用 Redis 配合其他分布式事务框架,如 Seata、TCC 等。

Redis 支持事务机制,通过命令队列的方式实现原子操作。事务中的命令按照先后顺序依次执行,避免了复杂的分布式事务模型。

Redis 事务的三个阶段

  1. 开始事务:MULTI
    • 使用 MULTI 命令开启一个事务,后续的命令将被入队,直到执行 EXEC
  2. 命令入队:执行多个命令
    • MULTIEXEC 之间的命令被按顺序入队。
    • 不会立即执行,而是等待事务提交。
  3. 执行事务:EXEC
    • 提交事务,将入队的命令按顺序依次执行。

Redis 事务命令

命令 描述
MULTI 开启事务
EXEC 提交事务,执行命令,执行事务块内命令
DISCARD 放弃事务,清空队列
WATCH key [key ...] 乐观锁监视键,监视一个或多个key,如果事务执行前key被改动,事务将打断
UNWATCH 取消监视键

示例:简单事务操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 开启事务
127.0.0.1:6379> MULTI
OK

# 执行多条命令
127.0.0.1:6379> SET key1 value1
QUEUED
127.0.0.1:6379> INCR counter
QUEUED

# 提交事务
127.0.0.1:6379> EXEC
1) OK
2) (integer) 1

事务特性

  1. 批量执行:事务中的命令会一次性提交执行,减少了客户端与 Redis 服务器的通信成本。
  2. 单独执行:事务中的命令按队列顺序依次执行,执行期间不会被其他命令插入。
  3. 隔离性与原子性(部分保障):事务中的每个命令都会执行,但 Redis 不支持回滚机制。

事务中的错误处理

  1. 语法错误(在命令入队阶段)

    • 如果命令在入队时有语法错误,整个事务在提交时会执行失败。
    1
    2
    3
    4
    5
    6
    7
    8
    127.0.0.1:6379> MULTI
    OK
    127.0.0.1:6379> SET key1 value1
    QUEUED
    127.0.0.1:6379> BADCOMMAND key2
    (error) ERR unknown command 'BADCOMMAND'
    127.0.0.1:6379> EXEC
    (error) EXECABORT Transaction discarded because of previous errors.
  2. 运行时错误(执行时)

    • 如果事务中的某条命令在执行时发生错误,Redis 仍然会执行其他命令。

    • 示例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      127.0.0.1:6379> MULTI
      OK
      127.0.0.1:6379> SET key1 value1
      QUEUED
      127.0.0.1:6379> INCR key1 # 错误,key1 是字符串类型
      QUEUED
      127.0.0.1:6379> EXEC
      1) OK
      2) (error) ERR value is not an integer or out of range

Redis 乐观锁机制 (WATCH)

Redis 通过 WATCH 实现乐观锁,确保在事务提交前,监视的键未被修改。

示例:WATCH 锁的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
127.0.0.1:6379> WATCH balance
OK

# 开启事务
127.0.0.1:6379> MULTI
OK

# 修改 balance
127.0.0.1:6379> DECR balance
QUEUED

# 提交事务
127.0.0.1:6379> EXEC

如果 balance 在事务提交前被其他客户端修改,则 EXEC 提交将失败,返回 nil

Redis 事务的局限性与不足

  1. 无回滚机制
    • 如果事务中的某条命令执行失败,Redis 不会回滚整个事务,已执行的命令依然生效。
  2. 无隔离级别支持
    • Redis 不支持隔离级别,如 READ COMMITTEDSERIALIZABLE 等。
  3. 单线程限制
    • Redis 是单线程模型,事务操作可能被其他命令阻塞。
  4. 锁粒度粗
    • Redis 事务的 WATCH 锁是基于键的,粒度较粗,适用于简单应用场景。

Redis 事务的应用场景

  • 计数器与库存扣减
  • 银行转账与账户结算
  • 分布式锁实现(结合 WATCH
  • 任务队列的原子操作

问:redis事务的实现特征?

  1. 所有命令都将会被串行化的顺序执行,事务执行期间,Redis不会再为其它客户端的请求提供任何服务,从而保证了事物中的所有命令被原子的执行
  2. Redis事务中如果有某一条命令执行失败,其后的命令仍然会被继续执行
  3. 在事务开启之前,如果客户端与服务器之间出现通讯故障并导致网络断开,其后所有待执行的语句都将不会被服务器执行。然而如果网络中断事件是发生在客户端执行EXEC命令之后,那么该事务中的所有命令都会被服务器执行
  4. 当使用Append-Only模式时,Redis会通过调用系统函数write将该事务内的所有写操作在本次调用中全部写入磁盘。

然而如果在写入的过程中出现系统崩溃,如电源故障导致的宕机,那么此时也许只有部分数据被写入到磁盘,而另外一部分数据却已经丢失。

Redis服务器会在重新启动时执行一系列必要的一致性检测,一旦发现类似问题,就会立即退出并给出相应的错误提示。此时,我们就要充分利用Redis工具包中提供的redis-check-aof工具,该工具可以帮助我们定位到数据不一致的错误,并将已经写入的部分数据进行回滚。修复之后我们就可以再次重新启动Redis服务器了

问:Redis如何大批量插入数据?⭐⭐

  1. 使用 MSET 命令(多键插入):MSET 命令支持同时设置多个键值对,适用于批量插入小规模数据。

    优点:

    • 原子性操作,多个键值对一次性插入。

    缺点:

    • 不支持复杂数据结构。

    示例:批量插入字符串

    1
    MSET key1 value1 key2 value2 key3 value3
  2. 使用 pipeline 命令:Redis pipeline 支持一次性发送多个命令,减少客户端与 Redis 服务器之间的网络延迟。

    优点:

    • 降低网络通信次数,提高写入效率。

    注意:

    • 批量大小控制在 1000~5000 条之间,避免占用过多内存。

    示例:批量插入字符串键值对

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import redis

    # 创建 Redis 连接
    r = redis.Redis(host='localhost', port=6379, db=0)

    # 批量插入
    pipe = r.pipeline()
    for i in range(1, 100001):
    pipe.set(f"key{i}", f"value{i}")
    pipe.execute()
  3. 使用 Lua 脚本批量插入,避免多次网络请求

    优点:

    • 批量执行,事务性保障,避免网络延迟。

    示例:批量插入哈希表数据

    1
    2
    3
    4
    5
    6
    7
    8
    local data = {
    {key="user:1", field="name", value="Alice"},
    {key="user:2", field="name", value="Bob"},
    }

    for _, item in ipairs(data) do
    redis.call("HSET", item.key, item.field, item.value)
    end
  4. 使用 Redis RDB 文件导入:适用于超大规模数据导入场景,提前生成 Redis 数据库快照文件,直接加载。

    步骤:

    1. 在离线环境中准备 RDB 文件。
    2. 将 RDB 文件上传到 Redis 服务器 dump.rdb
    3. 重启 Redis 自动加载数据。

    优点:

    • 超大规模数据一次性导入,极高性能。

    缺点:

    • 需要服务器权限,数据导入期间 Redis 不可用。
  5. Redis Mass Insert 工具导入

    • 使用 Redis 官方推荐的工具,如 redis-benchmarkredis-cli --pipe

    示例:redis-cli --pipe 批量插入

    1
    cat data.txt | redis-cli --pipe

    示例:redis-benchmark 插入性能测试

    1
    redis-benchmark -h 127.0.0.1 -p 6379 -t set -n 100000

    优点:

    • 高效插入,适用于测试和真实环境。

四. 发布订阅

问:Redis的发布订阅机制?PubSub有什么缺点?⭐⭐⭐

使用pub/sub主题订阅者模式,可以实现1:N的消息队列。

发布者和订阅者不直接通信

命令:

  1. 发布消息:publish channel message 返回订阅者个数。
  2. 订阅消息:subscribe channel [channel...] 订阅一个或多个频道。
    • 客户端执行订阅命令后进入订阅状态,只能接收subscribe、psubscribe、unsubscribe、punsubscribe这四个命令。
    • 新开启的订阅客户端不会接收到该频道之前的消息,因为Redis不会对消息持久化。
  3. 取消订阅:unsubscribe [channel [channel...]]
  4. 按照模式订阅和取消订阅:
    • psubscribe pattern [pattern...] 支持glob风格的订阅,如it*表示以it开头的所有频道。
    • punsubscribe pattern [pattern...]
  5. 查询订阅:
    • 查看活跃的频道:pubsub channels [pattern] 频道至少有一个订阅者。
    • 查看频道订阅数:pubsub numsub [channel ...]
    • 查看模式订阅数:pubsub numpat

在消费者下线的情况下,生产的消息会丢失,得使用专业的消息队列如rabbitmq等。

Redis Pub/Sub 模式的缺点?

Redis 的 Pub/Sub 是一种轻量级消息发布与订阅机制,适用于简单的消息广播和实时通知。但由于其实现机制的局限性,存在一些显著缺点:

主要缺点分析

  1. 无消息持久化(数据丢失风险)
    • Redis Pub/Sub 不会持久化消息,消息仅存储在内存中。
    • 如果消费者在消息发布时未在线或掉线,将错过该消息,导致消息丢失。
      解决方案: 考虑使用 Redis Stream 或引入消息队列系统(如 Kafka、RabbitMQ)。

  1. 无消费确认机制
    • Pub/Sub 没有消息确认机制,生产者无法确认消息是否被消费者成功接收。
    • 消费失败时,消息不会被重发。
      解决方案: 使用 Redis Stream 提供的手动确认机制。

  1. 无消息排队(实时性要求高)
    • Pub/Sub 仅支持实时消息推送,消费者必须始终在线才能接收消息。
    • 没有内置队列功能,无法存储历史消息。
      解决方案: 使用 Redis Stream 或其他消息队列中间件,支持消息排队和延迟处理。

  1. 不支持水平扩展(单点瓶颈)
    • 在分布式环境中,Pub/Sub 只能在单个 Redis 节点上工作,无法跨节点扩展。
    • 集群环境中不同节点之间无法共享订阅消息。
      解决方案: 使用 Redis 集群或引入专业的分布式消息中间件。

  1. 高并发下性能下降
    • 在高并发场景下,订阅和发布数量过多时,Redis Pub/Sub 性能可能受到影响。
      解决方案: 优化 Redis 配置、使用多 Redis 实例或引入消息中间件。

  1. 无消费分组和负载均衡
    • Redis Pub/Sub 不支持消费分组,无法实现多消费者之间的负载均衡。
    • 所有订阅者都收到相同消息,无法分摊消费压力。
      解决方案: 使用 Redis Stream 消费组,自动分配和负载均衡。

何时适合使用 Redis Pub/Sub

尽管存在上述缺点,Redis Pub/Sub 仍然适用于以下场景:

  • 实时通知与广播消息:如聊天室消息、在线推送通知。
  • 轻量级消息传递:对可靠性要求不高的临时通知。
  • 简单应用监控与告警:实时状态和日志通知。

总结:选择建议

  • 对可靠性和持久化要求高: 使用 Redis Stream 或专业消息队列(如 Kafka)。
  • 需要消费确认和重发机制: 使用 Redis Stream 的消息确认和消费组功能。
  • 需要轻量级消息广播: 使用 Redis Pub/Sub,适合实时性要求高但可靠性要求低的场景。

问:Redis Stream?⭐⭐⭐

1. Redis Stream 是什么?

定义:Redis Stream 是 Redis 5.0 引入的数据结构,旨在实现持久化、多播、多消费者组的消息队列,弥补了 Pub/Sub 无法持久化消息的缺陷。Stream 具备消息队列的基本要素,包括生产者 API、消费者 API、消息 Broker、消息确认机制等。

核心特性

  • 消息持久化:默认持久存储(结合 AOF/RDB),支持回溯消费。
  • 多消费者组:多个消费者组独立消费同一 Stream,互不影响,借鉴 Kafka 消费组概念。
  • 消息确认机制:通过 XACK 确认消息处理完成,避免消息丢失。
  • 消息追溯:消费者可通过 Pending List(PEL)查找未确认的消息,实现消息重试和故障恢复。
  • 阻塞式消费:支持 XREAD/XREADGROUP 阻塞模式,消费者可等待新消息,减少轮询开销。

2. Redis Stream 操作

操作类型 命令 功能说明
生产者 XADD stream * key value 添加消息到 Stream,* 表示自动生成消息 ID,可用 MAXLEN 控制长度。
独立消费者 XREAD COUNT n STREAMS stream 0 读取最新的 n 条消息。
阻塞读取 XREAD BLOCK 0 STREAMS mystream 0 阻塞直到有新消息,从 ID 为 0 开始读取。
消费者组 XGROUP CREATE stream group $ 创建消费者组,$ 表示从最新消息开始消费。
组内消费 XREADGROUP GROUP group consumer COUNT n STREAMS stream > 组内消费者读取未处理的消息,> 表示获取新消息。
消息确认 XACK stream group message-id 确认消息处理完成,从 PEL 中移除。
查看未确认消息 XPENDING stream group 查看消费者组内未确认的消息列表及重试次数。
范围查询 XRANGE stream start-id end-id 根据消息 ID 范围查询历史消息。
删除消息 XDEL message-id 仅做标记删除,消息仍在 PEL 中可见。

3. 使用场景

  • 消息队列/任务调度:订单处理、任务分发,多个消费者共同处理消息并 XACK 确认。
  • 日志收集与监控:应用日志写入 Stream,消费组实时分析监控。
  • 事件溯源与数据同步:微服务之间事件传递,保证数据一致性。
  • 高并发场景:通过 MAXLEN 限制流长度,PEL 确保消息不丢失。

示例:

  1. 订单异步处理
    • 订单事件写入 Stream,库存、物流等服务消费处理。
    • 提升吞吐量,避免同步阻塞。
  2. 实时日志收集
    • 微服务日志写入 Stream,消费组并行处理。
    • 支持实时报警、日志存储。
  3. 实时通知推送
    • 消息写入 Stream,在线用户通过 XREAD 拉取。
    • 低延迟,支持历史消息回溯。

4. 关键问题与解决方案

1. 消息积累与内存控制

  • 问题:消息过多导致内存压力。
  • 解决方案
    • XADD stream MAXLEN 1000 * key value 限制 Stream 长度。
    • XDEL 标记删除消息,结合 MAXLEN 进行物理删除。

2. 未确认消息(PEL)

  • 问题:消费者未 XACK,PEL 过大。
  • 解决方案
    • 业务处理完成后立即 XACK
    • XPENDING 查询未确认消息,进行重试。
    • 客户端断连后使用 XREADGROUP ... 0-0 重新获取 PEL 中的消息。

3. 死信(Dead Letter)处理

  • 问题:某些消息无法消费,反复重试。
  • 解决方案
    • XPENDING 监控 delivery counter,超过阈值标记为死信。
    • XDEL 删除消息,并 XACK 处理完成。

4. 高可用与数据持久化

  • 高可用:基于 Redis 主从复制,Sentinel 监控,但故障切换可能丢失部分数据。
  • 持久化:依赖 AOF/RDB,需平衡性能与可靠性。

5. 分区(Partitioning)支持

  • Redis 不支持原生分区,可在客户端按哈希策略分片。

  • 示例:

    1
    2
    stream_name = "chat_stream_{user_id % 4}"
    XADD {stream_name} * content "hello"

5. Redis Stream vs Kafka 对比

特性 Redis Stream Kafka
持久化 依赖 Redis 内存与持久化策略 基于磁盘存储,数据可靠性更高
吞吐量 10万+/秒(内存操作) 百万+/秒(批处理与磁盘顺序写优化)
消费者组 支持多组,但无原生分区 支持多组且分区,天然支持水平扩展
消息回溯 支持,通过消息 ID 查询 支持,基于 offset 灵活控制
适用场景 轻量级、低延迟、简单消息队列 大数据量、高吞吐、复杂业务场景

6. Redis 适合作为消息队列吗?

适合场景

  • 消息量适中(日均百万级以下)。
  • 低延迟(毫秒级响应)。
  • 无需复杂功能(如分区、事务消息)。

不适合场景

  • 海量消息(日均千万级以上),内存成本高。
  • 需要严格顺序性、分区扩展性。

选型建议

  • 轻量级任务:Redis Stream(如实时通知、日志收集)。
  • 复杂业务:Kafka、RocketMQ 更适合大规模消息处理。

PEL(Pending Entries List,待处理消息列表)如何避免消息丢失?

  • 当消费者从 Redis Stream 读取消息时,Redis 会将该消息标记为「已投递」并存入 PEL(待处理消息列表),以防止消息丢失。
  • 如果 消费者在读取后宕机或断开连接,这些消息不会丢失,因为 Redis 不会自动从 Stream 删除消息
  • 该消费者重连后,可以通过 XPENDING 命令查看哪些消息尚未被 XACK(即未确认处理)。
  • 重新读取 PEL 中的消息时,XREADGROUP 需要指定一个起始消息 ID,通常设置为 **0-0**,表示获取 PEL 里的所有未确认消息,并继续消费 last_delivered_id 之后的新消息。

关键点

  • 消息不会因为消费者宕机而丢失,因为它们存储在 PEL 中,直到被 XACK 确认处理。
  • 断线重连的消费者需要手动重新消费 PEL 里的消息,而不是用默认的 > 读取最新消息。

死信问题?

  • 死信(Dead Letter) 指的是 长时间无法被消费者成功处理的消息,通常是因为:

    1. 代码异常导致消费失败。
    2. 消息格式错误,消费者无法解析。
    3. 业务逻辑导致消息无法被处理。
  • 在 Redis Stream 中,每次消息被投递但未 XACK 时,deliverycounter 计数都会增加(可以通过 XPENDING 查看)。

  • 如何处理死信?

    1. 设定 deliverycounter 阈值,例如超过 5 次投递仍未 XACK,认为是死信。

    2. 手动删除死信:

      1
      XDEL mystream message-id
    3. 注意:XDEL 只是删除 Stream 里的消息,PEL 仍然会保留该消息,因此还需要 XACK:

      1
      XACK mystream mygroup message-id
    4. 也可以将死信转移到另一个 Stream(死信队列)进行后续处理:

      1
      2
      XADD dead_letter_stream * original_message_id <message_data>
      XACK mystream mygroup original_message_id

关键点

  • 使用 XPENDING 监控消息的 deliverycounter,判断是否变成死信
  • 死信可以删除、转移到死信队列,或者进行人工干预

Stream 的高可用?

  • Redis Stream 本身不具备高可用性,但可以通过 Redis 主从复制(Replication)Sentinel 监控 来实现高可用。
  • Redis ClusterSentinel 模式 下,Stream 的数据会在多个节点间复制,从而保证故障转移(failover)。
  • 但需要注意:
    • Redis 复制是异步的,如果主节点故障,可能会丢失一小部分最近写入的数据。
    • 这种情况适用于大多数 Redis 数据结构,不仅限于 Stream。

关键点

  • 高可用依赖于 Redis 的主从复制,而不是 Stream 本身的机制
  • 可能会因为异步复制导致极少量数据丢失,但大部分情况下可以接受。

分区 Partition

  • Redis Stream 不支持像 Kafka 那样的分区(Partition)机制,即多个消费者并行消费不同的分区。
  • 如果需要类似的分区能力,可以手动实现:
    • 创建多个 Stream(如 mystream-1mystream-2)。
    • 根据消息内容进行哈希分片,让不同的消息进入不同的 Stream。
    • 客户端决定从哪个 Stream 读取,实现类似 Kafka 的分区消费模式。

关键点

  • Redis Stream 没有原生的分区功能,需要手动实现多个 Stream 进行消息分片
  • 相比 Kafka,Redis Stream 更适合轻量级消息队列场景

Stream小结

  • Stream 的消费模型借鉴了Kafka 的消费分组的概念,它弥补了 Redis Pub/Sub 不能持久化消息的缺陷。但是它又不同于 kafka,Kafka的消息可以分 partition,而 Stream 不行。如果非要分 parition 的话,得在客户端做提供不同的 Stream 名称,对消息进行 hash 取模来选择往哪个 Stream 里塞。
    关于 Redis 是否适合做消息队列,业界一直是有争论的。很多人认为,要使用消息队列,就应该采用 Kafka.
  • Redis Stream = Kafka-like 消费模型 + Redis 的高性能数据存储
  • 相比于 Redis Pub/Sub,Stream 可以持久化消息,并支持消费者组(Consumer Group)
  • 与 Kafka 的不同点:
    • Kafka 有原生的分区机制,Stream 没有
    • Redis Stream 适用于轻量级、低延迟场景,如实时日志、在线聊天。
    • Kafka 更适合大规模数据流处理,如日志分析、事件流。

关键点

  • Redis Stream 适用于小规模、高吞吐、低延迟的消息队列
  • 如果需要大规模分布式处理,Kafka 是更好的选择

问:Redis如何实现消息队列?能不能生产一次消费多次呢?⭐⭐⭐?

  • 基于List的LPUSH+BRPOP

  • 实现:

    • 生产者端:使用 LPUSH 命令将消息推入一个列表(队列)的左侧。例如:LPUSH my_queue "message1"
    • 消费者端:使用 BRPOP 命令从队列右侧阻塞式地弹出消息。例如:BRPOP my_queue 0 这里的 0 表示无限期阻塞,直到有新消息到达。
  • 优点:

    • 简单实现,消息延迟几乎为0。
  • 缺点:

    • 空闲连接问题:如果消费者线程长时间阻塞在 BRPOP 上,会导致 Redis 客户端连接处于闲置状态;长时间的空闲可能被服务器断开连接,从而在执行阻塞命令时抛出异常。实现时需要捕捉异常并设计重连或重试机制。
    • 确认机制(ACK)不友好:使用 List 作为队列时,消息一旦被弹出即从队列中删除,无法内置确认(ACK)机制保证消费者处理成功。如果消费者在处理消息期间宕机或发生异常,消息可能丢失。为了弥补这一点,通常需要开发者在应用层额外实现 Pending 列表来跟踪未确认的消息。
    • 广播和重复消费支持不足:这种模式只适合点对点消费(即一条消息只能被一个消费者消费),不支持广播模式;消息一旦被消费即删除,不允许重复消费,也不支持消费者分组模式。
  • 基于Sorted Set:多用来实现延迟队列,也可以实现有序的普通消息队列。

    • 实现:

      • 延迟队列场景:消息存储时使用 ZADD 命令将消息放入有序集合中,score 一般设置为消息的时间戳(或期望的触发时间)。例如:ZADD delay_queue 1672531200 "message1"

      • 消费过程:消费者定期使用 ZRANGEBYSCORE 命令查询 score 小于当前时间的消息(即已到期的消息),并处理后再用 ZREM 删除。例如:

        1
        2
        ZRANGEBYSCORE delay_queue -inf 1672531200 LIMIT 0 1
        ZREM delay_queue "message1"
    • 优点:

      • 延迟队列:非常适合实现延迟或定时任务,能够根据消息时间顺序有序消费。
      • 有序性:自然具备排序功能,可以根据 score 来精确控制消息的顺序。
    • 缺点:消费者无法阻塞的获取消息,只能轮询,不允许重复消费。

      • 轮询方式:消费者无法通过阻塞等待方式获取消息,只能定期轮询当前时间范围内的消息,这会带来一定的资源浪费和不确定的延迟。
      • 重复消费问题:消息处理后一般需要手动删除,且不支持内置的消息重复消费机制(因为消息一旦删除,就无法再次消费)。
  • 基于PUB/SUB发布订阅模式:不适合做消息存储和积压类的业务,而是擅长做即时通讯、即时反馈的业务。

    • 实现:
      • 生产者端:使用 PUBLISH 命令将消息发送到一个或多个频道。例如:PUBLISH my_channel "message1"
      • 消费者端:使用 SUBSCRIBE 命令订阅相应频道,消费者实时接收该频道中发布的消息。例如:SUBSCRIBE my_channel
    • 优点:
      • 广播模式:一个消息可以被多个订阅者同时接收,实现天然的广播效果;同时支持多信道订阅。
      • 即时性:消息一经发布,所有在线订阅者都会立即收到,无需轮询。
    • 缺点:
      • 消息不可持久化:消息发布后不会存储到 Redis 中。如果消费者不在线,则无法接收到历史消息,消息会丢失。
      • 消费者离线问题:发布时如果消费者不在线,消息就丢失;此外,当消费者处理不过来或出现消息积压时,可能会被服务器断开连接。
      • 缺乏确认机制:没有消息确认(ACK)机制,不能保证每条消息都被处理成功。
  • 基于Stream类型的实现:基本上已经有了消息中间件的雏形,可以考虑在生产中使用。

    • 实现:

      • 生产者端:使用 XADD 命令向 Stream 中添加消息,Redis 会自动为每条消息生成全局唯一的 ID。例如:XADD mystream * field1 value1 field2 value2

      • 消费者端:消费者可以使用 XREADXREADGROUP 命令来读取消息。XREAD 支持阻塞读取模式:XREAD BLOCK 0 STREAMS mystream 0

        XREADGROUP 则支持消费组模式,实现负载均衡和消息确认(ACK),保证消息可靠消费:

        1
        XREADGROUP GROUP mygroup consumer1 COUNT 1 BLOCK 0 STREAMS mystream >
      • 确认机制:消费者在处理完成消息后,使用 XACK 命令确认消息,确保消息从 Pending 列表中移除,保证消息不丢失。
    • 优点:

      • 持久化与可靠性:Stream 支持消息持久化,且内置了消费组、消息确认等机制,能保证消息至少被消费一次。
      • 阻塞与批量读取:支持阻塞读取和批量获取,有助于构建高吞吐量的消息处理系统。
      • 消费组支持:可实现多个消费者对同一消息流的协同消费,既避免重复消费,也能分散消费压力。
      • 功能丰富:除了基本的消息队列功能,Stream 还支持消息追溯、消费者组管理、消息重新分配等高级功能,使其接近专业消息中间件的能力。
    • 缺点:

      • 实现复杂:相对于 List 和 Pub/Sub,Stream 的使用和管理(如消费组、Pending 列表、重试机制)更复杂,需要开发者设计好消息处理流程。
      • 资源管理:如果消息处理不及时或消费组配置不当,Pending 消息可能积压,导致内存占用增加,需要定期清理或自动管理。

选择方案的考虑因素

功能点 Pub/Sub Stream List 自定义
消息持久化
消息可靠性 取决于实现
多消费者支持 是(广播) 是(消费组) 手动实现
消费失败重试 手动实现
实时通知
消息顺序保障

总结:生产一次消费多次选型建议

  1. 实时广播消息: 使用 Pub/Sub,但需注意消息持久化和丢失问题。
  2. 可靠持久化、多消费者消费: 使用 Redis Stream
  3. 简单队列模型: 使用 Redis List,但需额外管理消费状态。

问:Redis如何实现延时队列?⭐⭐⭐

使用sortedset,想要执行时间的时间戳作为score,消息内容作为key调用zadd来生产消息,消费者用zrangebyscore指令获取N秒之前的数据轮询进行处理。

Sorted Set(有序集合)

核心思想:

  • 使用 Redis 的 Sorted Set(有序集合),其成员按分值(score)排序。
  • 将消息的执行时间作为 score,消息内容作为 value
  • 定期轮询 Sorted Set,获取到期消息。

实现步骤:

  1. 生产者:消息入队

    1
    ZADD delay_queue <timestamp> <message>
    • timestamp:当前时间 + 延时时间(Unix 时间戳)。
    • message:消息内容。
  2. 消费者:消息出队(轮询)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    while True:
    now = current_timestamp()
    messages = ZRANGEBYSCORE delay_queue -inf now LIMIT 0 1

    if messages:
    # 获取到期消息
    message = messages[0]

    # 消费消息
    process(message)

    # 从队列中删除
    ZREM delay_queue message
    else:
    # 无可消费消息时,等待一段时间
    sleep(interval)

优点:

  • 精准延时:按分值排序,精确到毫秒级。
  • 持久存储:消息不会丢失,支持重启恢复。
  • 灵活性高:支持任意延时和批量获取。

缺点:

  • 轮询开销大:如果消息量巨大且延时精确度高,频繁轮询会增加 Redis 的压力。
  • 单点瓶颈:Redis 需要高可用部署,否则数据易丢失。
List + 定时任务

核心思想:

  • 将消息推入 List。
  • 使用定时任务(如 cronTimerTask)定期检查消息并消费。

生产者:消息入队

1
LPUSH delay_queue <message>

消费者:定时消费任务

  • 定时任务轮询队列,检查消息是否到期。
  • 适用于简单场景,但延时精度较低。
Redis Stream(消息流)

核心思想:

  • Redis Stream 是天然的消息队列,可以带有时间戳。
  • 消息按时间顺序存储,支持持久化和消费组。

步骤:

  1. 生产者:添加消息

    1
    XADD delay_queue * message "Hello World" delay <timestamp>
  2. 消费者:轮询消息流

    • 定期检查消息 delay 字段是否到期,进行消费。

适用场景:

  • 高可靠性分布式系统。
  • 消息有序、延时精度高的场景。
Pub/Sub(发布/订阅)模式(不推荐)

说明:

  • 使用 Redis 的发布/订阅模式实现延时消息。
  • 生产者延迟发布消息。
  • 消费者订阅频道实时接收消息。

问题:

  • 无法持久化,消费者未订阅时消息丢失。
  • 不适合延时精度高的场景。

总结:如何选择 Redis 延时队列方案?

方案 优点 缺点 应用场景
Sorted Set 精确延时、持久化、灵活性高 轮询开销大 精确延时任务
List + 定时任务 简单易用 精度低、不可持久化 轻量级延时队列
Redis Stream 高可靠、持久化、分布式支持 实现复杂,开销较大 分布式任务调度
Pub/Sub 实时推送,延迟小 消息丢失,不支持重试 简单消息通知

在高并发、精确延时的场景中,推荐使用 Sorted SetRedis Stream。若对精度要求不高且实现简单,可以选择 List + 定时任务


五. 持久化

问:Redis 持久化策略?如何选择合适的策略?Redis如何做持久化?⭐⭐⭐

Redis 提供两种主要的持久化方式,分别是:

  1. RDB(Redis Database Snapshot)全量持久化
    • 原理:在指定的时间间隔内通过 fork 子进程,将当前内存数据生成一个全量快照(snapshot)并保存到磁盘上,文件名通常是 dump.rdb
    • 触发机制
      • 自动触发:根据 save 配置(如 save 900 1 表示 900 秒内有 1 次更改时触发)。
      • 手动触发:执行命令 SAVE(阻塞) 或 BGSAVE(后台保存)。
      • Redis 退出时自动执行。
    • 优点:
      • 快照文件体积小,适合备份、迁移;
      • 重启恢复速度较快;
      • 对性能影响较小,因为子进程生成快照是异步进行的。
    • 缺点:
      • 如果 Redis 崩溃,可能会丢失最后一次快照之后的数据;
      • 快照过程(fork)在数据量非常大时可能会消耗较多资源,短时间内影响主进程性能。
  2. AOF(Append Only File)增量持久化
    • 原理:将所有写操作记录(SETINCR 等)以 Redis 协议格式追加到一个日志文件中,默认文件名为 appendonly.aof。服务器重启时通过重放这些命令恢复数据。随着文件增大还会进行文件重写。
    • 同步策略(appendfsync 配置)
      • always: 每次操作都刷盘(最安全,但性能最差)。
      • everysec(默认): 每秒刷盘(高效,数据丢失在 1 秒内)。
      • no: 不主动刷盘(完全依赖操作系统,可能丢失数据)。
    • 优点:
      • 数据恢复更完整,理论上可以做到每次写操作都持久化;
      • 配置灵活,支持每次写操作同步、每秒同步或异步同步。
    • 缺点:
      • AOF 文件通常比 RDB 文件大;
      • 需定期重写(rewrite)文件,重写也会消耗系统资源;
      • 写操作频繁时性能受影响。
      • 恢复速度相对 RDB 较慢(日志回放时间长)。

此外,主从同步在一定程度上也能作为一种持久化手段(数据复制),但主要用于高可用性和读写分离,不完全算持久化。

AOF的工作流程:

  1. 命令写入

    • 客户端每次对 Redis 执行写操作,都会将该命令以追加方式写入 AOF 缓冲区,并根据 AOF 策略(always、everysec 或 no)同步到磁盘。例如:
      • always:每次写操作后立即 fsync,同步到磁盘(性能较低,数据安全性最高)。
      • everysec:每秒 fsync 一次(默认),在高并发时可以降低磁盘 I/O 压力,但可能丢失最多 1 秒的数据。
      • no:完全异步,不主动 fsync,由操作系统决定,性能最高但风险也最大。
  2. 重写机制

    • 随着写操作不断追加,AOF 文件会逐渐变大,同时可能包含大量重复或无效的命令。为此,Redis 会在后台启动 AOF 重写(rewrite)进程,将当前内存中的数据转换为一组最小化的、可以完整重构数据集的命令,并写入一个新的 AOF 文件,然后用新的文件替换旧的。
    • Redis 4.x 及更高版本还提供了“混合持久化”模式,即在 AOF 文件开头追加一个 RDB 快照,然后继续追加写命令,这样可以提高重写效率和恢复速度。
  3. 重启加载

    • 当 Redis 重启时,会首先尝试加载 AOF 文件(如果同时开启了 RDB 和 AOF,则默认加载 AOF),重放文件中的命令来恢复数据状态。
  4. 文件校验

    • Redis 在加载 AOF 文件时,会对文件进行校验,确保文件格式正确。如果发现损坏,会尝试修复或截断到最后一个有效位置。

appendfsync everysec 模式的工作原理

  1. 写入流程
    • 当客户端执行写操作时(如 SETINCR),Redis 首先将数据写入内存和 AOF 缓冲区(操作日志)。
    • Redis 采用后台线程每秒执行一次磁盘同步(fsync),将 AOF 缓冲区中的数据写入磁盘。
  2. 安全保证
    • Redis 使用操作系统的磁盘缓存机制(fsync),该操作是异步的,每秒执行一次。
    • 如果 Redis 或操作系统崩溃,尚未刷盘的操作将丢失。

RDB 的工作流程:

  1. 全量快照
    • 通过 fork 子进程,主进程继续处理请求,而子进程将当前内存中的数据快照写入磁盘文件。
  2. 触发条件
    • 可通过配置文件中的 save 规则(如 “save 900 1”, “save 300 10”)定时触发快照生成。
  3. 恢复速度
    • 由于 RDB 文件是全量数据快照,恢复速度较快,但可能会丢失最近一次快照之后的数据。

如何选择合适的持久化策略?

自 Redis 4.0 起,支持 RDB 与 AOF 混合持久化:

  • 在 Redis 重启时,先加载最新的 RDB 快照,再回放未落盘的 AOF 日志。
  • 启用方式:aof-use-rdb-preamble yes
使用场景 推荐策略
高可靠性、数据不能丢失 AOF(everysec
快速备份与灾难恢复 RDB(定期备份)
数据量大,写操作频繁 RDB(减少磁盘 IO)
高性能与高可用混合需求 RDB + AOF 混合持久化

如何选择合适的策略?

  1. 高性能优先(读多写少)
    • 使用 RDB,备份频率配置合理,减少磁盘 IO。
  2. 高可靠性优先(写多、实时性强)
    • 使用 AOF(everysec 模式),或结合 RDB 进行混合持久化。
  3. 企业级场景(高可用、数据不可丢)
    • 启用 AOF + RDB,确保数据持久化与恢复速度平衡。

Redis 持久化的注意事项

  1. 文件管理与存储监控
    • 定期检查磁盘使用情况,防止存储空间耗尽。
  2. 定期快照与备份
    • 配置定期 RDB 快照,做异地备份。
  3. 日志重写与优化
    • 开启 AOF 日志重写,配置合适的重写阈值,避免文件过大。
  4. 分布式环境中的持久化
    • 在 Redis 集群环境中,考虑多主多从架构,借助 Redis Sentinel 或 Redis Cluster 实现高可用。

问:如果突然机器断电会怎样?⭐⭐⭐

一般会选择everysec模式,即每秒执行一次fsync同步文件,所以只会丢失1s的数据(同步时主线程会判断最近2s是否有进行同步,有则直接返回,否则阻塞等同步线程执行完毕,所以最多可能丢失2s的数据)。

一、断电对 Redis 的影响

Redis 是内存数据库,所有数据主要存储在内存中,因此在断电或系统崩溃时,未持久化的数据会丢失。为了降低断电带来的数据风险,Redis 提供了两种主要持久化方式:

  • RDB:全量快照,定时保存内存中的数据到磁盘
  • AOF:记录每次写操作的日志,通过日志重放恢复数据

此外,主从复制也能起到一定的数据冗余作用,但它主要用于高可用而非严格持久化。

二、持久化策略对断电数据丢失的影响

不同的持久化配置下,断电后的数据恢复情况不同,下面给出一个对比表:

持久化配置 断电后数据状态 数据丢失情况
无持久化(默认关闭) 内存数据全部丢失 丢失全部数据
仅启用 RDB 恢复到最近一次 RDB 快照 丢失 RDB 快照后产生的数据
仅启用 AOF 恢复到最近的 AOF 日志 丢失 appendfsync 配置后(如 everysec)的数据
RDB + AOF 混合持久化 恢复到 RDB 快照 + AOF 最新状态 丢失 AOF 刷盘前(可能1秒或最多2秒内)的数据

说明:
appendfsync everysec 模式下,Redis 会每秒将 AOF 缓冲区数据同步到磁盘,理论上最多丢失 1 秒的数据,但由于主线程在同步时可能会检测到最近2秒内是否已进行 fsync,极端情况下可能丢失 2 秒数据。

三、如何减少断电带来的数据丢失?

  1. 启用合适的持久化策略

    • 同时启用 RDB 和 AOF:
      • RDB 快照便于备份和快速恢复,AOF 提供更高的数据完整性。
      • 推荐混合持久化模式:Redis 启动时先加载 RDB 快照,再重放 AOF 日志,确保数据更完整。

    示例配置(redis.conf):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # RDB 快照配置
    save 900 1
    save 300 10
    save 60 10000

    # 启用 AOF,并设置每秒同步
    appendonly yes
    appendfsync everysec

    # 开启混合持久化,使用 RDB 快照作为 AOF 文件头
    aof-use-rdb-preamble yes
  2. 设置合理的 AOF 刷盘策略

    • 使用 appendfsync everysec 模式

      每秒 fsync 一次,在性能和数据安全之间取得平衡。

      可通过命令:CONFIG SET appendfsync everysec

  3. 部署主从复制与高可用架构

    • 主从复制:当主节点断电时,从节点可以接管服务,降低数据丢失风险。
    • 高可用集群:部署 Redis Sentinel 或 Redis Cluster,确保故障转移时数据冗余。
  4. 硬件与系统保障

    • 稳定的硬盘:使用 SSD,并配置 RAID,确保写入过程中硬件故障风险降低。
    • UPS 电源:采用不间断电源(UPS)防止突发断电。
  5. 定期备份与异地灾备

    • 定期备份:定期备份 RDB 和 AOF 文件,异地存储,确保即使本地数据丢失也能恢复。

四、断电后的恢复步骤

  1. 检查持久化文件与日志

    • 查看 dump.rdbappendonly.aof 文件是否存在并完整。
    • 查看 Redis 日志确认是否成功加载持久化数据。
  2. 文件校验与修复

    • 若 AOF 文件损坏,可使用以下命令尝试修复: redis-check-aof --fix appendonly.aof
  3. 手动恢复(如必要)

    • 从备份文件中恢复数据,或手动执行重放操作,确保系统恢复到断电前状态。

五、总结

  • 断电风险
    Redis 断电时,由于数据主要存储在内存,未持久化的数据会丢失。
  • 持久化策略
    通过合理配置 RDB、AOF(或混合持久化)以及主从复制,高可用架构,可以有效降低数据丢失风险。
  • 配置与恢复
    采用 appendfsync everysec 模式能在性能与数据安全间取得平衡;断电后,通过日志检查、AOF 修复和备份恢复确保数据尽可能完整。

问:bgsave的原理是什么?⭐⭐⭐

bgsave相比于已废弃的save,后者阻塞服务器直到RDB过程结束,前者则通过fork创建子进程,持久化过程由子进程来完成,减少了主进程的阻塞时间。通过copy-on-write机制,父子进程共享内存,父进程继续提供读写服务,子进程根据父进程内存生成快照文件,并替换到RDB文件。

BGSAVE 是 Redis 的一种持久化机制,用于创建数据库的 RDB 快照(dump.rdb 文件),将内存中的数据异步保存到磁盘中。

BGSAVE 的工作流程

  1. 主线程发送持久化请求
    • 当客户端执行 BGSAVE 命令时,Redis 主线程会派生一个 子进程fork 操作)。
  2. 子进程创建 RDB 文件
    • 子进程将内存中的数据库快照序列化到临时 RDB 文件。
    • 持久化过程中,Redis 主线程继续处理客户端请求,不受影响。
  3. 文件替换与完成
    • 持久化完成后,临时 RDB 文件会原子地替换现有的 dump.rdb 文件。

关键技术点分析

阶段 操作描述
fork() 操作 主线程创建子进程,复制内存页(COW)。
数据序列化 子进程将数据序列化成 RDB 格式。
原子替换与完成 临时文件替换 dump.rdb

优点:

  1. 异步执行:主线程不阻塞,Redis 可继续处理请求。
  2. 完整快照:适用于全量数据备份。
  3. 文件安全性高:通过临时文件原子替换,确保文件完整性。

缺点:

  1. 内存开销大fork 会占用与主进程相同大小的内存,数据量大时内存占用翻倍。
  2. 性能影响fork 操作会阻塞主线程短暂时间,大内存环境中影响更明显。
  3. 数据丢失风险:仅保留快照时,最后一次 RDB 持久化后的数据修改可能丢失。

Redis 配置中的 BGSAVE 自动触发规则

Redis 配置文件中的 save 指令会自动触发 BGSAVE

1
2
3
save 900 1   # 900 秒内至少发生 1 次写操作
save 300 10 # 300 秒内至少发生 10 次写操作
save 60 10000 # 60 秒内至少发生 10000 次写操作

常见应用场景

  1. 定期全量备份:用于手动或自动创建数据库快照。
  2. 灾备方案的一部分:结合 AOF 提供数据持久化保障。
  3. 内存占用小的环境:适用于小型 Redis 数据集。

潜在问题与优化方案

  1. 大内存时 fork()
    • 优化:使用高性能 SSD,确保操作系统 fork() 性能更好。
  2. 数据丢失风险
    • 优化:结合 AOF 日志,配置 appendfsync always/everysec
  3. 性能影响
    • 优化:减小 Redis 实例的内存占用,分片部署。

总结

  • BGSAVE 是 Redis 中的重要持久化机制,适用于全量数据备份。
  • 结合 AOF 持久化与高可用方案(如主从复制、Redis Cluster),可构建高可靠性的数据存储系统。
  • 在生产环境中,应根据业务数据量与写入频率,调整 BGSAVE 配置策略,避免持久化过程中资源耗尽或服务中断。

问:RDB与AOF区别?

  1. RDB压缩文件格式紧凑,适合备份和全量复制,数据恢复快。但没法实时持久化,且有不同版本多个格式问题。

  2. AOF则不断的追加命令到文件(文本协议RESP),因此文件会不断变大,需要重写机制来压缩体积。重写时通过重写缓冲区保存此期间主进程响应的命令。

RDB 与 AOF 的区别详解

Redis 提供了两种数据持久化方式:RDB(Redis Database File)AOF(Append Only File)。它们在数据保存方式、性能表现、恢复速度等方面各有特点。

1. 持久化机制对比

特性 RDB AOF
触发方式 定期快照(手动或自动触发) 日志记录(追加式)
保存内容 全量快照,内存数据的完整镜像 每个写命令的执行日志
数据丢失风险 最多丢失最后一次快照后的数据 最多丢失 1 秒内数据(everysec
文件体积 相对小,存储压缩后的数据 相对大,记录每个命令日志
性能开销 fork 可能占用大量内存和 CPU 写命令附带磁盘 I/O 操作
数据恢复速度 恢复速度快,直接加载内存镜像 重放日志,恢复速度较慢
持久化文件名 dump.rdb appendonly.aof

2. 工作原理对比

RDB(Redis Database File)

  • 通过创建内存快照保存数据到磁盘。
  • 支持手动执行命令:SAVE(同步) 或 BGSAVE(异步)。
  • 配置文件触发规则:
1
2
3
save 900 1     # 900 秒内至少发生 1 次写操作时触发
save 300 10 # 300 秒内至少发生 10 次写操作时触发
save 60 10000 # 60 秒内至少发生 10000 次写操作时触发

AOF(Append Only File)

  • 将每个写操作追加到日志文件。
  • 刷盘模式:
1
2
3
4
appendonly yes
appendfsync always # 每次写入操作都同步到磁盘,最安全但性能差
appendfsync everysec # 每秒同步一次,推荐,性能与安全性折中
appendfsync no # 由操作系统自行决定同步时机,风险较高

3. 优缺点对比

特性 RDB 优点 RDB 缺点 AOF 优点 AOF 缺点
数据完整性 文件小,恢复快 可能丢失大量数据 数据更完整 恢复较慢,文件大
性能 最小性能影响 fork 占用内存 实时持久化更安全 写频繁时性能受限
使用场景 全量备份、冷备 不适合高频数据变化场景 实时持久化,重要数据保存 磁盘 I/O 成本高
灾难恢复能力 快速加载 数据可能较旧 最多丢失 1 秒内数据 重放日志速度慢

4. 推荐使用场景

  1. 仅使用 RDB(全量快照)
    • 数据变化不频繁,允许一定程度的数据丢失。
    • 定期做全量备份,适用于离线批处理服务。
  2. 仅使用 AOF(日志持久化)
    • 数据变化频繁,不能容忍数据丢失。
    • 日志文件需要定期重写,适用于重要数据存储服务。
  3. 混合使用 RDB 与 AOF(推荐)
    • Redis 提供了混合持久化选项:
1
aof-use-rdb-preamble yes  # 混合持久化:先写 RDB 快照,再附加 AOF 日志

总结:选择策略

  • 高性能要求(内存占用敏感):使用 RDB,减少磁盘 I/O。
  • 高可靠性要求(数据丢失敏感):使用 AOF,appendfsync everysec
  • 数据安全与恢复速度兼顾:启用 RDB + AOF 混合持久化

通过灵活配置,Redis 可以满足不同业务场景的数据持久化与容灾需求。

问:Redis 持久化数据和缓存怎么做扩容?TODODODODODO

如果 Redis 被当做缓存使用,使用一致性哈希实现动态扩容缩容。

如果 Redis 被当做一个持久化存储使用,必须使用固定的 keys-to-nodes 映射关系,节点的数量一旦确定不能变化。否则的话(即 Redis 节点需要动态变化的情况),必须使用可以在运行时进行数据再平衡的一套系统,而当前只有 Redis 集群可以做到这样。

Redis 持久化数据与缓存的扩容方案

Redis 的扩容分为 持久化数据扩容缓存扩容,需要根据业务需求、数据量和访问模式选择合适的扩展策略。

一、Redis 持久化数据扩容方案

  1. 垂直扩展(Scale-up)
  • 方案描述:升级 Redis 节点硬件,提升单机性能。

  • 适用场景:数据量和请求量增长不大时。

  • 操作步骤:

    • 选择内存更大的服务器。
    • 配置更高的 CPU、磁盘 I/O。
    • 迁移 Redis 实例数据。

优点

  • 简单快速,易于操作。

缺点

  • 存在单点瓶颈,硬件扩展上限受限。
  1. 水平扩展(Scale-out)
  • 方案描述:部署多台 Redis 实例,分散存储与请求负载。

扩展方案 1:主从复制(Master-Slave)

  • 将 Redis 主节点的数据复制到多个从节点。
  • 读操作分摊到从节点,主节点负责写入。
  • 提升 读性能,但 写性能 无法扩展。

扩展方案 2:Redis 哨兵模式(Sentinel)

  • 在主从复制基础上引入 Redis Sentinel,自动完成主从切换。
  • 增强了高可用性与自动故障转移。

扩展方案 3:Redis Cluster(官方推荐)

  • Redis 原生分布式集群,数据自动分片。
  • 支持 高可用高扩展性

操作步骤

  1. 配置 redis.conf 文件,启用集群模式:
1
2
3
4
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes
  1. 启动 Redis 节点:
1
redis-server redis.conf
  1. 使用命令行工具创建集群:
1
redis-cli --cluster create 192.168.0.1:7000 192.168.0.2:7001 ... --cluster-replicas 1

二、Redis 缓存扩容方案

  1. 垂直扩展(Scale-up)
  • 增加单个 Redis 节点的内存容量。

适用场景

  • 数据量不大,单节点性能足够。
  1. 水平扩展(Scale-out)

方案 1:客户端分片(Client Sharding)

  • 客户端根据一致性哈希或分片算法,将请求分配到多个 Redis 节点。
  • 示例框架:Jedis Sharded API、Spring Cache。

优点

  • 简单高效,支持不同 Redis 版本。

缺点

  • 需要客户端管理分片逻辑,客户端开发复杂。

方案 2:代理分片(Proxy Sharding)

  • 使用 Redis 中间件(如 Twemproxy、Codis)管理分片逻辑。
  • 适用场景:高并发、高吞吐应用。

优点

  • 开发透明,无需修改业务代码。

缺点

  • 中间件本身需要维护,存在额外延迟。

方案 3:Redis Cluster 模式(推荐)

  • Redis 自带集群管理功能,客户端透明分片。

三、数据迁移方案(扩容迁移注意点)

  1. 冷迁移(停机维护)
    • 在非高峰期停机,进行 Redis 数据全量迁移。
  2. 热迁移(在线迁移)
    • 使用 Redis 数据迁移工具,如:
      • redis-cli --pipe 批量导入导出。
      • redis-shake 等开源迁移工具。
  3. 双写与数据同步
    • 部署新 Redis 集群后,应用程序写入旧集群和新集群,完成数据同步。

四、扩容注意事项

  1. 监控与预警
    • 使用 Redis 监控工具(如 Prometheus、Grafana)监控内存、CPU、网络带宽等。
  2. 数据预分片与规划
    • 预估数据增长量,合理规划 Redis 集群节点数和分片策略。
  3. 节点冗余与备份
    • 配置主从复制或 Redis Cluster,确保节点冗余与高可用。
  4. 持久化策略与配置优化
    • 根据业务需求调整 RDB 与 AOF 的频率,减少磁盘 I/O 开销。

总结:选择扩容方案时的决策参考

需求场景 扩容方案
数据量小,增长有限 垂直扩展(Scale-up)
数据量大,增长迅速 水平扩展(Scale-out)
读请求远多于写请求 主从复制(Master-Slave)
数据高可靠性、持久化需求 Redis Cluster + AOF
纯缓存应用,高吞吐量 客户端分片 / 代理分片
高可用与自动故障恢复 Redis 哨兵模式 / Cluster

选择合适的扩容方案,结合 Redis 自带的分片与复制机制,能够构建高性能、可扩展和高可用的 Redis 架构,满足不同规模和业务需求的分布式缓存与持久化数据存储场景。


六. 锁

问:讲一下Redis分布式锁?说说怎么用redis实现分布式锁?为什么使用SETNX?

2.6版本以后lua脚本保证setnx跟setex进行原子性(setnx之后,未setex,服务挂了,锁不释放) a获取锁,超过过期时间,自动释放锁,b获取到锁执行,a代码执行完remove锁,a和b是一样的key,导致a释放了b的锁。 解决办法:remove之前判断value(高并发下value可能被修改,应该用lua来保证原子性)

SETNX:

  1. 原子操作,锁不存在的情况下完成创建
  2. 如果要做分布式锁,要用set k v nx ex,不存在和过期时间,避免死锁。

锁过期时间不好评估怎么办?

  • 比如设置过期时间10s,但业务逻辑执行超过10s。那么其释放锁之前,锁其实已经失效,别的线程已经可以拿锁。
  • 尽量冗余过期时间,但不能完美解决问题。过长就相当于死锁,影响性能,过低又有概率失效。
  • 增加看门狗:先设置过期时间,开启一个守护线程,定时检测锁的失效时间,若快过期了但操作还没结束,就自动对锁进行续期。

Redis 分布式锁的实现方式

1. 单节点实现(最简单方式)

步骤:

  1. 使用 SET key value NX EX seconds 原子操作。

    • NX:键不存在时才能成功设置,确保锁的独占性。
    • EX seconds:自动过期时间,防止死锁。

示例:

1
2
3
String lockKey = "myLock";
String lockValue = UUID.randomUUID().toString();
boolean isLocked = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);

解锁:

1
2
3
if (lockValue.equals(redisTemplate.opsForValue().get(lockKey))) {
redisTemplate.delete(lockKey);
}

2. Lua 脚本保证原子性解锁

为防止解锁时误删其他线程的锁,使用 Lua 脚本:

1
2
3
4
5
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end

Redis 分布式锁的特性分析

特性 实现方式
互斥性 SET NX 原子操作
死锁预防 锁自动过期 EX seconds
高可用性 Redis 高可用模式(主从、哨兵)
容错性 Redis 集群或 RedLock 算法

Redis 分布式锁的典型问题与解决方案

  1. 锁误删(安全性问题)
    • 解决方案:解锁时比对锁的唯一标识,使用 Lua 脚本保证原子操作。
  2. 锁续期(防止锁过期)
    • 解决方案:
      • 使用锁续期机制(如 Redisson)。
      • 后台任务定期续期。
  3. 锁失效(Redis 故障)
    • 解决方案:
      • 部署 Redis 高可用架构(如主从复制、哨兵模式)。
      • 使用 RedLock 算法。

Redis 分布式锁高级实现 - RedLock 算法

RedLock 是 Redis 作者提出的分布式锁算法,适用于 Redis 集群环境。

RedLock 的核心步骤:

  1. 客户端依次向多个 Redis 节点请求获取锁,使用相同的键和唯一标识。
  2. 成功获取大多数节点的锁后,视为成功获取分布式锁。
  3. 若获取锁失败或超时,释放已获取的锁。

应用场景与示例

应用场景:

  • 分布式任务调度
  • 库存扣减(秒杀场景)
  • 电商订单唯一性校验
  • 限制高并发操作

示例:库存扣减场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
String lockKey = "product_stock_123";
String requestId = UUID.randomUUID().toString();
boolean isLocked = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS);

if (isLocked) {
try {
// 执行业务逻辑(扣减库存)
} finally {
// 使用 Lua 脚本原子释放锁
String luaScript =
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
"return redis.call('DEL', KEYS[1]) " +
"else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class), Collections.singletonList(lockKey), requestId);
}
}

Redis 分布式锁总结

Redis 分布式锁实现简单高效,但需要注意锁误删、死锁与 Redis 故障等问题。对高并发和高可用要求较高的场景,推荐使用 RedissonRedLock 算法来增强分布式锁的可靠性和容错能力。

Jedis获取分布式锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import redis.clients.jedis.Jedis;
import java.util.Collections;

public class RedisDistributedLock {

private Jedis jedis;

public RedisDistributedLock(Jedis jedis) {
this.jedis = jedis;
}

/**
* 尝试加锁
* @param lockKey 锁的 key
* @param requestId 当前请求的唯一标识,通常用 UUID
* @param expireTimeInSeconds 锁的过期时间(秒)
* @return true 表示加锁成功;false 表示加锁失败
*/
public boolean acquireLock(String lockKey, String requestId, int expireTimeInSeconds) {
String result = jedis.set(lockKey, requestId, "NX", "EX", expireTimeInSeconds);
return "OK".equals(result);
}

/**
* 释放锁
* @param lockKey 锁的 key
* @param requestId 当前请求的唯一标识,用于验证是否为锁持有者
* @return true 表示释放成功;false 表示释放失败
*/
public boolean releaseLock(String lockKey, String requestId) {
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then " +
"return redis.call('del',KEYS[1]) else return 0 end";
Object result = jedis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(requestId));
return 1L == (Long) result;
}

public static void main(String[] args) {
// 示例:连接本地Redis服务器
Jedis jedis = new Jedis("127.0.0.1", 6379);
RedisDistributedLock lock = new RedisDistributedLock(jedis);
String lockKey = "myLock";
String requestId = java.util.UUID.randomUUID().toString();
int expireTime = 10; // 10秒超时

// 尝试加锁
if (lock.acquireLock(lockKey, requestId, expireTime)) {
try {
System.out.println("加锁成功,执行业务逻辑...");
// 业务逻辑处理
} finally {
// 释放锁
if (lock.releaseLock(lockKey, requestId)) {
System.out.println("释放锁成功");
} else {
System.out.println("释放锁失败");
}
}
} else {
System.out.println("加锁失败,稍后重试");
}
jedis.close();
}
}

Redisson 是一个功能丰富的 Java 客户端,封装了 Redis 的分布式数据结构和锁等功能,并提供了基于 Redlock 算法的分布式锁实现。使用 Redisson 可以大大简化开发过程,同时内置了看门狗续期机制,确保锁在业务执行过程中不会因超时被误释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import java.util.concurrent.TimeUnit;

public class RedissonLockExample {

public static void main(String[] args) {
// 配置 Redisson(单机模式示例)
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
// 若使用密码:.setPassword("yourPassword");
RedissonClient redissonClient = Redisson.create(config);

// 获取分布式锁对象,锁名称为 "myLock"
RLock lock = redissonClient.getLock("myLock");

try {
// 尝试获取锁,最多等待10秒,锁租期30秒
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
try {
System.out.println("Redisson 加锁成功,执行业务逻辑...");
// 执行业务逻辑
} finally {
// 释放锁
lock.unlock();
System.out.println("Redisson 释放锁成功");
}
} else {
System.out.println("Redisson 获取锁失败");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
redissonClient.shutdown();
}
}
}

tryLock 参数说明

  • 第一个参数表示获取锁的等待时间(最多等待10秒);
  • 第二个参数表示锁的持有时间(30秒);
  • 如果在等待时间内成功获取锁,则进入业务逻辑;否则返回获取失败。

看门狗机制
Redisson 的分布式锁内部会启动看门狗自动续期,确保在业务执行时间超过锁租期时自动延长锁的有效期(除非主动释放锁)。

问:Redisson?

  • 定义与特点
    Redisson 是基于 Redis 协议实现的 Java 客户端,不仅支持 Redis 的基本操作,还提供了分布式对象、分布式集合、分布式锁、分布式队列、远程服务调用等高层 API。例如,Redisson 提供的 RLockRMapRQueue 等接口,使得开发者可以像操作本地 Java 集合一样操作 Redis 中的分布式数据。
  • 优势
    • 易用性:Redisson 封装了底层的 Redis 操作,支持同步、异步以及反应式编程模式,并且与 Spring Boot 集成非常方便。
    • 分布式锁及扩展特性:内置了多种锁实现(例如可重入锁、读写锁、红锁等),并提供了看门狗续期机制,避免因执行时间过长而导致锁自动失效。
    • 分布式数据结构:除了锁之外,Redisson 还提供了分布式集合、列表、队列、信号量、布隆过滤器等数据结构,方便构建分布式应用。

问:RedLock?

定义与原理
Redlock 是由 Redis 的作者 Salvatore Sanfilippo(antirez)提出的一种分布式锁算法。它的核心思想是:

  • 在多个(建议至少 5 个)独立的 Redis 实例上同时尝试获取锁;
  • 只有在大多数(例如 3 个或更多)实例上成功获取锁时,才认为锁被成功获得;
  • 每个 Redis 实例中的锁都有一个唯一的值和超时时间,防止因客户端崩溃而导致死锁。
    这样,即使部分 Redis 实例出现故障或延迟,也可以保证整体锁机制的安全性和可用性。

使用场景与争议
Redlock 主要用于要求较高强一致性的场景,如分布式锁。但在实际生产中,关于 Redlock 的安全性和正确性也存在一定争议,具体是否采用需要结合业务场景评估。

Redisson 中的实现
Redisson 内置的分布式锁(例如 RLock)可以基于 Redlock 算法来实现分布式锁功能,同时内置了续期(看门狗)等机制来保证锁的持有时间足够长,从而降低锁误释放的风险。

问:redis锁续期问题?

Redisson 分布式锁与看门狗机制

  • Redisson 的分布式锁(RLock):Redisson 封装了 Redis 分布式锁的实现,使其支持可重入锁。它内部会启动一个看门狗线程,在锁持有期间不断检测锁的 TTL,并自动延长(续期)锁的有效期。默认情况下,这个看门狗会每隔一定时间(通常约为锁超时时间的一半或更短,例如 30 秒)执行一次续期操作。
  • 续期的目的:当业务逻辑执行时间超过初始锁的 TTL 时,看门狗可以防止锁意外过期,从而确保锁在整个业务过程中保持有效。

主从复制下的潜在问题:

  • 异步复制与主备切换

    Redis 的主从复制是异步的。即便 Redisson 的看门狗机制在主节点上正常延长锁的 TTL,但当主节点发生故障,由从节点提升为新的主节点时,存在以下问题:

    • 数据延迟或丢失:由于复制延迟,新主节点可能没有完全更新之前的锁状态。
    • 锁冲突:在主节点故障后,新主节点可能允许其他客户端(比如客户端2)获取锁,而原来持有锁的客户端(客户端1)依然认为自己持有锁,导致同一锁被多个客户端同时“拥有”。

解决办法

为了避免在主节点故障、主从切换时导致分布式锁混乱,可以考虑以下策略:

  • 故障转移期间保护锁:在主节点发生故障、从节点提升为新主节点的过程中,可以设置一个保护期(TTL),使得在这段时间内所有原有的锁自动失效或不再响应客户端的锁操作。这样,即使新主节点允许新的客户端加锁,也不会和旧锁冲突。
  • 锁失效机制:可在应用层设计,确保在主从切换发生时,通过特殊的机制(比如通过监控或内部逻辑)将旧锁强制失效或提前释放,确保只有一个客户端持有锁。
  • Redisson 内部改进:一些场景下,可以通过 Redisson 配置参数来调整看门狗续期和锁失效策略,使其对主从切换更为容错,例如缩短锁的续期周期或者在故障转移后重置锁状态。

总结

  • Redisson 的看门狗机制在正常情况下能自动延长锁的 TTL,确保锁在业务执行过程中不会过期;
  • 问题场景:由于 Redis 主从复制采用异步机制,当主节点宕机并由从节点提升为新主节点时,可能导致锁状态不一致,从而使得多个客户端误认为自己持有锁;
  • 解决策略:需要在主从切换期间设计一个保护期,使得旧锁在转移过程中自动失效,或者通过应用逻辑确保在故障转移后锁状态重置,从而避免多个客户端同时持有同一分布式锁。

这种设计要求在高可用架构中,除了依赖 Redisson 内部机制外,还需要在系统架构层面做好主从切换、故障检测和锁状态保护,才能确保分布式锁的一致性和正确性。

  1. 问题背景

Redis 主从复制是异步的,若主节点宕机,可能导致以下问题:

  • 锁丢失:锁在主节点写入成功,但未同步到从节点,主备切换后锁信息丢失。
  • 锁冲突:其他客户端在新主节点重新获取同一把锁,导致多个客户端同时持有锁。
  1. Redisson 解决方案:联锁(MultiLock)

Redisson 提供 RedissonMultiLock(基于 Redis 的 RedLock 算法思想,但实现更简化),通过 多节点独立加锁 降低主从锁冲突风险。

  • 加锁流程
    向多个独立的 Redis 节点(非主从关系)发起加锁请求,当多数节点(N/2 + 1)加锁成功时,视为整体加锁成功。

    1
    2
    3
    4
    5
    RLock lock1 = redisson1.getLock("lock");
    RLock lock2 = redisson2.getLock("lock");
    RLock lock3 = redisson3.getLock("lock");
    RedissonMultiLock multiLock = new RedissonMultiLock(lock1, lock2, lock3);
    multiLock.lock(); // 在多个独立节点上加锁
  • 锁续期
    联锁的每个子锁会单独启动看门狗任务续期。

  • 释放锁
    无论是否加锁成功,均向所有节点发起释放锁请求。

设计目标

  • 容错性:允许少数节点故障,只要多数节点存活即可保证锁有效性。
  • 安全性:即使某个节点主从切换后锁丢失,其他节点仍持有锁,整体锁仍有效。

局限性

  • 性能开销:需操作多个节点,网络交互次数增加。
  • 部署成本:需维护多个独立 Redis 实例(非主从或集群模式)。
  • 非绝对安全:极端情况下(如多数节点同时崩溃),仍可能发生锁冲突。

问:怎么提高缓存命中率?

  1. 提前加载。
  2. 增加Redis内存空间。
  3. 调整缓存的存储类型,使用更优的数据类型。
  4. 提高缓存的更新频次。

问:如何解决 Redis 的Key冲突问题?并发竞争 Key 问题?

Key的冲突问题?

  • 业务隔离:比如通过业务模块+系统名称+主键。
  • 分布式锁:解决并发竞争Key问题。

Redis 并发竞争 Key 问题:指的是多个客户端在高并发环境下同时对同一个 Key 执行读写操作,可能导致以下问题:

  1. 数据不一致:多个客户端并发修改相同的 Key,导致最终存储的值不确定,出现数据覆盖。
  2. 超卖/超扣:常见于库存扣减等场景,在没有正确加锁的情况下,多个客户端同时扣减库存,导致商品被超卖。
  3. 脏读/脏写:由于并发操作,客户端读取到未更新或已过期的数据,导致业务错误。

并发竞争 Key 的主要原因

  1. 高并发环境:大量客户端同时对 Redis 发起读写请求。
  2. 无锁操作:未使用分布式锁保护关键业务操作。
  3. 原子性缺失:多步操作没有原子性保障,如 GETSET
  4. 缓存不一致:缓存与数据库的数据不一致。
  5. 网络延迟与失败:客户端重试机制未正确设计。

Redis 并发竞争 Key 典型场景

  1. 电商抢购秒杀:库存扣减、订单生成。
  2. 分布式 ID 生成:多个客户端请求分布式唯一 ID。
  3. 分布式锁竞争:多节点服务竞争分布式锁。
  4. 分布式计数器:高并发访问计数器,如点击量、访问量统计。
  5. 排行榜与计分板:更新排行榜分数时的并发竞争。

示例

  • 示例 1:库存扣减超卖问题

    多个客户端同时执行以下代码:

    1
    2
    3
    4
    5
    6
    7
    int stock = Integer.parseInt(redisTemplate.opsForValue().get("product_stock"));
    if (stock > 0) {
    redisTemplate.opsForValue().set("product_stock", String.valueOf(stock - 1));
    System.out.println("扣减成功");
    } else {
    System.out.println("库存不足");
    }

    问题分析:

    • 多个客户端在 get("product_stock") 后同时获取了相同的库存值。
    • 检查 stock > 0 通过后,同时执行了 set("product_stock", stock - 1),导致库存被重复扣减。
  • 示例 2:缓存击穿(重建缓存)

    假设一个热点 Key 被大量请求,并且该 Key 恰好在某一时刻过期。
    多个客户端会同时尝试重建缓存,导致缓存重建多次,出现请求风暴。

  • 示例 3:分布式锁失效问题Redis 锁如果没有正确设置过期时间,或者 Redis 故障,可能导致分布式锁失效,引发并发竞争。

如何解决 Redis 并发竞争 Key 问题?

  1. 使用分布式锁:如 Redisson、Lua 脚本实现原子加锁与解锁。

    使用 Redis 分布式锁来确保对某个 Key 的操作是原子的,避免多个客户端并发修改同一 Key。

    示例:手动实现分布式锁(SET + Lua 脚本)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 获取锁
    boolean isLocked = redisTemplate.opsForValue().setIfAbsent("lockKey", "lockValue", 5, TimeUnit.SECONDS);

    if (isLocked) {
    try {
    // 执行业务逻辑
    System.out.println("获得锁,处理业务");
    } finally {
    // 释放锁(Lua 脚本原子性)
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    redisTemplate.execute((RedisCallback<Long>) connection ->
    connection.eval(script.getBytes(), ReturnType.INTEGER, 1, "lockKey".getBytes(), "lockValue".getBytes()));
    }
    }

    改进:使用 Redisson

    推荐使用 Redisson 内置分布式锁,支持自动续租与容错:

    1
    2
    3
    4
    5
    6
    7
    RLock lock = redissonClient.getLock("lockKey");
    try {
    lock.lock(10, TimeUnit.SECONDS);
    System.out.println("获得锁,处理业务");
    } finally {
    lock.unlock();
    }
  2. 使用 Lua 脚本:通过 Lua 脚本执行多步骤操作,保证操作的原子性。

    Redis 多个命令在事务中可能存在中断风险,使用 Lua 脚本执行多命令操作,确保操作的原子性。

    示例:库存扣减(原子操作)

    1
    2
    3
    4
    5
    6
    7
    8
    -- Lua 脚本:原子扣减库存
    local stock = redis.call("GET", KEYS[1])
    if tonumber(stock) > 0 then
    redis.call("DECR", KEYS[1])
    return "success"
    else
    return "fail"
    end
  3. 数据分片与分区:将热点 Key 拆分为多个子 Key,分散竞争。

    将热点 Key 拆分成多个小 Key,分散请求压力,避免竞争:

    示例:分片 Key 设计

    • user_score_1user_score_2、…、user_score_n
    • 请求时,哈希分片:hash(userId) % N
  4. 热点 Key 预防与缓存策略优化:如设置合理的缓存过期策略和预加载机制。

    1. 缓存穿透解决方案
      • 使用布隆过滤器(Bloom Filter)提前过滤无效请求。
    2. 缓存雪崩解决方案
      • 设置 Key 的过期时间时加上随机时间,防止大规模 Key 同时失效。
    3. 缓存击穿解决方案
      • 使用分布式锁或双重检查机制,避免并发请求同时重建缓存。
  5. 消息队列削峰:使用消息队列异步处理请求,避免 Redis 直接竞争。对于高并发写操作,可以将请求存入消息队列,后端异步处理,减少对 Redis 的直接竞争。

  6. 使用 Redis 事务和乐观锁(CAS):如 WATCH 命令,监控 Key 的变化。

    Redis 提供 WATCH 命令,可以在事务提交前检查 Key 是否被其他客户端修改。

    示例:乐观锁事务操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    redisTemplate.execute((RedisCallback<Object>) connection -> {
    connection.watch("balance".getBytes());
    byte[] balance = connection.get("balance".getBytes());
    int newBalance = Integer.parseInt(new String(balance)) - 100;
    if (newBalance >= 0) {
    connection.multi(); // 开启事务
    connection.set("balance".getBytes(), String.valueOf(newBalance).getBytes());
    List<Object> exec = connection.exec(); // 提交事务
    if (exec == null || exec.isEmpty()) {
    System.out.println("事务失败,重试...");
    } else {
    System.out.println("扣款成功,余额:" + newBalance);
    }
    } else {
    System.out.println("余额不足");
    }
    return null;
    });

总结

解决方案 适用场景 优点 缺点
分布式锁(Redisson) 数据唯一性与原子操作控制 简单易用,可靠性高 多线程环境可能死锁
Lua 脚本 高并发原子操作 原子性强,性能高 学习成本高
数据分片(Sharding) 热点 Key 拆分与分散压力 扩展性强,性能高 复杂度高
缓存策略优化 缓存高并发场景 缓解压力,成本低 设计难度较大
消息队列削峰 高并发写操作削峰 异步化,抗压强 数据时效性延迟
乐观锁事务(CAS) 更新敏感场景 数据一致性高 容易失败需重试

Redis常见方案

问:Redis如何实现限流?

算法 原理 性能 内存消耗 精度 优点 缺点 适用场景 优化技巧
固定窗口(计数器算法) 统计固定时间窗口内的请求总数,超过阈值则拒绝。 实现简单,内存消耗低 存在窗口临界突发流量问题 低频限流,对精度要求不高 结合Pipeline批量操作减少网络往返
滑动窗口 将时间划分为多个小窗口,统计连续滑动窗口内的总请求数。 精度高于固定窗口 内存占用较高 中等精度要求的API限流,API网关 使用ZSET的SCORE存储微秒时间戳提升精度
漏桶算法 以恒定速率处理请求,桶满则拒绝新请求。 平滑流量,防止突发压力 无法应对突发流量 流量整形,保护下游系统(如短信发送) 预生成令牌减少实时计算开销
令牌桶算法 以固定速率生成令牌,请求需获取令牌,无令牌则拒绝。 允许突发流量,弹性限流 实现复杂度较高 高并发允许突发的场景(如秒杀) 本地缓存部分令牌减少Redis访问频率

1. 固定窗口计数器(Fixed Window)

实现原理:利用INCREXPIRE命令统计时间窗口内的请求总数。

  • 将时间划分为固定窗口(如 1 分钟),每个窗口内允许固定数量的请求。
  • 使用 Redis 的 INCREXPIRE 命令实现计数和窗口重置。

Java代码(Spring Data Redis)

  • API 每分钟允许 100 次请求。每个请求调用 allowRequest(),Redis 计数并自动过期。
  • 临界时间问题:窗口切换时可能出现双倍流量(如 59s 和 1s 的请求合并)。
  • 不精确控制:无法限制窗口内的请求均匀分布。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class FixedWindowRateLimiter {
private Jedis jedis;
private String key;
private int maxRequests;
private int windowSec;

public FixedWindowRateLimiter(Jedis jedis, String key, int maxRequests, int windowSec) {
this.jedis = jedis;
this.key = key;
this.maxRequests = maxRequests;
this.windowSec = windowSec;
}

public boolean allowRequest() {
Long currentCount = jedis.incr(key);
if (currentCount == 1) {
jedis.expire(key, windowSec);
}
return currentCount <= maxRequests;
}
}


public boolean isAllowedFixedWindow(String key, int limit, int windowSec) {
String redisKey = "rate_limit:fixed:" + key;
// 原子操作:计数+设置过期时间
Long count = redisTemplate.execute(
new DefaultRedisScript<>(
"local current = redis.call('incr', KEYS[1])\n" +
"if current == 1 then\n" +
" redis.call('expire', KEYS[1], ARGV[1])\n" +
"end\n" +
"return current",
Long.class
),
Collections.singletonList(redisKey),
String.valueOf(windowSec)
);
return count != null && count <= limit;
}

2. 滑动窗口(Sliding Window)

实现原理:使用有序集合(ZSET)记录每次请求的时间戳,通过ZREMRANGEBYSCORE清理旧数据并统计窗口内请求数。

  • 记录每个请求的时间戳,统计最近时间窗口内的总请求数。
  • 使用 Redis 的有序集合(ZSet)存储时间戳。

Java代码

  • 用户每秒最多发送 10 条消息。每次发送消息时,清理旧时间戳并统计当前窗口内数量。
  • 内存消耗高:需存储所有时间戳,高并发时占用内存。
  • 性能开销:频繁的 ZSet 操作可能影响 Redis 性能。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class SlidingWindowRateLimiter {
private Jedis jedis;
private String key;
private int maxRequests;
private int windowSec;

public SlidingWindowRateLimiter(Jedis jedis, String key, int maxRequests, int windowSec) {
this.jedis = jedis;
this.key = key;
this.maxRequests = maxRequests;
this.windowSec = windowSec;
}

public boolean allowRequest(String userId) {
long now = System.currentTimeMillis() / 1000;
long windowStart = now - windowSec;

Pipeline pipeline = jedis.pipelined();
pipeline.multi();
pipeline.zremrangeByScore(key, 0, windowStart);
pipeline.zadd(key, now, userId + ":" + now); // 使用唯一标识避免重复
pipeline.zcard(key);
pipeline.expire(key, windowSec + 1);
Response<Long> count = pipeline.zcard(key);
pipeline.exec();
pipeline.close();

return count.get() <= maxRequests;
}
}

public boolean isAllowedSlidingWindow(String key, int limit, int windowSec) {
long now = System.currentTimeMillis();
long windowMillis = windowSec * 1000L;
String redisKey = "rate_limit:sliding:" + key;

// 删除窗口外的数据
redisTemplate.opsForZSet().removeRangeByScore(redisKey, 0, now - windowMillis);

// 统计当前窗口请求数
Long count = redisTemplate.opsForZSet().zCard(redisKey);
if (count != null && count >= limit) {
return false;
}

// 添加当前请求
redisTemplate.opsForZSet().add(redisKey, UUID.randomUUID().toString(), now);
// 设置过期时间避免内存泄漏
redisTemplate.expire(redisKey, windowSec + 1, TimeUnit.SECONDS);
return true;
}

3. 漏桶算法(Leaky Bucket)

漏桶算法将请求(或数据包)看作“水滴”,每个请求进入桶中,桶的漏水速度是固定的。每当一个请求进入桶中,如果桶没有满,那么它就会被处理。桶满了,则该请求会被丢弃或拒绝。

  • 桶大小:表示可以存储的请求数量。
  • 漏水速度:表示请求被处理的速率,通常是一个固定的速率。

如果桶已经满了,即已经有足够的请求被放入桶中,那么新到达的请求将被丢弃。

特点:

  • 稳定性:漏桶算法产生一个平稳的请求流量(固定的处理速率),即使有很多请求在短时间内到达,处理速度仍然是恒定的。
  • 丢弃策略:一旦桶满,新的请求会被丢弃(通常不允许任何超速请求)。

优缺点:

  • 优点:算法简单且直观。能够有效防止瞬时流量的冲击,保证请求以恒定速率处理。
  • 缺点:无法处理突发流量。它对流量的波动非常敏感,当突发请求超出桶的容量时,所有的请求都会被丢弃,甚至是合理的请求。

应用场景:

漏桶算法适用于那些需要平滑处理流量的场景,例如流量整形或网络带宽控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class LeakyBucketRateLimiter {
private Jedis jedis;
private String key;
private int capacity;
private double leakRate; // 漏出速率(请求/秒)

public LeakyBucketRateLimiter(Jedis jedis, String key, int capacity, double leakRate) {
this.jedis = jedis;
this.key = key;
this.capacity = capacity;
this.leakRate = leakRate;
}

public boolean allowRequest() {
long now = System.currentTimeMillis();
String luaScript =
"local key = KEYS[1] " +
"local now = tonumber(ARGV[1]) " +
"local capacity = tonumber(ARGV[2]) " +
"local leakRate = tonumber(ARGV[3]) " +
"local lastTime = tonumber(redis.call('get', key) or 0) " +
"local water = math.max(0, capacity - (now - lastTime) * leakRate) " +
"if water < capacity then " +
" redis.call('set', key, now, 'PX', 10000) " + // 设置过期时间
" return 1 " + // 允许请求
"else " +
" return 0 " + // 拒绝请求
"end";
Object result = jedis.eval(luaScript, 1, key, String.valueOf(now), String.valueOf(capacity), String.valueOf(leakRate));
return (Long) result == 1;
}
}

Lua脚本(原子操作)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
local key = KEYS[1]
local capacity = tonumber(ARGV[1]) -- 桶容量
local rate = tonumber(ARGV[2]) -- 漏水速率(单位:请求/秒)
local now = tonumber(ARGV[3]) -- 当前时间戳(秒)

local bucket = redis.call('HMGET', key, 'water', 'last_time')
local water = tonumber(bucket[1]) or 0
local last_time = tonumber(bucket[2]) or now

-- 计算漏水量:时间差 * 速率
local leaked = math.floor((now - last_time) * rate)
water = math.max(0, water - leaked)
last_time = now

-- 判断是否允许新请求
local allowed = false
if water + 1 <= capacity then
redis.call('HMSET', key, 'water', water + 1, 'last_time', last_time)
allowed = true
end

-- 设置过期时间(避免长期不用的key占用内存)
redis.call('EXPIRE', key, math.ceil(capacity / rate) + 10)
return allowed

Java调用代码

1
2
3
4
5
6
7
8
9
10
11
public boolean isAllowedLeakyBucket(String key, int capacity, int ratePerSec) {
String script = "..."; // 上述Lua脚本内容
long now = Instant.now().getEpochSecond();
return Boolean.TRUE.equals(redisTemplate.execute(
new DefaultRedisScript<>(script, Boolean.class),
Collections.singletonList("rate_limit:leaky:" + key),
String.valueOf(capacity),
String.valueOf(ratePerSec),
String.valueOf(now)
));
}

4. 令牌桶算法(Token Bucket)

令牌桶算法与漏桶算法的不同之处在于,它允许在一定时间内积累请求,但仍然会以一个固定的速率处理请求。令牌桶由一个桶和令牌组成,桶中有令牌,处理请求需要从桶中获取令牌。

  • 桶大小:表示可以存储的令牌数量,决定了突发流量的上限。
  • 令牌生成速率:表示每秒生成一定数量的令牌。
  • 请求处理:每个请求需要消耗一个令牌,如果桶中没有令牌,请求将被拒绝。如果桶中有令牌,则请求可以继续处理。

特点:

  • 灵活性:令牌桶算法允许突发流量的出现,桶中的令牌可以积累,允许一定数量的突发请求。系统能够在流量短时间内增大后,逐渐恢复到常态。
  • 突发流量控制:突发请求可以迅速被处理,前提是令牌桶中有足够的令牌。并且,在没有令牌的情况下,新请求会被阻塞或丢弃。

优缺点:

  • 优点:
    • 令牌桶能够平滑处理突发流量,并能在突发流量过后的时间里逐渐恢复。
    • 允许一定的请求积压,灵活性更强。
  • 缺点:
    • 不像漏桶那样严格限制速率,可能会造成流量的过多积累,导致短时间内的处理压力。

应用场景:

令牌桶算法更适用于那些需要处理突发请求的场景,比如网络流量控制、API 接口限流等。

缺点

  • 实现复杂:需维护令牌数量和时间戳。
  • 依赖时钟同步:分布式环境下需注意时间一致性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class TokenBucketRateLimiter {
private Jedis jedis;
private String key;
private int capacity;
private double refillRate; // 令牌填充速率(令牌/秒)

public TokenBucketRateLimiter(Jedis jedis, String key, int capacity, double refillRate) {
this.jedis = jedis;
this.key = key;
this.capacity = capacity;
this.refillRate = refillRate;
}

public boolean allowRequest(int tokensRequested) {
long now = System.currentTimeMillis();
String luaScript =
"local key = KEYS[1] " +
"local now = tonumber(ARGV[1]) " +
"local capacity = tonumber(ARGV[2]) " +
"local refillRate = tonumber(ARGV[3]) " +
"local tokensRequested = tonumber(ARGV[4]) " +
"local data = redis.call('hmget', key, 'tokens', 'lastRefillTime') " +
"local tokens = tonumber(data[1]) or capacity " +
"local lastRefillTime = tonumber(data[2]) or now " +
"local delta = math.max(0, now - lastRefillTime) " +
"local newTokens = delta * refillRate " +
"tokens = math.min(capacity, tokens + newTokens) " +
"if tokens >= tokensRequested then " +
" tokens = tokens - tokensRequested " +
" redis.call('hmset', key, 'tokens', tokens, 'lastRefillTime', now) " +
" redis.call('expire', key, 10) " + // 设置过期时间
" return 1 " +
"else " +
" return 0 " +
"end";
Object result = jedis.eval(luaScript, 1, key, String.valueOf(now), String.valueOf(capacity), String.valueOf(refillRate), String.valueOf(tokensRequested));
return (Long) result == 1;
}
}

Lua脚本实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
local key = KEYS[1]
local capacity = tonumber(ARGV[1]) -- 桶容量
local rate = tonumber(ARGV[2]) -- 令牌生成速率(单位:令牌/秒)
local now = tonumber(ARGV[3]) -- 当前时间戳(秒)

local bucket = redis.call('HMGET', key, 'tokens', 'last_time')
local tokens = tonumber(bucket[1]) or capacity
local last_time = tonumber(bucket[2]) or now

-- 计算生成的令牌数:时间差 * 速率
local generated = math.floor((now - last_time) * rate)
tokens = math.min(capacity, tokens + generated)
last_time = now

-- 判断是否允许请求(消耗1个令牌)
local allowed = false
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_time', last_time)
allowed = true
end

-- 设置过期时间
redis.call('EXPIRE', key, math.ceil(capacity / rate) + 10)
return allowed

生产环境注意事项

  1. 时钟同步:使用Redis的TIME命令获取时间,避免服务器间时钟差异。
  2. 集群模式:在Redis Cluster中,需确保限流Key通过{}强制路由到同一节点(如rate_limit:{user123})。
  3. 限流维度:按用户ID、IP、接口等多维度设计Key(如rate_limit:api_login:ip_192.168.1.1)。
  4. 降级策略:限流触发后返回特制响应(如HTTP 429),并记录日志供后续分析。

完整示例:Spring AOP + Redis限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Aspect
@Component
public class RateLimitAspect {
@Autowired
private RedisTemplate<String, String> redisTemplate;

@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
String key = rateLimit.key();
int limit = rateLimit.limit();
int window = rateLimit.window();

if (!isAllowedSlidingWindow(key, limit, window)) {
throw new RateLimitExceededException("请求过于频繁,请稍后再试");
}
return joinPoint.proceed();
}

private boolean isAllowedSlidingWindow(String key, int limit, int windowSec) {
// 滑动窗口实现代码(见上文)
}
}

// 使用注解控制接口限流
@GetMapping("/api")
@RateLimit(key = "api_index", limit = 100, window = 60)
public String api() {
return "success";
}

通过以上方案,可灵活应对不同场景的限流需求,结合Redis的高性能与原子性操作,构建高可靠的分布式限流系统。

Redis常见问题

问:Redis常见性能问题和解决方案?

  1. 持久化:早期只支持全量复制,后面又支持了部分复制。主节点不做持久化,从节点来做持久化。一般写操作在主节点进行。若是数据比较重要,开启了AOF持久化,策略为每秒同步一次。为了确保主从复制的流畅,一般会让主从服务器在同一个局域网,避免网路堵塞。
  2. 尽量避免主节点压力很大,而增加从库数量,会让主库压力更大。
    • 增加从库数量虽然能提高数据冗余和读取能力,但主节点的写入负载并不会因为从库增多而降低,反而需要额外的网络带宽和 CPU 资源来同步数据到更多的从库。
  3. 主从尽量不要采用网状结构,使用线性结构。主->从->从。对主节点的压力小。

问:缓存是如何淘汰的?Redis有哪些内存淘汰策略?

过期Key删除策略针对设置了过期时间的Key,而内存淘汰策略则针对当前运行内存超过Redis设置的最大内存。

淘汰策略触发条件

  • 当Redis内存使用量达到 maxmemory <bytes> 配置阈值时,所有新增数据的写操作将触发淘汰机制,根据预设策略释放内存空间。若未配置策略或选择noeviction,写操作将直接报错。
  • 在 64 位操作系统中,maxmemory 的默认值是 0,表示没有内存大小限制,那么不管用户存放多少数据到 Redis 中,Redis 也不会对可用内存进行检查,直到 Redis 实例因内存不足而崩溃也无作为。
  • 在 32 位操作系统中,maxmemory 的默认值是 3G,因为 32 位的机器最大只支持 4GB 的内存,而系统本身就需要一定的内存资源来支持运行,所以 32 位操作系统限制最大 3 GB 的可用内存是非常合理的,这样可以避免因为内存不足而导致 Redis 实例崩溃。

8种淘汰策略详解

策略名称 作用范围 淘汰规则 适用场景
noeviction 不淘汰 拒绝所有写操作,仅允许删除和读操作 金融订单、配置数据等不可丢失场景
allkeys-lru 全部Key 淘汰最近最少访问的Key(近似LRU算法) 存在明显热点数据,需保留高频访问内容
allkeys-lfu 全部Key 淘汰访问频率最低的Key(Redis 4.0+支持) 访问模式波动大,需识别长期冷门数据
volatile-lru 带过期时间的Key 在过期Key中淘汰最近最少访问的 混合持久化与缓存数据,需保护持久化Key
volatile-lfu 带过期时间的Key 在过期Key中淘汰访问频率最低的 短期缓存场景,需自动清理低频过期数据
allkeys-random 全部Key 随机淘汰任意Key 数据访问无规律,内存压力需快速释放
volatile-random 带过期时间的Key 随机淘汰过期Key 过期Key数量多,且淘汰顺序无关紧要
volatile-ttl 带过期时间的Key 优先淘汰剩余存活时间最短的Key 短期缓存(如验证码),依赖TTL自动失效

当触发淘汰时,Redis 会按照当前配置的策略,从候选集合中选取一个或多个键删除,以便为新数据腾出足够的内存。

  • 对于 LRU 和 LFU(Redis 4.0 以后支持 LFU 淘汰模式)的策略,Redis 并不会精确记录每个键的访问顺序或频次,而是通过采样算法(例如随机抽取多个键,选取其中最不活跃的一个)来近似实现,既能保持 O(log⁡N) 的时间复杂度,也能减少开销。
  • 随机淘汰(random)则直接随机选取键进行删除;
  • volatile-ttl 则会选择那些剩余 TTL 最短的键。

核心算法原理与优化

传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。

Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题:

  • 需要用链表管理所有的缓存数据,这会带来额外的空间开销;
  • 当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。

Redis 实现的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间

当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个

Redis 实现的 LRU 算法的优点:

  • 不用为所有的数据维护一个大链表,节省了空间占用;
  • 不用在每次数据访问时都移动链表项,提升了缓存的性能;

但是 LRU 算法有一个问题,无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。

因此,在 Redis 4.0 之后引入了 LFU 算法来解决这个问题。

LFU 全称是 Least Frequently Used 翻译为最近最不常用,LFU 算法是根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。

所以, LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些。

LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。Redis 对象的结构如下:

1
2
3
4
5
6
7
typedef struct redisObject {
...

// 24 bits,用于记录对象的访问信息
unsigned lru:24;
...
} robj;

Redis 对象头中的 lru 字段,在 LRU 算法下和 LFU 算法下使用方式并不相同。

在 LRU 算法中,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。

在 LFU 算法中,Redis对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),低 8bit 存储 logc(Logistic Counter)。

img

  • ldt 是用来记录 key 的访问时间戳;
  • logc 是用来记录 key 的访问频次,它的值越小表示使用频率越低,越容易淘汰,每个新加入的 key 的logc 初始值为 5。

注意,logc 并不是单纯的访问次数,而是访问频次(访问频率),因为 logc 会随时间推移而衰减的

在每次 key 被访问时,会先对 logc 做一个衰减操作,衰减的值跟前后访问时间的差距有关系,如果上一次访问的时间与这一次访问的时间差距很大,那么衰减的值就越大,这样实现的 LFU 算法是根据访问频率来淘汰数据的,而不只是访问次数。访问频率需要考虑 key 的访问是多长时间段内发生的。key 的先前访问距离当前时间越长,那么这个 key 的访问频率相应地也就会降低,这样被淘汰的概率也会更大。

对 logc 做完衰减操作后,就开始对 logc 进行增加操作,增加操作并不是单纯的 + 1,而是根据概率增加,如果 logc 越大的 key,它的 logc 就越难再增加。

所以,Redis 在访问 key 时,对于 logc 是这样变化的:

  1. 先按照上次访问距离当前的时长,来对 logc 进行衰减;
  2. 然后,再按照一定概率增加 logc 的值

redis.conf 提供了两个配置项,用于调整 LFU 算法从而控制 logc 的增长和衰减:

  • lfu-decay-time 用于调整 logc 的衰减速度,它是一个以分钟为单位的数值,默认值为1,lfu-decay-time 值越大,衰减越慢;
  • lfu-log-factor 用于调整 logc 的增长速度,lfu-log-factor 值越大,logc 增长越慢。

1. LRU(最近最少使用)

  • 传统实现:维护链表,移动访问节点至头部,淘汰尾部节点。
  • Redis优化:采用概率性LRU,随机采样5个Key,淘汰最久未访问的。
  • 优势:内存开销从O(n)降至O(1),性能稳定。
  • 缺陷:周期性批量操作可能导致热点数据误删。

2. LFU(最不经常使用)

  • Redis实现:每个Key记录访问计数器,随时间衰减计数器值。
  • 淘汰逻辑:优先淘汰计数器值最小的Key。
  • 优势:更好应对突发流量,识别长期冷门数据。
  • 配置项lfu-log-factor(计数器增长速率)、lfu-decay-time(衰减周期)。

3. TTL淘汰

  • 逻辑:仅针对设置过期时间的Key,按剩余时间升序淘汰。
  • 陷阱:若大量Key同时过期,可能引发缓存雪崩,需搭配随机过期时间使用。

策略选型指南

场景特征 推荐策略 原因
数据不可丢失 noeviction 避免业务中断,依赖外部扩容或清理机制
存在明显热点数据 allkeys-lru 保留高频访问数据,提升缓存命中率
访问分布均匀,无规律 allkeys-random 快速释放内存,避免算法开销
混合持久化与缓存数据 volatile-lru 保护持久化Key,仅淘汰缓存类数据
短期缓存(如会话数据) volatile-ttl 自动清理过期数据,减少手动维护成本
需要识别长期冷数据 allkeys-lfu 精确捕获低频率访问Key,优化内存利用率

配置与监控

设置内存淘汰策略有两种方法:

  • 方式一:通过“config set maxmemory-policy <策略>”命令设置。它的优点是设置之后立即生效,不需要重启 Redis 服务,缺点是重启 Redis 之后,设置就会失效。
  • 方式二:通过修改 Redis 配置文件修改,设置“maxmemory-policy <策略>”,它的优点是重启 Redis 服务后配置不会丢失,缺点是必须重启 Redis 服务,设置才能生效。

1. 设置内存阈值与策略

1
2
3
4
5
6
7
8
# 设置最大内存为2GB
CONFIG SET maxmemory 2gb

# 使用LFU策略淘汰所有Key
CONFIG SET maxmemory-policy allkeys-lfu

# 查看当前内存策略
CONFIG GET maxmemory-policy

2. 关键监控指标

  • 内存使用量used_memorymaxmemory
  • 淘汰Key数量evicted_keys
  • 缓存命中率:通过keyspace_hitskeyspace_misses计算
  • LFU计数器分布:使用redis-cli --hotkeys识别低频Key

3. 调优建议

  • 预防雪崩:为过期时间添加随机值,避免集中失效。
  • 容量规划:预留20%内存缓冲,避免频繁触发淘汰。
  • 混合存储:对持久化数据关闭淘汰(volatile-*策略),对缓存数据启用淘汰。

常见问题解决

1. 缓存污染(大量冷数据挤占热点)

  • 现象:批量读取历史数据导致热点数据被LRU淘汰。
  • 方案:切换为LFU策略,或使用volatile-*限定淘汰范围。

2. 淘汰性能瓶颈

  • 现象:内存触顶后,写入延迟陡增。
  • 方案:升级至Redis 4.0+使用LFU算法,或扩容集群分片。

3. 策略误配置导致数据丢失

  • 现象:误设allkeys-*策略删除持久化Key。
  • 方案:通过CONFIG REWRITE持久化配置,区分业务Key类型。

问:Redis过期key删除策略?以及惰性删除?

Redis所有数据结构都可以设置过期时间,一旦过期就会自动删除。

  • 会不会同一时间过多的Key过期,导致忙不过来?
  • Redis单线程,删除时间也占用线程,会不会删除过于频繁导致线上的读写指令出现卡顿?
    • 会,扫描过期字典会循环直到过期占比稀疏,而且内存管理器要频繁回收内存页,造成CPU消耗。
    • 所有要尽量避免大批量Key同时到期,给定随机范围。

过期的Key集合

  • Redis将每个设置了过期时间的Key放到一个独立的字典中,定时遍历该字典来删除到期的Key。
  • 除了定时遍历外,还会通过惰性删除来删除过期Key。
  • 即集中处理+零散处理。

Redis设置过期时间

设置过期时间和永久有效:EXPIRE 和 PERSIST 命令。

  • expire <key> <n>:设置 key 在 n 秒后过期,比如 expire key 100 表示设置 key 在 100 秒后过期;
  • pexpire <key> <n>:设置 key 在 n 毫秒后过期,比如 pexpire key2 100000 表示设置 key2 在 100000 毫秒(100 秒)后过期。
  • expireat <key> <n>:设置 key 在某个时间戳(精确到秒)之后过期,比如 expireat key3 1655654400 表示 key3 在时间戳 1655654400 后过期(精确到秒);
  • pexpireat <key> <n>:设置 key 在某个时间戳(精确到毫秒)之后过期,比如 pexpireat key4 1655654400000 表示 key4 在时间戳 1655654400000 后过期(精确到毫秒)

当然,在设置字符串时,也可以同时对 key 设置过期时间,共有 3 种命令:

  • set <key> <value> ex <n> :设置键值对的时候,同时指定过期时间(精确到秒);
  • set <key> <value> px <n> :设置键值对的时候,同时指定过期时间(精确到毫秒);
  • setex <key> <n> <valule> :设置键值对的时候,同时指定过期时间(精确到秒)。

查看某个 key 剩余的存活时间,可以使用 TTL <key> 命令。取消 key 的过期时间,则可以使用 PERSIST <key> 命令。

如何判定 key 已过期了?

每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。字典实际上是哈希表,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找。当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:

  • 如果不在,则正常读取键值;
  • 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。

三种过期删除策略

Redis 中的键可以设置过期时间,超时的键会被删除。为保证内存高效利用,Redis 提供了三种过期键删除策略:

  1. 定时删除(主动删除)不常用
    • 机制:在设置键的过期时间时,Redis 内部会创建一个定时器,键到期时自动删除。
    • 优点:内存释放及时,避免内存占用。定时删除对内存是最友好的。
    • 缺点:高并发场景下频繁检查过期键,可能影响 Redis 性能。在内存不紧张但 CPU 时间紧张的情况下,将 CPU 时间用于删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。所以,定时删除策略对 CPU 不友好。
  2. 惰性删除(被动删除)
    • 机制:每次访问键时,Redis 会检查该键是否过期,过期则删除。
    • 优点:不主动扫描,降低系统性能开销。惰性删除策略对 CPU 时间最友好。
    • 缺点:如果过期键长期未被访问,可能导致大量内存占用,产生 内存泄漏 风险。惰性删除策略对内存不友好。
  3. 定期删除(主动删除)
    • 机制:Redis 定期运行后台任务,扫描一定数量的键,删除其中的过期键。不会扫描字典中所有的Key,而是采用了贪心策略:每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。如果删除占比超过1/4,就再重复选择一定数量的Key。
    • 配置参数:
      • hz 参数: Redis 配置文件中的 hz 值(默认 10)控制检查频率。即每秒10次过期扫描。
      • active-expire-cycle-max-burst 限制一次扫描的最大键数。
    • 优点:在后台异步执行,降低实时性要求。通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。
    • 缺点:扫描频率与负载成正比,可能漏掉一些过期键。内存清理方面没有定时删除效果好,同时没有惰性删除使用的系统资源少。难以确定删除操作执行的时长和频率。如果执行的太频繁,定期删除策略变得和定时删除策略一样,对CPU不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。

实际Redis采用的删除策略

Redis 删除策略的实际应用与优化

  • 策略组合:Redis 同时使用 惰性删除定期删除,并不使用性能开销较大的 定时删除
  • 优化策略:
    1. 设置合理的过期时间: 确保缓存数据不过期太快或积压太久。
    2. 分批设置过期时间: 避免大量键同时过期引发缓存雪崩。
    3. 监控与诊断: 使用 INFO STATS 查看 expired_keys 指标,监控过期键删除情况。

常用命令示例

1
2
3
4
5
6
7
8
9
10
11
12
13
# 设置键的过期时间
SET mykey "value" EX 60 # 60 秒后过期
EXPIRE mykey 120 # 重设过期时间为 120 秒

# 查看键的过期时间
TTL mykey # 查看剩余时间(秒)
PTTL mykey # 查看剩余时间(毫秒)

# 删除键
DEL mykey # 手动删除键

# 查看 Redis 过期统计信息
INFO STATS

从库的过期策略

  • Redis从库不会进行定期扫描,而是被动的。主库的Key到期时,会在AOF文件中添加一条del指令,同步到从库后删除过期的key。
  • 因为数据同步是异步进行的,所以若del指令没有及时到从库执行的话,就会主从不一致。

问:Redis 回收进程如何工作的?

Redis 检查内存使用情况,如果大于 maxmemory 的限制, 则根据设定好的策略进行回收。一个新的命令被执行,等等。所以不断地穿越内存限制的边界,通过不断达到边界然后不断地回收回到边界以下。如果一个命令的结果导致大量内存被使用(例如很大的集合的交集保存到一个新的键),不用多久内存限制就会被这个内存使用量超越。

  • Redis 回收进程的主要目标是管理内存,防止内存耗尽。当内存达到配置的 maxmemory 限制时,Redis 会启动内存回收机制,根据配置的淘汰策略删除部分键。

Redis 回收内存的触发条件

  1. 写入新数据时触发:

    • 当 Redis 内存使用量达到 maxmemory 限制时,写操作会触发内存回收。
  2. Redis 淘汰策略启用:

    • Redis 根据 maxmemory-policy 参数决定如何回收键。

Redis 回收内存的执行步骤

  1. 命中检测:
    • Redis 在每次写操作时检查是否超过内存限制。
  2. 键筛选:
    • 根据淘汰策略,选择合适的键进行删除。
  3. 删除键:
    • 删除符合策略的键,释放内存。
  4. 重试写入:
    • 在内存释放后,重新尝试写入新数据。

问:热数据与冷数据?如何利用Redis处理热点数据?进行过缓存预热吗?

进行过缓存预热吗?缓存预热容易出现缓存击穿/雪崩。

  • 什么是热数据和冷数据?

    • 热数据:在较短时间内频繁访问、读写请求密集的数据。数据更新之前最少读两次。
    • 冷数据:指那些访问频率低、不常更新但需要长期保存的数据。数据更新之前没被读过,或是只被读过一次。
  • 如何利用Redis处理热点数据?

    1. 提前缓存热点数据

    • 预加载数据: 在 Redis 中预热热点数据,避免高并发场景下数据库负载过重。
    • 示例: 项目启动时批量加载热门商品、文章等。

    2. 利用 Redis 数据结构

    • Sorted Set (ZSet): 排序存储,按访问次数统计热门数据。
    • HyperLogLog: 近似去重计数,快速统计数据访问量。

    3. 热点缓存分片(拆分)

    • 缓存分片: 针对热点 Key,手动将其拆分为多个 Key,分散压力。
    • 示例: hotkey_1, hotkey_2 等。

    4. 利用 Redis 分布式锁

    • 互斥锁: 热点数据更新时加锁,防止缓存击穿。
    • 示例: 使用 SET key value NX EX 实现分布式锁。

    5. 缓存雪崩和击穿保护

    • 缓存雪崩: 设置缓存过期时间的随机值,避免批量过期。
    • 缓存击穿: 对热门数据设置较长的过期时间,防止数据库超负载。

    6. 使用多级缓存架构

    • 内存 + Redis + 数据库: 在内存中缓存最常用数据,Redis 存储次常用数据,数据库存储冷数据。

    设置合理的过期时间和内存淘汰策略

    • 针对热点数据,可以设置较长或永不过期的 TTL,以避免频繁过期导致缓存重建(缓存击穿)。
    • 同时,通过配置 Redis 的内存淘汰策略(如 allkeys-lruvolatile-lru 或 LFU 策略),确保内存有限时,保留访问频率最高的数据,淘汰冷数据。

    分布式锁防止缓存击穿

    • 当热点数据失效时,为防止大量并发请求直接访问数据库,可以在缓存未命中时利用 Redis 的分布式锁(如使用 SETNX+EX)确保只有一个线程或实例负责重建缓存,其它请求等待重建完成后再读取缓存数据。

    缓存预热和本地缓存

    • 缓存预热: 在系统启动或低峰期时提前加载热点数据到 Redis 中,减少冷启动时数据库的压力。
    • 本地缓存: 将热点数据加载到应用服务器的本地缓存(例如JVM内存、Ehcache),从而分散 Redis 集群的压力,避免单个 Redis 节点成为瓶颈。

    分布式部署和数据分片

    • 通过搭建 Redis 集群,将数据分布到多个节点上,避免某个节点因热点数据请求过多而过载。
    • 同时,在应用层面可以对热点数据进行备份,即在多个节点上保存一份副本,从而实现负载均衡。

    2

问:Redis怎么解决热点Key问题?hot key出现造成集群访问量倾斜解决办法?

  • 什么是热点Key问题?会导致什么后果?
    • 热点Key问题指的是某个或少数几个key在短时间内被大量并发访问,导致单个Redis实例或节点承受过高压力,进而出现响应延迟增大、内存或CPU资源耗尽,甚至可能导致节点宕机,从而影响整个系统的稳定性和可用性。
    • 什么是Hot Key热键?
      • Redis 集群 中,hot key(热键)是指某些键的访问频率远高于其他键。由于 Redis 是基于哈希槽来分片的,如果某些键所在的哈希槽被频繁访问,而其他哈希槽的负载较轻,就会导致 集群访问量倾斜,即某些节点负载过高,其他节点空闲或负载较低。这种情况可能会影响 Redis 集群的性能,增加响应延迟,甚至可能导致节点过载崩溃。
  • 解决办法:

总结:

在 Redis 集群中出现 hot key 造成集群访问量倾斜的原因通常是因为某些键频繁访问,导致集群某些节点负载过重。为了缓解这一问题,可以采用以下措施:

  1. 通过应用层调整热点数据的分布:使用哈希槽分布优化或前缀/加盐方式来打散热键。
  2. 通过 Redis 集群的重新分片(Resharding)实现负载均衡:手动或自动迁移哈希槽。
  3. 优化分片键选择:选择合理的分片键来避免单个哈希槽过载。
  4. 通过限流策略控制访问频率:在应用层实施限流机制来减少热点数据的访问。
  5. 合理配置 Redis 缓存淘汰策略:使用 LRU 或 LFU 等策略淘汰不常用数据,释放资源。

这些措施可以有效地减少 hot key 对 Redis 集群的负载倾斜,提升集群的稳定性和性能。

1.本地缓存

缓解Redis压力,减少对热Key的直接访问。(例如使用Ehcache、Guava Cache或简单的HashMap)

  • 应用启动时预热本地缓存,将热点数据加载到本地内存中。
  • 使用Cache-Aside模式:请求数据时先查询本地缓存;若命中,则直接返回;若未命中,再查询Redis(或数据库),然后更新本地缓存。
  • 当数据更新时,通过发布订阅、回调或主动刷新机制同步更新本地缓存,保证数据一致性。

2.请求分摊

把热Key拆分为多个子Key,将读请求分摊到多个Key上,降低单Key的压力。利用分片算法的特性,对key进行打散处理(给hot key加上前缀或者后缀,把一个hotkey 的数量变成 redis 实例个数N的倍数M,从而由访问一个 redis key 变成访问 N * M 个redis key)

  • 在应用层,对原有热点Key进行逻辑拆分,生成多个子Key,每个子Key缓存数据的一个副本。
  • 在写更新时,同步更新所有子Key或采用异步批量更新。
  • 在读时,通过hash算法或随机策略选择其中一个子Key进行查询,从而平摊流量。

通过应用层进行热点数据分布调整

通过改变访问热点数据的 访问模式,将热点数据分散到不同的哈希槽,从而缓解集群的负载倾斜。

解决办法:

  • 改变热点键的命名规则:在 Redis 中,键是通过哈希槽来分配的。哈希槽是通过计算键的 CRC16 值来分配的,因此不同的键被分配到不同的哈希槽。如果多个热键在同一个哈希槽,容易导致该哈希槽的负载过重。可以通过改变键的命名规则,使用不同的前缀或加盐的方式,避免多个热键分布到同一个哈希槽。比如,在对某些热点数据进行访问时,可以在键名上添加前缀或者随机字符进行哈希槽的分配,分散热键的压力:
    • 键名 user:1234user:abc1234
    • 键名 session:1234session:xyz1234
  • 改变应用访问模式:如果某个操作频繁访问某个特定的键,可以将热点数据分散到多个不同的键或哈希槽中。例如,可以通过将某个热点数据拆分成多个小块并分别存储在多个键中(例如,分页存储),从而避免单个键成为集群的瓶颈。

通过选择合适的 分片键(sharding key),可以在 Redis 集群中实现更合理的负载分布,减少热点键的影响。

解决办法:

  • 选择合适的分片键:为了避免热点数据集中在单个哈希槽上,可以选择一个能均匀分布数据的分片键。例如,在电商应用中,如果数据按商品 ID 分片,而某些商品的访问量极高(例如热销商品),那么就会导致该哈希槽成为热点。可以考虑将商品 ID 与其他维度(如商家 ID、类别 ID)结合使用作为分片键,从而实现更均匀的分布。
  • 使用多层级分片键:可以考虑使用 复合分片键,即组合多个字段来构造键名,避免单一字段的热度集中。例如,"user:<user_id>:data:<data_id>" 而不是单独使用 "user:<user_id>" 作为分片键。这样可以将数据更均匀地分配到不同的哈希槽。

3.限流

对热Key访问进行限流,防止过多请求进入。

  • 利用Redis的原子计数器(如使用INCR命令)结合过期时间(EXPIRE)来实现简单的限流。例如,为每个请求来源(IP或用户)在特定时间窗口内累计请求数,超过阈值则拒绝或延迟处理。
  • 也可以在应用层引入限流组件(如令牌桶或漏斗算法)对热点Key的请求进行限流,从而降低Redis压力。

通过限流的方式,控制对某些热点数据的访问频率,避免单个键被过度访问,减轻集群的负载。

解决办法:

  • 限流策略:在应用层实现限流机制,控制某个热点数据的访问频率。可以使用如 令牌桶算法漏桶算法滑动窗口算法 来进行限流,从而减少热点数据的访问请求,避免热点数据导致的负载倾斜。

    例如,可以使用 Redis 自身的 Lua 脚本来实现限流,或者在应用层进行限流,减少对 Redis 的频繁访问。

4.监控和报警

通过Redis的INFO命令或监控工具,对于不可预知的热Key场景,接入热点探测系统,定期上报key的调用次数,热点探测系统检测是否热Key,通过SDK通知各个应用节点快速构建本地缓存。

  • 利用Redis的INFO命令、MONITOR命令或第三方监控工具(如Redis-faina、Redis-Stat)收集各Key的访问统计数据。
  • 配置热点探测系统或SDK,当某个Key的访问频次超过预设阈值时,自动上报并通知相关应用节点。
  • 结合日志、报警平台,实现自动告警和人工干预流程,以便及时采取应急措施。

备份热点Key

对于极为热点的Key,可以在多个Redis节点上冗余存储(例如手动复制多份Key,或通过客户端进行数据分布),从而分散单节点的访问压力。

缓存预热

当检测到热点趋势时,通过预热机制提前将数据加载到缓存中,减少因初次请求引发的缓存击穿问题。

问:什么是缓存穿透、缓存击穿、缓存雪崩?如何避免?三者对比?

什么是缓存穿透、缓存击穿、缓存雪崩?

  • 缓存穿透:解决的是请求的数据从一开始就不存在,需要防止无效请求查询数据库。
    • 频繁请求查询系统中不存在的数据;即数据库和Redis都不存在的数据。
  • 缓存击穿:解决的是缓存中热点数据失效,多个请求同时访问数据库,通常通过分布式锁和异步加载来防止。
    • Redis的缓存热点Key失效(或是不存在),但数据库存在数据,大量并发请求访问数据库。
  • 缓存雪崩:解决的是大量缓存同时过期,导致数据库压力陡增,通常通过设置随机过期时间和提前预热缓存来避免。

1.缓存穿透

解决方案总结:

  1. Cache Null 策略:缓存 null 值,在数据库查询不到数据时,将 null 存入缓存,避免后续重复查询数据库。适用于数据不存在的情况。
  2. 布隆过滤器:在访问数据库前,使用布隆过滤器判断数据是否存在,从而避免不必要的数据库查询,减少数据库压力。适用于防止缓存穿透。
  3. Hystrix:使用断路器模式处理高并发请求,避免缓存击穿时直接访问数据库,通过降级和隔离策略保障系统稳定性。适用于分布式系统中的容错处理。

详细:

  • 定义:

    • 缓存穿透是指请求的数据在缓存和数据库中都不存在,即缓存中没有数据,数据库也没有该数据,导致每次请求都需要查询数据库,从而给数据库带来极大的压力。缓存无法起到加速的作用,直接查询数据库,导致性能下降。
  • 发生场景:

    • 请求的数据根本不存在,例如非法的请求或者恶意攻击。
    • 没有使用合理的过滤机制进行数据验证。
  • 解决方案:

    1. 空值缓存策略(Cache Null):又叫 null key 策略

      方案

      • 当查询到某个数据不存在时,缓存一个空值,防止每次请求都访问数据库。当缓存未命中时,如果后端查询没有数据返回(例如数据库没有该数据),可以将 null 存入缓存(设置5分钟过期时间),以避免每次查询时都去访问数据库。浪费时间,一般不使用。

      如何阻止并发查询到数据库

      1. 通过setnx key value给hot key上锁
      2. setnx成功,访问数据库
      3. setnx失败,sleep(sleep的线程不占用CPU和内核调度、而且一般也不会命中到同一个Redis,做好线程池的扩容缩容就可以)

      优缺点

      • 优点: 避免了高并发的请求同时访问数据库的问题,减少了数据库压力。
      • 缺点: 如果没有进行过期控制,可能会在缓存中存储 null 值,导致缓存的有效性降低。因此需要合理设置 null 值的缓存时间。
    2. 布隆过滤器(Bloom Filter)

      方案

      • 在查缓存以及数据库之前,首先使用布隆过滤器判断该数据是否存在。如果判断该数据不在集合中,则直接返回空值或错误提示,从而避免不必要的查询。

      • 布隆过滤器有一个特点:可能会误判(即判断某个元素是集合成员时,实际上它不在集合中)。但它的误判率可以控制,通过适当的设计来降低误判的可能性。

      • Google布隆过滤器:基于内存,重启失效不支持大数据量,无法在分布式场景。

      • Redis布隆过滤器:可扩展性,不存在重启失效问题,需要网络io,性能低于google。

      原理

      • 通过二进制数组+Hash算法来实现,Redis中由Bitmaps来实现。
      • 比如UserId的Set集合,通过Hash算法散列到二进制数组上,判断UserId是否存在,只需查找Hash后的散列坐标是否为1。
      • 误判问题:
        • 哈希冲突:Hash计算后在数组上,结果为1,但其实不在Set集合。
        • 但Hash计算后不在数组上,就一定不在集合里。
      • 优化:减少哈希冲突的方案。

      优缺点:

      • 优点: 可以有效地避免 缓存穿透,避免无意义的查询数据库,提高系统性能,减少数据库的压力。
      • 缺点: 布隆过滤器本身可能会出现误判(假阳性),但通过合理设计误判率可以在一定程度上降低影响;同时布隆过滤器本身需要消耗内存和存储空间。
    3. Hystrix

      方案

      • 由 Netflix 提供的一个用于处理分布式系统中服务调用的容错框架。Hystrix 的核心目标是通过 断路器模式降级策略隔离策略,来应对和解决高并发情况下的服务调用问题,提高系统的稳定性。
      • 用于避免缓存击穿引发的数据库压力过大。尤其是当缓存系统宕机或某些缓存请求没有命中时,Hystrix 能够通过断路器和降级机制来避免直接访问数据库。
      • 使用 Hystrix 进行容错处理,在缓存查询失败或缓存不可用时,能够快速响应并返回默认值或做降级处理,避免请求直接访问数据库,导致数据库压力过大。

      优缺点:

      • 优点: 可以有效隔离故障,避免一个请求的失败导致整个系统崩溃。通过 Hystrix,系统可以在高并发情况下保证一定的容错能力。
      • 缺点: 引入了额外的复杂性,增加了系统的维护成本和监控成本,需要配置合适的参数和规则以达到理想的效果。

实现代码:

空值策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public String getData(String key) {
// 从缓存中获取数据
String value = redis.get(key);
if (value == null) {
// 缓存没有数据,查询数据库
value = databaseQuery(key);
if (value == null) {
// 如果数据库中也没有数据,缓存空值(null)
redis.set(key, "null", expirationTime);
} else {
// 如果数据库有数据,将数据缓存
redis.set(key, value, expirationTime);
}
}
return value;
}

布隆过滤器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public String getData(String key) {
// 判断 key 是否存在于布隆过滤器中
if (!bloomFilter.mightContain(key)) {
return null; // 如果不存在,直接返回空值或默认值
}

// 从缓存中获取数据
String value = redis.get(key);
if (value == null) {
// 缓存没有数据,查询数据库
value = databaseQuery(key);
if (value != null) {
// 如果数据库有数据,将数据缓存
redis.set(key, value, expirationTime);
}
}
return value;
}

Hystrix

1
2
3
4
5
6
7
8
9
public String getData(String key) {
try {
// 通过 Hystrix 封装的缓存查询操作
return hystrixCommand.execute(() -> redis.get(key));
} catch (Exception e) {
// 发生错误时执行降级策略
return "default value"; // 返回默认值
}
}

2.缓存击穿

缓存击穿的问题通常发生在缓存中的热点数据失效时,多个请求并发访问数据库,导致数据库的压力急剧增大。为了解决这个问题,常用的策略包括:

  1. 使用缓存互斥锁:通过分布式锁保证只有一个请求能访问数据库并更新缓存,避免重复查询。
  2. 设置永不过期或较长的过期时间:避免热点数据频繁失效。
  3. 设置随机过期时间:避免缓存中的数据同时过期,造成大量请求访问数据库。
  4. 异步更新缓存:当缓存失效时,返回默认值,后台异步加载数据并更新缓存。
  5. 降级策略:缓存失效时返回默认值或静态缓存数据,避免数据库压力过大。
  6. 合理缓存粒度:将缓存粒度拆分,避免热点数据缓存击穿影响其他数据。
  7. 使用本地缓存结合 Redis:通过本地缓存减轻缓存击穿对 Redis 和数据库的影响。

详细:

  • 定义:

    • 缓存击穿是指缓存中的热点数据过期后(或者没有被缓存过的),同时有多个请求并发访问数据库,导致多个请求直接访问数据库,造成数据库压力过大。通常发生在缓存中的某些热点数据过期时,并且没有有效的同步机制防止多个请求同时访问数据库。
  • 发生场景:

    • 热点数据过期或失效:如果缓存中的某些热点数据过期,并且多个并发请求同时访问这些数据,缓存会被击穿,所有请求都访问数据库,从而造成数据库压力过大。
    • 缓存失效时间较短: 如果缓存设置的过期时间较短,尤其是对于某些热点数据,可能会导致频繁的缓存失效,进而引发缓存击穿问题。
    • 缓存清除操作不当: 如果使用了定期清理缓存(如定时任务清理),某些热点数据可能在清理时失效,导致并发请求同时查询数据库。
    • 缓存没有更新: 如果缓存中的数据没有及时更新,或者由于网络问题或其他原因导致缓存没有更新,那么请求会直接访问数据库,造成缓存击穿。
  • 解决方案:

    1. 预防缓存击穿的发生:

      • 热点数据永不过期
        • 对于一些热点数据,可以设置 永不过期(不设置过期时间)或非常长的过期时间,避免这些数据频繁失效,减小缓存击穿的风险。然后,使用后台线程定时异步刷新这些缓存的数据,确保数据的时效性。
        • 具体做法: 设置一个较长的缓存过期时间,或者使用异步更新缓存的策略。后台线程定期从数据库更新缓存,避免每次请求都去访问数据库。redis.set(key, value, Long.MAX_VALUE); // 设置永不过期
      • 很难预测,所以只能尽量。这些方法都有局限性和不适用性。
    2. 互斥锁

      方案:

      • 使用分布式锁(如 Redis 的 SETNX 或 Redisson)保证只有一个请求能查询数据库并更新缓存,其余请求等到缓存更新后再获取数据。
      • 当缓存中的数据失效时,多个请求可能会同时去查询数据库,这时可以使用 互斥锁 来避免多个请求同时访问数据库。只有第一个请求会去查询数据库并更新缓存,后续请求则可以直接从缓存中获取数据。

      如何阻止并发查询到数据库

      1. 通过setnx key value给hot key上锁
      2. setnx成功,访问数据库
      3. setnx失败,sleep(sleep的线程不占用CPU和内核调度、而且一般也不会命中到同一个Redis,做好线程池的扩容缩容就可以)

      代码:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      String lockKey = "lock:" + key;
      if (redis.setnx(lockKey, "locked")) {
      // nx ex获取分布式锁,查询数据库并更新缓存
      try {
      value = databaseQuery();
      redis.set(key, value, expirationTime);
      } finally {
      redis.del(lockKey); // 释放锁
      }
      } else {
      // 如果没有获得锁,等待一段时间后重试
      Thread.sleep(100);
      return getCache(key);
      }
    3. 异步加载:没有处理并发请求问题。

      方案:

      • 当缓存失效时,不直接访问数据库,而是返回一个默认值(如空值或过期数据),并且在后台异步去查询数据库并更新缓存。这样,前端用户能够快速收到响应,避免对数据库造成大量压力。

      示例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      public String getData(String key) {
      String value = redis.get(key);
      if (value == null) {
      // 返回默认值
      value = "default";
      // 异步刷新缓存
      executor.submit(() -> {
      String dbValue = databaseQuery(key);
      redis.set(key, dbValue, expirationTime);
      });
      }
      return value;
      }
    4. 延时双删:对于缓存的更新,采用双重删除策略,先删除缓存,再等待一定时间后再次删除缓存,以避免并发的更新请求。

3.缓存雪崩

  • 定义:缓存雪崩是指缓存中存储的数据在同一时间大规模失效或过期,导致大量请求同时访问数据库,从而使得数据库承受极大的压力,甚至崩溃。通常发生在缓存的过期时间设置一致,或者缓存刷新失败时。

  • 发生场景:

    • 多个缓存的过期时间相同,导致大量缓存同时失效。
    • 在高并发场景下,大量请求同时访问数据库,造成数据库的雪崩式压力。
    • 缓存过期策略不合理,未做有效的错峰操作。
  • 解决方案:

    • 设置随机过期时间:避免缓存中的数据在同一时刻全部过期,可以为每个缓存项设置不同的过期时间,增加随机性,防止集中的过期时刻。
    • 缓存数据提前预热:定时刷新缓存或者提前加载缓存,避免缓存失效时大量请求打到数据库。
    • 异步更新缓存:当缓存失效时,异步去加载新的缓存,保证不会大量并发访问数据库。
    • 分布式限流:对于高并发的请求,可以通过限流机制减少请求压力,避免数据库承受过大的负载。
  • 示例:

    为缓存设置不同的过期时间,避免缓存同时过期:

1
2
3
// 设置过期时间时加入随机数
long expirationTime = 60 * 60 + (Math.random() * 60); // 随机时间
redis.set(key, value, expirationTime);

什么是缓存雪崩?

  • 同一时刻大量缓存失效;
  • 缓存雪崩是指在分布式缓存系统中,某一时刻大量的缓存失效,导致大量请求直接访问后端数据库,造成数据库压力过大,从而可能导致数据库崩溃,进而引发整个系统不可用的现象。这种情况通常发生在缓存中的大量数据在同一时刻过期,或者某些缓存系统宕机后,所有请求都转发到后端数据库。

缓存雪崩的原因:

  1. 大量缓存失效: 如果缓存中的大量数据在同一时间过期(例如所有的缓存都设置了相同的过期时间),当缓存失效时,所有的请求将直接去访问数据库,导致数据库瞬间压力增大,甚至崩溃。
  2. 缓存集群宕机: 如果缓存服务器或缓存集群发生故障,所有的缓存请求都会直接访问后端数据库,增加数据库的压力。
  3. 大规模的缓存更新失败: 如果缓存更新失败,导致大量的缓存失效,瞬间对后端数据库的访问量急剧增加。

因为数据库是系统架构的瓶颈,所以要尽量控制有效请求到达数据库,即使要放大前置环节的复杂度和成本。

处理方法:

  1. 缓存数据增加过期标记
  2. 设置不同的缓存失效时间
  3. 双层缓存策略C1为短期,C2为长期
  4. 定时更新策略

问题:

  1. Redis故障,并发数据库扛不住:
    • Redis分片集群
    • 引入EHCache
    • 引入限流组件:hystrix
  2. Redis大量Key的TTL过期:把TTL岔开,随机设置过期时间。比如10W条数据分散到10s,平均1s1W条,1万条也分散到1s的各个节点。
  • AKF分治:AKF立方体包括三个维度:X轴(水平复制)、Y轴(功能分解)、Z轴(数据分片)。
  • 每一个key对应的锁隔离
  • 第三方提供的锁可以分片

如何解决缓存雪崩问题:

  1. 缓存过期时间设置为不同的值(加随机值)

为了避免所有缓存的过期时间集中在同一时刻,可以给每个缓存设置一个随机的过期时间,使得缓存失效的时间错开。

  • 具体做法: 对于每个缓存项,设置一个基础的过期时间,再加上一个随机值。例如,如果基础过期时间是 10 分钟,可以在每个缓存项的过期时间上加一个 0 到 60 秒之间的随机数。这样即使有大量的缓存失效,也不会在同一时间失效,能有效避免雪崩效应。

    示例:

    1
    2
    long expirationTime = 10 * 60 * 1000 + (Math.random() * 60 * 1000);
    redis.set(key, value, expirationTime);
  1. 使用缓存预热

在缓存系统启动或缓存数据即将过期时,可以通过提前加载缓存的方式,避免缓存失效导致的高并发请求直接访问数据库。缓存预热通常是指将一些关键数据在应用启动时预先加载到缓存中,或者在数据即将过期时提前重新加载。

  • 具体做法: 在缓存失效之前,后台可以定期刷新缓存,或者通过定时任务(如定时查询数据库)来更新缓存中的数据。
  1. 缓存互斥锁(防止击穿)

当缓存中的数据失效时,可能会有大量请求同时去数据库获取数据,导致数据库压力过大。为了避免这种情况,可以使用 互斥锁 来确保只有一个请求能够查询数据库并重新缓存数据。

  • 具体做法: 当缓存数据失效时,多个请求可能同时访问数据库。为避免多个请求重复访问数据库,可以在请求中使用 Redis 锁机制(例如使用 SETNX 或 Redisson 的分布式锁)来实现互斥锁。

    示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    String lockKey = "lock:" + key;
    if (redis.setnx(lockKey, "locked")) {
    // 获取锁,查询数据库并更新缓存
    try {
    value = databaseQuery();
    redis.set(key, value, expirationTime);
    } finally {
    redis.del(lockKey); // 释放锁
    }
    } else {
    // 如果没有获得锁,等待一段时间后重试
    Thread.sleep(100);
    return getCache(key);
    }
  1. 使用本地缓存与远程缓存结合

本地缓存(如 GuavaCaffeine)可以用来缓存热点数据,这样即使远程缓存(如 Redis)出现雪崩问题,本地缓存也能继续工作,避免直接访问数据库。

  • 具体做法: 本地缓存用于存储最常用的数据,而 Redis 用于存储大部分的数据。这样即使 Redis 崩溃,仍然可以从本地缓存中获取部分数据,减少数据库压力。
  1. 合理设置缓存更新策略(异步更新)

对于一些不频繁更新的数据,可以采用 异步更新策略,即缓存失效后,不立即从数据库中加载数据,而是先返回一个默认值(如空值或过期数据),并在后台异步从数据库中获取数据并更新缓存。

  • 具体做法: 当缓存失效时,返回一个空值或过期值,异步线程从数据库加载并更新缓存。这样可以避免瞬间的大量数据库请求。

    示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public String getData(String key) {
    String value = redis.get(key);
    if (value == null) {
    // 返回默认值
    value = "default";
    // 异步刷新缓存
    executor.submit(() -> {
    String dbValue = databaseQuery(key);
    redis.set(key, dbValue, expirationTime);
    });
    }
    return value;
    }
  1. 降级策略

当缓存失效或出现异常时,可以设计 降级策略,例如将缓存中的数据暂时置为过期状态并进行异步加载,或者直接从数据库中加载数据并进行限流,避免对数据库造成过大压力。

  • 具体做法: 当 Redis 崩溃或者出现访问延迟时,可以设计应用级的降级策略,将部分请求直接转到数据库中,或返回一个静态缓存的版本,避免影响整体的服务质量。
  1. 监控和报警机制

建立监控机制,监控缓存的命中率、缓存容量、缓存服务器的负载等。当发现缓存命中率低或者缓存集群出现异常时,可以及时预警,防止问题扩大。

  • 具体做法: 使用 Redis 自带的 INFO 命令或其他监控工具,如 Prometheus 和 Grafana,来实时监控 Redis 的运行状态。一旦检测到异常,及时报警,并采取应急措施。

总结:

缓存雪崩是指在缓存大量数据同时过期或缓存服务宕机时,导致请求集中涌向数据库,造成数据库压力过大,最终可能引发系统崩溃。为了避免缓存雪崩,可以采取以下措施:

  • 设置不同的缓存过期时间并加随机值;
  • 使用缓存预热策略;
  • 使用缓存互斥锁避免缓存击穿;
  • 配置本地缓存和远程缓存结合;
  • 采用异步缓存更新策略;
  • 设置合理的降级策略;
  • 实时监控并设置报警机制。

通过这些策略,可以有效避免缓存雪崩对系统性能和稳定性的影响。

对比总结

问题类型 定义 主要原因 解决方案
缓存穿透 请求的数据在缓存和数据库中都不存在,导致每次都查询数据库。 缓存未命中,且数据库中也没有数据,可能是恶意请求或非法请求。 使用布隆过滤器、空值缓存策略、前端校验。
缓存击穿 热点数据缓存失效后,但数据库有数据,多个请求同时查询数据库,导致数据库压力过大。 热点数据过期,多个请求同时访问数据库,造成数据库压力过大。 使用分布式锁、异步加载、延时双删。
缓存雪崩 大量缓存同时失效,导致大量请求访问数据库,给数据库带来巨大的压力。 缓存的过期时间设置一致,或者缓存刷新失败。 设置随机过期时间、缓存预热、异步更新缓存、分布式限流。

问:Redis AKF分治?

一、X轴扩展:水平复制(Replication)

核心思想:通过数据副本提升系统可用性和读吞吐量,一主多从架构。
Redis实现

  1. 主从复制(Master-Slave)
    • 主节点(Master)处理写请求,异步复制数据到从节点(Slave)。
    • 从节点提供读服务,分担主节点压力,支持故障切换(需Sentinel或Cluster配合)。
  2. 哨兵模式(Sentinel)
    • 监控主从节点健康状态,自动故障转移(Failover),实现高可用。
    • 客户端通过Sentinel获取最新主节点地址,实现透明切换。

适用场景

  • 读多写少:通过多副本提升读并发能力(如热点数据查询)。
  • 容灾恢复:主节点宕机时,从节点快速接管服务。

局限

  • 写性能瓶颈:主节点单点写入,无法横向扩展写能力。
  • 数据延迟:异步复制导致从节点数据短暂不一致。

二、Y轴扩展:功能拆分(Functional Decomposition)

核心思想:按业务功能拆分Redis实例,隔离不同类型数据。
Redis实现

  1. 业务维度拆分

    • 不同业务线使用独立Redis集群(如订单缓存、用户会话、商品库存)。

    • 示例

      1
      2
      3
      4
      # 订单服务使用独立Redis
      SET order:1001 "{...}"
      # 用户服务使用另一Redis集群
      SET user:2001 "{...}"
  2. 数据类型拆分

    • 按数据结构特性分配实例(如String存储简单缓存,Sorted Set处理排行榜)。
  3. 读写分离架构

    • 写请求集中到主节点,读请求分散到多个从节点或专用读集群。

适用场景

  • 复杂业务系统:避免不同业务竞争资源,降低耦合性。
  • 性能隔离:防止某类数据操作(如大Key扫描)影响全局性能。

局限

  • 运维成本:多实例增加部署、监控复杂度。
  • 数据一致性:跨实例事务需额外协调(如分布式锁)。

三、Z轴扩展:数据分片(Sharding)

核心思想:按数据特征(如Key哈希值)将数据集拆分到多个节点,实现水平扩展。
Redis实现

  1. 客户端分片
    • 应用层计算分片路由(如CRC32(key) % N),直连多个Redis实例。
    • 缺点:需业务代码维护分片逻辑,扩容需数据迁移。
  2. 代理分片
    • 使用中间件(如Twemproxy、Codis)代理请求,自动路由到后端节点。
    • 优点:业务无感知,支持动态扩缩容。
  3. Redis Cluster
    • 官方分片方案,将数据划分为16384个哈希槽(Slot),每个节点负责部分槽。
    • 特性
      • 自动槽迁移:支持在线扩容、缩容。
      • 高可用:主从节点组内复制,主宕机时从节点提升为新主。

适用场景

  • 大数据量:单机内存无法容纳全量数据(如社交平台用户关系链)。
  • 高并发写入:分散写压力到多节点(如秒杀库存扣减)。

局限

  • 跨节点操作:不支持多Key事务(除非在同Slot)。
  • 迁移开销:槽迁移期间可能影响性能。

四、AKF分治组合应用

实际生产环境中,常结合多个维度构建立体化扩展架构:

场景 扩展策略 架构示例
电商平台核心缓存 Y+Z轴 按业务拆分订单、用户、商品缓存(Y轴),每类缓存按哈希分片(Z轴)。
实时排行榜系统 X+Z轴 每个分片(Z轴)部署主从集群(X轴),支撑高并发读写。
全局会话管理 X+Y轴 独立会话Redis集群(Y轴),主从复制(X轴)保障高可用。
海量日志存储 Z轴(Cluster) 使用Redis Cluster分片存储日志ID与内容,按时间范围分片。

五、选型建议与注意事项

  1. 优先X轴:若读压力大且数据量可控,优先通过主从复制扩展。
  2. 慎用Y轴:业务拆分需评估运维成本,避免过度碎片化。
  3. Z轴标配:数据量超单机内存时,必须采用Cluster或代理分片。
  4. 监控告警:集群状态下,需监控槽分布、节点负载及迁移状态。
  5. 客户端适配:使用Cluster时,确保客户端支持重定向(MOVED/ASK)与智能路由。

六、总结

Redis通过AKF分治的三维扩展,完美适配不同规模的业务需求:

  • X轴:简单复制,快速提升读能力与可用性。
  • Y轴:业务解耦,实现资源与性能隔离。
  • Z轴:数据分片,突破单机容量与性能极限。

问:缓存和数据库一致性问题?如何保证缓存与数据库双写时的数据一致性?双写一致性问题?

缓存与数据库的一致性问题、常见场景、双写一致性问题

  1. 什么是缓存与数据库的一致性问题?

    • 缓存与数据库的一致性指缓存中数据与底层数据库的数据在任意时刻处于相同状态。然而,由于缓存与数据库的读写操作存在时序差异,在高并发场景下极易出现数据不一致。
  2. 常见的不一致场景?

    场景 原因 示例
    缓存穿透后延迟更新 缓存失效瞬间大量请求直接查询数据库,未及时回填缓存导致后续请求继续穿透。 商品详情页缓存过期后,瞬间被10万请求击穿数据库。
    并发写操作时序错乱 多线程/分布式环境下,写操作顺序无法保证,导致最终状态不一致。 用户A修改余额为100,用户B同时修改为200,缓存与数据库更新顺序错乱。
    缓存更新失败 缓存服务异常或网络抖动,导致数据库更新成功但缓存未更新。 订单支付成功后更新数据库,但Redis因超时未更新,后续查询显示未支付。
    异步复制延迟 主从数据库同步存在延迟,缓存读取从库旧数据。 用户注册后主库写入成功,但缓存从从库读取到未注册状态。
  3. 双写一致性问题?

    双写操作的顺序方案:

    1. 先更新缓存,再更新数据库。
    2. 先更新数据库,再更新缓存。
    3. 先删除缓存,后更新数据库。
    4. 先更新数据库,后删除缓存。

    1和2,第一步更新成功、第二步更新失败,就会不一致。而且第一种因为缓存数据在,很难发觉数据库数据丢失。第二种还会有并发问题。

    如果我们是写数据库场景频繁、读数据场景较少的需求,数据可能还未读到,缓存就已经被频繁的更新了。

    当同时更新缓存和数据库时,任何一步失败都可能引发数据不一致。典型问题包括:

    1. 写后读不一致:A线程更新数据库后未更新缓存,B线程读取到旧缓存数据。
    2. 并发写覆盖:多线程并发更新导致最终缓存与数据库状态不一致。
  4. 当更新缓存的代价很小时,最好是更新缓存,以保证较高的缓存命中率。若更新缓存的代价很大,就最好选择删除缓存。

    第三种也会有问题,比如A、B同时请求,A先删除缓存、再去数据库更新数据;此时B看到缓存中为空,会去数据库进行查询、甚至补录到Redis;此时A还未更新成功,请求B查到的是旧值;就产生了缓存与数据库不一致的问题。最简单的解决方案就是延迟双删。

    第四种就是Cache Aside Pattern即旁路缓存。

解决方案

方案对比与选型建议

方案 一致性强度 性能影响 复杂度 适用场景
Cache Aside 最终一致 读多写少,允许短暂不一致
延迟双删 最终一致 高并发写入,需降低脏数据概率
Binlog监听 最终一致 系统重构,需解耦业务与缓存
分布式事务 强一致 极差 金融交易,容忍低吞吐量
  • 可以使用分布式事务来解决,但意义不大。最多读多写稀有场景下可以尝试。
  • 读写尽量发生在Redis上,性能高一些。再去更新数据库就会遇到丢失数据、数据不一致的情况。看业务对数据不一致的容忍度。
  • 若先动数据库,后更新缓存,数据时效性就差,数据更新有时差。Redis可能数据更新失败,数据仍有不一致。

缓存与数据库一致性需根据业务容忍度选择策略:

  • 容忍短暂不一致:优先使用Cache Aside + 延迟双删。
  • 要求最终一致:引入Binlog监听 + 消息队列。
  • 强一致性需求:仅限金融场景,牺牲性能换取强一致。
  1. 为什么推荐“删缓存”而非“更新缓存”?
    • 避免无效更新:并发写时可能覆盖其他线程已更新的数据。
    • 减少计算开销:直接删除比序列化新值更高效。
  2. 如何处理缓存删除失败?
    • 重试机制:结合MQ实现异步重试(至少3次)。
    • 设置缓存过期时间:作为兜底,即使删除失败,旧数据也会自动失效。
  3. Binlog方案如何保证顺序性?
    • Kafka分区有序:同一主键的Binlog事件路由到同一分区,确保顺序消费。
    • 本地队列排序:在消费者内部按事件时间戳排序处理。

具体实现示例

假设你有一个商品信息,需要缓存到 Redis 中:

  1. 更新商品信息:

    • 首先删除 Redis 中商品的缓存。
    • 更新数据库中的商品信息。
    • 使用延迟双删策略,等待几百毫秒后,重新检查 Redis 中该商品信息是否存在,如果存在则删除。
  2. 读取商品信息:

    • 从 Redis 中读取商品信息。
    • 如果 Redis 中没有该商品信息,去数据库中查询,并更新缓存。
1. 强一致性方案(极少使用)
  • 分布式事务(2PC/3PC)

    • 实现:通过XA协议保证缓存与数据库操作的原子性。

    • 缺点:性能极差(吞吐量下降80%+),仅适合金融级强一致场景。

    • 代码示例

      1
      2
      3
      4
      5
      @Transactional
      public void updateData(Data data) {
      jdbcTemplate.update("UPDATE table SET ..."); // 数据库操作
      redisTemplate.opsForValue().set(data.getId(), data); // 缓存操作
      }
2. 最终一致性方案(主流选择)
  • 策略一:Cache Aside Pattern(旁路缓存)

    • 思路: 在写操作时,先更新数据库,再删除缓存。读操作时,先读缓存,未命中再查询数据库并重建缓存。

    • 优点: 简单且广泛应用,适用于大部分读多写少的场景。一般线上删除再更新缓存的速度比DB快,一般只有查询比删除慢的情况下才会出现偶尔的不一致。

      缺点: 如果删除缓存操作失败,可能会产生短暂的不一致;并且并发更新时仍需注意竞态问题。

    • 写操作:先更新数据库,后删除缓存(非更新)。

    • 读操作:缓存命中直接返回;未命中则查询数据库并回填缓存。

    • 优势:避免并发写导致的缓存与数据库不一致。

    • 改进措施:

      • 可以在删除缓存后,进行异步重建缓存;
      • 利用分布式锁确保同一数据更新时只有一个线程去重建缓存。
    • 代码逻辑

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      public void updateData(Data data) {
      // 1. 更新数据库
      jdbc.update(data);
      // 2. 删除缓存
      redis.delete(data.getId());
      }

      public Data getData(String id) {
      Data data = redis.get(id);
      if (data == null) {
      data = jdbc.query(id);
      redis.set(id, data, 30, TimeUnit.MINUTES);
      }
      return data;
      }
  • 策略二:延迟双删(Delayed Double Deletion)(针对高并发场景)

    • 概念:当你更新数据库时,不直接更新缓存,而是先删除缓存中的相关数据,然后更新数据库。接着,异步等待一定时间后,再次检查缓存中是否还有旧数据,如果有则重新删除。

    • 原因:数据库更新时,可能会有多个请求并发更新同一数据,删除缓存时会有竞争情况,可能导致缓存和数据库不一致。因此,通过延迟的方式再次删除缓存,可以避免这种竞争。

    • 优缺点:

      • 优点:可以保证缓存删除后,数据库中的数据一定是最新的。
      • 缺点:可能会存在短时间内缓存和数据库不一致的情况,但通常这个时间较短,且通过过期策略来解决。
    • 步骤

      1. 删除缓存
      2. 更新数据库
      3. 延迟数百毫秒后再次删除缓存(清除可能的脏数据)
    • 补充:

      • 休眠时间根据项目读数据的耗时再加上几百毫秒作为写数据的耗时,确保读请求结束,写请求可以删除掉读请求造成的缓存脏数据。
      • 主从库的同步也有可能比较耗时,休眠时间可能还要加上主从同步的延时时间。
    • 适用场景:应对“先更新数据库,再删缓存”期间出现的并发读脏数据。

    • 实现示例

      1
      2
      3
      4
      5
      6
      7
      8
      public void updateData(Data data) {
      // 第一次删除缓存
      redis.delete(data.getId());
      // 更新数据库
      jdbc.update(data);
      // 延迟500ms后二次删除
      executor.schedule(() -> redis.delete(data.getId()), 500, TimeUnit.MILLISECONDS);
      }
  • 策略三:异步监听数据库变更(无侵入方案)

    • 思路: 更新数据库后,不直接操作缓存,而是利用数据库的 binlog 进行变更捕捉(如 Canal),再由后台任务异步同步更新缓存。

      优点: 实现了最终一致性,降低了双写操作中的网络延迟和失败风险;

      缺点: 具有一定的延迟,不适用于对数据强一致性要求极高的场景。

    • 实现:通过数据库Binlog(如MySQL)监听数据变更,由中间件(Canal、Debezium)捕获变更事件后更新缓存。

    • 流程

      1
      MySQL → Binlog → Canal Server → Kafka → 缓存更新服务 → Redis
    • 优势:业务代码无侵入,保证最终一致性。

    • 部署步骤

      1. 部署Canal Server并配置同步MySQL Binlog。
      2. 编写消费者监听Binlog事件,解析后更新Redis。
3. 降级补偿方案
  • 消息队列重试:将缓存更新操作发送至MQ,失败时自动重试。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public void updateData(Data data) {
    // 更新数据库
    jdbc.update(data);
    // 发送缓存删除消息至MQ
    kafkaTemplate.send("cache-update-topic", data.getId());
    }

    @KafkaListener(topics = "cache-update-topic")
    public void handleCacheUpdate(String id) {
    try {
    redis.delete(id);
    } catch (Exception e) {
    // 记录日志并重试
    kafkaTemplate.send("cache-retry-topic", id);
    }
    }

Canal + binlog 方案

Binlog 是什么?

binlog(Binary Log,二进制日志)是 MySQL 提供的一种逻辑日志,记录了 MySQL 中的 INSERTUPDATEDELETE 等数据变更操作,主要用于数据恢复、主从复制、数据同步等场景。

MySQL binlog 记录了:

  • 数据的变更(DML:插入、更新、删除)。
  • 事务的开始和结束(Commit、Rollback)。
  • 数据库结构的变化(DDL)。

Canal 是什么?

Canal 是阿里巴巴开源的**数据库变更捕获(CDC,Change Data Capture)**工具,基于 MySQL 主从复制协议,模拟 MySQL 从库,解析 MySQL 主库的 binlog,并以**实时订阅**的方式将数据变化发送给下游(如 Redis、Elasticsearch、Kafka)。

Canal + binlog 方案工作原理

  1. 数据变更:MySQL 数据库发生 INSERTUPDATEDELETE 操作,产生 binlog。
  2. 日志解析:Canal 作为模拟的 MySQL slave,实时读取 MySQL 主库 binlog 日志。
  3. 数据消费:Canal 解析 binlog 并生成数据变更事件(Data Change Event,DCE),输出到 Kafka、RocketMQ、Redis 等下游。
  4. 更新缓存:应用服务监听 Canal 推送的变更事件,及时更新 Redis 缓存,确保缓存与数据库一致。

📊 数据流示意图

1
2
3
4
5
scssCopyEditMySQL (主库)
binlog
Canal (模拟从库)

Redis (缓存)

Canal + binlog 方案实现流程

① MySQL 配置 binlog

确保 MySQL 开启 binlog 日志功能,编辑 MySQL 配置文件 my.cnf

1
2
3
4
bashCopyEdit[mysqld]
server-id=1
log-bin=mysql-bin
binlog-format=ROW # 使用 ROW 模式,能记录数据的每行变化

重启 MySQL 服务:

1
2
3
4
5
bash


CopyEdit
systemctl restart mysqld

② 安装和配置 Canal

  1. 下载 Canal:
  2. 修改 Canal 配置文件: conf/example/instance.properties
1
2
3
4
bashCopyEditcanal.instance.master.address=127.0.0.1:3306  # MySQL 地址
canal.instance.dbUsername=root # MySQL 用户名
canal.instance.dbPassword=your_password # MySQL 密码
canal.instance.filter.regex=mydb\\.mytable # 监听的数据库和表
  1. 启动 Canal:
1
sh bin/startup.sh

③ 监听 binlog 并更新 Redis

示例代码(Java):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
javaCopyEditpublic void processCanalMessage(Message message) {
for (Entry entry : message.getEntries()) {
if (entry.getEntryType() == EntryType.ROWDATA) {
RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());
for (RowData rowData : rowChange.getRowDatasList()) {
if (rowChange.getEventType() == EventType.INSERT) {
updateCache(rowData.getAfterColumnsList());
} else if (rowChange.getEventType() == EventType.UPDATE) {
updateCache(rowData.getAfterColumnsList());
} else if (rowChange.getEventType() == EventType.DELETE) {
deleteCache(rowData.getBeforeColumnsList());
}
}
}
}
}

Canal + binlog 方案的优缺点

优点

  1. 实时性强:通过监听 binlog,能以毫秒级的延迟实时更新 Redis 缓存。
  2. 解耦:数据库与缓存更新分离,业务代码无需关心缓存更新逻辑。
  3. 适用复杂场景:支持多表多库同步,能满足大规模分布式系统需求。
  4. 容灾性好:基于 MySQL 主从复制协议,具有良好的容错性,Canal 宕机时可恢复消费位置。

缺点

  1. 一致性延迟:存在微小的时间窗口,在 binlog 解析和 Redis 更新之间,缓存与数据库可能短暂不一致。
  2. 复杂度高:需要维护 Canal 服务、监控 binlog 消费状态,系统复杂度增加。
  3. 多数据源:只适用于 MySQL 数据库,其他数据库需要实现类似 CDC(如 Debezium)。
  4. 扩展性问题:高并发场景下需要对 Canal 消费和 Redis 更新进行优化,防止缓存击穿。

Canal + binlog 解决 Redis 缓存一致性问题

  1. 缓存更新策略

    • 更新缓存:捕获 INSERTUPDATE 事件,及时更新 Redis。
    • 删除缓存:捕获 DELETE 事件,删除 Redis 中的缓存,防止数据脏读。
  2. 双写不一致的解决

    • 异步更新:通过 Canal 异步更新缓存,避免业务代码直接操作 Redis,减少双写不一致问题。
    • 延迟双删:在更新数据库前后进行两次缓存删除,确保缓存最终一致性。

    示例代码:

    1
    2
    3
    4
    5
    6
    7
    javaCopyEdit// 数据库更新前删除缓存
    redisTemplate.delete("user:" + userId);
    // 更新数据库
    userMapper.updateUser(user);
    // 数据库更新后再次删除缓存
    Thread.sleep(500);
    redisTemplate.delete("user:" + userId);

适用场景分析

  1. 高并发读写场景
    • 适用于订单系统、社交平台、推荐系统等对一致性要求较高的业务。
  2. 复杂数据同步
    • 多个数据源需要缓存同步,如 MySQL + Redis + Elasticsearch。
  3. 异步处理任务
    • 适用于数据变更后需触发异步任务的场景,如日志审计、数据清洗。

总结

特性 Canal + binlog 方案
一致性 99.9% 准实时一致性,适用于高要求场景
实时性 毫秒级延迟,能满足大多数业务实时性需求
扩展性 支持多表多库,适配大规模分布式系统
复杂度 需要维护 Canal 服务,系统架构复杂度提升
适用场景 订单、支付、库存、推荐系统等强一致性场景

Canal + binlog 是一种可靠的数据库与缓存同步方案,虽然存在一定的实现复杂度,但凭借其高实时性与解耦特性,在企业级分布式系统中得到了广泛应用。

问:Redis的主从不一致问题?

Redis默认是弱一致性。锁不能用主从、要么单实例、要么分片集群、RedLock,或者直接Redisson。配置中要提供有多个Client连接能同步,配置同步因子,趋向于强一致性。wait 2 5000 或 wait 2 0;

Redis 默认采用异步主从复制模型,这意味着写操作完成后,数据会先写入主节点,然后异步复制到从节点,存在复制延迟,从而导致主从数据不一致,即“弱一致性”。在这种模型下,如果读请求落到从节点上,可能读到旧数据;此外,对于依赖数据一致性的分布式锁,如果使用主从数据可能会出现问题。

为了解决主从不一致的问题,可以考虑以下几种方案:

  1. 单实例模式
    将所有操作集中在单个 Redis 实例上,从而避免主从复制带来的不一致问题。但这会牺牲高可用性和扩展性。
  2. Redis 分片集群
    使用 Redis Cluster,数据按照槽(slot)分布在多个节点上,每个槽内的数据在主节点写入后再复制给从节点。集群模式下可以配置较严格的复制策略,但复制仍然是异步的,理论上仍然存在短暂的不一致。
    如果对一致性要求较高,可以借助 WAIT 命令,例如:
    • WAIT 2 5000:主节点在返回写操作结果前,会等待至少 2 个从节点确认写入,最多等待 5000 毫秒。这样可以提高写入的强一致性,但会增加响应延迟。
    • WAIT 2 0:等待 2 个从节点确认写入,但不设超时(实际上等于同步复制)。
  3. 分布式锁解决方案
    由于锁操作要求较高的强一致性,不能简单依赖于主从复制。常见的做法有:
    • 使用 RedLock 算法,它基于多个独立的 Redis 实例(通常分布在不同节点上)来实现分布式锁,能在一定程度上保证锁的安全性。
    • 直接使用 Redisson 客户端,它封装了分布式锁的实现,并内置了超时、看门狗续期等机制,可以大大简化分布式锁的使用。
  4. 同步复制参数与客户端配置
    对于写入操作,可以通过配置同步因子和使用 WAIT 命令,强制主节点等待足够多的从节点同步后再返回。这样可以趋向于较高的一致性,但可能会牺牲部分写入性能。

综上所述,Redis 主从复制存在弱一致性问题,这在分布式锁或数据敏感场景下可能引起问题。通常的解决方案包括:

  • 如果业务允许,可以采用单实例或者 Redis Cluster 并结合 WAIT 命令来提高一致性;
  • 对于分布式锁,推荐使用 RedLock 或 Redisson 等成熟方案,避免直接依赖主从复制的数据一致性。

例如,在使用 WAIT 命令时,可以这样写:

1
2
3
bashCopyEdit# 写入操作后等待至少2个从节点确认写入,最多等待5000毫秒
SET mykey "value"
WAIT 2 5000

通过这种方式,可以在一定程度上保证写入操作在多个节点同步后再返回结果,从而提高数据一致性,但同时也要权衡性能和延迟问题。

问:Redis也扛不住了,万级的流量打在DB上,该怎么处理?todododododododo

当 Redis 无法承受高并发流量,导致大量请求直接打到数据库时,可能会引发数据库性能瓶颈,影响系统稳定性。为了解决这一问题,可以考虑以下策略:

  1. 限流与降级
    • 限流:通过在应用层或 API 网关实现限流策略,限制单位时间内对数据库的请求数量,防止瞬时流量冲击数据库。
    • 降级:在高负载情况下,部分非核心功能可以暂时关闭或提供简化服务,减轻数据库压力。
  2. 数据库优化
    • 索引优化:确保数据库查询使用了适当的索引,以提高查询效率。
    • 读写分离:将读请求和写请求分发到不同的数据库实例,减轻主数据库的负担。
    • 数据库分片:将数据库水平拆分,分散数据存储,提升并发处理能力。
  3. 异步处理与消息队列
    • 异步处理:对于非实时性要求高的操作,采用异步处理方式,将任务放入消息队列,后台处理,避免同步操作阻塞主流程。
    • 消息队列:使用消息队列(如 Kafka、RabbitMQ)缓冲请求,平滑流量,避免瞬时高并发直接冲击数据库。
  4. 缓存预热与降级
    • 缓存预热:在系统启动或流量高峰前,预先将热点数据加载到缓存中,减少高并发时对数据库的访问。
    • 缓存降级:当缓存不可用或过期时,提供降级策略,如直接访问数据库或返回默认值,避免缓存穿透。
  5. 监控与报警
    • 实时监控:部署监控系统,实时监测数据库性能指标,如 QPS、响应时间、连接数等,及时发现性能瓶颈。
    • 自动扩容:根据监控数据,自动调整数据库资源,如增加读写实例,提升处理能力。

应对万级流量冲击数据库的解决方案

当Redis无法承载流量,导致大量请求直接穿透到数据库时,需系统性优化架构,从缓存、数据库、业务逻辑等多个层面入手。以下为分阶段解决方案:

一、紧急止血:快速降低数据库压力

  1. 限流降级

    • 接入层限流:在Nginx或API网关设置请求速率限制(如每秒5000次),拒绝超量请求,返回友好提示(如“系统繁忙,请稍后重试”)。
    • 服务降级:关闭非核心功能(如排行榜更新、日志记录),优先保障核心交易链路(如下单、支付)。
    • 代码示例(Sentinel限流)
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      // 定义资源名
      @SentinelResource(value = "queryProduct", blockHandler = "handleBlock")
      public Product queryProduct(String id) {
      // 业务逻辑
      }

      // 限流处理
      public Product handleBlock(String id, BlockException ex) {
      throw new ServiceBusyException("系统繁忙,请稍后重试");
      }
  2. 静态化兜底

    • 对热点数据(如商品详情页)提前生成静态HTML页面,通过CDN分发,直接绕过动态查询。
    • 实现方式:商品信息变更时,触发静态页生成并推送至CDN。

二、缓存层深度优化

  1. 缓存架构升级

    • Redis Cluster分片:将数据分散到多节点,提升整体吞吐量(单Cluster可支持10万+ QPS)。
    • 多级缓存体系
      1
      客户端 → CDN → Nginx本地缓存 → Redis集群 → 数据库
    • 本地缓存:在应用层使用Caffeine/Guava Cache,缓存极热点数据(如秒杀商品库存)。
      1
      2
      3
      4
      Cache<String, Product> cache = Caffeine.newBuilder()
      .maximumSize(10_000)
      .expireAfterWrite(10, TimeUnit.SECONDS)
      .build();
  2. 缓存策略强化

    • 防穿透:对空值设置短TTL缓存(如SET key-null "" EX 30)。
    • 防雪崩:缓存过期时间添加随机值(如基础30分钟±5分钟随机)。
    • 防击穿:使用Redis分布式锁控制单请求回源,其他请求等待。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      public Product getProduct(String id) {
      String key = "product:" + id;
      Product product = redis.get(key);
      if (product == null) {
      if (redis.lock(key, 3)) { // 获取分布式锁
      try {
      product = db.query(id);
      redis.setex(key, 3600, product);
      } finally {
      redis.unlock(key);
      }
      } else {
      Thread.sleep(100); // 等待重试
      return getProduct(id);
      }
      }
      return product;
      }

三、数据库层优化

  1. 读写分离

    • 主从架构:写操作走主库,读操作分流至多个从库(1主3从支撑5万+ QPS)。
    • 数据库代理:使用MyCat/ShardingSphere自动路由读请求到从库。
  2. 异步写入

    • 消息队列削峰:将写操作异步化,通过Kafka/RocketMQ暂存请求,消费者批量写入。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      // 下单请求异步处理
      public void createOrder(Order order) {
      kafkaTemplate.send("order-topic", order);
      }

      // 消费者批量写入
      @KafkaListener(topics = "order-topic")
      public void batchInsert(List<Order> orders) {
      jdbc.batchUpdate(orders);
      }
  3. 分库分表

    • 垂直拆分:按业务拆分订单库、用户库、商品库。
    • 水平拆分:对订单表按用户ID哈希分16个库,每个库分64张表。
    • 工具支持:使用ShardingSphere实现透明分片。

四、业务逻辑重构

  1. 请求合并

    • 批量查询:将多个单品查询合并为批量接口(如GET /products?ids=1001,1002,1003)。
    • 前端优化:增加请求间隔(如用户连续点击按钮时,500ms内只发送一次请求)。
  2. 计算分离

    • 离线计算:将排行榜、统计类数据通过Flink/Spark离线计算后写入缓存。
    • 示例流程
      1
      用户行为日志 → Kafka → Flink实时计算 → 更新Redis排行榜
  3. 热点探测

    • 动态发现:通过Redis监控识别热点Key(如使用redis-cli --hotkeys)。
    • 专项处理:对热点数据(如明星直播)提前预热到各级缓存,甚至做本地内存缓存。

五、架构升级路线

阶段 目标 具体措施
应急响应 快速止血 限流降级、静态化兜底、扩容Redis连接数
短期优化 提升系统吞吐量 多级缓存、读写分离、异步写队列
中期改造 彻底解决容量瓶颈 Redis Cluster分片、数据库分库分表、引入OLAP分析库
长期规划 构建弹性云原生架构 容器化部署(K8s)、自动扩缩容(HPA)、Serverless化(如AWS Lambda+DAX)

六、监控与预案

  1. 核心监控指标

    • Redis:CPU使用率、内存碎片率、命中率、慢查询
    • 数据库:QPS、连接数、慢SQL、锁等待时间
    • 系统层:网络带宽、磁盘IO、线程池队列
  2. 故障演练

    • 定期模拟缓存宕机、数据库主从切换,验证降级策略有效性。
    • 使用ChaosBlade注入网络延迟、节点故障等异常。
  3. 容量规划

    • 按每日峰值流量的3倍预留资源(如Redis Cluster节点数、数据库连接池大小)。
    • 建立自动化扩容机制(如阿里云弹性伸缩组)。

总结

应对高流量冲击需采取分层防御策略:

  1. 前端限流:阻挡过量请求进入系统。
  2. 缓存扛压:通过多级缓存吸收99%以上读请求。
  3. 数据库保护:异步化+分库分表保障写操作不崩溃。
  4. 业务柔性:核心链路优先,非关键功能可降级。

通过上述组合拳,可将万级QPS对数据库的冲击降至百级以下,同时为后续架构演进赢得时间窗口。

问:Redis 常见性能问题和解决方案?

  • Master 最好不要做任何持久化工作,如 RDB 内存快照和 AOF 日志文件
  • 如果数据比较重要,某个 Slave 开启 AOF 备份数据,策略设置为每秒同步一次
  • 为了主从复制的速度和连接的稳定性,Master 和 Slave 最好在同一个局域网内
  • 尽量避免在压力很大的主库上增加从库
  • 主从复制不要用图状结构,用单向链表结构更为稳定,即:Master <- Slave1 <- Slave2 <- Slave3…

这样的结构方便解决单点故障问题,实现 Slave 对 Master 的替换。如果 Master 挂了,可以立刻启用 Slave1 做 Master,其他不变。

一、CPU 相关

1. CPU 使用率过高

原因:

  • 复杂命令执行时间长,如 SORTZUNIONSTORE
  • 大量写入请求。
  • Lua 脚本运行时间过长。

解决方案:

  • 优化命令使用,避免阻塞型操作。
  • 拆分大任务为小任务,使用批处理操作。
  • 使用 MONITORSLOWLOG 命令排查慢命令。

二、内存相关

2. 内存占用过高

原因:

  • 数据量过大,无限制写入。
  • 键值未设置过期时间。
  • 内存碎片导致占用增长。

解决方案:

  • 配置 maxmemory 限制最大内存。
  • 设置键过期时间 (EXPIRE)。
  • 使用 MEMORY PURGE 清理内存碎片。
  • 使用 INFO MEMORY 命令监控内存使用情况。

3. 内存淘汰失败(OOM 错误)

原因:

  • 没有配置内存淘汰策略,内存用尽时 Redis 报错。

解决方案:

  • 配置 maxmemory-policy,推荐 allkeys-lruvolatile-lru
  • 确保键设置合理的过期时间。

4. 内存泄漏

原因:

  • 键未删除或未设置 TTL。
  • 长时间运行导致数据积累。

解决方案:

  • 使用 SCAN 命令排查大量无用数据。
  • 定期清理过期键。

三、网络与连接问题

5. Redis 阻塞与响应延迟

原因:

  • Redis 单线程执行阻塞命令。
  • 客户端连接过多,服务器负载高。

解决方案:

  • 避免使用 KEYSFLUSHALL 等阻塞命令。
  • 使用 客户端连接池 优化连接管理。
  • 配置 maxclients 限制最大连接数。

6. 网络超时与连接断开

原因:

  • 网络波动或服务器负载过高。
  • Redis 配置 timeout 值过短。

解决方案:

  • 配置合理的 timeout 值。
  • 使用客户端重试机制。
  • 检查服务器硬件与网络链路。

四、持久化与数据安全问题

7. RDB/AOF 持久化导致性能下降

原因:

  • 大量写操作时,RDB 快照或 AOF 重写占用 CPU 和 IO。

解决方案:

  • 调整 save 配置,减少 RDB 生成频率。
  • 配置 AOF 重写阈值,优化磁盘 IO。
  • 在高并发场景使用异步持久化。

8. 数据丢失问题

原因:

  • Redis 崩溃或服务器断电。
  • AOF 未启用或刷盘不及时。

解决方案:

  • 开启 AOF,配置 appendfsyncalwayseverysec
  • 部署 Redis 哨兵(Sentinel)或集群模式。

五、集群与复制问题

9. 主从同步延迟

原因:

  • 从节点 IO 性能差,数据同步过慢。

解决方案:

  • 提高从节点硬件配置。
  • 配置 repl-backlog-size,减少主从重同步。

10. Redis 集群数据分布不均

原因:

  • 键分片算法导致数据倾斜。

解决方案:

  • 使用哈希标签,优化键分布策略。
  • 检查节点负载均衡情况。

11. 主节点故障,服务中断

原因:

  • 主节点单点故障。

解决方案:

  • 使用 Redis Sentinel 自动故障转移。
  • 配置多主从节点,启用高可用。

常用排查与优化命令

1
2
3
4
5
6
7
8
9
10
11
# 查看慢查询日志
redis-cli SLOWLOG GET

# 查看内存使用情况
redis-cli INFO MEMORY

# 查看连接数与请求量
redis-cli INFO STATS

# 查看 CPU 与持久化配置
redis-cli INFO PERSISTENCE

问:redis阻塞原因?以及解决方案等

Redis 阻塞的常见原因及解决方案

  1. 数据结构使用不合理(BigKey 问题)
    • 原因: 如果存储的 key 对应的数据结构过大(例如超大列表、超大哈希或集合),在执行遍历、更新或序列化操作时,会消耗较长时间,阻塞主线程。
      • 单个键值过大(如超大字符串、List、Set、Hash、ZSet)。
      • 执行 DELLRANGEHGETALL 等命令时阻塞。
    • 解决方案:
      • 拆分 BigKey:将大键拆分成小键。避免存储过大的 key,将大数据拆分成更小的子数据结构;
      • 使用适合的编码(如 ziplist/listpack)存储小型数据;
      • 通过应用层逻辑对大 key 进行分页或分片处理,降低单次操作的数据量。
      • 异步删除:使用 UNLINK 而不是 DEL
  2. CPU饱和:
    • 原因: Redis 是单线程处理请求,如果遇到 CPU 密集型操作(如复杂的 Lua 脚本、排序、重计算等),会使 CPU 使用率飙升,导致响应变慢,从而阻塞后续请求。
      • 慢查询、过多复杂操作。
      • 数据结构操作不当,频繁执行 Lua 脚本。
    • 解决方案:
      • 优化 Lua 脚本和数据处理逻辑,尽量避免长时间运行的命令;
      • 分析和优化慢查询,使用 SLOWLOG 检查问题命令;
      • 在 Redis 6 及以后版本中,可以启用 I/O 多线程(只针对网络 I/O 部分)来缓解部分压力。
      • 水平扩展:部署 Redis 集群,分片存储。
      • 多实例部署:在多核服务器上运行多个 Redis 实例,充分利用 CPU。
      • 使用 监控工具(如 Redis Insight、Prometheus)。
  3. 持久化阻塞:
    • RDB 快照:
      • 原因: 在执行 RDB 快照时,Redis 会 fork 一个子进程复制数据,这个过程会导致写操作受到影响,特别是在数据量大时可能引起较长阻塞。
        • RDB 触发 BGSAVE,子进程 fork() 时阻塞主线程。
      • 解决方案:
        • 选择合适的 RDB 触发时机(例如低峰时段);
        • 禁用 RDB 持久化(适用于缓存场景)。使用 AOF 模式代替或配合 RDB;
        • 调整 fork 时的系统参数,或考虑硬件升级(如 SSD)。
    • AOF 持久化:
      • 原因: 如果 AOF 配置为 always 模式(每次写操作都 fsync),会因磁盘 I/O 慢而阻塞命令执行;即使采用 everysec 模式,也可能在高并发下受到影响。
        • AOF 持久化使用 fsync always,导致频繁磁盘 IO 阻塞。
      • 解决方案:
        • 调整 AOF 策略:推荐 appendfsync everysec。建议使用 appendfsync everysec 模式,平衡数据安全性和性能;
        • 使用更快的磁盘(SSD)降低 fsync 延迟;
        • 优化配置:调整 no-appendfsync-on-rewrite,减少持久化频率。
        • 考虑 AOF 重写优化,降低 AOF 文件体积。
  4. 命令执行耗时过长:
    • 原因:执行复杂或耗时的命令,如 KEYS *FLUSHALLSAVESORTLRANGE(大范围)。遍历全量数据的命令在数据量大时会阻塞 Redis 主线程。
    • 解决方案:
      • 避免使用阻塞命令,使用 SCAN 代替 KEYS
      • 禁用 SAVE,使用异步 BGSAVE
      • 对于大数据集,分批处理,避免一次性操作大量数据。
  5. 大量客户端连接:
    • 原因:客户端连接数过多,达到 Redis 配置的 maxclients 限制。Redis 无法及时响应所有请求,阻塞主线程。
    • 解决方案:
      • 增加 maxclients 配置,调优连接池。
      • 使用 连接池框架(如 Jedis、Redisson)。
      • 部署 Redis 集群,实现负载均衡。
  6. 网络延迟与阻塞 I/O:
    • 原因:Redis 使用 TCP 套接字进行通信,网络延迟和阻塞 I/O 会导致请求堆积。
    • 解决方案:
      • 检查网络延迟与带宽,优化 网络环境
      • 调整 Redis 的 tcp-keepalive 配置。
      • 开启 持久连接,减少 TCP 建立成本。
  7. 内存耗尽或垃圾回收:
    • 原因:Redis 达到内存上限,执行内存清理或数据淘汰时阻塞。内存分配与释放频繁,触发系统的垃圾回收。
    • 解决方案:
      • 设置 内存上限 (maxmemory),启用淘汰策略。
      • 优化 数据模型,避免存储过大的键。
      • 使用 更高性能的内存硬件(如 SSD)。
  8. 大批量数据插入与导入:
    • 原因:一次性导入大量数据,超过 Redis 处理能力,导致阻塞。
    • 解决方案:
      • 使用 批量插入工具(如 redis-cli --pipe)。
      • 分批处理数据,限流控制
  9. 锁与阻塞命令:
    • 原因:使用 Redis 的阻塞命令(如 BLPOPBRPOP)处理队列时,等待超时。
    • 解决方案:
      • 降低阻塞命令使用频率,设置 超时时间
      • 使用 消息队列替代方案(如 Kafka、RabbitMQ)。

4. Redis 主从复制阻塞

  • 现象:主节点在全量同步时阻塞,导致读写卡顿。

  • 解决方案:

    • 开启 无磁盘复制 (repl-diskless-sync)。
  • 减少主从同步频率,优先启用增量复制。

5. Redis 数据迁移与重分片

  • 现象:在 Redis 集群中重分片或迁移大数据时阻塞。

  • 解决方案:

    • 使用 Redis 集群管理工具,控制迁移速度。
  • 优化迁移策略,避免大批量数据一次性迁移。

6. 锁争用与阻塞命令

  • 现象:使用 BLPOPBRPOPWAIT 等阻塞命令时等待超时。

  • 解决方案:

    • 优化队列模型,减少阻塞操作。
  • 使用分布式锁方案(如 Redisson、Redis 分布式锁)。

7. 内存不足与垃圾回收

  • 现象:内存使用率超过 maxmemory 限制,Redis 执行数据清理时阻塞。

  • 解决方案:

    • 配置 内存淘汰策略(如 volatile-lruallkeys-lru)。
  • 使用高性能内存硬件,监控和优化数据模型。

8. Redis Lua 脚本执行超时

  • 现象:Lua 脚本执行时间过长,导致主线程阻塞。

  • 解决方案:

    • 限制 Lua 脚本的执行时间 (lua-time-limit)。
  • 避免复杂逻辑和大规模数据操作。

9. 网络故障与客户端过多

  • 现象:客户端连接数超出 maxclients 限制,Redis 无法及时响应。

  • 解决方案:

    • 优化客户端连接池,减少长连接。
  • 增加 Redis 服务器节点,分摊负载。

总结:Redis 阻塞的全面预防措施

  • 优化数据结构,避免使用 BigKey。
  • 合理配置 RDB 和 AOF 策略,防止磁盘 IO 过载。
  • 水平扩展 Redis 集群,部署多实例。
  • 优化网络与持久化配置,启用无磁盘复制。
  • 使用监控工具,及时发现与修复潜在问题。

x. Redis高并发高可用

问:分布式 Redis 是前期做还是后期规模上来了再做好?为什么?

既然 Redis 是如此的轻量(单实例只使用 1M 内存),为防止以后的扩容,最好的办法就是一开始就启动较多实例。即便只有一台服务器,也可以一开始就让 Redis 以分布式的方式运行,使用分区,在同一台服务器上启动多个实例。一开始就多设置几个 Redis 实例,例如 32或者 64个实例,对大多数用户来说这操作起来可能比较麻烦,但是从长久来看做这点牺牲是值得的。这样的话,当数据不断增长,需要更多的 Redis 服务器时,需要做的就是仅仅将 Redis实例从一台服务迁移到另外一台服务器而已(而不用考虑重新分区的问题)。一旦添加了另一台服务器,需要将一半的 Redis 实例从第一台机器迁移到第二台机器。

分布式 Redis:前期 vs. 后期实施分析

选择在 前期 还是 后期 引入分布式 Redis,取决于项目的业务特性、数据规模、预期增长速度和团队的开发维护能力。以下是详细的对比与推荐:

一、前期部署分布式 Redis(业务初期)

适用场景

  • 业务启动时预计数据规模增长快,用户量潜力大(如电商、社交、视频直播)。
  • 业务对高可用性、容灾与故障恢复要求高。
  • 系统架构规划有明确的分布式需求,且团队具备 Redis 集群部署和维护经验。

优势

  • 弹性扩展:随业务增长平滑扩展节点。
  • 高可用性:支持主从复制与自动故障转移。
  • 数据可靠性:具备数据分片与持久化支持。
  • 性能优化提前介入:减少后期大规模改造的技术债务。

劣势

  • 复杂性增加:系统设计更复杂,部署与运维成本提升。
  • 资源浪费风险:业务初期数据量小,资源利用率低。

二、后期扩展分布式 Redis(业务规模增长后)

适用场景

  • 初期业务数据规模小,访问量有限,Redis 单机部署能满足需求。
  • 系统架构相对简单,初期专注于核心功能开发,节省早期开发成本。
  • 团队 Redis 集群维护经验不足,需要积累运维经验。

优势

  • 开发与运维成本低:无需初期引入复杂的分布式方案。
  • 资源节省:单机 Redis 内存资源可用到极限。
  • 渐进式扩展:根据业务增长逐步扩展,降低初期资源浪费。

劣势

  • 迁移与改造成本高:后期引入分布式 Redis 需要大量重构。
  • 服务中断风险:迁移期间可能导致业务停机和服务不可用。
  • 不可预测的瓶颈:单机性能和存储限制可能导致服务性能突然下降。

三、决策因素对比表

决策因素 前期部署分布式 Redis 后期扩展分布式 Redis
用户规模预测 用户增长潜力大,峰值高 用户增长逐步扩展
数据存储容量要求 数据量预期大,增长迅速 数据量初期较小
高可用与容灾需求 高可用要求高,需故障转移 容灾需求低,数据非关键
资源利用率与成本 初期资源成本高,分片预留 初期资源成本低,按需扩展
架构复杂度与维护成本 部署、维护与开发复杂 初期简单,后期迁移成本高

最佳实践与推荐

  1. 初期推荐方案(小规模系统)
    • 使用 单机 Redis 部署,支持持久化(AOF/RDB)配置。
    • 结合 主从复制 提高高可用性。
    • 使用 哨兵模式(Sentinel) 提供自动故障切换。
  2. 成长中期方案(中等规模系统)
    • 引入 Redis Cluster代理分片(如 Codis/Twemproxy)
    • 确保数据持久化和节点高可用配置,支持平滑扩展与负载均衡。
  3. 长期方案(大规模系统)
    • 使用 Redis Cluster,数据自动分片,主从复制与高可用。
    • 结合 监控系统(Prometheus/Grafana),自动化管理与告警。

结论:如何选择?

  • 如果 业务增长可预期,数据敏感,架构团队有 Redis 集群维护经验,建议 前期部署分布式 Redis
  • 如果 业务初期不确定,用户规模和数据量小,需降低运营成本,建议先采用 单机 Redis,在增长期引入 Redis Cluster

这是一种在功能开发和架构复杂度之间的权衡,最终需要结合业务需求、团队经验与预算做出最佳决策。

问:Redis的同步机制?

  • 全量拷贝:
    1. slave第一次启动时,连接Master,发送PSYNC命令,
    2. master会执行bgsave命令来生成rdb文件,期间的所有写命令将被写入缓冲区。
    3. master bgsave执行完毕,向slave发送rdb文件
    4. slave收到rdb文件,丢弃所有旧数据,开始载入rdb文件
    5. rdb文件同步结束之后,slave执行从master缓冲区发送过来的所以写命令。此后 master 每执行一个写命令,就向slave发送相同的写命令。
  • 增量拷贝:如果出现网络闪断或者命令丢失等异常情况,从节点之前保存了自身已复制的偏移量和主节点的运行ID。主节点根据偏移量把复制积压缓冲区里的数据发送给从节点,保证主从复制进入正常状态。

Redis 的同步机制确保主从服务器之间的数据一致性,主要包括 主从复制(Replication)持久化同步。Redis 的同步机制主要通过主从复制与持久化策略实现数据一致性与高可用。主从复制确保高并发读写能力,持久化机制防止数据丢失。在实际生产环境中,结合哨兵模式与集群模式可以实现稳定可靠的 Redis 部署。

一、主从复制(Replication)

主从复制是 Redis 的核心同步机制。主服务器(Master)负责处理写请求,从服务器(Slave)复制主服务器的数据,提供读请求服务,实现读写分离与高可用。

1. 主从复制过程

初次复制(全量同步 - RDB 快照)

  1. 从服务器启动时,向主服务器发送 SYNCPSYNC 命令。
  2. 主服务器生成一个 RDB 快照,将快照数据和命令缓存发送给从服务器。
  3. 从服务器加载 RDB 文件并重放命令缓存,完成同步。

增量复制(命令流同步)

  • 在完成全量同步后,主服务器将执行的写命令通过命令流(Replicas Buffer)发送给从服务器,从服务器实时应用这些命令。

2. 主从复制机制中的关键点

机制 描述
复制偏移量 主从间的复制进度,标识数据同步位置。
复制 ID 标识主服务器实例,用于主从连接恢复。
命令缓冲区(Backlog) 存储主服务器未同步的命令,用于断线恢复。
PSYNC Redis 2.8 引入,支持部分同步。
SYNC Redis 旧版的全量同步命令。

3. 主从复制的触发条件

  • 新的从服务器上线。
  • 主从服务器断开连接后恢复。
  • 主服务器重启。

4. Redis 主从复制的高可用实现

  • 哨兵模式(Sentinel):自动主从切换,主服务器故障时从服务器晋升为主服务器。
  • Redis 集群模式:多主多从架构,分片管理与故障转移。

5. 主从复制的注意事项与优化

优化点 描述
异步复制 Redis 复制是异步的,可能导致数据延迟。
复制压缩 使用 repl-diskless-sync 减少磁盘 IO。
复制积压缓冲区大小 调整 repl-backlog-size 优化同步恢复。
网络优化 配置 tcp-keepalive 保持连接稳定。

二、持久化同步机制

Redis 提供两种持久化方式,通过 RDB 快照和 AOF 日志将数据保存到磁盘,防止数据丢失。

1. RDB(Redis DataBase)快照

  • 定期将内存数据生成快照,保存到磁盘。
  • 恢复时加载 RDB 文件。

优点: 启动恢复快,适合灾难恢复。
缺点: 数据可能丢失,适合冷备。

2. AOF(Append Only File)日志

  • 记录所有写命令,以日志形式存储。
  • Redis 重启时通过重放日志恢复数据。

优点: 数据安全,几乎无数据丢失。
缺点: 恢复速度较慢,文件体积较大。

3. 持久化同步策略配置

配置项 描述
save RDB 自动保存策略。
appendonly 启用 AOF。
appendfsync AOF 持久化策略(always/everysec/no)。
auto-aof-rewrite-min-size 自动重写 AOF 文件的最小体积。
repl-diskless-sync 启用无磁盘复制(减少 IO 负载)。

三、Redis 集群同步机制

Redis 集群通过分片(Sharding)管理数据,使用异步复制机制同步主从节点数据:

  • 每个分片都有主服务器和多个从服务器。
  • 集群间通过 Gossip 协议交换数据节点信息。
  • 主节点故障时,集群自动触发故障转移。

四、Redis 数据同步机制的优势与挑战

优势 挑战
数据同步实时,读写分离支持 异步复制,可能出现数据丢失
支持高可用主从切换与容灾备份 主从同步时 CPU 与内存负载高
可配置的持久化与备份策略 磁盘 IO 成为性能瓶颈
多分片数据分布与负载均衡支持 网络抖动导致同步延迟

问:Redis 的部署方式了解么?主从,集群?

Redis 的部署方式:主从复制与集群模式

Redis 提供了多种部署方式,其中最常见的是 主从复制Redis 集群。这两种部署方式在高可用性、性能优化、容错等方面具有不同的特性和应用场景。下面详细介绍这两种部署方式及其特点。

1. Redis 主从复制(Master-Slave)

1.1 主从复制概念

  • 主节点(Master):负责接收客户端的写请求(写操作)。
  • 从节点(Slave):通过复制主节点的数据来进行数据同步,主要用于处理读请求,减轻主节点的压力。

1.2 主从复制工作原理

  • 在 Redis 中,主从复制是通过异步复制实现的:当主节点数据发生变化时,会将这些变化同步到从节点。
  • 从节点会定期向主节点发送 SYNC 命令以获得最新的数据快照,之后保持同步更新。
  • 每个从节点只能与一个主节点连接,但一个主节点可以有多个从节点。

1.3 优缺点

优点

  • 读写分离:主节点负责写操作,从节点负责读操作,能够减轻主节点的负担,提高系统的读性能。
  • 高可用性:通过设置多个从节点,保证在主节点故障时可以快速切换到从节点。
  • 数据冗余:从节点存有主节点的完整数据,可以在主节点故障时提供数据恢复。

缺点

  • 主节点瓶颈:所有的写操作都集中在主节点进行,随着业务增长可能导致主节点性能瓶颈。
  • 异步复制延迟:主从复制采用的是异步复制,数据可能在主节点和从节点之间有延迟。

1.4 主从复制的应用场景

  • 读操作占比大于写操作:典型的电商、广告系统等。
  • 高可用要求:通过增加从节点,提高可用性。
  • 数据冗余要求:从节点可以作为备份,提高容灾能力。

2. Redis 集群(Redis Cluster)

2.1 Redis 集群概念

Redis 集群是 Redis 官方支持的分布式部署方式,它通过分片机制将数据分布到多个节点上,从而实现高可用和高性能的 Redis 集群。

2.2 集群架构

  • 节点分片:Redis 集群将数据分布到多个节点(主节点)上,每个节点负责存储数据的一部分。数据通过 哈希槽(hash slot) 分配到不同的节点。Redis 集群有 16384 个哈希槽,数据通过哈希算法分配到这些槽中。
  • 复制与高可用性:每个主节点都可以有一个或多个从节点,从节点用来备份主节点的数据,并且可以在主节点故障时接管工作。
  • 无中心节点:Redis 集群是一个去中心化的架构,节点之间是平等的。没有单点的 Master-Slave 结构,集群中每个节点都是独立的。

2.3 Redis 集群工作原理

  • 数据分片:每个 Redis 集群的节点都负责管理一定范围的哈希槽(从 0 到 16383)。客户端请求 Redis 集群时,会根据请求的键计算哈希槽,并将请求转发到对应的节点。
  • 故障转移:如果主节点不可用,Redis 集群会自动将一个从节点提升为主节点。集群内部有一套选举机制来保证高可用性。

2.4 优缺点

优点

  • 水平扩展:通过增加更多节点来扩展存储和处理能力。集群支持自动数据分片,增加节点可以方便地提升性能和容量。
  • 高可用性:Redis 集群自带故障转移功能,如果主节点故障,集群会自动将从节点升级为主节点,确保服务持续运行。
  • 高性能:集群通过分片技术减少了单个节点的负载,可以分摊请求,提高系统的整体性能。

缺点

  • 复杂性增加:集群模式比单机模式和主从模式要复杂得多,配置和维护需要更高的技术能力。
  • 数据迁移:当节点添加或删除时,需要进行数据迁移,这会带来一定的性能开销。
  • 一致性问题:虽然 Redis 集群提供高可用性,但在一些情况下,它可能不提供严格的一致性。由于节点的故障转移和数据分片,可能会出现短时间的数据不一致。

2.5 Redis 集群的应用场景

  • 大规模分布式应用:当业务量增长到单机 Redis 无法承载时,Redis 集群提供了水平扩展的能力。
  • 高并发、高吞吐量应用:如大规模电商网站、社交网络、搜索引擎等需要处理大量数据和请求的系统。
  • 高可用性和容错性要求:对于一些需要高可用且对数据不丢失有严格要求的系统,Redis 集群提供了高可用性。

3. Redis 部署方式的对比

特性 主从复制 Redis 集群
架构类型 主节点与从节点(Master-Slave) 数据分片(Sharding)
数据分布 同步复制,所有数据存储在主节点 数据通过哈希槽分布在多个节点
读写分离 主节点负责写操作,从节点负责读 读写都可以分布到多个主节点
高可用性 从节点备份主节点数据,支持主从切换 自动故障转移,节点间复制
水平扩展 不支持自动分片,增加节点需手动分配 支持自动水平扩展,数据分片
复杂性 相对简单,配置和维护较容易 配置复杂,需要更多的运维管理
适用场景 适合小型中型应用,读多写少 适合大规模应用,需要分布式存储和高并发处理

4. 总结

  • 主从复制适合数据量较小,读多写少的场景,配置简单,容易维护,适合业务规模较小的应用。
  • Redis 集群适用于大规模、高并发的分布式系统,支持数据分片和水平扩展,具有更高的可用性和容错能力,适合高并发和大数据量场景。

选择哪种部署方式应根据业务需求、数据量、集群的规模和维护能力来决定。对于大部分中小型业务,主从复制可能就足够了;而对于大规模、高可用、高性能的需求,Redis 集群是更合适的选择。

问:Redis主从机制了解么?怎么实现的?


一、复制拓扑结构

Redis主从复制支持多种拓扑结构,适应不同场景下的数据同步与高可用需求:

拓扑类型 架构描述 优点 缺点 适用场景
一主一从 单个主节点(Master)与单个从节点(Slave)组成。 架构简单,适合数据备份与故障转移。 读扩展能力有限,主节点故障需手动切换。 小型系统、数据冷备场景。
一主多从 单个主节点连接多个从节点(如1主3从)。 显著提升读吞吐量,支持读写分离。 主节点写压力集中,网络带宽可能成为瓶颈。 高并发读场景(如电商商品详情页)。
树状主从 从节点作为其他从节点的主节点,形成层级结构(如主 → 从1 → 从2)。 减轻主节点同步压力,优化网络带宽使用。 数据同步延迟层级叠加,故障排查复杂度增加。 跨地域多机房部署(如主在中心机房,从节点分布边缘节点)。

二、复制配置与管理

1. 建立复制
  • 命令方式:通过SLAVEOFREPLICAOF(Redis 5.0+)配置从节点连接主节点。

    1
    2
    # 在从节点执行
    127.0.0.1:6380> REPLICAOF 192.168.1.100 6379
  • 配置文件:在从节点redis.conf中设置主节点信息。

    1
    2
    replicaof 192.168.1.100 6379
    masterauth <password> # 若主节点有密码
2. 断开复制
  • 临时断开:从节点执行REPLICAOF NO ONE,保留现有数据,停止同步。
  • 永久断开:移除配置文件中的replicaof并重启。
3. 只读模式
  • 默认行为:从节点为只读模式(replica-read-only yes),拒绝所有写操作。
  • 风险操作:可通过CONFIG SET replica-read-only no临时允许写入,但数据会被主从同步覆盖。
4. 传输延迟优化
  • 网络优化:主从节点部署在同一机房或使用高速内网。

  • 参数调优

    1
    2
    3
    4
    5
    # 主节点配置(减少全量同步频率)
    repl-backlog-size 1GB # 增大复制积压缓冲区
    repl-diskless-sync yes # 无盘复制(适用于磁盘IO慢的场景)
    # 从节点配置
    repl-ping-replica-period 10 # 从节点心跳间隔(默认10秒)

三、主从复制原理与流程

1. 主从复制六步流程
  1. 保存主节点信息
    • 从节点持久化主节点的IP、端口、认证密码到本地,重启后自动重连。
  2. 建立Socket连接
    • 从节点向主节点发起Socket连接,默认端口6379。若主节点防火墙限制,需开放端口。
  3. 发送PING命令
    • 连接建立后,从节点发送PING检测主节点是否可用。若收到PONG则继续,否则重试。
  4. 权限验证
    • 若主节点配置requirepass,从节点需通过masterauth配置正确密码,否则复制终止。
  5. 数据同步
    • 全量同步:从节点首次连接或复制中断后无法部分同步时触发。
    • 部分同步:复制中断后,从节点偏移量仍在主节点积压缓冲区内时触发。
  6. 命令持续复制
    • 全量同步完成后,主节点将新写入命令异步发送给从节点(基于长连接)。

四、数据同步机制

1. 全量同步(Full Sync)

流程说明

  1. 从节点发送PSYNC ? -1请求全量同步。
  2. 主节点执行BGSAVE生成RDB快照,同时缓存新写入命令至复制缓冲区
  3. RDB文件生成后,主节点将其发送给从节点。
  4. 从节点清空旧数据,加载RDB文件。
  5. 主节点发送复制缓冲区的增量命令,从节点执行以保持数据最新。

问题与优化

  • 主节点性能压力BGSAVE可能导致CPU/内存飙升,建议低峰期操作或使用无盘复制。
  • 网络带宽占用:大数据量时RDB传输耗时,可升级网络或分片集群。
2. 部分同步(Partial Sync)

流程说明

  1. 从节点发送PSYNC <runid> <offset>,其中runid为主节点ID,offset为当前复制偏移量。
  2. 主节点检查offset是否在复制积压缓冲区repl_backlog)范围内。
  3. 若存在,主节点发送缓冲区中从offset到最新的所有命令,从节点执行。
  4. 若不存在(缓冲区被覆盖),触发全量同步。

关键参数

  • repl-backlog-size:缓冲区大小(建议设置为平均网络中断时间 * 主节点写入QPS)。
  • repl-backlog-ttl:缓冲区保留时长(默认1小时)。
3. 心跳机制
  • 主 → 从心跳:每10秒发送PING,检测从节点存活状态。
  • 从 → 主心跳:每秒发送REPLCONF ACK <offset>,上报自身复制偏移量,主节点据此计算延迟。
4. 异步复制机制
  • 流程:主节点处理写命令后,立即返回客户端,随后异步将命令发送给从节点。
  • 风险:主节点宕机可能导致未同步数据丢失(可通过WAIT命令实现半同步,但影响性能)。

五、主从复制状态监控

1. 关键命令
1
2
3
4
5
6
7
8
# 查看主节点复制信息
127.0.0.1:6379> INFO replication
# 查看从节点列表及状态
Connected slaves: 2
slave0: ip=192.168.1.101,port=6380,state=online,offset=123456,lag=0
slave1: ip=192.168.1.102,port=6381,state=online,offset=123456,lag=1

# 查看主从延迟(lag单位为秒)
2. 异常处理
  • 从节点延迟过高
    • 排查主节点写入压力,优化大Key或批量操作。
    • 升级从节点硬件或调整repl-backlog-size
  • 主从断连
    • 检查网络连通性(telnetping)。
    • 查看主节点日志,确认是否因认证失败或缓冲区不足触发全量同步。

六、生产环境最佳实践

  1. 读写分离
    • 写请求仅发送到主节点,读请求分散到多个从节点。
    • 使用代理中间件(如Predixy)自动路由请求。
  2. 高可用架构
    • 结合Sentinel实现自动故障转移,避免手动切换主从。
    • 跨机房部署时,优先选择树状拓扑减少主节点出口带宽压力。
  3. 监控告警
    • 监控master_link_status(up/down)、master_last_io_seconds_ago(心跳延迟)。
    • 配置报警规则(如从节点延迟超过10秒触发告警)。
  4. 安全加固
    • 主节点开启requirepass,从节点配置masterauth
    • 使用SSL加密主从通信(Redis 6.0+支持TLS)。

全量复制的各个场景进行避免:

  • 首次建立复制:该情况无法避免。
    • 在对数据量较大且流量较高的主节点添加从节点时,建议在低峰进行操作,或者干脆规避使用大数量的节点。
  • 节点运行ID不匹配:当主节点故障重启,其运行ID会改变,从节点发现主节点运行ID不匹配会认为自己复制的是一个新节点导致进行全量复制。
    • 避免这种情况需要在架构上规避,如提供故障转移功能,在主节点发生故障后,手动提升从节点为主节点,或采用支持自动故障转移的哨兵或集群方案。
  • 复制积压缓冲区不足:当主从节点网络中断后,从节点再次连接上主节点时会发送 psync 请求部分复制,如果请求的偏移量不在主节点的缓冲区内,则无法提供给从节点数据,导致部分复制退化为全量复制。
    • 避免这种情况需要根据网络中断时长,写命令数据量分析出合理的积压缓冲区大小。
    • 网络中断一般有闪断、机房割接、网络分区等情况。中断时长一般在分钟级别。
    • 写命令数据量可以统计高峰期主节点每秒的master_repl_offset差值获取。
    • 积压缓冲区默认为1MB,对于大流量场景明显不够,加大缓冲区,保证 repl_backlog_size > net_break_time * write_size_per_minute。

复制风暴指大量从节点对同一主节点或同台机器的多个主节点短时间内发起全量复制的场景。会导致相应主节点所在机器造成CPU、内存、带宽等大量开销。

复制风暴的场景:

  • 单主节点复制风暴:

    一般发生在主节点挂载多个从节点的场景。主节点重启恢复后,从节点发起全量复制流程,主节点创建RDB快照,在创建完毕前若有多个从节点都尝试进行全量复制,它们会共享这份RDB。但同时向多个从节点发送RDB文件会使带宽消耗严重,导致主节点延迟变大,极端时使主从节点断开连接。

    • 解决方案:首先减少主节点挂载的从节点数量,或采用树状的复制结构,加入中间层从节点保护主节点。网络开销分摊给了中间层,但带来了运维的复杂性,增加了手动和自动处理故障转移的难度。
  • 单机器复制风暴:

    Redis单线程,所以单台机器常常会部署多个实例。如果这台机器出现故障或网络长时间中断,重启恢复后会有大量从节点对其发起全量复制,导致当前机器带宽耗尽。

    解决方案:

    • 应该把主节点尽量分散到多台机器上,避免在单台机器上部署过多的节点。
    • 当主节点所在机器发生故障后,提供故障转移机制。

问:Redis 的哨兵模式?Reids Sentinel?

1. 主从复制的问题

  1. 手动故障转移:主节点宕机需人工介入切换从节点,导致服务中断。
  2. 单点写压力:写操作集中在主节点,可能成为性能瓶颈。
  3. 数据同步延迟:从节点同步存在延迟,可能读到旧数据。Redis 主从复制采用异步复制模型,主节点写入数据后,再异步将数据同步到从节点。这种设计存在复制延迟,可能导致在故障发生时,从节点数据落后,造成读取不一致。

2. Redis Sentinel 哨兵模式是什么?

定义:分布式监控系统,用于实现Redis主从架构的高可用,核心功能包括:

  • 监控:持续检查主从节点状态。哨兵进程持续监控所有被管理的 Redis 实例(包括主节点和从节点)的运行状态和健康状况,通过定期发送 PING/PONG 命令检测节点是否响应。
  • 通知:向客户端推送新主节点地址。当监控到某个节点宕机或异常时,Sentinel 会通知管理员或其他系统组件。
  • 自动故障转移:主节点故障时,自动选举新主。当主节点被判定为故障(下线)后,哨兵会在经过一致性判断(达到 quorum 数量的 Sentinel 认为该主节点不可用)后,自动发起故障转移,将一个合适的从节点升级为新的主节点,并更新其他从节点的复制配置。
  • 服务发现
    客户端可以通过 Sentinel 查询当前的 master 地址,从而实现自动切换到新的 master。

3. 如何搭建 Redis Sentinel

3.1 Sentinel 配置文件

在 Redis Sentinel 配置文件(通常名为 sentinel.conf)中,最关键的配置项是 sentinel monitor,其格式为:

1
2
3
4
sentinel monitor <master-name> <ip> <port> <quorum>
# sentinel.conf
sentinel monitor mymaster 127.0.0.1 6379 2 # 监控主节点,法定人数为2
sentinel down-after-milliseconds mymaster 5000 # 5秒无响应判定主观下线

这表示 Sentinel 将监控名为 mymaster 的主节点,地址是 127.0.0.1:6379,当至少 2 个 Sentinel 实例同时报告该主节点故障时,认为该主节点已经不可用。

3.2 启动 Sentinel 节点

启动 Sentinel 时,指定配置文件即可,例如在命令行中运行:

1
2
3
redis-server /path/to/sentinel.conf --sentinel
//
redis-sentinel sentinel.conf

如果搭建多个 Sentinel 节点(建议至少 3 个以保证可靠性),分别启动并指定相同的监控配置。

3.3 确认 Sentinel 状态

向主从节点发INFO,获取拓扑信息。使用 Redis 命令行工具可以查询 Sentinel 的状态,例如:

1
2
3
4
5
6
7
8
# 查看所有被 Sentinel 监控的主节点信息
redis-cli -p <sentinel_port> SENTINEL masters

# 查看某个主节点的从节点信息
redis-cli -p <sentinel_port> SENTINEL slaves mymaster

# 查看 Sentinel 自身的状态(包括投票、故障信息等)
redis-cli -p <sentinel_port> SENTINEL sentinels mymaster

4. Sentinel 的实现原理

Redis Sentinel 的核心工作原理可以分为以下几个阶段:

4.1 三个定时监控任务

  • 周期性监控任务

    Sentinel 内部启动多个定时任务来监控 Redis 节点的状态,常见的任务包括:

    1. 每隔10秒的监控任务:向主从节点发INFO,获取拓扑信息。负责周期性检查各个节点的整体状态、内存、负载等指标,进行健康评估。
    2. 每隔2秒的监控任务:通过__sentinel__:hello频道交换节点状态。更频繁地发送 PING 命令,检测节点是否响应,从而及时捕捉节点故障情况。
    3. 每隔1秒的监控任务:向所有节点发PING,检测存活状态。对故障节点进行密集检测,保证在发生网络抖动或临界故障时迅速作出反应。

4.2 主观下线与客观下线

  • 主观下线(Subjective Down,SDOWN)
    单个Sentinel判定节点不可达。当某个 Sentinel 自己检测到某个 Redis 节点连续未响应(或响应时间异常),会将该节点标记为主观下线,但还不代表整个系统都认为该节点不可用。
  • 客观下线(Objective Down,ODOWN)
    超过quorum数量的Sentinel确认主节点下线。当达到 quorum 要求,即足够多的 Sentinel 都报告该节点出现问题时,该节点会被正式标记为客观下线,此时就会触发故障转移流程。

4.3 领导者 Sentinel 节点选举与故障转移

基于Raft算法选举出领导者Sentinel执行故障转移。

  • 当主节点被客观下线后,所有 Sentinel 会进行一次领导者选举,由其中一个 Sentinel 负责协调故障转移。
  • 领导者 Sentinel 选出一个从节点升级为新的主节点,并通知其他 Sentinel 更新复制关系,完成整个故障转移过程。

故障转移步骤

  • 选择数据最新的从节点作为新主。
  • 向其他从节点发送SLAVEOF命令,指向新主。
  • 更新客户端配置,旧主恢复后成为从节点。

4.4 数据同步

  • 故障转移后,新主节点会通过重新复制(全量或部分同步)的方式与其他从节点保持数据一致。
  • Sentinel 会确保所有客户端得知新的主节点地址,以便后续的请求能够正确路由。

5. Sentinel 客户端

Redis Sentinel 同时为客户端提供服务发现功能。客户端库(如 Jedis、Lettuce、Redisson)通常内置对 Sentinel 模式的支持,能够自动从 Sentinel 获取当前的主节点地址,实现故障转移后的自动切换。使用 Sentinel 客户端的基本流程是:

  • 连接Sentinel获取主节点地址。
  • 订阅Sentinel通知,感知主节点切换。
  • 故障时自动重连到新主节点。

例如,使用 Jedis Sentinel 模式的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class SentinelExample {
public static void main(String[] args) {
// Sentinel节点地址
Set<String> sentinels = new HashSet<>();
sentinels.add("127.0.0.1:26379");
sentinels.add("127.0.0.1:26380");
sentinels.add("127.0.0.1:26381");

// 指定监控的主节点名称(与sentinel.conf中配置一致)
JedisSentinelPool sentinelPool = new JedisSentinelPool("mymaster", sentinels);

try (Jedis jedis = sentinelPool.getResource()) {
// 执行Redis操作
System.out.println("当前主节点:" + jedis.info("server"));
jedis.set("key", "value");
System.out.println("key=" + jedis.get("key"));
}

sentinelPool.close();
}
}

Redis集群中Key的定位

  1. 分片机制

    • Key通过CRC16(key) % 16384计算哈希槽(Slot)。
    • 每个节点负责一部分槽位。
  2. 定位步骤

    • 客户端计算槽位

      1
      CLUSTER KEYSLOT "your_key"  # 返回槽位编号
    • 查找槽位所在节点

      1
      CLUSTER NODES  # 查看所有节点及其负责的槽位范围
    • 直接重定向:客户端访问错误节点时,会收到MOVED响应,包含正确节点地址。

  3. 智能客户端:缓存槽位与节点映射关系,直接路由请求,减少重定向。

在 Redis 哨兵模式下,实际上并没有涉及到 数据分片(即将数据分布在多个 Redis 实例上)。哨兵模式本质上是一个高可用方案,它的主要功能是监控 Redis 实例的健康状态,并在主节点(Master)发生故障时,自动将一个从节点(Slave)提升为新的主节点,实现故障转移(Failover)。

哨兵模式并不支持水平扩展,也就是说,所有的 Redis 节点仍然是单点存储,所有的 key 都保存在单个 Redis 实例的内存中。如果数据量较大,仍然是单个 Redis 实例来存储,无法像 Redis 集群那样自动分片。

Redis 哨兵模式下如何找到 key

  1. 单节点存储:在没有开启 Redis 集群的情况下,所有的 key 都存在 Master 节点中。客户端直接连接主节点(Master),并请求该节点存取对应的 key。哨兵只是用来保证 Redis 的高可用性,确保如果 Master 节点宕机,能够自动将一个 Slave 节点升级为新的 Master,继续提供服务。客户端连接的目标节点会根据当前的主节点变化而变化。
  2. 哨兵的作用
    • 监控:哨兵定期监控主节点的状态。
    • 故障转移:如果检测到主节点故障,哨兵会自动提升一个从节点为新的主节点,并更新相关配置。
    • 通知:当主节点变更时,哨兵会通知客户端,客户端根据新的配置连接到新的主节点。
  3. 客户端与 Redis 哨兵的交互: 客户端通过 Redis 哨兵获取 主节点地址,然后通过该主节点来进行数据操作。当发生故障转移时,哨兵会通知客户端进行主节点切换。

Redis 哨兵模式的工作流程简述

  • 客户端请求数据时,通过连接到 Redis 哨兵获取当前主节点的 IP 和端口。
  • 哨兵持续监控 Redis 主节点和从节点。
  • 如果检测到主节点宕机,哨兵会发起故障转移,并将一个从节点提升为新的主节点。
  • 故障转移完成后,哨兵会通知客户端和集群中的其他节点更新主节点信息。

与 Redis 集群模式的区别

  • Redis 集群模式:在 Redis 集群模式下,数据会被 分片 存储在不同的节点上,每个 Redis 节点负责一部分的哈希槽(16384个槽)。每个 key 会通过计算其哈希值来确定存储在哪个节点。Redis 集群支持水平扩展,可以将数据分散到多个节点中,支持自动故障转移和数据重分布。
  • Redis 哨兵模式:哨兵模式主要是用来管理单一的 Redis 实例,保证高可用性。它不支持数据的分片,所有数据仍然存储在主节点上,哨兵负责监控主从节点的状态,自动进行故障转移。

总结

在 Redis 哨兵模式下,key 的存储位置通常就是主节点(Master)所在的 Redis 实例,因为哨兵模式本身不支持分片。如果主节点故障,哨兵会将一个从节点提升为主节点,并通知客户端更新连接地址。Redis 哨兵只是解决高可用问题,不涉及数据的分布和水平扩展。如果需要数据分片和扩展性,应该使用 Redis 集群 模式。

问:Redis集群?模式性能优化?什么是哈希槽?一个 key 值如何在 redis 集群中找到存储在哪?

一、集群前置知识

1. 数据分布理论

分布式数据库首先要解决把整个数据集按照分区规则映射到多个节点的问题。重点就是分区规则,常见有哈希和顺序两种。哈希离散度好、数据分区与业务无关、无法顺序访问。顺序则离散度低易倾斜、数据分布与业务相关、可顺序访问。

  • 节点取余分区:传统简单的分区方法,哈希分区。

    根据 key 的哈希值对节点数N取余 hash(key)%N ,将数据均匀分配到各个节点上。

    • 缺点:当节点数量发生变化时,数据迁移量大。Rehash操作的损耗。
  • 一致性哈希分区:利用一致性哈希算法,将 key 映射到一个哈希环上,再将节点散列到该环上。这样,节点增加或减少时,只需要迁移少量数据。

    为系统每一个节点分配一个token,范围一般在0~23之间,这些token构成一个哈希环。先根据key计算hash,然后顺时针找到第一个大于等于此hash的token节点。加入和删除节点只影响哈希环中相邻的节点。

    • 优点:数据迁移量小;
    • 缺点:可能出现数据分布不均衡的问题。
      • 不适合少量节点,节点变化会大范围影响哈希环中数据映射。
      • 增加节点只能对下一个相邻节点有比较好的负载分担效果。
  • 虚拟一致性哈希分区:在一致性哈希基础上引入虚拟节点,每个实际节点对应多个虚拟节点,从而更均衡地分布 key。

    为了使增删节点时,各个节点保持动态的平衡,将每个真实节点虚拟出若干虚拟节点,再将这些虚拟节点随机映射到环上,真实节点则不映射到环而只是用来存储键值对,负责接应各自的一组环上虚拟节点。对键值对进行存取路由时,首先路由到虚拟节点,再找到真实节点。

    • 例如,每个物理节点映射 100 个虚拟节点,这100个虚拟节点随机分散在环上,减小单个节点负载波动。
  • 虚拟槽分区
    Redis Cluster 并非直接使用一致性哈希,而是将整个 keyspace 分为 16384 个虚拟槽(slot)。每个 key 根据 CRC16 算法取模后得到对应的槽号,再将槽分配给具体的节点。

    使用分散度良好的哈希函数将所有数据映射到一个固定范围的整数集合中,整数被定义为槽(slot)。这个范围一般远远大于节点数(0~16383)。槽是集群内数据管理和迁移的基本单位,大范围槽方便数据拆分和集群扩展,每个节点都会负责一定数量的槽。

    • 为什么槽的范围是 0~16383?这是一个历史设计和经验值。16384 槽提供了足够的划分粒度,可以较均衡地分布数据,同时当集群节点发生变动时,只需要迁移部分槽的数据。若槽为65536,发送心跳信息的消息头将达到8k,非常浪费带宽。集群节点数量一般也不建议超过1000个,否则会导致网络拥挤。对于1000以内的节点,16384个槽足够使用。
    • 比如集群有3个节点,则每个节点平均大概负责5460个槽
    • 虚拟槽方式既简化了实现,也使得节点扩容、收缩时的数据迁移变得更简单、可控。
2. Redis 数据分区
  • Redis 虚拟槽分区的特点
    • 每个 key 都映射到 0~16383 范围内的一个槽。
    • 集群中的每个节点负责一部分连续或不连续的槽。
    • 当节点变动(扩容、缩容、故障转移)时,迁移的是槽的所有数据,而不是单个 key。
    • 计算公式:slot=CRC16(key)&16383
  • 集群功能限制
    • 集群模式下,不支持单个 multi/exec 事务跨多个节点;
    • 脚本(Lua)也受限于单个槽;
    • 某些命令(如 keys)受到分片限制,需要通过客户端聚合处理。

二、搭建集群

1. 节点配置
  • 每个 Redis 实例配置 cluster-enabled yescluster-config-file nodes.confcluster-node-timeout 等参数。

  • 配置文件示例(redis.conf):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 节点端口
    port 6379
    # 开启集群模式
    cluster-enabled yes
    # 节点超时时间,单位毫秒
    cluster-node-timeout 15000
    # 集群内部配置文件,命名规则redis-{port}.conf
    cluster-config-file "nodes-6379.conf"
    appendonly yes
  • 对于多节点部署,确保不同实例使用不同端口,且实例间网络互通。

  • 依次启动所有节点:

    1
    2
    3
    4
    5
    $ redis-server conf/redis-6379.conf
    ......
    $ redis-server conf/redis-6384.conf
    # 检测节点日志是否正确
    $ cat log/redis-6379.log
  • 首次启动若没有集群配置文件,会根据cluster-config-file自动创建,通过端口区分不同节点防止同一机器下多个节点彼此覆盖。当集群内节点信息发生变化,如添加节点、节点下线、故障转移等。节点会自动保存集群状态到配置文件中。Redis会自动维持集群配置文件,不需要手动修改。文件中记录的文件ID,是一个40位16进制字符串,用于唯一标识集群内一个节点,不同于运行ID的是只在集群初始化创建一次,后者则每次重启都会变化。

  • 节点之间不知道彼此存在,只能通过节点握手才能建立联系。由客户端发起命令:cluster meet {ip} {port} 。异步命令,执行后立即返回。主要作用是交换节点状态信息。

    1. 节点6379本地创建6380的节点对象,发送meet消息。
    2. 节点6380收到消息后,保存并回复pong消息。
    3. 之后二者定期通过ping/pong消息进行通信。
  • 全部节点建立握手后,集群并不能立即工作,此时处于下线状态,禁止所有的数据读写。被分配的槽是0,因为没有映射的节点,只有所有槽都分配给节点后才进入在线状态。

  • 分配槽:将所有数据映射到16384个槽中,每个key会映射到固定的槽,之后才能响应和槽相关的命令。通过命令 cluster addslots 为节点分配槽,通过bash批量设置:

    1
    2
    3
    $ redis-cli -h 127.0.0.1 -p 6379 cluster addslots {0...5461}
    $ redis-cli -h 127.0.0.1 -p 6380 cluster addslots {5462...10922}
    $ redis-cli -h 127.0.0.1 -p 6381 cluster addslots {10923...16383}

    分配后,集群进入在线状态。

    每个节点还需要一个从节点来保证故障时进行转移。首次启动的节点和被分配槽的都是主节点,从节点负责负责主节点槽信息和数据。通过命令 cluster replicate {nodeId} 使节点变为从节点(nodeId为主节点Id)。

    1
    2
    3
    4
    5
    6
    127.0.0.1:6382>cluster replicate cb.......
    OK
    127.0.0.1:6383>cluster replicate 8e.......
    OK
    127.0.0.1:6384>cluster replicate 4b.......
    OK
2. 集群创建(redis-trib.rb)
2.1 创建集群(随机主从)
  • 使用 Redis 提供的集群创建工具,如 redis-cli --cluster create 命令自动选主选从。

  • 示例命令:

    1
    redis-cli --cluster create 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 --cluster-replicas 1

    这里集群中 6 个节点,其中每个主节点配 1 个从节点。

2.2 指定主从节点
  • 创建集群主节点:通过上述命令可以创建主节点,也可以手动配置指定哪些节点作为主。

  • 添加从节点:使用 redis-cli --cluster add-node命令将新节点加入集群,并用 --cluster-slave 指定为从节点:

    1
    redis-cli --cluster add-node 127.0.0.1:7006 127.0.0.1:7000 --cluster-slave
3. 集群管理
  • 检查集群状态,使用命令:

    1
    redis-cli -p 7000 cluster info
  • 查看集群节点信息,使用命令:

    1
    redis-cli -p 7000 cluster nodes
  • 修复集群:如果集群出现不一致或节点故障,可使用 --cluster fix 参数修复集群。

  • 设置集群超时时间:配置文件中通过 cluster-node-timeout 设置节点超时时间。

  • 集群配置:使用 redis-cli --cluster 命令行工具管理集群参数、迁移槽等操作。


三、集群伸缩

1. 集群扩容
  • 扩容有三个步骤:

    1. 准备新节点:启动新的 Redis 实例并启用集群模式。此时新节点为孤儿节点。

    2. 加入集群:新节点在任意节点通过 cluster meet 命令加入到集群。集群间一段时间的交换信息后统一发现新节点。

      或者使用工具:

      • 主节点扩容:使用 redis-cli --cluster add-node 将新节点加入为主节点。
      • 从节点扩容:同理使用 --cluster-slave 参数加入新从节点。
    3. 迁移槽和数据:迁移槽的过程集群可以正常提供读写服务。槽是Redis集群数据管理的基本单位,首先要为新节点制定槽的迁移计划,确认原节点有哪些槽要迁移到新节点。迁移计划要确保最终每个节点负责相似数量的槽,从而保证各个节点的数据均匀。

      使用 redis-cli --cluster reshard 命令迁移部分槽数据到新节点,平衡数据分布。例如:

      1
      redis-cli --cluster reshard 127.0.0.1:7000

      根据提示输入目标槽范围和目标节点信息。

  • 槽和数据迁移流程

    1. 对目标节点发送 cluster setslot {slot} importing {sourceNodeId} 命令,让目标节点准备导入槽的数据
    2. 对源节点发送 cluster setslot {slot} migrating {targetNodeId} 命令,让源节点准备迁出槽的数据
    3. 源节点循环执行 cluster getkeysinslot {slot} {count} 命令,获取count个属于槽 {slot} 的值
    4. 在源节点执行 migrate {targetIp} {targetPort} “” 0 {timeout} keys {key…} 命令,把获取的键通过流水线机制批量迁移到目标节点(该命令在3.0.6版本前只能单个执行,批量可以极大降低网络IO次数)。‘
    5. 重复执行步骤3和4直到槽下所有的键值数据迁移到目标节点。
    6. 向集群内所有主节点发送 cluster setslot {slot} node {targetNodeId} 命令,通知槽分配给目标节点。为了保证槽节点映射及时传播,需要遍历发送给所有主节点更新被迁移的槽执行新节点。
2. 集群缩容
  • 迁移槽和数据:将要下线节点上的槽数据迁移到其他节点。收缩与扩容迁移方向相反,直接使用 reshard 命令完成槽迁移。
  • 下线节点:下线节点的槽迁移完毕,剩下就是让集群忘记该节点。使用 redis-cli --cluster del-node 命令将节点移出集群。Redis提供 cluster forget {downNodeId} 命令来实现该功能。节点收到命令后把nodeId指定的节点加入到禁用列表中,有效期为60秒,超过后会再次参加消息交换。线上不建议直接使用该命令下线节点,需要跟大量节点交互,实际操作繁琐且容易遗漏节点。建议使用 redis-trib.rb del-node {host:port} {downNodeId} 命令。
  • 流程:
    • 首先确定是否有槽,有则需要先迁移到其他节点。
    • 下线节点不再负责槽或是从节点时,可以通知集群其他节点忘记下线的节点,所有节点都忘记后可以关闭。
3. 迁移相关
  • 在线迁移 slot:利用 reshard 工具进行在线槽迁移。
  • 平衡(rebalance)slot:使用 redis-cli --cluster rebalance 命令自动平衡集群中各节点的槽分布。

四、请求路由

  • 请求重定向
    • 集群模式下,Redis接收任何键相关命令时首先要计算对应的槽,再根据槽找到对应节点,若节点是自身则处理键命令;否则回复MOVED重定向错误,通知客户端请求正确的节点。
    • 命令 cluster keyslot {key} 返回key对应的槽。重定向的信息包含键对应的槽以及对应节点地址,方便客户端再向正确节点发送。使用redis-cli命令可以添加 -c 参数支持自动重定向。
    • 计算槽:客户端通过计算 key 的 CRC16 值对 16384 取模,确定 key 对应的槽,然后查找该槽所在的节点。如果请求落到错误的节点,节点会返回 MOVEDASK 错误,客户端根据错误信息重新定位到正确的节点。
    • 槽节点查找:集群内通过消息交换,每个节点都知道所有节点的槽信息,保存在clusterState结构中。
  • call 命令:部分客户端实现了智能调用,自动处理 MOVEDASK 重定向。

五、Smart 客户端

  • Smart 客户端原理:Smart 客户端(例如 JedisCluster、Redisson)内置了对集群模式的支持,可以自动从 Sentinel 或集群管理节点获取槽映射,自动处理重定向错误。
  • ASK 重定向:当槽迁移过程中,新节点尚未接管所有数据时,Redis 会返回 ASK 错误。客户端收到后,会临时向指定节点发送 ASKING 命令,再重试请求。
  • 集群下的 Jedis 客户端:Hash tags:JedisCluster 支持通过使用 hash tag(例如 {user:1001} 部分),确保同一组 key 被映射到同一个槽,方便事务和批量操作。

六、集群原理

1. 节点通信
  • 通信流程
    每个 Redis 集群节点之间通过 TCP 进行通信,采用 Gossip 协议定期互相交换状态信息。
  • Gossip 消息
    节点通过 Gossip 消息了解其他节点的健康状态、槽分布、复制关系等信息。
  • 节点选择
    Gossip 协议帮助节点选择故障节点和协调故障转移过程。
2. 故障转移
  • 故障发现:当集群内某个节点出现问题时,需要一种健壮的方式保证识别出节点是否发生故障。故障发现也通过消息传播机制ping/pong来实现。

    • 主观下线(SDOWN):某个节点被单个节点检测为不可用。指某个节点认为另一个节点不可用,即下线状态,这个状态并不是最终的故障判定,只能代表一个节点意见,可能存在误判情况。

      • 每个节点都会定期向其他节点发送ping消息,接收节点回复pong消息作为响应。如果在cluster-node-timeout时间内通信一直失败,则发送节点会认为接收节点存在故障,把接收节点标记为主观下线(pfail)状态。
      • 主观下线流程:
        1. 节点a发送ping消息给节点b,如果通信正常将接收到pong消息,节点a更新最近一次与节点b的通信时间。
        2. 如果节点a与节点b通信出现问题则断开连接,下次会进行重连。如果一直通信失败,则节点a记录的与节点b最后通信时间将无法更新。
        3. 节点a内的定时任务检测到与节点b最后通信时间超高cluster-node-timeout时,更新本地对节点b的状态为主观下线(pfail)。
    • 客观下线(ODOWN):当超过一定数量的节点确认某节点故障时,该节点被标记为不可用。指标记一个节点真正的下线,集群内多个节点都认为该节点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节点进行故障转移。

      • 当某个节点判断另个节点主观下线后,相应节点状态会跟随消息在集群内传播。

      • 通过Gossip消息传播,集群内节点不断收集到故障节点的下线报告。当半数以上持有槽的主节点都标记某个节点是主观下线时,触发客观下线流程。存在两个问题:

        1. 为什么必须是负责槽的主节点参与故障发现决策?因为集群模式下只有处理槽的主节点才负责读写请求和集群槽等关键信息维护,而从节点只进行主节点数据和状态信息的复制。
        2. 为什么半数以上处理槽的主节点?必须半数以上是为了应对网络分区,等原因造成的集群分割情况,被分割的小集群因为无法完成从主观下线到客观下线这一关键过程,从而防止小集群完成故障转移之后继续对外提供服务。
      • 客观下线流程:

        1. 当消息体内含有其他节点的pfail状态会判断发送节点的状态,如果发送节点是主节点则对报告的pfail状态处理,从节点则忽略。
        2. 找到pfail对应的节点结构,更新clusterNode内部下线报告链表。
        3. 根据更新后的下线报告链表尝试进行客观下线。
  • 故障恢复:下线节点是持有槽的主节点,需要从它的从节点选一个代替。从节点通过内部定时任务发现其所属主节点进入客观下线,就会触发故障恢复流程。

    • 资格检查:判断从节点是否满足提升为主节点的条件。每个从节点检查最后与主节点断开时间,如果超过 cluster-node-time * cluster-slave-validity-factor(默认为10),则该从节点不具备资格。

    • 准备选举:故障主节点达到 ODOWN 后,集群中的 Sentinel 开始准备选举。从节点具有资格则更新触发故障选举的时间,到达该才继续流程。采用延迟触发机制的原因:通过对多个从节点使用不同选举时间来支持优先级问题,复制偏移量越大说明从节点延迟越低,则其就应该有更高的优先级替代主节点。越高优先级的从节点越早触发故障选举流程。

    • 发起选举:从节点定时任务检测到达故障选举时间(failover_auth_time),发起选举流程:

      1. 更新配置纪元:

        是一个只增不减的整数,每个主节点用其标识自身版本,从节点会复制此纪元。整个集群会维护一个全局的配置纪元(clusterState.currentEpoch),用来记录集群内最大版本。可以通过 cluster ingo 查看配置。

        1
        2
        cluster_current_epoch // 集群最大配置纪元
        cluster_my_epoch // 当前主节点配置纪元

        配置纪元随着ping/pong消息在集群内传播,当发送和接收都是主节点且纪元相等表示出现冲突,nodeId更大的一方会递增全局纪元并赋值给当前节点来区分冲突。

        作用:

        • 标识集群内每个主节点的不同版本和集群最大版本。
        • 每次集群发生重要事件时(指出现新的主节点),从节点竞争选举,都会递增全局配置纪元并赋值给相关主节点,用来记录这一事件。
        • 主节点具有更大配置纪元代表更新的集群状态,当节点间进行ping/pong消息交换时,如出现slots等关键信息不一致时,以配置纪元更大的一方为准,防止过时的消息状态污染集群。

        应用场景:

        • 新节点加入。
        • 槽节点映射冲突检测。
        • 从节点投票选举冲突检测。
    1. 广播选举消息:集群内广播选举消息(FAILOVER_AUTH_REQUEST),并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次选举。消息内容如同ping消息只是将type类型变为FAILOVER_AUTH_REQUEST。
    • 选举投票:只有持有槽的主节点才会处理故障选举消息(FAILOVER_AUTH_REQUEST),其在一个配置纪元内都有唯一的选票,接到一个请求投票的从节点消息时回复FAILOVER_AUTH_ACK消息作为投票,相同配置纪元内其他从节点的选举消息将忽略。

      投票过程其实是一个领导者选举的过程,每个配置纪元内持有槽的主节点只能投票给一个从节点,只有一个从节点会获得N/2+1的选票。Redis不使用从节点进行领导者选举,因为从节点数必须大于等于3个才能保证凑够N/2+1个节点,将导致从节点资源浪费。使用主节点的方案即使只有一个从节点也可以完成选举。

      从节点收集到N/2+1个持有槽的主节点投票时,从节点执行替换主节点操作:

      故障主节点也算在投票数内,所以容易因选票不足导致故障转移失败,部署时需要部署3台物理机以上避免单点问题。

      投票作废:每个配置纪元代表了一次选举周期,如果在开始投票后的cluster-node-time*2时间内从节点没有获取足够数量的投票,则本次选举作废。从节点对配置纪元自增并发起下一轮投票,直到选举成功为止。

    • 替换主节点:领导者通知所有节点,将一个合适的从节点升级为新的主节点,更新复制关系。

      1. 当前从节点取消复制变为主节点。
      2. 执行clusterDelSlot操作撤销故障主节点负责的槽,并执行clusterAddSlot把这些槽委派给自己。
      3. 向集群广播自己的pong消息,通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息。
  • 故障转移时间:整个故障转移过程通常在几秒钟内完成,但具体时间取决于集群规模和网络延迟。

    估算故障转移时间:

    1. 主观下线(pfail)识别时间=cluster-node-timeout。
    2. 主观下线状态消息传播时间<=cluster-node-timeout/2。消息通信机制对超过cluster-node-timeout/2未通信节点会发起ping消息,消息体在选择包含哪些节点时会优先选取下线状态节点,通常这段时间内能够收集到半数以上主节点的pfail报告从而完成故障发现。
    3. 从节点转移时间<=1000毫秒。由于存在延迟发起选举机制,偏移量最大的从节点会最多延迟1秒发起选举。通常第一次选举就会成功,所以从节点执行转移时间在1秒以内。

    公式:failover-time(毫秒) <= cluster-node-timeout + cluster-node-timeout/2 + 1000 。故障转移时间跟cluster-node-timeout参数息息相关,默认15秒。


七、集群管理命令(redis-cli –cluster)

常用命令包括:

  • 创建集群:

    1
    redis-cli --cluster create <node1> <node2> ... --cluster-replicas 1
  • 添加节点:

    1
    redis-cli --cluster add-node <newNode> <existingNode> [--cluster-slave]
  • 迁移槽(reshard):

    1
    redis-cli --cluster reshard <node>
  • 平衡槽(rebalance):

    1
    redis-cli --cluster rebalance <node>
  • 删除节点:

    1
    redis-cli --cluster del-node <node> <nodeId>

集群原理综述

  • 节点通信
    通过 Gossip 协议定期交换状态信息,确保每个节点知道集群整体的健康状态和槽分布。
  • 故障转移
    采用主观下线和客观下线机制进行故障检测,并通过 Sentinel 类似的投票机制完成从节点提升为主节点的过程。
  • 数据迁移
    当节点增加或减少时,使用 reshard 命令在线迁移槽数据,保证数据均衡分布。
  1. Master最好不要做任何持久化工作,如RDB内存快照和AOF日志文件
  2. 如果数据比较重要,某个Slave开启AOF备份数据,策略设置为每秒同步一次
  3. 为了主从复制的速度和连接的稳定性,Master和Slave最好在同一个局域网内
  4. 尽量避免在压力很大的主库上增加从库
  5. 主从复制不要用图状结构,用单向链表结构更为稳定,即:Master - Slave1 - Slave2 - Slave3… 这样的结构方便解决单点故障问题,实现Slave对Master的替换。如果Master挂了,可以立刻启用Slave1做Master,其他不变。

问:集群不可用场景?Redis如何判断集群不可用?

  1. master挂掉,且当前master没有slave,没有替代方案,则整个集群不可用。
  2. 默认集群有16384个槽,任意一个没有指派到节点时,集群都不可用。执行任何键命令都会返回ERROR CLUSTERDOWN Hash slot not served。当持有槽的主节点下线,从故障发现到自动完成转移的期间整个集群都不可用,对于大部分业务都无法容忍,所以会把cluster-require-full-coverage配置为no,主节点故障只影响它负责槽的相关命令执行,而不影响其它主节点可用性。
  3. 当访问一个Master和Slave节点都挂掉时,且cluster-require-full-coverage=yes,会提示槽无法获取。
  4. 集群超过半数以上master挂掉,无论是否有slave集群进入fail状态。fail掉一个主节点需要一半以上主节点投票通过才可以。
  5. 有 A,B,C 三个节点的集群,在没有复制模型的情况下,如果节点 B 失败了,那么整个集群就会以为缺少 5501-11000 这个范围的槽而不可用。

集群不可用的场景

  1. 单个 Master 无法替代
    • 情况描述: 如果某个 master 节点挂掉,而该 master 没有对应的 slave 或者其 slave 也处于故障状态,则负责该节点槽(slot)的数据将没有备份,导致这部分数据不可用。
    • 后果: 任何针对这部分槽的命令都会返回错误,整个业务可能因为关键数据不可访问而中断。
  2. 槽缺失问题
    • 情况描述: Redis 集群要求所有 16384 个槽都必须被某个节点负责。如果任意一个槽未被分配(例如,在故障转移过程中或配置不当时),则对该槽上 key 的任何操作都会返回错误,提示“CLUSTERDOWN Hash slot not served”。
    • 配置影响:
      • 如果配置项 cluster-require-full-coverageyes,那么一旦有任一槽缺失,整个集群都被视为不可用,所有命令都会返回 CLUSTERDOWN 错误。
      • 如果设置为 no,则缺槽只会影响对应槽的数据访问,其它槽依然可用,从而降低整体业务中断的风险。
  3. 超过半数 Master 节点挂掉
    • 情况描述: Redis 集群在进行故障检测时,会根据一定的 quorum 规则判定一个 master 节点是否失效。如果集群中超过半数的 master 节点同时挂掉(无论 slave 状态如何),集群将无法正常提供服务。
    • 后果: 集群进入 FAIL 状态,所有操作均返回 CLUSTERDOWN。
  4. 无复制模式下的节点故障
    • 情况描述: 在没有复制(master-slave)模型的简单集群中,如果某个节点挂掉,那么该节点负责的槽区间(例如假设 B 节点负责槽 5501-11000)就会缺失,导致整个集群认为数据不完整。
    • 后果: 即使其它节点正常,针对缺失槽区间的任何键操作都会报错,集群整体也会被判定为不可用。

Redis 如何判断集群不可用

Redis 集群的不可用判断主要依赖以下几个方面:

  1. 槽完整性检查
    • 集群模式下,所有 16384 个槽必须都被某个节点负责。
    • 如果任意一个槽没有分配到节点,Redis 在处理请求时会返回错误信息,如 “ERROR CLUSTERDOWN Hash slot not served”,这表明集群处于不完整状态,从而被判定为不可用。
  2. 节点故障检测与 quorum 投票机制
    • 主观下线(SDOWN): 每个节点周期性地对其他节点进行健康检测,如果连续几次检测发现某节点无响应,该节点被标记为主观下线。
    • 客观下线(ODOWN): 当超过预设数量(quorum)的节点都认为某个 master 节点处于不可用状态时,该 master 将被正式标记为客观下线。
    • 如果故障 master 无法及时转移,且超过半数 master 节点失效,则集群整体将进入不可用状态。
  3. 配置参数 cluster-require-full-coverage 的影响
    • 如果该参数设置为 yes,只要有任意槽缺失,整个集群都会报错,判断为不可用。
    • 如果设置为 no,集群允许部分槽缺失,只有访问缺失槽数据时才返回错误,整体集群仍然可以处理其它槽的数据请求。
  4. 主从同步状态
    • 当对某个 master 节点及其所有从节点均无法访问时,集群会认为该节点故障,即使其它 master 正常,也会影响到该槽上的数据可用性。

总结

  • 集群不可用场景
    1. 某个 master 节点挂掉且没有可用的从节点替代;
    2. 集群中任一槽(0~16383)未被分配,或在故障转移过程中存在空缺;
    3. 超过半数的 master 节点挂掉;
    4. 无复制模式下单个节点故障导致对应槽缺失。
  • 判断依据
    Redis 集群通过检查槽分配完整性、利用 Gossip 协议和 quorum 投票机制监控节点健康状态,并依赖配置参数 cluster-require-full-coverage 来决定是否因部分槽缺失而将集群判为不可用。只要有关键部分(如多数 master 节点或部分槽数据)不可用,Redis 就会返回 “CLUSTERDOWN” 错误。

问:Redis集群会发生写操作丢失吗?为什么?

Redis 并不能保证数据的强一致性,这意味这在实际中集群在特定的条件下可能会丢失写操作。一般写操作只往主节点中写入,通过异步方式同步到从节点,若主节点宕机,数据还未到从节点就导致了丢失。

Redis 集群 中,写操作丢失是可能发生的,尽管 Redis 本身提供了强一致性保障,但由于一些特定场景和配置问题,仍然可能导致写操作丢失。以下是几种可能导致 Redis 集群中写操作丢失的原因:

  1. 节点故障导致的写操作丢失

在 Redis 集群中,写操作是通过 主节点 执行的,主节点的故障会导致写操作丢失,特别是在 主节点与从节点同步尚未完成 的情况下。

  • 主节点故障:如果主节点故障发生在写操作之后、从节点同步数据之前,且该操作没有持久化到 AOF 或 RDB 文件,那么该写操作将丢失。
  • 从节点同步延迟:如果主节点和从节点之间的同步延迟较大,主节点宕机时,从节点可能没有及时接收到最新的写操作。

解决方案:

  • 启用 AOF 持久化:通过 AOF(Append-Only File)持久化,Redis 能够记录所有写操作,这样即使发生主节点故障,也可以通过 AOF 文件恢复数据。
  • 增加从节点数量:保证每个主节点有多个从节点,以减少主节点故障时的影响。如果主节点故障,集群可以通过选举新的主节点,避免数据丢失。
  • 采用同步机制加强一致性:使用 wait 命令进行等待确认,确保写操作被多个从节点确认后再认为写成功。
  1. 网络分区问题(脑裂)

网络分区(split-brain)会导致集群中的部分节点无法与其他节点通信,进而影响集群的正常工作。在此情况下,部分节点可能会继续接受写操作,而这些写操作不会被同步到集群中的其他节点。当网络恢复后,这些分区节点的数据会发生冲突,导致数据不一致,甚至丢失。

  • 网络分区导致的脑裂:如果集群分为两个子集且每个子集都有写操作,可能导致两个子集的数据不同步,从而丢失其中一个子集的写操作。

解决方案:

  • 启用 Redis 哨兵(Sentinel):哨兵可以通过检测集群中的节点故障,并进行故障转移,尽量减少脑裂问题的发生。
  • 保证大多数节点可用:配置 Redis 集群时,使用合理的故障转移策略,确保集群中的大多数节点可用,防止脑裂问题。
  1. 集群模式下的主从同步配置问题

在 Redis 集群中,数据的分布是基于哈希槽(hash slots)来进行的,每个主节点管理一部分哈希槽。主节点的写操作需要通过同步机制传播到从节点,但在某些情况下,从节点的同步可能会失败,导致数据丢失。

  • 同步超时:如果从节点长时间无法与主节点同步,可能导致主节点的数据没有及时同步到从节点。
  • 单向同步:在一些配置中,如果从节点处于某些不稳定的状态(比如网络问题或者硬件故障),它无法及时从主节点同步数据,这会导致数据丢失。

解决方案:

  • **配置 min-slaves-to-write**:通过配置 min-slaves-to-write,可以确保至少有足够的从节点成功同步数据之后才允许执行写操作,这样可以减少写操作丢失的风险。
  • **设置 min-slaves-max-lag**:通过设置 min-slaves-max-lag,可以确保只有延迟较小的从节点才被认为是有效的,从而避免写操作丢失。
  1. AOF 持久化策略导致的丢失

Redis 的 AOF 持久化通过追加日志的方式记录每一条写操作。当使用 AOF 时,如果 AOF 配置为每秒刷新一次(appendfsync everysec,在极端情况下,某些操作可能未及时刷盘,这就可能导致写操作丢失。

  • AOF 持久化未及时刷新:如果 Redis 在写操作之后突然宕机,并且写操作尚未被 AOF 文件刷新到磁盘,则可能会丢失这些操作。

解决方案:

  • **配置为 appendfsync always**:配置 AOF 持久化为每次写操作都同步到磁盘,虽然会影响性能,但可以保证数据不丢失。
  • 合理选择 AOF 刷新策略:如果写操作频繁且性能要求高,建议使用 appendfsync everysec,并配合合理的持久化策略,保证数据的可靠性。
  1. 集群扩容/缩容期间的写操作丢失

在进行集群扩容或缩容时,如果迁移操作没有完全同步,可能导致某些哈希槽的数据丢失或未完全同步。

  • 迁移过程中的写操作丢失:在扩容或缩容时,某些写操作可能会到达正在迁移的节点,导致写操作丢失。

解决方案:

  • 谨慎操作集群扩容和缩容:在扩容或缩容期间,确保迁移过程中的每一步都被正确同步,避免数据丢失。
  • 利用wait命令确保数据同步:可以使用 wait 命令确保某些写操作在指定的从节点上成功。

总结

虽然 Redis 集群在正常情况下是非常高效的,但在特定的场景下,写操作丢失是可能发生的。常见原因包括:

  • 主节点故障与从节点同步延迟
  • 网络分区导致的脑裂
  • AOF 持久化策略导致的数据丢失
  • 集群扩容或缩容期间的数据迁移问题

为减少写操作丢失的风险,通常可以通过以下方式进行保障:

  • 配置 AOF 持久化
  • 增加从节点数量,保证高可用性
  • 配置合理的同步机制
  • 启用 Redis 哨兵进行故障转移
  • 优化网络和集群配置,防止脑裂

这些措施能够确保 Redis 集群的高可用性和数据一致性,减少写操作丢失的可能性。

问:谈谈Redis哨兵、复制、集群?

主从复制的作用:

  1. 数据一致性:在主节点故障时顶上,并保证数据不丢失。
  2. 读写分离:扩展主节点的读能力,分摊并发读压力。

主从复制的问题:

  • 主节点故障,从节点需要手动晋升,还要修改应用方的主节点地址,还要命令其他从节点复制新的主节点。
  • 主节点的写能力受到单机限制。
  • 主节点的存储能力受到单机限制。

主从复制模式下,一旦主节点故障,需要手动晋升从节点,还要通知应用更新主节点地址。Redis 2.8版本提供Sentinel(哨兵)架构来解决该问题。

Redis提供了三种集群策略:

  1. 主从模式:主库可以读写,会和从库进行数据同步。该模式下,客户端直连主库或从库,当有节点宕机后,客户端需要手动修改IP,该模式较难扩容,集群所有存储数据受限于单个节点的内存容量,无法支持较大数据量。
  2. 哨兵模式:该模式在主从模式上新增了哨兵节点,当主库宕机后,哨兵发现该情况并在从库中选择一个库作为新的主库。也可以对哨兵做集群,避免哨兵的单点问题。该模式能较好的保证Redis的高可用,但无法解决容量上限的问题。
  3. cluster模式:支持多主多从,按照key进行槽位的分配,可以使不同的key分散到不同的主节点上。该模式可以使集群能支持更大的数据容量,同时每个主节点有多个从节点保证高可用。

Sentinel哨兵模式

  • Sentinel能够自动完成故障发现和故障转移,并通知应用方来实现高可用。
  • Sentinel是一个分布式架构,包含若干Sentinel节点和Redis数据节点,每个Sentinel节点会对数据节点和其它Sentinel节点进行监控,当发现节点不可达时,会标记下线标识。如果被标识的是主节点,还会和其它Sentinel节点协商,当大部分Sentinel节点都认为主节点不可达时,会选举出一个Sentinel节点来完成自动故障转移工作,同时将变化通知给应用。

Sentinel相比主从复制从结构上只是多了若干Sentinel节点,并没有对Redis节点做特殊处理。

当数据量不大时选择哨兵模式,数据量大时选择cluster模式。

  1. twemproxy,它类似于一个代理方式,使用方法和普通Redis 无任何区别,设置好它下属的多个 Redis 实例后,使用时在本需要连接 Redis 的地方改为连接twemproxy,它会以一个代理的身份接收请求并使用一致性 hash 算法,将请求转接到具体 Redis,将结果再返回twemproxy。使用方式简便(相对 Redis 只需修改连接端口),对旧项目扩展的首选。 问题:twemproxy 自身单端口实例的压力,使用一致性 hash后,对Redis 节点数量改变时候的计算值的改变,数据无法自动移动到新的节点。
  2. codis,目前用的最多的集群方案,基本和 twemproxy 一致的效果,但它支持在节点数量改变情况下,旧节点数据可恢复到新 hash 节点。
  3. Redis cluster3.0 自带的集群,特点在于他的分布式算法不是一致性 hash,而是 hash槽的概念,以及自身支持节点设置从节点。具体看官方文档介绍。
  4. 在业务代码层实现,起几个毫无关联的Redis 实例,在代码层,对 key进行 hash 计算,然后去对应的 Redis 实例操作数据。 这种方式对 hash 层代码要求比较高,考虑部分包括,节点失效后的替代算法方案,数据震荡后的自动脚本恢复,实例的监控,等等。
特性 复制(Replication) 哨兵(Sentinel) 集群(Cluster)
数据分布 无分片,所有数据存储在主节点 无分片,依赖主从结构 自动分片,数据分布在多个节点
读写分离 支持,读操作可以分发到从节点 支持,读操作可以分发到从节点 支持,读操作可以从多个节点读取
高可用性 有,依赖于从节点备份和手动故障转移 自动故障转移 内置自动故障转移机制
水平扩展 不支持自动分片,依赖垂直扩展 不支持自动分片,依赖垂直扩展 支持水平扩展,自动分片
配置复杂度 简单,容易配置 复杂,需要多个哨兵节点 配置相对复杂,需要运维管理
适用场景 中小型应用,读写分离需求 需要高可用,且无法采用集群的场景 大规模、高并发、高可用、高性能应用

1. 主从复制(Replication)

解决的问题

  • 数据冗余与备份:从节点复制主节点数据,提供数据副本。
  • 读写分离:主节点处理写请求,从节点处理读请求,分担主节点负载。
  • 高可用基础:为后续的哨兵和集群提供数据同步的基础能力。

存在的问题

  • 手动故障转移:主节点故障时,需人工介入将从节点提升为主节点,导致服务中断。
  • 写性能瓶颈:所有写操作仍集中在单主节点,无法扩展写能力。
  • 数据一致性问题:异步复制可能导致从节点数据短暂落后(复制延迟)。

2. 哨兵(Sentinel)

解决的问题

  • 自动故障转移:监控主节点状态,主节点故障时自动选举新主节点,实现高可用。
  • 服务发现:客户端通过哨兵获取当前主节点地址,无需硬编码配置。

存在的问题

  • 写能力未扩展:仍为单主节点架构,写性能受限于单个主节点。
  • 存储容量限制:数据存储在单主节点,无法水平扩展数据量。
  • 复杂度增加:需部署多个哨兵实例以避免自身单点故障,管理成本上升。

3. 集群(Cluster)

解决的问题

  • 水平扩展:通过数据分片(16384个哈希槽)将数据分布到多个主节点,支持更高的写并发和更大的数据量。
  • 高可用集成:每个分片由主节点和从节点组成,主节点故障时,从节点自动提升为新主节点(无需额外哨兵)。
  • 去中心化架构:集群节点通过Gossip协议通信,自主管理故障转移与数据迁移。

存在的问题

  • 功能限制:跨节点操作(如事务、多键命令)受限,需使用哈希标签(Hash Tag)保证键在同一节点。
  • 运维复杂度:节点扩缩容需手动迁移数据或使用工具(如redis-cli --cluster reshard)。
  • 客户端适配:客户端需支持集群协议,直连节点并处理重定向(MOVED/ASK响应)。

问:Hystrix的隔离机制有哪些?Hystrix常见配置是哪些?

Hystrix 的隔离机制

Hystrix 是一个用于处理分布式系统中故障的容错框架,它的核心目标是通过 断路器模式隔离策略降级策略等机制来确保系统的高可用性和稳定性。在 隔离机制 方面,Hystrix 主要有以下几种:

  1. 线程隔离(Thread Isolation)
  • 定义:Hystrix 会为每一个请求创建一个新的线程(线程池),从而将各个请求的处理隔离开来。即使某个请求在执行过程中阻塞或超时,也不会影响其他请求的执行。

  • 适用场景:适用于需要进行独立处理的任务,如外部系统的调用、数据库操作等,能够隔离各个请求之间的资源竞争。

  • 优势

    • 资源隔离:可以控制每个请求的最大并发数,防止某个服务因为高并发导致整个系统性能下降。
    • 错误隔离:一个请求的失败不会影响到其他请求,避免了“雪崩效应”。
  • 缺点

    • 线程池管理成本较高,需要精心设置线程池的大小。
    • 如果线程池设置得不合理,可能导致线程池耗尽,从而影响系统性能。
  1. 信号量隔离(Semaphore Isolation)
  • 定义:信号量隔离是通过一个信号量(Semaphore)来限制对资源的访问。每次请求获取一个信号量,如果信号量耗尽(即所有的信号量都被占用),则请求会被拒绝。

  • 适用场景:适用于不需要为每个请求创建独立线程的场景,例如对一些轻量级任务的控制,减少线程的上下文切换开销。

  • 优势

    • 更轻量级:相比于线程池隔离,信号量隔离不需要创建新的线程,适合处理轻量级的任务。
    • 降低资源消耗:避免了线程池管理带来的额外开销。
  • 缺点

    • 没有线程隔离那么强的资源隔离效果,容易受到共享资源的影响。
    • 如果任务执行时间过长,可能会占用信号量,导致系统其他请求无法获得信号量。
  1. 不隔离(No Isolation)
  • 定义:没有隔离措施,所有的请求都在主线程中执行。

  • 适用场景:一般情况下,不推荐使用这种模式,但在某些轻量级任务中,可能会选择不进行隔离。

  • 优势

    • 没有额外的线程和资源管理开销。
  • 缺点

    • 无法避免不同请求之间的干扰,可能导致性能下降,影响系统的可扩展性。

Hystrix 常见配置

Hystrix 提供了多种配置项来控制断路器的行为、线程池的大小、请求超时等,下面是一些常用的配置项。

  1. 断路器相关配置
  • hystrix.command.default.circuitBreaker.enabled

    :启用或禁用断路器,默认为

    1
    true

    ,表示启用。

    • 配置例子:hystrix.command.default.circuitBreaker.enabled=true
  • hystrix.command.default.circuitBreaker.requestVolumeThreshold

    :断路器切换到“打开”状态的请求数量阈值,默认是

    1
    20

    。当超过该阈值的请求失败时,断路器会触发。

    • 配置例子:hystrix.command.default.circuitBreaker.requestVolumeThreshold=10
  • hystrix.command.default.circuitBreaker.errorThresholdPercentage

    :断路器在请求中失败的比例阈值,超过该阈值时,断路器会打开。默认值为

    1
    50

    ,即 50% 的失败请求时触发。

    • 配置例子:hystrix.command.default.circuitBreaker.errorThresholdPercentage=50
  • hystrix.command.default.circuitBreaker.sleepWindowInMilliseconds

    :断路器在“打开”状态后多久尝试切换到“半打开”状态(即允许一定量的请求通过进行恢复检查)。默认是

    1
    5000

    (即 5 秒)。

    • 配置例子:hystrix.command.default.circuitBreaker.sleepWindowInMilliseconds=5000
  1. 超时配置
  • hystrix.command.default.execution.timeout.enabled

    :是否启用超时功能,默认值为

    1
    true

    • 配置例子:hystrix.command.default.execution.timeout.enabled=true
  • hystrix.command.default.execution.timeoutInMilliseconds

    :请求超时的时间,单位毫秒,默认值是

    1
    1000

    (即 1 秒)。

    • 配置例子:hystrix.command.default.execution.timeoutInMilliseconds=1000
  1. 线程池配置
  • hystrix.threadpool.default.coreSize

    :线程池的核心线程数,默认为

    1
    10

    。表示 Hystrix 执行请求时能够同时处理的最小并发量。

    • 配置例子:hystrix.threadpool.default.coreSize=20
  • hystrix.threadpool.default.maximumSize

    :线程池的最大线程数,默认为

    1
    10

    。表示当有大量并发请求时,线程池能够容纳的最大线程数。

    • 配置例子:hystrix.threadpool.default.maximumSize=50
  • hystrix.threadpool.default.keepAliveTimeMinutes

    :线程池的线程空闲时长,默认为

    1
    1

    ,单位是分钟。

    • 配置例子:hystrix.threadpool.default.keepAliveTimeMinutes=2
  • hystrix.threadpool.default.queueSizeRejectionThreshold

    :队列拒绝的最大请求数,当队列中的请求数超过该值时,新的请求会被拒绝并触发回退机制。默认值是

    1
    5

    • 配置例子:hystrix.threadpool.default.queueSizeRejectionThreshold=5
  1. 请求缓存与请求合并
  • hystrix.command.default.requestCache.enabled

    :是否启用请求缓存,默认为

    1
    true

    。启用后,相同的请求会直接返回缓存值,而不会重复执行。

    • 配置例子:hystrix.command.default.requestCache.enabled=true
  • hystrix.command.default.requestLog.enabled

    :是否启用请求日志,默认为

    1
    true

    • 配置例子:hystrix.command.default.requestLog.enabled=true
  • hystrix.command.default.execution.isolation.strategy

    :设置请求隔离策略。可以是

    1
    THREAD

    (线程隔离)或

    1
    SEMAPHORE

    (信号量隔离)。默认值是

    1
    THREAD

    • 配置例子:hystrix.command.default.execution.isolation.strategy=SEMAPHORE

常见配置总结

  • 断路器相关配置:控制何时打开断路器、何时恢复、失败请求的阈值等。
  • 超时配置:控制请求的超时设置。
  • 线程池配置:控制每个命令的线程池大小和线程的生命周期。
  • 请求缓存与请求合并:控制请求缓存和请求合并的行为,以优化系统性能。

总结

Hystrix 提供了多种配置选项,可以帮助开发者控制服务的容错能力、超时策略、线程池的大小等。根据不同的业务场景,可以调整这些配置,以确保服务的高可用性。常见的隔离机制包括 线程隔离信号量隔离无隔离。对于分布式系统,合理配置 Hystrix 的各项参数是非常重要的,可以有效降低系统故障的影响。

问:Redis分区场景?实现方案?分区的缺点?

分区可以让 Redis 管理更大的内存,Redis 将可以使用所有机器的内存。如果没有分区,最多只能使用一台机器的内存。分区使 Redis 的计算能力通过简单地增加计算机得到成倍提升,Redis 的网络带宽也会随着计算机和网卡的增加而成倍增长。

客户端分区就是在客户端就已经决定数据会被存储到哪个 Redis 节点或者从哪个 Redis 节点读取。大多数客户端已经实现了客户端分区。代理分区意味着客户端将请求发送给代理,然后代理决定去哪个节点写数据或者读数据。代理根据分区规则决定请求哪些Redis 实例,然后根据 Redis 的响应结果返回给客户端。Redis 和 memcached 的一种代理实现就是 Twemproxy查询路由(Query routing) 的意思是客户端随机地请求任意一个 Redis实例,然后由 Redis将请求转发给正确的 Redis 节点。RedisCluster 实现了一种混合形式的查询路由,但并不是直接将请求从一个 Redis 节点转发到另一个 Redis 节点,而是在客户端的帮助下直接redirected 到正确的Redis 节点。

  • 涉及多个 key 的操作通常不会被支持。例如不能对两个集合求交集,因为他们可能被存储到不同的 Redis 实例(实际上这种情况也有办法,但是不能直接使用交集指令)。
  • 同时操作多个 key,则不能使用 Redis 事务。
  • 分区使用的粒度是 key,不能使用一个非常长的排序 key 存储一个数据集(The partitioning granularity is the key, so it is not possible to shard a dataset with a single huge keylike a very big sorted set)。
  • 当使用分区的时候,数据处理会非常复杂,例如为了备份必须从不同的 Redis 实例和主机同时收集RDB / AOF 文件。
  • 分区时动态扩容或缩容可能非常复杂。Redis集群在运行时增加或者删除 Redis 节点,能做到最大程度对用户透明地数据再平衡,但其他一些客户端分区或者代理分区方法则不支持这种特性。然而,有一种预分片的技术也可以较好的解决这个问题。

Redis 分区(Sharding)场景

分区(Sharding)是指将数据拆分成多个部分,分布到不同的 Redis 实例或集群节点中。Redis 分区常用于水平扩展,尤其是当单个 Redis 实例无法处理大量数据或并发时,通过分区可以将负载分散到多个 Redis 实例上,提高系统的可伸缩性和性能。

Redis 分区的应用场景:

  1. 数据量大
    • 当单个 Redis 实例的内存容量达到上限时,使用分区可以将数据拆分到多个实例中,避免单个实例内存溢出。
  2. 高并发
    • 如果一个 Redis 实例的并发请求量过高,可能导致性能瓶颈。通过将请求分布到多个实例中,可以分散负载,避免单个 Redis 实例的资源耗尽。
  3. 高可用性和容错性
    • 分区可以与复制和高可用性(如 Redis Sentinel 或 Redis Cluster)结合使用,提高系统的可靠性和容错能力。
  4. 业务需求分割
    • 某些业务场景可能需要将数据按照某些维度进行划分(例如,按用户 ID、商品 ID 等),分区能够根据业务逻辑将数据拆分到多个实例上。

Redis 分区实现方案

  1. 客户端分区(Client-Side Sharding)
  • 描述:客户端直接根据分区规则计算出数据应存储在哪个 Redis 实例中,从而将请求发送到相应的实例。这种方式客户端需要知道所有 Redis 节点的地址,并且根据特定规则(如哈希算法)来确定数据存储的节点。

  • 实现方式

    • 常见的是使用 一致性哈希取模哈希 算法,计算出键值应该存储的 Redis 实例。
  • 优缺点

    • 优点

      • 客户端控制分区,灵活度高,能够应对不同的分区策略。
      • 无需修改 Redis 服务端的架构。
    • 缺点

      • 客户端需要有更多的逻辑,代码复杂。
      • 扩容时,可能需要重新计算和迁移数据,涉及较高的开销。
  1. Redis Cluster
  • 描述:Redis Cluster 是 Redis 官方提供的分区解决方案,通过将数据分布在多个节点上,实现数据的分区存储。每个 Redis 节点管理一部分数据(使用 进行划分),客户端通过哈希槽算法将请求路由到正确的 Redis 节点。

  • 实现方式

    • Redis Cluster 采用了 哈希槽(Hash Slots)机制,将键空间划分为 16384 个槽(slots),每个键根据哈希值映射到一个槽。然后,这些槽会分配到不同的 Redis 节点。
    • 每个 Redis 节点负责一部分槽的数据。客户端根据哈希槽将请求发送到正确的节点。
    • 通过 复制故障转移 保证高可用性。
  • 优缺点

    • 优点

      • 自动分配和管理数据分区,简化了分区管理。
      • 集群自动处理节点扩容、故障转移等,保证高可用。
    • 缺点

      • 存在数据迁移和 rebalancing 的复杂性。
      • 由于采用的是一致性哈希,集群扩容时会涉及较大的数据迁移,可能会带来性能影响。
  1. Redis Sentinel + 手动分区
  • 描述:使用 Redis Sentinel 来实现主从复制和故障转移,结合手动分区,将数据分散到不同的 Redis 节点。分区策略可以使用哈希、业务划分等方法。

  • 实现方式

    • 在 Redis Sentinel 的支持下,配置多个 Redis 节点,手动将数据分布到不同的 Redis 实例上。
    • 需要通过客户端进行数据路由,客户端需要根据分区策略选择正确的 Redis 节点。
  • 优缺点

    • 优点

      • 通过 Sentinel 实现高可用和故障转移。
      • 可以根据业务需要进行灵活的分区设计。
    • 缺点

      • 扩展性不如 Redis Cluster,手动管理的复杂度较高。

Redis 分区的缺点

  1. 数据迁移的复杂性
    • 当 Redis 实例的数量增加或减少时,需要重新分配数据。尤其是使用一致性哈希算法时,数据迁移的代价较高,可能会对系统性能产生影响。尤其是 Redis 集群扩容时,数据需要从旧节点迁移到新节点,可能会导致性能波动。
  2. 单节点瓶颈问题
    • 分区的目的是通过水平扩展来避免单节点瓶颈,但如果某个 Redis 节点的负载过高,仍然可能成为系统的瓶颈。特别是当某些键的访问量非常大(即热键),可能会导致单个节点的压力过大。
  3. 跨分区查询复杂
    • 如果需要跨多个 Redis 节点进行查询,可能需要多个网络请求,增加了查询的延迟。比如,需要从多个 Redis 节点获取不同的键值,再汇总结果,增加了查询的复杂度。
  4. 一致性问题
    • 分区后,可能会面临数据一致性的问题,特别是在网络分区或节点故障时。Redis 集群通过复制来保证高可用性,但在分区环境下,跨节点的操作可能会引发数据一致性问题,需要通过合适的设计和协议来解决。
  5. 扩容难度
    • 在进行水平扩展时(如增加 Redis 节点),可能需要重新计算和迁移大量的数据,增加了系统的复杂度和运维成本。

总结

Redis 分区的主要目的是解决数据量大、并发高时的性能瓶颈问题。常见的分区方案有客户端分区、Redis Cluster 和 Redis Sentinel + 手动分区。不同的分区方案有不同的优缺点,需要根据具体业务场景来选择。

缺点

  • 数据迁移复杂性
  • 单节点瓶颈问题
  • 跨分区查询的复杂性
  • 一致性问题
  • 扩容难度

因此,在实施 Redis 分区时,需要合理选择分区策略,并考虑如何处理扩容、数据迁移、热键等问题。

问:什么是Canal?

问:亿级用户热点数据更新如何优化?

什么是热点数据?

  • 指访问频率很高、更新频繁的数据,如:并发量高的接口、查询用户信息、优惠券信息等

热点数据带来的问题?

  1. 数据库连接被耗尽,无法处理新的请求
  2. 行锁冲突、锁等待、线程阻塞、降低数据处理性能
  3. CPU被打满
  4. 频繁更新数据,慢SQL,主从同步延迟更严重

假设一个业务场景:某物流公司(数亿用户)小程序收到大量反馈:系统无法下单,影响巨大。

现象分析:

  • 第一时间,首先确认除问题外其它系统功能是否正常,缩小排查范围。
  • 验证步骤:
    1. 验证小程序中与订单无关的其它核心功能,如会员信息、营销活动、优惠券购买等。结果:正常。
    2. 验证小程序中与订单相关的功能,如下单、查询订单等。结果:异常。
  • 结论:订单模块功能异常,未影响到全部系统功能。

问题定位:

  1. 初步判断:根据经验+工具。有经验的架构师、经理等根据自身经验,确定一个大概出问题的方向。
    • 架构:该系统非单体,而是微服务,根据业务进行拆分,所以订单系统没有影响到别的模块。
    • 方向:
      • 应用线程被打挂:活跃线程超过配置最大值,导致新的请求都无法被处理。基于K8s部署的应用通过请求探活接口,发现没响应后会不断重启应用,导致C端小程序是不是出现异常。
      • 数据库CPU被打挂:
        • 数据库出现大量SQL,业务请求量太大,导致大量线程阻塞,CPU很快飙升到100%。
        • 并发请求太高,超过数据库能承受的最大线程数(业务请求TPS:2w/s,数据库单节点TPS:2k/s),CPU很快飙升到100%。
      • Redis被打挂:
        • CPU被打爆,接近100%。可能原因:出现热key。
        • 内存满了,接近100%。可能原因:key短时间内增长很快,超过内存最大值。设计不合理或者过期时间设置较长。
        • 网络IO太大,如1.8G/s。可能原因:出现大key
    • 监控工具:
      • 优先查看报警信息,找到有用信息。如:数据库告警->慢接口告警->应用程序打满告警
      • 如果没有订阅中间件、数据库的告警监控,通过监控系统(promethus+grafana)依次检查:应用(cpu、活跃线程数)->数据库(cpu、并发查询QPS/写入TPS)->Redis(CPU、内存、网络IO)
  2. 初步分析:假设我们发现了数据库的某个分片库CPU达到100%,导致订单应用线程被打满,应用频繁重启。即数据库被打挂。
    • 通过数据库监控,进一步查看是否有慢SQL,发现有大量insert语句。
    • 根据SQL去检查代码,发现有ToC的公司用户在某个时间批量推送的大批订单更新,TPS接近2w/s。然后通过kafka消费模块进入MySQL的某单一分片,造成了高并发Update,从而打爆了某个分片库。因为MyCat架构的原因,集群的单节点挂掉后无法自动进行故障转移,从而引起了大面积请求失败。
  3. 临时解决方案:
    • 重启出问题的MySQL分片库。
    • 流量快速切到容灾。
    • 关闭大客户Kafka数据消费。但会导致消息积压,待问题处理完成后要尽快恢复。
    • 限制带宽、查单功能。等待数据库、MyCat恢复。
    • 逐步开放业务功能。
  4. 长期解决方案:
    • 原架构痛点分析:
      • 流量突增时,系统没有限流,数据库容易被打挂。
      • 大客户消费订单时没有限速,流量突增,负载过高。
      • 大客户消费和散户下订单在同个应用,共用数据库,任一出问题相互影响。
    • 优化方案:
      • 流量控制:
        • 在流量突增的源头增加限速限流:大客户kafka消费入口增加一个kafka分流服务来限速,应对突发流量动态配置消息优先级、消息处理时间。
        • 大客户C端查单限流:大客户查单存在瞬时并发风暴的性能风险。识别大客户(根据最近90天下单量),查询订单功能部分限流,应用层网关增加限流(基于单节点或集群,可以接入sentinel或基于redis的各种实现限流)
      • 热点隔离:
        • 热点业务的应用独立部署:将出现问题的运单业务从原订单业务中拆出独立部署,对接外部第三方的大客户运单也从运单业务中剥离出独立部署。
        • 热点数据垂直拆分为独立的数据库:运单业务独立后,相关的表拆分到独立的运单库。
        • 热点业务的中间件隔离(如Redis、MQ等):考虑到成本,可以进行拆分,一般Redis和MQ性能较高,可以在线无损扩容。
      • 存储层优化:
        • 分库分表中间件切换:mycat中间件替换为shardingsphere,因为mycat架构应用到数据库只有一个连接池,在一个库异常的情况下,会导致JDBC池满,缺少隔离。
        • 数据分批次提交:自定义mybatis拦截器,拦截执行SQL,动态配置一个阈值。当insert或update记录数超过阈值时,改为分批次提交事务(参考:mybatis-plus批量提交)。
        • 存储架构升级:C端查询运单数据架构升级,查询mycat分片库升级为es+hbase架构。es支持各种查询条件,如全模糊查询。明细数据放入hbase,从es中查询运单ID,再根据运单ID批量从hbase查询运单明细。
  5. 总结与拓展:
    • 面试提问的回答:从3个层面分析,流量控制、热点隔离、存储层优化。
    • 热点更新的其它解决方案:
      • 数据合并后更新。适合异步更新的场景,比如:秒杀系统设计时,针对秒杀商品库存的扣减,可以在内存合并为一个商品的记录,计算商品扣减的总库存后,批量更新热点表,减少锁冲突。
      • update转insert。采用类似流水表的设计方式。因为insert性能要高于update,可以基于流水记录做各种统计,采用定时任务异步更新热点表,减少锁冲突。
    • 压力提问:你给出的方案比较复杂,有没有更好的热点数据处理方案?改造底层数据库。基于数据库层面做统一改造,通过改写mysql的执行层,提供自动探测热点行更新开关。当检测到单行有大量热点更新,在执行层引入排队机制,减少行锁冲突,提高并发性能。

什么是热点数据?

热点数据(Hot Data)指的是在系统中频繁访问、操作的数据,通常是用户最关注、最常请求的内容。对于亿级用户的场景,热点数据通常是指在短时间内被大量用户频繁访问或更新的数据。例如:

  • 电商平台中,某个热销商品的库存信息、价格信息。
  • 社交平台中,热门文章、用户的最新动态。
  • 互联网金融平台中的热门股票、理财产品等。

热点数据带来的问题

在大规模系统中,热点数据如果没有正确优化,会带来一系列问题:

  1. 数据库压力
    • 热点数据会被大量请求,导致数据库频繁访问并产生高并发。这会造成数据库性能瓶颈,甚至导致数据库崩溃。
  2. 缓存雪崩
    • 热点数据通常会被缓存,若缓存的热点数据在同一时间过期,所有请求会同时访问数据库,可能导致数据库压力急剧增加,出现雪崩效应。
  3. 缓存穿透
    • 如果没有有效的缓存策略,热点数据的请求可能会频繁查询数据库,造成数据库负担过重。比如,热点数据更新后缓存未及时更新,或者缓存数据不存在。
  4. 缓存击穿
    • 如果某个热点数据的缓存因某种原因被清除,接下来的请求可能直接访问数据库,导致缓存击穿现象。
  5. 负载不均衡
    • 热点数据的集中访问会使得请求分布不均,可能导致某些节点的负载过高,造成服务器压力不均,影响系统整体的稳定性和响应速度。

热点数据带来的问题分析

  1. 性能瓶颈
    • 热点数据的频繁访问可能导致缓存和数据库同时承受很大的读请求负载,导致性能瓶颈。
  2. 系统不稳定
    • 在并发量非常高的情况下,如果没有合理的负载均衡和缓存管理策略,可能导致某些节点或服务超载,进而影响整个系统的稳定性。
  3. 数据一致性问题
    • 热点数据在缓存和数据库中保持一致性较为困难,尤其是在高并发更新场景中,如果没有合适的缓存更新策略,可能会出现数据不一致的情况。
  4. 延迟和响应时间
    • 热点数据被频繁访问时,数据库的延迟会逐渐增大,尤其是在数据库负载过高时,响应时间会显著上升,影响用户体验。

如何优化解决热点数据问题?

优化热点数据的访问与更新策略,需要从多个方面入手。以下是几种常见的优化方案:

  1. 数据缓存优化
  • 缓存热点数据
    • 将热点数据缓存到 RedisMemcached 等内存数据库中。因为内存读取速度远远高于磁盘,可以显著提高数据访问速度。
    • 使用合理的 过期策略(例如:根据数据更新频率设置过期时间)和 LRU(Least Recently Used)算法,来保证缓存有效性。
  • 热点数据预热
    • 对于一些已知的热点数据,可以在系统启动时,提前将其加载到缓存中,这样在用户第一次请求时,可以直接从缓存中获取数据。
  • 避免缓存雪崩
    • 设置 不同的缓存过期时间。避免所有热点数据在同一时刻过期,造成数据库压力暴增。
    • 通过 加随机过期时间,使得多个缓存的过期时间错开,避免短时间内大量请求访问数据库。
  • 双层缓存机制
    • 使用 本地缓存 + 分布式缓存,例如,使用本地缓存(如 guava cache)存储热点数据,同时使用 Redis 作为分布式缓存。通过减少访问 Redis 的次数,进一步提升性能。
  1. 读写分离和负载均衡
  • 读写分离
    • 使用 主从复制(如 Redis 和 MySQL),将数据库的写操作发送到主节点,而读操作发送到从节点。通过分散读请求,可以减少热点数据对主数据库的压力。
  • 负载均衡
    • 使用负载均衡策略,避免对某些节点的请求压力过大。通过 请求路由(如使用一致性哈希、分布式缓存等)来确保负载的均衡分布。
  1. 热点数据隔离
  • 热点数据分离
    • 对热点数据和普通数据进行分离,采用不同的缓存和存储策略。可以将热点数据缓存并单独存储,而对冷数据使用传统数据库进行存储和处理。
  • 分库分表
    • 在数据库层面上,通过 分库分表 对不同类型的数据进行分区处理。例如,可以将热点数据与普通数据放到不同的数据库中,或将热点数据拆分到不同的数据库实例中,减少压力集中。
  1. 异步更新和队列机制
  • 异步更新
    • 使用 异步队列消息队列(如 Kafka、RabbitMQ),将热点数据的更新操作异步化。这样,更新操作不会阻塞业务流程,同时避免过高的并发请求直接访问数据库。
  • 延迟队列
    • 对热点数据的缓存更新,可以通过延迟队列来实现,定期批量更新缓存,避免高并发时直接写入数据库。
  1. 优化数据库
  • 数据库索引优化
    • 对热点数据相关的字段创建合理的数据库索引,提升查询效率。
  • 数据库分区和分片
    • 使用 分区(partitioning)分片(sharding) 将热点数据存储到多个数据库实例上,分担负载。
  1. 利用布隆过滤器
  • 布隆过滤器

    • 对于热点数据的查询,可以在缓存之前先通过布隆过滤器判断数据是否存在。如果布隆过滤器判断数据不存在,就可以避免直接查询数据库,减少不必要的数据库请求。
  1. 热数据热备和异步同步
  • 热备
    • 对热点数据进行热备,即在多个 Redis 节点间进行数据复制,确保热点数据能够快速访问。可以在多个节点部署 Redis 主从集群,避免单点故障。
  • 异步同步
    • 对于热数据,可以通过异步的方式同步更新缓存和数据库。比如,使用定时任务或者后台服务,定期将数据库中的数据同步到缓存,避免高并发时直接操作数据库。

总结

热点数据是指在短时间内被大量用户频繁访问的数据。它带来的问题主要包括数据库压力增大、缓存雪崩、缓存穿透、负载不均衡等。

为了解决这些问题,可以通过以下策略进行优化:

  • 数据缓存优化:合理利用缓存策略、双层缓存机制和热点数据预热。
  • 读写分离和负载均衡:使用数据库读写分离,结合负载均衡来分散请求压力。
  • 热点数据隔离:将热点数据和普通数据分开处理,使用分库分表技术。
  • 异步更新和队列机制:异步化热点数据更新,减少数据库的直接访问。
  • 优化数据库性能:通过优化数据库索引和使用分区或分片来处理热点数据。
  • 布隆过滤器:避免无效的查询,减少数据库负担。

通过这些优化措施,可以有效缓解热点数据带来的问题,提高系统的性能和可扩展性。