Redis 简介:从诞生到数据类型,再到缓存三大问题

一篇写给后端开发者的 Redis 全景笔记。从 2009 年一个意大利程序员为了解决 MySQL 卡顿写下的第一行代码讲起,到五种基础数据类型的底层编码,再到线上真正会遇到的性能瓶颈、缓存穿透、缓存击穿、缓存雪崩和热 key。看完这篇,你应该能回答面试里 80% 的 Redis 问题。

一、Redis 的诞生:一个意大利程序员和一次 MySQL 的"卡顿"

1.1 2009 年,LLOOGG 撑不住了

Redis 的作者是 Salvatore Sanfilippo,网名 antirez,意大利人。在写 Redis 之前,他做过嵌入式开发,也写过网络安全工具 hping。2008 年前后,他做了一个叫 LLOOGG 的产品:一个实时网站访客分析工具,用户打开网页就能看到访客的实时访问流。

LLOOGG 的数据特点很刁钻:

  • 每个访客都要维护一份"最近访问列表",写入极其频繁;
  • 查询永远是"取最近 N 条",按时间倒序;
  • 数据是热的,只有最近一段时间的记录有意义。

他一开始用 MySQL 存,很快就撞墙了:B+ 树索引对这类高频写入 + 定长范围读取的场景非常不友好,写放大严重,表也越来越大。每次写入都要更新索引、维护事务日志,磁盘 IO 直接成为瓶颈。

于是他做了一个很自然的选择:既然数据是热的、结构是固定的,为什么要用磁盘和关系模型去折腾它? 他用 C 语言写了一个内存里的数据结构服务器,最初只支持 List(用来存访问记录)和 Set(后来加),把它挂在 LLOOGG 后面做实时数据的存储。

这个"顺手写的小工具",2009 年被开源出去,当天就冲上了 Hacker News 首页。

1.2 名字的由来

Redis 是 REmote DIctionary Server(远程字典服务器)的缩写。这个名字其实就说明了它的定位:一个可以通过网络访问的、以 key-value 字典为核心的服务器。

但它的价值从来不是"能存 key-value"------Memcached 早就做了。Redis 的差异点是:value 是有类型的、结构化的。你可以往里塞一个哈希、一个有序集合、一个位图,然后在服务端直接对这些结构做运算(求交集、按分数排序、自增、位运算),而不是把整个 value 拉到客户端再算。

这一句话,就决定了 Redis 后面十几年的生态位。

1.3 商业化与开源协议的那些事

|------|-------------------------------------------------------------|
| 时间 | 事件 |
| 2009 | antirez 开源 Redis,采用 BSD 协议 |
| 2010 | VMware 聘请 antirez 全职开发 Redis |
| 2013 | Pivotal 接手赞助 |
| 2015 | Redis Labs(后更名 Redis Ltd.)成为主要赞助方 |
| 2020 | antirez 宣布退出 Redis 维护,交棒给社区团队 |
| 2024 | Redis 7.4 起改用 RSALv2 / SSPLv1 双协议,Linux 基金会随即分叉出 Valkey |
| 2025 | Redis 8 发布,重新采用 AGPLv3,并把 Redis Stack 的模块能力合并进核心 |

这一段历史值得知道,因为它解释了为什么今天你会同时看到 Redis 和 Valkey 两个名字,以及为什么某些云厂商的"Redis 兼容服务"其实是 Valkey。对使用者来说,API 层面几乎无感;对企业来说,这是法务问题。

1.4 为什么 Redis 快?"单线程"到底是什么意思

几乎每篇 Redis 文章都会说"Redis 快是因为单线程",这句话对,但只说对了一半,而且很容易被误解。

先看真实原因,按重要性排序:

1)数据在内存里。 这是最根本的。内存随机读写的延迟约 100 纳秒,SSD 随机读约 100 微秒,机械盘约 10 毫秒。差了三到五个数量级。任何"内存数据库比磁盘数据库快"的讨论,本质上都是在说这件事。

2)单线程执行命令,没有锁,没有上下文切换。 因为所有命令在一个线程里串行执行,天然不存在并发写冲突。不需要加锁,也就不需要等锁、不需要维护锁的元数据、不需要线程调度。这是 Redis 能用极其简单的代码实现极高吞吐的核心。

3)IO 多路复用。 一个线程通过 epoll(Linux)/ kqueue(macOS)同时监听成千上万个连接,哪个连接有数据就处理哪个。所以"单线程"绝不等于"同时只能服务一个客户端"。

4)高效的数据结构与编码。 SDS 字符串、跳表、listpack、intset,都是为特定场景专门优化的结构,常数项极小。

5)RESP 协议足够简单。 基于文本行、解析成本极低,客户端实现也简单。

那"单线程"这个说法的准确版本是什么?

Redis 处理命令的主流程是单线程的,但 Redis 进程远不止一个线程。

具体来说:

  • 主线程:接收连接、解析命令、执行命令、写回响应。
  • 后台线程(bio) :Redis 4.0 引入,负责异步删除大 key(UNLINK)、AOF 落盘 fsync、关闭文件描述符等耗时操作。
  • IO 线程 :Redis 6.0 引入,负责网络读写的并行化(io-threads),但命令执行仍然是单线程。

所以 6.0 的多线程并不会让一条 GET 变快,它优化的是"大量连接、大量小包"场景下的网络开销。

单线程模型的代价也很明确:一条慢命令会阻塞所有请求 。KEYS *、大 key 的 DEL、几十万元素的 SINTER,都能让整个实例卡住几百毫秒甚至几秒。这就是后面"性能优化"一节要讲的全部内容。

二、Redis 的数据类型:不只是 KV

2.1 先建立整体认知

从源码视角看,Redis 里每一个 key-value 都是一个 redisObject:

复制代码
typedef struct redisObject {
    unsigned type:4;      // 对外类型:string / hash / list / set / zset / stream
    unsigned encoding:4;  // 内部编码:int / embstr / raw / listpack / quicklist / hashtable / skiplist / intset
    void *ptr;            // 指向真正的底层数据结构
    int refcount;         // 引用计数,用于对象共享
    unsigned lru:24;      // LRU 时间戳或 LFU 计数+衰减时间
} robj;

理解 type 和 encoding 的分离非常关键:同一个类型,在不同数据规模下会用完全不同的编码。小哈希是 listpack,大哈希自动变成 hashtable。这正是 Redis 能兼顾内存和性能的原因,也是很多"为什么我的 key 占这么多内存"问题的答案。

用 OBJECT ENCODING key 可以直接看当前编码。

2.2 String:最万能的那个

String 是唯一一个"什么都能塞"的类型,二进制安全,能存文本、JSON、序列化对象,甚至图片。

底层实现是 SDS(Simple Dynamic String) ,不是 C 原生字符串。SDS 相比 char* 有三个关键改进:

  • 记录长度 ,strlen 是 O(1)(C 字符串是 O(N));
  • 空间预分配 + 惰性释放:追加时按需多分配,避免每次都在内存里挪动;缩短时不立刻回收,避免频繁 realloc;
  • 二进制安全 :不依赖 \0 结尾,中间可以有 \0,所以能存 protobuf、图片等二进制数据。

三种编码:

|----------|--------------|------------------------------|
| 编码 | 条件 | 说明 |
| int | 值是 64 位有符号整数 | 直接把数值存在 ptr 里,不额外分配内存 |
| embstr | 长度 ≤ 44 字节 | 对象头和 SDS 一次性分配,只 alloc 一次,只读 |
| raw | 长度 > 44 字节 | 两次分配,可修改,追加时可能重新分配 |

