Redis
Redis学习记录
Remote Dictionary Server 远程字典服务,支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。
与 MySQL 数据库不同的是,Redis的数据是存在内存中的。它的读写速度非常快,每秒可以处理超过10万次读写操作。因此redis被广泛应用于缓存,另外,Redis也经常用来做分布式锁。除此之外,Redis支持事务、持久化、Lua 脚本、LRU 驱动事件、多种集群方案。
基本数据结构类型
- String(字符串):可以存储图片或序列化的对象,值最大 512M,二进制安全。
- Hash(哈希):嵌套在 Redis 键值对中的二级键值映射结构,专门用于存储多个字段(field)和对应值(value)的组合。
- List(列表):保存有顺序的一系列字符串元素,支持从两端插入和弹出元素。
- Set(集合):无序且成员唯一的字符串集合,支持添加、删除、判断成员是否存在,以及交集、并集等运算。
- ZSet(有序集合):为成员关联分数,并按分数维护排序。
示例 · Redis 命令:写入 Hash
HSET user:1001 name "张三" age 25 email "zhangsan@example.com"
三种特殊的数据结构类型:
- Geospatial
- HyperLogLog
- Bitmap
为什么用 Redis?
每次访问 /users/1001,都直接查询数据库。如果这个用户信息被大量重复访问,就可能产生大量相同的查询。
引入 Redis 后,流程可以变成:
- 先根据用户 ID 查询 Redis。
- 如果缓存命中,直接返回数据。
- 如果缓存未命中,再查询数据库。
- 将查询结果写回 Redis,设置合理的有效时间。
- 后续相同请求优先读取缓存。
这样能减少重复数据库查询,降低数据库压力,并改善常见读取请求的响应速度。不过,缓存还会带来数据一致性、过期和内存管理等问题,这些也是面试重点。
Redis 常见的应用场景
| 使用场景 | 例子 | 为什么使用 Redis |
|---|---|---|
| 缓存 | 用户信息、商品信息 | 减少重复查库 |
| 计数器 | 浏览量、点赞数 | INCR 等原子操作 |
| 会话存储 | 登录状态、临时验证码 | 支持设置过期时间 |
| 排行榜 | 游戏积分、热门内容 | ZSet 支持按分数排序 |
| 限流 | 限制接口请求频率 | 结合计数、过期时间及原子操作 |
| 分布式锁 | 限制同一资源被重复并发处理 | 多个应用实例共同协调访问 |
Redis INCR 是 Redis 字符串类型专属的原子自增操作命令,可以将指定 key 存储的整数值加1
- INCRBY:指定步长自增,例如
INCRBY page_view 5将值加5 - DECR:原子减1,和INCR功能相反
- DECRBY:指定步长自减
- INCRBYFLOAT:支持浮点数的原子增减操作
普通的INCR user:1001:age是操作独立的字符串键,会在Redis里单独生成一个key;而HINCRBY user:1001 age 1是把age字段和用户的姓名、手机号等其他属性全部收纳在同一个哈希结构里,更符合用户属性分组的业务逻辑,也能减少大量零散的独立key,方便统一管理该用户的所有属性。
Hash 和 String 应该怎么选?
看你存的数据是「不可拆分的单值」,还是「有多个独立字段的结构化对象」
优先选 String 的场景
- 你要存的是完全不可拆分的单值:比如登录验证码、页面浏览量计数器、Token、全局功能开关这类只有一个值的数据
- 整个对象永远只会整体读写,几乎不会单独修改其中某一个字段:比如静态商品详情、几乎不变的配置快照
- 需要给这个值单独设置精确的过期时间:比如短信验证码、临时令牌这类有明确生命周期的单值数据
优先选 Hash 的场景
- 存储一个有多个独立属性的对象,且经常需要单独修改其中某几个字段:比如用户信息(改年龄、改头像、改登录时间)、购物车商品属性、订单状态分组
- 需要对对象里的某个数值字段做原子增减:比如用户经验值、商品库存、积分这类需要并发安全计数的字段
- 不想在Redis里生成大量零散的独立key,希望把同业务的属性收拢到同一个key下统一管理:比如把用户的name、age、city全部收纳在
user:1001这个哈希key里,避免出现几十上百个user:1001:xxx的零散key
注意:不要用 String 存 JSON 格式的多字段对象,每次改一个小字段都要全量读写,不仅性能差 3 倍以上,还很容易出现并发覆盖的问题。
Redis List 可以实现消息队列吗?
可以。使用 RPUSH 和 LPOP 等命令可以实现简单的先进先出队列,也可以用 BLPOP 等阻塞命令减少空队列时的轮询。不过,任务一旦弹出就会从 List 中移除,如果消费者处理失败,任务可能丢失。因此,在要求可靠投递、确认机制和失败重试的场景中,需要进一步考虑 Redis Streams、可靠队列方案或专业消息队列。
面试时怎么区分 Set 和 List?
| 对比项 | List | Set |
|---|---|---|
| 是否有序 | 按列表中的位置组织 | 不保证顺序 |
| 是否自动去重 | 不会 | 会 |
| 能否方便地求交集 | 不适合 | 支持 |
| 典型场景 | 消息、任务列表 | 标签、去重、共同好友 |
为什么不用 Set?
Set 可以记录谁参与了活动,但它本身不为每个成员关联分数、并按分数维护排名。
ZSet 则把这两个能力结合起来了,所以适合排行榜、按分数排序的任务以及某些限流实现。
Redis 如何实现实时排行榜?
推荐回答:
可以使用 Sorted Set,以用户 ID 作为 member,以积分作为 score。新增或更新分数时使用
ZADD或ZINCRBY,查询高分排名可以使用ZREVRANGE,查询指定用户排名可以使用ZREVRANK。这样就不必每次都把所有用户取回应用层再进行排序。
缓存穿透(Cache Penetration)
用户请求的这个数据,缓存里没有,数据库里也没有,导致所有请求直接绕过缓存层,次次都打到数据库上,缓存完全形同虚设。
典型触发场景
- 恶意攻击:黑客批量构造完全不存在的ID(比如商品ID=-1、ID=999999999)疯狂扫接口,专门找系统里根本不存在的数据请求
- 业务漏洞:前端没做参数校验,把非法的负数ID、超长ID直接透传到后端,触发大量无效查询
- 数据清理遗漏:数据库里已经删掉的数据,没有同步清理缓存,后续请求持续穿透查库,永远拿不到有效数据
三种解决方案
| 方案 | 原理 | 适用情况 |
|---|---|---|
| 缓存空值 | 将“不存在”这个结果也短暂缓存起来 | 常见的用户、商品查询接口 |
| 布隆过滤器 | 先判断某个元素是否可能存在,过滤明显不存在的数据 | 数据量大、查询频繁的场景 |
| 参数校验与限流 | 拦截无效参数、异常请求或高频恶意请求 | 几乎所有公开 API |
参数校验
在API网关、业务入口层直接做参数校验,把明显非法的请求直接拦在最外层:比如要求商品ID必须是正整数、ID范围不能超过系统预设的最大值,直接把id=-1、字符串格式ID这类完全不合理的请求直接返回,根本不让它走到缓存和数据库链路。
这个方案成本为零,但只能拦截格式明显错误的请求,拦不住格式合法但数据本身不存在的请求。
布隆过滤器
提前把系统里所有合法存在的业务ID,全部存入布隆过滤器这个极省内存的概率型数据结构里。请求进来之后先过布隆过滤器校验:
- 如果过滤器判定这个ID一定不存在,直接返回空,连Redis都不用查
- 如果过滤器判定这个ID可能存在,再走正常的查缓存→查数据库流程
布隆过滤器的特性是「说不存在就100%不存在,说存在有极小概率误判」,完全不会把不存在的数据放过去,只会把少量存在的数据误判成不存在,完全不会出现反向穿透的问题。现在Redis官方已经提供了RedisBloom扩展,直接用命令就能操作,不用自己手写哈希逻辑。
缓存击穿:一个热点 Key 突然失效
假设某个商品正在参加秒杀活动:
示意 · 热点 Key(纯文本)
product:1001
这个商品被数千个用户同时查询。Redis 缓存原本存在,大家都能直接获取数据。
突然,缓存过期了。
这时,大量请求同时发现缓存不存在,于是一起查询数据库。
这就叫 缓存击穿(Cache Breakdown),也常称为热点 Key 失效引起的缓存惊群。
它和缓存穿透的区别是:
- 穿透:查询的通常是不存在的数据,因此缓存总是无法命中。
- 击穿:查询的是确实存在的热点数据,但某个热点 Key 恰好失效,导致请求集中回源。
| 问题类型 | 核心特征 | 触发场景 |
|---|---|---|
| 缓存击穿 | 单热点Key过期,数据在数据库里真实存在 | 秒杀爆款商品、首页热搜、大促活动配置这类被几万QPS访问的单个热点数据 |
| 缓存穿透 | 请求的数据缓存和数据库里都不存在 | 黑客批量扫不存在的ID、非法参数请求 |
| 缓存雪崩 | 大量Key同时集体失效,全量请求无差别打库 | 批量设置相同过期时间、Redis集群整体宕机 |
1. 互斥锁(分布式锁):保证数据强一致
这是业内最通用的标准解法,核心逻辑是:缓存失效时,只放行第一个抢到分布式锁的线程去查数据库、回写缓存,其他所有并发线程短暂休眠后,直接读取已经重建好的缓存,不会重复查库。
用Redis原生的SET NX EX就能实现轻量级互斥锁,完全不需要引入额外的分布式锁组件
2. 逻辑过期 + 异步刷新:零等待
完全不给热点Key设置物理过期时间,而是把过期时间字段直接存在缓存的value里。当业务线程读取缓存时,判断value里的逻辑过期时间是否已经到期:
- 如果没过期,直接返回缓存数据,全程零等待
- 如果已经过期,当前线程直接返回旧数据,同时后台异步启动一个线程去查库刷新缓存,其他所有并发请求也全部直接返回旧值,完全不会出现大量请求同时打数据库的情况
3. 热点数据预热
在活动开始前提前将高频访问的数据放入缓存,避免流量高峰时才首次加载。
面试答法:根据业务的一致性要求选择互斥锁或逻辑过期,并结合热点 Key 预热;如果不能容忍旧数据,就不能仅靠返回旧缓存解决问题。
4. 热点 Key 永不过期:静态热点数据最优解
对于几乎不会变更的超级热点数据,直接不给缓存设置任何TTL,物理上永不过期。启动时提前预热写入缓存,后续用后台定时任务定期异步更新缓存,完全不会出现缓存过期的场景,从根源上杜绝击穿风险。
5. 多级缓存兜底:本地缓存 + Redis 双层防护
在应用服务里引入Caffeine/Guava本地缓存作为L1层,Redis作为L2层。热点数据先存在本地缓存里,就算Redis里的热点Key意外失效,本地缓存还能继续承接绝大多数流量,只有极少量请求会穿透到Redis,完全不会出现大量请求直接打数据库的情况。
缓存雪崩:大量 Key 同时失效,或 Redis 整体不可用
你为一批商品缓存数据,全部设置相同的过期时间:
示例 · Python:统一 TTL 会引发集中失效
await redis_client.set("product:1", data1, ex=3600)
await redis_client.set("product:2", data2, ex=3600)
await redis_client.set("product:3", data3, ex=3600)
如果它们几乎同时写入、又统一设置 3600 秒过期,那么一小时后可能出现一批缓存集中失效的情况。
大量请求被迫回源数据库,就可能形成流量冲击。
另一种情况是 Redis 整体发生故障,原本的缓存命中突然全部消失,也可能引发类似后果。
这就是 缓存雪崩(Cache Avalanche)。
| 防护层级 | 具体方案 | 实现细节 | 适用场景 | 优势 | 注意事项 |
|---|---|---|---|---|---|
| 源头预防:避免集中失效 | TTL随机打散 | 在基础过期时间上叠加±10% ~ ±5分钟的随机偏移,比如原本30分钟的缓存设置为30±5分钟随机分布 | 绝大多数普通业务缓存,如商品详情、用户画像 | 零额外成本,从根源上把集中失效的尖刺流量均匀分散到时间窗口里 | 禁止硬编码统一固定TTL,必须封装工具方法强制加随机扰动 |
| 源头预防:避免集中失效 | 按业务分组差异化过期 | 不同业务模块设置完全不同的基础过期时间,比如商品详情缓存1小时、订单列表缓存30分钟、用户信息缓存2小时 | 多模块共存的中大型业务系统 | 从业务维度天然错开不同类型缓存的失效高峰 | 避免跨业务复用统一的缓存过期常量 |
| 源头预防:避免集中失效 | 热点数据永不过期 | 超高热度核心数据不设置物理TTL,通过后台定时任务、消息通知主动异步刷新数据 | 首页Banner、爆款商品、全局活动配置这类访问量极高的静态热点 | 从根源上杜绝热点Key过期引发的集中冲击 | 控制永不过期Key的总数量,配合Redis的allkeys-lru内存淘汰策略,避免内存溢出 |
| 缓存层高可用:避免整体宕机 | 分布式缓存集群部署 | 采用Redis Cluster分片集群、哨兵主从自动切换架构,数据分片存储,单节点故障自动转移 | 生产级高并发业务集群 | 单个节点宕机仅影响部分数据,不会出现全量缓存整体失效 | 开启RDB+AOF混合持久化,集群重启后可快速恢复全量缓存数据 |
| 缓存层高可用:避免整体宕机 | 多级缓存架构 | 搭建「本地Caffeine/Guava缓存(L1)+ Redis分布式缓存(L2)」双层缓存体系,请求优先读取本地缓存 | 所有读多写少的高并发场景 | 本地缓存可拦截70%以上的热点读请求,就算Redis整体宕机也能短时承接流量 | 本地缓存TTL设置为30秒以内,避免出现严重脏数据 |
| 缓存层高可用:避免整体宕机 | 缓存预热 | 大促前、系统上线前,通过离线任务提前把热点Key批量加载进本地缓存和Redis集群 | 大促活动、新系统上线等流量突增场景 | 避免服务刚启动时缓存全空,大量请求直接穿透到数据库 | 按业务维度分批次预热,不要一次性全量写入引发Redis流量尖刺 |
| 数据库层兜底:降低冲击 | 数据库限流 | 通过令牌桶/漏桶算法限制数据库总QPS上限,超出阈值的请求直接排队或返回降级结果 | 所有高并发生产环境 | 从入口层面直接限制数据库的总访问量,避免被瞬时流量打垮 | 分级设置限流阈值,核心接口分配更高的配额,优先保障主业务 |
| 数据库层兜底:降低冲击 | 数据库读写分离 | 主库仅处理写请求,多个从库分担全量读请求 | 读远大于写的业务场景 | 大幅降低单库的CPU和IO负载,提升数据库整体承载能力 | 注意主从同步延迟带来的短暂数据不一致问题 |
| 最后防线:熔断降级 | 服务熔断 | 用Sentinel/Resilience4j配置熔断规则,数据库错误率超过50%或响应超时超过1秒时,自动触发熔断 | 缓存大面积失效、数据库异常的故障场景 | 故障发生时快速停止无效请求,避免资源耗尽引发级联雪崩 | 熔断恢复后逐步放量,不要瞬间把全量流量重新打回数据库 |
| 最后防线:熔断降级 | 服务降级 | 非核心接口直接返回预设的兜底静态数据,核心接口返回简化版占位内容 | 极端故障场景下优先保障核心链路可用 | 牺牲非核心业务的完整性,保证核心下单、支付链路不中断 | 提前预置兜底静态JSON文件,极端情况下完全不依赖任何后端组件 |
面试官往往还会追问:“假如 Redis 挂了,你的 FastAPI 是否全部改查数据库?”
不会全裸奔查库,我用本地Caffeine缓存先扛住80%热点流量,再靠Sentinel限流熔断把数据库QPS压在承载阈值内,非核心接口直接返回本地预置的兜底数据。线上Redis是哨兵集群加灾备,真出问题30秒内就能切流恢复,根本不会让数据库直接承接全量流量。
缓存一致
通常我们把数据库看作业务数据的主要事实来源,而 Redis 缓存属于可以重新生成的数据副本。我们需要设计缓存更新策略,让不一致的时间尽可能短,并在异常时能够恢复。
策略 1:先更新数据库,再删除缓存
第一步:更新数据库
将 age 从 25 更新为 26,并提交事务
第二步:删除 Redis 缓存
DEL user:v1:1001
第三步:下一次查询重新加载
读取最新数据库数据,再写入缓存
策略 3:更新数据库后,再直接更新缓存
这种方式也可以使用,但会额外引入问题:
- 数据库更新成功,Redis 写入失败,两者仍然不一致。
- 并发请求可能按不同顺序写入缓存,后完成的操作未必拥有更新的数据。
- 应用需要维护缓存序列化和字段更新逻辑。
因此,在常见的 Cache-Aside 场景里,通常优先考虑“更新数据库后删除缓存”,而不是无条件地同时维护两份数据。
策略 1 也有可能失败:
| 方法 | 解决思路 | 局限性 |
|---|---|---|
| 给缓存设置 TTL | 即使旧数据残留,也能在过期后重新加载 | 过期前仍可能读到旧值 |
| 延迟二次删除 | 数据库更新并首次删除后,延迟再删一次,清除可能晚到的旧缓存写入 | 依赖时序;进程故障或延迟估计不当时仍可能失效 |
| 可靠重试或事务 Outbox | 数据库事务成功后可靠地投递缓存失效事件,失败可以重试 | 需要额外组件和运维机制 |
| 版本号、锁或其他并发控制 | 防止旧版本数据覆盖新版本,或协调冲突读写 | 设计更复杂,需要明确一致性要求 |
更新接口不是把 Redis 当作唯一的数据来源。它先更新数据源,再让旧缓存失效;新的 GET 请求发现缓存不存在后,重新查询数据源并回填缓存。
面试问答 · Cache-Aside 更新策略
问:为什么更新数据库后删除缓存,而不是先删除缓存?
答:先删除缓存会给并发读请求留下机会,让它读取旧数据库数据并重新填充缓存。先更新数据库并提交,再删除缓存,是常用的 Cache-Aside 更新策略。
问:更新数据库后删除缓存,就绝对一致了吗?
答:不能。并发读可能在数据库更新前读到旧值,随后在缓存删除后再把旧值写回。还需要考虑 TTL、重试、延迟二次删除或更严格的并发控制。
问:缓存删除失败怎么办?
答:不能静默忽略。需要记录和监控异常,并通过重试、可靠消息或事务 Outbox 等方式保证后续能够重新执行失效操作。
问:使用分布式锁,为什么还需要 Token?
答:锁可能过期后被其他请求重新获得。释放锁前检查唯一 Token,可以防止旧持有者误删新持有者的锁。
过期策略
Redis 并不是为每个 Key 都设置一个独立的定时器,在到期瞬间立即完成物理删除。Redis 使用两种机制处理过期 Key:
- 惰性删除(passive expiration):客户端访问一个已经过期的 Key 时,Redis 会发现它已过期,不再向客户端返回原有值,并将其删除。
- 定期主动过期(active expiration):Redis 会定期抽样检查具有过期时间的 Key,并删除已经过期的 Key,避免无人访问的过期数据一直占用内存。
| TTL 返回值 | 意义 |
|---|---|
| 大于等于 0 | 返回剩余秒数 |
-1 | Key 存在,但没有过期时间 |
-2 | Key 不存在 |
内存淘汰
过期策略决定哪些数据已经失效。
内存淘汰策略则是在内存达到配置的限制时,决定该删除哪些 Key,或者是否拒绝新的写入。
例如,你的 Redis 缓存了 100 万条商品信息。如果设置了内存上限,随着数据持续写入,Redis 可能需要主动淘汰一部分 Key。
1. 关键配置:maxmemory
maxmemory 用于设置 Redis 数据集的内存上限。maxmemory-policy 决定到达限制时采用什么策略。
| 策略 | 行为 | 典型考虑 |
|---|---|---|
noeviction | 不淘汰已有 Key;需要额外内存的写入可能报错 | 不能随意淘汰数据的场景,但必须做好写入失败处理 |
allkeys-lru | 从全部 Key 中淘汰近期最少使用的数据 | 常见缓存场景 |
allkeys-lfu | 从全部 Key 中淘汰使用频率较低的数据 | 热点访问频率差异明显的缓存 |
volatile-lru | 只在设置了 TTL 的 Key 中进行 LRU 淘汰 | 希望优先淘汰有过期时间的数据 |
volatile-lfu | 只在设置了 TTL 的 Key 中进行 LFU 淘汰 | 同时按使用频率和 TTL 属性考虑 |
allkeys-random | 从全部 Key 中随机淘汰 | 对冷热数据没有明显区分需求的特殊场景 |
volatile-ttl | 优先淘汰剩余有效时间较短的 Key | 适用于特定 TTL 设计 |
这里的 LRU 是 Least Recently Used,表示近期较少使用;LFU 是 Least Frequently Used,表示使用频率较低。
| 场景特征 | 优先选LRU | 优先选LFU |
|---|---|---|
| 访问模式 | 突发流量、用户近期行为驱动 | 长期稳定热点、固定高频内容 |
| 性能要求 | 极致读写性能,低维护开销 | 优先保证热点留存率,可接受略高的性能开销 |
| 典型问题规避 | 避免顺序遍历全量数据时频繁触发缓存置换 | 必须加访问计数老化机制,防止历史热点数据永久滞留缓存 |
Big Key
1. Big Key 会造成什么问题?
第一,大量数据读取可能消耗较多网络带宽和客户端内存。
第二,对巨大集合执行某些遍历、删除或集合运算,可能耗费较多 Redis 处理时间。
第三,删除或更新大对象、进行持久化和复制等操作时,也可能带来额外的性能压力。
Redis 官方特别提醒,复杂数据结构的大规模操作可能造成延迟尖峰,生产环境应该注意命令复杂度,避免无节制地扫描或操作大量元素。
2. 如何排查 Big Key?
你的 Redis 容器名称是 redis-demo1,可以直接使用下面的命令。
命令 · 查看整体内存
docker exec -it redis-demo1 redis-cli INFO memory
命令 · 查看单个 Key 的内存占用
docker exec -it redis-demo1 redis-cli MEMORY USAGE user:v1:1001
命令 · 扫描大 Key
docker exec -it redis-demo1 redis-cli --bigkeys
Big Key 如何优化?
如果某个 String 中塞进了整个大型报表,而业务只需要其中的一小部分,可以考虑按业务维度拆分数据、分页读取,避免每次传输整个对象。
如果一个 Set 或 List 包含海量元素,可以根据业务设计拆分 Key、分批处理,或者采用更适合查询需求的数据结构。
如果确认需要删除大型 Key,可以考虑 Redis 的 UNLINK 命令。与 DEL 可能同步释放对象内存不同,UNLINK 会先将 Key 从键空间中移除,再在后台线程中释放对象内存。它可以降低大型对象删除时的阻塞风险,但不代表释放内存完全没有成本。
热 Key 如何优化?
如果某个商品详情是读多写少的数据,可以考虑在 FastAPI 服务进程内部增加短时间的本地缓存,减少对 Redis 的重复访问。但本地缓存会增加多实例之间的数据一致性复杂度。
如果需要把读取压力分散到多个 Key,可以根据业务设计数据分片或副本,例如把只读热门配置保存到多个副本 Key,再让请求分散读取。但这会增加更新同步和读取路由的复杂度,不应该不加分析地把每个 Key 都拆成很多副本。
如果热 Key 本身属于热点业务,还可以结合缓存预热、请求合并和合理限流,避免数据库或缓存服务承受突发的流量冲击。
持久化机制
RDB:Redis DataBase —— 给数据拍一张快照
在某个时间点,将 Redis 的数据集生成一个快照,保存到磁盘文件中。
核心是将Redis内存中的全量数据以二进制快照形式写入磁盘,防止重启/宕机导致数据丢失。
| 触发类型 | 命令/配置 | 说明 |
|---|---|---|
| 手动触发 | SAVE | 主进程阻塞执行,直接停止对外服务,生产环境禁用 |
| 手动触发(推荐) | BGSAVE | fork子进程后台执行,主进程不阻塞,是生产环境主流操作 |
| 自动触发 | 配置save m n | 如save 60 10000代表60秒内有10000个键被修改则自动执行BGSAVE |
| 自动触发 | 主从全量复制、执行SHUTDOWN命令时 | 自动生成快照 |
RDB 的优缺点
| 优点 | 缺点 |
|---|---|
| 文件通常比较紧凑,适合备份和迁移 | 两次快照之间发生的变更可能丢失 |
| 大数据集恢复通常比较快 | 创建快照需要额外的系统资源 |
| 适合保存某个时间点的数据副本 | 不适合单独承担对近期写入丢失容忍度极低的场景 |
这些特点使 RDB 特别适合定期备份和灾难恢复。
AOF:把写操作记录下来
AOF(Append Only File)采用另一种思路:
不只是保存某个时间点的数据快照,而是记录会改变数据集的写命令,以便重启时重新执行这些命令,恢复数据。
和RDB「定时拍一张全量数据快照」的逻辑完全不同,AOF不存数据结果,只记录Redis收到的每一条修改数据的写命令(比如SET、DEL、LPUSH这类操作),永远只往文件末尾追加新内容,绝对不修改、不删除之前的记录。
当Redis宕机、断电导致内存数据全部清空时,重启后只要AOF文件还在,Redis就会从头到尾把文件里的所有命令重新执行一遍,就能100%还原出宕机前的完整数据,就像照着菜谱重新做一遍菜就能得到一模一样的成品。
AOF 完整工作流程
- 命令写入:Redis每执行完一条写命令,就会把这条命令转换成标准的RESP协议格式,先写入内存的AOF缓冲区,不会直接落盘。
- 磁盘同步:根据你配置的刷盘策略,把缓冲区里的命令真正持久化到硬盘的AOF文件中。
- 后台重写:当AOF文件体积膨胀到阈值时,Redis自动fork子进程,生成一份只保留当前数据集最小必要命令的精简新文件,替换掉旧的冗余AOF文件。
- 重启恢复:Redis启动时自动加载AOF文件,逐条重放所有命令,完整恢复内存数据。
| 配置值 | 行为说明 | 数据安全性 | 性能表现 | 适用场景 |
|---|---|---|---|---|
always | 每执行完一条写命令就立刻刷盘 | 最高,几乎不会丢任何数据 | 最差,频繁磁盘IO会大幅拖慢性能 | 金融交易、核心对账等零数据丢失要求的场景 |
everysec | 后台线程每秒执行一次刷盘 | 较高,极端宕机场景最多丢失1秒内的写入数据 | 性能平衡,是官方默认推荐配置 | 90%以上的常规生产场景,比如电商订单、社交业务 |
no | 完全交给操作系统决定刷盘时机,通常30秒左右自动刷一次 | 最低,服务器宕机可能丢失几十秒的数据 | 性能最好 | 纯缓存、非核心数据场景,比如短视频点赞数 |
生产环境的最优方案是开启Redis 4.0+的混合持久化:配置aof-use-rdb-preamble yes,让AOF文件开头直接嵌入RDB格式的全量快照,后面再追加增量的AOF命令,重启时先快速加载RDB快照恢复全量数据,再重放少量增量命令,同时兼顾RDB的快速恢复能力和AOF的高数据安全性,再搭配RDB做定期冷备份,就能覆盖绝大多数生产场景的需求。
原子性与单线程:常见面试问答
Redis 为什么快?它真的只有一个线程吗?
参考答法
Redis 的许多核心命令由主执行线程串行处理,但 Redis 并不意味着整个进程的所有工作永远只有一个线程。
多个客户端可以同时发送请求,Redis 会接收这些请求,并按相应的处理顺序执行普通命令。能够同时有很多请求等待处理,不等于多条普通命令会在主执行线程上同时执行。
为什么 INCR 是原子操作,但 GET 后再 SET 可能出错?
参考答法
INCR是Redis原生单命令:整个递增逻辑在Redis单线程里一次性执行完,从读旧值、加1、写回新值全程不会被任何其他客户端的请求打断,天然是原子的,哪怕上万个并发请求同时打过来,Redis也会排队逐个执行,不会出现冲突。
2、GET后再SET是客户端拆分的三段操作:GET在客户端读值、客户端本地做+1计算、再发SET写回,这三步是完全独立的三次网络请求,中间会穿插其他客户端的请求。比如两个并发请求同时GET到值10,各自本地加1得到11,最后都SET写回11,最终结果就会少算一次,明明两次递增最终值只涨了1,直接丢数据。
Redis 事务和 MySQL 事务有什么区别?
| 对比维度 | MySQL事务 | Redis事务 |
|---|---|---|
| 原子性 | 完全支持,所有操作要么全成功要么全失败,出错自动回滚 | 不支持回滚,事务里某条命令执行报错,后面的命令会继续执行,不会回滚之前已经成功的操作 |
| 隔离性 | 靠MVCC、行锁、隔离级别实现,不同事务之间互相隔离 | 单线程天然串行,事务提交后所有命令一次性执行,中间绝对不会插入其他客户端的请求 |
| 执行逻辑 | 逐条解析SQL,执行过程中可以等待锁、被其他请求打断 | 先把所有命令存入队列,收到EXEC后一次性批量执行,执行前不会解析命令逻辑 |
| 一致性 | 靠UNDO/REDO日志、约束机制保证事务前后数据完全符合预设规则 | 没有一致性校验机制,完全靠业务侧自己保证数据正确性 |
| 设计目标 | 面向强一致性的金融交易场景,优先保证数据绝对正确 | 面向高性能场景,优先保证一批命令连续不被插队执行,极致追求速度 |
Lua 脚本、事务、Pipeline 分别解决什么问题?
参考答法
这三个工具经常被混淆,各自的适用边界非常清晰,完全不能互相替代:
Pipeline:解决网络往返开销问题,把几十条命令一次性打包发给Redis,不用等上一条命令返回再发下一条,大幅减少RTT网络延迟。但所有命令之间完全没有原子性保证,执行过程中随时可能被其他客户端的请求插队,适合批量导入数据、批量查询这类不需要并发控制的场景。
Redis事务:解决多命令不被插队的问题,用MULTI把命令存入队列,EXEC一次性执行,保证这批命令执行期间不会插入其他请求。但它不支持业务逻辑判断,所有命令必须提前确定,适合简单的多命令串行场景。
Lua脚本:解决带业务逻辑的原子执行问题,你可以在脚本里写判断、循环、计算逻辑,整个Lua脚本在Redis里作为一个整体原子执行,完全不会被打断。相当于你可以把一段自定义的业务逻辑直接放到Redis单线程里跑,是三者里灵活性最高的,适合复杂的并发控制场景。
怎样利用 Redis 保证扣库存、计数等操作不被并发请求破坏?
参考答法
按优先级从简单到复杂选,99%的场景用前两种方案就能搞定:
优先用原生原子命令:扣库存直接用DECR/DECRBY,计数直接用INCR,单命令天然原子,零额外开发成本,性能最高,是首选方案。
带逻辑的操作用Lua脚本:比如扣库存前要判断库存不能小于0,直接写一段Lua脚本,把判断和扣减逻辑放在脚本里原子执行,不会出现判断完库存后被其他请求抢先扣减的竞态问题。
简单场景用WATCH+事务兜底:如果不想写Lua脚本,可以用WATCH监听库存Key,开启事务后执行扣减操作,如果EXEC返回空说明期间有其他客户端修改了库存,直接重试一次即可,属于乐观锁实现。
绝对不要用客户端GET后本地计算再SET的写法,哪怕加了分布式锁也没必要,Redis原生的原子能力完全可以覆盖绝大多数场景,性能比额外加锁高一个数量级。
排查 Redis 性能问题时,应该先看什么?
第一步:先看 CPU 使用率和慢日志
Redis是单线程执行模型,CPU是第一优先级指标:
- 先确认CPU基线:正常业务下Redis的CPU使用率应该稳定在30%以内,如果平均CPU超过60%,峰值持续5分钟超过90%,基本就是性能瓶颈的直接信号。
- 立刻查
SLOWLOG慢日志:Redis官方明确要求优先排查慢查询,所有执行时间超过阈值的命令都会被记录,大概率能直接抓到KEYS *、SORT、SUNION这类高时间复杂度的阻塞命令,这些命令会直接卡住整个事件循环,导致所有请求排队超时。 - 配套开起Redis自带的Latency Monitor延迟监控,它会自动采样不同执行路径的阻塞事件,直接给出延迟尖刺的具体来源,不用自己猜。
第二步:查热 Key 和大 Key
慢日志没找到问题,直接排查这两类最常见的业务侧问题:
- 热Key:某个Key的访问量远超其他Key,比如秒杀商品、热搜内容,几万QPS全部打到同一个实例的同一个Key上,直接把单实例CPU打满。用Redis自带的热点Key分析功能,或者云服务商的诊断工具,1秒就能定位到访问频次异常的Key。
- 大Key:单个Key的value体积过大,比如一个存了几十万元素的Hash、几MB的字符串,读取、删除这类Key的时候会占满带宽和CPU,直接拖慢整个实例的响应速度。用
redis-cli --bigkeys命令就能快速扫描出所有占用内存异常的大Key。
第三步:看连接数和短连接问题
很多时候QPS没到上限,但CPU和响应延迟已经异常,大概率是连接侧出了问题:
- 看
INFO stats里的total_connections_received指标,如果这个数值短时间内暴涨,远高于当前活跃连接数,说明客户端没有用连接池,每次请求都新建短连接,大量CPU资源被消耗在TCP握手、连接释放上,实际业务QPS根本没上去。 - 确认有没有大量频繁执行
SELECT切换DB的请求,这类无意义的操作会带来大量不必要的资源开销,完全可以通过业务拆分规避。
第四步:补全基础资源校验
前面三步都没找到问题,再回头看底层资源指标:
- 内存:确认
used_memory没有接近你设置的maxmemory上限,避免频繁触发内存淘汰、内存碎片整理抢占CPU资源。 - 网络带宽:确认实例的出入带宽没有打满,大Key的传输、主从同步流量很容易把网卡跑满,导致请求排队延迟飙升。
- 主机环境:检查系统有没有开透明巨页(Transparent Huge Pages),这个配置会让Redis的fork操作慢几十倍,直接引发全实例的延迟尖刺,官方明确要求必须关闭。
| 场景 | 要解决的问题 | Redis 常用方案 |
|---|---|---|
| 分布式锁 | 多个服务实例同时操作同一资源 | SET NX PX + Lua 安全解锁 |
| 幂等 | 用户重复点击、网络重试导致重复处理 | SET NX EX + 请求结果记录 |
| 限流 | 防止某个用户或接口请求过多 | INCR + TTL + Lua |
| 库存扣减 | 并发购买造成库存超卖 | Lua 原子检查并扣减 |
分布式锁
假设你的 FastAPI 项目部署了两个服务实例:
- 实例 A 正在处理用户的订单。
- 实例 B 也同时接收到了同一订单的处理请求。
Python 的 asyncio.Lock 或 threading.Lock 只能在各自的进程内协调任务,不能天然协调两个独立的服务实例。
这时可以使用 Redis 作为多个实例共享的锁状态存储。
分布式锁的三个面试陷阱
第一,锁必须设置过期时间,否则服务进程崩溃后可能遗留一个无法自动释放的锁。
第二,过期时间不能随意设置。如果业务执行时间超过锁的有效期,其他请求可能获得新锁,而旧请求仍然在执行。耗时较长的业务需要考虑安全续期、适当的租约设计,必要时还需要 fencing token 等机制。
第三,分布式锁不等于业务数据绝对安全。对于订单、支付和库存等重要操作,仍应使用数据库事务、唯一约束或其他业务级保护措施。
高可用集群
1. 数据复制(主从复制)
主从复制:主节点将数据变化复制给副本节点,帮助副本保持数据副本,并可分担部分只读请求。
参考答法
主从复制解决了什么问题?
第一,增加数据副本。 主节点故障时,副本可能成为恢复服务的基础。
第二,分担部分读流量。 某些允许读取稍旧数据的业务,可以将一部分读请求发送到副本。
第三,为故障转移提供候选节点。 Sentinel 或 Cluster 可以在符合条件时提升副本。
note:
副本的读取可能落后于主节点。
复制不是备份:误删除、错误更新等数据变化也可能被复制到副本。
主从复制有个明显缺点:如果主节点崩溃,副本不一定会自动成为新主节点。
这就是 Sentinel(哨兵)要解决的问题。
2. Sentinel 哨兵
监控主从实例;主节点故障时,协调选举并提升一个合适的副本为新主节点。
Redis 的高可用方案,负责监控主从节点,主节点挂了就自动做故障转移,并告诉客户端新地址。
实际部署通常会配置多个 Sentinel,让它们相互通信。典型部署会使用三个 Sentinel 实例,并配置合适的 quorum(法定判断数量)。
这里有两个面试概念:
- 主观下线(SDOWN):某个 Sentinel 认为主节点在指定时间内没有正常响应。
- 客观下线(ODOWN):达到配置要求的 Sentinel 共同认定主节点不可达。
参考答法
面试重点:Sentinel 会自动帮客户端切换连接吗?
Sentinel 会负责发现新的主节点,但应用客户端也必须支持 Sentinel 协议和主节点重新发现。
如果 FastAPI 仍然把 Redis 地址硬编码为某一个固定 IP,它不一定会自动切换到新主节点。
面试标准回答:
Sentinel 提供非分片主从架构的监控与自动故障转移能力。主节点故障时,Sentinel 根据配置和可用副本选择新主节点。应用客户端还需要支持通过 Sentinel 发现主节点,不能只依赖固定的旧地址。
通过这种机制,Sentinel 可以降低单个监控节点误判带来的风险。它仍然不能消除所有网络分区和故障情形。
如果数据规模和吞吐量继续增长,单个主节点可能成为内存、CPU 或网络瓶颈。这时可以考虑 Redis Cluster。
3. Redis Cluster:数据太多,单机放不下怎么办?
把数据按哈希槽(16384 个 slot)分散到多个节点,解决单机容量和性能上限。
将数据分布到多个主节点,并支持副本与分片主节点的故障转移。
| 项目 | 说明 | 示例值 |
|---|---|---|
| 集群规模 | 至少 3 主 3 从 | 3 master + 3 slave |
| 哈希槽总数 | 固定 16384 个 slot | 0 ~ 16383 |
| 分片规则 | CRC16(key) % 16384 | key=user:1 → slot |
| 主节点职责 | 负责读写自己槽内的数据 | node-1 管 0~5460 |
| 从节点职责 | 复制主节点,故障时顶替 | node-1 的 slave |
| 故障转移 | 主挂了从自动升主 | 需超过半数主节点同意 |
| 客户端 | 需支持集群模式 | 否则要加代理 |
| 搭建命令 | redis-cli --cluster create | 6 个节点地址 |
查看 Redis 运行统计
命令 · 查看运行统计
docker exec -it redis-demo1 redis-cli INFO stats
| 指标 | 含义 |
|---|---|
keyspace_hits | 成功命中 Key 的累计次数 |
keyspace_misses | 未找到 Key 的累计次数 |
evicted_keys | 因内存淘汰而删除的 Key 数量 |
expired_keys | 因过期而删除的 Key 数量 |
instantaneous_ops_per_sec | 最近一段时间的命令处理速率 |
客户端与命令统计(INFO clients / stats)
| 字段 | 含义 |
|---|---|
connected_clients | 当前连接的客户端数量 |
cluster_connections | 集群内部节点间连接数(集群模式才有) |
maxclients | 允许的最大连接数上限 |
client_recent_max_input_buffer | 最近客户端最大输入缓冲 |
client_recent_max_output_buffer | 最近客户端最大输出缓冲 |
blocked_clients | 正在阻塞等待(BLPOP 等)的客户端数 |
tracking_clients | 开启客户端缓存的连接数 |
pubsub_clients | 订阅了 Pub/Sub 的客户端数 |
watching_clients | 正在 WATCH 的客户端数 |
clients_in_timeout_table | 超时表中的客户端数 |
total_connections_received | 累计接受连接数(含重连) |
total_commands_processed | 累计处理命令数 |
instantaneous_ops_per_sec | 当前每秒操作数(QPS 实时值) |
total_net_input_bytes | 累计网络入流量字节 |
total_net_output_bytes | 累计网络出流量字节 |
rejected_connections | 因超过 maxclients 被拒绝的连接数 |
sync_full / sync_partial_ok / sync_partial_err | 主从全量/部分同步次数 |
expired_keys | 累计过期删除的 key 数 |
evicted_keys | 因内存满被淘汰的 key 数 |
keyspace_hits / keyspace_misses | 命中 / 未命中次数 |
pubsub_channels / pubsub_patterns | 当前频道 / 模式订阅数 |
latest_fork_usec | 最近一次 fork 耗时(微秒),影响阻塞 |
total_forks | 累计 fork 次数 |
migrate_cached_sockets | MIGRATE 缓存的 socket 数 |
内存(Memory)信息
MEMORY USAGE 返回指定 Key 的内存占用估算值,单位是字节,不是访问次数或 TTL。
| 字段 | 含义 |
|---|---|
used_memory | Redis 实际分配的内存总量(字节),最核心指标 |
used_memory_human | 同上,人类可读格式(如 1.2G) |
used_memory_rss | 操作系统视角看到的物理内存(RSS),含内存碎片 |
used_memory_rss_human | 同上,可读格式 |
used_memory_peak | 历史峰值内存 |
used_memory_peak_human | 峰值可读格式 |
used_memory_peak_perc | 当前内存占峰值的百分比 |
used_memory_overhead | 服务端自身管理开销(不是数据) |
used_memory_startup | 启动时初始内存 |
used_memory_dataset | 纯数据集占用内存 |
used_memory_dataset_perc | 数据集占净内存比例 |
allocator_allocated | 分配器已分配字节数 |
allocator_active | 分配器活跃页字节数 |
allocator_resident | 分配器驻留字节数 |
total_system_memory | 机器总内存 |
total_system_memory_human | 同上,可读格式 |
used_memory_lua | Lua 引擎占用内存(旧版本字段) |
used_memory_vm_eval | 脚本引擎占用内存(新版本) |
used_memory_scripts_eval | 脚本缓存占用内存 |
number_of_cached_scripts | 已缓存的 Lua 脚本数量 |
used_memory_vm_functions | 函数引擎(Redis Functions)占用 |
maxmemory | 配置的最大可用内存,0 表示不限 |
maxmemory_human | 同上,可读格式 |
maxmemory_policy | 内存淘汰策略,如 allkeys-lru、noeviction |
allocator_frag_ratio | 分配器碎片率(active/allocated) |
allocator_frag_bytes | 碎片字节数 |
allocator_rss_ratio | RSS/active 比例 |
allocator_rss_bytes | 对应字节数 |
rss_overhead_ratio | RSS 相对分配器的额外开销比 |
rss_overhead_bytes | 对应字节数 |
mem_fragmentation_ratio | 内存碎片率 = rss / used_memory,关键指标 |
mem_fragmentation_bytes | 碎片字节数 |
mem_not_counted_for_evict | 不计入淘汰的内存(如 AOF 缓冲) |
mem_replication_backlog | 复制积压缓冲区占用 |
mem_total_replication_buffers | 复制相关缓冲总占用 |
mem_clients_slaves | 从节点客户端缓冲占用 |
mem_clients_normal | 普通客户端缓冲占用 |
mem_cluster_links | 集群节点间连接占用 |
mem_aof_buffer | AOF 缓冲区占用 |
mem_allocator | 使用的内存分配器,如 jemalloc-5.3.0 |
active_defrag_running | 活跃碎片整理是否在跑 |
lazyfree_pending_objects | 等待异步释放的对象数 |
lazyfreed_objects | 已惰性释放对象数 |
集群(Cluster)信息
| 字段 | 含义 |
|---|---|
cluster_enabled | 是否开启集群,1/0 |
role | 当前节点是 master 还是 slave |
connected_slaves | 该主节点挂的从节点数 |
master_link_status | 从节点与主节点链路是否 up/down |
master_repl_offset | 复制偏移量,判断主从是否同步 |
slave_repl_offset | 从节点复制偏移量 |
cluster_known_nodes | 集群已知节点总数 |
cluster_size | 集群内主节点数量(决定 slot 覆盖) |
cluster_slots_assigned | 已分配的哈希槽数,健康时应为 16384 |
cluster_slots_ok | 状态正常的槽数 |
cluster_slots_pfail / cluster_slots_fail | 疑似失败 / 确认失败的槽数 |
cluster_stats_messages_* | 节点间各种消息收发计数 |
日常真正需要盯的字段
字段上百个,但 95% 的时间你只需要看下面这些:
① 内存类(最优先)
used_memory/used_memory_human— 服务实际用了多少内存used_memory_rssvsused_memory— 两者差距大说明有内存碎片mem_fragmentation_ratio— 大于 1.5 要警惕,大于 2 建议重启或开启碎片整理maxmemory和maxmemory_policy— 是否设了上限、淘汰策略是否合理
② 连接与流量
connected_clients— 连接数是否异常增长(连接泄漏)rejected_connections— 有没有被拒绝的连接(maxclients 打满)instantaneous_ops_per_sec— 当前 QPSblocked_clients— 有没有大量阻塞
③ 性能与淘汰(最容易被忽略但很重要)
evicted_keys— > 0 说明内存不够在淘汰数据,业务可能丢 keykeyspace_hits/keyspace_misses— 算命中率hits/(hits+misses)latest_fork_usec— fork 太慢会导致主线程卡顿(影响 RDB/AOF 重写)expired_keys— 过期清理是否正常
④ 集群健康(集群模式下必看)
cluster_slots_assigned— 必须等于 16384,少了说明有槽没分配cluster_slots_fail— 必须为 0,大于 0 说明有槽不可用cluster_size— 主节点数是否符合预期master_link_status— 从节点链路是否 up
⑤ 持久化/复制(如果有从节点或 AOF)
master_repl_offset和从节点的差值 — 差值持续增大说明复制落后rdb_last_bgsave_status/aof_last_bgrewrite_status— 最近一次持久化是否成功
一句话记忆法:
内存看
used_memory和mem_fragmentation_ratio;健康看
evicted_keys和命中率;集群看
cluster_slots_assigned和cluster_slots_fail;性能看
instantaneous_ops_per_sec和latest_fork_usec。
观察 Redis 的响应延迟
命令 · 观察响应延迟
docker exec -it redis-demo1 redis-cli --latency
面试时如何排查 Redis 变慢?
Redis 性能排查不能只看一个指标。我会先检查实例状态、内存和淘汰情况,再查看慢查询日志与命令统计。如果 Redis 命令执行很快,但应用接口仍然慢,就会进一步检查网络、连接池、FastAPI 业务逻辑和数据库查询,区分服务端延迟与端到端请求延迟。
INFO stats— 查看命中率、淘汰次数、过期次数和命令速率INFO memory— 分析 Redis 内存使用、RSS 和碎片指标MEMORY USAGE— 查看单个 Key 的内存占用SLOWLOG GET 10— 排查执行时间较长的 Redis 命令INFO commandstats— 查看各类命令的调用次数和平均执行时间redis-cli --latency— 观察 Redis 测试链路的响应延迟docker stats— 查看 Redis 容器的 CPU 和内存使用情况
高级数据结构
| 数据结构 | 主要解决什么问题 | 典型应用 |
|---|---|---|
| Bitmap | 高效记录大量元素的 0/1 状态 | 用户签到、活跃标记 |
| HyperLogLog | 用较少内存估算不重复元素数量 | 网站 UV、独立访客数 |
| GEO | 按经纬度存储位置并进行附近搜索 | 附近门店、附近充电站 |
| Stream | 保存有序消息流,支持消费者组 | 异步任务、事件处理 |
这些数据结构适合不同的问题,不能互相随意替代。Redis 官方文档也将它们分别归类为位图、概率统计结构、地理空间索引和消息流。
1. Bitmap 面试要点
常用命令:
| 命令 | 作用 |
|---|---|
SETBIT | 设置指定偏移位置的 bit |
GETBIT | 获取指定偏移位置的 bit |
BITCOUNT | 统计值为 1 的 bit 数量 |
BITOP | 对多个 Bitmap 执行位运算 |
例如,用户连续两天都签到,可以把两天的 Bitmap 通过 BITOP AND 运算,统计两个集合的交集人数。
需要注意: Bitmap 很适合状态密集、编号相对连续的场景。如果直接用极大的稀疏偏移量,依然可能造成额外内存开销。此外,一个 bit 只能表达一个二元状态,复杂用户信息仍应使用其他数据结构。
2. HyperLogLog 面试要点
面试官最常问:
为什么不直接使用 Set 统计 UV?
推荐回答:
Set 可以保存真实成员并精确去重,但内存开销通常会随着成员数增长。HyperLogLog 使用概率算法估算独立元素数量,在允许一定估算误差且不需要枚举成员时,可以用较少内存处理大规模 UV 统计。
注意两个限制:
第一,HyperLogLog 不是精确计数器,小数据量时即使结果正确,也不代表它永远不会出现误差。
第二,它不能告诉你具体有哪些用户,也不能直接回答“某个用户是否访问过”。如果要精确查成员,应选择 Set 或其他合适的数据存储。
3. GEO 面试要点
推荐回答:
GEO 适合附近门店、附近充电站等基于经纬度的距离检索。可以用 GEOADD 写入经纬度,用 GEOSEARCH 按指定中心和半径查询,用 GEODIST 计算成员间距离。它适合常见的点位置查询,但不是完整的 GIS 引擎,复杂多边形或专业地理分析还需要其他解决方案。
Redis 的 GEO 功能底层利用有序集合来存储地理位置信息,所以某些 ZSet 命令也可以用于管理 GEO 成员。
4. Stream —— 消息流与消费者组
Stream 是什么?
Stream 是 Redis 提供的一种消息流结构。消息按 ID 排列,可以追加新消息、读取历史记录,还可以使用消费者组把消息分配给不同消费者。
例如,你的 FastAPI 项目收到一个订单后,不一定需要在 HTTP 请求里完成所有耗时处理。可以先产生一条订单事件,由后台消费者处理后续任务。
但要注意:仅仅把消息写入 Stream,并不自动意味着业务处理一定成功。 消费者组提供待确认消息等机制,仍需要应用自己正确处理失败、重试与重复消费。
命令 · 创建 Stream 消费者组
docker exec -it redis-demo1 redis-cli XGROUP CREATE lesson12:orders lesson12-group 0 MKSTREAM
lesson12:orders是 Stream Key。lesson12-group是消费者组名称。0表示消费者组从流的起始位置建立读取位置。MKSTREAM表示如果 Stream 不存在就创建
命令 · 查看整个消息流
docker exec -it redis-demo1 redis-cli XRANGE lesson12:orders - +
Stream 面试要点
| 命令 | 用途 |
|---|---|
XADD | 向 Stream 追加消息 |
XRANGE | 查询指定范围的消息 |
XREAD | 读取消息流 |
XGROUP CREATE | 创建消费者组 |
XREADGROUP | 通过消费者组读取消息 |
XPENDING | 查看尚未确认的消息 |
XACK | 确认已处理的消息 |
“Redis Stream 与 List 有什么区别?”
推荐回答:
List 可以实现简单的队列,但基础的弹出操作会直接移除元素。Stream 提供消息 ID、历史消息范围读取、消费者组和待确认消息等机制,更适合需要多消费者分工、确认和失败恢复的事件处理场景。但可靠性仍取决于持久化、消息保留策略、消费者恢复和业务幂等设计。
Stream 就是来解决这些的,它引入了三个 List 没有的概念:
- 消息 ID:每条消息有位置标识,可精确定位
- 消费组(Consumer Group):多个消费者组内分工,一条消息只被组内一个消费者拿到
- PENDING + ACK:读过但没确认的消息留在 PEL(Pending Entries List),可查询、可重投
Pub/Sub 实时广播
Pub/Sub(Publish/Subscribe,发布/订阅) 是 Redis 提供的一种消息通信机制。
它解决的问题是:
一个程序发生了某件事,如何让其他正在监听的程序立即知道?
例如,你的 DeepResearch 系统正在执行一个研究任务:
- Worker A: 正在搜索论文。
- Worker B: 正在分析论文。
- FastAPI: 需要把执行进度实时通知给前端。
- 前端: 希望立即看到任务进度,而不是每隔几秒请求一次接口。
这时,Worker 可以向 Redis 发布消息,订阅了对应频道的程序就能收到消息。
Pub/Sub 的三个核心概念
| 概念 | 含义 | Redis 命令 |
|---|---|---|
| Publisher | 发布消息的一方 | PUBLISH |
| Subscriber | 订阅频道的一方 | SUBSCRIBE |
| Channel | 消息广播的频道 | 例如 task:123 |
Pub/Sub 和 Redis Stream 有什么区别?
这是你最需要掌握的地方,因为在 DeepResearch 项目中,两者的职责不同。
Pub/Sub:实时广播
- 适合进度通知、状态变更提醒。
- 消息不持久化。
- 离线订阅者会错过消息。
- 多个订阅者可以同时收到同一条消息。
Redis Stream:可追踪的消息流
- 适合任务入队、Worker 消费。
- 消息可保留在 Redis 中。
- 支持消费组、消息确认和待处理消息恢复机制。
- 更适合需要可靠处理的后台任务。
- FastAPI 创建任务,并将任务写入 SQLite。
- Redis Stream 接收任务消息,等待 Worker 消费。
- Worker 执行 LangGraph 研究流程。
- Worker 将研究进度事件写入 SQLite。
- Worker 通过 Pub/Sub 发布一条进度通知。
- FastAPI 的进度推送服务收到通知,向在线前端推送更新。
这里,Stream 负责传递需要处理的任务,Pub/Sub 负责快速通知其他组件。任务历史和进度事件仍然可以保存在 SQLite 中。
- 在 FastAPI 项目中,Pub/Sub 可以做实时进度通知,但可靠的任务状态和事件历史应该另有持久化来源。
- 如果通知丢失,前端可以根据任务 ID 从 SQLite 查询最新状态或补齐历史事件;不要把 Pub/Sub 当作唯一的数据来源。
Redis 为什么单线程也快?
- 数据主要存储在内存中,简单查询不需要去读磁盘。
- 常见命令执行路径不需要多线程争抢同一份数据。
为什么还会阻塞
因为命令执行通常由一个主要线程负责,所以如果某条命令执行很久,后面的命令就可能等待。
如果 huge_set 包含大量成员,Redis 不仅需要遍历集合,还需要组织和返回大量数据,产生明显的 CPU、内存和网络开销。
面试时不要说“Redis 所有操作都只有一个线程完成”。
更准确的表达是:
Redis 的核心命令执行通常采用单线程模型,以减少共享数据的同步开销;同时,它可以利用事件驱动的网络处理方式以及后台线程或子进程完成部分工作。具体机制与 Redis 版本和配置有关。
| 命令 | 主要风险 | 常见替代方案 |
|---|---|---|
| KEYS * | 遍历大量 Key,可能阻塞 | 使用 SCAN 分批遍历 |
| SMEMBERS | 一次返回整个集合 | 根据业务使用 SSCAN 分批处理 |
| 大范围 LRANGE | 处理和返回大量元素 | 使用分页、限制返回数量 |
| 超大 Lua 脚本 | 执行期间长时间占用命令执行路径 | 缩小脚本工作量,拆分非原子业务 |
Outbox
Outbox(发件箱模式,Transactional Outbox Pattern) 是分布式系统里解决「数据库写成功、消息没发出去」这类不一致问题的经典方案。核心思路一句话:
把”要发的消息”和”业务数据”写在同一个数据库事务里,再由一个后台进程把消息发出去。
一、它要解决什么问题
考虑下单场景:下单成功要扣库存(写数据库)+ 发消息通知积分服务。
示例 · Python:双写不一致的典型写法
# 天真的写法
await db.insert(order) # ① 写订单
await mq.send("order.created", ...) # ② 发消息
这两步不在同一个事务里,于是有经典的双写不一致:
双写不一致的三种情况:
| 情况 | 后果 |
|---|---|
| ①成功,②失败(MQ 挂了/进程崩了) | 订单建了,积分没加,数据不一致 |
| ①成功,②成功但发重复 | 积分重复加 |
| 先发消息再写库,反过来同理 | 消息发了,订单没建,更糟 |
根因:数据库事务保证不了”写库 + 发 MQ”的原子性。MQ 不受你的 DB 事务管辖。
二、Outbox 的核心做法
关键点:不直接发消息,而是把”待发消息”当一条普通数据写进同一张 outbox 表。
示例 · Python:业务数据与待发消息同事务
async with db.transaction(): # 同一个事务!
await db.insert(order) # 业务数据
await db.insert(outbox, { # 待发消息,也写进 DB
"topic": "order.created",
"payload": {"order_id": ...},
"status": "pending",
})
# 事务提交 → 两条一起成功,或一起失败,绝无中间态
然后后台有个独立的发布器(publisher)不断扫描 outbox 表:
示意 · 后台 Publisher 轮询逻辑(伪代码)
循环:
1. 查出 status = pending 的消息
2. 发送到 MQ
3. 发送成功 → 标记 status = sent(或删除)
4. 失败 → 保留,下次重试
三、为什么这样就一致了
因为 「业务写成功」和「消息记录存在」变成了同一个原子操作(同一事务):
- 事务提交 → 订单在,消息记录也一定在,后台迟早会发出去
- 事务回滚 → 订单不在,消息记录也不在,不会误发
于是”消息一定会被发”这件事,由数据库的可靠性来兜底,不再依赖 MQ 当时是否可用。
四、完整流程示意
示意 · Outbox 完整流程(纯文本)
业务事务
┌──────────────────────────────┐
│ INSERT order │
│ INSERT outbox (pending) │ ← 原子提交
└──────────────────────────────┘
│
▼
后台 Publisher 轮询
│
▼
发到 MQ / 消息总线
│
┌─────┴─────┐
成功 失败
│ │
标记 sent 保留 pending
(重试)
五、两个必然出现的「坑」及处理
1. 重复投递(至少一次)
后台可能”发了但没来得及标记就崩了”,重启后又发一次 → 消息重复。
所以消费端必须幂等:
- 用唯一业务 ID 去重(如 order_id + 事件类型)
- 消费前查”这条是否处理过”,或写唯一索引
Outbox 保证的是 “至少一次(at-least-once)“,不是恰好一次。
2. 发送顺序
如果轮询/并发发送,可能乱序。需要顺序的场景:
- 按主键/自增 ID 排序发送
- 单分区、单消费者串行处理
3. 消息在哪发
消息投递方式对比:
| 方案 | 做法 | 特点 |
|---|---|---|
| 轮询(Polling Publisher) | 定时扫 outbox 表 | 简单,但有延迟和空转 |
| CDC(如 Debezium) | 订阅数据库 binlog | 实时、重量级、需额外组件 |
CDC 更优雅:直接监听数据库日志,outbox 表一变就发出去,不用轮询。但搭建成本高。
六、Outbox 的优缺点
优点
- 解决双写不一致,不依赖分布式事务(不用 2PC,简单可靠)
- 业务和消息同库同位,实现直观
- 失败自动重试,消息不丢
缺点
- 需要额外一张表和后台发布器
- 是”最终一致”,不是强一致(有延迟)
- 需要消费端幂等配合
- 轮询模式有延迟和 DB 压力;CDC 模式运维成本高
速记清单
1. 核心数据结构
| 类型 | 一句话记忆 | 常用命令 |
|---|---|---|
| String | 缓存、验证码、计数器 | GET、SET、INCR |
| Hash | 对象字段 | HSET、HGET |
| List | 有序列表、简单队列 | LPUSH、RPUSH、LPOP |
| Set | 去重、集合运算 | SADD、SINTER |
| ZSet | 分数与排序 | ZADD、ZREVRANGE |
| Bitmap | 二元状态 | SETBIT、GETBIT |
| HyperLogLog | 估算去重数量 | PFADD、PFCOUNT |
| GEO | 地理位置查询 | GEOADD、GEODIST |
| Stream | 消息流与消费组 | XADD、XREADGROUP、XACK |
2. 缓存三大问题
| 问题 | 本质 | 常见方案 |
|---|---|---|
| 缓存穿透 | 请求的数据不存在 | 缓存空值、布隆过滤器、参数校验和限流 |
| 缓存击穿 | 热点 Key 失效 | 互斥锁、逻辑过期、热点预热 |
| 缓存雪崩 | 大量 Key 集中失效或缓存整体故障 | TTL 随机抖动、高可用、限流、熔断、多级缓存 |
记忆口诀:穿透查不到,击穿热点倒,雪崩一大片。
3. 持久化
| RDB | AOF |
|---|---|
| 保存某个时间点的数据快照 | 记录写操作,以便重放恢复 |
| 文件通常更紧凑,适合备份 | 可以通过同步策略调整持久性与性能 |
| 可能丢失两次快照之间的变更 | 同样存在数据丢失风险,取决于同步和故障条件 |
appendfsync everysec:通常以每秒同步为目标,并不保证任何故障下都只丢失一秒以内的数据。
4. 原子性、事务、Lua、Pipeline
| 机制 | 记住这个结论 |
|---|---|
单条命令,如 INCR | 通常是原子操作 |
MULTI/EXEC | 一组命令连续执行,但不支持传统自动回滚 |
| Lua | 将条件判断和多条 Redis 操作组合为原子操作 |
| Pipeline | 减少网络往返,不保证普通批次原子性 |
5. 分布式锁与幂等
获取锁:
命令 · 获取分布式锁
SET lock:order:1001 unique_token NX PX 10000
安全解锁:通过 Lua 比较 Token,再原子删除锁。
记住两个边界:
- Token 可以防止误删别人的锁,但不能阻止旧请求在锁过期后继续执行业务。
- 幂等键可以识别重复请求,但真实订单还需要持久化状态、数据库唯一约束以及失败恢复。
6. 高可用与故障排查
| 主题 | 重点 |
|---|---|
| 主从复制 | 复制数据,通常是异步的 |
| Sentinel | 监控和故障转移 |
| Cluster | 数据分片、哈希槽、集群级故障转移 |
INFO stats | 命中率相关计数、过期和淘汰统计 |
INFO memory | 内存使用与碎片情况 |
SLOWLOG GET 10 | 发现超过阈值的命令 |
MEMORY USAGE | 估算单个 Key 的内存占用 |
--bigkeys | 辅助识别大 Key |
评论 0
评论保存在本站服务器,无需登录。带「作者」标识的是站主回复,请友善交流。
还没有评论,来写第一条。