编程导航Redis话题讨论

Redis

164 参与
分享

快来分享你的内容吧~

点击登录,快来和大家讨论吧~
表情
图片
话题
打卡
综合
交流
文章
问答

springboot项目使用lua脚本的流程概览

在 Spring Boot 3.x 项目中,利用已有的 Redis 依赖(通常是 `spring-boot-starter-data-redis`)来使用 Lua 脚本非常简单。使用 Lua 脚本最大的好处是可以保证多个 Redis 操作的**原子性**,并且能够显著**减少网络开销**。 下面为你整理了一套标准的实战步骤,教你如何在项目中优雅地集成和调用 Lua 脚本: ### 第一步:编写 Lua 脚本文件 首先,在你的 Spring Boot 项目的资源目录下创建一个 Lua 脚本文件。 建议路径:`src/main/resources/lua/your_script.lua` 假设我们写一个简单的“获取值,若不存在则设置默认值”的脚本: ```lua -- get_or_set_default.lua local key = KEYS[1] local defaultVal = ARGV[1] local value = redis.call('GET', key) if not value then redis.call('SET', key, defaultVal) value = defaultVal end return value ``` * **KEYS**:用于接收你传入的 Redis 键(数组形式)。 * **ARGV**:用于接收你传入的其他业务参数(数组形式)。 * **redis.call()**:在 Lua 脚本中执行 Redis 命令的标准方式。 ### 第二步:将 Lua 脚本配置为 Bean 在 Spring Boot 3.x 中,推荐将 Lua 脚本封装成一个 `RedisScript` 的 Bean,交由 Spring 容器管理。这样不仅加载一次即可复用,Spring Data Redis 还会自动帮你处理脚本的 SHA1 缓存(即底层自动使用 `EVALSHA` 命令,极大提升性能)。 创建一个配置类: ```java import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ClassPathResource; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.scripting.support.ResourceScriptSource; @Configuration public class RedisLuaConfig { @Bean public RedisScript<String> getOrDefaultScript() { DefaultRedisScript<String> redisScript = new DefaultRedisScript<>(); // 加载类路径下的 lua 脚本 redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/get_or_set_default.lua"))); // 指定脚本返回值的类型(支持 String, Long, Boolean, List 等) redisScript.setResultType(String.class); return redisScript; } } ``` ### 第三步:在业务代码中调用脚本 在你的 Service 或 Component 中,直接注入 `RedisTemplate` 和刚刚配置好的 `RedisScript` Bean,然后通过 `execute` 方法执行即可。 ```java import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Service; import java.util.Collections; import java.util.List; @Service public class RedisLuaService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private RedisScript<String> getOrDefaultScript; public String getValueOrDefault(String key, String defaultValue) { // 调用 execute 方法执行 Lua 脚本 // 参数依次为:脚本对象、键的列表、其余参数(可变长参数) return redisTemplate.execute( getOrDefaultScript, Collections.singletonList(key), defaultValue ); } } ``` ### 💡 核心注意事项 1. **序列化问题**: `RedisTemplate` 默认的 Key 和 Value 序列化方式可能会影响 Lua 脚本的参数传递。如果你的 Key 是普通的字符串,建议确保 `RedisTemplate` 的 Key 序列化器使用的是 `StringRedisSerializer`,否则传入脚本的 Key 可能会带有额外的序列化前缀(如 `\xac\xed\x00\x05t\x00...`)。 2. **参数传递规则**: 在 `redisTemplate.execute()` 方法中,`KEYS` 必须通过 `List` 传入,而 `ARGV` 则是跟在后面的可变长参数。在 Lua 脚本中分别通过 `KEYS[1]`, `ARGV[1]` 等下标来获取。 3. **返回值类型**: 在配置 `DefaultRedisScript` 时,务必通过 `setResultType` 明确指定返回值的 Java 类型,否则可能会导致类型转换异常。 按照以上三步,你就可以在 Spring Boot 3.x 项目中轻松、高性能地使用 Redis Lua 脚本来处理复杂的原子性业务逻辑了。

Redisson 集群节点故障恢复后客户端无法自动重连,必须重启应用

### Bug 描述 生产环境发生过一次集群节点故障(其中一个主节点宕机,从节点切主),整个过程持续约几分钟,Redis Cluster 自身已经完成 failover 并恢复正常,通过 redis-cli 直接连接集群读写都正常。但是Redisson客户端在节点恢复后没有自动恢复,所有针对原宕机节点 slot 范围的操作持续抛出以下异常: org.redisson.client.RedisNodeNotFoundException: Node: NodeSource [slot=null, addr=null, redisClient=null, redirect=null, entry=null] hasn't been discovered yet 只能通过重启应用才能恢复。 ### 环境 - Redisson 版本:3.11.0 (升级版本可能会产生兼容性问题) - 部署模式:Redis Cluster (3 主 3 从) - 客户端初始化方式:Redisson.create(config) - 配置:scanInterval = 5000ms (集群拓扑扫描间隔) ### 已排查项 1. Redis Cluster 自身在故障期间和恢复后都是健康的(cluster nodes / cluster info 都正常) 2. scanInterval 已配置为 5000ms,理论上应该能在 5s 内感知到拓扑变化 3. 异常持续时间远远超过 scanInterval,看起来客户端命中了某种内部缓存状态,即使拓扑扫描线程仍在运行也无法刷新 4. 不是网络问题,其他基于同一套网络的 Redis 客户端(JedisCluster)在同样的故障场景下能自动恢复 5. 如果检测到异常后重建client,可能会因为命中缓存无法真正重建 ### 问题与期望行为 1. Redisson 是否提供 API 可以主动触发集群拓扑刷新?(类似强制执行 CLUSTER NODES 并重建连接池) 我看了 RedissonClient 接口没有找到类似方法。 2. 3.11.0 是否有已知的集群拓扑刷新 bug?升级到哪个版本可以修复?(我们目前评估升级到 3.44.0,但有兼容性顾虑) 3. 期望redis集群出现问题恢复后,不需要重启项目来恢复项目的功能。有什么解决方案? ### 目前想到的思路 将redisson相关数据结构重构为redis的,RDelayedQueue + RBlockingDeque改为定时轮询扫 ZSet,RLock转换为setNX,但这样成本比较高,目前的业务逻辑已经在生产运行。

耄耄的Redis常见面试题