44 这个数字不是随便定的:Redis 用 jemalloc,内存分配以 64 字节为档位。redisObject 占 16 字节,sdshdr8 头占 3 字节,加上结尾 \0,16 + 3 + 1 = 20,剩 44 字节给数据正好塞进 64 字节的分配单元。

常用命令:

复制代码
SET user:1:name "tom" EX 3600 NX   # 秒级过期 + 不存在才设置(分布式锁的基础)
GET user:1:name
MSET k1 v1 k2 v2                   # 批量写,减少 RTT
INCR article:1001:views            # 原子自增
INCRBY stock:1001 -1               # 原子自减(秒杀扣库存演示常用)
SETEX captcha:13800138000 300 8231 # 验证码
APPEND log "tail"                  # 追加
STRLEN log

典型场景:

  • 缓存对象:把整个 JSON 序列化进去,读多写少时最划算。
  • 计数器 :文章阅读数、点赞数。INCR 是原子的,天然并发安全。
  • 分布式锁 :SET lock:order:1001 <uuid> NX PX 30000。
  • 限流 :固定窗口用 INCR + EXPIRE,滑动窗口用 ZSet。
  • Session / Token :把登录态从 Tomcat 的 HttpSession 搬到 Redis,实现多实例共享(这也是黑马点评项目里替换 Session 的做法)。
  • 位图的基础:见 2.7。

坑点:

  • 用 String 存一个大 JSON,每次只改一个字段也要全量读出来、全量写回去。字段多且需要局部更新时,用 Hash。
  • SET 大 value 会阻塞主线程(序列化 + 网络传输),单 value 建议控制在 10KB 以内,超过 100KB 就要认真考虑拆分或换存储。

2.3 Hash:对象的正确打开方式

Hash 是一个 field-value 的映射表,非常适合存对象:user:1001 里放 name、age、city、lastLogin。

编码:

|-------------|-----------------------------------------------------------------------------------------------|
| 编码 | 条件(默认阈值) |
| listpack | field 数量 ≤ hash-max-listpack-entries(默认 128)且每个 value ≤ hash-max-listpack-value(默认 64 字节) |
| hashtable | 超过任一阈值后自动转换 |

补充:Redis 7.0 之前小哈希用的是 ziplist,7.0 起统一替换为 listpack 。listpack 去掉了 ziplist 里那个容易引发"连锁更新"的 prevlen 字段,结构更简洁。面试被问到"ziplist 的缺点",答案就是连锁更新。

常用命令:

复制代码
HSET user:1001 name tom age 18 city beijing
HGET user:1001 name
HMGET user:1001 name age            # 只取需要的字段,别用 HGETALL
HINCRBY user:1001 age 1
HDEL user:1001 city
HEXISTS user:1001 name
HLEN user:1001
HSCAN user:1001 0 COUNT 100         # 大 hash 遍历的正确姿势

典型场景:

  • 对象缓存:只读/只改部分字段时优于 String。
  • 购物车 :key = cart:{uid},field = 商品id,value = 数量,HINCRBY 加购减购,HLEN 算总数。
  • 分组计数 :HINCRBY stats:2026-10-22 pv 1。

坑点:

  • HGETALL 一个十万 field 的 hash,会一次性返回全量数据,直接打爆网络和内存,必须用 HSCAN。
  • Hash 的过期只能对整个 key 生效,不能给单个 field 设 TTL (Redis 7.4 起支持 HEXPIRE,但兼容性要确认)。

2.4 List:双向链表,也是简易队列

List 是双向链表,支持两端插入删除,下标访问是 O(N)。

底层是 quicklist :一个由 listpack 组成的双向链表。每个链表节点是一个 listpack(默认不超过 8KB,由 list-max-listpack-size 控制),既保留了链表的插入效率,又避免了每个元素一个指针的内存浪费。Redis 3.2 之前用的是 ziplist + 双向链表,quicklist 是二者的折中。

常用命令:

复制代码
LPUSH queue:order 1001 1002        # 左侧入队
RPOP queue:order                   # 右侧出队(FIFO)
BRPOP queue:order 0                # 阻塞式出队,0 表示永久阻塞
LRANGE timeline:1001 0 9           # 取前 10 条
LTRIM timeline:1001 0 999          # 只保留前 1000 条,控制内存
LPOS queue:order 1001              # 查找元素位置
LMOVE src dst LEFT RIGHT           # 原子地在两个 list 间搬元素

典型场景:

  • 简易消息队列 :LPUSH + BRPOP。优点是实现极简、支持阻塞;致命缺点是没有 ACK------消费者取走消息后进程崩溃,消息就丢了。
  • 最新列表 / 时间线 :LPUSH + LTRIM,永远只保留最近 N 条(朋友圈、最新公告)。
  • 栈 :LPUSH + LPOP 同侧进出。

坑点: 需要可靠消费就别用 List,用 Stream(有消费者组、ACK、pending 列表)。

2.5 Set:去重与集合运算

无序、不重复的字符串集合。

编码:

|-------------|--------------------------------------------------|
| 编码 | 条件 |
| intset | 所有元素都是整数,且元素数 ≤ set-max-intset-entries(默认 512) |
| listpack | Redis 7.2+ 起,非整数小集合也可能用 listpack |
| hashtable | 超过阈值 |

常用命令:

复制代码
SADD article:1001:likes 2001 2002 2003
SISMEMBER article:1001:likes 2001    # O(1) 判断是否点赞
SCARD article:1001:likes             # 基数
SINTER user:1:follow user:2:follow   # 共同关注
SDIFF  user:1:follow user:2:follow   # 我关注但他没关注
SUNION tag:a tag:b
SRANDMEMBER tag:a 3                  # 随机取 3 个(不删除),抽奖
SPOP lottery 1                       # 随机弹出(删除),不可重复中奖
SSCAN tag:a 0 COUNT 100

典型场景:

  • 去重:点赞用户、文章标签。
  • 集合运算:共同好友、共同关注、推荐("关注了 A 的人也关注了")。
  • 抽奖 :SRANDMEMBER 允许重复,SPOP 不允许。
  • UV 统计(小规模) :SADD uv:20261022 <uid>,SCARD 取数。缺点是内存随用户数线性增长,上亿 UV 要用 HyperLogLog。

坑点: SINTER / SUNION / SDIFF 的时间复杂度是 O(N*M),两个百万元素的集合求交集会阻塞主线程好几秒。要么用 SINTERCARD(只算基数不返回元素,Redis 7.0+),要么放到从库 / 离线任务里算。

2.6 Sorted Set:排行榜的标准答案

ZSet 的每个元素都带一个 score(double),按 score 排序,score 相同则按成员字典序。

底层是「跳表 + 哈希表」双结构:

  • 跳表(skiplist):负责按 score 有序,支持范围查询,增删查都是 O(log N)。
  • 哈希表(dict) :负责 member -> score 的 O(1) 映射,ZSCORE 靠它。

为什么不用红黑树 / 平衡树?三个原因:

  1. 跳表实现简单得多,不用维护旋转和平衡因子;
  1. 范围查询更高效:跳表找到起点后顺着底层链表往后走就行,树结构需要中序遍历;
  1. 内存更可控:跳表的层级是概率生成的,可以按需调参。

(顺带一提:Redis 是单线程的,跳表"无锁并发友好"这个优势在这里用不上,所以这条理由不成立,面试别答错。)

编码: 元素数 ≤ 128 且成员长度 ≤ 64 字节时用 listpack,否则 skiplist。

常用命令:

复制代码
ZADD rank 100 tom 95 jerry 88 lucy    # 添加/更新分数
ZINCRBY rank 10 tom                    # 加分,点赞数、热度
ZREVRANGE rank 0 9 WITHSCORES          # Top 10(高分在前)
ZRANGEBYSCORE rank 90 100              # 按分数区间查
ZRANK rank tom                         # 正序排名(从 0 开始)
ZREVRANK rank tom                      # 倒序排名
ZCARD rank                             # 总数
ZCOUNT rank 90 100                     # 区间内数量
ZREMRANGEBYSCORE rank 0 60             # 按分数删除(清理过期数据)

典型场景:

  • 排行榜 :游戏积分榜、热搜榜、直播间礼物榜。ZINCRBY 加热度,ZREVRANGE 0 9 取前十,ZREVRANK 查我的排名。
  • 延迟队列 :score = 执行时间戳,消费者用 ZRANGEBYSCORE key 0 now LIMIT 0 1 轮询到期任务。
  • 滑动窗口限流 :score = 请求时间戳,每次请求先 ZREMRANGEBYSCORE 清掉窗口外的,再 ZCARD 判断是否超限。
  • 带权重的消息队列:score 表示优先级。

坑点: ZSet 内存开销大(跳表 + 哈希双份),元素多时用 ZREMRANGEBYRANK 定期裁剪,别让它无限增长。

2.7 四种"特殊"类型

除了五个基础类型,Redis 还有几个基于 String 或 Stream 演化出来的能力,面试和实战都很常见。

Bitmap(位图)

本质就是 String,把每个 bit 当成一个布尔值。最大 512MB,即 2^32 个 bit。

复制代码
SETBIT sign:202610:1001 21 1      # 用户 1001 在 10 月 21 日签到
GETBIT sign:202610:1001 21
BITCOUNT sign:202610:1001         # 本月签到天数
BITOP AND dest bitmap1 bitmap2    # 多天都签到的用户 = 留存
BITPOS sign:202610:1001 0         # 第一个未签到日

一个 1000 万用户的签到系统,用 Bitmap 一天只要 1.25MB。这是它最大的价值。

HyperLogLog(基数统计)

用固定 12KB 估算集合基数,标准误差 0.81%。

复制代码
PFADD uv:20261022 user1 user2 user3
PFCOUNT uv:20261022               # 约等于去重后的 UV
PFMERGE uv:month uv:20261021 uv:20261022

它只存估算值,不存原始元素,所以不能判断"某个用户是否来过"。适合 UV、搜索词去重计数这类"只要数字、不要明细"的场景。100 万 UV,用 Set 要几十 MB,用 HLL 固定 12KB。

GEO(地理位置)

底层是 ZSet + GeoHash,把二维经纬度编码成一个 52 位整数当 score。

复制代码
GEOADD shops 116.404 39.915 "shop:1" 116.410 39.920 "shop:2"
GEODIST shops "shop:1" "shop:2" km
GEOSEARCH shops FROMLONLAT 116.40 39.91 BYRADIUS 5 km ASC

"附近的门店"、"附近的人"就是它。

Stream(消息流)

Redis 5.0 引入,是 Redis 官方给出的"正经消息队列"答案,思路对标 Kafka。

复制代码
XADD orders * orderId 1001 amount 99     # * 表示自动生成消息 ID
XGROUP CREATE orders g1 0 MKSTREAM       # 创建消费者组
XREADGROUP GROUP g1 c1 COUNT 10 BLOCK 2000 STREAMS orders >
XACK orders g1 1698000000000-0           # 确认消费
XPENDING orders g1                      # 查看未确认消息
XAUTOCLAIM orders g1 c2 60000 0         # 认领超时未 ACK 的消息

相比 List 的三个关键升级:消息 ID 可回溯、消费者组、ACK + pending 列表 。消费者宕机后,消息不会丢,会被其他消费者 XAUTOCLAIM 走。

(但如果是正经的订单、支付这类必须可靠的场景,还是用 RocketMQ / Kafka。Redis Stream 适合"轻量、可容忍极端情况下少量丢失"的场景。)

2.8 编码与内存:为什么你的 key 占这么多内存

几个实战中很有用的判断:

复制代码
OBJECT ENCODING user:1001      # 看编码:listpack 还是 hashtable
MEMORY USAGE user:1001         # 看这个 key 占多少字节
MEMORY USAGE user:1001 SAMPLES 0   # 精确计算(慢,慎用)
INFO memory                    # 整体内存、碎片率

省钱的三条经验:

  1. 控制集合规模在 listpack 阈值内。 129 个 field 的 hash 会从 listpack 变成 hashtable,内存可能翻 3 到 5 倍。如果业务允许,把大 hash 拆成多个小 hash(比如按 id 取模分桶),反而更省。
  1. key 要短但要可读。 user:1001:name 比 application:production:user:1001:profile:name 省很多,几十亿 key 时差距是 GB 级。但别短到看不懂,前缀用统一规范(业务:对象:id:字段)。
  1. 别用 String 存一堆小对象。 100 万个独立 key,每个都有 redisObject 头 + dictEntry + SDS 头 + 过期字典的开销,实际内存可能是数据本身的 3 倍。用 Hash 聚合存储能省一半以上。

2.9 选型速查表

|-------------------|-------------|---------------------------------------|
| 需求 | 类型 | 关键命令 |
| 缓存对象 / 计数器 / 分布式锁 | String | SET NX PX、INCR |
| 对象的局部更新 | Hash | HSET、HINCRBY、HSCAN |
| 队列 / 最新 N 条 | List | LPUSH、BRPOP、LTRIM |
| 去重 / 交并差 / 抽奖 | Set | SADD、SINTER、SPOP |
| 排行榜 / 延迟队列 / 滑动窗口 | ZSet | ZINCRBY、ZREVRANGE、ZRANGEBYSCORE |
| 签到 / 布尔标记(海量) | Bitmap | SETBIT、BITCOUNT |
| UV / 基数估算(海量) | HyperLogLog | PFADD、PFCOUNT |
| 附近的人 / 门店 | GEO | GEOADD、GEOSEARCH |
| 可靠消息队列(轻量) | Stream | XADD、XREADGROUP、XACK |

三、性能优化:把 Redis 用对,比换更强的机器有用

Redis 本身已经足够快,线上"Redis 慢"的案例里,99% 是用错了,不是机器不够。这一节按"怎么发现问题 → 怎么解决问题"的顺序展开。

3.1 第一步:先定位,别猜

|----------|------------------------------|---------------------------------|
| 手段 | 命令 | 用途 |
| 慢查询日志 | SLOWLOG GET 10 | 找出执行时间超阈值的命令 |
| 命令统计 | INFO commandstats | 看哪类命令调用最多、平均耗时多少 |
| 延迟监控 | LATENCY HISTORY event | 看 fork、命令、过期等事件的延迟分布 |
| 大 key 扫描 | redis-cli --bigkeys | 按类型采样找出最大的 key |
| 热 key 扫描 | redis-cli --hotkeys | 需要 maxmemory-policy 为 LFU 才有效 |
| 实时命令流 | MONITOR | 生产禁用,会把吞吐打掉一半 |
| 连接与内存 | INFO clients、INFO memory | 连接数、内存、碎片率 |

慢查询日志的配置:

复制代码
CONFIG SET slowlog-log-slower-than 10000   # 单位微秒,10000 = 10ms
CONFIG SET slowlog-max-len 256
SLOWLOG GET 10
SLOWLOG RESET

要注意:慢查询日志只统计命令执行时间,不含网络传输和排队时间。也就是说,如果慢日志里一片干净但客户端还是慢,那问题大概率在网络、连接池或者队列积压上。

3.2 致命清单:这些命令不要在生产用

