第 08 篇 · Redis 与缓存架构
本篇把缓存当加速层而非数据源:结构与持久化 → 一致性与三大故障 → 锁/过期/布隆 → 集群、多级缓存与 Stream/值班。
骨架见 M10.05 / M07.02。本篇只补领域细枝,框在 核心方法论 · 开篇。
缓存主线是「结构编码与持久化能力 → 与 DB 的一致性协议 → 穿透/击穿/雪崩分型防护 → 锁与过期正确性 → Cluster/多级缓存与消息化」,故障时先止血再谈架构。
- 数据类型与底层编码:5 基础 + 4 扩展类型,小对象编码切换省内存;命令接口与 encoding 解耦是选型依据。
- RDB/AOF 与恢复目标:快照与日志在 RPO/RTO、写放大间权衡;混合持久化与 fork/COW 抖动要进容量模型。
- 三大问题与一致性协议:穿透/击穿/雪崩分因施策;Cache Aside、延迟双删、CDC 各自窗口与补偿。
- 分布式锁与过期淘汰:Redisson 可重入 + WatchDog 续期;TTL 过期与 maxmemory 淘汰是两套机制,不可混谈。
- 布隆、多级缓存与 Cluster:概率过滤降穿透;本地+Redis 分层;16384 槽、MOVED、Gossip 与热 key/槽迁移。
- 高并发优化、Stream 与值班:大/热 key、pipeline/Lua、连接池;Stream 消费组与 PEL;击穿现场分钟级止血手册。
- 权威在 DB(或指定源);缓存失败必须可降级,用户路径不能「缓存挂了就全站 500」,降级策略写进 Runbook。
- 穿透/击穿/雪崩防护组合拳进基线:空值缓存、单飞、过期抖动、隔离与限流;热点 key 有探测与预案。
- 一致性方案按业务容忍窗口选型并文档化;禁止口头「最终一致」而无补偿与对账。
- 分布式锁必须可重入/续期/防误删语义清晰;持锁业务要幂等,超时与时钟风险写进评审。
- maxmemory 与淘汰策略、持久化策略随实例角色(缓存 vs 存储)分开配置;禁止一套参数打天下。
- Cluster 扩缩容与跨槽限制进变更手册;大 key/热 key 扫描与拆分有定期任务。
- 多级缓存要有失效广播或短 TTL 策略,防止本地缓存成为幽灵源。
- 击穿与雪崩值班手册分钟级:限流、临时永不过期/互斥回源、扩容只读;恢复后再做复盘,不在现场做架构演讲。
- 缓存非源:源故障与缓存故障的体验与降级路径要事先设计;缓存只是加速层。
- 分型再治理:穿透/击穿/雪崩失效原因不同,方案不可一套贴膏药。
- 一致性有窗口:双写无事务;选协议就是选可接受的不一致窗口与补偿。
- 锁是契约:互斥、续期、释放与业务幂等是一整套;只 setnx 不等于分布式锁。
- 过期≠淘汰:TTL 删除与内存满驱逐目标不同,混用诊断会走偏。
- 集群有物理边界:槽、迁移与热点分片决定上限;命令对了也可能打在同一槽上。
- 现场先止血:击穿值班目标是恢复保护层,架构优化放在事后。
这道题考察对 Redis 对外数据类型与底层编码(redisObject + encoding)解耦设计的理解:为什么同一种类型(如 List)在不同数据量下会切换不同底层结构(quicklist/ziplist/listpack)?本质是 Redis 用"统一命令接口 + 多种可切换编码"的设计,让小数据省内存、大数据保性能。掌握 5 种基础类型(String/List/Hash/Set/Zset)+ 4 种扩展类型(Stream/Bitmap/HyperLogLog/Vector Set)的命令语义、底层编码演进、典型场景,是 Redis 选型与性能优化的地基。
早期 Redis 2.x~3.x:基础五类型 + 简单编码。String 底层 int/embstr/raw;List 用 ziplist(小,已废弃:Redis 7.0 起全面退役,替代:listpack,消除连锁更新问题)或 linkedlist(大);Hash/Set/Zset 用 ziplist(同上已废弃)或 hashtable/skiplist。痛点:ziplist 的连锁更新(cascade update)问题——中间元素扩张导致后续所有元素 prevlen 字段级联修改,最坏 O(n²)。
Redis 3.2:List 引入 quicklist——双向链表 + 每节点一个 ziplist(Redis 7.0 起节点内部改用 listpack),兼顾内存紧凑与修改效率,替代纯 linkedlist。Redis 5.0 引入 Stream(借鉴 Kafka 消费组模型)。
Redis 7.0:ziplist 全面退役,引入 listpack。listpack 取消了 prevlen 字段,每个元素只存自身长度,从根本上消除连锁更新。Hash/Set/Zset 的小数据编码从 ziplist 切换为 listpack,List 的 quicklist 内部也从 ziplist 替换为 listpack(quicklist->listpack)。
趋势:① 编码持续向"紧凑 + 无连锁更新"演进,listpack 之后社区在探索 listpack 是否可承载更大容量;② 类型语义融合数据库能力——Stream 补齐消息队列、Bitmap 补齐位图统计、HyperLogLog 补齐基数去重;③ Redis 7.4+ 引入 Hash field expiration(hexpire)、Redis 8.0(2025-05 GA)引入 Vector Set(向量检索,HNSW 索引,命令 VADD/VSIM/VRANDMEMBER),向多模数据库演进。
redisObject 抽象:每个值都是 redisObject{type, encoding, lru, refcount, *ptr}。type 是对外类型,共 9 种:5 基础(String/List/Hash/Set/Zset)+ 4 扩展(Stream/Bitmap/HyperLogLog/Vector Set),encoding 是底层编码。Redis 根据元素数量与大小阈值(hash-max-listpack-entries
等)自动切换编码,对用户透明。
| 类型 | 典型底层编码(7.x / 8.0) | 核心命令 | 典型场景 |
|---|---|---|---|
| String | int / embstr / raw | SET/GET/INCR/SETNX | 缓存对象JSON、计数器、分布式锁、限流器 |
| List | quicklist(=listpack链表) | LPUSH/RPOP/LRANGE/BLPOP | 消息队列(LPUSH+BRPOP)、最新N条、栈/队列 |
| Hash | listpack / hashtable | HSET/HGET/HMSET/HINCRBY | 对象字段存储(省内存)、购物车、计数器分组 |
| Set | intset / listpack / hashtable | SADD/SMEMBERS/SINTER/SUNION | 标签、共同好友、去重、抽奖 |
| Zset | listpack / skiplist+hashtable | ZADD/ZRANGE/ZRANGEBYSCORE | 排行榜、延迟队列(score=执行时间)、带权去重 |
| Stream | radix tree + listpack | XADD/XREAD/XGROUP | 消息队列(消费组+ACK)、事件日志 |
| Bitmap | String(SDS位操作) | SETBIT/BITCOUNT/BITFIELD | 用户签到、在线状态、布隆过滤器 |
| HyperLogLog | 稀疏/稠密 String | PFADD/PFCOUNT/PFMERGE | UV 去重统计(误差 0.81%, 固定 12KB) |
| Vector Set | HNSW + 8-bit/二值量化8.0+ | VADD/VSIM/VRANDMEMBER | 语义检索、RAG 向量召回 |
ziplist → listpack 的根本差异:
ziplist(已废弃:Redis 7.0 起,替代:listpack)每个元素结构:[prevlen][encoding][data],prevlen 记录前一节点长度用于反向遍历。当中间节点扩张使 prevlen 从 1 字节变 5 字节时,后继节点的 prevlen 都需重算——连锁更新,极端情况 O(n²)。
listpack 结构:[encoding][data][backlen],backlen 记录自身长度用于反向遍历。修改节点只影响自身,无连锁更新。这是 Redis 7.0 的关键底层优化。
Zset 的 skiplist+hashtable 双结构:hashtable 提供 O(1) 的 member→score 查找,skiplist 提供 O(logN) 的按 score 范围查询。两者共享元素节点指针,不重复存储数据。跳表为何不用红黑树?跳表实现更简单、范围查询天然支持(链表顺指针走)、并发友好(局部锁)。
Redis 类型选型的核心原则:① 存对象用 Hash 而非 String(JSON)——Hash 可独立修改字段、listpack 编码省内存;② 统计去重用 HyperLogLog/Bitmap 而非 Set——空间常数级;③ 消息队列用 Stream 而非 List——Stream 有消费组、ACK、持久化,List 只有简单队列语义。
编码阈值调优:默认 hash-max-listpack-entries=128, hash-max-listpack-value=64。若 Hash 字段多但单值小(如商品属性),可适当调大 entries 阈值保持 listpack 编码省内存;反之若字段大且频繁改单字段,应主动超出阈值切 hashtable(避免 listpack 频繁 realloc)。用 OBJECT ENCODING key 查看实际编码。
排行榜典型实现:Zset 存 member=userId, score=积分。ZADD 更新积分、ZREVRANGE 0 9 取 Top10、ZREVRANK 取个人排名。注意 ZREVRANGE 是 O(logN+M),避免一次性取大范围;百万级排行榜用 Zset 性能仍优秀。
延迟队列实现:Zset 存 member=任务ID, score=执行时间戳。消费端轮询 ZRANGEBYSCORE 0 now LIMIT 0 100 取到期任务,ZREM
成功则执行。注意 ZRANGEBYSCORE+ZREM 非原子,多消费者需用 Lua 保证不重复消费,或用 ZPOPMIN(原子但破坏按时间顺序)。
追问 1:String 的 embstr 与 raw 编码有何区别?为何 44 字节是分界线?
embstr(embedded string)将 redisObject 与 SDS 内存连续分配,一次 malloc,对 CPU cache 友好;raw 是 redisObject 与 SDS 分两次 malloc。embstr 要求 redisObject + SDS 连续分配且总大小 ≤ 64 字节(jemalloc 小对象分配边界),即数据部分 ≤ 64 − 16(redisObject) − 3(SDS 头) − 1(\0结尾) = 44 字节(Redis 4.x 后调整为 44)。超过即切 raw。embstr 是只读的——修改会转为 raw 再操作。
追问 2:Stream 相比 List/Kafka 做消息队列的边界?
Stream 优势:内置消费组(XGROUP)、ACK 机制(XPENDING/XCLAIM)、持久化、内存级低延迟。劣势:单机容量受内存限制、无分区并行(Kafka 分区多消费者并行)、消息堆积后回溯不如 Kafka 灵活。适用:中小规模(日千万级)、对延迟敏感、不希望引入独立 MQ 的场景;大规模/可观测性要求高用 Kafka。
- 误认为 Bitmap/HyperLogLog 是独立类型:
TYPE返回string(它们确实以字符串存储),需要用OBJECT ENCODING或命令语义(GETBIT/PFADD)区分——不是"不能用 TYPE 区分"的问题,而是"TYPE 层面本就是同一类型"。 - 忽视编码切换的性能抖动:listpack→hashtable 切换瞬间有 realloc 与全量重建,大 Hash 频繁跨越阈值会抖动。
- 用 String 存大 JSON 再用 GET 整体改:每次修改要全量反序列化+序列化+写回,应用 Hash 存字段独立修改。
- Zset 当无界延迟队列用:任务积压导致 Zset 膨胀,listpack→skiplist 切换后内存与查询性能下降,需监控 ZCARD 与定期清理。
- SET 用大字符串 member:Set 的 hashtable 元素是 SDS,member 过长导致单元素内存大,应用 ID 替代长字符串。
背景:Redis 长期依赖 EVAL/EVALSHA 执行 Lua 脚本实现原子操作,但 Lua 脚本存在"无持久化、无管理、无审计"的痛点——脚本散落在客户端代码中,重启后丢失,FUNCTION 列表无法查询已注册脚本。Redis 7.0 引入 Functions(RFC 风格的 FUNCTION LOAD / FCALL),将脚本作为服务端一等公民管理。
核心机制:
- FUNCTION LOAD:注册一个函数库(library),含多个函数定义。语法:
FUNCTION LOAD "#!lua name=mylib\n redis.register_function('myfunc', function(keys, args) ... end)"。加载后持久化到 RDB/AOF,重启不丢失。 - FCALL:通过
FCALL mylib.myfunc 1 key1 arg1调用,参数格式与 EVAL 一致(numkeys + keys + args)。 - FUNCTION LIST / DUMP / DELETE:列出已注册库、导出库代码、删除库——实现了脚本的可管理与可审计。
- 集群同步:Functions 自动通过 RDB/AOF 复制到从节点,Lua 脚本需客户端各自发送。
EVAL vs Functions 对比:
| 维度 | EVAL / EVALSHA (Lua 脚本) | FUNCTION LOAD / FCALL (Functions) |
|---|---|---|
| 持久化 | 不持久化,重启后 SHA 丢失 | 持久化到 RDB/AOF,重启不丢失 |
| 管理 | 无 API 查询已注册脚本 | FUNCTION LIST / DUMP / DELETE 可管理 |
| 集群复制 | 需客户端向每个主节点发送 | 自动通过 RDB/AOF 复制到从节点 |
| 调用方式 | EVAL script numkeys ... / EVALSHA sha1 ... | FCALL lib.func numkeys ... |
| 引入版本 | Redis 2.6 | Redis 7.0 |
选型建议:
- 新项目(Redis 7.0+):优先使用 Functions,享受持久化 + 管理便利 + 集群自动同步。Lua 脚本的 EVAL 仅用于一次性临时操作。
- 存量项目:可渐进迁移,EVAL 脚本用 FUNCTION LOAD 重新注册为库函数,客户端从 EVAL 改为 FCALL。Redisson 3.17+ 已内置支持。
- 注意:Functions 仍是单线程执行,长时间运行的函数会阻塞 Redis(与 EVAL 相同限制)。避免在函数中做大量循环或复杂计算。
# Redis 7.0 Functions 示例:原子扣减库存
# 1. 注册函数库
FUNCTION LOAD "#!lua name=inventory
redis.register_function('deduct_stock', function(keys, args)
local stock = redis.call('GET', keys[1])
local qty = tonumber(args[1])
if not stock or tonumber(stock) < qty then
return 0 -- 库存不足
end
redis.call('DECRBY', keys[1], qty)
return 1 -- 扣减成功
end)"
# 2. 调用函数
FCALL inventory.deduct_stock 1 product:1001 5
# 3. 管理函数
FUNCTION LIST -- 列出所有库
FUNCTION DUMP inventory -- 导出库代码
FUNCTION DELETE inventory -- 删除库
掌握要点:Functions 不是"另一个脚本引擎",而是将脚本从"客户端临时发送"升级为"服务端持久管理"。回答时应强调三点:① 持久化与重启不丢失;② 可管理可审计(LIST/DUMP/DELETE);③ 集群自动同步——这三点解决了 EVAL 时代脚本散落、丢失、难运维的根本痛点。
核心变化:Redis 8.0 是历史上最大的单版本跃迁——从"内存缓存 + 模块扩展"向统一多模数据平台的范式转变。三条主线:① One Redis 统一发行(Redis Stack 的搜索/JSON/时序/概率等能力内置化,消除模块管理碎片化);② 性能大幅跃迁(全新多线程 I/O;官方相对 Redis 7.2.5 基准:149 项测试中 90 项更快,p50 延迟降幅约 5.4%~87.4%——峰值非全命令保证;全量同步峰值复制缓冲约省 35%);③ 向 AI 原生演进(Vector Set:8.0 引入时为 Beta;8.4+ 性能与命令面增强,生产需按版本评测召回/延迟)。
版本演进脉络:
- Redis 6.0(2020):多线程网络 IO(命令执行仍单线程),网络读写多线程化,多核利用第一步。
- Redis 7.0(2022):listpack 替换 ziplist(消除连锁更新)+ Functions(Lua 脚本服务端一等公民管理)+ ACL 细粒度权限。
- Redis 7.2~7.4(2023~2024):Hash 字段过期(
HEXPIRE/HPEXPIRE/HTTL),为 8.0 新 Hash 命令铺路。 - Redis 8.0(2025-05 GA):One Redis 统一发行 + 全新多线程 I/O + Vector Set(发布时 Beta,HNSW 索引)+ 30+ 项性能优化(相对 7.2.5,命令 p50 延迟降幅区间约 5.4%~87.4%;全量同步峰值缓冲约省 35%)。
- Redis 8.4(2025-11 GA):Vector Set 性能强化(AVX2/AVX512、
VRANGE)、FT.HYBRID混合检索、原子字符串 CAS(DIGEST/DELEX)、Cluster 原子槽迁移等;向量能力进入可生产评估窗口。 - Redis 8.8 / 8.10(2026):8.x 线持续小版本;选型以目标发行版 Release Notes 为准,勿把 8.0 当作"最新终态"。
Redis 7.x vs 8.0 关键对比:
| 特性 | Redis 7.x | Redis 8.0 |
|---|---|---|
| 发行形态 | Redis CE + Redis Stack(模块需单独安装) | Redis Open Source(统一内置,无模块) |
| I/O 多线程 | 6.0 引入旧实现(网络读写) | 全新实现,多核吞吐更高 |
| 向量检索 | 仅 Redis Search 模块 | Vector Set 原生(8.0 引入;8.4+ 强化)+ Redis Search 内置 |
| Hash 字段操作 | HEXPIRE/HPEXPIRE/HTTL(7.4+) | 新增 HGETEX/HSETEX/HGETDEL |
| 命令延迟 | 官方对照基线 Redis 7.2.5 | 149 测中 90 项更快;p50 降幅约 5.4%~87.4%(峰值≠均值,生产以自身压测为准) |
| 复制缓冲峰值 | 全量同步峰值缓冲 | 官方场景峰值缓冲约省 35%(非副本日常驻留内存普降) |
| 全文检索评分 | TF-IDF 默认 | BM25 默认(Redis Search) |
Vector Set 核心机制:Redis 8.0 新增的原生数据结构(Beta),由 antirez 从零重新实现 HNSW 索引。关键差异:① 真实删除——VREM
立即解除节点链接并重连邻居(非 tombstone),内存即时回收;②
链接双向强制——保证删除后图连通性不退化;③ 谓词过滤扫描——图遍历时支持 JSON 属性过滤。默认使用 int8 量化(FP32 的 25% 内存,召回率几乎无损)。VSIM 采用多线程处理(antirez 认为向量操作天然慢,值得打破单线程约束)。核心命令:VADD/VREM/VSIM/VRANDMEMBER/VGETATTR/VSETATTR。antirez 实测 300 万向量 × 300 维,单机 VSIM(Top-10)可达 5~6 万 QPS。
新 Hash 命令设计动机:HGETEX(取值并可选设过期)、HSETEX(设值并可选设过期)、HGETDEL(取值并删除)将"读/写 + 过期/删除"合并为原子操作,减少 RTT。会话管理可用 HSETEX session:uid field token EX 1800 一步完成设值+过期;HGETDEL
实现"读后即焚"一次性令牌校验。
-
检查是否依赖被移除的旧模块 API(8.0 将模块命令纳入标准 ACL 分类,
+@all -@write等规则行为变化)。 - Redis Search 评分默认从 TF-IDF 改为 BM25,排序结果可能变化,依赖评分排序的业务需回归测试。
- Vector Set 为 Beta,生产使用需评估 API 稳定性风险,关键路径建议仍用 Redis Search 向量检索(已 GA)。
-
全新多线程 I/O 需重新调优
io-threads参数,沿用旧参数可能无法发挥最优吞吐。
- 把 Vector Set 当稳定特性用于生产:仍为 Beta,关键路径应优先用 Redis Search 向量检索(已 GA)。
- 忽视 ACL 破坏性变更:8.0 将原模块命令纳入标准 ACL 分类,旧规则语义变化可能导致
JSON.SET等命令权限丢失。 - 误以为 Vector Set = Redis Search 向量检索:两者互补但不同——Vector Set 是独立数据类型(VADD/VSIM),Redis Search 向量检索基于 JSON/Hash + FT.SEARCH。
- 误以为 8.0 多线程 I/O = 命令多线程执行:命令执行仍单线程(Vector Set 向量计算是例外)。
上线门禁:命中率/回源 QPS、TTL 抖动、故障降级演练。表达提纲:一致性窗口→防护组合→指标与预案。
先给可观测证据与门禁,再谈实现细节;避免只报组件名。
这道题考察对 Redis 持久化机制在"数据安全"与"性能开销"之间的权衡的理解:RDB 是全量快照(空间小、恢复快、可能丢数据),AOF 是增量日志(数据全、恢复慢、写放大)。本质是在内存数据库中,如何以最小的运行时开销换取崩溃后最小的数据丢失。混合持久化(4.0+)是两者折中的成熟方案。掌握 fork/COW/appendfsync/重写机制,才能回答持久化性能抖动与数据丢失问题。
Redis 早期(2.x 前):仅 RDB。BGSAVE 通过 fork 子进程 + COW(Copy-On-Write)写时复制生成全量二进制快照。优点:文件紧凑、恢复快、fork 后子进程异步不影响主进程。缺点:两次 BGSAVE 之间崩溃会丢失该窗口所有写入;fork 大内存实例阻塞(详见深度追问)。
Redis 2.x:引入 AOF。追加记录每条写命令到 appendonly.aof。Redis 7.0 起默认改为多文件 AOF(base + incr + manifest);历史单文件加载路径仍存在,但新建实例不应把单文件当作当前默认布局。优点:数据丢失少(取决于 appendfsync)。缺点:文件膨胀、恢复慢(重放全量命令)。引入 AOF 重写(BGREWRITEAOF)压缩历史命令——只保留最终状态。
Redis 4.0:混合持久化(aof-use-rdb-preamble)。AOF 重写时,前半段写 RDB 全量快照、后半段追加增量 AOF 命令。加载时先加载 RDB(快)再重放增量 AOF(少)。结合 RDB 的恢复速度与 AOF 的数据完整性,成为生产推荐配置。
Redis 7.0:AOF 改为多文件(manifest + base + incr)。base 文件承载 RDB preamble 或 AOF 格式,incr 文件追加增量命令,appendonly.aof.manifest
管理文件清单。重写不再覆盖单文件,而是生成新 base + incr,避免重写期间崩溃损坏原文件。趋势:持久化向"RDB 快照 + AOF 增量"的混合模式统一,7.x 的多文件 AOF 提升了原子性与可维护性。
RDB 原理:fork + COW。BGSAVE 调用 fork(),子进程共享父进程物理页(页表指向同一物理内存)。子进程遍历所有 key 写入 rdb 文件;父进程继续处理写请求,写某页时内核触发 COW 复制该页。关键:fork 瞬间会阻塞主线程(复制页表),大内存实例阻塞可达秒级。
| 维度 | RDB | AOF | 混合持久化(4.0+) |
|---|---|---|---|
| 持久化方式 | 全量二进制快照 | 增量命令日志 | RDB快照 + AOF增量 |
| 数据安全性 | 丢失上次快照后所有写 | 丢失 appendfsync 窗口 | 丢失 appendfsync 窗口 |
| 文件体积 | 小(压缩二进制) | 大(文本命令,重写后减小) | 中等 |
| 恢复速度 | 快(直接加载) | 慢(重放全量命令) | 较快(RDB快+少量AOF) |
| 运行时开销 | fork 瞬间阻塞 + 子进程I/O | 每次写追加 + fsync 阻塞 | 两者都有 |
| 可读性 | 二进制不可读 | 文本可读可人工修复 | 前半二进制后半文本 |
| 触发方式 | save/bgsave/定时 | always/everysec/no | aof-use-rdb-preamble yes |
AOF 的三种 appendfsync 策略:
- always:每条命令 fsync 落盘,最安全但性能极差(同步 I/O 阻塞主线程),基本不用。
- everysec(默认):每秒 fsync 一次,由后台线程执行,最多丢 1 秒数据。生产推荐。
- no:由 OS 决定 fsync 时机(通常 30 秒),性能好但数据丢失不可控。
AOF 重写原理:fork 子进程,遍历当前内存生成"达到当前状态所需的最小命令集"(如对同一 key 的多次 SET 只保留最后一次)。重写期间父进程新写入的命令缓存在 aof_rewrite_buffer,子进程完成后追加到新 AOF 末尾。重写触发条件:当前 AOF 大小 ≥ auto-aof-rewrite-percentage(默认 100%) 且 ≥ auto-aof-rewrite-min-size(默认 64MB)。
写入 aof_rewrite_buffer"] B --> D["子进程完成, 写入新AOF文件"] C --> E["父进程收到信号"] D --> E E --> F["追加 buffer 到新AOF"] F --> G["原子 rename 替换旧AOF"] style A fill:#dbeafe,stroke:#2563eb style B fill:#dcfce7,stroke:#059669 style C fill:#fef3c7,stroke:#d97706 style G fill:#ede9fe,stroke:#7c3aed
生产推荐配置:开启 AOF + 混合持久化。
# redis.conf 生产推荐
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
aof-use-rdb-preamble yes # 4.0+ 混合持久化(Redis 7.0 起配合多文件 AOF:base 用 RDB 快照 + incr 增量日志)
# 同时保留 RDB 作为冷备(定期 BGSAVE 拷贝到对象存储)
save 900 1
save 300 10
save 60 10000
大内存实例的 fork 阻塞问题:fork 需复制页表,10GB 实例 fork 阻塞约 100-200ms,30GB 可达秒级。表现:主线程延迟突刺、客户端超时。优化:① 控制单实例内存(建议 ≤ 10GB);② 关闭 THP(Transparent Huge Pages)——THP 的 2MB 大页使 COW 复制单位放大,写放大严重;③ 使用 INFO stats 的 latest_fork_usec 监控 fork 耗时。
COW 写放大监控:fork 后若父进程写量大,COW 复制大量内存页,可能使物理内存翻倍。监控 INFO memory 的 used_memory_rss 与 used_memory 比值,持续增长说明 COW 活跃。大写量场景应缩短 RDB 间隔或使用副本持久化。
追问 1:fork 为什么会阻塞?如何量化?
fork 虽然用 COW 共享物理页,但必须复制页表(不是物理页)。页表大小与内存线性相关——64 位系统每 4KB 内存对应 8 字节页表项,10GB 实例页表约 20MB,fork 复制页表耗时约 100-200ms。此期间主线程阻塞。INFO stats | grep latest_fork_usec
查看最近一次 fork 微秒数。规避:单实例内存上限、用从节点做持久化(主节点关闭 save 与 appendonly)。
追问 2:AOF 损坏如何修复?
AOF 文件末尾可能因宕机不完整(如只写了半条命令)。用
redis-check-aof --fix appendonly.aof 工具截断到最后一个完整命令。7.x 多文件 AOF 用
redis-check-aof --fix incr.aof
单独修复增量文件。生产应在修复前备份原文件,并确认丢失的命令范围。
- "AOF always 最安全所以用 always":always 每条命令 fsync 同步阻塞主线程,QPS 降一个数量级,生产用 everysec 即可。
- 忽视 THP 对 COW 的影响:开启 THP 时 COW 以 2MB 为单位,写一个字节复制 2MB,大实例 fork 后内存暴涨。
- RDB 与 AOF 二选一:现代 Redis 应同时开(AOF 主、RDB 冷备),混合持久化已解决矛盾。
- 重写期间 OOM:重写需额外内存(buffer + 新 AOF),大实例重写时 RSS 翻倍,需预留 50% 内存余量。
- 以为 RDB 是实时同步:RDB 是周期性快照,崩溃丢失的是两次快照间的数据,不能作为强保证。
这道题考察对缓存作为"数据库挡箭牌"失效时的三种典型故障模式的区分与治理:穿透是"查不存在的数据,缓存永远 miss",击穿是"热 key 过期瞬间高并发回源",雪崩是"大量 key 同时过期或缓存宕机"。本质都是缓存层失去对数据库的保护能力,导致请求直达 DB。三者区别在"失效原因",方案各异且可组合。能区分三者并给出组合方案,是缓存设计的入门门槛。
早期:单层缓存 + 简单 TTL。问题裸露——缓存 miss 即回源,无防护。穿透用"缓存空值"缓解,击穿用"互斥锁"缓解,雪崩用"随机 TTL"缓解。各自孤立解决。
中期:引入布隆过滤器与逻辑过期。布隆过滤器从源头拦截不存在的 key(穿透);逻辑过期(不设 TTL,value 内嵌逻辑过期时间,异步刷新)化解击穿;多级缓存(Caffeine+Redis)化解雪崩。
现状:组合方案 + 平台化。生产实践是布隆过滤器防穿透 + 互斥锁/逻辑过期防击穿 + 随机TTL+多级缓存+熔断降级防雪崩的组合拳,并通过热点探测(如 JD hotkey)动态识别热 key 提前本地缓存。趋势是缓存治理平台化——自动识别三大风险并注入防护策略。
| 维度 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 触发原因 | 查询不存在的数据(恶意攻击/业务空查) | 单个热点 key 过期瞬间高并发 | 大量 key 同时过期 或 缓存服务宕机 |
| 影响范围 | 持续回源 DB(key 不存在无法缓存) | 单个 key 的请求瞬间压垮 DB | 大面积 key 同时失效,DB 瞬时压力陡增 |
| 本质 | 缓存无法填充(数据不存在) | 缓存瞬时失效(TTL 到期) | 缓存批量失效 |
| 方案1 | 缓存空值(NULL,短TTL) | 互斥锁(只让一个线程回源) | 随机 TTL(打散过期时间) |
| 方案2 | 布隆过滤器(前置拦截) | 逻辑过期(永不过期,异步刷新) | 多级缓存(本地+Redis) |
| 方案3 | 参数校验(非法参数拦截) | 热点预加载(提前刷新) | 服务限流 + 熔断降级(防止瞬时流量压垮 DB) |
缓存空值的实现:查询 DB 未命中,缓存 key → "" 或特殊标记,设短 TTL(如 60s)防止长期占用。缺点:空值占内存、短 TTL 期间 DB 新增数据不能及时感知。
// 缓存空值防穿透 + 互斥锁防击穿 组合实现
public Object getWithCache(String key) {
Object val = redis.get(key);
if (val != null) {
return NULL_MARK.equals(val) ? null : val; // 空值标记直接返回
}
// 互斥锁防击穿:只有一个线程回源
String lockKey = "lock:" + key;
// 互斥锁值用 UUID 标识持有者
String lockValue = UUID.randomUUID().toString();
try {
boolean locked = redis.setIfAbsent(lockKey, lockValue, 10, SECONDS);
if (!locked) {
Thread.sleep(50);
return redis.get(key); // 等待后直接重读缓存,不递归抢锁
}
val = db.query(key);
if (val == null) {
redis.set(key, NULL_MARK, 60, SECONDS);
} else {
int ttl = 1800 + new Random().nextInt(600);
redis.set(key, val, ttl, SECONDS);
}
return val;
} finally {
// Lua 校验持有者后释放,防止误删他人锁
redis.eval("if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
1, lockKey, lockValue);
}
}
逻辑过期防击穿:value 内嵌逻辑过期时间,不设 Redis TTL(永不过期)。查询时判断逻辑过期,若过期则释放锁后异步刷新(其他线程返回旧值)。优点:无阻塞、无击穿;缺点:短时数据不一致、内存不释放。
组合方案设计:生产缓存层应同时部署三道防线:
- 防穿透:布隆过滤器前置(详见 C08.09),对确定不存在的 key 直接拦截;兜底缓存空值。
- 防击穿:热 key 用互斥锁(Caffeine + Redisson);超热 key(如秒杀商品)用逻辑过期 + 异步刷新。
- 防雪崩:TTL 加随机抖动(基础 TTL ± 20%);本地缓存(Caffeine)做兜底;DB 前加 Sentinel 限流熔断。
缓存预热:系统启动或大促前,主动加载热点数据到缓存,避免开闸瞬间全部 miss。用脚本或 @PostConstruct 批量 MSET,配合 TTL 随机化。
热点探测:超大规模场景用热点探测组件(JD hotkey、阿里 Sentinel 热点参数)实时识别热 key,自动推送到本地缓存,避免热 key 打爆 Redis 单分片。
追问 1:互斥锁防击穿有什么副作用?如何选择互斥锁 vs 逻辑过期?
互斥锁会让其他线程等待/重试,增加 RT 与线程占用;高并发下锁竞争本身消耗资源。逻辑过期无等待但牺牲短时一致性。选择:一致性要求高、能容忍少量等待 → 互斥锁;延迟敏感、可容忍旧数据 → 逻辑过期。秒杀场景常用逻辑过期。
追问 2:缓存雪崩中"缓存宕机"如何防范?
① Redis 集群高可用——主从+哨兵或 Cluster,避免单点;② 多级缓存——Caffeine 本地缓存兜底,即使 Redis 全挂本地仍能扛一部分;③ 服务降级——DB 限流,超限返回默认值/降级页;④ 熔断——Sentinel 熔断 DB 访问,防止 DB 被压垮引发连锁故障。
- 混淆三者定义:穿透是"不存在",击穿是"单 key 过期",雪崩是"批量过期/宕机"——口诀"穿不存、击单点、崩批量"。
- 缓存空值不设 TTL:空值长期占用内存,且 DB 新增数据后无法感知,必须设短 TTL(30-120s)。
- 互斥锁不释放:异常未走 finally 导致锁泄漏,应用 Redisson 或 try-finally 保证释放。
- TTL 全部相同:批量缓存同时过期是雪崩的直接诱因,TTL 必须加随机抖动。
- 布隆过滤器不更新:DB 新增数据后布隆过滤器未同步,导致新数据被误判为不存在,需支持动态添加。
缓存三件套组合方案(布隆过滤 + 互斥锁 + 随机 TTL)的价值,必须用"保护后 DB 峰值 QPS 与缓存命中率"量化,而非仅描述方案。
| 指标 | 参考基准(业界常见量级) | 说明 |
|---|---|---|
| 缓存命中率 | 目标 95%+(低于 90% 需排查) | 缓存层对 DB 的保护强度,命中率越高 DB 压力越小 |
| 布隆过滤器误判率 | <1%(bitmap 调优) | 误判会让不存在的 key 回源,但远优于全量穿透 |
| 击穿压测 DB 峰值 | 单 key 失效瞬间 1w→10w+(~10x);互斥锁保护后 DB 峰值<2w | 无保护的热点回源会把 DB 压力放大 10 倍 |
| 雪崩演练 DB 冲击 | 大量 key 同时过期:10x→1.5x(逻辑过期 + 随机 TTL) | 降级阈值 DB QPS>5w 触发限流,防止 DB 被打垮 |
| RT 量化 | 命中 P99<5ms;击穿保护下未命中 P99<50ms | 保护方案需在可接受的 RT 预算内 |
组合方案应以"保护后 DB 峰值 QPS 与命中率"量化:将 DB 峰值控制在 2w 以内、缓存命中率维持 95%+。以上为参考基准,需以自身生产数据替换。
这道题考察对分布式系统中"双写一致性"本质矛盾的架构级理解:缓存与数据库是两个独立存储,无法用一个事务原子化更新。本质问题是在"先更DB还是先更缓存""更新缓存还是删除缓存""如何补偿并发与失败"之间做权衡。掌握 Cache Aside 模式、延迟双删、Canal binlog 订阅三种方案的适用边界与一致性强度,是缓存架构师的核心能力。
方案1:双写(先更DB再更缓存 / 先更缓存再更DB)。问题:① 任何一步失败导致不一致;② 并发下"先更DB后更缓存"会出现线程A后发先至覆盖线程B的最新值(见图 8.4-1)。因此删除缓存 + 读时重建通常是默认选择;更新缓存只在"新值可低成本确定、可按版本单调写入、写后读收益明显"时使用,并必须处理失败补偿与乱序。
方案2:Cache Aside(先更DB再删缓存)。最经典模式。问题:① 删缓存失败导致脏数据;② 极端并发——线程A读旧值写缓存,线程B更DB删缓存后,A再写入旧值。此场景概率极低(需读比写慢),但强一致场景仍不可接受。
方案3:延迟双删。先删缓存→更DB→休眠N ms→再删缓存。第二次删除兜底"读旧值写缓存"的并发窗口。问题:N 难以确定(需大于一次读耗时)、休眠降低写性能、第二次删仍可能失败。
方案4:Canal binlog 订阅(最终一致)。业务只更DB,Canal 订阅 MySQL binlog,解析变更后删除/更新 Redis。彻底解耦业务与缓存,DB 是唯一写入源,避免双写并发问题。这是大型系统的主流方案。
方案5:强一致(分布式事务/锁)。如 2PC、TCC、加分布式锁串行化读写。性能开销大,仅用于金融等强一致场景,缓存场景罕见。趋势:业务系统接受"最终一致"+ Canal 订阅,强一致需求下沉到 NewSQL(TiDB)或牺牲缓存直读DB。
为什么多数业务默认"删除缓存"而非"更新缓存":
- 更新缓存:每次DB变更都要算缓存新值,复杂(多表 JOIN);并发下可能乱序覆盖。
- 删除缓存:懒加载,下次读时按最新DB重建;多个写只需删一次;避免无效更新。
Cache Aside 并发不一致场景(先更DB后删缓存):
此场景发生条件:① 线程A读DB(旧值)后阻塞;② 线程B完成 更DB+删缓存;③ 线程A恢复写入旧值。因"读DB"通常快于"更DB+删缓存",实际概率极低,但强一致场景需防范。
延迟双删时序:
// 延迟双删:先删→更DB→延迟→再删
redis.del(key); // 1. 先删缓存
db.update(key, newVal); // 2. 更新DB
Thread.sleep(500); // 3. 延迟(大于一次读耗时)
redis.del(key); // 4. 再删缓存(兜底并发脏读)
// 第二次删除失败的处理:发MQ重试删
Canal binlog 订阅架构:
Canal 伪装成 MySQL 从库,接收 binlog 流,解析 ROW 模式的变更事件,投递给消费端删除对应缓存。优点:业务无感知、DB 单一写入源。缺点:引入组件与消费延迟;同一 key 须进同一有序队列,并用 position/GTID 或版本去重。
Canal vs Debezium(选型对照):Canal 偏阿里系、常直连业务消费/自建投递,运维模型贴近“假从库 + Client”;Debezium 走 Kafka Connect 生态,多源(MySQL/PG/Oracle…)与 Schema History、SMT 更成熟,适合已有 Kafka 中台、要跨库 CDC 入仓/Outbox 的场景(与 C09.16 / C14.12 衔接)。缓存失效两者都能做:团队已有 Canal 运维经验可继续;新建链路且已上 Kafka Connect 优先 Debezium。
方案选型决策树:
- 弱一致(允许秒级不一致):Cache Aside + 缓存 TTL 兜底。最简单,90% 场景适用。
- 最终一致(要求较快收敛):Cache Aside + 删缓存失败走 MQ 重试;或 Canal 订阅。
- 强一致(金融/库存):加分布式锁串行化读写;或牺牲缓存直读 DB;或用 2PC(极少)。
删缓存失败的兜底:删除缓存可能失败(网络/Redis故障),需补偿——① 删除失败发 MQ 异步重试;② 设置 TTL 兜底(即使删失败,TTL 到期也会失效);③ Canal 订阅作为终极补偿。
Cache Aside 的"读时重建"并发控制:缓存 miss 后回源 DB 重建缓存时,多线程同时重建可能压垮 DB。结合 8.3 的互斥锁——只让一个线程重建,其他等待。这与防击穿是同一机制。
追问 1:为什么不能"先删缓存再更DB"?
先删缓存后更DB有典型并发问题:线程A删缓存→线程B读(miss,读DB旧值,写回缓存)→线程A更DB。结果缓存是旧值、DB是新值,若不加 TTL、延迟再删或读时校验,不一致可能持续到 TTL。该方案不应作为强一致/较快收敛场景的默认;在弱一致或需要缩短旧读窗口的场景可接受,但必须给出 TTL 和二次删除成本。
追问 2:Canal 方案如何保证缓存删除的顺序与幂等?
顺序:Canal 侧有 binlog 位点和队列内顺序,但进入 MQ 或消费线程池后可能乱序;应按缓存 key 做哈希路由,让同一 key 进入同一分区/队列串行处理。幂等:删除缓存天然幂等;更新缓存必须带 binlog position/GTID 或业务版本号做单调校验。失败重试用 MQ 重试与死信队列,并配合 TTL 兜底。
- 把"更新缓存"无条件当反模式:默认删除缓存更稳;但可低成本算值且能按版本单调更新时,更新缓存也是合法策略。
- 无补偿地"先删缓存后更DB":并发不一致窗口大;若必须使用,需 TTL、延迟再删和监控。
- 删缓存失败不补偿:删失败导致脏数据长期存在,必须有 MQ 重试或 TTL 兜底。
- 追求强一致还用缓存:缓存本质是最终一致,强一致应直读 DB 或用分布式事务,不要在缓存层强求。
- 延迟双删的 N 凭感觉:N 应基于线上 P99 读耗时监控确定,过小无效、过大降低写吞吐。
这道题考察对分布式锁正确性边界与工程化实现的专家级理解:单节点 setnx 锁的过期与续期矛盾、RedLock 的争议、Redisson 如何用 Lua + Hash + WatchDog 解决可重入与续期。本质是在"锁释放(防死锁)"与"业务未完成锁过期(防误删)"之间,用看门狗自动续期实现平衡。掌握 Redisson 的 Lua 脚本语义、Hash 结构记录重入数、WatchDog 定时续期机制,并理解 Martin Kleppmann 对 RedLock 的质疑,是分布式锁深度考察的终点。
阶段1:单节点 SETNX + EXPIRE。SETNX key value 加锁,EXPIRE 设过期防死锁。问题:两条命令非原子,SETNX 成功后宕机未 EXPIRE 导致死锁。改进:Redis 2.6.12 后 SET key value NX EX seconds 原子化。但仍有问题:① 不可重入(同线程再次加锁失败);② 业务超时锁过期后被他人获取,原线程释放了别人的锁。
阶段2:value 存唯一标识 + Lua 释放。加锁时 value 存 UUID(标识持有者),释放用 Lua 脚本"判断 value 相等才删",避免误删他人锁。解决误删,但未解决可重入与续期。
阶段3:RedLock(Antirez 提出)。在 N(奇数,通常5)个独立 Redis 实例上同时加锁,超过半数(N/2+1)成功且耗时小于锁 TTL 则加锁成功。意图:解决单点故障导致的锁失效。Martin Kleppmann 质疑:① GC pause 期间锁过期,客户端仍以为自己持锁,导致互斥被破坏;② RedLock 依赖各节点时间同步,但分布式系统时钟不可靠(NTP 漂移);③ RedLock 既非 AP 也非 CP,定位尴尬。结论:RedLock 在 correctness 要求高的场景不安全。
阶段4:Redisson(事实标准)。基于 Lua + Hash 实现可重入,WatchDog 定时续期解决业务超时锁过期。单节点模式够用于大多数场景;历史 RedLock 封装 RedissonRedLock 在较新版本中已标记废弃。对正确性要求高的协调可评估 ZK/etcd 的多数派与 session/lease 语义,但业务资源仍要保留唯一约束、状态机和幂等;性能优先则通常评估 Redisson 单节点。
Redisson 可重入锁的 Hash 结构:锁 key 的 value 不是简单字符串,而是 Hash:{threadId-uuid: 重入次数}。同一线程再次加锁时重入数 +1,释放时 -1,归零才删 key。
加锁 Lua 脚本(核心语义):
-- KEYS[1]=锁名, ARGV[1]=过期ms, ARGV[2]=threadId标识
if (redis.call('exists', KEYS[1]) == 0) then -- 锁不存在
redis.call('hset', KEYS[1], ARGV[2], 1); -- 记录持有者+重入数1
redis.call('pexpire', KEYS[1], ARGV[1]); -- 设过期
return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then -- 锁存在且是当前线程持有
redis.call('hincrby', KEYS[1], ARGV[2], 1); -- 重入数+1
redis.call('pexpire', KEYS[1], ARGV[1]); -- 续期
return nil;
end;
return redis.call('pttl', KEYS[1]); -- 锁被他人持有,返回剩余TTL
WatchDog 看门狗续期机制:加锁成功后,若未指定 leaseTime(即用默认 -1),Redisson 启动一个定时任务,每 lockWatchdogTimeout/3(默认 10s)检查锁是否仍被当前线程持有,若是则续期到 lockWatchdogTimeout(默认 30s)。业务完成 unlock 后定时任务取消。
关键参数:
lockWatchdogTimeout(默认 30000ms):锁的 TTL,看门狗续期到此值。- 续期周期 = lockWatchdogTimeout / 3 = 10s。
- 若调用
lock(10, TimeUnit.SECONDS)指定 leaseTime,看门狗不会启动,锁在 10s 后强制释放——适用于明确知道业务耗时的场景。
Redisson 的两个核心创新:① Hash 记录重入数实现可重入,同线程多次加锁不阻塞;② WatchDog 自动续期解决"业务耗时不可预测 vs 锁TTL固定"的矛盾。但看门狗依赖客户端进程存活——客户端崩溃后看门狗停止,锁仍会在 TTL 后释放,这是 Redis 锁"非严格互斥"的根本原因。
标准用法:
RLock lock = redissonClient.getLock("order:lock:" + orderId);
try {
// 不指定leaseTime,启用看门狗自动续期
boolean ok = lock.tryLock(3, TimeUnit.SECONDS); // 等待3s
if (!ok) throw new BizException("操作太频繁");
// 执行业务逻辑
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock(); // 必须判断是否持有,避免IllegalMonitorStateException
}
}
选型建议:① 大多数业务(订单防重、库存扣减、限流)—— Redisson 单节点锁 + 看门狗,性能好、可容忍极端情况锁失效;② 金融转账、正确性敏感场景—— 先以数据库唯一约束/状态机兜底,再按预算评估 ZK 临时顺序节点或 etcd lease 的 session/lease 语义;这类系统在多数派存活时有更强协调保证,也不是不需要幂等和 fencing token;③ RedLock 争议大、复杂度高,引入前必须按所用 Redisson/Redis 版本和故障模型验证。
追问 1:Martin Kleppmann 对 RedLock 的质疑核心是什么?Antirez 如何回应?
Kleppmann 质疑:① GC/STW 问题——客户端持锁后长时间 GC pause,锁过期被他人获取,两个客户端同时以为自己持锁,互斥被破坏;② 时钟依赖——RedLock 用 TTL 判断合法性,依赖各 Redis 节点时钟一致,但 NTP 漂移、闰秒会使判断错误。Antirez 回应:RedLock 不比单节点更差,并讨论用 fencing token 缓解 GC 问题。该令牌的核心不是发放方,而是资源端必须比较单调 token 后才执行;Redis SET 锁和常规 Redisson RLock 不自动替下游资源完成这个校验。结论:正确性敏感场景,先落库约束/状态机;协调服务选型再按版本与故障模型评估 ZK/etcd。
追问 2:看门狗续期失败会怎样?
看门狗续期用 Lua 脚本(hexists 判断 + pexpire),若 Redis 故障或网络分区导致续期失败,锁会在配置的 TTL 内过期,其他客户端可获取。此时原业务可能仍在执行,从而破坏互斥。这是 Redis 分布式锁的固有边界;正确性敏感业务必须用数据库唯一约束/状态机兜底。ZK/etcd 通过 session/lease 提供更强的协调失效语义,但客户端仍可能经历 pause、时钟或网络边界,需要业务幂等。
- 指定 leaseTime 后以为还有看门狗:指定 leaseTime 则看门狗不启动,锁会强制过期,业务超时则互斥失效。
- unlock 不判断 isHeldByCurrentThread:锁已过期被他人获取时 unlock 会抛 IllegalMonitorStateException。
- 用 SETNX 手写锁替代 Redisson:手写锁缺可重入、续期、误删防护,应直接用 Redisson。
- 把 Redis 锁当唯一正确性保障:GC/网络分区下可能双持;关键资源要有唯一约束或状态机。
- 把 RedLock/ZK/etcd 当银弹:RedLock 复杂且有争议;ZK/etcd 也要按 session/lease、时钟和幂等语义验证。
这道题考察对缓存层次化设计的架构能力:为何要分本地缓存 + 分布式缓存 + DB 多层?本质是用"距离 CPU 越近越快但容量越小"的存储金字塔原理,在延迟、容量、一致性之间分层权衡。本地缓存(Caffeine)纳秒级但容量小且不共享,分布式缓存(Redis)毫秒级且共享但网络开销,DB 毫秒~秒级为最终数据源。掌握多级缓存的命中链路、一致性保证、热点探测、缓存预热与失效策略,是高并发系统设计的核心。
阶段1:单层 Redis 缓存。DB 前加 Redis 挡 80% 读。问题:Redis 单点故障或网络抖动时回源 DB 压力骤增;超高频 key(热 key)打爆 Redis 单分片。
阶段2:本地缓存 + Redis 两级。应用进程内 Caffeine/Guava 做一级缓存(L1),Redis 做二级(L2)。L1 命中纳秒级、无网络开销;L2 兜底共享。问题:L1 各实例独立,数据更新后多实例 L1 不一致。
阶段3:多级 + 一致性治理。引入 Redis Pub/Sub 或 MQ 广播 L1 失效消息,所有实例收到后清本地;或用 Canal 订阅 DB binlog 触发 L1+L2 双失效。引入热点探测(JD hotkey)动态识别热 key 推送 L1。
阶段4:云原生多级缓存。Sidecar 缓存(如 Envoy/自研 agent)下沉到 Pod,业务无感知;CDN + 边缘缓存 + 中心 Redis + DB 的全球多级。趋势:缓存能力平台化、下沉到基础设施层,业务只关心业务逻辑。
多级缓存架构图:
(纳秒级, 单机MB级)"] L1 -->|miss| L2["L2: 分布式缓存 Redis
(毫秒级, GB~TB级)"] L2 -->|miss| DB[("DB MySQL
(毫秒~秒级, 持久化)")] DB --> L2 L2 --> L1 L1 --> REQ BINLOG["DB binlog"] --> CANAL["Canal"] CANAL -->|"删L2"| L2 CANAL -->|"广播删L1"| MQ["MQ/Redis PubSub"] MQ -->|"各实例清本地"| L1 style L1 fill:#dcfce7,stroke:#059669 style L2 fill:#dbeafe,stroke:#2563eb style DB fill:#fef3c7,stroke:#d97706 style CANAL fill:#ede9fe,stroke:#7c3aed
Caffeine 的 W-TinyLFU 算法:现代本地缓存(Caffeine)淘汰策略不用传统 LRU,而用 W-TinyLFU——结合 LRU(准入)+ Count-Min Sketch(频率统计)+ TinyLFU(淘汰决策)。优势:① 命中率高于 LRU/LFU(贴近 ARC);② 抗扫描污染(一次性大量 key 不会挤掉热点);③ 内存友好(Count-Min Sketch 省空间)。这是 Caffeine 性能远超 Guava Cache 的关键。
L1 一致性保证机制:
- 广播失效:DB 更新后,通过 Redis Pub/Sub 或 MQ 广播"清本地缓存 key X",所有实例收到后清 L1。优点:实时;缺点:消息可能丢失、多实例广播开销。
- 短 TTL 兜底:L1 设较短 TTL(如 10-30s),即使广播丢失,TTL 到期也会自动失效。最终一致。
- Canal 触发:Canal 订阅 binlog,同时删 L2 和广播删 L1,保证 DB 是唯一写入源。
Spring Boot + Caffeine + Redis 多级缓存实现:
// 1. Caffeine 本地缓存配置
CaffeineCacheManager l1 = new CaffeineCacheManager();
l1.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS) // L1 短TTL兜底
.maximumSize(10000));
// 2. 多级缓存查询(读路径)
public Object get(String key) {
Object v = caffeine.getIfPresent(key); // L1
if (v != null) return v;
v = redis.get(key); // L2
if (v != null) {
caffeine.put(key, v); // 回填L1
return v;
}
v = db.query(key); // DB
if (v != null) {
redis.set(key, v, 1800, SECONDS);
caffeine.put(key, v);
}
return v;
}
// 3. 失效(写路径,结合Canal或手动)
public void evict(String key) {
redis.del(key); // 删L2
redisTemplate.convertAndSend("cache:evict", key); // 广播删L1
}
// 4. 各实例订阅广播清本地(配置 RedisMessageListenerContainer)
@Bean
public RedisMessageListenerContainer container(
RedisConnectionFactory factory) {
RedisMessageListenerContainer c = new RedisMessageListenerContainer();
c.setConnectionFactory(factory);
c.addMessageListener((message, pattern) -> {
String key = new String(message.getBody(), StandardCharsets.UTF_8);
caffeine.invalidate(key); // 清本地 L1 缓存
}, new ChannelTopic("cache:evict"));
return c;
}
热点探测集成:超热 key(如秒杀商品)即使 L2 也扛不住单分片读。用 JD hotkey 或自研热点探测——滑动窗口统计访问频次超阈值的 key,自动推送到所有实例 L1 并延长 TTL。业务无感知,热 key 自动本地化。
缓存预热:系统启动/大促前,用离线脚本批量加载热点数据到 L2 和 L1,避免冷启动雪崩。可结合定时任务在低峰期刷新即将过期的热 key。
追问 1:L1 缓存各实例数据不一致如何彻底解决?
无法"彻底"解决——多实例本地缓存在更新瞬间必然有窗口期不一致。可行策略:① 广播失效 + 短 TTL兜底(最常用);② 强一致放弃 L1——对一致性敏感的 key 只用 L2(Redis),不走本地;③ 版本号校验——L1 存 value+version,查询时与 L2 版本比对,不一致则失效。架构上应区分 key 的一致性要求,分别走多级或单级缓存。
追问 2:多级缓存的命中率如何监控与调优?
分层监控命中率:L1 命中率(Caffeine stats)、L2 命中率(Redis INFO stats)、DB 回源率。理想:L1 命中 70%+、L2 命中 25%+、DB 回源 <5%。调优:L1 命中率低 → 调大 maximumSize 或排查热 key 是否未进 L1;DB 回源高 → 检查 L2 TTL 是否过短或缓存 key 设计是否合理。用 Micrometer 暴露指标到 Prometheus。
- 所有 key 都走多级缓存:强一致需求的 key(如库存)走 L1 会放大不一致,应只走 L2 或直读 DB。
- L1 不设 TTL 只靠广播失效:广播消息丢失则 L1 永久脏数据,必须 TTL 兜底。
- L1 容量过大:本地缓存吃 JVM 堆,过大引发 GC 压力,建议 maximumSize 控制在万级。
- 广播失效风暴:大批量 key 同时失效时广播消息打满 MQ,应批量合并广播或限频。
- 忽视 L1 序列化开销:L1 存对象引用零序列化,但若用 Spring Cache 抽象可能引入序列化,需确认配置。
多级缓存解决了命中率问题,但热 key 的发现与治理需要一套完整的标准操作流程(SOP),从检测到拆分到降级形成闭环。
- 检测手段:①
redis-cli --hotkeys(需开启 LFU 淘汰策略maxmemory-policy=allkeys-lfu);②redis-cli --bigkeys(顺带发现大 key);③ MONITOR 命令(实时捕获所有命令,生产慎用,影响性能);④ 代理层统计(Codis/Twemproxy 的 hotkey 统计);⑤ 业务埋点(客户端本地 LRU 统计访问频次,超阈值上报)。 - 拆分策略:① 多副本读——将热 key 的值复制到
key1, key2, key3...,客户端随机选一个读,分散单分片压力;② 本地缓存(Caffeine)——热 key 的值缓存在应用本地,设置短 TTL(如 5s),减少 Redis 访问;③ 计算/存储分离——热 key 的计算结果预计算后存入多个 key。 - 限流与降级:① 令牌桶限流——对热 key 的访问限速,超限直接返回降级值(如缓存旧值或默认值);② 熔断——热 key 对应节点 CPU/带宽超阈值时,自动切换到本地缓存模式,保护 Redis 不被打挂。
- 典型案例:秒杀商品详情页的 SKU 信息是典型热 key,QPS 可达 10 万+。解决方案:Caffeine 本地缓存(1s TTL)+ Redis 多副本读 + 令牌桶限流降级,三层防护确保 Redis 与 DB 均不被击穿。
热 key 治理的核心矛盾是"检测滞后"与"突发流量"的冲突。被动检测(redis-cli --hotkeys)有延迟,秒杀场景需在流量到达前完成预热。生产最佳实践是主动+被动双引擎:被动引擎用 JD hotkey 或自研组件实时统计滑动窗口频次;主动引擎在大促前由运营平台预推热 key 列表到所有实例 L1。两者结合,既防突发又防遗漏。
这道题考察对 Redis 水平扩展与去中心化集群的理解:为何要 Cluster?数据如何分片?节点如何通信?故障如何转移?本质是用 16384 个哈希槽做数据分片 + Gossip 协议做节点通信 + 主从复制做高可用,实现去中心化的水平扩展与自动故障转移。掌握槽位分配、MOVED 重定向、Gossip、故障选举,能回答 Cluster 的扩缩容、跨槽操作限制、脑裂等问题。
阶段1:单节点。问题:单点故障、容量受限、无法水平扩展。
阶段2:主从复制。一主多从,主写从读,数据全量复制。解决读扩展与读高可用,但写仍单点、主故障需人工切换。
阶段3:哨兵 Sentinel(2.8+)。独立部署 Sentinel 集群监控主从,主故障自动选举从提升为主。解决主故障自动切换,但仍单主写入、容量未扩展。适合写量不大但要求高可用的场景。
阶段4:Cluster(3.0+)。去中心化分片集群——16384 槽位分散到多个主节点,每个主节点带从节点。写能力与容量水平扩展,节点故障自动 failover。适用:大数据量/高写入/要求水平扩展。这是 Redis 生产集群的主流形态。
趋势:Cluster 持续完善——7.0 支持 ACL 集群同步、Sharded Pub/Sub(按槽位分发避免广播);云上 Redis(如阿里云 Tair、AWS ElastiCache)在 Cluster 基础上加读写分离、无感扩缩容、持久内存。
哈希槽分片:Cluster 有 16384(2^14)个虚拟槽。key 通过 CRC16(key) mod 16384 计算槽位,槽位分配到各主节点。例如 3 主节点:节点A 持有 0-5460,节点B 5461-10922,节点C 10923-16383。
为何是 16384 而非 65536:① Gossip 消息携带每个节点的槽位 bitmap,16384 位 = 2KB,65536 位 = 8KB,前者带宽友好;② 集群规模实际不超过 1000 节点,16384 槽足够细粒度;③ CRC16 的 14 位即可覆盖。
MOVED 重定向:客户端向任意节点发命令,若 key 不在该节点槽位范围,返回 MOVED <slot> <ip:port>,客户端缓存槽位→节点映射后重试。智能客户端(如 Jedis Cluster、Lettuce)启动时缓存全部映射并本地计算槽位,减少重定向。
Gossip 协议:节点间通过 Gossip(PING/PONG)交换集群状态——节点存活、槽位归属、主从关系。每节点定期向随机几个节点发 PING,携带自己已知的集群信息,PONG 回包携带对方信息,信息像 gossip 一样扩散到全网。Gossip 收敛时间 O(logN)。用于故障检测与配置同步。
故障转移流程:
- 主观下线(PFAIL):节点A 发现节点B PING 超时,标记 B 为主观下线。
- 客观下线(FAIL):A 通过 Gossip 传播 B 的 PFAIL,超半数主节点确认后标记 B 为 FAIL。
- 从节点选举:候选从节点先受
master-link-status、cluster-replica-validity-factor和replica-priority约束;priority 为 0 不参与,数值小者优先。在通过有效性检查的候选中,复制偏移量大(数据更新)者排名靠前、更早发起选举,仍需获超半数主节点投票才当选新主。 - 接管:新主广播 PONG 宣告槽位归属变更,集群更新路由。
Cluster 的核心设计:① 去中心化——无中心节点,所有节点平等,靠 Gossip 同步状态;② 去中心化分片——16384 槽分散写入压力与存储;③ 主从高可用——每主带从,主故障自动 failover。代价:跨槽操作受限(mset/事务/批量需 hash tag)、客户端需智能路由。
Hash Tag 保证同槽:需同槽的多 key 操作用 {tag} 括号。user:{1000}:info 与 user:{1000}:order 都按
user:1000
计算槽位,落在同节点,可用事务/Lua/mget。但需注意热点集中风险。
扩缩容:扩容——新节点加入集群,从现有主节点迁移部分槽位(CLUSTER SETSLOT
+ MIGRATE 逐 key 迁移);缩容——先迁移走槽位再下线。迁移期间存在 ASK 重定向(临时重定向到目标节点读一次)。云上 Redis 通常支持无感扩缩容(在线 reshard)。
集群规模建议:单主节点内存 ≤ 10GB(控制 fork 与 RDB 影响),集群规模 ≤ 1000 节点(Gossip 通信开销)。3 主 3 从是常见起步配置,可按需扩展。
追问 1:Cluster 与哨兵如何选型?
哨兵:单主多从 + Sentinel 监控,写不扩展、容量不扩展,仅解决高可用。适合数据量小(<10GB)、写量适中的场景。Cluster:多主分片 + 各主从,写与容量水平扩展。适合大数据量/高写入。两者可视为演进关系——Cluster 是哨兵的超集(功能上),但部署更复杂。生产大数据量首选 Cluster。
追问 2:Cluster 的脑裂问题如何防范?
网络分区下,主节点与集群失联但仍接收客户端写入,从节点被选为新主也接收写入——两个主同时写。旧主恢复后被降级为从,其分区期间的未同步写入可能丢失。min-replicas-to-write 与 min-replicas-max-lag 是写准入检查:主节点要求至少 N 个连接且最近心跳/延迟不超过阈值的从节点,否则拒绝写;它不等待该请求在从库执行或持久化完成,因此不是请求级同步 ACK,也不能绝对防止脑裂。
- 跨槽 mset/事务报错:Cluster 默认禁止跨槽多 key 操作,需用 Hash Tag 或拆分命令。
- 以为 Cluster 强一致:Cluster 是 AP,主从异步复制,failover 期间会丢未同步的写入。
- 忽视 ASK/MOVED 重定向开销:非智能客户端每次重定向多一跳,应用 Jedis Cluster/Lettuce。
- Hash Tag 滥用导致热点:所有同 tag 的 key 落同节点,若 tag 是高频用户会打爆单分片。
- 哨兵当 Cluster 用:哨兵不扩展写与容量,大数据量用哨兵是误选型。
Redis Cluster 在单机房内运行良好,但跨机房部署会引入网络延迟、分区风险与锁续约等新问题,需要专门的同步方案设计。
- Redis Cluster 跨机房部署:① 同城双活——两个机房各部署部分 master+slave,master 在 A 机房、slave 在 B 机房(或反之),依赖同城低延迟专线;② 异地灾备——主集群 + 异地从集群,通过 redis-shake/redis-sync 做异步复制;③ 网络分区风险——跨机房 cluster 可能因网络抖动触发 failover,导致数据不一致,需配合
cluster-node-timeout调大容忍窗口。 -
Redisson 看门狗跨机房问题:看门狗默认 30s 续约,跨机房网络延迟可能导致续约失败→锁过期。不要靠简单拉大
lockWatchdogTimeout掩盖故障;锁应按机房/共享资源域就近组织,跨机房调用配合数据库唯一约束、状态机或资源端 fencing token 校验,避免双持直接写坏状态。 - Proxy 层同步:① Codis——支持跨机房 proxy 集群,数据分片后可跨机房副本,支持在线扩缩容;② twemproxy——不支持动态扩缩容,适合静态分片场景,跨机房需上层做副本同步。
- 异步复制延迟容忍度设计:① 写后立即读一致性——用"读主"策略减少读到旧副本,但牺牲性能(读走主库);② 最终一致——接受秒级延迟,业务侧用本地缓存补偿(先读本地缓存,未命中读主或按路由读副本);③ 关键交易——Redis OSS 没有数据库意义上的原生半同步复制。可用
WAIT等待前序写入到达至少 N 个副本,但返回值必须校验,且不能视为跨机房 RPO=0 或强一致;必要时改用提供对应语义的同步复制产品。
跨机房同步的本质是按 RPO、延迟和故障模型分级取舍。同城低延迟提供了做更强复制的空间,但不自动等于 CP;异地多活通常异步复制,偏向 AP,需明确可容忍的 RPO。Redis OSS 场景下,核心交易可通过读主、WAIT、写准入配置和业务约束降低风险;要 RPO≈0 必须使用并验证支持该目标的产品与部署方案。切忌一刀切——强复制增加延迟与故障面,纯异步则必须接受数据丢失窗口。
这道题考察对 Redis "key 过期"与"内存满"两个独立机制的区分:过期策略解决"设了 TTL 的 key 何时被删",淘汰策略解决"内存满时按什么规则驱逐 key"。本质是用惰性删除保准确、定期删除控延迟、淘汰策略兜底内存的三层机制。掌握 8 种淘汰策略的演进(noeviction→LRU→LFU→random→TTL)与 LRU/LFU 的近似实现,能回答 maxmemory 调优与内存抖动问题。
过期策略:惰性 + 定期(长期如此)。惰性——访问时检查过期则删,保准确但过期 key 不访问则一直占内存;定期——主动循环抽样检查并渐进清理。两者结合平衡准确与延迟。演进:Redis 4.0 引入 lazy-free 体系,包括 UNLINK 和 lazyfree-lazy-expire 等配置;开启后过期 key 的释放可交给后台线程,减少大对象释放阻塞主线程的概率。抽样判断本身仍是过期机制的一部分,不应概括为“主动过期整体都在子线程”;不同版本可用的 lazy-free 开关要按目标版本确认。
淘汰策略演进:
- Redis 2.x:noeviction +volatile-lru + allkeys-lru。LRU 是近似 LRU——不维护全局链表(太贵),而是随机采样 5 个 key 淘汰最久未用的。maxmemory-samples 控制采样数。
- Redis 2.8:volatile-ttl + volatile-random + allkeys-random。补充 TTL 优先与随机策略。
- Redis 4.0:volatile-lfu + allkeys-lfu。LFU(Least Frequently Used)——按访问频率淘汰,解决 LRU "扫描污染"问题(一次性大批量 key 把热点挤出去)。LFU 用 Morris 计数器做概率性频率统计,8 位存频率。
趋势:LFU 在多数场景优于 LRU(抗扫描、识别真热点),4.x+ 生产推荐 allkeys-lfu。8 种策略中 noeviction 是默认但生产危险(内存满直接报错拒绝写)。
过期 key 不立即删除:SET key value EX 60 后,key 在 RedisDB 的 expires 字典记录过期时间,但expires 字典与主字典分开,key 仍存在于主字典。删除时机:访问时(惰性)或定期扫描(定期)。
| 策略 | 范围 | 规则 | 适用场景 |
|---|---|---|---|
| noeviction | — | 内存满拒绝写(返回OOM错误) | 默认,生产危险,仅缓存纯只读场景 |
| allkeys-lru | 所有key | 近似LRU淘汰最久未用 | 通用缓存(无明显热点) |
| allkeys-lfu | 所有key | LFU淘汰访问频率最低 | 推荐缓存(有热点,抗扫描) |
| allkeys-random | 所有key | 随机淘汰 | 无访问模式差异(罕见) |
| volatile-lru | 设TTL的key | LRU淘汰设TTL的最久未用 | 混合存储(部分持久数据无TTL) |
| volatile-lfu | 设TTL的key | LFU淘汰设TTL的频率最低 | 混合存储+有热点 |
| volatile-random | 设TTL的key | 随机淘汰设TTL的 | 混合存储无差异 |
| volatile-ttl | 设TTL的key | 优先淘汰TTL最短的 | 希望尽快回收临时 key |
近似 LRU 实现:Redis 不维护全局 LRU 链表(百万 key 的链表移动开销大)。每个 redisObject 有 24 位 lru 字段记录最后访问时间戳(秒级)。淘汰时随机采样 maxmemory-samples(默认5)个 key,淘汰其中 lru 最旧的。采样数越大越接近真实 LRU 但越慢。5 是性能与准确性的折中,可调到 10 提升命中率。
LFU 的 Morris 计数器:lru 字段在 LFU 模式拆为 16 位衰减时间 + 8 位频率计数。8 位频率用 Morris 计数器——概率性递增(访问越多递增概率越低),8 位可表示远超 255 的访问次数;衰减基于 24 位 lru 字段中的高 16 位时间戳(分钟精度),按 lfu-decay-time(默认 1 分钟)周期对频率做对数衰减。
过期 ≠ 淘汰:过期是"key 到期该删"(惰性+定期),淘汰是"内存满该驱逐谁"(8 种策略)。生产配置:maxmemory 8gb, maxmemory-policy allkeys-lfu, maxmemory-samples 10。纯缓存场景 allkeys-lfu 最优;混合存储(部分 key 无 TTL 需持久)用 volatile-lfu。
maxmemory 配置:单实例 maxmemory 建议 = 物理内存 × 60%(留 fork 与 COW 余量)。超限触发淘汰,INFO memory 的 used_memory 接近 maxmemory 时需告警。
内存碎片监控:INFO memory 的 mem_fragmentation_ratio = used_memory_rss / used_memory。>1.5 表示碎片严重(频繁修改/删除导致)。Redis 4.0+ 支持 activedefrag yes 主动碎片整理(Jemalloc 后台合并碎片)。<1 表示用了 swap,必须立即处理。
大 key 删除阻塞:删除大 key(如百万元素 Hash)可能阻塞主线程。Redis 4.0+ 用 UNLINK 把实际释放交给 lazy-free 路径;也可评估 lazyfree-lazy-eviction yes 让淘汰释放异步化。定期用 redis-cli --bigkeys 或 MEMORY USAGE 扫描大 key 拆分。
追问 1:LRU vs LFU 如何选?为何 4.0 后推荐 LFU?
LRU 按时间淘汰,问题:扫描污染——一次批量查询大量冷 key 会把热 key 挤出(冷 key 刚访问过 lru 最新)。LFU 按频率淘汰,热 key 因高频访问频率高不被淘汰,冷 key 即使刚被访问频率仍低被淘汰。多数缓存场景有热点(20% key 占 80% 访问),LFU 命中率更高。无热点(均匀访问)两者相近。所以 4.0+ 推荐 allkeys-lfu。
追问 2:maxmemory-samples 调大有何副作用?
采样数越大,淘汰的 key 越接近真实最该淘汰的,命中率提升。但每次淘汰采样更多 key 增加单次淘汰耗时,高淘汰频率(内存接近满)时主线程压力增大。生产建议 5-10,超过 10 收益递减。可通过监控 evicted_keys 增速与命中率调优。
- 混淆过期与淘汰:过期是 TTL 到期,淘汰是内存满驱逐,两套独立机制。
- 用 noeviction 当默认:内存满直接 OOM 拒绝写,生产必须改 allkeys-lfu。
- 以为 LRU 是精确 LRU:Redis 是近似 LRU(随机采样),非全局链表精确 LRU。
- 忽视内存碎片:used_memory 与 RSS 偏差大时碎片严重,影响实际可用内存。
- 大 key 用 DEL:阻塞主线程,应用 UNLINK 或分批 HSCAN+HDEL。
这道题考察对概率型数据结构在空间效率与准确性权衡的理解:布隆过滤器用极少内存判断"元素一定不存在"或"可能存在",本质是用 k 个哈希函数映射到位数组,牺牲一定误判率换取 O(1) 判定与常数级空间。掌握误判率公式、参数计算、不可删除问题与 Counting Bloom Filter/布谷鸟过滤器的演进,能设计穿透防护与去重方案。
布隆过滤器(1970 Burton Bloom):位数组 + k 个哈希函数。添加元素时 k 个位置置 1;查询时 k 个位置全 1 则"可能存在",有 0 则"一定不存在"。误判率可控但不可删除(删除某元素会把其他元素的 1 也清掉)。
Counting Bloom Filter:将位数组改为计数数组(每位置用 4-8 位计数器)。添加 +1、删除 -1。解决删除问题,但空间放大 4-8 倍,且计数器溢出风险。
布谷鸟过滤器(2014 Cuckoo Filter):用布谷鸟哈希(每元素两个候选桶,冲突则踢出已有元素到其另一候选桶)。优势:① 支持删除(无计数器溢出);② 空间更省(相同误判率下比 BF 少 20-40%);③ 查询更快(最多两次哈希)。劣势:装填因子受限(超过 95% 性能下降)、重复元素受限。
趋势:Redis 4.0+ 提供 RedisBloom 模块(BF.ADD/BF.EXISTS);Guava/Caffeine 内置 BloomFilter。需要删除选布谷鸟,只需判存在选布隆。组合应用——缓存前置布隆防穿透、爬虫 URL 去重、海量数据存在性判断。
布隆过滤器结构:m 位位数组 + k 个独立哈希函数。添加元素 x:对每个哈希函数 h_i 计算 h_i(x) mod m,将位数组对应位置 1。查询:k 个位置全为 1 则"可能存在"(可能误判),任一为 0 则"一定不存在"(绝不漏判)。
误判率公式:设 n 为元素数、m 为位数组长度、k 为哈希函数数,误判率 p ≈ (1 - e^(-kn/m))^k。给定 n 与期望 p,最优 m = -n·ln(p) / (ln2)²,最优 k = (m/n)·ln2。
典型参数计算:1 亿元素、误判率 1%:m = -1e8 × ln(0.01) / (0.693)² ≈ 9.58 亿位 ≈ 114MB;k = (9.58/1) × 0.693 ≈ 7 个哈希函数。1 亿元素仅需 114MB,远小于 HashSet 的 GB 级。
| 维度 | 布隆过滤器 | Counting BF | 布谷鸟过滤器 |
|---|---|---|---|
| 空间效率 | 高 | 低(4-8倍) | 最高(省20-40%) |
| 支持删除 | 否 | 是 | 是 |
| 误判率 | 可控 | 同BF | 同或更优 |
| 查询复杂度 | O(k) | O(k) | O(1)~O(2) |
| 添加性能 | 稳定O(k) | 稳定O(k) | 可能退化(踢出链) |
| 计数器溢出 | 无 | 有风险 | 无 |
缓存穿透防护架构:请求 → 布隆过滤器判断 → 不存在直接返回 → 可能存在则查 Redis → miss 查 DB → 回填 Redis 与布隆。布隆过滤器需初始化加载所有合法 key(启动时或异步同步)。
// Guava BloomFilter 防穿透
BloomFilter<Long> userIdFilter = BloomFilter.create(
Funnels.longFunnel(), 100_000_000, 0.01); // 1亿,1%误判
public User getUser(Long id) {
if (!userIdFilter.mightContain(id)) {
return null; // 一定不存在,直接拒绝
}
User u = redis.get("user:" + id);
if (u == null) {
u = db.findById(id);
if (u != null) redis.set("user:"+id, u, 3600, SECONDS);
}
return u;
}
动态更新问题:DB 新增数据需同步加入布隆过滤器,否则新数据被误判为不存在。方案:① 业务写入 DB 时同步 BF.ADD(RedisBloom);② Canal 订阅 binlog 解析新增 key 加入 BF。Guava BF 是单机的,集群共享用 RedisBloom 模块。
误判容忍设计:1% 误判率意味着 1% 的不存在 key 会"漏网"查到 Redis/DB,可接受。若不可接受,可降误判率到 0.1%(空间翻倍)或叠加二级精确校验(如用小 HashSet 存近期已确认不存在的 key)。
追问 1:布隆过滤器为什么不能删除?如何解决?
位数组的 1 可能被多个元素共享(多个哈希函数映射到同一位)。删除元素清 0 会影响其他元素。解决:① Counting Bloom Filter 用计数器替代位(+1/-1),但空间放大;② 布谷鸟过滤器天然支持删除;③ 重建——定期重建布隆过滤器(全量重新加载合法 key),适用于 key 集合可枚举的场景。
追问 2:布隆过滤器的哈希函数如何选择?为何可以不独立?
实际用 2 个独立哈希函数(如 murmur3 + xxhash)通过
h_i(x) = h1(x) + i × h2(x) 派生 k 个哈希值(双哈希技术),避免维护 k 个独立哈希函数。数学上已证明双哈希在 k 不大时与独立哈希效果相近。Guava 即用此技术。
- 误以为布隆能"确认存在":只能"确认不存在"或"可能存在",不能确认存在,需下游兜底。
- 容量预估不足:实际元素数远超预估时误判率飙升,需按 2-3 倍峰值预估 n。
- 新增数据不同步布隆:新数据被误判不存在导致穿透,必须保证 DB 写入与 BF 更新的一致。
- 对删除场景用标准布隆:标准布隆不可删,需 Counting BF 或布谷鸟过滤器。
- 多实例 Guava BF 不共享:Guava BF 是进程内的,多实例各自维护不一致,集群共享用 RedisBloom。
扩展 1:布谷鸟过滤器的二维桶结构与指纹。与"位数组"不同,布谷鸟过滤器是桶数组 + 槽位 + 指纹的二维逻辑结构 buckets[m][b]:m 个桶,标准实现每桶 b=4 个 slot,每个 slot 存一个定长指纹(常见 8bit,工程取值 4–12bit),0 表示空槽。指纹 f = fingerprint(x) 取哈希低 N 位,取值不能为 0(取到 0 时置 1);指纹长度直接决定假阳性率。底层内存是紧凑打包的比特数组,而非"m×b 个对象",这是它内存接近标准布隆的根本原因。
扩展 2:部分-key 布谷鸟哈希的异或公式(核心考点)。普通布谷鸟哈希表存完整 key,被踢出时可用原始 key 重算另一个位置;布谷鸟过滤器只存指纹、没有原始 key,因此改用
i₂ = i₁ ⊕ hash(f)
计算备选桶。该式可逆:已知当前桶号与指纹即可反推另一候选桶
i₁ = i₂ ⊕ hash(f)。这正是论文 Cuckoo Filter: Practically Better Than Bloom 提出的 partial-key cuckoo hashing,是过滤器区别于普通布谷鸟哈希的根本。
| 对比项 | 普通布谷鸟哈希表 | 布谷鸟过滤器 |
|---|---|---|
| 存储内容 | 完整 key/value | 只存短指纹 |
| 桶结构 | 每桶 1 个元素 | 每桶 b=4 个 slot |
| 备选位置计算 | 需要原始 key 算 h1(x)、h2(x) | i₂ = i₁ ⊕ hash(f),仅凭指纹 + 桶号 |
| 用途 | 完整哈希表,可取回 key | 概率过滤器,只做存在性判断 |
| 负载因子上限 | b=1 时约 50% | b=4 时可达 95% |
扩展 3:三大操作与"插入会失败"这件事。
- Insert:算 f 与 i₁、i₂ → 两桶有空 slot 直接放 → 两桶皆满则进入踢挪循环:随机挑一个 slot 把旧指纹 f_old 踢出、放入新 f,用
i = i ⊕ hash(f_old)算出 f_old 的另一候选桶并递归安置 → 循环次数达MaxKicks ≈ 50判定插入失败,需扩容重建。 - Lookup:算 f 与 i₁、i₂,最多扫描 2×b 个 slot 比对指纹,命中即"可能存在",两桶都无则"一定不存在"。只访问 2 个桶,O(1) 且缓存友好。
- Delete:在 i₁ 遍历 slot 找到 f 清空返回成功;否则去 i₂ 找;两桶都没有则删除失败。这是 CF 相对标准 BF 的原生优势。
- 重复插入陷阱:CF 允许同一元素重复插入并占用多个 slot,简单实现不计数,删除一次只清掉一条 slot,剩余仍判定存在。需要支持重复元素计数时须用 Counting-Cuckoo Filter。
扩展 4:三者共同的边界与负载因子。都不能遍历全部元素,都不能从过滤器取回原始值——因此它们只能做"前置拦截/存在性判断",不能当存储用,也不适合需要枚举的业务。假阳性率与过滤器大小、哈希数量、负载因子三者强相关。
| 过滤器 | 存储内容 | 支持删除 | 内存开销 | 插入失败 | 溢出/碰撞风险 | 典型负载因子 |
|---|---|---|---|---|---|---|
| 标准布隆 BF | bit 位 | 否 | 最低 | 永不失败 | 无 | < 0.7 |
| 计数布隆 CBF | 4bit 计数器 | 是 | 约 BF 的 4 倍 | 永不失败 | 计数器溢出 | < 0.7 |
| 布谷鸟 CF | 指纹(如 8bit) | 原生支持 | 接近 BF,优于 CBF | 会失败(受负载限制) | 指纹碰撞 | ≤ 0.95(b=4) |
扩展 5:单机 vs 分布式——算法本身没有分布式。这是一个高频误区:BF / CBF / CF 三种算法原生定义都是纯内存的单机算法,没有任何内置的分布式协议。所谓"分布式布隆过滤器"并不是算法自带能力,而是把整个过滤器搬到共享存储(Redis)里,由多客户端远程访问来实现的。
| 过滤器 | 原生形态 | 单机实现 | 分布式方案 | 限制 |
|---|---|---|---|---|
| 标准布隆 | 内存 bit 数组 | Guava BloomFilter、Hutool | RedisBloom BF.ADD/BF.EXISTS、Redisson 封装 |
Redis 集群单 key 不跨分片;不支持删除 |
| 计数布隆 | 内存计数器数组 | 各开源 lib(无通用成熟库) | RedisBloom 计数布隆 | 内存开销大;计数器溢出风险 |
| 布谷鸟 | 内存桶 + 指纹数组 | CuckooFilter4J、panmari/cuckoofilter | RedisBloom CF.ADD/CF.DEL/CF.EXISTS |
踢挪在 Redis 服务端执行;插入会失败;单 key 不跨分片 |
Redis 集群的关键坑(必须掌握):RedisBloom 的一个过滤器 key 只会落在一个分片上,不会自动跨节点拆分。集群模式下超大容量过滤器必须在业务层手动拆成多个过滤器 key(如按业务维度分 url_bf:{0}~{15} 十六个分片),并用 hash-tag 把需要同一 slot 的 key 强制路由到一起。这是"多实例共享"与"超大规模"两个需求的分界点。
# RedisBloom:分布式共享过滤器的三种形态
BF.RESERVE url_bf 0.01 100000000 # 标准布隆:1 亿、1% 误判,只增不删
BF.ADD url_bf https://a.com
BF.EXISTS url_bf https://a.com # 1=可能存在 0=一定不存在
CF.RESERVE uid_cf 100000000 # 布谷鸟:b=4,原生支持删除
CF.ADD uid_cf 10086
CF.DEL uid_cf 10086 # 标准 BF 无此命令
# 集群分片坑:一个过滤器 key 只落一个 slot,不自动跨节点
# 超大容量 → 业务层拆多个 key;hash-tag 只取 {} 内内容算 slot
url_bf:{shard0} url_bf:{shard1} ... url_bf:{shard15}
扩展 6:选型决策矩阵(场景 → 算法 → 部署形态)。
| 场景 | 算法 | 部署形态 | 理由 |
|---|---|---|---|
| 只增不删:URL 去重、爬虫判重 | 标准 BF | 单机 Guava / 集群 RedisBloom | 内存最省、实现最简单 |
| 需删除、实现简单优先、内存充足 | CBF | RedisBloom 计数布隆 | 支持删,但要预估碰撞防溢出 |
| 频繁增删、追求内存效率、可接受插入失败 | CF | RedisBloom CF.* |
内存接近 BF 且原生可删 |
| 单进程、极致性能 | BF / 本地 CF | 进程内内存 | 无网络 IO,最快 |
| 微服务多实例、需共享状态 | 按上表选 | RedisBloom(共享存储) | 多实例访问同一份过滤器 |
| 超大规模(单分片装不下) | 按上表选 | 业务层多分片 + hash-tag | 集群单 key 不跨分片 |
- Q:布谷鸟过滤器为什么能删,标准布隆不行? A:标准布隆多个元素共享 bit 位,清 0 会误伤;CF 每个元素的指纹独占一个 slot,找到即清空。
- Q:被踢出的指纹没有原始 key,怎么算另一个桶? A:partial-key cuckoo hashing 的异或公式
i₂ = i₁ ⊕ hash(f),只依赖指纹与当前桶号,且可逆。 - Q:布谷鸟过滤器插入为什么会失败? A:两个候选桶全满、踢挪链超过
MaxKicks≈50次仍未安置成功,判定过滤器已满,需扩容重建。 - Q:Guava BloomFilter 能直接用在微服务多实例吗? A:不能。Guava BF 是进程内内存,每个 JVM 各持一份且互不同步,微服务共享必须用 RedisBloom。
这道题考察对 Redis 生产高并发场景的问题全景与优化方法论的架构级综合能力:大 Key、热 Key、慢查询、pipeline、Lua、集群扩缩容、连接池、内存治理等。本质是从单命令延迟、单分片压力、网络往返、内存占用四个维度系统识别瓶颈并优化。这是前 9 题的综合应用——数据结构选型、持久化权衡、缓存三大问题、一致性、分布式锁、多级缓存、Cluster、淘汰策略的工程化串联。
早期:单线程模型的性能认知。Redis 6.0 前命令处理和网络 IO 都在单线程中完成,避免锁竞争;简单 GET/SET 常见压测可达十万级 QPS,但上限取决于命令复杂度、值大小、网络、协议、TLS 和客户端并发,不是固定 10 万上限。优化聚焦命令选择与 pipeline。
Redis 6.0:多线程网络 IO。命令执行仍单线程(保有序与无锁),但网络读写多线程化,提升网络密集场景吞吐。配合 IO threads 配置。
现状:全链路优化。从客户端(连接池、pipeline、集群路由)→ 网络(多线程 IO、TLS)→ 服务端(Lua 原子、大 key 拆分、慢命令禁用)→ 集群(分片均衡、热 key 迁移)→ 持久化(fork 优化、异步删除)→ 监控(latency monitor、slowlog)的全链路治理。
演进关注点:按目标版本读取官方发布说明,重点核对多线程 I/O、TLS、向量检索和扩展模块能力,不要把已退市或小众硬件(如 Intel Optane)写成当前生产趋势;云上 Tair 等扩展能力和 Sidecar/本地缓存下沉属于特定架构选项,收益需结合部署拓扑和压测确认。
| 问题 | 表现 | 根因 | 优化方案 |
|---|---|---|---|
| 大 Key | 单 key 占内存大(百万元素Hash/MB级String) | 阻塞主线程(O(N)操作)、网络带宽、fork慢 | 拆分(Hash分桶)、UNLINK异步删、单独实例隔离 |
| 热 Key | 单 key 高并发访问打爆单分片 | Cluster 单分片单线程瓶颈 | 本地缓存(Caffeine)、读副本分散、热key迁移 |
| 慢命令 | KEYS/SMEMBERS/HGETALL大集合阻塞 | O(N)命令占主线程 | 禁用KEYS,用SCAN系列;分页;HSCAN/SSCAN |
| 网络往返 | 大量单命令RTT累积延迟 | 每命令一次网络往返 | Pipeline批量;MGET/MSET;Lua打包 |
| 非原子 | 多命令并发竞态 | 命令间有依赖但非原子执行 | Lua脚本原子;MULTI/EXEC事务 |
| 连接耗尽 | Too many connections | 连接池配置不当/泄漏 | 连接池调参(maxTotal/maxIdle);复用 |
| fork阻塞 | 大实例持久化延迟突刺 | fork复制页表耗时 | 单实例内存≤10GB;关THP;从节点持久化 |
| 内存碎片 | RSS远超used_memory | 频繁修改删除导致allocator碎片 | activedefrag主动整理;重启回收 |
Pipeline 原理:客户端将多条命令打包一次发送,服务端依次执行后打包返回,省去每命令的 RTT。1000 命令 × 1ms RTT = 1000ms 串行,pipeline 后 ≈ 1ms RTT + 执行时间。注意 pipeline 非原子(中间可插其他客户端命令),需原子用 Lua。
Lua 脚本原子性:Redis 用单线程执行 Lua,脚本执行期间不处理其他命令,天然原子。适用于"检查-操作"复合操作(如库存扣减:判断≥0 再 DECR)。注意 Lua 脚本不要执行慢操作(KEYS、大集合遍历),否则阻塞主线程。
Redis 高并发优化的四象限:① 延迟——pipeline 降 RTT、Lua 保原子、避免慢命令;② 吞吐——Cluster 水平扩展、热 key 本地化、6.0 多线程 IO;③ 内存——大 key 拆分、合适淘汰策略、碎片整理;④ 可用性——主从+哨兵/Cluster、fork 优化、异步删除。四象限联动才是全链路优化。
大 Key 治理全流程:① 发现——redis-cli --bigkeys 采样、MEMORY USAGE key 精确、RDB 离线分析;② 拆分——大 Hash 按 hash(key)%N 分桶到多个 key、大 List 改 Stream、大 String 拆字段用 Hash;③ 删除——UNLINK 异步删,或 HSCAN+HDEL 分批;④ 隔离——大 key 单独实例,避免影响其他业务。
热 Key 治理:① 探测——redis-cli --hotkeys(需 LFU)、JD hotkey 组件、Sentinel 热点参数;② 本地化——热 key 推送到 Caffeine 本地缓存,TTL 短(秒级);③ 读副本——热 key 读多副本分散压力(Cluster 从节点读);④ 拆分——热 key 拆多份(如 hotkey_0~hotkey_9 随机读)。
连接池调优(Lettuce/Jedis):
# Lettuce (Spring Boot 默认, 基于Netty, 单连接共享+连接池)
spring.data.redis.lettuce.pool.max-active=32 # 总连接数
spring.data.redis.lettuce.pool.max-idle=16
spring.data.redis.lettuce.pool.min-idle=4
spring.data.redis.lettuce.pool.max-wait=2000ms # 获取连接超时
# 高并发要点:
# - max-active 不宜过大(每连接占内存+socket,建议 32-64)
# - Lettuce 默认单连接共享(多路复用),多数场景无需池化
# - Cluster 模式每节点独立连接池,总连接数=节点数×max-active
集群扩缩容平滑:扩容时新分片加入,迁移槽位期间用 ASK 重定向。为避免迁移期间性能抖动:① 低峰期执行;② 单批迁移量控制;③ 云上用无感扩缩容(在线 reshard);④ 客户端预热新节点连接池。
追问 1:Redis 6.0 多线程 IO 为何不把命令执行也多线程化?
命令执行单线程是 Redis 的核心设计——保证命令原子有序、无锁、数据结构简单。多线程执行需对每个数据结构加锁,Hash/Set/List 等复杂结构的细粒度锁代价巨大,反而降低性能。6.0 的多线程仅在网络读写(协议解析、响应发送)环节,命令执行仍单线程,既提升网络吞吐又保核心简单。这也是 Redis 与 Memcached(多线程+锁)的根本区别。
追问 2:如何监控 Redis 性能瓶颈?
① slowlog——SLOWLOG GET 慢命令(>10ms);② latency monitor——LATENCY HISTORY event
延迟事件(fork、expire、aof-write);③
INFO stats——instantaneous_ops_per_sec、rejected_connections、expired_keys、evicted_keys;④ INFO commandstats——各命令调用次数与耗时,定位热点命令;⑤ Prometheus + redis_exporter——生产监控标配,配合 Grafana 告警。
- 生产用 KEYS 扫描:KEYS O(N) 阻塞主线程,必须用 SCAN 增量迭代。
- 大 key 用 DEL:阻塞主线程,用 UNLINK 或分批删。
- pipeline 当原子用:pipeline 非原子,中间可插其他命令,需原子用 Lua。
- 连接池 max-active 盲目调大:过大耗内存与 socket,且后端承压反而更慢。
- 忽视热 key 单分片瓶颈:Cluster 不能解决热 key,热 key 必须本地缓存或拆分。
- Lua 脚本写慢操作:Lua 内 KEYS/大集合遍历阻塞主线程,Lua 应短小快速。
bigkey 的根本治理是拆分,但当单 key 的 value 确实需要存储大容量数据(如 100MB 的 Hash/List)时,纯内存 Redis 成本不可承受,需要替代存储方案。
- Pika(360 开源):基于 RocksDB 的 SSD 存储,兼容 Redis 协议。适用于单 key 超大 value(如 100MB 的 Hash/List),SSD 成本远低于内存。读写性能:内存命中时接近 Redis,未命中时受 SSD IOPS 限制(毫秒级)。局限:不支持 Stream、部分命令行为与 Redis 不完全一致。
- Codis(豌豆荚开源):分片中间件,将数据分散到多个 Redis 实例。支持在线扩缩容(slot 迁移),平滑扩展。bigkey 拆分:利用 hash tag 将大 Hash 拆分为多个小 Hash,分散到不同 slot。
- bigkey 拆分策略:① Hash tag——
key:{shard1}、key:{shard2},确保同一 shard 的 key 落在同一 slot,便于局部聚合;② 前缀分片——user:profile:{userId%100},将大集合拆为 100 个小集合;③ 冷热分离——热数据存 Redis,冷数据转存 MongoDB/ES,定期迁移。 - 阿里云 Tair(商业方案):持久内存(PMem)+ 混合存储,支持 TB 级数据且延迟在微秒级,兼顾大容量与低延迟,适合预算充足且对延迟敏感的场景。
bigkey 替代方案选型的核心是延迟敏感度与成本的权衡。Pika 用 SSD 换成本,延迟从微秒级降到毫秒级,适合可容忍毫秒延迟的场景(如用户画像、历史消息)。Tair 用持久内存兼顾两者,但成本高。Codis 不改变单 key 的物理限制,只是横向分散。最佳实践:先拆分(Codis + hash tag),拆不动的转 SSD(Pika),延迟敏感且预算充足上 Tair。同时,从架构源头避免 bigkey——用 Hash 替代大 String、用 Stream 替代大 List、限制单集合元素数。
考察Redis Stream 的日志型消息队列设计与生产落地能力。核心回答:Redis Stream 是 Redis 5.0 引入的类 Kafka 的追加日志结构,通过 Radix Tree(基数树)存储消息、Consumer Group 实现组内消费与 ACK 机制,适合轻量级、低延迟、小吞吐的消息队列场景。需厘清 Stream ID 的生成规则、消费者组的 Pending Entries List(PEL)机制、以及与传统 List/ PubSub 的本质差异。
阶段 1:List(LPUSH/BRPOP)。Redis 早期用 List 做队列,简单但无 ACK 机制、无消费者组、无消息回溯,消息一出队就丢失,失败无法重试。
阶段 2:Pub/Sub。发布订阅模式,支持多消费者,但消息不持久化、无 ACK、离线消费者收不到消息,只适合做广播通知,不适合任务队列。
阶段 3:Stream(Redis 5.0,2018)。引入日志型结构:消息持久化、支持范围查询、Consumer Group 组内竞争消费、ACK 确认与未确认重投(Claim)。是 List + Pub/Sub 的补足,但非 Kafka 替代——适合中小规模(万级 QPS)、低延迟(亚毫秒级)场景。
阶段 4:Redis Streams 生态化。Redis 6.2+ 增强 Stream 的自动裁剪(MAXLEN/MINID)、消费者组迭代查询(XINFO)、以及与 Redis Functions 结合实现服务端消息处理。
Stream 核心结构:
- Radix Tree(基数树):Stream 底层用 Radix Tree 存储消息,每个节点是一个消息 ID(时间戳-序列号),保证有序且支持按 ID 范围查询。
- 消息 ID:格式
毫秒时间戳-序列号(如 1650000000000-0),默认自动分配,也可自定义。时间戳保证全局有序,序列号解决同一毫秒内的并发。 - Consumer Group(消费者组):类似 Kafka Consumer Group,组内多个消费者竞争消费不同消息,每条消息只被组内一个消费者处理。
- PEL(Pending Entries List):消费者读取消息后,消息进入该消费者的 PEL(未确认列表)。只有收到 XACK 后才从 PEL 移除;若超时未 ACK,可被其他消费者 XCLAIM 抢占重投。
Stream vs List vs PubSub:
| 特性 | Stream | List (LPUSH/BRPOP) | Pub/Sub |
|---|---|---|---|
| 消息持久化 | 是(存储在内存) | 是(存储在内存) | 否(即发即弃) |
| ACK 机制 | 有(XACK + PEL) | 无 | 无 |
| 消费者组 | 有(竞争消费) | 无 | 有(广播) |
| 消息回溯 | 有(按 ID 查询) | 无 | 无 |
| 阻塞读取 | 有(XREAD BLOCK) | 有(BRPOP) | 有 |
| 适用场景 | 可靠任务队列 | 简单队列 | 实时广播 |
Stream 生产者与消费者示例:
// 生产者:添加消息到 Stream
String msgId = redisTemplate.opsForStream()
.add(StreamRecords.newRecord()
.in("order-stream")
.ofObject(Map.of(
"orderId", orderId,
"userId", userId,
"amount", amount)));
// 返回自动生成的消息 ID,如 1650000000000-0
// 消费者组读取(Spring Data Redis)
StreamMessageListenerContainer.StreamMessageListenerContainerOptions<String, ObjectRecord<String, Order>> options =
StreamMessageListenerContainerOptions.builder()
.pollTimeout(Duration.ofSeconds(1))
.targetType(Order.class)
.build();
StreamMessageListenerContainer<String, ObjectRecord<String, Order>> container =
StreamMessageListenerContainer.create(redisConnectionFactory, options);
container.receiveAutoAck(
Consumer.from("order-group", "consumer-1"),
StreamOffset.create("order-stream", ReadOffset.lastConsumed()),
message -> processOrder(message.getValue())
);
container.start();
PEL 未确认消息排查与重投:
-- 查看消费者组中各消费者的未确认消息数
XINFO CONSUMERS order-stream order-group
-- 查看某消费者具体的 PEL 列表(未确认消息)
XPENDING order-stream order-group - + 10 consumer-1
-- 将超时未确认的消息转给其他消费者处理(claim)
XCLAIM order-stream order-group consumer-2 60000 1650000000000-0
-- 60000ms 为最小空闲时间,超过此时间未 ACK 的消息可被 claim
Stream 长度控制(防止无限增长):
-- 添加消息时自动裁剪,只保留最近 10000 条
XADD order-stream MAXLEN ~ 10000 * field value
-- 或按最小 ID 裁剪(删除早于某时间戳的消息)
XTRIM order-stream MINID 1640000000000-0
落地建议:① Stream 适合中小规模可靠队列(< 10万 QPS),大吞吐仍选 Kafka/RocketMQ;② 消费者必须 ACK,否则 PEL 无限膨胀导致内存泄漏;③ 用 MAXLEN 或定时任务清理老消息;④ 监控 PEL 长度与消费者滞后,及时发现消费积压。
追问 1:Stream 能否完全替代 Kafka?
不能。Stream 是内存结构,受单机内存限制,无分区多副本机制(Redis Cluster 的 Stream 分片需客户端手动按 key 拆分),无磁盘持久化重放能力,消费者组协调也较简单。Stream 的优势是低延迟(亚毫秒)+ 部署简单,适合:订单状态流转、延时任务调度(配合 ZSet)、轻量事件总线。Kafka 适合:大数据吞吐(百万 QPS)、长时间消息保留、跨机房复制、复杂流处理(Flink/Spark)。
追问 2:Stream 的消费者组与 Kafka Consumer Group 的差异?
① 协调器:Kafka 有 Broker 端的 GroupCoordinator 管理消费者位移与 Rebalance;Redis Stream 无中心协调器,消费者状态(PEL)是 Redis Stream 数据结构在服务端维护的,不是分散在各客户端;客户端通过 XPENDING/XACK/XAUTOCLAIM 查询与确认。② Rebalance:Kafka 自动处理分区分配与 Rebalance;Redis Stream 无分区概念,消费者各自独立读取,不自动 Rebalance。③ 位移管理:Kafka 位移持久化在 Broker;Redis Stream 的 PEL 存储在 Stream 内存结构中,重启 Redis 会丢失未 ACK 消息(除非 RDB/AOF 持久化)。
- 不 ACK 导致 PEL 膨胀:消费者处理完不 XACK,PEL 无限增长,最终耗尽内存。必须确保 ACK,或用自动 ACK 模式。
- 无消息上限导致内存爆掉:Stream 默认无限追加,必须用 MAXLEN 或定时裁剪控制长度。
- 用 Stream 做大流量日志采集:Stream 是内存结构,日志量巨大时很快打爆内存,应选 Kafka/Filebeat。
- 忽视 Redis 持久化对 Stream 的影响:若 Redis 未开 AOF 或 RDB,重启后 Stream 消息全丢。重要消息必须开持久化。
- 消费者故障后消息长期挂起:消费者宕机后其 PEL 中的消息无人处理,需监控 pending 消息数,用 XCLAIM 自动重投。
击穿=热点 key 过期瞬间请求打穿到 DB。分钟级目标是止血,不是架构演讲。
击穿与穿透、雪崩处置不同:击穿靠互斥重建/逻辑过期;穿透靠空值或布隆;雪崩靠随机 TTL/预热。失败可降级必须写进契约(对照 G05 / K05)。
值班顺序:
- 确认热点 key 与 DB QPS 尖刺是否同秒对齐。
- 打开互斥重建或逻辑过期开关。
- 临时加长 TTL / 预热该 key。
- 对该 key 或接口限流。
- 事后改为「永不过期 + 异步刷新」或本地缓存,走正式变更。
开关表:singleflight/mutex、逻辑过期、热点 key 名单、DB 限流阈值、降级开关。看板:缓存命中率、该 key QPS、DB 同指纹 QPS。禁止直接「关缓存」。
失败模式:当穿透处理乱加空值;关缓存直打;互斥锁变成全局锁。回滚点:关闭错误开关,恢复旧 TTL/降级策略。
击穿与穿透不同:击穿是热点过期瞬间并发打穿 DB。临时止血用互斥重建或逻辑过期;长期靠预热与单 key 护栏。
核对:TTL 是否对齐、是否有单飞锁、降级是否可读旧值。禁止只靠「把 TTL 调很长」掩盖设计问题。
量化:命中率跌破基线 −5 个百分点,或单 key QPS > 5000 且 DB 同指纹 QPS 同秒出现尖刺,即判定击穿;互斥重建等待上限设 200ms,超时返回旧值兜底。演进:热点治理按「永不过期 + 异步刷新 + 本地多级」收口,加长 TTL 只是临时手段;用 TTL 随机化 ±10% 与预热清单替代「把 TTL 调很长」的掩盖式修法。