# 耄耄的Redis常见面试题 ```mermaid graph TD A[使用场景] --> B[缓存] A --> C[分布式锁] A --> D[计数器] A --> E[保存token] A --> F[消息队列] A --> G[延迟队列] B --> H[穿透、击穿、雪崩] B --> I[双写一致、持久化] B --> J[数据过期、淘汰策略] C --> K[setnx、redisson] E & F & G --> L[数据类型] M[其他面试题] --> N[集群] M --> O[事务] M --> P[Redis为什么快] N --> Q[主从] N --> R[哨兵] N --> S[集群] style A fill:#a6d1fa,stroke:#333,stroke-width:2px style B fill:#a6d1fa,stroke:#333,stroke-width:2px style C fill:#a6d1fa,stroke:#333,stroke-width:2px style D fill:#a6d1fa,stroke:#333,stroke-width:2px style E fill:#a6d1fa,stroke:#333,stroke-width:2px style F fill:#a6d1fa,stroke:#333,stroke-width:2px style G fill:#a6d1fa,stroke:#333,stroke-width:2px style H fill:#f0a0a0,stroke:#333,stroke-width:2px style I fill:#f0a0a0,stroke:#333,stroke-width:2px style J fill:#f0a0a0,stroke:#333,stroke-width:2px style K fill:#f0a0a0,stroke:#333,stroke-width:2px style L fill:#f0a0a0,stroke:#333,stroke-width:2px style M fill:#a6c1fa,stroke:#333,stroke-width:2px style N fill:#a6c1fa,stroke:#333,stroke-width:2px style O fill:#a6c1fa,stroke:#333,stroke-width:2px style P fill:#a6c1fa,stroke:#333,stroke-width:2px style Q fill:#f0a0a0,stroke:#333,stroke-width:2px style R fill:#f0a0a0,stroke:#333,stroke-width:2px style S fill:#f0a0a0,stroke:#333,stroke-width:2px ``` 本文对Redis常见面试题进行详细的讲解,主要包括以下几个部分:Redis缓存击穿,缓存穿透,缓存雪崩、Redis和DB双写一致性、Redis数据持久化、数据过期策略、数据淘汰策略、Redis分布式锁、主从复制、Redis集群等。 ## 缓存穿透 ```mermaid sequenceDiagram participant 客户端 as Client participant 缓存 as Redis participant 数据库 as DB 客户端->>缓存: 1.查询key(不存在于缓存) 缓存->>客户端: 2.返回缓存未命中(null) 客户端->>数据库: 3.查询数据库(该key实际也不存在于数据库) 数据库->>客户端: 4.返回无数据(null) 客户端->>客户端: 5.向用户返回“无数据”响应 Note over 客户端,数据库: 缓存穿透:请求不存在的key,绕过缓存直接冲击数据库 ``` **缓存穿透**:查询一个**不存在**的数据,MySQL查询不到数据也不会直接写入缓存,就会导致每次请求都查数据库 这里我们主要有两个解决方案: | 解决方案 | 优点 | 缺点 | | ---------- | ------------------------- | -------------------------------- | | 缓存空数据 | 简单 | 消耗内存,可能会发生不一致的问题 | | 布隆过滤器 | 内存占用较少,没有多余key | 实现复杂,存在误判 | 下面详细说一下什么是布隆过滤器。 ### 布隆过滤器 布隆过滤器(Bloom Filter)是一种**空间效率极高的概率型数据结构**,用于判断一个元素是否 “可能存在” 于一个集合中。它通过**多个哈希函数**将元素映射到一个 二进制数组(位数组)的多个位置,将这些位置标记为 1。当查询一个元素时,若其对应的所有哈希位置都为 1,则认为该元素 “可能存在”;若有一个位置为 0,则确定该元素 “一定不存在”。 ![image-20251107173303144](https://pic.code-nav.cn/post_picture/1917113123715145729/Xaii0GAWIE8nIub5.webp) ```mermaid sequenceDiagram participant 客户端 as Client participant 布隆过滤器 as BloomFilter participant 缓存 as Redis participant 数据库 as DB 客户端->>布隆过滤器: 1.发起查询请求(key=待查ID) 布隆过滤器->>布隆过滤器: 2.通过哈希函数判断key是否“可能存在” alt key一定不存在(位数组有0) 布隆过滤器->>客户端: 3.返回“无数据” 客户端->>客户端: 4.向用户返回“无数据”响应 else key可能存在(位数组全1) 布隆过滤器->>缓存: 3.允许查询缓存,key=待查ID 缓存->>客户端: 4.返回缓存结果(命中则直接响应;未命中则查数据库) alt 缓存未命中 客户端->>数据库: 5.查询数据库,key=待查ID 数据库->>客户端: 6.返回数据(存在则返回,不存在则返回null) 客户端->>缓存: 7.将数据库结果写入缓存(存在则写正常数据,不存在则写空值缓存) 客户端->>客户端: 8.向用户返回响应 end end Note over 客户端,DB: 布隆过滤器拦截“一定不存在”的请求,避免缓存穿透 Note over 布隆过滤器: 核心逻辑:用概率型结构过滤无效请求,保护数据库 ``` 要解决缓存穿透问题,布隆过滤器的核心思路是提前拦截 “一定不存在” 的请求,避免这些请求绕过缓存直接冲击数据库。具体逻辑如下: **步骤 1:初始化布隆过滤器** 在系统启动或数据加载时,将数据库中所有存在的 key(比如用户 ID、商品 ID 等)通过布隆过滤器的哈希函数映射到其内部的位数组中,标记这些位置为 1。 **步骤 2:拦截请求** 当客户端发起查询请求时,流程变为: 先查布隆过滤器:用同样的哈希函数计算请求的 key 对应的位数组位置。 - 如果有任意一个位置为 0,说明这个 key一定不存在于数据库,直接返回 “无数据”,无需再查缓存和数据库。 - 如果所有位置都为 1,说明这个 key可能存在(因布隆过滤器有误判率),再继续走正常的 “缓存→数据库” 流程。 ## 缓存击穿 ```mermaid sequenceDiagram participant 客户端集群 as Client Cluster participant 缓存 as Redis participant 数据库 as DB Note over 缓存: 缓存中key已设置过期时间,此时恰好过期 客户端集群->>缓存: 1.大量并发请求:查询已过期的key 缓存->>客户端集群: 2.所有请求均返回:缓存未命中(key已过期) 客户端集群->>数据库: 3.大量并发请求同时冲击数据库,查询该key Note over 客户端集群,DB: 缓存击穿核心:热点key过期瞬间,大量并发请求穿透缓存压垮DB ``` **缓存击穿:**给某一个key设置了过期时间,当key过期的时候,恰好这时间点对这个key有大量的并发请求过来,这些并发的请求可能会瞬间把DB压垮。 这里主要有两种解决方式:互斥锁和逻辑过期 ### 互斥锁 ```mermaid sequenceDiagram participant 客户端 as Client Cluster participant 分布式锁 as DistLock (Redis setnx/Redisson) participant 缓存 as Redis participant 数据库 as DB Note over 缓存: 热点key即将过期或已过期 客户端->>缓存: 1.并发请求查询热点key 缓存->>客户端: 2.返回缓存未命中 客户端->>分布式锁: 3.竞争分布式锁(只有一个客户端能获取) alt 成功获取锁 客户端->>数据库: 4.查询数据库获取最新数据 数据库->>客户端: 5.返回数据 客户端->>缓存: 6.将数据写入缓存(并设置合理过期时间) 客户端->>分布式锁: 7.释放锁 客户端->>客户端: 8.返回数据给用户 else 未获取到锁 客户端->>缓存: 9.短暂休眠后,再次查询缓存(等待持有锁的客户端更新缓存) 缓存->>客户端: 10.返回已更新的缓存数据 客户端->>客户端: 11.返回数据给用户 end Note over 客户端,DB: 互斥锁保证“只有一个请求去查数据库”,其他请求等待缓存更新后再读缓存 ``` - **思路**:当缓存未命中时,客户端先竞争**分布式锁**,只有获取到锁的客户端才去查询数据库并更新缓存,其他客户端则等待一段时间后重新查询缓存。 - **优点**:确保高并发场景下只有一个请求穿透到数据库,有效保护数据库;实现相对灵活,可适配多种分布式锁方案(如 Redis 的 `setnx`、Redisson 框架等)。 - **缺点**:存在一定的锁竞争开销;若持有锁的客户端查询数据库或更新缓存时发生异常,需设置锁的超时时间避免死锁。 ### 逻辑删除 ```mermaid sequenceDiagram participant 线程1 as Thread1 participant 线程2 as Thread2 participant 线程3 as Thread3 participant 线程4 as Thread4 participant 缓存 as Cache participant 数据库 as DB participant 互斥锁 as Lock Note over 缓存: 数据存储结构含“逻辑过期时间”,缓存本身无物理过期时间 %% 线程1流程 线程1->>缓存: 1.查询缓存,检查逻辑过期时间 缓存->>线程1: 返回数据(逻辑已过期) 线程1->>互斥锁: 2.尝试获取互斥锁 互斥锁->>线程1: 锁获取成功 线程1->>线程2: 3.开启新线程(线程2)执行缓存重建 线程1->>客户端: 4.返回过期数据 %% 线程2流程 线程2->>数据库: 1.查询数据库,获取最新数据 数据库->>线程2: 返回最新数据 线程2->>缓存: 2.写入新数据到缓存,并重置逻辑过期时间 缓存->>线程2: 缓存更新成功 线程2->>互斥锁: 3.释放互斥锁 %% 线程3流程 线程3->>缓存: 1.查询缓存,检查逻辑过期时间 缓存->>线程3: 返回数据(逻辑已过期) 线程3->>互斥锁: 2.尝试获取互斥锁 互斥锁->>线程3: 锁获取失败(被线程1持有) 线程3->>客户端: 3.返回过期数据 %% 线程4流程 线程4->>缓存: 1.查询缓存,检查逻辑过期时间 缓存->>线程4: 返回数据(逻辑未过期) 线程4->>客户端: 返回正常数据 Note left of 线程1: 高可用:过期数据兜底,用户始终能拿到数据 Note left of 线程2: 性能优:仅单线程查库,避免并发冲击DB ``` 这是**逻辑过期 + 互斥锁**的方案来解决缓存击穿,核心是通过 “逻辑上标记过期时间、加锁保证单线程更新、异步重建缓存” 的流程,既避免数据库被并发冲击,又保证用户能拿到数据(过期数据兜底)。具体过程如下: 1. **缓存存储结构设计** 缓存中存储的数据包含两部分:**实际业务数据** + **逻辑过期时间戳**(缓存本身不设置物理过期时间)。例如存储结构为 `{data: "商品详情", expireTime: 1741363200000}`。 2. **线程 1 的流程(触发缓存更新)** - **步骤 1**:线程 1 查询缓存,发现**逻辑过期时间已到**(当前时间超过`expireTime`)。 - **步骤 2**:线程 1 尝试**获取互斥锁**(如 Redis 的`setnx`锁),且获取成功。 - **步骤 3**:线程 1 开启**新线程(线程 2)** 去执行缓存重建逻辑,自己则立即返回**过期的缓存数据**给用户(保证用户能拿到数据,不阻塞)。 3. **线程 2 的流程(重建缓存)** - **步骤 1**:线程 2 查询数据库,获取最新的业务数据。 - **步骤 2**:线程 2 将新数据写入缓存,并**重置逻辑过期时间戳**(比如设置为未来 30 分钟)。 - **步骤 3**:线程 2 释放互斥锁,完成缓存更新。 4. **线程 3 的流程(锁竞争失败)** - **步骤 1**:线程 3 查询缓存,发现逻辑过期时间已到。 - **步骤 2**:线程 3 尝试获取互斥锁,但**获取失败**(因为锁被线程 1 持有)。 - **步骤 3**:线程 3 直接返回**过期的缓存数据**给用户(等待线程 2 完成缓存更新)。 5. **线程 4 的流程(缓存未过期)** - 线程 4 查询缓存时,发现**逻辑过期时间未到**,直接命中缓存并返回正常数据,流程结束。 ## 缓存雪崩 ```mermaid sequenceDiagram participant 客户端集群 as Client Cluster participant 缓存 as Redis participant 数据库 as DB Note over 缓存: 大量key同时过期(或缓存服务宕机) 客户端集群->>缓存: 1.大量并发请求查询不同的key 缓存->>客户端集群: 2.所有请求均返回:缓存未命中 客户端集群->>数据库: 3.大量并发请求同时冲击数据库,查询多个key 数据库->>客户端集群: 4.数据库压力剧增,响应缓慢甚至宕机 客户端集群->>客户端集群: 5.用户请求超时,系统可用性下降 Note over 客户端集群,DB: 缓存雪崩核心:大量key同时失效/缓存宕机,导致流量全压向数据库 ``` **缓存雪崩**是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。 | 解决方法 | 具体说明 | 优势 | 注意事项 | | ----------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | | 给不同Key的TTL添加随机值 | 为每个缓存Key的过期时间在基础值上增加一个随机偏移量(如基础TTL是30分钟,随机增加0-5分钟),避免大量Key在同一时间点集中过期。 | 实现简单,能从根源上分散Key的过期时间,防止“时间点集中击穿”的雪崩场景。 | 需合理设置随机偏移的范围,避免因偏移过大导致缓存数据长期未更新而出现一致性问题。 | | 利用Redis集群提高服务的可用性 | 通过Redis主从、哨兵或Cluster集群模式,实现缓存服务的高可用。当部分节点故障时,集群可自动切换或路由请求,保证缓存服务不宕机。 | 从缓存服务层面保障可用性,避免因单点缓存故障导致所有请求穿透到数据库。 | 需做好集群的监控、扩容和数据同步策略,确保集群性能和数据一致性。 | | 给缓存业务添加降级限流策略 | 当缓存服务出现异常(如响应超时、节点不可用)或数据库压力剧增时,启动降级策略(如返回默认值、拒绝部分请求),同时通过限流(如令牌桶、漏桶算法)控制请求流量,避免数据库被压垮。 | 能在缓存雪崩发生时,主动切断流量对数据库的冲击,保障核心业务的可用性。 | 需提前定义降级和限流的触发条件、阈值,以及降级后的兜底逻辑(如返回兜底数据、提示页面),同时要做好降级后的监控和恢复机制。 | | 给业务添加多级缓存 | 构建“本地缓存(如Guava Cache)+ 分布式缓存(如Redis)”的多级缓存架构。请求优先查询本地缓存,本地缓存未命中再查询分布式缓存,最后才查询数据库。 | 多级缓存可分散流量压力,本地缓存能拦截大量高频请求,降低分布式缓存和数据库的负载;即使分布式缓存雪崩,本地缓存仍能提供部分数据兜底。 | 需注意多级缓存的一致性问题(如本地缓存的过期、更新机制),以及本地缓存的内存占用控制,避免影响业务系统本身的性能。 | --- ## Redis和DB双写一致性 我们知道Redis作为缓存,数据是主要来源于数据库的,那么当数据库的数据需要更新时,是先删除缓存再更新数据库;还是先更新数据库再删除缓存呢? ### 数据一致的场景 #### 先删除缓存,再更新数据库 要理解“先删除缓存,再修改数据库”为何会导致数据不一致,需结合**并发场景**分析流程: 假设存在两个并发操作的线程: - **线程1(写操作)**:执行“删除缓存 → 修改数据库(将值从10改为20)”。 - **线程2(读操作)**:执行“查询缓存(未命中)→ 查询数据库(此时线程1的数据库修改可能还未完成)→ 写入缓存(将旧值10写入缓存)”。 此时,数据库中是新值20,但缓存中是旧值10,就出现了**缓存与数据库的数据不一致**。 ```mermaid sequenceDiagram title 先删缓存再改数据库-并发数据不一致场景 线程1(写) ->> 缓存: 1. 删除缓存(旧值10) 缓存-->>线程1(写): 删除成功 线程1(写) ->> 数据库: 2. 执行更新(10→20) note over 数据库: 更新中...(未完成) %% 并发读操作 线程2(读) ->> 缓存: 3. 查询缓存 缓存-->>线程2(读): 未命中(空) 线程2(读) ->> 数据库: 4. 查询数据库 数据库-->>线程2(读): 5. 返回旧值10(更新未完成) 线程2(读) ->> 缓存: 6. 写入旧值10到缓存 缓存-->>线程2(读): 写入成功 %% 写操作最终完成 数据库-->>线程1(写): 7. 更新完成(值=20) note over 缓存,数据库: 结果:缓存=10,数据库=20 → 数据不一致 ``` #### 先更新数据库,再更新缓存 假设存在两个并发线程: 1. 线程 1 查询缓存,**未命中**,于是去查询数据库(此时数据库中是**旧值**,比如`v=10`)。 2. 线程 2**先更新数据库**,将值从`10`改为`20`。 3. 线程 2**再更新缓存**,将缓存值设为`20`。 4. 线程 1 拿到数据库的旧值`10`,**写入缓存**,将缓存值覆盖为`10`。 ```mermaid sequenceDiagram title 先更数据库再更缓存-并发不一致 线程1(读) ->> 缓存: 1. 查询缓存 缓存-->>线程1(读): 未命中 线程1(读) ->> 数据库: 2. 查询数据库 note over 数据库: 线程1查询中... 线程2(写) ->> 数据库: 3. 更新数据库(10→20) 数据库-->>线程2(写): 更新完成 线程2(写) ->> 缓存: 4. 更新缓存(写20) 缓存-->>线程2(写): 缓存更新成功 数据库-->>线程1(读): 5. 返回旧值10 线程1(读) ->> 缓存: 6. 写入旧值10 缓存-->>线程1(读): 写入成功 note over 缓存,数据库: 结果:缓存=10 数据库=20 → 不一致 ``` ### 双写一致 **双写一致性**:当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要保持一致。 #### 延迟双删 延迟双删是针对 “先删除缓存,再修改数据库” 场景的优化方案,流程如下: 1. **第一次删除缓存**:写操作开始时,先删除缓存中的旧数据。 2. **修改数据库**:执行数据库的更新操作。 3. **延迟一段时间后,第二次删除缓存**:等待足够时间(确保读操作的 “缓存未命中→查数据库→写缓存” 流程完成),再次删除缓存。 ```mermaid sequenceDiagram title 延迟双删时序图 写线程->>缓存: 1. 删缓存 缓存-->>写线程: 删完 写线程->>数据库: 2. 改数据库(10→20) 数据库-->>写线程: 改完 读线程->>缓存: 3. 查缓存(空) 缓存-->>读线程: 未命中 读线程->>数据库: 4. 查库(旧值10) 数据库-->>读线程: 返10 读线程->>缓存: 5. 写10到缓存 缓存-->>读线程: 写完 写线程->>写线程: 6. 延迟N毫秒 写线程->>缓存: 7. 再删缓存 缓存-->>写线程: 删完 新读线程->>缓存: 8. 查缓存(空) 缓存-->>新读线程: 未命中 新读线程->>数据库: 9. 查库(新值20) 数据库-->>新读线程: 返20 新读线程->>缓存: 10. 写20到缓存 缓存-->>新读线程: 写完 note over 缓存,数据库: 结果:缓存=20 数据库=20 → 一致 ``` #### 共享锁,排他锁 **共享锁:**读锁readLock,加锁之后,其他线程可以共享读操作 **排他锁:**独占锁writeLock也叫,加锁之后,阻塞其他线程读写操作 对于数据强一致性来说,我们可以通过共享锁和排他锁来解决。 写操作(如更新数据)需加**排他锁**,确保写过程中没有并发读 / 写干扰,步骤如下: 1. 写线程发起写请求,先对**数据库中要修改的数据行**加排他锁(X 锁)。 2. 加锁成功后,执行数据库更新(如值从 10→20);若加锁失败(被其他锁占用),则等待直到锁释放。 3. 数据库更新完成后,更新或删除缓存(根据双写策略选择,如 “改库后更缓存”“删缓存后改库”)。 4. 缓存操作完成后,释放排他锁(X 锁)。 读操作(如查询数据)需加**共享锁**,确保读过程中不会被写操作打断,步骤如下: 1. 读线程发起读请求,先对**数据库中要查询的数据行**加共享锁(S 锁)。 2. 加锁成功后,查询数据库(此时数据库数据已被写锁保护,要么是旧值的稳定态,要么是新值的稳定态,不会读到 “中间修改值”);若加锁失败(被写锁占用),则等待直到写锁释放。 3. 查询到数据库最新值后,写入或更新缓存。 4. 缓存操作完成后,释放共享锁(S 锁)。 ```mermaid sequenceDiagram 读线程->>数据库: 1. 加共享锁(S锁) 数据库-->>读线程: 加锁成功 读线程->>数据库: 2. 查库(旧值10) 数据库-->>读线程: 返10 写线程->>数据库: 3. 加排他锁(X锁) note over 写线程: 4. 阻塞(S/X冲突) 读线程->>缓存: 5. 写10到缓存 缓存-->>读线程: 写完 读线程->>数据库: 6. 释放S锁 数据库-->>读线程: 锁释放 数据库-->>写线程: 7. 加X锁成功 写线程->>数据库: 8. 改库(10→20) 数据库-->>写线程: 改完 写线程->>缓存: 9. 写20到缓存 缓存-->>写线程: 写完 写线程->>数据库: 10. 释放X锁 数据库-->>写线程: 锁释放 note over 缓存,数据库: 结果:缓存=20 数据库=20→一致 ``` #### 异步通知 异步通知保证数据的最终一致性 1. **数据写入阶段**: - 业务发起 “修改数据” 请求,由`item-service`(业务服务)执行数据库操作,将新数据写入`MySQL`。 - 数据库写入成功后,`item-service`向`MQ`(消息队列,如 RabbitMQ、Kafka)发布一条 “数据已更新” 的消息。 2. **缓存更新阶段**: - `cache-service`(缓存服务)监听`MQ`中的消息(步骤 2.1),当收到 “数据已更新” 的通知后,执行缓存更新操作。 ```mermaid sequenceDiagram title 异步通知保证数据最终一致性时序图 participant 业务请求方 participant item-service(业务服务) participant MySQL(数据库) participant MQ(消息队列) participant cache-service(缓存服务) participant Redis(缓存) %% 业务发起数据修改请求 业务请求方->>item-service(业务服务): 1. 发起“修改数据”请求 item-service(业务服务)->>MySQL(数据库): 2. 写入数据库(同步操作) MySQL(数据库)-->>item-service(业务服务): 3. 数据库写入成功 item-service(业务服务)->>MQ(消息队列): 4. 发布“数据已更新”消息(异步操作) MQ(消息队列)-->>item-service(业务服务): 5. 消息发布确认 item-service(业务服务)-->>业务请求方: 6. 业务请求处理完成(立即返回) %% 缓存服务监听并处理消息 MQ(消息队列)->>cache-service(缓存服务): 7. 推送“数据已更新”消息 cache-service(缓存服务)->>Redis(缓存): 8. 更新缓存(如删除旧值/写入新值) Redis(缓存)-->>cache-service(缓存服务): 9. 缓存更新成功 cache-service(缓存服务)->>MQ(消息队列): 10. 发送消费确认(手动ACK) MQ(消息队列)-->>cache-service(缓存服务): 11. 确认接收 ``` ## Redis数据持久化 在Redis中提供了两种数据持久化的方式:1) RDB;2) AOF。 ### RDB(Redis Database) RDB是Redis默认的持久化方式,通过**周期性生成内存数据的全量快照**(.rdb文件)来实现持久化。 #### 1. 工作原理 - 触发方式分手动和自动:手动用`save`(阻塞Redis)或`bgsave`(fork子进程,主进程不阻塞);自动通过配置`save <秒数> <修改次数>`(如`save 900 1`表示900秒内1次修改就触发)。 - 子进程遍历内存数据,生成快照文件并替换旧文件,主进程继续处理命令,不影响服务。 #### 2. 优缺点 - 优点:文件体积小,加载速度极快(恢复时直接读快照到内存),对Redis性能影响小(仅fork瞬间有轻微阻塞)。 - 缺点:数据安全性低,快照间隔内(如15分钟)Redis宕机,这段时间的数据会丢失;fork子进程时,内存占用临时翻倍(拷贝写机制)。 ### AOF(Append Only File) AOF是“日志型”持久化,通过**记录每一条写命令**(如set、hmset)到.aof文件,恢复时重新执行命令来还原数据。 #### 1. 工作原理 - 开启方式:配置`appendonly yes`,命令执行后先写入aof缓冲区,再根据同步策略刷盘。 - 同步策略(影响性能和安全性): - `appendfsync always`:每条命令刷盘,数据无丢失,但性能最差; - `appendfsync everysec`:每秒刷盘一次,平衡性能和安全性(默认); - `appendfsync no`:由操作系统决定刷盘时机,性能最好,但数据丢失风险最高。 - AOF重写:解决文件膨胀问题,Redis会生成“最终状态”的精简命令集(如多次set同一key合并为一条),替换旧AOF文件。 #### 2. 优缺点 - 优点:数据安全性高(最多丢失1秒数据),日志文件是文本格式,可手动修改恢复(如删除误操作命令)。 - 缺点:文件体积大,加载速度慢(需重新执行所有命令),写命令刷盘对性能有一定影响(比RDB略高)。 ### RDB与AOF核心对比 | 维度 | RDB | AOF | | ---------- | -------------------- | -------------------------- | | 持久化方式 | 全量快照(周期性) | 增量命令日志(实时/秒级) | | 数据安全性 | 低(间隔内数据丢失) | 高(最多丢1秒数据) | | 文件体积 | 小 | 大 | | 加载速度 | 快 | 慢 | | 性能影响 | 小(仅fork时阻塞) | 中(刷盘消耗) | | 适用场景 | 备份、主从复制初始化 | 生产环境高可用(默认开启) | ### 混合持久化(Redis 4.0+) - 开启配置`aof-use-rdb-preamble yes`,AOF文件开头存储RDB快照,后续存储增量命令日志。 - 优势:加载时先读RDB(快),再执行AOF命令(数据全),兼顾RDB的速度和AOF的安全性。 --- ## 数据过期策略 **Redis**的过期删除策略:**惰性删除** **+** **定期删除**两种策略进行配合使用 ### 惰性删除 惰性删除:设置该key过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key。 **优点** :对CPU友好,只会在使用该key时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查 **缺点** :对内存不友好,如果一个key已经过期,但是一直没有使用,那么该key就会一直存在内存中,内存永远不会释放 ### 定期删除 定期删除:每隔一段时间,我们就对一些key进行检查,删除里面过期的key(从一定数量的数据库中取出一定数量的随机key进行检查,并删除其中的过期key)。 定期清理有两种模式: - lSLOW模式是定时任务,执行频率默认为10hz,每次不超过25ms,以通过修改配置文件redis.conf 的hz 选项来调整这个次数 - lFAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms **优点**:可以通过限制删除操作执行的时长和频率来减少删除操作对 CPU 的影响。另外定期删除,也能有效释放过期键占用的内存。 **缺点**:难以确定删除操作执行的时长和频率。 --- ## Redis数据淘汰策略 **数据的淘汰策略**:当Redis中的内存不够用时,此时在向Redis中添加新的key,那么Redis就会按照某一种规则将内存中的数据删除掉,这种数据的删除规则被称之为内存的淘汰策略。 Redis支持8种不同策略来选择要删除的key: | 策略名称 | 核心说明 | | --------------- | --------------------------------------------------------- | | noeviction | 不淘汰任何key,内存满时不允许写入新数据,默认策略 | | volatile-ttl | 仅针对设置了TTL的key,比较剩余TTL值,TTL越小越先被淘汰 | | allkeys-random | 针对全体key,随机选择进行淘汰 | | volatile-random | 仅针对设置了TTL的key,随机选择进行淘汰 | | allkeys-lru | 针对全体key,基于LRU(最近最少使用)算法进行淘汰 | | volatile-lru | 仅针对设置了TTL的key,基于LRU(最近最少使用)算法进行淘汰 | | allkeys-lfu | 针对全体key,基于LFU(最不经常使用)算法进行淘汰 | | volatile-lfu | 仅针对设置了TTL的key,基于LFU(最不经常使用)算法进行淘汰 | > **LRU**(**L**east **R**ecently **U**sed)最近最少使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。 > > 例如:key1是在3s之前访问的, key2是在9s之前访问的,删除的就是key2 > > **LFU**(**L**east **F**requently **U**sed)最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。 > > 例如:key1最近5s访问了4次, key2最近5s访问了9次, 删除的就是key1 **使用建议**: 1. 优先使用 allkeys-lru 策略。充分利用 LRU 算法的优势,把最近最常访问的数据留在缓存中。如果业务有明显的冷热数据区分,建议使用。 2. 如果业务中数据访问频率差别不大,没有明显冷热数据区分,建议使用 allkeys-random,随机选择淘汰。 3. 如果业务中有置顶的需求,可以使用 volatile-lru 策略,同时置顶数据不设置过期时间,这些数据就一直不被删除,会淘汰其他设置过期时间的数据。 4. 如果业务中有短时高频访问的数据,可以使用 allkeys-lfu 或 volatile-lfu 策略。 ## Redis分布式锁 Redis 分布式锁是**基于 Redis 实现的跨进程、跨服务器的并发控制机制**,用于解决分布式系统中多个节点(或服务实例)对共享资源的竞争问题,确保同一时刻只有一个节点能操作共享资源,避免并发冲突(比如超卖、重复下单、数据不一致等场景)。 ### Redis原生实现 Redis实现分布式锁主要利用Redis的setnx命令。setnx是SET if not exists(如果不存在,则 SET)的简写。 ```bash # 添加锁,NX是互斥、EX是设置超时时间 SET lock value NX EX 10 # 释放锁,删除即可 DEL key ``` 那么Redis实现分布式锁如何合理的控制锁的有效时长? 1. 根据业务时间进行预估 2. 给锁续期 ```mermaid flowchart LR A[开始] --> B[尝试获取锁] B --> C{判断结果} C -->|nil| D[获取锁失败] C -->|ok| E[获取锁成功] E --> F[执行业务] F --> G[释放锁] F -->|业务超时<br/>或服务宕机| H[自动释放锁] ``` **手动用 Redis 命令实现分布式锁容易踩坑(如死锁、误删、单点故障),而 Redisson 已经帮你封装好了工业级的分布式锁及各类分布式组件,直接开箱即用**。 ### Redisson实现分布式锁 Redisson 是一个 **基于 Redis 实现的 Java 分布式框架**,它不仅封装了 Redis 分布式锁的完整实现(解决了手动实现的各种坑),还提供了大量分布式场景下的工具类(如分布式集合、分布式对象、分布式服务等),让开发者无需关注底层 Redis 命令细节,就能快速实现高可用的分布式应用。 Redisson 提供了多种类型的分布式锁,满足不同场景需求: | 锁类型 | 适用场景 | 核心特点 | | ------------------------ | ---------------------------------------------- | ------------------------------------------------------------ | | 可重入锁(RLock) | 同一线程需多次获取同一把锁(如递归、嵌套调用) | 支持重入性(避免自己阻塞自己),自动续期(看门狗机制) | | 公平锁(FairLock) | 需按请求顺序获取锁(避免饥饿) | 保证锁的获取顺序与请求顺序一致 | | 红锁(RedLock) | Redis 集群场景,需极高可靠性 | 实现 Redis 官方 Redlock 算法,基于多独立 Redis 节点,避免单点故障 | | 读写锁(RReadWriteLock) | 读多写少场景(如缓存查询 + 更新) | 读锁共享(多个线程可同时读),写锁互斥(同一时刻只有一个线程能写) | | 信号量(RSemaphore) | 控制并发访问数量(如限流、资源池) | 类似 Java 的 Semaphore,支持 acquire/release,可动态调整许可数 | | 闭锁(RLatch) | 等待多个线程完成任务后再执行(如批量处理) | 类似 Java 的 CountDownLatch,支持 countDown/await | #### 看门狗(Watch Dog)机制 手动实现分布式锁时,若业务执行时间超过锁的过期时间,会导致锁提前释放,引发并发问题。Redisson 的 **看门狗机制** 自动解决此问题: - 当获取锁成功后,Redisson 会启动一个后台线程(看门狗); - 若业务未执行完,且锁快过期时,看门狗会自动向 Redis 发送命令延长锁的过期时间; - 业务执行完成后,手动释放锁,看门狗线程自动销毁; - 若持有锁的节点崩溃,看门狗线程终止,锁会因过期时间自动释放,避免死锁。 ```mermaid sequenceDiagram participant 客户端A as 客户端A participant 客户端B as 客户端B participant Redisson as Redisson participant Redis as Redis participant WatchDog as WatchDog(看门狗) note over 客户端A,Redis: 加锁流程 客户端A->>Redisson: 请求加锁 Redisson->>Redis: 执行Lua脚本(加锁+设置过期时间) Redis-->>Redisson: 加锁成功响应 Redisson-->>客户端A: 加锁成功 Redisson->>WatchDog: 启动续期任务(每隔releaseTime/3续期) WatchDog->>Redis: 执行Lua脚本(续期锁过期时间) Redis-->>WatchDog: 续期成功响应 loop 执行业务 客户端A->>客户端A: 执行业务逻辑 end 客户端A->>Redisson: 请求释放锁 Redisson->>Redis: 执行Lua脚本(释放锁) Redis-->>Redisson: 释放锁成功响应 Redisson-->>客户端A: 释放锁成功 WatchDog->>WatchDog: 终止续期任务 note over 客户端B,Redis: 竞争加锁流程 客户端B->>Redisson: 请求加锁 Redisson->>Redis: 执行Lua脚本(加锁) Redis-->>Redisson: 加锁失败响应 Redisson-->>客户端B: 加锁失败 loop while循环尝试加锁 客户端B->>Redisson: 再次请求加锁 Redisson->>Redis: 执行Lua脚本(加锁) Redis-->>Redisson: 加锁失败响应 Redisson-->>客户端B: 加锁失败 end 客户端B->>Redisson: 请求加锁 Redisson->>Redis: 执行Lua脚本(加锁) Redis-->>Redisson: 加锁成功响应 Redisson-->>客户端B: 加锁成功 Redisson->>WatchDog: 启动续期任务(每隔releaseTime/3续期) ``` ### 主从一致性 Redisson 主要通过 **MultiLock(联锁)和 RedLock(红锁)** 两种机制解决 Redis 主从架构下的一致性问题,核心思路是**避免依赖单主节点的同步延迟**,通过多独立节点的加锁逻辑保证锁的可靠性。 #### 主从一致性问题的成因 在 Redis 主从架构中,写操作先在**主节点**执行,再异步同步到**从节点**。若主节点在锁信息同步到从节点前宕机,从节点升级为主节点后会丢失锁信息,导致其他节点可重复获取锁,引发并发安全问题。 #### Redisson 的解决方案 **1. MultiLock(联锁):多节点同时加锁** - **原理**:将锁同时写入多个**独立的 Redis 节点**(可包含主从架构的节点),只有所有节点都加锁成功,才算整体加锁成功。 - **解决逻辑**:即使某主节点宕机且从节点未同步锁信息,其他独立节点仍持有锁标识,因此其他线程无法在所有节点上获取锁,避免了锁失效。 **2. RedLock(红锁):多节点过半成功机制** - **原理**:基于 Redis 官方 Redlock 算法,向**多个无主从关系的独立 Redis 节点**(通常 3~5 个)发起加锁请求,只要超过半数节点加锁成功,即视为整体加锁成功。 - **解决逻辑**:即使部分节点宕机,只要多数节点正常,锁机制仍能工作,彻底避免主从同步延迟导致的锁丢失。 ## Redis集群 在Redis中提供的集群方案总共有三种 - 主从复制 - 哨兵模式 - 分片集群 ### 主从复制 单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离。 ```mermaid graph LR A[客户端] --> B[RedisClient] B -->|写操作| C[master] C -->|数据同步| D[slave/replica] C -->|数据同步| E[slave/replica] B -->|读操作| D B -->|读操作| E style C fill:#f99,stroke:#333,stroke-width:2px style D fill:#ccc,stroke:#333,stroke-width:2px style E fill:#ccc,stroke:#333,stroke-width:2px style B fill:#666,stroke:#fff,stroke-width:2px,color:#fff ``` > Replication Id:简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid > offset:偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。 #### 全量同步 1. slave 发起同步请求 slave 执行 replicaof 命令(旧版本为 slaveof),主动与 master(主节点)建立网络连接,开启数据同步流程。 2. slave 向 master 请求数据同步 slave 向 master 发送包含自身 replid(复制 ID,标识数据版本)和 offset(复制偏移量,标识已同步到的位置)的请求,告知 master 自己当前的同步状态,请求开始数据同步。 3. master 判断同步类型 master 收到请求后,检查 slave 的 replid: 若 replid 与自身不一致(说明是第一次同步,或 slave 之前的主节点不是当前 master),则触发全量复制; 若 replid 一致但 offset 有差异,则触发部分复制(这里我们聚焦全量复制)。 4. master 返回自身数据版本信息 master 向 slave 返回自己的 replid 和 offset,让 slave 记录这些信息,作为后续同步的 “基准版本”。 5. master 执行 bgsave 生成 RDB 文件 master 执行后台持久化命令 bgsave,在后台异步生成 RDB 快照文件(包含 master 所有数据的全量快照)。这个过程中,master 会继续处理客户端的写请求。 6. master 记录 RDB 生成期间的写命令 为了保证 “RDB 生成期间的新写操作不丢失”,master 会将这些命令记录到 **repl_backlog 缓冲区 **(复制积压日志)中。 7. master 向 slave 发送 RDB 文件 master 生成 RDB 文件后,将其发送给 slave。slave 接收并保存该文件。 8. slave 清空本地数据并加载 RDB slave 先清空自身的旧数据(避免数据冲突),然后加载从 master 收到的 RDB 文件,将 RDB 中的全量数据加载到内存中。 9. master 发送 repl_backlog 中的命令 slave 加载完 RDB 后,master 会将 repl_backlog 中记录的 “RDB 生成期间的所有写命令” 发送给 slave。 10. slave 执行收到的命令 slave 逐条执行这些命令,确保自己的数据与 master 在 “RDB 生成时刻 + 后续写操作” 后的状态完全一致。 至此,全量复制流程完成,slave 拥有了与 master 完全一致的全量数据。后续 master 有新写操作时,会通过增量复制(基于 repl_backlog 和 offset)持续同步,保证主从数据一致。 ```mermaid sequenceDiagram participant S as Slave participant M as Master participant RDB as RDB文件 participant RB as repl_backlog S->>S: 1. 执行replicaof命令,建立连接 S->>M: 2. 请求数据同步(携带replid、offset) M->>M: 3. 判断是否第一次同步(replid是否一致) M->>S: 4. 返回master的数据版本信息(replid、offset) S->>S: 5. 保存版本信息 M->>M: 6. 执行bgsave,生成RDB M->>RB: 9. 记录RDB期间的所有命令 M->>S: 7. 发送RDB文件 S->>S: 8. 清空本地数据,加载RDB文件 M->>S: 10. 发送repl_backlog中的命令 S->>S: 11. 执行接收到的命令 ``` #### 增量同步 Redis 主从架构中的**增量同步**是用于在主从数据已完成全量同步后,持续保持数据一致的机制,尤其适用于 slave 重启或主从间仅存在少量数据差异的场景。其核心是通过`repl_backlog`(复制积压日志)和`offset`(复制偏移量),仅同步 master 上新增的写命令,避免全量复制的性能开销。 以下是 slave 重启后触发增量同步的完整步骤: 1. slave 重启 slave 节点重启后,需要重新与 master 同步数据,此时触发增量同步流程。 2. slave 向 master 发送`psync`请求 slave 向 master 发送`psync replid offset`命令,其中: - `replid`:slave 记录的 master 复制 ID(标识数据版本); - `offset`:slave 记录的复制偏移量(标识已同步到的位置)。 3. master 判断`replid`是否一致 master 收到请求后,检查 slave 的`replid`是否与自身的`replid`一致: - 若一致:说明 slave 之前的主节点就是当前 master,可进行**增量同步**; - 若不一致:说明 slave 是新节点或之前的主节点已变更,将触发**全量复制**(本文聚焦增量同步,故假设`replid`一致)。 4. master 回复`continue` master 确认`replid`一致后,回复`continue`,表示将进行增量同步。 5. slave 保存版本信息 slave 记录 master 返回的`replid`和`offset`,作为后续同步的基准。 6. master 从`repl_backlog`中获取增量数据 master 内部维护`repl_backlog`缓冲区,该缓冲区按顺序记录了所有 master 的写命令。master 根据 slave 的`offset`,从`repl_backlog`中筛选出 “`offset`之后的所有写命令”。 7. master 发送增量命令 master 将筛选出的增量写命令发送给 slave。 8. slave 执行增量命令 slave 逐条执行收到的写命令,使自身数据与 master 完全一致。 ```mermaid sequenceDiagram participant S as Slave participant M as Master participant RB as repl_backlog S->>S: 1. 重启 S->>M: 2. psync replid offset M->>M: 3. 判断请求replid是否一致 M->>S: 4. 回复 continue S->>S: 5. 保存版本信息 M->>RB: 6. 去repl_backlog中获取offset后的数据 M->>S: 7. 发送offset后的命令 S->>S: 8. 执行命令 ``` ### 哨兵模式 Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。哨兵的结构和作用如下: ![image-20251107213414873](https://pic.code-nav.cn/post_picture/1917113123715145729/BxCgyPvljNsjyywC.webp) - **监控**:Sentinel 会不断检查您的master和slave是否按预期工作 - **自动故障恢复**:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主 - **通知**:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端 #### 服务状态监控 Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令: - 主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例**主观下线**。 - 客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例**客观下线**。quorum值最好超过Sentinel实例数量的一半。 **哨兵选主规则** - 首先判断主与从节点断开时间长短,如超过指定值就排该从节点 - 然后判断从节点的slave-priority值,越小优先级越高 - 如果slave-prority一样,则判断slave节点的offset值,越大优先级越高 - 最后是判断slave节点的运行id大小,越小优先级越高。 #### Redis集群(哨兵模式)脑裂 在 Redis 哨兵(Sentinel)模式中,**脑裂(Split Brain)** 是指主从架构因网络分区(或主节点短暂不可达),导致哨兵误判主节点宕机,将从节点升级为新主节点;而原主节点恢复后,集群中出现 **两个独立的主节点**(旧主 + 新主),最终引发数据不一致、业务冲突的严重问题。 <img src="https://picbed-chengfu-1327906653.cos.ap-guangzhou.myqcloud.com/image/image-20251107215955459.webp" alt="image-20251107215955459" style="zoom:50%;" /> 关于解决的话,我记得在redis的配置中可以设置:第一可以设置最少的salve节点个数,比如设置至少要有一个从节点才能同步数据,第二个可以设置主从数据复制和同步的延迟时间,达不到要求就拒绝请求,就可以避免大量的数据丢失。 > min-replicas-to-write 1 表示最少的salve节点为1个 > > min-replicas-max-lag 5 表示数据复制和同步的延迟不能超过5秒 ### 分片集群 主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决: - 海量数据存储问题 - 高并发写的问题 使用分片集群可以解决上述问题,分片集群特征: - 集群中有多个master,每个master保存不同数据 - 每个master都可以有多个slave节点 - master之间通过ping监测彼此健康状态 - 客户端请求可以访问集群任意节点,最终都会被转发到正确节点 <img src="https://picbed-chengfu-1327906653.cos.ap-guangzhou.myqcloud.com/image/%E5%9B%BE%E7%89%871.webp" alt="图片1" style="zoom: 30%;" /> #### 分片集群结构-数据读写 Redis 分片集群引入了哈希槽的概念,Redis 集群有 16384 个哈希槽,每个 key通过 CRC16 校验后对 16384 取模来决定放置哪个槽,集群的每个节点负责一部分 hash 槽。 ![image-20251107230731936](https://pic.code-nav.cn/post_picture/1917113123715145729/6jpxgMMgnSC0jg1e.webp) ## Redis为什么那么快? - Redis是纯内存操作,执行速度非常快 - 采用单线程,避免不必要的上下文切换可竞争条件,多线程还要考虑线程安全问题 - 使用I/O多路复用模型,非阻塞IO ### I/O多路复用模型 **IO多路复用**:是利用单个线程来同时监听多个Socket ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。 <img src="https://picbed-chengfu-1327906653.cos.ap-guangzhou.myqcloud.com/image/image-20251107230916904.webp" alt="image-20251107230916904" style="zoom: 67%;" /> 阶段一: ①用户进程调用select,指定要监听的Socket集合 ②内核监听对应的多个socket ③任意一个或多个socket数据就绪则返回readable ④此过程中用户进程阻塞 阶段二: ①用户进程找到就绪的socket ②依次调用recvfrom读取数据 ③内核将数据拷贝到用户空间 ④用户进程处理数据