|------------------------|-----------------------|----------------------------------|
| 禁用 | 原因 | 替代 |
| KEYS * | O(N) 全量扫描,百万 key 直接卡死 | SCAN 0 MATCH user:* COUNT 1000 |
| FLUSHALL / FLUSHDB | 同步清空,直接阻塞 | FLUSHALL ASYNC(4.0+) |
| DEL bigkey | 同步释放内存,阻塞 | UNLINK bigkey(异步) |
| HGETALL bigHash | 一次返回全量数据 | HSCAN 分批 |
| SMEMBERS bigSet | 同上 | SSCAN |
| LRANGE key 0 -1 | 同上 | LRANGE key 0 99 分页 |
| SINTER 大集合 | O(N*M) | SINTERCARD 或异步离线算 |
| MONITOR | 吞吐骤降 | SLOWLOG + commandstats |

SCAN 的三个特性必须知道:游标式遍历、不保证不重复、可能漏元素(扩容缩容期间)。所以用它做业务逻辑时要能容忍重复(比如用 Set 去重)。

3.3 大 key:最容易被忽视的性能杀手

怎么算大 key? 没有统一标准,实践中的经验值:

  • String:value > 10KB 就值得注意,> 100KB 算大;
  • 集合类(Hash / List / Set / ZSet):元素数 > 5000,或总大小 > 1MB。

为什么危险?

  1. 网络阻塞:一次读 10MB 的 value,千兆网卡上就是 80ms,这期间该连接的其他请求全在等。
  1. 删除阻塞 :DEL 一个百万元素的 hash,要逐个释放,主线程卡住。
  1. 过期阻塞:大 key 到期时,惰性删除/定期删除都会阻塞。
  1. 集群倾斜 :某个 slot 上挂着一个 5GB 的 key,那个节点的内存和流量都会被打爆,而且无法自动迁移。
  1. 持久化放大:AOF 重写、RDB 生成时,大 key 会占用大量内存和 IO。

怎么治?

复制代码
redis-cli --bigkeys                 # 快速普查
redis-cli --memkeys                # 按内存排序(更准,需要 6.0+)
UNLINK user:1001:bigjson           # 异步删除,交给后台线程
  • 拆分:一个大 hash 按 id 取模拆成 N 个小 hash;一个大 List 按时间分片。
  • 压缩 :JSON 换成 protobuf / MessagePack,或者开启 list-compress-depth。
  • 开启惰性删除 (redis.conf):

    lazyfree-lazy-eviction yes # 淘汰时异步释放
    lazyfree-lazy-expire yes # 过期时异步释放
    lazyfree-lazy-server-del yes # 内部删除(如 rename)异步
    lazyfree-lazy-user-del yes # 用户 DEL 也走异步(4.0+)
    lazyfree-lazy-user-flush yes # FLUSHALL 异步

3.4 Pipeline 与批量命令:省的是 RTT,不是 CPU

每条命令一次往返,即使同机房 RTT 只有 0.5ms,1 万条命令就是 5 秒------而 Redis 真正执行这些命令可能只要 5ms。瓶颈在网络往返上。

复制代码
// 一次 RTT 发 1000 条命令
List<Object> results = redisTemplate.executePipelined((RedisCallback<Object>) conn -> {
    for (int i = 0; i < 1000; i++) {
        conn.stringCommands().set(("k" + i).getBytes(), ("v" + i).getBytes());
    }
    return null;
});

Pipeline 的注意点:

  • 不是原子的。中间的某条命令失败,后面的照常执行,也不会回滚。
  • 要分批 。一次塞 10 万条会把服务端的输出缓冲区撑爆,建议每批 500~1000 条。服务端有 client-output-buffer-limit 保护,超了会直接断开连接。
  • 同类型批量操作优先用原生命令 :MGET、MSET、HMGET 比 Pipeline 更省,因为服务端一次解析。

Pipeline vs 事务 vs Lua 的选择:

|--------------|--------|-------------|------------------------------|
| 方案 | 原子性 | 能否读中间结果 | 适用 |
| Pipeline | 否 | 是(客户端收全部结果) | 批量写、无依赖的批量操作 |
| MULTI/EXEC | 是(不中断) | 否 | 极少用,无法在事务中做条件判断 |
| Lua 脚本 | 是 | 是 | 需要"读-判断-写"原子的场景,如分布式锁释放、秒杀扣减 |

3.5 连接与网络:最常被忽略的一层

  • 一定用连接池 。每次 new Jedis() 都要 TCP 三次握手 + 认证,QPS 高时大量时间浪费在握手和 TIME_WAIT 上。Spring Boot 2.x 起默认 Lettuce(基于 Netty,线程安全,可共享连接),Jedis 是每个线程一个连接。
  • 池大小要合理 。不是越大越好,Redis 单线程处理,几百个连接足够。设置 maxTotal 后要配合 maxWaitMillis,避免请求无限堆积。
  • maxclients 与文件描述符 。默认 maxclients=10000,但 Linux 的 ulimit -n 常常是 1024,连接一多就报 "max number of clients reached"。
  • 网络位置 。跨可用区、跨机房的 Redis 延迟可能是同机房的 10 倍以上。缓存服务的部署原则是尽量和业务在同一可用区。
  • 禁用 THP(透明大页)。THP 会让 fork 时的 COW 开销变大,延迟抖动明显,Redis 官方明确建议关闭。

3.6 内存:淘汰策略与过期机制

先设上限,否则 Redis 会把机器内存吃光然后被 OOM Killer 干掉:

复制代码
maxmemory 4gb
maxmemory-policy allkeys-lfu

8 种淘汰策略:

|-------------------|----------|---------------|--------------------|
| 策略 | 淘汰范围 | 算法 | 适用 |
| noeviction | 不淘汰 | 写入直接报错 | 数据不能丢(配合持久化当 DB 用) |
| allkeys-lru | 所有 key | 近似 LRU | 纯缓存场景首选 |
| allkeys-lfu | 所有 key | 近似 LFU(带频率衰减) | 有明显热点、长尾分布 |
| allkeys-random | 所有 key | 随机 | 访问分布均匀 |
| volatile-lru | 设了 TTL 的 | 近似 LRU | 缓存和持久数据混存 |
| volatile-lfu | 设了 TTL 的 | 近似 LFU | 同上,有热点 |
| volatile-random | 设了 TTL 的 | 随机 | 少见 |
| volatile-ttl | 设了 TTL 的 | 剩余 TTL 最小优先 | 希望快过期的先走 |

面试常问"LRU 是怎么实现的"。准确答案:Redis 的 LRU 是近似 LRU ,每个对象只存 24 bit 的时间戳,淘汰时随机采样 maxmemory-samples(默认 5)个 key,挑最久没用的。不是维护一个完整链表。LFU 则是 8 bit 计数 + 8 bit 衰减时间,计数还会随时间衰减,避免"曾经的热点永远不过期"。

过期删除机制(这个必须会):

Redis 用的是惰性删除 + 定期删除的组合:

  • 惰性删除:访问 key 时才检查是否过期,过期就删。省 CPU,但没人访问的 key 会一直占内存。
  • 定期删除 :每秒执行 10 次(hz=10),每次随机抽 20 个设了 TTL 的 key,删掉过期的;如果这 20 个里过期比例超过 25%,就再来一轮。为了防止阻塞,单轮有时间上限(默认不超过 25% 的 CPU 时间)。

这就是为什么"过期的 key 不一定马上消失",也是 DBSIZE 和实际 key 数量对不上的原因。

内存碎片:

复制代码
INFO memory   # 看 mem_fragmentation_ratio

比值 > 1.5 说明碎片较多,可以考虑开启主动碎片整理:

复制代码
activedefrag yes
active-defrag-ignore-bytes 100mb
active-defrag-threshold-lower 10

比值 < 1 说明用了 Swap,性能会断崖式下跌,必须立刻处理。

3.7 持久化带来的抖动

很多人不知道,RDB 的 fork 本身就会造成延迟尖峰,尤其是内存大的实例(fork 要复制页表,10GB 实例可能耗时几十到上百毫秒)。

|----------------|------------------|-----------------------------------------|
| 问题 | 原因 | 应对 |
| fork 阻塞 | 复制页表,内存越大越久 | 控制单实例内存 ≤ 10GB,用集群分片 |
| COW 内存膨胀 | 写操作触发页复制,最坏翻倍 | 预留 50% 内存;避免 fork 期间大量写 |
| AOF always 慢 | 每条命令都 fsync | 用 everysec,最多丢 1 秒数据 |
| AOF 重写抖动 | 重写时 fork + 大量 IO | 用混合持久化,auto-aof-rewrite-percentage 调大 |
| 主从全量同步 | 生成 RDB + 传输,占用带宽 | 避免频繁主从切换,适当调大 repl-backlog |

推荐的持久化配置(兼顾恢复速度与数据安全):

复制代码
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes    # 混合持久化,AOF 前半段是 RDB 格式,恢复快
save 900 1
save 300 10

3.8 Redis 6.0 的多线程 IO 到底要不要开

复制代码
io-threads 4                  # 建议为 CPU 核数的 3/4,不超过 8
io-threads-do-reads yes       # 6.0 需要显式开启读多线程

什么时候有用? 连接数多、单 value 较大、网络带宽接近打满时,能提升 30%~100%。

什么时候没用? 命令本身慢(O(N) 命令、大 key)、CPU 已经打满、单连接场景。因为命令执行还是单线程,IO 多线程只分担了"读 socket / 写 socket / 协议解析"这部分。

一个常见误区:以为开了多线程就能解决 KEYS * 导致的卡顿。不能,那条命令还是在主线程里跑。

3.9 优化清单(可以直接拿去对着查)

  1. 生产环境禁用 KEYS、FLUSHALL、MONITOR、HGETALL 大对象。
  1. 所有集合类操作必须分页,用 SCAN 系列。
  1. 监控 SLOWLOG,超过 10ms 的命令一律复盘;把 slowlog-log-slower-than 调到 5ms 抓得更细。
  1. 定期跑 --bigkeys,大 key 拆分成小 key。
  1. 开 lazyfree 全家桶。
  1. 批量写入用 MGET / Pipeline,每批 ≤ 1000。
  1. 用连接池,maxTotal 按并发量算,别拍脑袋给 1000。
  1. 设 maxmemory + allkeys-lfu,别让实例裸奔。
  1. 单实例内存控制在 10GB 以内,超过就分片。
  1. 关闭 THP,Redis 与业务同可用区部署。
  1. 读多写少、value 大的场景再考虑开 io-threads。
  1. 上线前用 redis-benchmark 或全链路压测验证,别在线上试。

四、缓存三大问题:穿透、击穿、雪崩,还有热点

4.1 先用一张表把四个概念分清楚

这四个词经常被混着说,但成因完全不同,解决方案也完全不同。

|-----------------|-----------------------------------|-------------------------|-----------------|-----------------------|
| 问题 | 一句话定义 | 典型触发 | 影响范围 | 核心解法 |
| 缓存穿透 | 查一个根本不存在的数据,缓存永远不命中,每次都打到 DB | 恶意刷 id=-1、爬虫遍历不存在的 ID | 单次请求量 × 全部打到 DB | 空值缓存、布隆过滤器、参数校验 |
| 缓存击穿 | 单个热点 key 过期瞬间,大量并发同时回源 | 秒杀商品详情、首页配置、热搜 | 单个 key,但并发极高 | 互斥锁重建、逻辑过期 |
| 缓存雪崩 | 大量 key 同时过期 或 Redis 整体不可用 | 批量预热用了相同 TTL、Redis 宕机 | 整个 DB | TTL 随机化、多级缓存、高可用、熔断降级 |
| 缓存热点(热 key) | 单个 key 的访问量极端倾斜,压垮单个节点/单线程 | 明星离婚、爆款商品、突发热点 | 单个分片或单个实例 | 本地缓存、key 打散、读写分离 |

用一句更直白的话区分:

穿透是"查不存在的东西",击穿是"一个热点没了",雪崩是"一片缓存没了",热 key 是"一个热点太热了"。

严格来说"三大缓存问题"指的是前三个,热 key 属于第四个维度的问题。但在真实的高并发系统里,热 key 往往比前三个更容易造成事故,所以必须一起讲。

4.2 缓存穿透:查询一个不存在的数据

场景还原:

正常流程是"查缓存 → 未命中 → 查 DB → 回写缓存"。如果查询的 ID 在 DB 里也不存在,那就不会回写任何东西,缓存永远不命中。攻击者只要用脚本遍历 id = -1, -2, -3 ... 或者随机 ID,每个请求都会穿透到 DB。10 万 QPS 的穿透流量,MySQL 扛不住。

方案一:缓存空值(最简单,优先上)

DB 查不到时,往 Redis 里写一个特殊值(空字符串或 "null"),给一个较短的 TTL(比如 30~60 秒)。

复制代码
public Shop queryById(Long id) {
    String key = "cache:shop:" + id;
    String json = redis.opsForValue().get(key);

    if (json != null) {
        // 空字符串代表"确认不存在",直接返回 null,不打 DB
        return json.isEmpty() ? null : JSON.parseObject(json, Shop.class);
    }

    Shop shop = shopMapper.selectById(id);
    if (shop == null) {
        // 关键:缓存空值,TTL 要短
        redis.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
        return null;
    }
    redis.opsForValue().set(key, JSON.toJSONString(shop), 30, TimeUnit.MINUTES);
    return shop;
}

要点:

  • 必须区分"缓存未命中"和"缓存了空值" 。用 null 判断未命中,用 "" 判断确认不存在。用 Spring 的 StringRedisTemplate 时,get 返回 null 和 "" 是两种状态。
  • TTL 要短。空值缓存会占用内存,TTL 太长会撑爆 Redis;而且如果这个数据后来被创建了,TTL 太长会导致"新建了却查不到"的脏读。
  • 数据新增后要主动删掉空值缓存,否则新数据要等 TTL 过期才可见。
  • 风险:如果攻击者每次用不同的随机 ID,空值缓存会被大量"一次性 key"填满,反而造成内存浪费。所以空值缓存只能挡住"重复 ID"的穿透,挡不住随机 ID。

方案二:布隆过滤器(挡住随机 ID)

布隆过滤器的核心特性:判断"一定不存在"是准确的,判断"存在"可能有误判。

  • 说不存在 → 100% 不存在,直接拒绝;
  • 说存在 → 可能存在,放行去查缓存/DB。

所以它正好用来做"前置拦截",挡住那些根本不存在的 ID。

复制代码
// 1. 预热:把所有合法 id 写进位图(多个哈希函数)
public void initBloom(List<Long> allIds) {
    for (Long id : allIds) {
        for (int seed = 0; seed < 5; seed++) {   // 5 个哈希函数
            long offset = Math.abs(hash(id, seed)) % BIT_SIZE;
            redis.opsForValue().setBit("bloom:shop", offset, true);
        }
    }
}

