写下这篇 Redis 学习日志的时候,我手头正攒着好几个踩坑现场:一次是 redis-cli 连接超时排查了半天,一次是缓存穿透把数据库打挂、被群里老哥拉去复盘,还有一次是 Docker 里起的 Redis 容器数据全没了的“惨案”。所以这篇日志打算把从零学 Redis 到日常使用中最值得记录的东西,按一条真实的学习路径沉淀下来。不讲教科书式的堆概念,而是尽力还原我当时是怎么理解、怎么上手、又怎么吃了教训的——包括数据类型、持久化、高可用、缓存治理、分布式锁这五大主线,以及面试八股和排错经验。适合正在学 Redis 的开发者,也适合想看坑位总结的老手。
1. 学习路线与整体理解
1.1 为什么必须学 Redis,它到底解决了什么问题
我在学 Redis 之前先记住了一个词:内存中的数据结构服务器。这句官方定位信息量其实很大——核心在“内存”,所以它快;核心在“数据结构”,所以它比单纯 KV 都比别的多了一层灵活性;核心在“服务器”,所以它被设计成独立服务而不是嵌入在应用里的一个库。
Redis 解决的问题,通俗点讲是这三类:一是把热点数据从数据库搬到内存,把高并发读扛住,给后端数据库留出喘息空间;二是借助 Redis 的过期策略、分布式锁、发布订阅、队列等能力,完成跨服务的状态协调;三是通过持久化和主从复制,在极端场景下保数据不丢、服务可用。我记得第一次用它就是在秒杀场景里做了个简单的缓存预热,把商品库存快照放进 Redis,直接把接口耗时从 120ms 拉到 3ms 以内,那一刻对“内存快”就有了体感。
1.2 我给自己定的学习路线
学 Redis 很容易掉进“只会 set/get”的舒适区。为了不浅尝辄止,我当时给自己定了六步路线,每一步对应一套实际问题:
- 先装起来:Windows 或 Linux 本机可跑,理解 redis-server 与 redis-cli 的关系。
- 掌握数据类型:不只是 String,而是把 Hash、List、Set、ZSet 在真实场景里各用一遍。
- 搞懂持久化与淘汰策略:弄明白 RDB、AOF 的取舍,以及 Redis 如何“在内存放不下时”保护自己。
- 搭建高可用:主从复制、哨兵、集群模式都用 Docker 实际起一遍。
- 深入缓存治理:穿透、击穿、雪崩、双写一致性,这四件事必须形成自己的解决方案。
- 用 Redis 搞定分布式锁:从 SETNX 手写锁,到 Redisson 看门狗机制,层层递进。
这条路线走完之后,我面试时被问 Redis 就直接闭眼能讲半小时。所以这篇日志也按这个顺序展开,每个阶段我都会补上自己踩坑的第一手经验。
2. 安装篇:Windows、macOS、Docker 三种实操记录
2.1 Windows 下安装 Redis 的一个干净办法
Windows 官方不提供 Redis 二进制,这是个老坑。我一开始搜 redis下载,跑进各种“Windows 安装包”站点,下载下来的却是一堆破解软件弹窗,后来学乖了,只认两个靠谱渠道:一是 Microsoft 的 Open Tech 项目维护的 Win64 分支,二是 Redis 官方推荐的 WSL 方案。当时我用的版本是 5.0.14.1,下载 zip 解压,目录下 redis-server.exe 和 redis-cli.exe 都在,双击 redis-server.exe 默认端口 6379 就跑起来了,redis-cli.exe 里 ping 一下返回 PONG,就算通了。
需要特别提一句的是 Windows 下设置 Redis 密码。直接在 redis.windows.conf 文件里搜索requirepass,把这行注释打开,改成requirepass 你的密码,然后用redis-server.exe redis.windows.conf启动。这时候 redis-cli 里直接 ping 会报 NOAUTH,你得先执行AUTH 你的密码才能继续操作。这个配置文件路径很容易搞混,我最初以为要修改根目录的 redis.conf,结果文件里根本没有 requirepass 字段,折腾了十分钟。
2.2 macOS 安装 Redis 两种方式实测
mac 上最省事的方式是用 Homebrew:brew install redis。安装后默认会执行redis-server,开一个终端保持前台运行。每次开机想用 Redis 的话可以brew services start redis,让它常驻后台。我实际更推荐在开发机上让它跑在后台,因为项目里 Redis 是常态依赖,不需要每次都手动启动。
另一种方式是 Docker:docker run -d --name redis-test -p 6379:6379 redis。macOS 上 Docker 跑 Redis 需要注意一个点:如果你是 Apple Silicon 芯片,镜像平台不要指定错了,直接拉官方 redis 镜像是没问题的,我之前手动加--platform linux/amd64反而会额外多一层模拟层,性能亏一点。还有一个小技巧:docker exec -it redis-test redis-cli这条命令可以在宿主机上直接进入容器里的 redis-cli,省去先 docker exec 开 shell 再敲 redis-cli 的两步操作。
2.3 Docker 安装 Redis 主从时我遇到的 500 错误
我照着教程准备用 Docker 搭 Redis 主从时,第一条命令docker search redis就回报了request returned 500 Internal Server Error for API route ... /images/search?term=redis。查日志才知道是 Docker Desktop 和 Docker Hub 之间的连接出了问题,和命令本身无关。解决办法是:检查 Docker Desktop 是否退出重启过、确认当前网络到 Docker Hub 通畅、然后重启 Docker Desktop 再试。如果重启还不行,就在 /etc/docker/daemon.json 配置镜像加速地址,改完重启 Docker Daemon。
主从配置我最终没有依赖docker search,而是直接docker pull redis:7.2-alpine,然后起两个容器,第二个容器启动时加入参数--replicaof 主容器名 6379,或者挂载一个 redis.conf,里面写上replicaof redis-master 6379。启动后进从库执行info replication,看到role:slave和master_link_status:up就代表主从已经通了。注意容器间要通,必须让它们在同一自定义 bridge 网络里:docker network create redis-net,两个容器都--network redis-net才行。我一开始直接使用--link老参数,发现新版 Docker 对redis-cli -h 容器名已经能通,但 Sentinel 探测结构还是建议用自定义网络,实测省心得多。
3. 数据类型:五种基础类型背后的场景逻辑
3.1 String 不是只能存字符串
String 是 Redis 最常用的类型,底层是 SDS(简单动态字符串),支持存二进制安全的内容。实际开发中它不只是存取普通文本,还能存 JSON 序列化后的对象、计数器等等。我最早的一个误区是以为 String 只能英文数字,后来才知道它存中文字符也毫无问题,Redis 存的是字节序列。
String 几个实测实用的命令:
SET key value [EX 秒] [NX]:原子设置,NX 表示不存在才设置,这是后面做分布式锁的基础。SETEX key 秒 value:设置同时指定过期时间。INCR / DECR:原子自增/自减,适合做点击量、库存扣减。GETSET key value:取旧值同时设新值。
我最常用到的场景是缓存 token,登录成功生成一个 UUID,SET token 用户ID EX 7200,每次请求过来拿到用户 ID,不用频繁查库。这里有个隐蔽的坑:SET的EX参数要求逗号前面是数字,但有些人会写成SET token "abc" EX 7200 NX,注意EX和NX之间的顺序无所谓,Redis 命令解析是按参数名识别的,但如果你写SET token abc EX NX 7200,那就报参数数量不对了。刚开始学的时候这里容易栽跟头。
3.2 Hash:最能命中“对象”语义的类型
Hash 是 Redis 里最实用的一个类型,底层是哈希表加 ziplist(元素少、值短时自动使用压缩列表)。它存储的是一组 field-value 对,天然适合表示一个对象。比如用户信息user:1001,里面存name、age、gender,本质上是把一个 Java 对象铺展成多个字段,避免了整个对象 JSON 序列化和反序列化的开销。
实际开发中,如果你只需要修改对象的某一个字段,用 String 全量覆盖会很浪费,而 Hash 的HINCRBY可以只对某个字段做原子递增,比如在一个对象的age字段上做HINCRBY user:1001 age 1。我用它做过购物车:hset cart:1001 sku_001 2,要加一件就让HINCRBY cart:1001 sku_001 1。底层实现细节上,当 Hash 对象满足hash-max-ziplist-entries和hash-max-ziplist-value配置时,内部使用 ziplist 节省内存,超过阈值后转成 hashtable 保证访问效率,这一点面试常考,实际调优也有意义。
3.3 List 的队列属性和阻塞读取
List 在 Redis 内部是一个双向链表或者压缩双端链表(quicklist)。Java 里的 LinkedList 朋友很熟悉,但它多了专门用于队列消费的命令:LPUSH往左边塞,RPOP从右边取,这就是一个标准的 FIFO 队列。
Redis 的 List 还提供了阻塞版本BRPOP和BLPOP,这是我在项目里用它做简单任务队列的最大亮点。消费端使用BRPOP queue 0时,如果队列为空,线程是被阻塞的,不会疯狂轮询给 Redis 打压力。这里有个细节:BRPOP queue 0的 0 表示无限等待,如果是 5 就表示最多阻塞 5 秒,超时后返回 nil,实际业务里可以根据消费频率来设置,太短会导致很弱的空转,太长又可能堆积处理延迟,我偏好在低峰期设置 10 秒,高峰期设置 3 秒。
3.4 Set 与 ZSet:去重、标签、排行榜
Set 是字符串的无序集合,适合做去重。比如点赞记录用SADD post:1001:likes user_1,再SISMEMBER判断这个用户是否已经点过赞。ZSet 在 Set 基础上给每个元素挂了 score,底层是跳跃表加哈希表,适合做排行榜:ZADD rank 100 user_1,用户得分变化就ZINCRBY rank 10 user_1。要拿前 10 名用ZREVRANGE rank 0 9 WITHSCORES。
这里要特别说下 ZSet 的排序原则:当多个元素 score 相同时,按字典序排序,这个对排行榜的稳定性很有影响。有一次我写周榜,发现有两个用户积分相同时,排名顺序居然是按用户名 ASCII 码排的,产品那边差点当成 bug,后来在需求里明确加上“同分按时间先后”,于是我把 score 设计成了积分 + 时间戳的小数组合,比如1000.00000123456,这样同分时先到的就能排前面。这个技巧常见于积分榜的需求设计,值得记下来。
3.5 其他实用的新类型:Bitmap 与 HyperLogLog
除了五大基础类型,后面认识的 Bitmap 和 HyperLogLog 也很实用。Bitmap 不是新数据结构,本质是 String 类型的按位操作,但适合做打卡、在线状态这类只有 0/1 的场景。用户连续签到可以用SETBIT sign:2024:10 1 1,要查某天是否签到就是GETBIT,做统计就是BITCOUNT。一亿用户一天的在线状态,Bitmap 只需要约 12MB 内存,这个账一算就能打动业务方。
HyperLogLog 做 UV 统计很微妙,它用固定大小的内存(理论最多 12KB)去估算去重数量,误差在 0.81% 左右。我看过一个案例,用PFADD记录当天访问者 ID,PFCOUNT直接拿到 UV 估算值,既不用 Set 那样在大量元素下占用高内存,准确度也足以支撑业务决策。但要注意:它只能做基数统计,不能查具体有哪些元素;如果是精度要求极高的统计,比如对账,就不适合用它,还是老老实实用SADD的那套。
4. 持久化机制:RDB 与 AOF 的取舍选择
4.1 RDB 快照与 COW 机制的本质理解
Redis 默认持久化方式是 RDB,它会 fork 一个子进程,把当前内存中的全量数据生成一个二进制快照 dump.rdb。子进程持久化时,主进程还能继续处理命令,这是靠的是 fork 时的写时复制(Copy On Write)机制——fork 的瞬间父子进程共享同一份内存页,只有数据被修改时才会复制被改的页,所以 fork 不阻塞服务。
这里有三个配置项要重点看:
save 900 1:900 秒内至少 1 次写命令,就触发一次快照。save 300 10:300 秒内至少 10 次写命令触发。stop-writes-on-bgsave-error yes:当后台保存失败时,Redis 默认会拒绝写命令,这在实际生产中经常把人搞懵。有次我在磁盘满了情况下,业务日志里突然报大量只读错误,排查半天发现是磁盘问题被 RDB 保存机制夹杂进来。
RDB 文件最大的缺点是,两次快照之间的数据会丢失。如果 5 分钟做一次快照,服务挂掉就可能丢 5 分钟的数据,这在支付类场景很难接受,所以单独靠 RDB 不够,还要有 AOF。
4.2 AOF 追加日志与 fsync 策略
AOF(Append Only File)把每条写命令追加到文件末尾,恢复时重新执行一遍写命令,保证数据能恢复到某时刻的完整状态。但它也有性能开销,所以设计了appendfsync三个档位:
always:每次写命令都调用 fsync 同步到磁盘,最安全但慢。everysec:每秒 fsync 一次,性能与安全的折中,这是默认值。no:由操作系统决定磁盘刷新时机,数据丢失风险更大。
我对这三档真实体感是:always在写频繁的业务下会让 Redis 吞吐从十万级掉到万级甚至更低,场景非常受限,我见到的实际生产环境九成都是everysec,同时配合appendonly yes。AOF 文件会随写入不断增大,所以有重写机制auto-aof-rewrite-percentage 100和auto-aof-rewrite-min-size 64mb,触发后压缩成新的最小化文件,等价于合并历史写命令。
4.3 混合持久化是目前最常用的配置
Redis 4.0 之后支持混合持久化:aof-use-rdb-preamble yes。开启后 AOF 文件前面先放一个 RDB 二进制块,之后才是增量命令日志。恢复时先加载 RDB 快,再回放增量,加载速度比纯 AOF 快很多,数据安全性也接近 AOF 的 everysec 级别。我现在的生产配置基本固定成:appendonly yes + appendfsync everysec + aof-use-rdb-preamble yes,这套组合对大多数业务足够稳。如果你对数据丢失零容忍,再考虑打开 always,但一定要做性能压测,别想当然直接换。
4.4 踩过的一次“redis 数据全没了”的坑
我印象最深的一次线上事故就是没配好持久化。当时用 Docker 起 Redis,挂在容器里的数据目录是匿名的,容器一删,dump.rdb 也没了,业务缓存全部打回源,数据库瞬间压力升高。后来意识到这是持久化和数据卷两个概念混在一起造成的问题。
正确的做法是,启动容器时挂载-v /宿主机路径:/data,并确保 redis.conf 里dir /data。这样的话,即使容器没有了,数据文件还在宿主机磁盘上。重启容器后再redis-cli --bigkeys核查数据量,发现 key 数对得上才算放心。想快速验证数据是否真的持久化成功,可以写个测试 key,SAVE,再重启容器,GET一下确认。不要嫌麻烦,这是最基础也最容易被忽略的容灾细节。
5. 缓存治理:穿透、击穿、雪崩、双写一致性
5.1 缓存穿透:查一个不存在的数据
缓存穿透是指请求查询一个数据库里也不存在的数据,缓存没有任何记录,于是每次请求都会打到数据库,高并发下直接把 DB 打崩。比如商品 ID 是自增的,攻击者专门请求一个不存在的负数 ID,缓存查不到,数据库每次也查不到,流量全部穿透。
处理方案有四种主流做法。一是缓存空值:对不存在的数据也缓存一个空值,并设置较短的过期时间,比如 60 秒,防止恶意请求持续打库。二是布隆过滤器:请求先走过滤器判断 key 是否存在,不存在直接返回,因为存储层面用位数组,千万级别的 key 也就占用几十 MB,基本不会成为性能瓶颈。三是参数校验,在入口拦截明显非法的 ID。四是数据库层的限流降级,作为最后的兜底。
我当时在项目里把方案一和方案三结合:入口先校验 ID 合法性,内存缓存再做一层短暂空值,最后数据库前面加轻量级限流。空值缓存有个坑,就是如果一个 key 本来不存在,但后续真的插入数据了,空值缓存还没过期,可能会导致数据短时间不一致,所以空值过期时间不能设太长,我一般采用 30 到 60 秒。
5.2 缓存击穿:热点 key 过期瞬间被打爆
缓存击穿和穿透很像,但文件完全不同:击穿是某个热点的 key 刚好失效,比如weibo:hot这种,一瞬间上万人同时去数据库查。解决思路是在“重建缓存”这个步骤上做控制,保证同一时刻只有一个线程去查数据库,其他人等待或直接用旧值。
我用过三种具体办法:
- 互斥锁(Redis SETNX):发现缓存没值,先尝试设置锁,拿到锁的线程查库回填缓存;没拿到锁的线程短暂 sleep 后重查缓存。缺点是加了等待,对访问量稍大的接口延迟会上升。
- 逻辑过期时间:缓存里额外存一个过期逻辑时间,比如
hotkey:{value,expireAt},访问时如果发现逻辑过期,只让一个线程去刷新,其他线程仍返回旧值。这样能保证永远有数据返回,但会造成短暂的数据不一致。 - 永久热点 key 不过期:后台任务定时主动刷新。最简单的做法,但不适合需要实时性的数据。
生产上我偏向用逻辑过期时间,因为对调用方感知最小。一个要注意的坑:互斥锁方案里,如果查库线程异常崩溃,锁没有被释放,可能会造成死锁。所以锁必须设置过期时间,通常是 3 到 5 秒,还要加上唯一标识判断,防止误删别人持有的锁,这已经是分布式锁的雏形了,和后面聊的锁问题天然连在一起。
5.3 缓存雪崩:大面积失效引发数据库洪峰
雪崩和击穿的区别在于规模。击穿是单个热点 key 失效,雪崩是大批 key 在同一时间失效,或者 Redis 整个实例宕机,后台流量直接倾泻到数据库。解决思路主要有三个方向:
第一,过期时间打散。如果所有 key 都是固定 1 小时过期,那一小时内某一秒就会集中大量过期,非常容易触发雪崩。我为每条缓存数据加一个随机偏移,比如 TTL 设置为base + random(0,300)秒,把过期时间均匀打散。第二,多级缓存。在 Redis 前面加一层本地 Caffeine,即使 Redis 挂了,本地缓存还能扛住一部分,给数据库争取恢复时间。第三,Redis 高可用。一个 Redis 挂了还有其他主从或集群节点,不至于全是单点的不可用。我第一次搭哨兵也是为了防雪崩,虽然实际中 Redis 宕机概率低,但一挂就是所有 key 同时失效,确实是灾难级别的事故。
5.4 双写一致性:数据库与缓存的最终一致性
缓存和数据库双写时,最容易出现不一致的场景是:先更新数据库,再删除缓存,这中间有一个时间窗,其他请求可能读到旧缓存。反过来,如果先删缓存再更新库,更新库期间会有请求把旧值回填到缓存,同样会造成不一致。
我比较推荐的策略是 Cache Aside Pattern:读的时候先读缓存,没有就读库然后写缓存;写的时候先更新库,再删缓存。注意是“删缓存”而不是“更新缓存”,因为更新缓存可能在并发下有脏数据覆盖问题,删掉后让下次读请求自然回填,反而更安全。如果想进一步降低数据不一致的概率,可以用延迟双删:更新库后删除缓存,等几百毫秒再删除一次,把并发期间可能回填的旧缓存再清一遍。这个几百毫秒的延迟时间要根据实际业务对一致性的要求调节,我一般用 500ms。
6. 高可用实践:主从、哨兵、Cluster 集群
6.1 主从复制的作用与异步机制
主从复制解决了读写分离和基础容灾问题。主库负责写,从库负责读,从库同步是异步的,默认情况下主库只要成功执行写命令就返回,不等待从库确认,所以从库会有一定延迟。replica-read-only yes默认打开,从库不做写操作。
异步复制带来的常见问题是主从数据延迟:主库刚写的 key,还没复制到从库,读请求打到从库就查不到。这种场景下,如果业务对强一致有要求,就必须读主库,不能用从库。我在做秒杀库存查询时就犯过这个错,后来把读库存的入口直接指向主库,才消除了瞬时超卖误报的问题。
复制过程中如果主从断连,从库会尝试增量同步,增量复制不成功则退化为全量同步,全量同步期间会生成 RDB 快照发给从库,数据量大的时候对主库会有明显的 I/O 压力。所以,初次建立主从还是放在业务低峰期为好,否则最后被迫实践一把“复制风暴”。
6.2 哨兵模式:自动故障转移
哨兵是一个独立进程,专门用来监控 Redis 主库状态。当主库挂了,哨兵会在从库中选出一个提升为新主库,然后把其他从库的复制目标切到新主库。客户端也需要感知新的主库地址,所以哨兵还承担了“服务发现”的功能。
哨兵的“主观下线”是指单个哨兵发现主库心跳超时,但它不会立刻切换;要等超过一半的哨兵都认为主库下线,才判定为“客观下线”,然后进入故障转移流程。这就是为什么生产环境至少要部署三个哨兵实例,否则两个哨兵里一个宕机,剩下一个无法形成多数派,故障转移就永远不触发。
我在搭建哨兵时踩过一个浅坑:哨兵配置文件里sentinel monitor mymaster 127.0.0.1 6379 2,这个参数含义是“2 个哨兵判定下线就客观下线”,如果你只有 1 个哨兵,就算配了2也永远不触发自动切换。所以规则是:哨兵数量必须大于 quorum。这个在纸上很好懂,但真搭环境时特别容易忽略。
6.3 Redis Cluster:去中心化与槽位分配
当单机内存和并发都到了瓶颈,就需要 Redis Cluster。Cluster 模式把数据分布到多个主节点上,默认有 16384 个哈希槽,每个 key 通过CRC16(key) % 16384计算落到的槽位,每个主节点负责一部分槽位。
Cluster 最醒目的特点是去中心化:每个节点都保存了完整的槽位映射关系,客户端访问任意节点都能被重定向到正确的节点。重定向响应有两种:MOVED 表示目标节点永久换了位置,客户端需要更新本地缓存;ASK 表示数据正在迁移中,客户端只做这一次的临时转发。
我在 K8s 里部署 Redis Cluster 的经验是,别把每个 Pod 搞成有状态单独部署,最好用 StatefulSet,这样 Pod 的 DNS 名称和编号是稳定的,Cluster 各节点之间可以通过服务名通信。pod 重启后 IP 可能会变,如果没有稳定的域名,整个 Cluster 拓扑会乱套,那一次我花了整整一天反复cluster meet,教训极深。
6.4 可视化工具与服务连接:RedisInsight 和 Another Redis Desktop Manager
这个算不上核心原理,但是开发日常的实际痛点。命令行工具redis-cli用顺手后,可视化工具仍然是排查线上数据分布、查看 key 过期情况、内存分析的有效工具。
我个人最常用的是 RedisInsight,官方维护,跨平台,功能含 GUI 的 Redis 浏览器、慢日志分析、内存分析,最实用的是内置的 CLI 面板。Another Redis Desktop Manager 免费且支持直连和 SSH 隧道方式,在 Windows 下体验流畅,部署到公司内网环境也常见。无论用哪个工具,连接之前先明确认证信息和安全配置:默认没设 requirepass 时可以直接连,设了密码要正确填写;生产环境最好确认一下连接走的是 6379 端口还是内部 SGW 端口,有时候网络层做了隔离。
工具连不上时还有一个最常见的排查点:目标 Redis 是否绑定了bind 127.0.0.1,如果只绑了本地,远程工具是无论如何连不上的。需要改成bind 0.0.0.0或指定网卡,同时要注意网络安全,不要裸奔到公网。这个问题在主机或者虚拟机里尤其常见。
6.5 单机部署与配置调优速记
单机部署不等于把安装包跑起来就完事。我整理了一份适合本机/小团队初始化的配置清单:
daemonize yes:后台运行,避免被终端挂掉。requirepass:设置访问密码。maxmemory:设置最大内存,比如 2gb。不加这个配置时,Redis 默认在 64 位系统里是无限制的,等内存扛不住就可能触发操作系统 OOM,非常被动。maxmemory-policy:内存上限到达后怎么淘汰,默认noeviction是不淘汰直接报错,所以必须显式配置。
maxmemory-policy里我常用的几种:allkeys-lru适合当作纯缓存,把所有 key 一起按最近最少使用淘汰;allkeys-random适合访问分布均匀的情况;volatile-lru只淘汰带 TTL 的 key,适合保留那些不想被淘汰的数据。这个选择本质上决定 Redis 的角色,它是“缓存”还是“数据库”。如果你把它当数据库用,那 maxmemory-policy 一般就不应该是淘汰策略,而应该用 Redis 自身的持久化 + 外部存储兜底。
7. 分布式锁:从 SETNX 到 Redisson 看门狗
7.1 为什么单机锁不够用,SETNX 怎么演变
在单机单进程里可以用 Java 的 synchronized 或者 JUC 的 Lock,但在多服务实例部署或跨进程共享资源时就不行了。分布式锁要求多个进程之间能互斥访问某个资源,同一时刻只允许一个客户端持有锁。
最早大家用SETNX lock unique_value,但只 SETNX 太简陋。假设持有锁的进程崩溃了,锁永远不释放,其他进程就得死等。于是有人给锁加过期时间SETEX lock 30 unique_value,却又带来另一个问题:如果线程 A 执行任务超过了 30 秒,锁自动过期,线程 B 拿到锁,此时 A 执行完又去释放锁,会把 B 刚刚持有的锁误删掉。
正确的解除方式是用 Redis 官方推荐的原子命令:SET lock unique_value NX EX 30,释放锁时先比对 value 是本人持有再 DEL,中间要保证原子性,所以释放操作要用 Lua 脚本实现:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end这个方案我踩过坑:Java 生成了唯一标识后,我没用 Lua,而是先 GET 再 DEL,中间间隔了几毫秒,并发压测时真出现了误删锁,后来才明白 GET 和 DEL 两条命令放在一起必须保证原子性,否则永远有隐患。
7.2 Redisson 的看门狗机制与公平锁
手写锁要解决锁续期、重入、阻塞等待这些复杂问题,非常繁琐。生产环境我更推荐用 Redisson 库,它的核心卖点是看门狗机制:客户端拿到锁后,后台守护线程会不断给锁续期,比如默认锁超时时间为 30 秒,每 10 秒续订一次,保证业务任务还没执行完,锁不会因为过期被其他线程趁虚而入。
这就是为什么建议在真实业务中不要用“固定 30 秒过期”的手写方案,而是用lock = redisson.getLock("order:pay"); lock.lock();,如果业务执行时间极长,看门狗会一直续期,直到业务线程 finally 里 unlock。如果 Redisson 客户端进程宕机,守护线程也就停了,锁会自然超过 30 秒后过期释放。
Redisson 还提供tryLock(waitTime, leaseTime, TimeUnit)支持拿锁等待超时,而不是傻等;以及getFairLock公平锁,让请求按队列顺序拿锁,适合低并发的强公平场景。不过公平锁性能不如普通锁,我个人用到的情况不多。
7.3 主从复制场景下锁的安全性与 RedLock
如果锁建立在主从架构上,有一个极端风险:客户端 A 在主库上拿到锁,主库还没来得及同步到从库就挂了,哨兵把从库提升为主库,此时客户端 B 再来拿锁,新主库上其实没有锁记录,于是 A 和 B 同时有锁,分布式锁失效。
针对这一问题,Redis 官方提出了 RedLock 算法思路:在 N 个彼此独立的 Redis 节点上依次加锁,大部分节点(N/2+1)加锁成功,客户端才认为自己成功获得锁。但 RedLock 本身在业界也有争议,因为它依赖绝对时钟和网络,复杂度高。我个人的建议是:对绝大多数业务来说,主从切换瞬间双锁存在的概率极低,业务幂等校验(比如数据库唯一索引)比再去引入 RedLock 更实在。分布式锁不是玄学,它只是性能兜底,最终的数据正确性还要靠业务层的唯一约束和幂等设计。
7.4 分布式锁常见面试案例分析
面试中这个点经常被延伸出三个问题,我都亲自回答过:
- 锁过期时间该怎么设?理想是业务预估最大执行时间的 3 倍以上,但更稳妥是用看门狗自动续期,不要依赖固定时间。
- 业务执行时间超过锁的过期时间怎么办?绝不要为了让锁等任务而把过期时间设得无限大,正确做法是续期或逻辑分段。
- 锁释放时误删其他线程的锁怎么避免?保存唯一标识,释放前比对,比对和删除要保证原子性。
以上三点,我在手写锁方案里全踩过一遍,后来老老实实切换到了 Redisson,线上再也没出过锁相关的事故。
8. 命令行超时、慢日志与连接工具排错记录
8.1 错误提示 RedisCommandTimeoutException 排查全过程
工作里最常见的报错是Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException。刚看到这个异常,很多人会第一时间想是不是 Redis 挂了,但 redis-cli 单独执行命令都很快,说明服务本身是健康的,问题更大概率出在应用连接 Redis 的链路或客户端的超时配置上。
我从这个异常中学到了系统性的排查顺序:
- 看 Redis 服务端状态:内存使用率、连接的客户端数量、慢日志。如果内存快满,可能触发淘汰风暴,导致命令执行变慢。
- 看网络层:应用服务器到 Redis 的 RTT 是否正常,是不是跨机房、跨 VPC。我遇到过一款内部老项目把 Redis 放在另一地域,即使 ping 延迟只有 5ms,因为 TCP 连接数暴增,整体吞吐也起不来。
- 看客户端配置:Lettuce 默认超时时间比较短,要确认客户端的 commandTimeout 是否与业务模型的响应时长匹配。如果缓存查询接口要求 QPS 高,超时时间设置过短也会频繁误判。
- 看 Redis 连接池和响应算子:如果代码里某一循环并发获取连接,连接池被打满,新命令会等连接释放,直到超过默认超时时间。
最后那次事故定位到的是一个后台报表查询,它循环拿 key 去批量 MGET,结果 key 数量过大,底层 Redis 单线程执行 MGET 时其他命令全部排队,表现就是 Lettuce 客户端大面积命令超时。解决办法是把大 MGET 拆成小批次,同时给 Redis 加监控,看慢查询曲线。
8.2 慢日志定位性能奇点
Redis 单线程模型意味着一个慢命令就能拖慢整个实例上所有的 key,这也是为什么排查超时的时候要优先看慢查询。SLOWLOG GET 20可以取最近 20 条慢命令,SLOWLOG RESET清零历史记录。
我在线上遇到过用KEYS pattern*在几十万 key 的库里去匹配前缀的代码,直接把 Redis 卡了近 10 秒。这个命令会遍历全部 key,千万不要在生产环境使用。解决办法是SCAN cursor MATCH pattern,通过游标迭代,每次只返回一小批,不阻塞服务。同类型的坑包括:大 key(单个 string 超过 100KB)在 GET/DEL 时会产生明显的阻塞,以及SMEMBERS在超大的 Set 上一次全量取出。以上都是“命令设计”层面的问题,和 Redis 本身没有关系。
8.3 Redis Desktop Manager 连接慢与连不上,优先查四件事
普通开发者在本地排查 Redis 连接问题时,最爱用可视化客户端,一旦连不上会非常沮丧。我建议按顺序排查四个点:
- bind 配置:服务端没监听目标网络接口。
- requirepass 配置:客户端密码未配置或错误。
- 防火墙/安全组:端口 6379 被拦截。本机虽然没防火墙,但云服务器默认安全组规则很可能没放行。
- Redis 服务是不是真的在运行:Windows 上最常见的是
redis-server进程被关,Linux 上可能是没配 daemonize 导致一关终端就退出。
8.4 Docker 容器内连接 Redis 的小技巧
平时用 Docker 体验 Redis,不需要总去查容器 IP。在同一宿主机上,用docker exec -it redis 容器名 redis-cli直接进入容器内命令行,也可以从本机用-p 6379:6379映射后通过宿主 127.0.0.1 去连。如果想在另一个容器内访问 Redis,就用上面提到的--network自定义桥接网络,并直接使用容器名作为 hostname。这几个方法我在多个环境里使用的频率非常高,建议记下来。
9. 面试八股清单与高频考点自查
9.1 为什么 Redis 是单线程却这么快
很多人会答“因为内存操作快”,这只是一个面。真正要完整说清,需要覆盖这几点:
- Redis 命令执行是单线程,避免了多线程上下文切换和锁竞争,这是设计上的取舍。
- 底层基于 I/O 多路复用,支持大量客户端连接,有事件驱动机制来处理网络读写,不会因为阻塞等待拖慢服务。
- 内存中存储数据,天然省去磁盘 I/O。
- 数据结构经过专门设计,比如 SDS、跳表、压缩列表,根据数据规模自动选择更高效的内存布局,减少访问开销。
但要补充一句:Redis 6.0 以后引入了多线程 I/O,但那不是用来执行命令的,而是把网络数据读写从主线程分离,命令执行仍然单线程。这点在面试里及时补充会让回答更完整。
9.2 Redis 过期删除策略与内存淘汰策略
过期键的删除不是定时的,而是惰性删除 + 定期删除的组合。惰性删除指当 key 被访问时才发现过期再删;定期删除指 Redis 每隔一段很短的时间抽查一批带过期时间的 key,删掉其中已过期的部分。这套组合的目的是平衡 CPU 占用和内存占用。
如果过期 key 没来得及删掉,且内存到达 maxmemory,就需要由淘汰策略来决定怎么腾空间。面试里被问到淘汰策略时,不能只背名字,至少要结合业务说明理由。比如一个“用户最近浏览记录”场景,适合allkeys-lru;一个“排行榜且不允许淘汰”的场景,就不要设置 maxmemory-policy 的淘汰,而是配合持久化和外部扩容来做。
9.3 热词里的“Jedis、Lettuce、Redisson 有什么区别”
这个问题在 Redis 客户端选型时经常被问到。Jedis 是老牌客户端,直连 Redis,线程不安全,需要使用连接池管理;Lettuce 默认基于 Netty,连接可以共享,支持同步、异步和响应式 API,Spring Boot 2 后的默认客户端;Redisson 是分布式工具集,封装了锁、队列、分布式对象等高级功能,但它的底层更多面向加锁和分布式协作场景,而不是做最基础的数据读写传输。
我自己的经验是:普通 CRUD 缓存场景用 Lettuce 就够,数据读写简单稳定;分布式锁、多级缓存需要更丰富的分布式数据结构时再引入 Redisson;Jedis 在很多遗留项目里还在用,它的 API 风格更贴近原生命令,学习 Redis 命令时也可以当参考。
9.4 Redis 事务与 MULTI 的局限性
Redis 事务和关系型数据库事务完全不同。MULTI开启,之后命令进入队列,EXEC一次性执行。它的重点是不支持回滚:执行过程中如果某条命令因为语法错误或类型错误失败,其他命令照常执行。这是设计上的选择,因为 Redis 认为命令错误应该在开发阶段就发现,不需要运行时回滚。
相关进阶问题时还会问到 WATCH 命令:它带乐观锁语义,在WATCH key之后,EXEC 前,如果 key 被其他客户端修改,事务会被打断,返回 nil。过去做秒杀扣减库存就有人用 WATCH + 循环重试来保证一致性,但现在性能和代码复杂度上都不如直接用 Lua 脚本或 Redis 原子命令来得干净。
9.5 Redis 做中间件与消息队列的边界
热词里还有“redis做中间件”和“redis做消息队列”,这是我实战中比较有感受的一块。Redis 的 List 和发布订阅确实可以模拟简单任务队列,但和真正消息中间件(如 Kafka、RabbitMQ)的边界很清晰:
- Redis 的 PUB/SUB 消息即发即弃,订阅者离线后消息就丢了,没有持久化回放机制。
- List 的 BRPOPLPUSH 可以充当可靠队列,把消息从一个 list 转到一个备份 list,消费者处理完再删除,但这要自己保证消息处理的事务边界,实现成本高。
- Redis Stream 5.0 之后提供了更完整的消息模型,支持消费者组、pending 列表、ACK,很多轻量场景可以替代 Kafka。我做过一个订单事件流转的小项目,用 Stream + XREADGROUP 消费消息,能实现小型业务的异步解耦。
如果消息量不大、业务延迟容忍度还行,Redis Stream 完全能胜任,省去维护独立消息队列集群的复杂度。但这不意味着它要能与 Kafka 有上百万吞吐的场景竞争,选型时心里要有一杆秤。
10. 日常常用命令清单与速查表
每次切换环境或者隔一段时间不碰 Redis,总会忘掉某个命令的细节。我把高频命令整理成了一张速查表,贴在代码仓库 README 里很管用:
10.1 基础命令
redis-server:启动服务端。带配置文件启动:redis-server /path/redis.conf。redis-cli -h <host> -p <port> -a <password>:带密码连接。redis-cli --raw:输出原始字符串,不再带包裹引号,看中文时体验好。redis-cli --bigkeys:扫描大 key,特别适合初查缓存热点与 OOM。redis-cli --scan --pattern "user:*":安全遍历 key,别再用 KEYS。
10.2 数据类型命令快速分类
- 通用:
EXISTS、TTL、EXPIRE key 秒、PERSIST key、TYPE key。 - String:
GET、SET、GETSET、MSET、MGET、INCR、DECR、STRLEN。 - Hash:
HSET、HGET、HGETALL、HINCRBY、HDEL、HEXISTS。 - List:
LPUSH、RPUSH、LPOP、RPOP、LRANGE、LLEN、BLPOP、BRPOP。 - Set:
SADD、SREM、SMEMBERS(注意大 Set 会阻塞)、SISMEMBER、SINTER、SUNION。 - ZSet:
ZADD、ZRANGE、ZREVRANGE、ZSCORE、ZINCRBY、ZREM、ZRANK。 - Stream:
XADD、XLEN、XREAD、XGROUP、XREADGROUP、XACK。
10.3 性能诊断命令
INFO:内存、连接、持久化、主从复制的全量状态,排查问题第一步。INFO commandstats:命令调用次数和耗时统计,能看出哪个命令拖慢了服务。SLOWLOG GET:慢查询日志。MONITOR:实时打印所有命令,调试阶段使用,绝不能在高流量长时间开着,因为会拖垮 Redis 性能。CONFIG GET parameter:在线查看配置项,CONFIG SET parameter value可以临时改配置,但不持久化。
我在每次面试或实际调优前会过一遍这个速查表,基本都是条件反射式的记忆。用多了之后会发现,命令本身只是接入 Redis 世界的最小触点,真正的知识密度在内存模型、持久化、复制协议、内存淘汰和系统设计这些更深的地方。
11. 个人体感与后续打算
如果不是亲手搭过一遍主从、见到过缓存穿透把数据库打崩的现场、被 Redis 超时异常折腾到凌晨,我对 Redis 的理解很可能一直停留在“能 set/get、能排行榜”的层面。这个项目日志记录到现在,最大的收获不是会了几条命令,而是学会了如何看待一个缓存系统:单线程模型决定了命令设计必须克制,内存有限意味着每个 key 都要算成本,异步复制和持久化之间永远存在一致性与性能的博弈。这些认知在实际工作中比背十个命令有价值得多。
最后再分享两个我个人的实操习惯:第一,任何 Redis 环境变更,先用CONFIG GET save和CONFIG GET appendonly确认持久化策略,再动数据操作,防止“测试没问题,一到生产重启就丢数据”;第二,可视化工具可以帮你看数据,但排查慢命令、分析大 key、理清主从关系时还是回去看redis-cli的输出,命令行给你的信息最诚实也最精确。后面我打算接着补充 Redis 7.0 的新特性、多线程 I/O 下的性能调优,以及 Cluster 迁移演练场景,日志还会持续更新,有同样在学 Redis 的朋友欢迎一起交流踩坑经验。