使用Jmeter对接口进行压力测试

今天第一次使用Jmeter对系统进行了压力测试,测试了一下纯数据库方案以及添加了缓存的方案,结果惊人。 ## 新建测试步骤 ### 在测试计划中添加一个线程组 ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/rnWNiQNyoT1600a9.png) ### 在新增的线程组中添加HTTP请求与查看结果树 ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/hSyZK5tkl7kCdzdH.webp) ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/RqR8LcyctGWywm52.webp) ### 在HTTP请求中添加聚合报告 ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/FaD9h3YByQuDk6Tv.webp) 容易踩坑的点:如果你的接口是POST请求,一定要多添加一个HTTP头管理器 ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/2Q5WZgyA1zZUfmMk.webp) 在其中新增条目:Content-Type:Application/json 也就是以请求体以 json 的格式发送 ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/u8c3HaenWNu2NKQ8.png) 如果接口需要登录,可以增加一个 cookie 管理器 完成这些之后,就可以将线程数设置为1,进行连接测试,连接正常后正式进入压力测试。 ## 只使用MySQL处理请求 在设置并发量为每秒1000次的时候,可以看到MySQL的处理速度已经很慢了,平均响应时间达到了5235ms ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/1CrOLar3aLaP5ngu.webp) ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/LfIa9QXfUYG1vaPr.png) ## 使用Caffeine+Redis多级缓存方案 每秒1000次并发,对于缓存来说这么小的并发量只需略微出手,热身级别。平均响应时间2ms,相差2000+倍 ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/UT1J3MKhtJXHc9aM.webp) ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/vi4nqoJJstSXr8VW.png) 继续增加并发数 2000并发测试: ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/LFpeISDD1rlTl1jv.png) 3000并发测试: ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/nXrZ9wnc3p2hIiuN.png) 4000并发测试: ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/dBiwWZB7vupyDbnQ.png) 5000并发测试: ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/5WNk5ZqxJgF1s6DE.png) 6000并发测试: ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/6jYJKPMmRiefWseJ.png) 可以看到,在6000并发时,虽然响应速度很快,但是异常值出现了波动,查看失败的请求,发现报错如下: ```java java.net.ConnectException: Connection refused: connect at java.base/sun.nio.ch.Net.connect0(Native Method) at java.base/sun.nio.ch.Net.connect(Net.java:589) at java.base/sun.nio.ch.Net.connect(Net.java:578) at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:583) at java.base/java.net.Socket.connect(Socket.java:760) at java.base/java.net.Socket.connect(Socket.java:695) at java.base/sun.net.NetworkClient.doConnect(NetworkClient.java:183) at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:531) at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:636) at java.base/sun.net.www.http.HttpClient.<init>(HttpClient.java:280) at java.base/sun.net.www.http.HttpClient.New(HttpClient.java:386) at java.base/sun.net.www.http.HttpClient.New(HttpClient.java:408) at java.base/sun.net.www.protocol.http.HttpURLConnection.getNewHttpClient(HttpURLConnection.java:1310) at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1243) at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1129) at java.base/sun.net.www.protocol.http.HttpURLConnection.connect(HttpURLConnection.java:1058) at org.apache.jmeter.protocol.http.sampler.HTTPJavaImpl.sample(HTTPJavaImpl.java:536) at org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy.sample(HTTPSamplerProxy.java:66) at org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.sample(HTTPSamplerBase.java:1311) at org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.sample(HTTPSamplerBase.java:1300) at org.apache.jmeter.threads.JMeterThread.doSampling(JMeterThread.java:651) at org.apache.jmeter.threads.JMeterThread.executeSamplePackage(JMeterThread.java:570) at org.apache.jmeter.threads.JMeterThread.processSampler(JMeterThread.java:501) at org.apache.jmeter.threads.JMeterThread.run(JMeterThread.java:268) at java.base/java.lang.Thread.run(Thread.java:1575) ``` 这是由于**服务端无法接受新连接**导致的 ```java tomcat: # 接受和处理连接的最大线程数 threads: max: 2000 min-spare: 100 # 最大连接数,超出后进入等待队列 max-connections: 10000 # 等待队列长度,当所有线程忙碌、连接数达max-connections时,新请求在此队列等待 accept-count: 5000 # 连接超时时间(毫秒) connection-timeout: 5000 ``` ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/SFswvlJeHEaPmvAS.png) 通过调整Tomcat服务器的配置,降低了异常值 但并发数是由客户端与服务器端共同制约的,使用 `HTTPJavaImpl` 的方式发送HTTP请求时,**无法通过勾选 `Use KeepAlive` 来复用连接**(Java 内置实现默认行为有限),这会导致每次请求都建立新的 TCP 连接,在高并发时客户端端口耗尽或服务端连接队列打满。 同时服务端操作系统:Linux 内核参数也需要调优,由于我是在本地windows测试,就跳过了这一步骤 ## 每秒10000次并发 MySQL近乎宕机,平均响应时间达到24秒是一个灾难性的故障信号,意味着服务已基本不可用,并随时可能引发系统性崩溃。 ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/FUPcf9q9AXSe9QrM.png) 引入了多级缓存的情况下,响应时间依旧很快,完全没有达到上限,但是异常值达到了44% ![image.png](https://pic.code-nav.cn/post_picture/1896090012971507713/vSbtlZZKuEznU4O4.png) 所以多级缓存只是构建高并发系统的一个必要条件,想要构建一个稳定的支持高并发的系统,还需要具备多种能力:**分布式架构、限流降级熔断、容器与线程模型调优、JVM与GC调优、监控与快速定位**等综合能力。

redis缓存的淘汰策略

redis是项目缓存大户,可以说90以上的项目都用它。但是redis也好比一个容器,如果满了总是需要处理后续流入的数据,是该将前面流入的数据删除,还是直接不让后面的数据流入,又或者将最久没有使用的数据删除,或将使用频率低的数据删除,或删除剩余生存时间短的数据?这些都需要按实际情况进行选择? ## 1 直接不让后面的数据流入(noeviction) 这种适合数据不能被容许被丢弃的情况,也就是将redis作为持久化存储了。目前在我做的项目中还是有用到这种情况。 ## 2 最久没有使用的数据删除(allkeys-Iru) 对所有key做LRU淘汰,不管有没有设置TTL。这是最常用的策略之一,适合纯缓存场景。 ## 3 使用频率低的数据删除(allkeys-lfu) 是基于访问频率淘汰,比LRU更能应对突发流量。allkeys-lfu现在是推荐的默认策略。 ## 4 删除剩余生存时间短的key(volatile-ttl) 优先删除剩余生存时间短的key,适用于大量短期key的堆积场景。 *如何设置:直接设置redis配置文件永久生效* ```js # 先设置最大内存(必须先设 maxmemory,淘汰策略才会生效) maxmemory 1gb # 比如限制 Redis 最多用 1G 内存 # 然后设置淘汰策略 maxmemory-policy allkeys-lfu ``` 以上内容只做记录,如有错误还请各位鱼友版帮忙指出。

Redis八股汇总-我的近10天成果