// 2. 查询:任一位为 0 → 一定不存在
public boolean mightExist(Long id) {
    for (int seed = 0; seed < 5; seed++) {
        long offset = Math.abs(hash(id, seed)) % BIT_SIZE;
        Boolean bit = redis.opsForValue().getBit("bloom:shop", offset);
        if (Boolean.FALSE.equals(bit)) {
            return false;
        }
    }
    return true;   // 可能存在
}

工程上一般直接用 RedisBloom 模块 (BF.ADD / BF.EXISTS),或者用 Guava 的 BloomFilter(单机内存版)。

复制代码
BF.RESERVE shop:bloom 0.001 10000000   # 误判率 0.001,容量 1000 万
BF.ADD shop:bloom 1001
BF.EXISTS shop:bloom 1001

选择参数时的公式(面试可能问):

  • 位数组大小 m = -n * ln(p) / (ln2)^2
  • 哈希函数个数 k = m/n * ln2
  • 其中 n 是预估元素数,p 是期望误判率。

布隆过滤器的三个坑:

  1. 不支持删除 。因为多个元素共享 bit 位,删一个会影响别人。需要删除场景用计数布隆过滤器 (Counting Bloom)或布谷鸟过滤器(Cuckoo Filter)。
  1. 需要预热。新增数据要同步写进过滤器,否则会误杀新数据。
  1. 有误判。误判率是概率,不能当作强一致判断,最终还是要靠 DB 兜底。

方案三:参数校验 + 限流 + 黑名单

最便宜也最有效的一层:

  • 入口校验:ID 必须为正整数、格式合法,id <= 0 直接拒绝;
  • 接口限流(IP / 用户维度),单 IP 每秒超过 N 次直接返回 429;
  • 黑名单:把高频异常 IP 拉黑一段时间;
  • 数据权限校验:不属于该用户的资源直接拒绝。

方案四:DB 兜底

唯一索引、非空约束。即使穿透到 DB,也不能让非法数据写进去。

总结:分层拦截

请求进入
→ 参数校验(挡掉非法格式,成本 ~0)
→ 布隆过滤器(挡掉不存在的 ID,内存成本低)
→ 缓存空值(挡掉重复的无效请求)
→ 限流 / 黑名单(挡掉恶意高频)
→ DB 唯一索引(最后一道)

4.3 缓存击穿:一个热点 key 失效了

场景还原:

双十一某爆款商品,key 平时 QPS 5 万,全打在缓存上。TTL 到期的那一刻,这个 key 消失了,5 万个并发同时发现"缓存没了",同时去查 DB、同时写缓存。DB 瞬间被打穿。

关键在于并发:不是"一次查 DB",而是"同一瞬间几万个请求都去查 DB"。

方案一:互斥锁重建(保证一致性)

核心思路:只让一个线程去查 DB 并重建缓存,其他线程等待后重试。

复制代码
public Shop queryWithMutex(Long id) throws InterruptedException {
    String key = "cache:shop:" + id;
    String lockKey = "lock:shop:" + id;

    // 1. 查缓存
    String json = redis.opsForValue().get(key);
    if (json != null) {
        return json.isEmpty() ? null : JSON.parseObject(json, Shop.class);
    }

    // 2. 未命中,尝试抢锁
    String token = UUID.randomUUID().toString();
    Boolean locked = redis.opsForValue()
            .setIfAbsent(lockKey, token, 10, TimeUnit.SECONDS);

    if (Boolean.FALSE.equals(locked)) {
        // 3. 没抢到锁,短暂睡眠后重试(走递归或循环)
        Thread.sleep(50);
        return queryWithMutex(id);
    }

    // 4. 抢到锁:双重检查,防止在等锁期间别人已经重建好了
    try {
        json = redis.opsForValue().get(key);
        if (json != null) {
            return json.isEmpty() ? null : JSON.parseObject(json, Shop.class);
        }

        Shop shop = shopMapper.selectById(id);
        if (shop == null) {
            redis.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
            return null;
        }
        redis.opsForValue().set(key, JSON.toJSONString(shop), 30, TimeUnit.MINUTES);
        return shop;
    } finally {
        unlock(lockKey, token);   // 必须在抢到锁的分支里释放
    }
}

释放锁必须用 Lua 保证原子性,否则可能删掉别人的锁:

复制代码
-- unlock.lua:只有 value 是自己的 token 才删
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

生产环境建议直接用 Redisson,它解决了三个问题:锁自动续期(看门狗)、可重入、释放安全。

复制代码
RLock lock = redissonClient.getLock("lock:shop:" + id);
try {
    if (lock.tryLock(0, 10, TimeUnit.SECONDS)) {
        // 查 DB + 重建缓存
    }
} finally {
    if (lock.isHeldByCurrentThread()) lock.unlock();
}

互斥锁的缺点 :同一时刻只有一个线程能返回数据,其他线程在等待,吞吐下降、响应变慢。等待的线程如果重试次数多,还会带来额外的 Redis 压力。

方案二:逻辑过期(保证可用性)

核心思路:不设物理 TTL,把过期时间写进 value 里。读到逻辑过期的数据时,先返回旧值,同时异步启动一个线程去重建缓存。

复制代码
public Shop queryWithLogicalExpire(Long id) {
    String key = "cache:shop:" + id;
    String json = redis.opsForValue().get(key);
    if (json == null) {
        return null;   // 热点数据必须提前预热,这里只是兜底
    }

    RedisData data = JSON.parseObject(json, RedisData.class);
    Shop shop = JSON.parseObject(data.getData().toString(), Shop.class);

    // 未逻辑过期,直接返回
    if (data.getExpireTime().isAfter(LocalDateTime.now())) {
        return shop;
    }

    // 已逻辑过期:尝试抢锁,抢到的线程异步重建
    String lockKey = "lock:shop:" + id;
    String token = UUID.randomUUID().toString();
    if (Boolean.TRUE.equals(redis.opsForValue()
            .setIfAbsent(lockKey, token, 10, TimeUnit.SECONDS))) {
        REBUILD_POOL.submit(() -> {
            try {
                Shop fresh = shopMapper.selectById(id);
                RedisData newData = new RedisData();
                newData.setData(fresh);
                newData.setExpireTime(LocalDateTime.now().plusMinutes(30));
                redis.opsForValue().set(key, JSON.toJSONString(newData));  // 不设 TTL
            } finally {
                unlock(lockKey, token);
            }
        });
    }

    // 无论是否抢到锁,都先返回旧数据
    return shop;
}

两种方案的取舍:

|----------|------------------|--------------------|
| 维度 | 互斥锁 | 逻辑过期 |
| 数据一致性 | 强(重建完成前所有请求等待) | 弱(会读到一段时间的旧数据) |
| 可用性 / 吞吐 | 低(线程阻塞等待) | 高(立即返回旧值) |
| 实现复杂度 | 低 | 中(需要包装过期时间、线程池) |
| 适用场景 | 对数据准确性要求高(库存、余额) | 对可用性要求高(商品详情、首页推荐) |

方案三:热点数据永不过期 + 后台刷新

用一个定时任务在低峰期主动刷新热点 key,或者用"双 key"策略:主 key 短 TTL,备份 key 长 TTL,主 key 失效时读备份 key 并触发异步重建。这样用户永远读得到数据。

4.4 缓存雪崩:一片缓存同时没了

雪崩有两种成因,应对方式完全不同。

成因一:大量 key 在同一时间过期

典型触发场景是"系统上线时批量预热缓存,所有 key 都设了 30 分钟 TTL"。30 分钟后,几百万个 key 在同一秒失效,全部请求打到 DB。

解法 1:TTL 加随机值

复制代码
int ttl = 1800 + RandomUtil.randomInt(0, 300);   // 30 分钟 ± 5 分钟
redis.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);