# 第一部分 | 基础概念 ## <font style="color:rgb(51, 51, 51);">1. Redis 的数据类型有哪些</font> 1. String:存文本、数字、二进制。常用来存用户信息、Session、查询结果、阅读量、点赞数 2. List:有序列表,底层使用 quicklist,可以用作简单的消息队列 3. Hash:键值对集合,把多个字段和值存在同一个 key 下面 4. Set:无序不重复,适用于标签系统 5. ZSet:类似于 Set,底层使用跳表+哈希表,每个元素都携带一个分数用来排序,常用于排行榜、热搜榜等场景 6. BitMap:用位来存数据,每个 bit 只有 0/1,常用于签到场景(setbit 设置状态,getbit 读取状态) ```bash SETBIT user:sign:202409 12345 1 # 用户 12345 在 9 月签到 GETBIT user:sign:202409 12345 # 查询签到状态 ``` 7. HyperLogLog:一种概率性去重算法,通过牺牲极小的精度(约 0.81%),换取固定且极小的内存开销(固定 12KB),适用于独立访客(UV)这种对精度要求不高但数据量巨大的场景 ```bash PFADD page:uv user1 user2 user3 # 记录访问用户 PFCOUNT page:uv # 估算独立用户数 ``` 8. GEO:存地理位置信息,支持经纬度存储和空间查询,底层使用 ZSet,经纬度会被编码成 score,典型场景就是“附近的人”、外卖配送距离计算 ```bash GEOADD riders 116.403 39.915 "rider001" # 存骑手位置 GEORADIUS riders 116.4 39.9 5 km # 查 5 公里内的骑手 ``` 9. Stream:专为消息队列设计,相比 List 更适合做消息队列,支持消息 ID,消费组、消息确认和失败重试 ## <font style="color:rgb(51, 51, 51);">2. Redis 的数据结构有哪些</font> 1. 简单动态字符串(SDS):不是使用 C 语言原生的 char 数组,而是 Redis 自己实现的字符串结构。可以直接获取长度、存二进制。SDS 有三个核心字段,len 记录当前长度;alloc 记录可分配空间;buf 存储数据。扩容后小于 1M 翻倍,大于 1M 加 1M。当值是 long 范围内的整数使用 int 编码;<=44 字节使用 embstr 编码;>44 字节使用 raw 编码 2. 哈希表(HashTable):数组+链表,精确查找 O(1),但内存分散指针开销大 3. 整数集合(IntSet):本质是一个有序整数数组+元信息,内存连续、有序不重复、不存指针 4. 压缩列表(ZipList):一块连续的内存,把所有元素紧凑的放到一起,省内存但不适合存大量数据。开头zlbytes 记录整个链表占多少字节,zltail 记录最后一个节点的偏移量用于快速定位尾部,zllen 记录节点个数,中间是一个个 entry 节点紧密排列,最后用 zlend(0xFF)标记结束 5. 紧凑列表(ListPack):Redis 7 及之后 ziplist 的替代品【其实是 5.0 引入的,只是那时只用于 stream】。内存连续无指针开销,顺序查找 O(n),解决了 ZipList 的级联更新问题(ziplist 每个 entry 存了前一个entry 的长度,这个 pre_entry_length 是变长的,前一个 entry 小于 254 字节时占 1 字节,大于 254 就占5 字节,如果有一串 entry 都为 253 字节,在前面插入一个 260 字节的新 entry 时,后面记录长度的字段就要从 1 字节变为 5 字节,一路传递;listpack 改成只存当前节点的长度,往前遍历的时候,先读当前节点的element-tot-len,就能算出这个节点的起始位置,跳转到前一个元素) 6. QuickList:Redis 3.2 之后 List 的默认实现,本质是双向链表穿起来一些小的 ziplist,每个 ziplist 控制在一定大小,级联更新最多影响一小块不会波及整个链表(Redis7 时 ziplist 就变为 listpack 了) 7. 跳表(SkipList):本质就是多层链表,最底层存放所有元素,往上依次是相邻下层链表的子集。并且 Redis 中的跳表多了回退指针(只在最底层)。查找:从最上层开始,逐层向下进行查找,如果在该层得到数据,直接返回,否则确定查找元素的范围继续进行下一层查找,时间复杂度 O(logn)。插入:查询所要插入的位置,随机决定新节点的层数(25% 往上加一层),插入节点并更新指针。删除:查询所要删除的位置,删除节点并更新指针 ## <font style="color:rgb(51, 51, 51);">3. 数据类型与数据结构的对应关系</font> 1. String:用 SDS,支持 O(1) 获取长度、存二进制 2. List:使用 QuickList 3. Hash:数据量小用 listpack 存储节省内存(Redis 7 之前使用的是 ziplist)【字段数 ≤ 512 且单个值 ≤ 64 字节】;数据量大用 hashtable 查询效率为 O(1) 4. Set:数据量小且元素都是整数时,用 intset 存储节省内存【元素个数 ≤ 512】;数据量大或出现非整数元素时,自动转换为 hashtable,支持 O(1) 的增删查 5. ZSet:数据量小用 listpack 存储节省内存(Redis 7 之前使用的是 ziplist)【元素个数 ≤ 128 且每个元素长度 ≤ 64字节】;数据量大用 skiplist+hashtable(跳表用来存储数据和快速查找,哈希表用来存储成员与其分数的映射) ## <font style="color:rgb(51, 51, 51);">4. Redis 数据过期后的删除策略有哪些</font> 1. 惰性删除:被动触发,每次读写某个 key 之前,先判断一下是否过期,过期了就删掉返回空 - 优点:不占用额外的 CPU 去扫描 - 缺点:如果 key 过期了一直没人访问,会一直存在,导致内存泄漏 2. 定期删除:每隔一定时间抽取一批(20个)设置了过期时间的 key,将过期的 key 删掉,如果本次过期比例超过 25%,就会继续取下一批进行操作,如果过期低于 25%,结束本轮。每轮启动的时候会计时,达到 25ms 直接强制结束 - 优点:25% 阈值保证在过期 key 多的情况下加速清理,25ms 限制避免清理动作影响正常请求处理 - 缺点:可能清理不完全 3. 所以一般是两种策略配合使用(惰性删除+定期删除) 4. Redis 维护了一个专门存放设置了过期时间 key 的字典,key 指向实际的 key 对象,value 是过期时间的毫秒级 unix 时间戳。检查 key 是否过期就是拿当前时间和字典里存的时间戳对比。惰性删除是每次访问 key 前检查,定期删除是在字典里随机抽样检查 5. 在主从场景下,从节点不会主动删除自己过期的 key,而是读到过期 key 就返回空(注意 3.2 版本之前依然能读取过期的 key ),等主节点同步过来 del 命令再执行删除 ## <font style="color:rgb(51, 51, 51);">5. Redis 的内存淘汰策略有哪些</font> 1. 有 8 种淘汰策略 - noeviction:Redis 默认策略,内存满了直接报错拒绝写入 - volatile-lru:最近最少使用的先淘汰(设置了过期时间的 key) - volatile-lfu:使用频率最低的先淘汰(设置了过期时间的 key) - volatile-random:随机淘汰(设置了过期时间的 key) - volatile-ttl:剩余存活时间短的先淘汰(设置了过期时间的 key) - allkeys-lru:最近最少使用的先淘汰(所有 key) - allkeys-lfu:使用频率最低的先淘汰(所有 key) - allkeys-random:随机淘汰(所有 key) 2. 关于 LRU 和 LFU 的缺点:LRU-某段时间有大量冷数据被扫描,真正的热点数据会被挤出去;LFU-如果历史访问了多次,近期内都不访问,也不会删除(4.0 对 LFU 进行了优化,访问计数会随时间缩减,避免“僵尸热点”) 3. noeviction 一般用于消息队列、allkeys-lru 适用于大多数场景、allkeys-lfu 适用于有明确的热点数据 4. 注意:如果没有任何 key 设置过期时间,那么用 volatile 毫无作用,跟没设置一样 5. LRU/LFU 并不是精确算法,而是默认采样 5 个 key,选一个符合条件的淘汰。将这个值调大能提高淘汰精度,但会增加 CPU 开销,一般 5-10 个就行 ## <font style="color:rgb(51, 51, 51);">6. Redis 的持久化机制有哪些</font> 1. RDB:快照持久化,某个时间点将整份内存数据转储为一个二进制文件,文件体积小,恢复速度快,适合做备份和灾难恢复。但是如果两次备份期间 Redis 挂了,就丢失了这段时间的数据 - 方式:bgsave,通过 fork 子进程来生成快照,fork 期间会阻塞主进程,之后主进程继续处理请求,子进程把内存数据写到临时文件,写完后替换掉旧的 dump.rdb 文件(还有一种就是 save 命令,但它是用主进程生成 RDB,期间 Redis 会阻塞,不推荐) - 流程:检查是否有正在执行的 AOF/RDB 子进程,有的话直接报错--->调用 rdbSaveBackground 触发持久化--->fork 子进程生成 RDB,主进程继续处理请求--->新文件替换旧文件 - 注意:子进程创建时并不是真的复制一份内存而是复制页表(页表里存的是物理内存地址),父子进程共享一块内存,只有当某个进程要修改数据时,才会把对应的内存页复制一份出来单独修改 2. AOF:日志持久化,将每条写命令追加到文件里,最多丢失 1s 数据,但是文件体积大,恢复速度慢 - 三种策略:always-每条命令执行完立刻写入;everysec-每秒写一次(默认);no-让操作系统决定什么时候刷盘 - AOF重写:随着时间推移,有很多命令执行,但最终有意义的只有少数,所以重写来生成新的精简的AOF文件 - 重写流程:fork 出子进程--->子进程生成新的 AOF 文件,期间主进程写的新命令追加到旧 AOF 和重写缓冲区--->子进程写完后,主进程将重写缓冲区内容追加到新的 AOF 文件--->新文件替换旧文件 3. 混合持久化(4.0 之后引入):AOF 重写时先写 RDB 快照,后面的增量命令用 AOF 追加。恢复时先加载RDB 部分,再加载 AOF 部分,恢复速度和安全性提高 4. 为了解决重复数据导致的内存、CPU、磁盘浪费问题,7.0 引入了 MP-AOF 机制,将单个 AOF 拆为基础文件、增量文件和清单文件。重写期间写命令添加到新的增量文件,子进程生成新的基础文件,重写完成后更新清单文件(增量文件+基础文件) ## <font style="color:rgb(51, 51, 51);">7. 缓存击穿/穿透/雪崩</font> 1. 缓存击穿:请求一个不存在的数据,无法进行缓存,每次都打到数据库 - 解决方案:参数合法性校验/布隆过滤器/缓存空值(推荐三者结合使用) 2. 缓存穿透:某个 key 过期的瞬间,大量请求同时涌进来,全部打到数据库(一个) - 解决方案:互斥锁/热点数据永不过期 3. 缓存雪崩:一批 key 同时过期,请求全部打到数据库上(一片);还有一种可能就是 Redis 挂掉 - 解决方案:设置随机过期时间;对于 Redis 方面而言,可以考虑主从+哨兵、本地缓存兜底、熔断降级等措施 4. 详细说说布隆过滤器:可以使用 Redis 自带的 RedisBloom 模块,把所有合法的 key 预先加载进去,请求来了先问布隆过滤器看看这个 key 存不存在,如果不存在直接返回。布隆过滤器可能会误判,但只会误判存在,不会误判不存在(位数组越大、哈希函数越多,误判率就越低。但内存占用和计算开销也会变高。一般把误判率控制在 1% 以内就可以了) 5. 关于热点数据永不过期的方案:第一种就是不设置过期时间;第二种是在 value 中存一个过期时间戳,后台起一个线程定期检查,快过期了就刷新一下 # 第二部分 | 主从集群 ## <font style="color:rgb(51, 51, 51);">1. Redis 主从复制的实现原理是什么</font> 1. 从节点连接主节点时会发送 psync 命令、runid、offset(首次:psync、?、-1),主节点收到命令判断为全量同步,返回 fullresync、master runid、offset,从节点保存 master runid 和 offset 2. 主节点生成一份 rdb 快照发给从节点,从节点拿到后先清空自己的数据,然后加载这份快照(在生成和发送快照这段时间,主节点收到新的写命令会存在`Replication Buffer`的缓冲区内,等快照发完后,再将这个缓冲区的命令发送给从节点) 3. 全量同步完成后,会在主从之间建立长连接,主节点收到命令后会异步发给从节点(期间也会进行心跳检测) 4. 如果从节点断线重连,会告诉主节点自己当前的偏移量,主节点拿着这个偏移量去`repl_backlog_buffer`环形缓冲区查找,如果找到执行增量同步,将断线这段时间的命令发给从节点;如果找不到,说明缓冲区数据已经被覆盖了,只能进行全量同步(环形缓冲区的大小默认 1m,可适当调大) 5. 注意点:从节点加载 rdb 时无法对外服务;主节点发送数据是异步的,自己写完不管从节点是否成功,直接返回,可以使用 wait 命令等待指定数量的从节点同步完成 ## <font style="color:rgb(51, 51, 51);">2. Redis 主从复制延迟的常见原因有哪些,如何解决</font> 1. 网络层面:带宽不足、网络抖动、跨机房部署延迟高 - 解决方案:监控流量、检查网络稳定性、主从节点同机房部署 2. 主节点瓶颈:写入 QPS 过高、生成 RDB/AOF 重写占用资源、大 key 写入阻塞 - 解决方案:分片、关掉主节点的持久化、大 key 拆为小 key 3. 从节点瓶颈:机器配置低、磁盘 IO 跟不上、从节点数量太多 - 解决方案:升级硬件、使用 SSD、限制从节点数量考虑级联复制 4. 复制机制:复制积压缓冲区配置过小,导致从节点频繁进行全量复制 - 解决方案:增加环形缓冲区大小 5. 如果一定要读最新的数据(写入立刻读),可以采用:关键业务读主库;使用版本号/时间戳,如果从库数据太旧就读主库;使用 wait 命令,等至少一个从节点同步完成在返回,但性能大幅降低 # 第三部分 | 分布式锁 ## <font style="color:rgb(51, 51, 51);">1. 如何使用 Redis 实现分布式锁</font> 1. 核心就是加锁时使用`set key value ex seconds nx`,nx 保证互斥,ex 设置过期时间防止死锁;解锁时使用 Lua 脚本先校验再删除,保证原子性 2. 加锁流程:执行`set key uuid ex 30 nx`,Redis 检查该 key 是否存在,不存在则加锁成功;存在则返回 nil,加锁失败 3. 解锁流程:执行 Lua 脚本,先获取当前 key 的值,判断值是否等于自己的 uuid,相等则进行解锁,否则锁不是自己的 ```java public class RedisLock { private Jedis jedis; private String lockKey; private String uuid; private int expireTime = 30; // 秒 public boolean tryLock() { uuid = UUID.randomUUID().toString(); String result = jedis.set(lockKey, uuid, SetParams.setParams().nx().ex(expireTime)); return "OK".equals(result); } public void unlock() { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) else return 0 end"; jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(uuid)); } } ``` ## <font style="color:rgb(51, 51, 51);">2. Redis 实现分布式锁需要注意哪些问题</font> 1. 锁过期但业务没跑完:锁过期自动释放,其他线程就能拿到锁,可能导致数据不一致 - 解决方案:使用Redisson 提供的看门狗机制,锁自动续期 2. 误删别人的锁:锁过期,另一个请求拿到锁,之前的请求执行完后删的锁就是新请求的锁了 - 解决方案:每次加锁带上唯一标识,使用 Lua 脚本先判断再删除 3. 主从同步延迟:主节点刚写入锁就挂了,数据还没来得及同步到从节点,新主上来后锁就丢失了 - 解决方案:使用 RedLock,加锁时向所有实例申请加锁,超过一半的实例加锁成功才能真正拿到锁,但这种是并列实例,不适用于主从延迟 - 正确方案:做幂等校验或者使用 zookeeper/etcd 这种强一致性系统 4. 时钟漂移:多节点的系统时间不一致,ttl判断不准确,锁可能提前释放或延迟释放 - 配置NTP(Network Time Protocol,网络时间协议)服务同步时间,将时钟偏差控制在毫秒级 5. 锁不可重入:同一线程想要再次获取同一把锁,被拒绝了,比如递归场景 - 解决方案:使用 Hash 类型,key 是锁名,field 是客户端标识+线程 ID,value 是重入次数,减到 0 才真正删除锁 ## <font style="color:rgb(51, 51, 51);">3. RedLock 是什么</font> 1. RedLock 专门用来解决主从架构下锁丢失问题:主节点加锁成功,还没同步到从节点就挂了,从节点晋升为主节点但锁的数据没有同步过来,另一个客户端来加锁也能成功,此时就会有两个客户端同时操作临界区资源,可能导致数据不一致 2. RedLock 实现思路是独立部署多台Redis实例(一般 5 台),通过投票的方式判断加锁是否成功。不需要主从复制,也不需要哨兵,完全独立 3. 加锁流程:记录当前时间 t1--->向所有 Redis 实例发送加锁命令--->统计成功加锁的实例数量以及当前时间 t2 --->只有成功数量≥半数以上且加锁总耗时 < 锁过期时间(t2-t1)才算加锁成功--->否则向所有实例发送解锁命令 4. 不足:时钟漂移、STW 等场景可能导致锁被释放,另一个客户端就能拿到锁,同时操作临界区资源,造成数据不一致。所以 RedLock 不能保证 100% 可靠;并且 RedLock 需要部署多台实例,访问多个节点,增大了资源开销与延迟 5. 所以大多数场景还是采用主从+哨兵方案,配合 Redisson 看门狗机制续期,以及幂等性校验;如果需要强一致性的分布式锁,可以考虑 zookeeper 或 etcd ## <font style="color:rgb(51, 51, 51);">4. Redisson 分布式锁的原理</font> 1. 核心就是使用 Redis 的 Hash 结构+ Lua 脚本保证原子性+后台线程自动续期 2. 加锁流程 - 如果锁不存在,直接创建 Hash 结构,field 是线程标识,value 设为 1,设置过期时间,加锁成功 - 如果锁存在且 field 是当前线程,说明是重入,将 value 加1,刷新过期时间,加锁成功 - 如果 field 不匹配,说明锁被占用,返回这个锁的剩余存活时间,加锁失败。等收到消息或者到达返回的剩余存活时间再重新加锁 3. 解锁流程 - 锁不存在或者 field 不匹配当前线程,直接返回 - field 匹配,value 减 1,如果减完 value 依然大于 0,说明还有重入没退出,刷新锁的过期时间 - value 减到 0,删除 key,推送消息通知其他等待的线程 4. Redisson 锁的类型 - 可重入锁:同一线程可多次获取同一把锁 - 公平锁:按顺序排队,先到先得 - 读写锁:读锁共享,写锁独占 # 第四部分 | 看门狗机制 ## <font style="color:rgb(51, 51, 51);">1. 说一说 Redisson 的看门狗机制</font> 1. 看门狗机制是用来解决分布式场景下锁的续期问题。比如业务逻辑没执行完锁就到期释放了,别的线程拿到锁,两个线程同时操作临界资源,造成数据不一致 2. 看门狗的实现原理是:如果不指定过期时间,Redisson 会启动一个定时任务,每隔 10s 向 Redis 发一次请求,如果任务还没结束,就通过 Lua 脚本将锁的过期时间重置为 30s,所以只要任务没结束,锁就不会释放(期间如果出现客户端宕机等问题,定时任务也就没了,到了 30s 会自动释放锁) 3. 续期间隔设置为锁过期时间的 1/3 的原因:在续期频率和网络开销取了个平衡值,太频繁对 Redis 压力大; 1/3 的过期时间也保证了出现网络抖动等问题续期失败后,还有机会重新续期 4. 注意:看门狗机制不一定 100% 解决冲突问题,如果某些原因打断了锁的自动续期,到了时间锁就释放了,这时候任务还在跑着,另一个线程拿到锁就会同时操作临界区资源了。所以关键业务必须保证幂等和进行数据校验 5. 注意:如果业务代码出现死循环,看门狗就会一直对锁进行续期,锁永远不会释放。所以一定要保证 unlock在 finally 里调用;或者使用 try-with-resources;或者放弃看门狗,主动设置过期时间等机制 ## <font style="color:rgb(51, 51, 51);">2. Redisson 看门狗机制的进一步探究</font> 1. 续期主要涉及来个方法:获取续期任务(scheduleExpirationRenewal)、定期刷新过期时间(renewExpiration) 2. scheduleExpirationRenewal:客户端拿到锁之后调用,将当前锁注册到续期 map 里,然后启动定时任务 - 创建一个 entry 对象存锁的过期时间 - 使用 putIfAbsent 往 map 里塞,如果已经有了就把当前线程 ID 加进去 - 否则,调用 renewExpiration 启动定时任务;如果线程被中断,调用 cancelExpirationRenewal 取消续期 3. renewExpiration:通过 Netty 时间轮创建定时任务 - 从 map 里拿 entry,如果拿不到说明锁已经释放,直接返回 - 检查里面有没有线程 ID,没有就说明没人持有锁,直接返回 - 调用 renewExpirationAsync 异步续期,续期成功就重新调度自己,失败就取消续期 4. renewExpirationAsync 的逻辑其实很简单,就是检查锁是不是自己的,是的话就续期 5. cancelExpirationRenewal 的逻辑是移除线程 ID,然后判断是否还有任务,没有的话就取消定时任务并移除 entry 6. 使用 Netty 时间轮的原因:添加和取消任务都是 O(1),并且 Redisson 底层通信依赖 Netty,直接复用 Netty 的时间轮也避免引入额外的线程池 # 第五部分 | 事务 ## 1. Redis 是否支持事务,如果支持如何实现 1. Redis 是支持事务的,但和 Mysql 中的事务不同,Redis 只保证将命令打包执行不被其他客户端插队,不支持回滚(因为 Redis 命令只有语法错误/类型错误的情况下才会执行出错,属于程序方面的问题,应该在开发阶段修正) 2. Redis 通过 multi、exec、watch、discard 这四个命令实现事务 - multi 用来开启事务,之后的命令不会立刻执行,而是进入一个队列排队。可以放多条命令,Redis 会回复 queued 表示入队成功 - exec 会一次性把队列中的任务全部执行,期间不会被其他客户端的命令打断 - 如果不想执行了,使用 discard 丢弃队列 - watch 则用来在 multi 前监视某些 key,如果这些 key 在 exec 前被改了,整个事务作废 3. Redis 会在入队阶段做语法检查,如果有问题(比如参数个数不对),入队会报错,后续 exec 会拒绝执行整个事务;如果是类型错误,则会成功入队,但在执行时这条命令会报错,其他命令正常执行 4. 再说一说 watch 这个命令,它类似于乐观锁,watch 之后的 key 如果被别的客户端改了,exec 时会返回 nil表示事务被放弃,需要重新读取 key 的值重试。底层实现就是 Redis 给每个被 watch 的 key 维护一个客户端列表,key 被修改时标记这些客户端的事务为失效状态 5. 在实际开发中,要想实现多条命令原子执行,还是推荐 Lua 脚本。Lua 脚本在 Redis 中执行是原子性的,并且可以写判断逻辑,比事务更灵活,并且网络往返只需一次 ## 2. <font style="color:rgb(51, 51, 51);">Redis 的 Pipeline 是什么,与事务、Lua 脚本、原生批处理命令有什么区别</font> 1. pipeline 可以让客户端将多条不同类型的命令打包一次性发给 Redis,减少网络往返次数。但是它不能保证原子性,批量执行命令的时候,失败不会回滚,而是继续执行;执行过程中可能穿插其他客户端的命令;底层原理是把命令的收发放到内存缓冲区,如果命令数量太多会导致两边内存占用增加。如果涉及多个节点,需要客户端先进行处理 2. 而事务使用 multi/exec 包裹命令,虽然也不支持回滚,但是具有原子性,这批命令执行期间不会穿插其他客户端的命令 3. Lua 脚本在 Redis 中执行是原子性的并且支持条件判断,脚本一次性执行,其他客户端没法穿插命令,但是同样不支持回滚 4. mget/mset 等原生处理命令只能用于同一种数据类型,天生具有原子性,中间不会被插入其他命令。如果涉及多个节点,需要客户端手动处理 # 第六部分 | 性能优化 ## <font style="color:rgb(51, 51, 51);">1. 如何解决热点 key 的问题</font> 1. 首先明确热点 key 的定义:访问频率占比大、带宽占比大、CPU 使用时间占比大等场景 2. 其次是发现热点 key - 业务预判:提前预判哪些 key 可能成为热点,但无法应对突发情况,适用于可预期的活动 - Redis 自带的命令`redis-cli-hotkeys`:扫描慢,非实时,适用于临时排查 - monitor 命令:监控所有命令执行,配合脚本统计 key 的频次,但会损失 50% 的性能,生产环境慎用! - 客户端埋点:在 Redis 客户端 SDK 加统计逻辑,进行上报监控,但是改造成本高,适用于有统一 SDK 的场景 - proxy 层收集:客户端无感,但是需要部署代理,适用于已有代理架构的场景 3. 最后针对性的解决热点 key 的问题 - 拆分 key:将 key 放到不同节点(全量复制/分片存储) - 多级缓存:在 Redis 前加 cdn/本地缓存 - 读写分离:一主多从,主节点只负责写,读请求发到从节点 - 限流降级:当某个 key 访问频率过高,进行限流,必要时返回降级的数据或空值 ## <font style="color:rgb(51, 51, 51);">2. Redis 达到性能瓶颈后应该如何处理</font> 1. 分析瓶颈点 - 内存不足:频繁淘汰数据,响应时间飙升 - CPU 打满:耗时操作、大 key 操作等 - 网络带宽:比如传输一个达到最大限制的 512M 的数据 - 持久化阻塞:AOF 重写或生成 RDB 快照的时候,fork 子进程会拷贝页表,内存越大 fork 越慢,这段时间主进程是卡死的 2. 解决方案 - 垂直扩容:看单机资源有没有用满,如果还有资源就加内存 - 读写分离:比如一主三从,读请求全部路由到从节点,QPS 直接翻 3 倍 - Redis 集群分片:数据量太大或者写请求压力大,就要使用分片集群,16384 个槽位分散到多个主节点,横向拓展写能力 - 多级缓存:加一层本地缓存(Caffeine/Guava Cache),热点数据放本地,减少向 Redis 请求的次数(本地缓存的时间调小一点,减小不一致的窗口期时间;如果对实时性要求高,可以使用发布订阅或者消息队列广播消息清空本地缓存) - 实际项目中这几种策略往往结合使用:读写分离+集群分片+多级缓存 - 对于 RDB 持久化的优化:控制内存大小增加 fork 的速度、使用混合持久化减少 RDB 的频率等 ## <font style="color:rgb(51, 51, 51);">3. Redis 中的内存碎片是什么,如何处理</font> 1. Redis 默认使用 jemalloc 做内存分配器(在多线程环境下碎片率低,虽然 Redis 是单线程,但后台有 IO 线程和持久化线程,jemalloc 综合表现更好),按固定大小分配内存,比如实际只需要 8KB,分配器却给了 12KB,多出来的 4KB 就浪费了,这就是内存碎片 2. 并且经过频繁的创建、删除,内存块的大小和位置会变得不连续,碎片化越来越多 3. 可以通过查看操作系统层面 Redis 的内存(used_memory_rss)占用和 Redis 真实的内存(used_memory)占用计算出碎片率(used_memory_rss/used_memory)。一般来说 1-1.5 算正常;超过 1.5 就应该考虑清理一下;小于 1 说明 Redis 内存不够用,已经在进行 swap 了,性能大幅度下跌(物理内存不够,把内存数据暂时放到磁盘上,这块磁盘空间就叫做 swap) 4. 碎片整理的方案 1. 重启 Redis:最简单,重启后内存会重新分配,碎片自然就没了。但服务会中断,生产环境慎用 2. 使用 activedefrag 自动碎片整理:4.0 引入,会在运行时自动整理碎片,增量式处理,每次处理一小部分。但会消耗额外资源 ```bash # 开启自动碎片整理 activedefrag yes # 碎片占用的字节数超过 100MB 时才开始整理 active-defrag-ignore-bytes 100mb # 碎片率达到多少百分比才触发 active-defrag-threshold-lower 10 active-defrag-threshold-upper 100 # CPU 使用率控制,避免整理影响正常请求 active-defrag-cycle-min 1 active-defrag-cycle-max 25 ``` 3. 手动触发:手动执行`memory purge`,期间会阻塞主线程,生产环境慎用 5. 预防方案:让 key 的大小固定或接近;给 key 设置过期时间让 Redis 自己清理;7.0 版本将 ziplist 换成了 liskpack,内存利用率更高,从源头减少了碎片 # 第八部分 | 其他 ## <font style="color:rgb(51, 51, 51);">1. Redis 快的原因有哪些</font> 1. 基于内存:访问内存的速度比访问 SSD 磁盘快了将近 1000 倍 2. 单线程+ IO 多路复用:Redis 采用单线程执行,没有锁竞争和上下文切换;采用多路复用,一个线程监听多个连接 3. 高效的数据结构:比如 String 底层是 SDS,O(1) 获取长度;Hash 小数据量用 listzip 省内存,大数据量用hashtable 保证 O(1) 查询;ZSe t使用跳表,插入查询都是 O(logN) 4. Redis 6.0 引入了多线程来处理网络 IO,命令执行还是单线程的 ## <font style="color:rgb(51, 51, 51);">2. Redis 的适用场景有哪些</font> 1. 作缓存提升性能:常用 String 类型来存储用户信息、Session、查询结果,查询时直接操作内存,速度快 2. 作分布式锁解决并发问题:Redis 是单线程,天生能保证原子性,谁先拿到锁谁先执行。可以使用 Redis 的setnx 命令或者使用 Redisson 框架(使用 setnx 时需要注意锁要设置过期时间、用 set ex nx这种原子命令、释放锁要检查是不是自己的) 3. 作计数器与限流:比如阅读量、点赞数,如果让这些数据都去操作数据库,锁竞争太大了。Redis 基于内存,并且单线程原子性,incr 自增,性能高且不会算错 4. 作排行榜:需要获取实时排名的情况,用数据库会经常全表扫描,开销太大;Redis 中的 ZSet 数据类型,会自动进行排序(zadd/zrange) 5. 作消息队列:如果只是做简单的异步处理,或者其他原因不想单独引入 mq,就可以用 Redis 的 List 数据类型实现简单的任务队列(lpush/brpop)[Redis 5.0 引入了 Stream,支持消费者组、消息 ID、ACK 确认、消费失败重新处理] ## <font style="color:rgb(51, 51, 51);">3. Java 中有哪些 Redis 客户端</font> 1. Jedis:同步阻塞、API 简单直接、线程不安全需要连接池 2. Lettuce:异步非阻塞、基于 Netty、线程安全可共享链接(SpringBoot 2.x 之后默认客户端) 3. Redisson:异步非阻塞、高级封装、提供分布式锁、限流器等开箱即用的组件(RLock 分布式锁、RReadWriteLock 读写锁、RSemaphore 信号量、RRateLimiter 限流器、RBloomFilter 布隆过滤器、RDelayedQueue 延迟队列) ## <font style="color:rgb(51, 51, 51);">4. 保证缓存和数据库一致性的方案有哪些</font> 1. 先更新数据库,再删缓存:但有个临界问题,如果此时缓存刚好过期,且此次读操作慢于更新数据库删除缓存,那么旧数据又被写回缓存了,但这种情况出现概率极低 2. 缓存双删:先删缓存,再更新数据库,一段时间后再删缓存。解决“先删缓存,再更新数据库”的并发问题。但延迟时间不好确定 3. Binlog 异步更新:只写数据库,canal 伪装成数据库的从节点,订阅 binlog,解析出变更事件,发送到消息队列,由消费者负责删除缓存,这样即使失败了也能重试,可以保证最终一致性(但要保证顺序消费!幂等处理) 4. 强一致性方案:使用分布式读写锁,读读不互斥,读写/写写互斥,但会导致性能下降 ## <font style="color:rgb(51, 51, 51);">5. 聊一聊 Lua 脚本</font> 1. Lua 脚本的特性:在Redis中是原子性的、减少网络往返次数、封装复杂操作等特性,超过了单个 Redis 命令的能力 2. 典型场景是分布式锁:在分布式锁场景下,释放锁前要判断锁是不是自己的,是的话再删除。由于判断和删除不是原子的,可能会误删别人的锁(比如刚判断完这个锁就过期了,另一个请求拿到锁,那么接下来删除的就是新请求的锁了),Lua 脚本把判断和删除放在一起,解决了这个问题 3. 在 Redis 中还有预加载功能:如果每次执行都要把完整的脚本发给 Redis,脚本传输会有网络开销,因此可以先把脚本缓存到 Redis,返回一个 sha1 哈希值,以后直接发送哈希值就行,省掉脚本内容传输的开销,生产环境建议使用这种方式(注意 Redis 重启后脚本缓存会丢失,所以要在代码里处理对应错误,重新加载一次) 4. Lua 脚本超时逻辑:默认 5s 超时,超时后不会立刻停止,而是接受客户端的 kill 命令,如果脚本还没执行写操作,会直接终止掉;如果已经写了一部分,再想停止,只能重启 Redis。所以 Lua 脚本不要太复杂 ## <font style="color:rgb(51, 51, 51);">6. 如何使用 Redis 实现布隆过滤器</font> 1. 布隆过滤器用于判断某个元素存不存在,可能会误判,但只会误判存在的,不存在就一定不存在。实现方式主要有两种,一种是位图手动实现,另一种是用官方的 RedisBloom 模块 - 位图手动实现:自己管理哈希函数和位数组,主要通过 Redis 的 setbit/getbit 命令,将位置设为 1 或者获取对应位置的值 - RedisBloom:官方提供,只需要指定误判率和容量,自动算出最优的位数组大小和哈希函数个数;支持 Cuckoo Filter 用来删除;支持动态扩容 2. 本质:由一个位数组和多个哈希函数组成。添加元素时通过 k 个哈希函数计算出 k 个位置,将这些位置设为 1;查询时同样使用 k 哈希函数计算 k 个位置,如果全为 1 则可能存在,有一个为 0 一定不存在 3. 还有一点就是布隆过滤器是单向的,只能新增不能删除。因为位数组里某个位可能是多个元素共同设置的,如果为了一个元素变更状态,其他元素也会收到影响。当然后续也出现了很多变种,允许删除 4. 适用于数据量大、允许误判、只需判断存在性的场景:缓存击穿、垃圾邮件过滤、推荐系统去重 ## <font style="color:rgb(51, 51, 51);">7. 说一下 Redis 的哨兵机制</font> 1. 使用 Redis sentinel 来对 Reids 主从进行监控、故障转移、通知;此时客户端会先向哨兵询问主节点的地址,拿到地址后再去连接 Redis 2. 奇数个(>=3)哨兵组成一个集群,可以相互通信,每个哨兵监控所有 Reids 的状态 3. 哨兵判断节点下线分为两步,主观下线和客观下线。每个哨兵每隔 1s 向所有 Redis 发送 ping,如果指定时间没收到 pong,则这个哨兵会认为该节点主观下线。如果是主节点,为了避免是因为网络抖动而未收到响应,这时这个哨兵会向其他哨兵询问并投票,如果票数达到指定值,则会认为主节点客观下线,开始走故障转移流程(这个值一般配置为哨兵总数/2+1) 4. 确定主节点客观下线了,会选出一个哨兵来主持故障转移,选举策略是:第一个发现主节点挂掉的哨兵先投自己一票,然后让其他哨兵投票,每个哨兵只有一票,并且谁先来就投谁,候选者拿到一半以上的票就成为Leader(可能会出现同票的情况,那么过一段时间会重新投票,所以最好哨兵设置为奇数个) 5. 然后选举主节点,哨兵 Leader 会从剩下的从节点选出一个来当主节点,选举策略是:先看优先级(0 表示不参选,值越小优先级越高),优先级相同就选 offset 大的那个,offset 还相同就选 runid 最小的那个 6. 最后 Leader 哨兵会给新主节点发送`slavof no one`成为主节点,给其他从节点发送新节点 IP 和端口,与新节点同步。如果原来的主节点恢复了,也是成为新主节点的从节点

Redis

分享一篇学习笔记,如有错误,请指出 ## 配置 ```java @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); // 配置 ObjectMapper,支持 Java 8 时间类型 ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()); mapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(mapper); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } ``` **Spring 是这样工作的:** 1. **读取配置:** Spring Boot 启动时,`RedisAutoConfiguration` 类会读取 `application.yml` 中以 `spring.data.redis`(或旧版 `spring.redis`)开头的配置项。 2. **创建工厂:** Spring 自动根据这些配置创建一个 `RedisConnectionFactory` 的 Bean(默认是 Lettuce,也可以配置为 Jedis)。这个工厂里已经包含了 host、port、password 等连接信息。 3. **注入参数:** 当 Spring 扫描到你的 `redisTemplate` 方法时,发现它需要一个 `RedisConnectionFactory` 参数。Spring 就会把**第 2 步自动创建好的那个工厂实例**注入进来。 4. **应用配置:** 你的代码 `template.setConnectionFactory(connectionFactory)` 实际上就是把读取了 YML 配置的连接工厂塞给了 RedisTemplate。 ```yaml spring: data: redis: host: 127.0.0.1 # Redis 服务器地址 port: 6379 # Redis 端口 password: yourpassword # 如果有密码 database: 0 # 指定数据库索引 timeout: 3000ms # 连接超时时间 lettuce: # 如果使用的是默认的 Lettuce 客户端 pool: max-active: 8 # 连接池最大连接数 max-wait: -1ms # 连接池最大阻塞等待时间 max-idle: 8 # 连接池中的最大空闲连接 min-idle: 0 # 连接池中的最小空闲连接 ``` Spring Boot 内部有一个类叫 `RedisProperties`。它使用了 `@ConfigurationProperties(prefix = "spring.data.redis")` 注解。 1. YML 中的值被绑定到 `RedisProperties` 对象上。 2. Spring Boot 的自动配置类(`RedisAutoConfiguration`)使用这个 `RedisProperties` 对象来构建 `LettuceConnectionFactory`。 3. 最后,这个 Factory 被传递给你的 `@Bean` 方法。 ## 使用 首先引入 ```java @Resource private RedisTemplate<String, Object> redisTemplate; ``` 使用过程最好搭配hutool工具类 ```java import cn.hutool.core.lang.TypeReference; import cn.hutool.json.JSONUtil; ``` 首先常量类中要配置key ```java public class RedisKeyConstants { /** * Redis 缓存键前缀 * 提示词 */ public static final String KEY = "key:"; } ``` 使用,存缓存,一定要写过期时间!!! ```java public VO getDetailById(Long id) { if (id == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "ID不能为空"); } // 缓存 key: scroll_prompt:detail:{id} String cacheKey = KEY + "detail:" + id; // 先查缓存 Object cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { try { // Hutool 极简写法:先转为 JSONObject,再转为 Bean return JSONUtil.toBean(JSONUtil.parseObj(cached), VO.class); } catch (Exception e) { log.error("Hutool 反序列化失败,删除缓存", e); redisTemplate.delete(cacheKey); } } //查数据库 VO = ... // 存入缓存,一定要写过期时间 redisTemplate.opsForValue().set(cacheKey, VO, 30, TimeUnit.MINUTES); return VO; } ``` 其他类似 注意的是有增删改时记得删除缓存 ```java // 1. 清除详情缓存 redisTemplate.delete(KEY + "detail:" + id); ``` 还有就是分页查询 ```java public Page<VO> pageQuery(Request request) { // 1. 生成缓存 key String cacheKey = buildPageCacheKey(request); // 1. 查询缓存 Object cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { try { // 1. 先把 Redis 返回的 LinkedHashMap 转为 Hutool 的 JSONObject // 2. 再通过 TypeReference 转回带泛型的 Page 对象 Page<VO> page = JSONUtil.toBean( JSONUtil.parseObj(cached), new TypeReference<Page<VO>>() {}, true // ignoreError: 是否忽略转换过程中的错误 ); return page; } catch (Exception e) { log.error("Hutool 反序列化失败", e); redisTemplate.delete(cacheKey); } } //查数据库 // 4. 存入缓存(过期时间建议 5-10 分钟) redisTemplate.opsForValue().set(cacheKey, voPage, 10, TimeUnit.MINUTES); return voPage; } //生成缓存key和删缓存可以封装成方法 /** * 生成分页查询的缓存 Key */ private String buildPageCacheKey(Request request) { StringBuilder keyBuilder = new StringBuilder(KEY); keyBuilder.append("page:"); // 添加查询条件 keyBuilder.append("req1:").append(StringUtils.isNotBlank(request.getPrompt()) ? request.getPrompt() : "null"); keyBuilder.append(":req2:").append(request.getStatus() != null ? request.getStatus() : "null"); keyBuilder.append(":current:").append(request.getCurrent()); keyBuilder.append(":size:").append(request.getPageSize()); return keyBuilder.toString(); } /** * 清除所有分页查询缓存 */ private void clearPageCache() { String pattern = KEY + "page:*"; Set<String> keys = redisTemplate.keys(pattern); if (keys != null && !keys.isEmpty()) { redisTemplate.delete(keys); } } ``` ## 补充 使用过程中发现使用StringRedisTemplate更好 ```java @Resource private StringRedisTemplate stringRedisTemplate; public List<Case> getIndexCases() { String key = "index:cases"; // 1. 先查 Redis String jsonStr = stringRedisTemplate.opsForValue().get(key); // 2. 如果 Redis 有,转成对象列表返回 if (StrUtil.isNotBlank(jsonStr)) { // 使用 Hutool 将 JSON 字符串转为 List<Case> return JSONUtil.toList(jsonStr, Case.class); } // 3. 如果 Redis 没有,去查数据库 List<Case> caseList = caseMapper.selectList(null); // 4. 查完存入 Redis (设置过期时间,比如 30 分钟) // 使用 Hutool 将对象转为 JSON 字符串 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(caseList), 30, TimeUnit.MINUTES); return caseList; } ``` 工具类 ```java /** * Redis工具类 * 封装了 StringRedisTemplate + Hutool JSON,实现对象自动序列化 */ @Component @Slf4j public class RedisUtils { @Resource private StringRedisTemplate stringRedisTemplate; /** * 写入缓存 (默认不过期) * @param key 键 * @param value 值 (对象会自动转为 JSON 字符串) */ public void set(String key, Object value) { stringRedisTemplate.opsForValue().set(key, objToStr(value)); } /** * 写入缓存并设置过期时间 * @param key 键 * @param value 值 * @param time 过期时间 * @param timeUnit 时间单位 (例如 TimeUnit.MINUTES) */ public void set(String key, Object value, long time, TimeUnit timeUnit) { stringRedisTemplate.opsForValue().set(key, objToStr(value), time, timeUnit); } /** * 读取缓存 (返回单个对象) * @param key 键 * @param clazz 目标对象的 Class * @return 对象,如果 key 不存在则返回 null */ public <T> T get(String key, Class<T> clazz) { String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(json)) { return null; } return JSONUtil.toBean(json, clazz); } /** * 读取缓存 (返回 List 集合) * @param key 键 * @param clazz 集合中元素的 Class * @return List 对象 */ public <T> List<T> getList(String key, Class<T> clazz) { String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(json)) { return null; } return JSONUtil.toList(json, clazz); } /** * 读取缓存 (返回原生 String) */ public String get(String key) { return stringRedisTemplate.opsForValue().get(key); } /** * 删除缓存 */ public void delete(String key) { stringRedisTemplate.delete(key); } /** * 检查 Key 是否存在 */ public Boolean hasKey(String key) { return stringRedisTemplate.hasKey(key); } // --- 私有辅助方法 --- private String objToStr(Object value) { if (value == null) { return null; } if (value instanceof String) { return (String) value; } return JSONUtil.toJsonStr(value); } } ``` 实战 ```java @Resource private RedisUtils redisUtils; // 直接存,不用管opsForValue redisUtils.set(key, code, 5, TimeUnit.MINUTES); // 取的时候 String cacheCode = redisUtils.get(key); ``` ```java @Resource private RedisUtils redisUtils; public List<Case> getIndexCases() { String key = "index:cases"; // 1. 尝试从缓存获取 List // 泛型自动处理:告诉它 key 是什么,List 里装的是什么类 (Case.class) List<Case> cacheList = redisUtils.getList(key, Case.class); // 2. 如果缓存有,直接返回 if (cacheList != null && !cacheList.isEmpty()) { return cacheList; } // 3. 缓存没有,查数据库 List<Case> dbList = caseMapper.selectList(null); // 4. 写入缓存 (设置30分钟过期) // 这里直接把 List 传进去,工具类会自动转成 JSON 字符串存入 if (dbList != null) { redisUtils.set(key, dbList, 30, TimeUnit.MINUTES); } return dbList; } ```

特训营刷题知识整理(Redis)-未完成