这是最简单、性价比最高的一招,所有缓存写入都应该这么干。

解法 2:分级 TTL

不同类型的数据用不同的基础 TTL(商品 30 分钟、用户信息 10 分钟、配置 1 小时),天然错开。

解法 3:双 key 兜底

主 key 设 TTL(比如 30 分钟),备份 key 不设 TTL 或设很长(比如 2 小时)。读主 key 未命中时读备份 key,同时异步重建主 key。用户几乎感知不到缓存失效。

解法 4:缓存预热 + 错峰刷新

  • 上线前把热点数据批量写入 Redis(用 Pipeline 分批);
  • 定时刷新任务用随机延迟启动,比如每个实例在预设时间 ±5 分钟内随机执行,避免所有实例同时刷。

成因二:Redis 整体不可用

这是更严重的情况:Redis 挂了、机房断电、网络分区、主从切换失败。这时候不是"少量请求穿透",而是全部流量瞬间压到 DB,DB 几秒内就会连接池耗尽、CPU 100%,然后整个系统崩掉。

解法 1:Redis 高可用

  • 主从 + 哨兵:自动故障转移,适合中小规模。
  • Redis Cluster:分片 + 自动故障转移,单机内存不够时的选择(16384 个 slot,CRC16 取模)。
  • 持久化 + 定期演练:光有主从不够,必须真正演练过主从切换,否则故障时才发现切换要 30 秒。
  • 多副本跨可用区:避免单机房故障。

解法 2:熔断降级限流(保命的最后一层)

即使 Redis 挂了,系统也不能死。用 Sentinel / Resilience4j 做熔断:

复制代码
@SentinelResource(
    value = "queryShop",
    fallback = "queryShopFallback",
    blockHandler = "queryShopBlockHandler"
)
public Shop queryShop(Long id) { ... }

// 降级:返回兜底数据(默认商品、缓存快照、静态页)
public Shop queryShopFallback(Long id, Throwable t) {
    return Shop.defaultShop();
}

要点:

  • 降级不是返回错误,是返回"可用的旧数据/默认数据"。用户看到默认推荐比看到 500 错误好得多。
  • DB 侧必须有限流。即使缓存全挂,也要保证 DB 不被压死,宁可拒绝一部分请求。
  • 连接池隔离。给缓存回源单独一个连接池,避免缓存故障拖垮其他业务。

解法 3:多级缓存

复制代码
请求 → 本地缓存(Caffeine,纳秒级)→ Redis(毫秒级)→ DB

本地缓存能扛住绝大部分热点读,即使 Redis 整体挂掉,本地缓存还能支撑一段时间(取决于命中率)。代价是一致性问题:多实例的本地缓存可能不一致,需要靠广播失效(Redis Pub/Sub、MQ)或短 TTL 来收敛。

解法 4:限流与排队

在网关层做全局限流,超过阈值的请求直接拒绝或排队。这听起来很粗暴,但比"整个系统雪崩、所有用户都不可用"要好得多。

雪崩检查清单:

  • 所有缓存写入的 TTL 都加了随机偏移?
  • 有缓存预热机制?
  • Redis 有主从 + 哨兵/集群?演练过故障切换吗?
  • 有关键接口的降级开关?
  • DB 有限流和连接池保护?
  • 有本地缓存兜底?

4.5 缓存热点(热 key):一根针扎穿一个节点

这和击穿不是一回事。 击穿是"key 失效"的瞬间问题,热 key 是"key 一直很热"的持续问题。

场景:

某明星官宣离婚,微博热搜第一。这个热搜 key 的 QPS 可能是 50 万,而其他 key 的 QPS 只有几百。问题在于:

  1. 单分片打满:在 Cluster 模式下,一个 key 只能落在一个节点上。50 万 QPS 全压那一个节点的单线程,其他节点闲得发慌。
  1. 网络带宽打满:如果 value 是 100KB 的榜单 JSON,50 万 QPS 就是 50GB/s,网卡先炸。
  1. 超出单实例能力上限:Redis 单实例大约 10 万 QPS,热 key 直接超过这个天花板。

怎么发现热 key?

|-------------------------|-----------------------------|-----------------------------|
| 方式 | 说明 | 优缺点 |
| redis-cli --hotkeys | 基于 LFU 计数排序 | 需要 allkeys-lfu,离线分析,有采样误差 |
| OBJECT FREQ key | 查单个 key 的访问频率 | 同样需要 LFU 策略 |
| 客户端埋点统计 | 在 SDK 层做滑动窗口计数,定期上报 | 精准,但要改客户端,有性能开销 |
| 代理层统计 | Twemproxy / Codis / 云厂商代理统计 | 无侵入,依赖代理能力 |
| MONITOR 采样 | 短时间采样分析 | 影响性能,只能应急 |
| 京东 hotspot-key / 有赞 TMC | 专门的热 key 探测中间件 | 需要额外部署 |

对于大多数团队,最实用的做法是客户端埋点 + 定时上报 Top N:在 Redis 客户端封装一层,用本地 LongAdder 按 key 计数,每 10 秒上报一次 Top 10 到监控。

怎么解决热 key?

方案一:本地缓存(最有效)

把热 key 放到 JVM 本地缓存(Caffeine / Guava Cache),请求根本不出进程。

复制代码
Cache<String, String> localCache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(10, TimeUnit.SECONDS)   // 短 TTL,控制不一致窗口
        .build();

public String get(String key) {
    return localCache.get(key, k -> redis.opsForValue().get(k));
}
  • 优点:延迟从毫秒降到纳秒,Redis 压力降几个数量级。
  • 缺点:多实例数据可能不一致。用短 TTL(5~10 秒)+ 变更时广播失效来收敛。

方案二:key 打散(分片副本)

把一份热数据复制成 N 份,读的时候随机选一份:

复制代码
hotkey:shop:1001:0
hotkey:shop:1001:1
...
hotkey:shop:1001:9
int idx = ThreadLocalRandom.current().nextInt(10);
String key = "hotkey:shop:1001:" + idx;

这样 50 万 QPS 被分散到 10 个 key 上,如果这些 key 落在不同 slot / 不同节点,就能利用多核多节点。

注意 :在 Cluster 模式下,不要用 hash tag({shop1001})把它们绑到同一个 slot------那等于没打散。要确保后缀参与 CRC16 计算。

方案三:读写分离 + 多副本

让从节点分担读流量。用 READONLY 命令让客户端从从节点读,一个主节点挂 3~5 个从节点,读能力线性提升。

代价:主从复制有延迟,可能读到旧数据;从节点数量多了会加重主节点的复制压力。

方案四:限流 + 降级

对极端热点(比如热搜),业务上要做取舍:

  • 热度超过阈值时,返回缓存快照或 CDN 静态页;
  • 非核心字段(如评论数、点赞数)降级为近似值或不展示;
  • 前端做请求合并 / 防抖,减少无效请求。

方案五:计算分离

如果是"热点 + 重计算"(比如实时榜单聚合),把计算从 Redis 挪出去,用 Flink / 定时任务算好结果直接写入,Redis 只存最终结果,读路径极简。

热 key 与缓存击穿的关系:

热 key 一定会遇到击穿问题(因为热点 key 也有 TTL),所以两者要组合防护:热 key 用逻辑过期 + 本地缓存 + 副本打散,是大型系统的标准配置。

4.6 顺带说清:缓存与数据库的一致性

讲缓存问题绕不开一致性,因为绝大多数"缓存导致的 bug"最后都归到这里。

Cache Aside 模式(最常用):

读:先读缓存,未命中读 DB,回写缓存。 写:先更新 DB,再删除缓存。