[TOC] # Redis ## 测试环境的搭建 > Win11 + WSL Ubuntu + redis7.0.15 **reidis安装** ```bash # 1. 更新系统源,安装基础依赖 sudo apt update && sudo apt upgrade -y sudo apt install -y wget curl net-tools # 2. 安装 Redis 稳定版APT 官方源) sudo apt install redis-server -y # 3. 验证安装查看版本,确认安装成功) redis-cli --version ``` **redis.conf配置** ```bash # 1. 备份原始配置防止改错回滚) sudo cp /etc/redis/redis.conf /etc/redis/redis.conf.bak # 2. 批量修改核心配置无需手动编辑) sudo sed -i 's/bind 127.0.0.1 -::1/bind 0.0.0.0/g' /etc/redis/redis.conf # 允许所有IP访问 sudo sed -i 's/protected-mode yes/protected-mode no/g' /etc/redis/redis.conf # 关闭保护模式 sudo sed -i 's/dir \.\//dir \/var\/lib\/redis/g' /etc/redis/redis.conf # 数据目录 sudo sed -i 's/# maxmemory <bytes>/maxmemory 256MB/g' /etc/redis/redis.conf # 内存限制 sudo sed -i 's/# maxmemory-policy noeviction/maxmemory-policy allkeys-lru/g' /etc/redis/redis.conf # 内存淘汰策略 sudo sed -i 's/daemonize yes/daemonize no/g' /etc/redis/redis.conf # 关闭后台运行适配systemd) # 3. 修复配置文件+数据目录权限解决WSL权限bug) sudo mkdir -p /var/lib/redis /var/log/redis sudo chown -R redis:redis /etc/redis/redis.conf /var/lib/redis /var/log/redis sudo chmod 644 /etc/redis/redis.conf sudo chmod 770 /var/lib/redis /var/log/redis ``` **redis.service服务配置** ```bash # 1. 备份原始服务配置 sudo cp /usr/lib/systemd/system/redis-server.service /usr/lib/systemd/system/redis-server.service.bak # 2. 批量修改服务配置解决超时/notify模式bug) sudo sed -i 's/Type=notify/Type=simple/g' /usr/lib/systemd/system/redis-server.service # 替换启动模式 sudo sed -i '/\[Service\]/a TimeoutStartSec=300' /usr/lib/systemd/system/redis-server.service # 延长超时时间 sudo sed -i 's/--supervised systemd//g' /usr/lib/systemd/system/redis-server.service # 删除WSL不兼容参数 sudo sed -i 's/ExecStart=.*/ExecStart=\/usr\/bin\/redis-server \/etc\/redis\/redis.conf --daemonize no/g' /usr/lib/systemd/system/redis-server.service # 统一启动命令 # 3. 重新加载systemd配置,清除失败状态 sudo systemctl daemon-reload sudo systemctl reset-failed redis-server ``` **验证** ```bash # 1. 启动 Redis 并设置开机自启 sudo systemctl start redis-server sudo systemctl enable redis-server # 2. 验证服务状态显示 active (running) 即为成功) sudo systemctl status redis-server # 3. 验证 Redis 功能返回 PONG 即为正常) redis-cli ping # 4. 验证核心配置生效输出对应值即为配置成功) redis-cli CONFIG GET bind # 输出 "0.0.0.0" redis-cli CONFIG GET maxmemory # 输出 "268435456"256MB) redis-cli CONFIG GET protected-mode # 输出 "no" # 5. 验证端口监听显示 0.0.0.0:6379 即为外网访问生效) sudo netstat -tulpn | grep 6379 ``` <img src="https://oss.luhuhu.cn/202601280944822.png" alt="image-20260128094447738" style="zoom: 80%;" /> ## Redis的应用场景 | 场景名称 | 技术点 | 缺陷 | 方案 | | ------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------ | | 缓存热点数据 | 1. 数据结构:String、Hash<br />2. 核心特性:过期策略、内存淘汰策略<br />3. 命令:`SET/GET`、`HGET/HMSET`、`EXPIRE`<br />4. 性能优化:Pipeline、批量操作 | 1. 缓存穿透<br />2. 缓存击穿<br />3. 缓存雪崩<br />4. 数据一致性问题<br />5. 热key导致节点压力 | 见[异常处理](#Redis异常) | | 存储Session | 1. 数据结构:String(序列化Session)、Hash<br />2. 核心特性:过期策略<br />3. 核心命令:`HMSET/HMGETALL`、`EXPIRE`、`DEL`<br />4. 集群特性:RedisCluster保证Session全局可访问 | 1. 单点故障导致Session不可用<br />2. 序列化/反序列化的性能损耗<br />3. 过期时间内的内存占用<br />4. 集群场景数据分片不均 | 见[高可用](Redis高可用) | | 分布式锁 | 1. `SETNX`+`EXPIRE`、`DEL`、`lua`脚本<br />2. 进阶:Redis Redlock算法<br />3. 特性:原子性、过期自动释放 | 1. `SETNX`+`EXPIRE`非原子,加锁成功设置过期失败导致死锁<br />2. 主从切换导致锁丢失 <br />3. 锁超时释放<br />4. 不可冲入、不可阻塞 | 见[分布式锁](#分布式锁) | | 排行榜 | 1. 数据结构:Sorted Set,score排序<br />2. 核心命令:`ZADD`、`ZRANGE/ZREVEAGE`、`ZSCORE`、`ZINCRBY`;<br />3. 扩展:Bitmap辅助统计 | 1. 数据量大情况下SortedSet性能下降<br />2. 实时性要求高,频繁`ZADD`、`ZINCRBY`导致redis节点压力大<br />3. 无法直接实现分组排行榜、分页查询<br />4. 分数相同排序不可定制 | | | 简单消息队列 | 1. 数据结构:List、Stream<br />2. 核心命令:`LPUSH/RPOP`、`BRPOP`;`XADD`、`XREADGROUP`、`XACK`<br />3. 特性:`Pub/Sub`,广播模式 | 1. List队列:消费者宕机未处理消息丢失;不支持重复消费、消费组<br />2. Stream队列:数据挤压导致内存占用过高;消费组配置复杂,运维成本高<br />3. Pub/Sub:无持久化、消费者理线丢失消息 | [消息队列](#消息队列) | | 计数/限流 | 1. 计数核心:String(INCR/DECR)、Hash(HINCRYBY)<br />2. 限流核心:固定窗口(INCR+EXPIRE)、滑动窗口(SortedSet记录时间戳)<br />3. 特性:院子命令保证计数准确 | 1. 高并发`INCR`导致节点CPU飙升、计数溢出String最大数值限制<br />2. 固定窗口临界问题、滑动窗口大数据量下SortedSet性能下降<br />3. 计数/限流无持久化,redis宕机数据丢失 | | ## Redis特性 ### 数据类型 数据类型在底层会根据数据量大小做编码切换 **基础类型** | 名称 | 说明 | 底层核心 | 场景 | | ------ | ----------------------------------- | ----------------- | ---------------------------- | | String | 基本类型,存文本、数字、二进制 | SDS | 缓存会话、页面数据、计数器 | | Hash | 键值对集合,适合存对象 | 哈希表+ziplist | | | List | 有序字符串列表,底层双向链表 | 快速列表quicklist | 消息队列 | | Set | 无序不重复集合,查找去重效率高 | 哈希表+整数集合 | 标签等需要去重的集合运算场景 | | Zset | 类似Set,多一个权重值,底层跳表实现 | 哈希表+**跳表** | 排行榜、积分榜 | **后续新增的高级类型** | 名称 | 说明 | 底层核心 | 场景 | | ----------- | ------------------------------------------------------------ | ------------------- | -------------------------------- | | BitMap | 位存储,空间利用率极高 | SDS | 签到等简单标识信息 | | HyperLogLog | 概率性数据结构,用于估算技术,固定大小 | 基数估算算法+字符串 | 网站UV,对精度要求不高但数据量大 | | GEO | 地理位置信息,支持经纬度存储和空间查询,底层Zset | | 附近的人、配送距离 | | Stream | 消息队列专用,比List多两个特性:自动生辰给全局唯一消息ID;相比Pub/Sub可以消息持久话 | | 消息队列 | **数据类型的优化:** 1. 内存紧凑存储,根据**数量大小使用不同的数据结构**; 2. 渐进式rehash:哈希扩容,拆分多次迁移,避免单次rehash阻塞主线程 3. 跳表优化:zset用[跳表](#跳表)而非[红黑树](#红黑树) ### 底层原理 redis的核心是 **单线程事件驱动模型** + **高效内存数据结构** + **按需持久化机制** * 单线程 > 单线程无并发问题 ,避免了上下文切换、锁竞争的性的性能消耗,保证命令执行的**原子性** > > 单线程下能保证效率的原因 > 所有操作基于**内存**,CPU直接通过总线访问内存,无协议、磁盘定位等开销; > 核心操作都是O(1)、O(lgN)的高效算法 如哈希、**[跳表](#跳表)**; > 采用非阻塞IO+事件驱动,避免网络、IO等待 * 事件驱动模型(Reactor) > redis将所有操作抽象为事件 > > * 文件事件:处理套接字的连接、读、写操作,依赖操作系统的**IO多路复用**,避免单线程阻塞 > * 事件事件:处理定时/周期任务 > > ```mermaid > graph TD > A[aeEventLoop 事件循环] --> B[文件事件File Event] > A --> C[时间事件Time Event] > B --> D[套接字操作连接读写] > C --> E[定时任务过期键清理持久化] > B --> F[aeFileEvent 事件处理器] > F --> G[aeAcceptHandler处理新连接] > F --> H[aeReadHandler处理读请求] > F --> I[aeWriteHandler处理写响应] > ``` #### 多路复用 > redis核心架构:命令执行是单线程的,写入场景的单线程有很多的有点,但是读取的场景并没有单线程的需求,当某个读请求卡住会因为这个读取而阻塞其他请求,所以我们想要保证单线程执行写入的同时,"多线程"同时监听多个客户端的套接字连接,全程不阻塞其他套接字的处理 > > 多路复用在网络IO层 | 多路复用函数 | 操作系统 | 核心特点 | 性能 | | ------------ | -------------------------- | ----------------------------------------------------- | ---------------------------------------------- | | epoll | Linux(2.6 及以上) | 事件驱动、高效、支持海量文件描述符(万级以上) | 最高(Redis 首选,生产环境主流) | | kqueue | macOS、FreeBSD | 功能与 `epoll` 类似,事件驱动、高效 | 次高(类 Unix 系统的最优选择) | | select | 所有操作系统(兼容性最好) | 轮询模式、低效、支持的文件描述符数量有限(默认 1024) | 最低(仅用于兼容老旧系统,**不推荐高并发场景** | **多路复用相当于队列?将请求排队,先把请求存起来不阻塞他们?** | 对比维度 | Redis多路复用 | 消息队列 | | ------------ | ------------------------------------------------ | ------------------------------------- | | 工作层级 | **网络 IO 层**(操作系统 / Redis 底层) | 业务逻辑层(应用层) | | 处理对象 | 未完成 IO 的请求(数据未传输) | 已完成 IO 的请求(数据已到达服务端) | | 核心目标 | 解决单线程 IO 阻塞,提升**连接并发能力** | 解决业务并发冲突,保证**执行一致性** | | 排队逻辑 | 无显式排队,仅处理「就绪的请求」,无序但不阻塞 | 显式排队,所有请求按序执行,严格串行 | | 是否产生延迟 | 几乎无延迟(仅唤醒 / 处理就绪 IO) | 有明显延迟(请求入队等待消费) | | 一依赖 | 操作系统原生支持(epoll/kqueue),Redis 封装使用 | 独立的中间件,需单独部署维护 | | 与redis关系 | Redis**底层核心机制**,必须依赖 | 与 Redis 无关,是业务层的并发控制方案 | 多路复用并不是让请求排队,而是请求IO就绪了自己来找我,**多路复用是不让IO卡线程,消息队列是不让业务卡业务** **医院场景示例** > ### 多路复用:医院的「大门分诊台」(网络 IO 层) > > - **工作对象**:刚到医院门口、还没进入诊疗区的病人(还未完成网络 IO 的请求,数据还没传到 Redis); > - **核心工作**:判断「哪个病人已经走到分诊台(IO 就绪)」,让他进入诊疗区,**避免工作人员跑到大门口挨个等病人(阻塞 IO)**; > - **排队逻辑**:病人不用排队,只要走到分诊台(IO 就绪),就会被依次接待,未到的病人不会占用工作人员时间; > - **核心目的**:让工作人员能高效接待「同时到达的大量病人」,不被某个走得慢的病人堵在大门口。 > > ### 消息队列:医院的「诊疗区叫号机」(业务逻辑层) > > - **工作对象**:已经进入诊疗区、挂完号等待看病的病人(已完成网络 IO,请求已经到达 Redis / 业务服务端); > - **核心工作**:让病人按号排队,**避免多个病人同时挤到医生诊室(业务并发冲突)**; > - **排队逻辑**:病人必须按顺序排队,叫到号才能进入诊室执行业务(看病),全程串行; > - **核心目的**:让医生能有序处理病人,避免并发冲突,保证业务执行的一致性。 > > ### 二者的配合(如果医院同时有分诊台 + 叫号机) > > 病人先经过**分诊台(多路复用)** 进入诊疗区,再通过**叫号机(消息队列)** 排队看病,二者分工明确,缺一不可,这也是实际生产中「Redis 多路复用 + 业务层 MQ」的真实配合逻辑。 ## Redis高可用 > Reid部署方案的演进,核心是为了解决单点Redis的两个核心问题: > > * 可用性问题:单点redis可靠性差,宕机后服务不可用 > * 扩展性问题:单节点的内存QPS瓶颈,无法支撑大规模业务的高并发、大数据量需求 ### 持久化机制 Redis的持久化核心是将**内存写入磁盘**,宕机时避免数据丢失 #### RDB快照持久化 **原理** 1. 主线程`fork`子进程(写实复制[COW](#COW)),子线程遍历内存数据,形成二进制RDB文件 2. 主线程继续处理请求,修改数据会单独 复制一份副本,不影响子进程快照 **同步策略:触发** 手动`save/bgsave`、**配置定时策略**、主从复制主节点自动触发 **优缺点** * 优点 1. 文件体积小,相对AOF文件RDB的二进制压缩文件小很多 2. RDB是数据的完整快照,只需要加载RDB到内存,和AOF相比无需 执行命令重放,恢复速度远快于AOF 3. 快照由子进程完成,父进程只在`fork`期间短暂阻塞,适合高并发 4. RDB完整快照适合 定期备份场景、数据归档 * 缺点 1. RDB是定时快照,快照期间宕机会丢失两个快照间隙的数据 2. fork阻塞问题:当写入数据量大的时候,`fork`子进程耗时越长,阻塞时间越长,影响redis可用性 > RDB因其原理导致只适合用于对恢复速度要求高 ,数据丢失容忍度高的场景。如定时备份、容灾等场景 #### AOF追加日志 **原理** 1. 不保存数据本身,记录所有写命令到AOF文件(类似MySQL binlog的`statement`模式),重启时重放AOF存的写命令恢复数据 2. AOF重写:`fork`子进程遍历内存数据,生成最终状态命令集,多次INCR合并压缩文件体积 **核心同步刷盘策略** * always:每次写命令同步刷盘,最安全性能,IO开销巨大redis性能急剧下降一般用于数据持久性要求极高的场景如金融交易、核心账务数据 * everysec:每秒刷盘,平衡性能和可靠性,最多丢掉1s数据 * no:由操作系统决定刷盘时机,性能最好,但是可靠性差 **优缺点** * 优点 1. 安全性可控,`always`模式下可以实现数据领丢失,可靠性远高于RDB 2. 数据恢复完整,AOF记录所有写命令,重启时重放可完整恢复数据 3. 文件可读性高:AOF文件是明文redis敏玲,可直接查看,便于排查数据问题 4. 无fork阻塞风险,追加命令时无需`fork`子进程,仅在AOF重写是fork,对主进程阻塞影响小于 RDB * 缺点 1. 文件体积大,存的是全部命令追加,文件远大于RDB,占用更多磁盘空间(**[AOF重写机制](#AOF重写机制)**) ##### AOF重写机制 为避免对同一条记录多次SET情况导致AOF文件爆炸,优化出一个AOF重写机制(**默认不开启**,也就是会记录所有SET),最终只保留该数据的最新一条有效SET,丢弃冗余SET. AOF重写机制并不是**修改**原AOF文件,而是生成一份**全新的精简AOF文件**流程如下: 1. Redis主进程`fork`一个子进程,负责生成新的AOF 2. 子进程遍历Redis中所有key,为每个key生成最终的有效命令,写入新AOF 3. 在子进程中生成新AOF期间,主进程继续处理正常写请求,同时 将这些新的命令追加到**AOF重写缓冲区** 4. 子进程完成新AOF文件后,主进程会将AOF重写缓冲区所有命令追加到新AOF中 1. 最后用新AOF替代旧AOF 除此之外AOF重写还有两个重要优化: * 合并批量命令,如对一个列表执行100次LPUSH,重写会合并成一条LPUSH命令 * 忽略无效命令:对不存在的key执行DEL或对非列表执行LPUSH等无效 命令会被直接忽略过滤 **触发方式** * 手动触发 ```bash 127.0.0.1:6379> BGREWRITEAOF Background append only file rewriting started ``` * 自动触发 修改配置文件,当增长率100%时,redis自动执行`BGREWRITEAOF` ```xml # AOF 文件最小重写大小(默认 64MB),小于该大小不会触发重写 auto-aof-rewrite-min-size 64mb # AOF 文件增长率(默认 100%),即当前 AOF 文件大小 ÷ 上一次重写后的 AOF 文件大小 ≥ 100% 时触发 auto-aof-rewrite-percentage 100 ``` #### 混合持久化 **绝大多数生产环境优选** > 由于RDB和AOF各自的短板,redis4.0版本引入了**混合持久化**,结合RDB和AOF的优点: > AOF文件的前半部分是RDB格式的完整快照,后半部分是AOF的增量写命令。 > > 在这种模式下,redis重启先加载RDB格式完整快照恢复大部分数据,再重放AOF格式的增量命令,兼顾了RDB的快速恢复和AOF的数据完整性 **开启混合配置** ```xml # 先开启 AOF(混合持久化依赖 AOF) appendonly yes # 开启混合持久化(Redis 4.0+ 支持,默认 yes,推荐开启) aof-use-rdb-preamble yes # 其他配置(沿用 AOF 和 RDB 的核心配置) appendfsync everysec auto-aof-rewrite-percentage 100 auto-aof-rewrite-min-size 64mb ``` **核心流程** 混合持久化的核心是AOF重写成RDB+AOF混合格式的文件 1. Redis 执行 `BGREWRITEAOF` 命令,主进程 `fork()` 子进程。 2. 子进程遍历 Redis 内存中的所有数据,将数据以 RDB 格式写入临时文件(前半部分)。 3. 父进程将重写期间接收的写命令,追加到「AOF 重写缓冲区」。 4. 子进程完成 RDB 格式数据写入后,通知父进程。 5. 父进程将「AOF 重写缓冲区」中的命令,以 AOF 格式追加到临时文件末尾(后半部分)。 6. 父进程用临时混合文件替换原有的 AOF 文件,重写完成。 ### 高可用方案 #### 主从架构 > 主从架构是**最基础**的高可用和数据备份方案,采用一主多从结构,核心是**数据复制**,主节点处理所有请求(也可以做**读写分离**只负责写入),从节点 通过复制主节点数据形成副本,实现数据备份和分担主节点的读压力 > > 纯主从架构中,主节点宕机redis服务会处于可读不可写状态,直到主节点恢复 * 主节点:核心节点,处理所有`SET/HSET`等命令,同时接受从节点复制请求,同步数据给从节点 * 从节点:只读节点,仅复制主节点,提供读服务,分担主节点读压力 **核心工作流程** * 全量复制(首次同步/大故障后同步) 1. 从节点向主节点发送同步请求 2. 主节点接收请求后,执行`bgsave`, `fork`子进程复制RDB快照 ,同时将快照创建期间接受的写命令缓存到**复制积压缓冲区**(repl_backlog_buffer) 3. 从节点获取到RDB文件,加载RDB文件 4. 主节点将复制积压缓冲区的增量写命令发送给 从节点 5. 从节点执行增量命令 * 增量同步 主节点后续执行的所有写命令都会通过复制积压缓冲区(repl_backlog_buffer),试试发送给从节点 * 主节点每执行一个写,就将命令追加到复制积压缓冲区 * 从节点定期向主节点发送心跳,同时携带同步的偏移量 * 主节点根据偏移量,将复制积压缓冲区中未同步命令发送给从节点 * 从节点 执行,保持和主节点的数据一致性,实现增量同步 > **级联复制**,主从复制在一主多从情况下,为了缓解主节点的复制压力,可以让从节点作为其他从节点的复制结点,形成主->从->从的链式结构 > ##### 复制积压缓冲区和AOF的区别 复制积压缓冲区是专服务于**主从同步**的,AOF是服务于**redis磁盘持久化**的,当开启混合持久化模式时同步依然是走先同步AOF(RDB+AOF),之后在同步复制积压缓冲区给从库(即便AOF中可能和复制积压缓冲区中有重复的部分),实现同步。 **整个同步流程** > **阶段 1**:从库发起同步请求,主库初始化同步 > > 1. 从库执行`slaveof 主库IP 端口`,向主库发送**同步请求**,携带自身标识; > 2. 主库接收到请求后,标记该从库为「待同步节点」,**初始化复制积压缓冲区**(若未初始化),同时记录「主库运行 ID(runid)」和「当前主库偏移量(master_repl_offset=0)」。 > > 阶段 2:主库 fork 子进程,生成「RDB+AOF 混合文件」(全量数据准备) > > 1. 主库执行`fork`系统调用创建子进程(利用 COW 机制,不阻塞主进程),**子进程负责遍历主库内存全量数据,生成 RDB 二进制快照**; > 2. 主进程继续处理客户端写命令,此时会做3 个关键操作(核心:同一份命令多端写入): > - 写入**AOF 缓冲区**(最终刷盘到混合格式的 AOF 文件,持久化落地); > - 写入**复制积压缓冲区**(内存环形缓冲区,供主从同步使用); > - 写入**复制客户端缓冲区**(实时推送给从库,若从库还未准备好,先暂存); > 3. 子进程生成 RDB 完成后,主库会将**fork 期间主进程产生的所有增量写命令**,以**AOF 明文格式**拼接在 RDB 文件后,生成 **「RDB 全量 + AOF 增量」的混合文件 **(这就是混合持久化的产物,和 AOF 重写的文件格式完全一致)。 > > **阶段 3**:主库传输混合文件,从库加载全量数据(全量同步核心) > > 1. 主库通过 TCP 长连接,将**混合文件完整传输给从库**; > > 2. 从库接收混合文件后, > > 先清空自身内存数据 > > ,执行两步加载: > > - 第一步:加载**混合文件的 RDB 部分**,快速恢复主库 fork 瞬间的全量数据(RDB 加载速度远快于纯 AOF); > - 第二步:加载**混合文件的 AOF 部分**,重放主库 fork 期间的增量命令,恢复到主库「当前最新数据状态」; > > > > 3. 加载完成后,从库记录**主库 runid**和**自身偏移量(slave_repl_offset)**,并向主库发送「加载完成确认」。 > > **阶段 4**:进入常态增量同步,依赖复制积压缓冲区(核心阶段,长期运行) > > 这是主从同步的**常态阶段**,全量同步完成后永久运行,**复制积压缓冲区是核心载体**,AOF 仅做自身持久化,流程如下: > > 1. 主库处理客户端 > > 任意写命令 > > (SET/HSET/DEL 等),执行后做 > > 3 个必选操作: > > - ✅ 更新自身内存数据; > - ✅ 将命令写入**AOF 缓冲区**(按混合格式刷盘,保障主库自身宕机不丢数据); > - ✅ 将命令写入**复制积压缓冲区**,同时**主库偏移量(master_repl_offset)自增**(每 1 字节命令 + 1); > > 2. 主库通过长连接,**将该写命令实时推送给从库**; > > 3. 从库接收命令后,**立即在本地执行**,更新自身内存数据,同时**从库偏移量(slave_repl_offset)自增**(与主库偏移量保持一致); > > 4. 从库以**1 秒为间隔**,向主库发送**心跳包(PING)**,心跳包中携带**自身当前偏移量**; > > 5. 主库接收心跳包后, > > 对比主从偏移量: > > - 若两者一致:回复 PONG,同步状态正常; > - 若从库偏移量 < 主库偏移量:说明从库漏同步了命令,主库从**复制积压缓冲区**中提取「从库偏移量→主库偏移量」之间的所有增量命令,推送给从库,从库执行后补全偏移量。 > > > > **阶段 5**:短暂断连后恢复,走「部分重同步」(复制积压缓冲区的核心价值) > > 若主从网络短暂波动导致断连,**只要复制积压缓冲区未被覆盖**,就不会触发全量同步,流程如下: > > 1. 主从断连期间,主库继续处理写命令,**正常写入 AOF 和复制积压缓冲区**,主库偏移量持续自增; > 2. 从库重连主库后,向主库发送**重同步请求**,携带「之前记录的主库 runid」+「自身断连前的偏移量」; > 3. 主库验证: > - 若**runid 一致**(主库未重启)+ **从库偏移量在复制积压缓冲区的有效范围内**(未被新命令覆盖):触发**部分重同步**; > 4. 主库从复制积压缓冲区中,提取「从库偏移量到当前主库偏移量」的所有增量命令,一次性推送给从库; > 5. 从库执行所有增量命令,更新自身偏移量,**快速恢复与主库的偏移量一致**,回到「阶段 4 的常态增量同步」。 > > **阶段 6**:极端情况触发「全量重同步」(兜底机制) > > 若从库断连时间过长,**复制积压缓冲区中的增量命令被新命令覆盖**,或主库重启(runid 变化),主库会拒绝部分重同步,**重新回到阶段 2**,再次生成「RDB+AOF 混合文件」,走全量同步流程,完成后回到阶段 4。 | 对比维度 | 复制积压缓冲区 | AOF | | -------- | ------------------------------------------------------------ | ------------------------------------------------------------ | | 核心用途 | 服务于「**主从增量同步 / 部分重同步**」,仅用于主从之间的数据补全 | 服务于「Redis 数据持久化」,防止 Redis 宕机后内存数据丢失,用于重启恢复数据 | | 存储形式 | 「内存级」环形缓冲区,数据存储在内存中,断电即失 | 「磁盘级」日志文件,数据存储在磁盘上,断电后数据不会丢失 | | 存储内容 | 仅存储「最新的写命令」(二进制格式,精简),仅保留近期命令,用于补全从库同步缺口 | (未开启混合持久化)存储「所有写命令」(明文 Redis 命令格式),按执行顺序完整追加,用于完整恢复内存数据 | | 生命周期 | 随Redis主进程启动创建,关闭而销毁;<br />环形特性,写满后会覆盖旧命令;<br />从库长期断连,主库会释放对应的缓冲区资源; | 随Redis启动(开启AOF),文件永久保存在磁盘;<br />无限追加;<br /> | | 作用对象 | 仅作用域主从同步,与客户端无关 | 仅作用域redis本身,用于redis持久化,与主从架构无关 | | 配置参数 | 1. repl-backlog-size:缓冲区大小<br />2.repl-bakclog-ttl:断连后缓冲区保留时间 | 1. appendonly yes:开启AOF<br />2. appendfsync:刷盘策略<br />3. aotu-aof-rewrite-*:自动重写配置 | ##### **增量/全量复制判断逻辑,缓冲区实现原理** Redis主从复制是根据**复制积压缓冲区**的偏移量来判断**增量复制**和**全量复制**的。 **核心结论** 1. 增量复制的前提:从节点的偏移量在主节点复制积压缓冲区有效范围内,切主从节点的ID匹配,此时触发**增量复制** 2. 全量复制触发场景 * 从节点首次复制 * 主从运行ID不匹配(主节点重启、故障恢复后runid会变更) * 从节点的偏移量已不在主节点的复制积压缓冲区(指针被覆盖) **复制积压缓冲区原理** 复制积压缓冲区的底层是一个**固定大小**的连续字节**环形**数组。 包含数据主体和3个管理标记,3个标记如下 * 主库偏移量,标记主库当前写入进度 * 起始偏移量,标记缓冲区最久的数据,用于判定增量同步可行性 * 缓冲区长度,管理环形覆盖逻辑,限定缓冲区最大内存 ##### 异常场景实例 纯主从架构,当主节点宕机会发生什么? > 主节点宕机,redis进入 可读不可写状态 > > 1. 场景一:修复主节点。主节点刷新**runid**,期间从节点会携带两个信息(旧runid和偏移量)持续尝试重连主节点,主节点验证runid不匹配,直接触发**全量复制**。即主从架构 **主节点宕机重启后 所有从节点都会执行一次全量复制** 会造成主节点网络带宽被大量占用(向多个从节点传输RDB快照,可以通过**级联同步规避**);从节点服务阻塞直到完成同步 > > 2. 场景二:等不了修复了,需要手动选择一个从节点作为新的主节点。 > 为了业务不中断,选择一个从节点作为新的主节点。 > 从节点如何变成主节点: > > 1. 选择一个健康、数据同步完整度最高的从节点,解除从节点身份 > > ```bash > # 核心命令:取消从节点身份,变为可写主节点 > 127.0.0.1:6380> SLAVEOF NO ONE > OK > > # 验证:查看节点角色,确认已变为master(可写) > 127.0.0.1:6380> INFO replication > # Replication > role:master # 角色为master > connected_slaves:0 # 暂无从节点 > master_runid:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # 新生成的runid(晋升后自动生成) > ``` > > 2. 逐个登录其他从节点,设置主节点指向 > > ```bash > # 核心命令:指向新主节点的IP和端口 > 127.0.0.1:6381> SLAVEOF 192.168.1.101 6380 # 新主节点IP:端口 > OK > > # 若新主节点有密码认证,需配置认证密码 > 127.0.0.1:6381> CONFIG SET masterauth 123456 > OK > ``` > > 3. 主从全量同步,切换业务读写地址,完成切换 > > 那么当原主节点恢复时会发生什么? > > 1. 原主节点恢复后任然保持主节点和原有数据,形成**双主并存并互不感知的情形** > 2. 数据必然存在不一致:原主节点宕机前未同步给从节点的偏移量、新主节点在宕机期间接收到的新写入数据,这两部分数据互为缺失,而**Redis无自动合并能力** > 3. 这种局面会导致业务读写混乱、数据丢失/错乱,**必须人工介入处理** > > 处置方式:核心是保留新主节点,将原主节点降级或直接废弃,处置步骤 > > 1. 隔离原主节点,防止后续新的写入 > 2. 处理数据差异:如果原主节点存储的非核心数据可以直接废弃;如果是核心数据就只能通过**人工甄别**迁移到新主节点上(**高危操作**) > 3. 原主节点降级或废弃 > 4. 验证业务状态 ##### 主从架构的缺陷 1. 主节点宕机后无自动故障转移,依赖人工介入 2. 主节点宕机恢复后的双主冲突,数据不一致且不具备合并能力 3. 主节点重启或新选举主节点,必然触发全量复制(runid的更新),引发性能风暴 4. 单主节点性能瓶颈、可靠性健壮性不佳 5. 无统一的节点状态感知监控,各节点的状态(同步进度、偏移量、节点存活)相互独立,无法全局感知管理 #### 哨兵模式 > 哨兵模式是Redis官方提供的企业级高可用方案,基于**主从架构**扩展,核心是 **自动化故障检测和故障转移** ```mermaid flowchart TB subgraph 哨兵集群 A[sentinel1]---B[sentinel2] end 哨兵集群 ---> 数据节点 subgraph 数据节点 C[master] --> D[slave1] --> E[slave2] end 数据节点 --> 客户端 ``` **哨兵节点** 1. 监控:实时监控数据节点健康状态,定时发送心跳请求 2. 通知:当节点出现故障时候,通过配置的脚本通知运维人员 3. 故障转移:主节点宕机后,自动选举从节点晋升为主节点,更新其他从节点和客户端配置 4. 配置提供者:客户端通过哨兵获取当前主节点地址,无需硬编码主节点IP:PORT 5. 哨兵节点通常部署奇数个,避免选举脑裂,单哨兵存在故障风险 **启动哨兵节点** ```bash # 格式:redis-sentinel <哨兵配置文件路径> redis-sentinel /usr/local/redis/conf/sentinel.conf # 或等价于(以哨兵模式启动 Redis) redis-server /usr/local/redis/conf/sentinel.conf --sentinel ``` ##### 工作原理 * 监听 1. 哨兵节点启动后,通过配置文件指定监控的主节点 2. 定期PING,判断主节点存活 3. 主节点超时未响应30s,尚明将主节点标记为`主观下线` 4. 集群哨兵间通信,交换割接点对主节点状态判断 5. 超过半数哨兵判断主观下线,则将主节点标记为`客观下线`,触发故障转移流程 * 故障转移 1. 选举master哨兵:集群 通过Raft选举出`master sentinel`,仅由MS执行故障转移 2. 选举新主节点:`mater sentinel`遍历数据节点,根据优先级、复制偏移量、运行规则等,选举 出最优从节点未主节点 3. 晋升主节点:`master sentiel`向选中的节点发送`SLAVEOF NO ONE` 晋升为主节点 4. 更新其他从节点:`master sentiel`向其他数据节点发送`SLAVE OF newip:port`,设置新主节点(**会全量同步?**) 5. 哨兵集群更新配置将心的主节点作为 后续监控主节点 6. 通知客户端:通过配置脚本通知客户端更新新主节点地址 7. 旧主节点恢复(可选):旧主节点宕机恢复后,会被哨兵自动配置为新主节点的从节点,复制新主节点数据 **核心配置(sentinel.conf)** ```bash # 格式:sentinel monitor <监控名称> <主节点IP> <主节点端口> <法定票数> # 含义:监控一个名为 mymaster 的主节点,法定票数为 2(需至少 2 个哨兵认为主节点下线,才触发故障转移) sentinel monitor mymaster 192.168.1.100 6379 2 # 主节点密码(若主节点有认证,必须配置) sentinel auth-pass mymaster 123456 # 主节点主观下线超时时间(默认 30000 毫秒 = 30 秒) sentinel down-after-milliseconds mymaster 30000 # 故障转移超时时间(默认 180000 毫秒 = 3 分钟) sentinel failover-timeout mymaster 180000 # 故障转移时,同时同步的从节点数量(默认 1,减少对新主节点的压力) sentinel parallel-syncs mymaster 1 # 故障通知脚本(可选,节点故障时执行,用于告警) sentinel notification-script mymaster /usr/local/redis/sentinel/notify.sh ``` ##### 优劣分析 **优点** * 自动化故障转移 * 基于主从架构 * 高可靠:哨兵集群 * 无需修改客户端配置(硬编码) **缺点** * 仅解决了高可用,未解决扩展性,哨兵模式依旧是一主多从,主节点性能瓶颈依然存在 * 写操作几种在单主节点,无法实现写入的负载均衡 * 故障转移期间,Redis服务可能有短暂的抖动,对实时性高的业务会有影响 * 配置和运维复杂度高于 主从架构 #### 集群部署 RedisCluster是Redis3.0+ 官方提供的**分布式集群方案**,核心是**分片存储**+**分布式高可用** ```mermaid flowchart TB subgraph Redis集群 A[master1:0-5460]---B[master2:5461-10922] --- C[master3:10923-16383] A --> D[slave1] B --> E[slave2] C --> F[slave3] end Redis集群 --> 客户端 ``` **核心概念** 集群采用五中心架构,所有节点地位平等 * 槽位:将数据空间划分为16384个槽位,每个键值对通过`CRC16(key) % 16384`计算,映射到其中一个槽位 * 主节点与槽位:每个主节点负责一部分槽位 * 主从节点对应:每个主节点至少配备1个从节点 * 集群通信:所有结点通过`Gossip`协议定期交换集群信息(节点状态、槽位分配、故障信息),保障集群的一致性 **节点要求** * 集群节点最少为3个主节点+3个从节点 * 每个节点需开启集群模式 ##### 工作原理 1. 数据分片与存储 1. 客户端发送 `SET key value` 命令,先通过 `CRC16(key) % 16384` 计算出 `key` 对应的槽位(如槽位 1000)。 2. 客户端(或节点)查询集群槽位分配信息,找到负责槽位 1000 的主节点(如主节点 1)。 3. 客户端将命令发送到该主节点,主节点执行命令,将数据存储在本地。 4. 主节点将数据同步给自身的从节点,形成数据副本。 2. 故障检测与故障转移 1. 集群中每个节点定期向其他节点发送 `PING` 命令,判断节点是否存活。 2. 若某个主节点在超时时间内(默认 15 秒)未返回响应,发送 `PING` 的节点将其标记为「主观下线」。 3. 若集群中超过半数的主节点都将该主节点标记为「主观下线」,则将其标记为「客观下线」。 4. 该主节点的从节点通过「Raft 算法」选举出一个新主节点,晋升为主节点,接管原主节点的槽位。 5. 集群通过 Gossip 协议更新槽位分配信息,客户端后续请求将发送到新主节点。 3. 客户端重定向 若客户端将命令发送到了不负责对应槽位的节点,该节点会返回 `MOVED` 重定向响应,告知客户端正确的主节点地址,客户端后续将命令发送到正确节点。 **核心配置**(redis.conf) ```conf # 开启 Redis 集群模式(默认 no,改为 yes) cluster-enabled yes # 集群配置文件名称(自动生成,无需手动编辑,记录集群槽位、节点信息等) cluster-config-file nodes-6379.conf # 集群节点超时时间(默认 15000 毫秒 = 15 秒,节点超时未响应则标记为下线) cluster-node-timeout 15000 # 开启持久化(推荐,避免集群重启后数据丢失) appendonly yes appendfsync everysec # 其他基础配置(端口、密码等) port 6379 requirepass 123456 masterauth 123456 ``` **集群的创建** Redis提供了`redis-cli --cluster`可快速搭建集群 1. 准备6个redis节点,分别配置不同端口,开启集群模式 2. 启动6个节点 3. 执行集群搭建命令,自动分配槽位和主从关系 ```bash # 格式:redis-cli --cluster create <节点1IP:端口> <节点2IP:端口> ... --cluster-replicas 1 # --cluster-replicas 1:表示每个主节点对应 1 个从节点 redis-cli --cluster create 127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384 --cluster-replicas 1 -a 123456 ``` 4. 验证集群状态 ```bash redis-cli -c -p 6379 -a 123456 127.0.0.1:6379> CLUSTER INFO # 查看集群整体信息 127.0.0.1:6379> CLUSTER NODES # 查看集群所有节点信息 ``` ##### 优劣分析 **优点** 1. 水平扩展:通过数据分片,将数据分散到多个主节点,增加主节点扩展内存和QPS 2. 分布式高可用:每个节点都有从节点,无需哨兵,主从自行切换 3. 无中心架构:所有结点地位平等,无单点故障风险 4. 支持读写分离:从节点可以提供读服务,分担主节点读压力 **缺点** 1. 配置和运维复杂度高:集群的搭建、扩缩容、故障排查难度远高于主从和哨兵 2. 不支持跨槽位操作:跨槽位命令需要客户端拆分命令分别执行 3. 数据迁移有开销:扩缩容期间会占用网络带宽和节点资源,会影响服务性能 4. 不分功能受限:如事务、Lua脚本等在集群模式下有一定限制 ##### 异常场景 1. 因为集群部署是数据分片的,那么范围查询跨分片时会怎么样?如何解决 范围查询在单节点内不受影响; 范围查询超过单节点会直接失效或额外处理; > 解决方案: > > 1. 使用哈希标签,强制相关key落入同槽位 > 2. 客户端/**中间件**(Twemproxy、Codis):分布式场景下通用解决方案,客户端或中间件一次连接集群所有节点,在每个节点上执行`SCAN`,最后将所有结果聚合返回完整数据 > 3. 业务侧重新设计数据结构(**业务侧让步**) 2. 如果分片节点和它相关的从节点都故障宕机了,会如何?只是分片主节点宕机 主从切换过程中是不是会丢数据? 分片节点和相关的从节点同时故障宕机,会导致该分片相关的所有业务中断,只能人工介入修复;分片节点宕机,主从切换必然会丢失一部分未同步的数据,**复制积压缓冲区原理**中的数据 **CRC算法** ##### Gossip协议 ##### 读写分离中间件 Codis、Twemproxy **异地多活** ##### 分布式存储 MinIO、Ceph #### 云厂商Redis托管 花钱买服务,相当于把Redis这块业务卖给云厂商,由他们去负责细节的实现,只管使用(**财力雄厚方案**) #### 心跳模式 > 节点间的心跳是双向、定时的,核心目的是检测**节点的存活** 和 **节点状态/偏移量**,不同架构下的心跳略有差异,单核心一致 1. 主从架构下的心跳模式 从节点主动发起,主节点被动响应 slave -> master,发送PING包,携带3个信息(自身节点ID+已同步的偏移量+主节点runid) master响应,收到PING后返回PONG包(包含主节点当前的偏移量+自身状态) slave接受到PONG后,判断主节点存活,更新自身记录的主节点偏移量,为后续增量同步做准备 若PING超时(默认60s),判定主节点宕机,停止增量同步,进入重连流程 主节点也会主动发送增量数据包,不属于心跳,是数据同步,和心跳并行互不干扰 2. 哨兵模式下的心跳模式 哨兵模式的心跳由哨兵节点(sentinel)发起,数据节点(主/从)被动响应,核心是为了监控数据节点的健康状态 sentinel -> master/slave,发送PING master/slave 响应PONG(自身状态+是否可读+是否主节点) sentinel在30s内未收到PONG响应,则将该节点标记为主观下线 哨兵集群间通过心跳同步节点状态,超过半数哨兵未收到PONG则标记为客观下线,触发故障转移 * ## Redis异常 ### 缓存异常 | 异常名称 | 描述 | | -------- | ------------------------------------------------------------ | | 缓存穿透 | **高危!**通常是恶意攻击。靶数据不存在,缓存、数据库中均没有 | | 缓存击穿 | 某热点key过期瞬间,大量请求打到数据库。常见于秒杀、抢车票情形 | | 缓存雪崩 | 大批量key同时过期,或redis服务宕机,请求全打到数据库 | #### 缓存穿透、击穿、雪崩的应对策略 **穿透** 1. 入口校验 参数和方法性校验,如数据类型、数值大小(ID < 0)等直接返回 2. **[布隆过滤器](#布隆过滤器)** ```java public User getUser(Long userId) { String key = "user:" + userId; // 1. 先查布隆过滤器 if (!bloomFilter.mightContain("user_bloom", String.valueOf(userId))) { return null; // 布隆过滤器说不存在,直接返回 } // 2. 查缓存 User user = cache.get(key); if (user != null) { return user; } // 3. 查数据库 user = userDao.findById(userId); if (user != null) { cache.set(key, user); } return user; } ``` 3. 缓存空值 已经打到数据库返回`null`的数据也在内存中缓存一个空标识,设置较短过期时间,防止同一个不存在的key反复穿透 **击穿** 1. 互斥锁:缓存失效时加锁限制只让一个请求数据库重建缓存,其他请求等待。分布式环境用redis `SETNX`实现[分布式锁](#分布式锁) 2. 热点数据定时刷新 **雪崩** 1. 大量key同时过期情形:过期时间随机分布,避免集体失效 2. redis宕机情形 * Redis[高可用](#高可用方案)部署:主从架构+哨兵模式、集群部署 * 本地缓存:本地缓存**Caffeine/GuavaCache**,转移部分压力到JVM缓存 * 服务熔断:Sentinel/Hystrix,监听压力过大直接熔断,避免系统崩溃 * 缓存预热:系统启动主动加载热点数据到缓存,避免冷启动大量请求穿透 ### 一致性问题 双写一致性问题,即不同数源之间同步间隙产生的不一致情形 ### 布隆过滤器 布隆过滤器(BloomFilter)是一种**概率型**数据结构,不存在一定,存在不一定。 **原理** > 布隆过滤器由一个位数组和k个独立的哈希函数组成。添加元素通过k哈希 函数算出k个位置,置1;查询时计算同样k位置,全1则可能存在,存在1个0则一定不存在。**误判发生在不同元素哈希冲突时** > > 布隆过滤器**不支持删除**,只能新增。想要删除某个元素,只能把整个过滤器重建 > 位数组的某个位可能是多个元素共同设置的,导致其中一个删除时会将另一个误判为不存在,所以不支持删除 **适用场景**:大数量,允许小概率误判允许一定的不可靠性),只判断存在性 * 爬虫url去重 * 垃圾邮件过滤 * 推荐系统去重 * 分布式缓存 **RedisBloom模块详解** ```bash # 创建过滤器时指定误判率和预期容量 BF.RESERVE myfilter 0.001 10000000 # 误判率 0.1%,容量 1000 万 # 批量添加 BF.MADD myfilter item1 item2 item3 # 批量查询 BF.MEXISTS myfilter item1 item2 item3 # 查看过滤器信息 BF.INFO myfilter ``` **Java Redisson操作** ```java RBloomFilter<String> bloomFilter = redisson.getBloomFilter("user_bloom"); // 初始化,预期插入 5000 万,误判率 3% bloomFilter.tryInit(50000000L, 0.03); bloomFilter.add("user:10086"); boolean exists = bloomFilter.contains("user:10086"); ``` **误判率和参数选择** 误判率取决于三个因素:位数组大小m,哈希函数个数k,已插入元素数量n 理论上最优哈希函数个数 `k = (m/n) & ln2`,大约是0.7倍的位数组和元素数量之比 实际工程中一般这样估算: 1. 误判率1%,每个元素约10bit 2. 误判率0.1%,每个元素约15bit 3. 误判率0.01%,每个元素约20bit 降低误判率通过增大位数组,增加哈希函数来降低,但是**哈希冲突不可避免**无法降低误判率到0),其实是用**空间换可靠性** **布隆过滤器的变种** * counting bloom fileter:每个位置 不是0/1,而是计数器,可以删除,代价是空间翻好几倍 * cuckoo filter:支持删除,空叫效率和原版差不多,Facebook在用 * scalable bloom filter:支持动态扩容,元素超了自动加一层过滤 > RedisBloom 模块只支持cuckoo filter,用CF.ADD/CF.EXISTS操作 **Redis实现布隆过滤器的方式** * *位图手动实现* (**不推荐使用,自己实现自己选哈希、计算数组大小、写代码逻辑容易出错)** `SETBIT/GETBIT`自己管理哈希函数和**位数组** * 官方RedisBloom 由一个**位数组**和**k个哈希函数**组成 ### 倾斜 数据分布/访问负载不均,导致单点压力过大 ## 分布式锁和消息队列 ### 分布式锁 > 多台应用服务器、多Redis节点(集群/主从)下,并发更新缓存需要用**分布式锁**,保证跨应用、跨节点的并发安全 **基础方案**:SETNX + EXPIRE 分布式锁的基础实现,核心是利用SETNX的原子性,存在**缺陷** ```java // 加锁 public boolean tryLock(String lockKey, String requestId, int expireSeconds) { // 1. SETNX 加锁 Long result = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId); if (result != null && result == 1) { // 2. EXPIRE 设置过期时间,防止死锁(加锁成功后进程宕机,锁无法释放) redisTemplate.expire(lockKey, expireSeconds, TimeUnit.SECONDS); return true; } return false; } // 解锁 public void unlock(String lockKey, String requestId) { // 校验请求ID,防止误删其他线程的锁(重要) if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } // 缓存更新逻辑(伪代码) public void updateCache(String cacheKey, Object newData) { String lockKey = "lock:" + cacheKey; // 锁key与缓存key绑定 String requestId = UUID.randomUUID().toString(); // 唯一标识,用于解锁校验 try { // 尝试加锁,超时时间30秒 boolean locked = tryLock(lockKey, requestId, 30); if (!locked) { // 加锁失败,重试或直接返回(根据业务场景) return; } // 加锁成功,安全更新缓存 redisTemplate.opsForValue().set(cacheKey, newData); } finally { // 解锁,释放资源 unlock(lockKey, requestId); } } ``` > SETNX 和 EXPIRE是两个独立的命令,两者一起运行的时候没有原子性,存在SETNX 加锁成功后服务宕机EXPIRE无法执行,造成死锁 **优化方案** SET key value NX EX,原子加锁 redis支持`SET`命令组合参数,将加锁和设置 过期时间合并为一个原子命令,解决SETNX 和EXPIRE的缺陷 ```bash SET lock:user:1001 uuid-12345 NX EX 30 ``` ```java // 原子加锁(推荐) public boolean tryLockAtomic(String lockKey, String requestId, int expireSeconds) { // setIfAbsent 重载方法,直接实现 NX + EX 原子操作 Boolean result = redisTemplate.opsForValue().setIfAbsent( lockKey, requestId, expireSeconds, TimeUnit.SECONDS ); // 避免空指针(RedisTemplate 集群环境下可能返回 null) return Boolean.TRUE.equals(result); } // 原子解锁(必须用 Lua 脚本,保证「校验+删除」原子性) public void unlockAtomic(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"; redisTemplate.execute( new DefaultRedisScript<>(luaScript, Long.class), Collections.singletonList(lockKey), requestId ); } ``` **进阶方案**:Redisson分布式锁 #### Redisson **Redisson**,封装了完善的分布式锁(可重入、可阻塞、自动续期),支持单机、集群、哨兵等多种部署模式 * 自动实现了`SET NX EX`原子加锁 * 内置**WatchDog**机制,自动给未执行完的业务续期所时间,避免锁在业务执行期间过期 * 支持可重入锁、公平锁、读写锁等锁类型 * 解锁使用`Lua`脚本,保证原子性 **示例** ```java // 1. 注入 RedissonClient(提前配置好连接) @Autowired private RedissonClient redissonClient; // 2. 缓存更新加锁逻辑 public void updateCacheWithRedisson(String cacheKey, Object newData) { String lockKey = "lock:" + cacheKey; // 获取可重入锁 RLock lock = redissonClient.getLock(lockKey); try { // 尝试加锁,最多等待10秒,锁自动过期30秒 boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException("获取分布式锁失败,无法更新缓存"); } // 加锁成功,安全更新缓存 redisTemplate.opsForValue().set(cacheKey, newData); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("加锁过程被中断", e); } finally { // 解锁(只有当前线程持有锁时才会释放) if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } ``` **分布式锁的兜底**RedLock > **为了解决主从架构中主节点加锁成功后宕机,从节点未同步到锁信息而造成锁丢失的情形,抽象出一层RedLock(多台redis) 专用于 分布式锁的申请、释放** Redis主从模式,存在主节点加锁后,同步期间宕机导致锁丢失的问题 1. 建立多个单独的主节点Redis(**专注于分布式锁,不涉及业务数据,不需要同步**) 2. 加锁时对所有节点发送`SET NX EX`超过半数成功才认为加锁成功 3. 解锁时发送所有节点发起释放锁命令,超过半数认为删除锁成功 ```mermaid graph TD A[1.业务发起写入请求] --> B[2.客户端向RedLock申请分布式锁] B --> C[3.超过半数RedLock Redis加锁成功,返回客户端加锁成功] C --> D[4.执行业务redis写入操作] D --> E[5.业务写入结束,向RedLock申请释放分布式锁] E --> F[6.超过半数RedLock redis释放锁成功,返回客户端解锁成功] ``` #### 单机场景(本地锁) **synchronized/ReentrantLock** ### 消息队列 **给我一个选择分布式锁,不选消息队列的理由?** > 分布式锁这么繁琐,人生苦短我选MQ! 分布式锁和消息队列都是并发控制的核心方案,适用场景的核心差异在于请求**是否需要实时执行、能否接受串行化的性能损耗、业务是否试单节点操作** **消息队列(MQ)vs分布式锁** 在大多数生产环境,**消息队列**的优先级都高于分布式锁,以下环境优先使用MQ: 1. 非实时性要求并发更新场景,允许请求有短暂延迟的,如库存扣减、缓存更新、订单状态同步、积分变更 2. 高并发峰值场景,如秒杀、大促、活动引流,需要削峰填谷,避免请求直接打垮redis/MySQL 3. 单节点业务操作,业务操作之恶换手机一个核心资源,串行处理无冲突 4. 需要失败重试,数据可靠性要求高的 单体MQ不适合的场景:(**但是我可以联合分布式事务避免原子性问题**) 1. 强实时性要求:请求需要立即执行立即返回结果,不允许等待耗时的 2. 多资源组合,涉及多个资源、多个库,如同时更新余额、库存、订单状态,需要通过锁保证操作的原子性 3. 大量查询极少数写入场景,串行会 导致查询延迟过高,影响用户体验 4. 没有MQ部署的轻量化应用(**没有你用个屁**) | 维度 | 消息队列 | 分布式锁 | | -------- | ----------------------------------------------------- | ----------------------------------------------------------- | | 核心思路 | 排队串行,消除并发竞争 | 几所互斥,控制并发竞争 | | 实时性 | 低,排队等待消费存在延迟 | 高,拿到锁立即执行,无额外延迟 | | 性能 | 串行处理,但消费端**TPS**有限,可以通过消费端分片提升 | 并行处理,拿到锁的请求同时执行TPS高 | | 复杂度 | 简单,生产/消费端简单开发,无需处理锁逻辑 | 高,需要封装加解锁、处理锁丢失/死锁/续期,RedLock等复杂技术 | | 适用操作 | 单业务操作,支持串行处理 | 多业务组合操作(事务型操作),需要并行执行业务 | | 峰值处理 | 串行处理无惧峰值 | 高并发情形锁竞争激烈,易导致请求重试/超时 | | 失败恢复 | 天然支持(消息持久化、失败重试、死信队列) | 徐手动实现(获取锁失败重试、执行失败回滚) | | 部署成本 | 部署 MQ(RocketMQ/Kafka/RabbitMQ) | 需部署redis,主从、集群、RedLock等 | **总结** > 在符合MQ适用场景的前提下,MQ+事务的方案 整体是优于分布式锁方案的,MQ更稳定、更少踩坑、可维护性更强,是生产环境高并发核心业务的优选。如果一定要说一个分布式锁优于MQ+事务的场景,那么就是单纯的查询业务,极少的写入需求场景 #### 消息队列的缺陷 消息队列保证**串行执行**,不保证**执行结果的原子性**,原子性保障需要通过**数据库事务、分布式事务**等其他技术实现 > **消息队列无法保障执行结果的原子性的原因** > > 1. 消息队列只负责投递消息,不负责监督操作执行结果 > 消息队列的职责是: > 保证消息可靠投递不丢失、不重复; > 保证消息按顺序被消费串行执行; > 消息队列不关心消费端的内部操作及执行结果; > 2. 多操作原子性,一来底层资源的事务支持,与消息包无关 > 如更新用户余额和扣减库存的两个操作,本质是对数据库的操作,他们的原子性应该有存储的事务机制来保障,与MQ的投递无关 > 如果是同库操作,通过数据库本地事务保障执行的原子性 > 如果是不同库,通过**[分布式事务](#分布式事务)**保障执行的原子性 > 3. 消息重试会导致重复执行,破幻原子性 > MQ的失败重试机制当执行失败时会重新投递消息包 > * 假设操作1执行成功,操作2执行失败,MQ重新投递消息,会导致操作以被重复执行 > * 即使做了幂等性处理,也只能保证操作1不被重复执行,依然无法解决操作1成功操作2失败的中间状态,无法保障原子性 | 概念 | 核心含义 | 保障主体 | 失败场景 | | -------- | ---------------------------------------------- | -------- | ----------------------------- | | 串行执行 | 多个操作顺序执行,不被其他请求打断 | 消息队列 | 操作1执行成功,操作2执行失败 | | 原子性 | 多个操作构成一个整体,要么全部成功要么全部失败 | **事务** | 操作1成功,操作2失败回滚操作1 | #### 如何解决消息队列的缺陷(执行一致性问题) MQ+(分布式)事务,兼顾串行化和原子性 **场景** * 简单场景(单库) 1. 把操作1操作2打成一个消息包,发送到MQ 2. 消费端接受消息,开启数据库**本地事务** 3. 串行执行操作1、操作2 4. 执行成功COMMIT,向MQ返回ACK,消息处理成功 5. 任意操作失败,回滚事务`ROLLBACK`,不向MQ返回ACK,MQ会重新投递消息,直到成功或进入**死信** * 复杂场景(多库):MQ+分布式事务 1. 把操作1操作2打成一个消息包,发送到MQ 2. 消费端接收消息,开启**Seata全局事务** 3. 串行调用操作1操作2,每个服务内部开启**数据库本地事务** 4. Seata会自动记录操作前快照、操作后快照,实现自动回滚 5. 如果都执行成功,则全局 提交事务,MQ返回ACK 6. 如果任意操作失败,全局回滚 事务,不向MQ返回ACK,等待重新投递直到成功或进入死信 #### 分布式事务 Seata、TCC、SAGA ## 术语 ### COW copy-on-write 写时复制 是Linux系统的一种内存管理机制,Redis深度依赖COW实现 **RDB持久化**、**AOF重写**、**主从复制**等核心功能 COW**核心逻辑**:当多个进程共享同一块内存数据时,只有 当某个进程对数据执行**写入**操作时,才会为该进程复制一份新的内存副本,供其单独修改,未执行写入操作时仍共享内存数据 **Linux底层实现** > Linux通过页表、内存也管理内存,COW基于这两个核心结构实现 > > * 内存共享:父进程创建子进程(如RDB持久化`fork`子进程)时,系统不会立即复制父进程的所有内存数据,而是父子共享同一套内存,仅在页表中标记内存页为**只读** > * 写时触发:当父进程对共享内存页执行**写入**操作时,系统检测到只读内存被写入,立即为该内存页复制一份新的物理副本,更新父进程页表指向该副本,允许父进程修改副本,而子进程页表仍然指向只读的原始页 > * 独立修改:父子进程后续内存操作相互隔离,子进程始终能访问到`fork`创建瞬间的原始内存数据,父进程则操作新的内存副本 > > 疑问? 那么当父进程写入之后,子进程访问的还是原副本 什么时候会合并?会存在子进程读取到fork之前的数据,新的进程访问到父进程写入完成后的数据 导致两个进程间数据不一致的情况吗 #### **Redis为什么需要COW** Redis是单进程单线程内存数据库,所有正常的读写请求都在主进程处理,而**RDB持久化**、**AOF重写**、**主从同步**等操作,需要读取全量内存写入磁盘/同步从库 直接让主进程执行会有以下问题 1. 阻塞主进程:全量读、写会导致主进程无法处理正常请求,阻塞业务服务 2. 数据一致性:操作过程中如果主进程修改数据,会导致持久化/同步数据不完整、不一致 `fork`子进程+COW机制,解决了阻塞主进程和不一致问题(读快照) #### COW的隐患 高写入场景下,会带来内存占用飙升、磁盘I/O压力增大,是Redis运维中需要关注的重点 1. fork后,redis主进程大量写操作,会 触发大量内存也的COW复制,导致内存占用迅速飙升,内存不足会触发内存Swap,redis性能会急剧下降 2. fork子进程曹总,本身就会造成短暂阻塞,当redis占用大时,fork操作耗时越长,会短暂阻塞主进程 3. 子进程的磁盘I/O竞争,fork进程持久化/同步时,会 大量读写磁盘,会造成磁盘带宽占用急剧上升,印象redis主进程**AOF追加写**(AOF默认每秒刷盘),严重甚至会阻塞主进程 **针对以上隐患的优化** * 系统层面 1. 关闭透明大表 2. 保证足够的物理内存(预留内存) 3. 降低fork频率 * redis层面 1. 合理设置`maxmemory`,避免redis内存占用过高 2. 选择核实的内存淘汰策略,高写入场景使用`allkeys-lru`/`volatile-lru`等淘汰策略,及时释放无用内存 3. 主从架构优化:关闭主库的RDB/AOF操作,在从库上做持久化操作,主库只负责读写请求 4. 监控fork耗时:监听fork耗时,超过阈值告警 ### 跳表 跳表的本质是**多层链表**,底层链表保存所有元素,上层是下层的子表,通过分层索引查找优化。 Redis的跳表笔常规的跳表多一个**回退指针**、并且score允许重复 > 为了删除节点时可以快速定位 到前驱节点,不需要 重新遍历 **随机层级的概率算法** > 层数n: 0.25 ^ (n-1)*0.75 > > ```c > #define ZSKIPLIST_MAXLEVEL 32 > #define ZSKIPLIST_P 0.25 > > int zslRandomLevel(void) { > static const int threshold = ZSKIPLIST_P*RAND_MAX; > int level = 1; > while (random() < threshold) > level += 1; > return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL; > } > ``` **跳表和红黑树的比较** 相比较红黑树,多层链表 1. 实现更简单 2. 范围查询效率高 3. 并发友好,只需要锁相关节点,红黑树旋转会锁一片节点 4. 内存占用可控,跳表每个节点平均1.33个指针,红黑树每个节点3个指针 ### 红黑树 ![RBTree](https://pic.code-nav.cn/post_picture/1866300517913518082/6IjTKuJyxPgmEiJn.webp) 红黑树是**平衡二叉查找树** > 二叉查找树**BST** > > 左子树所有节点值 < 根节点值,右侧所有节点值 > 根节点值; > > 中序遍历可以得到有序序列 > > 理想情况下查询/插入/删除效率O(lgN),如果插入有序数据则会退化为单链表 红黑树是一种近似平衡的二叉查找树,不追求绝对平衡(左右树高差不超过1),通过严格颜色规则维护平衡 **特性** 1. 每个节点要么红色要么黑色 2. 根节点必须黑色 3. 所有叶子结点必须黑色 4. 红色结点子结点必须黑色 5. 从任意结点到其他所有叶子结点路径包含的黑色节点数相同 **核心操作**:自平衡操作 * 旋转:左旋、右旋,调整节点的子树结构,不改变二叉查找树的有序性 * 变色:修改节点的颜色,满足颜色规则 **性能** * 查询/插入/删除的平均和最坏时间复杂度均为O(lgN) * 旋转操作的次数少,维护成本低于**AVL树** > AVL树严格平衡,每个节点维护平衡因子,旋转次数更多,适合查询效率要求极高,吸入操作少的场景(如数据库索引辅助结构) ### SDS ### TPS ## 版本演变 ### Redis4.0 引入混合持久化 结合RDB和AOF,RDB存储全量数据,AOF存储增量命令。兼顾RDB的快速回复和AOF的数据完整性 ### Redis6.0+ 多线程IO优化 核心命令执行仍单线程,仅将**网络读取**和**响应写入**拆分为多线程,解决单线程高并发情形下**网络带宽**的瓶颈

Day9 Redis补充

### 过期键缓存清除策略 - 清除策略 - 惰性删除(被动)当读写一个已经过期的key时,会直接删除掉这个key,然后返回空,但如果一个key一直没有访问,就会一直存在内存里 - 定期删除(主动) redis每隔100ms触发一次定时任务,随机抽取一批设置了过期时间的key,发现已过期就直接删掉,未过期的保留 - 策略删除 当内存达到maxmemory上线时,触发内存淘汰机制 - 内存淘汰策略,共8种 > maxmemory-policy默认是noeviction,推荐使用volatile-lru,如果访问模式稳定,volatile-lfu的命中率会更高些,一定要设置maxmemory,否则redis到达机器内存上限时,内存数据会开始频繁跟磁盘发生交换 > - volatile-ttl 设置了过期时间的key,根据过期时间先后删除,优先淘汰剩余存活时间短的 - volatile-random 对设置了过期时间的key随机淘汰 - volatile-lru 对设置了过期时间的key,使用LRU(最近最少使用)算法淘汰 - volatile-lfu 对设置了过期时间的key,使用LFU(最少频次使用)算法淘汰 - allkeys-random 对所有key进行随机淘汰 - allkeys-lru 对所有key,使用LRU(最近最少使用)算法淘汰 - allkeys-lfu 对所有key,使用LFU(最少频次使用)算法淘汰 - noeviction 不处理,拒绝写入并返回OOM command not allowed - LRU与LFU - LRU 最近最少使用,最近没访问过的先淘汰。 - 对于访问频次很少但最近有访问过的,短期内不会删除,会污染缓存 - LFU 最少频次使用,访问次数最少的先淘汰。 ## HotKey > 指在**有限时间**内被**高频访问**的key,可分为有预期的热点和无预期的热点 > - 有什么危害? - 容易引起请求排队,集群模式下流量会严重倾斜,严重时可能导致服务瘫痪 - 怎样算热点key? - 参考阿里云定义 ![image.png](https://pic.code-nav.cn/post_picture/1876274222060195841/wxp247xVoSG9A9t9.webp) - 怎么发现? - 业务预判 - redis-cli —hotkeys命令 - 客户端埋点 - Proxy代理层收集 - 如何优化? - 热点key拆分 - 多级缓存 在redis前加一层本地缓存 > 本地缓存可以减少网络请求,提高性能,缓解远程缓存压力;但是空间大小有限,不支持大量数据存储,重启程序数据会丢失,也有可能跟远程缓存数据不一致 > - 读写分离 读请求放在从节点 - 限流降级 限制请求流量,保系统优先 ### JDHotKey热点探测系统 - 核心目标 - 实时性 热点发现延迟小于等于1s - 低开销 对业务代码无入侵 - 高吞吐 支持百万级QPS的key流量分析 - 自动应对 发现热点key后自动推送到本地缓存 - 动态适应 热点变化快速收敛或释放 - 架构方案 - Agent代理 - 内嵌client客户端,无需独立部署,随client启动 - client用于拦截Redis调用,采集key访问数据 - 使用时间滑动窗口统计每个key的访问频次 - 高频过滤,只上报疑似key,避免海量普通key上报 - 本地缓存防护 - 一旦收到热key通知,自动缓存到本地 - 写操作走原链路,可配合本地缓存实效广播 - Center中心 - worker集群 汇总client上报数据,实时计算热点阈值,判定热key并下发热key > 热key判定算法 动态阈值:基于历史流量自动调整 突发检测:使用指数加权移动平均识别流量突增 防抖机制:避免频繁上下线 热key下发 通过websocket/tcp实时推送,支持分级热度 > - etcd集群 存储规则配置,worker ip,热key列表,提供监听订阅 - Dashboard控制台 - 可视化配置热key匹配策略 - 监控结果 - 实现能力 - 毫秒级保护 - 熔断降级 center不可用时,agent自动退化为本地简单限流 - 分级热度 热度策略差异化 ![image.png](https://pic.code-nav.cn/post_picture/1876274222060195841/2f1bWlYrXs1yDZEf.webp) ## Redis扩展 - RedisJson 提供对json数据的原生支持,可以对json中的数据进行增删改查等操作 - 相比于string的优势 - 存储json数据性能更高,底层以二进制格式存储,相比文本格式,读写性能高,也更节省内存 - 采用树状结构存储json,可以快速访问子元素 - 生态集成度高 - BloomFilter - 只能加数据,不能删数据,有变动时只能重建 - BF.RESERVE bf 0.01 1000 NONSCALING - CuckooFilter - 相比于BloomFilter,新增了删除指令 指令以CF开头 - CF.RESERVE cf 1000 BUSKETSIZE 2 MAXITERATIONS 20 EXPANSION 1 ## Redis常用类型底层数据结构 > 可以使用object encoding key查看底层实现类型 > - String字符串 基本的数据单元,可以存储字符串、整数或者浮点数 底层用SDS(简单动态字符串Simple Dynamic String)小数据量编码使用embstr/int类型,大数据量编码使用raw类型,切换阈值为44 ![image.png](https://pic.code-nav.cn/post_picture/1876274222060195841/p1oYdSQTLsEmpti6.webp) ![image.png](https://pic.code-nav.cn/post_picture/1876274222060195841/2ELyk3CXkQsNpVj2.webp) - List列表 简单的列表,最多存储40亿个成员,底层使用双向链表,两端操作性能高,随机读写性能低 使用要注意大key问题 小数据量使用listpack(7之前是ziplist)大数据量使用quicklist,切换阈值为512个元素或单元素超64字节 **ziplist** 是一块连续内存,把所有元素紧凑地挨在一起存,省内存空间但不适合大量数据 > 每个entry都要记录上一个节点的长度,如果前面插入了一个大元素,那么后面数据的prev_entry_length就都得扩容,会触发连锁更新 > ![image.png](https://pic.code-nav.cn/post_picture/1876274222060195841/2yasj2qt3Um7sxFZ.webp) **listpack** 一种紧凑型序列化数据结构,把数据直接按字节序列存储,用于替代ziplist > 主要差别就是Entry内部结构发生改变 listpack不再记录前一个entry的长度,而是记录自己的长度 > ![image.png](https://pic.code-nav.cn/post_picture/1876274222060195841/qwKw2RQLhBiVkcd7.webp) **quicklist** 是用双向链表串联的一堆ziplist/listpack,将连续的大量数据分散,解决更新时需要操作大量节点的痛点,此外quicklist还支持对中间节点做LZF压缩 ![image.png](https://pic.code-nav.cn/post_picture/1876274222060195841/8WWnbJ2brBkE0Rpo.webp) - Hash哈希表 简单的键值对集合,每个键值对可以储存多个字段,最多存储40亿个成员 相比string操作消耗更小,也能节省存储空间,但使用时要注意大key问题,过期时间不能作用在field上,有局限性 小数据量使用listpack,大数据量使用hashtable,切换阈值为512字段或单值超64字节 - Set集合 一个无序集合,最多存储40亿个成员 小数据量使用intset/listpack 大数据量用hashtable,新增、删除、查找复杂度都是O(1),切换阈值为512个元素或含非整数 - ZSet有序集合 一个有排序的集合,最多存储40亿个成员 小数据量使用listpack,大数据量使用skiplist+hashtable,切换阈值为128个元素或单值超64字节 **skiplist** 就是多层链表,底层保存全部元素,上层是下层的子集 > 随机层级概率算法,每次循环有25%的概率加一层,redis7最多32层,能够存储2^64个元素 第一层 75%的节点 第二层 25%的节点 第三层 6.25%的节点 第n层 0.25^(n-1)的节点 ![image.png](https://pic.code-nav.cn/post_picture/1876274222060195841/WpBRpwZrvedL8IS6.webp)