为什么是"删除"而不是"更新"?

  1. 更新缓存可能做了无用功(写多读少的场景,缓存刚更新就被覆盖);
  1. 并发更新时,两个线程的更新顺序可能颠倒,导致缓存里是旧值;
  1. 删除是幂等的,失败了可以重试。

为什么是先更新 DB 再删缓存?

反过来的顺序(先删缓存再更新 DB)有一个经典的脏数据窗口:

复制代码
线程 A: 删缓存
线程 B: 读缓存未命中 → 读 DB(旧值)→ 回写缓存(旧值)
线程 A: 更新 DB(新值)
结果:缓存里是旧值,DB 里是新值,长期不一致

而"先更新 DB 再删缓存"的异常窗口极小(要求"读请求恰好发生在 DB 更新前、回写发生在删除后",且 DB 读比写还慢),工程上可以接受。

延迟双删:

复制代码
// 1. 先删一次缓存
redis.delete(key);
// 2. 更新数据库
db.update(entity);
// 3. 延迟 500ms~1s 再删一次,兜底清掉期间被读请求回填的旧值
executor.schedule(() -> redis.delete(key), 800, TimeUnit.MILLISECONDS);

延迟时间要 > 一次读请求的耗时(读 DB + 回写缓存),且线程池必须独立,不能用业务线程池。

更强的方案:订阅 binlog

延迟双删依然是一种"猜测",真正可靠的做法是监听数据库变更,异步删缓存:

MySQL → Canal / Debezium → MQ → 消费者删 Redis 缓存(失败重试 + 记录补偿表)

优点:与业务代码解耦、不依赖延迟时间的估算、失败可重试可补偿。 缺点:引入额外组件,链路变长,有秒级延迟。

一致性等级要按业务选:

|----------|--------------------------------|--------------|
| 等级 | 做法 | 适用 |
| 强一致 | 读写都加分布式锁,或读写全走 DB | 余额、库存扣减等核心交易 |
| 最终一致(秒级) | Cache Aside + 延迟双删 / binlog 订阅 | 商品信息、用户资料 |
| 弱一致(分钟级) | 普通 Cache Aside + TTL 兜底 | 榜单、推荐、统计 |

最后一句大实话 :缓存和 DB 是两个系统,没有分布式事务,就不可能有真正的强一致。所有方案都是在"一致性、可用性、复杂度"之间做权衡。能用 TTL 兜底解决的问题,就不要上复杂的同步机制。

五、面试速记表

|--------------------|----------------------------------------------------------------------------------------------------------|
| 问题 | 30 秒版回答 |
| Redis 为什么快? | 纯内存 + 单线程无锁无切换 + IO 多路复用 + 高效数据结构 + 简单协议。注意 6.0 起网络 IO 是多线程,命令执行仍是单线程。 |
| Redis 是单线程的吗? | 命令执行主流程单线程;6.0 起网络读写多线程,4.0 起有后台线程做异步删除和 fsync。 |
| 五种基础类型? | String(SDS)、Hash(listpack/hashtable)、List(quicklist)、Set(intset/hashtable)、ZSet(listpack/skiplist+dict)。 |
| ZSet 为什么用跳表不用红黑树? | 实现简单、范围查询高效(找到起点顺着链表走)、层级概率生成内存可控。注意"并发无锁"不是理由,Redis 是单线程。 |
| String 的 44 字节是什么? | embstr 与 raw 的分界。jemalloc 64 字节分配单元减去 16 字节对象头和 3 字节 sdshdr8 头及结尾符,剩 44。 |
| 过期 key 是怎么删的? | 惰性删除(访问时判断)+ 定期删除(每 100ms 随机抽 20 个,过期率 > 25% 重复一轮)。所以过期 key 不会立刻消失。 |
| 内存淘汰策略? | 8 种。纯缓存用 allkeys-lfu;有持久数据用 volatile-*;不能丢用 noeviction。LRU/LFU 都是近似实现(采样 + 24bit 时间戳 / 8bit 计数)。 |
| 大 key 有什么危害? | 网络阻塞、删除阻塞、过期阻塞、集群倾斜、持久化放大。治理:拆分、压缩、UNLINK 异步删、开 lazyfree。 |
| 为什么用 Pipeline? | 省 RTT,不是省 CPU。非原子,要分批(500~1000),失败不回滚。 |
| 穿透 / 击穿 / 雪崩区别? | 穿透=查不存在的数据;击穿=单个热点 key 失效;雪崩=大量 key 同时失效或 Redis 挂了。 |
| 穿透怎么解决? | 空值缓存(短 TTL)+ 布隆过滤器 + 参数校验/限流 + DB 唯一索引。 |
| 布隆过滤器原理与缺陷? | 位数组 + 多哈希,说不存在一定准确、说存在可能误判。缺陷:不支持删除、需预热、有误判率。 |
| 击穿怎么解决? | 互斥锁重建(保一致性,牺牲吞吐)或逻辑过期(保可用性,读旧值)。 |
| 雪崩怎么解决? | TTL 加随机、多级缓存、熔断降级限流、Redis 高可用(哨兵/集群)、预热、双 key 兜底。 |
| 热 key 怎么办? | 本地缓存、key 打散(多副本随机读)、读写分离多从节点、限流降级、热 key 探测。 |
| 缓存和 DB 一致性怎么做? | Cache Aside:先更新 DB 再删缓存;加强版用延迟双删或 binlog 订阅异步删。没有分布式事务就没有强一致。 |
| 分布式锁怎么实现? | SET key uuid NX PX 30000 加锁,Lua 脚本比对 token 后删除释放;生产用 Redisson(看门狗续期、可重入)。 |

结语

Redis 的知识点看起来很散:诞生历史、数据类型、性能优化、缓存问题。但把它们串起来其实只有一条主线------Redis 是一个用内存和单线程换取极致简单与极致速度的系统,它的所有优点和所有坑,都来自这个设计选择。

因为单线程,所以命令必须短,于是有了大 key 和 O(N) 命令的问题;因为纯内存,所以容量有限,于是有了淘汰策略和过期机制;因为它是"缓存"而不是"数据库",所以一定会有不一致,于是有了穿透、击穿、雪崩。

理解了这条主线,剩下的都是细节。而细节,可以查文档。


写于 2026 年 10 月。文中命令基于 Redis 7.x / 8.x,部分配置项在低版本中名称不同(如 *-ziplist-* 在 7.0 后改为 *-listpack-*)。

相关推荐
ClouGence1 小时前
还在用命令行管理 Kafka?开源 CloudDM 让操作更简单
数据库·kafka·开源
一嘴一个橘子1 小时前
redis stream
redis
骑着蜗牛撵大象3271 小时前
四种库收进一个SQL编辑器(下):跨库取数、方言对齐与四库巡检的落地拆解
jvm·数据库·sql·编辑器·数据迁移·跨库查询
惊讶的猫1 小时前
mysql-多表连接查询(JOIN)
数据库·sql·mysql
小羊没烦恼!1 小时前
TypedSql:在 C# 类型系统上实现一个 SQL 查询引擎
java·linux·运维·服务器·开发语言·数据库·mysql
码流子2 小时前
高速公路视频AI分析,算力该放哪:边缘、中心还是云边协同?
大数据·数据库·人工智能·物联网·音视频
xiaogai_gai2 小时前
委外出入库单从旺店通同步到 MySQL:基于轻易云的实战方案
数据库·mysql
极光通讯2 小时前
国产存储替代进展:长鑫DDR5与长江存储企业级SSD的适配现状
服务器·数据库·内存
Mortalbreeze2 小时前
MySQL 基础篇(十):用户管理
数据库·mysql