Day8 Redis缓存问题

### 缓存失效问题 1. **缓存穿透** 指大量请求缓存中不存在的数据,导致这些请求都访问备用数据源(数据库、外部服务等),从而引起系统资源浪费和性能问题。 - 解决方案 1. 参数校验:通过参数校验拦截非法请求,避免不必要的查询记录 2. 缓存空值:数据源查询结果为空时,将redis缓存值设置为特殊值,标记记录不存在 ```mermaid flowchart TD A["Client Request"] --> B{"Cache Hit?"} B -- Yes --> C["Return Cache Data"] B -- No --> D{"Query Database"} D -- "Data Found" --> E["Store in Cache"] E --> F["Return Data"] D -- "No Data" --> G["Store NULL in Cache"] G --> H["Return Empty Result"] %% Cache penetration prevention flow %% NULL values are cached with TTL to prevent repeated DB queries ``` 3. 布隆过滤器检查记录ID存在性 1. 基于数据库记录初始化布隆过滤器 2. 新增记录时,同步将该记录的ID添加到布隆过滤器里 3. 查询Redis缓存前,先使用布隆过滤器检查,如果布隆过滤器判断不存在,则记录不存在,如果布隆过滤器可能存在,则查询redis ```mermaid flowchart TD A["客户端请求"] --> B["布隆过滤器检查"] B -- "可能存在" --> C{"Redis缓存检查"} B -- "不存在" --> D["返回空结果"] C -- "命中" --> E["返回缓存数据"] C -- "未命中" --> F{"查询数据库"} F -- "数据存在" --> G["写入Redis缓存"] G --> H["返回数据"] F -- "数据不存在" --> I["写入空值到Redis"] I --> J["返回空结果"] %% 布隆过滤器和缓存空值联合使用 %% 布隆过滤器快速判断不存在的情况 %% 缓存空值防止数据库重复查询 ``` 2. **缓存击穿** 指高并发情况下,一个热点key失效或未缓存时,大量请求同时访问备用数据源,导致备用数据源压力过大而宕机的情况 - 解决方案 1. 控制并发量:热点数据不存在,则控制访问备用数据源的并发量,避免对备用数据源造成冲击(使用分布式锁) 2. 多级缓存:使用互斥锁只允许一个线程更新缓存,其他线程需等待,为解决该问题,可以使用redis缓存 A1+本地缓存 A2的多级缓存机制,A2的过期时间大于A1,当A1失效时,第一个线程去数据源中查询,并重建A1缓存,其余线程访问缓存A2中的数据,避免等待 3. 双key:使用两个key分别缓存过期时间和缓存数据。 1. 如果缓存过期时间的key过期,则更新该key(使用NX实现原子性操作) 2. 若某个线程成功更新缓存过期时间key,该线程从数据源获取数据并更新缓存数据key,并返回新数据;更新失败,说明已被其他线程更新,直接获取缓存数据key 3. 其他线程将会直接获取缓存数据key的旧数据返回【会存在缓存一致性问题】 ```mermaid flowchart TD A["客户端请求"] --> B{"检查过期时间key"} B -- "未过期" --> C["获取数据key"] C --> D["返回数据"] B -- "已过期" --> E{"尝试更新过期时间key"} E -- "更新成功" --> F["查询数据源"] F --> G["更新数据key"] G --> H["返回新数据"] E -- "其他线程已更新" --> I["获取数据key"] I --> J["返回旧数据"] %% 双key方案流程说明 %% 使用独立的key控制过期时间 %% 仅允许一个线程更新数据 %% 其他线程返回旧数据,避免等待 ``` 4. 后台热数据刷新:通过定时任务后台刷新,避免热点数据丢失。需要考虑区分业务上的热点数据和非热点数据,且要设置合理的过期时间,不推荐使用 3. **缓存雪崩** 指因redis故障、操作不当等原因致使缓存中大量键同失效,导致所有请求都落到备用数据源而引起备用数据源瞬间压力过大甚至宕机的情况 - 解决方案 1. 控制并发量 2. 设置合理的过期时间,将偏移量打散避免同时过期 3. 拷贝缓存,对于采用双活实例部署架构提升redis缓存层高可用的场景,可以拷贝缓存以降低redis雪崩的概率 4. 熔断,设置合理熔断机制 ### 缓存一致性问题 特指备用数据源更新后,没有及时同步到redis缓存,导致缓存与数据源的不一致 | **更新策略** | **实现方式** | **优点** | **缺点** | **不一致场景** | | --- | --- | --- | --- | --- | | 先更新数据库,再删除缓存 | 1. 更新DB 2. 删除缓存 | 实现简单 延迟低 | 并发场景下可能不一致,短期内访问数据需重新加载 | 当删除缓存时,另一个线程正在读取旧数据并写入缓存 | | 先删除缓存,再更新数据库 | 1. 删除缓存 2. 更新DB | 实现简单 | 可能造成缓存穿透 数据不一致概率高 | 删除缓存后,其他线程读取到旧数据并写入缓存,而DB更新尚未完成 | | 先更新数据库,再更新缓存 | 1. 更新DB 2. 更新缓存 | 操作原子性好,短期内不需要重新加载 | 更新缓存可能失败 增加系统复杂度 数据不一致概率高 | 多个线程同时更新,可能因为更新顺序不同导致缓存数据不一致,概率较高,不推荐 | | 先更新缓存,再更新数据库 | 1. 更新缓存 2. 更新DB | 操作原子性好,短期内不需要重新加载 | 更新缓存可能失败 增加系统复杂度 数据不一致概率高 | 多个线程同时更新,可能因为更新顺序不同导致缓存数据不一致,概率较高,不推荐 | | 消息队列异步通知 | 更新DB后发送消息到队列,异步更新缓存 | 系统解耦 可靠性高 | 实现复杂 有一定延迟 | 消息处理延迟期间会出现短暂不一致 | | 延迟双删 | 先删除缓存,更新数据后再删一次缓存 | 实现简单,大幅缩小不一致窗口期 | 延迟时间不确定,第二次删除可能失败 | 第一次删除到更新数据库以及到第二次删除的空档期 | | 分布式锁 | 写操作时加锁 | 强一致性,彻底避免并发 | 性能差,有死锁风险,架构入侵性强 | 无 | ### BIG KEY问题 > 由于设计不当导致一个key占用比较大的空间 > - 怎样的数据算big key? - 没有标准值,阿里云的参考值 ![image.png](https://pic.code-nav.cn/post_picture/1876274222060195841/wPhyYYzex7ciQRVa.webp) - 怎么发现? - 可以在Redis Proxy层埋点记录每个命令响应,超过阈值就告警,也可以定时任务扫描采样,当前阿里云、腾讯云等成熟redis云产品自带big key分析功能 - 如何优化? - 拆分数据,把大数据拆小后分成多个键存储 - 选择合适的数据结构,同json存储的string类型可以换成hash,能节省内存 - 非必要数据不存储,且合理控制key的过期时间

下载 APP