简介:面向Redis学习者与实践者的系统课程资料包,基于《Redis核心技术与实战》内容整理,聚焦字符串、哈希等数据结构的选择,AOF与RDB持久化、主从复制、哨兵机制、切片集群等核心原理,也覆盖计数器统计、GEO类型、时间序列、消息队列、异步阻塞、CPU架构影响及响应延迟排查等高价值实战话题,适合初中级开发者系统学习与查漏补缺,也适合在面试和运维场景中快速查阅。压缩包为zip格式,整体约856MB,按课程模块组织目录,包含开篇词、基础篇(10讲)、实践篇(28讲)等章节,便于逐章检索。目前已有47人浏览学习。资料包含第1~9讲课后思考题答案与常见问题答疑,以及大量可落地的案例思路,能帮助读者理解Redis单线程模型为何够快、宕机后如何恢复数据,以及面对亿级key统计、消息队列选型或突发响应延迟时如何定位与优化,兼顾原理与实战。
1. Redis核心技术与实战:为什么说它是后端性能的最后一块短板
很多后端系统在流量涨起来之前,都把 Redis 当成一个“高级缓存”用:存个验证码、存个 session、查不到就回源数据库。直到某天线上查询全部超时、缓存被穿透打垮数据库、主从切换后数据对不上,你才会意识到 Redis 并不只是 GET/SET,而是一套有数据模型、持久化策略、高可用架构和分布式协调能力的中间件。这里说的“核心技术与实战”,指的是一套能把开发、部署、排障串起来的技术主线:先搞懂数据结构和内存模型,再搞定持久化与高可用,最后解决缓存治理、分布式锁这些真实业务痛点。适合还在把 Redis 当纯缓存用的后端开发、刚接手 Redis 运维的 DBA,以及准备 Redis 面试但只会背概念的求职者。
2. 把 Redis 数据类型选对:选错类型的代价比配置错误更大
2.1 五种基础数据类型的选型逻辑
Redis 的数据类型是它区别于普通 key-value 存储的核心。很多人一开始就踩了坑:所有数据都用 string 塞 JSON,结果一个对象字段更新就要读整个 string 再反序列化,内存和带宽都被白白浪费。我在做业务建模时,一般会先画一张表,把业务映射到类型上:
| 类型 | 底层编码 | 典型业务场景 | 核心命令 |
|---|---|---|---|
| string | int/embstr/raw | 验证码、计数器、分布式锁 | GET/SET/INCR |
| hash | ziplist/hashtable | 对象属性缓存、实时更新字段 | HSET/HGET/HINCRBY |
| list | quicklist | 消息队列、时间线、待办列表 | LPUSH/RPOP/BRPOPLPUSH |
| set | intset/hashtable | 去重、标签、好友关系 | SADD/SINTER/SUNION |
| zset | skiplist+ziplist | 排行榜、延迟队列、限流 | ZADD/ZRANGE/ZSCORE |
string 是最容易用错的类型。比如在线用户的点赞数,直接用INCR like:post:1001就是原子操作,根本不需要先读后写。反过来,把用户对象塞进 string 虽然省事,但每次只改一个字段都要整体序列化,字段一多就变成大 key 隐患。对象型数据,我建议优先用 hash:
# 对象缓存用 hash,字段级更新,不整体序列化 HSET user:1001 name "zhang" age 30 last_login 1735689600 HINCRBY user:1001 login_count 1 HGETALL user:1001这段命令的逻辑是:HSET 允许按字段写入,HINCRBY 能对数字字段做原子自增。相比 string 方式,hash 的好处是更新一个字段只产生很小的网络开销,也不会因为读改写造成并发覆盖。注意 hash 字段数量建议控制在 100 个以内,字段再多,底层从 ziplist 退化成 hashtable 后内存效率会明显下降。
list 当队列是另一个高频场景,但直接 LPUSH/RPOP 有个缺陷:消费者拿走后如果处理失败,消息就丢了。我一般会加上备份队列,用BRPOPLPUSH原子地把消息从主队列搬到备份队列,处理成功后再从备份队列删除:
# 生产者 LPUSH task:queue "task:123" # 消费者:原子取出并放入备份队列,阻塞 30 秒 BRPOPLPUSH task:queue task:backup 30BRPOPLPUSH 的语义是“取出并放入另一条队列”,这个动作是原子的,不会出现消息既被取走又没进备份的情况。处理成功的任务调用LREM task:backup 1 "task:123"删除即可;处理失败的任务可以从备份队列读回来重试。
zset 不只是排行榜。利用 score 存时间戳,zset 还可以做延迟队列:定时任务把执行时间作为 score,消费者用ZRANGEBYSCORE拉取到期的任务。这个模式在很多业务里比引入消息中间件更轻量,但需要注意 zset 的 score 是双精度浮点,毫秒级时间戳直接放进去会有精度损失。
2.2 高级数据类型:Bitmap、HyperLogLog、Geo 与 Stream
Bitmap 最适合做签到和在线状态这类布尔型统计。每个用户一天只占 1 bit,一个月不到 4 字节:
# 用户 1001 在 2025 年 1 月 3 日签到 SETBIT sign:2025:01 user:1001 2 1 # 查询是否签到 GETBIT sign:2025:01 user:1001 2 # 统计一月签到人数 BITCOUNT sign:2025:01BITCOUNT 统计的是位值为 1 的数量。这里要注意 offset 从 0 开始,第 3 天的下标是 2。签到场景用 bitmap 的最大好处是内存极省,一亿用户一年只要 1.2GB 左右,比存字符串省几十倍。
HyperLogLog 用来做 UV 统计,它牺牲了精度换内存。标准误差约 0.81%,但每个 key 最多占用 12KB,无论统计多少独立用户:
PFADD uv:2025:01:01 user:1001 user:1002 user:1003 PFCOUNT uv:2025:01:01注意 PFCOUNT 是近似值,精确度要求高的场景(比如对账)不适合。如果同一批用户要按天去重再按月去重,可以用 PFMERGE 合并多个 key。
Stream 是 Redis 5.0 引入的消息队列类型,支持消费者组和 ACK 机制。相比 list 队列,Stream 能记录消费位点,宕机后可以从上次未 ACK 的消息继续消费。但我的观点是:如果业务已经引入了 Kafka/RabbitMQ,不要把 Stream 当主力队列,它更适合轻量级、单机或主从环境的内部异步任务,一旦数据量上去,Stream 的持久化和扩容成本都不低。
2.3 序列化方案:Java 场景下都在哪里翻车
Spring Boot 项目里用 RedisTemplate,最典型的翻车现场是 key 变成\xAC\xED\x00\x05t\x00一串乱码。这是因为默认的 JdkSerializationRedisSerializer 会把 key 也做 Java 序列化,Redis Desktop Manager 里看到的全是乱码,排查问题都无从下手。我的常规做法是:key 一律用 StringRedisSerializer,value 用 JSON 序列化:
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // key 可读,方便命令行和可视化工具排查 template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); // value 用 JSON,保留类型信息 template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet(); return template; } }这里的逻辑是:StringRedisSerializer 让所有 key 变成人眼可读的字符串,GenericJackson2JsonRedisSerializer 在 value 里写入 @class 类型标记,反序列化时能还原成正确的 Java 对象。代价是类型标记会让 JSON 体积变大 10% 左右,如果你对内存敏感,可以换成不带类型标记的普通 Jackson,但取值时要手动指定目标类型。
另一个坑是把对象序列化成 JSON 字符串后塞进 string,然后业务需要做自增操作——这时候只能先反序列化、加一、再序列化写回,三个步骤不是原子的,并发下必然丢更新。数字型字段要么拆出来用 string 存,要么用 hash 的 HINCRBY,不要整个对象塞一个 key。
3. 从安装到持久化:把 Redis 部署成一个可依赖的中间件
3.1 三种环境下的最小安装方式
macOS 上用 Homebrew 是最省心的方式,装完直接注册成服务。Docker 方式适合任何环境,也是我推荐的生产部署方式,因为配置和数据目录的挂载非常明确:
# macOS brew install redis brew services start redis # Docker,跨平台,生产推荐 docker run -d --name redis \ -p 6379:6379 \ -v /etc/redis/redis.conf:/etc/redis/redis.conf \ -v /data/redis:/data \ redis:7-alpine \ redis-server /etc/redis/redis.confDocker 命令里两个 -v 是关键:一个挂载配置文件,一个挂载数据目录。Redis 官方镜像的默认数据目录是 /data,RDB 和 AOF 文件都会落在这里,不挂载的话容器重建数据就没了。生产环境不要使用 redis:latest 标签,而是锁定大版本,比如 redis:7-alpine,避免基础镜像更新带来的不确定性。
Windows 上官方并不提供原生 Redis 安装包,网上所谓的 Windows 版基本都是旧版本移植或者二次封装。我的建议是:Windows 本机开发直接用 Docker Desktop 跑容器,或者用 WSL2 里的 Linux 环境安装,不要在 Windows 原生环境里跑老版本 Redis,性能和特性都跟不上。
Linux 源码编译安装适合需要定制编译参数的环境,标准流程是下载源码、解压、make、make install。编译前确保系统有 gcc 和 make,如果只是使用而不是定制,还是直接用发行版的 redis-server 包更省心。
3.2 redis.conf 必调的五个参数
很多线上事故不是 Redis 坏了,而是默认配置不符合生产场景。以下五个参数是我每次部署都会检查的:
| 参数 | 默认值 | 生产建议 | 原因 |
|---|---|---|---|
| maxmemory | 0(不限制) | 物理内存的 60%-70% | 不限制会把系统内存耗尽,触发 OOM Killer |
| maxmemory-policy | noeviction | allkeys-lru 或 volatile-lru | noeviction 下内存满了所有写操作直接报错 |
| appendonly | no | yes | 不开启 AOF,重启可能丢几分钟数据 |
| requirepass | 空 | 强密码 | 空密码很容易被扫描工具发现并植入挖矿脚本 |
| rename-command | 无 | 禁用 KEYS/FLUSHALL | 防止手滑和线上误操作 |
maxmemory 的设定有个经验值:Redis 内存占用达到物理内存的 70% 后,fork 生成 RDB 的快照效率和系统稳定性会下降,所以要留出余量。maxmemory-policy 的理解很简单:allkeys-lru 对所有 key 做 LRU 淘汰,volatile-lru 只淘汰设置了过期时间的 key。缓存场景用 allkeys-lru,如果 Redis 里还存了不能丢的注册数据,就要用 volatile-lru 并给数据设过期时间。
requirepass 设置了密码后,从节点也要配 masterauth,否则主从复制断线后重连会被拒绝。这个坑出现频率很高,后面第 4 章会再提。
3.3 持久化机制详解:RDB、AOF 与混合持久化
RDB 是二进制快照,原理是 fork 子进程,利用 Copy-on-Write 机制把当前内存数据写成 dump.rdb。它的优点是恢复快、文件紧凑;缺点是两次快照之间的数据会丢。默认配置下 60 秒内如果有超过 10000 次写操作才触发快照,高并发下可能丢大量数据。
AOF 是追加日志,只要写入命令执行成功,就会按策略追加到 appendonly.aof。appendfsync 参数决定刷盘时机:
| appendfsync 值 | 含义 | 性能 | 安全性 |
|---|---|---|---|
| always | 每个命令都 fsync | 最差 | 最多丢一条命令 |
| everysec | 每秒 fsync | 折中 | 最多丢 1 秒数据 |
| no | 交给操作系统刷盘 | 最好 | 可能丢好几秒数据 |
生产环境默认 everysec,这是性能和安全的平衡点。always 在 SSD 上写放大严重,吞吐会掉到几千 QPS,非极端金融场景不要用。
AOF 文件会无限增长,所以要有重写机制。BGREWRITEAOF会根据当前内存数据生成最小化的写命令集,把历史冗余命令压缩掉。4.0 之后引入了混合持久化:重写时把当前数据以 RDB 格式写入 AOF 文件头部,后面的增量命令再用 AOF 格式追加。这样既解决了 AOF 文件体积大的问题,又让重启加载速度快了很多。
# 手动触发一次 RDB 快照 redis-cli -a yourpass BGSAVE # 手动触发 AOF 重写 redis-cli -a yourpass BGREWRITEAOFBGSAVE 和 BGREWRITEAOF 都会 fork 子进程,fork 的瞬间会短暂阻塞主线程。如果内存很大(几十 GB),fork 耗时可能达到秒级,这也是为什么我强调 maxmemory 不要顶满物理内存。我的经验是做持久化运维时,提前在业务低峰期手动触发大 key 重写,避免高峰期自动重写造成卡顿。
4. 主从、哨兵与集群:高可用架构的落地路径
4.1 主从复制与读写分离的搭建
生产环境至少应该是一主一从,主节点负责写,从节点负责读和备份。搭建主从的最小配置是在从节点 redis.conf 里写三行:
replicaof 192.168.1.10 6379 replica-read-only yes masterauth yourpassreplicaof 告诉从节点主节点地址和端口;replica-read-only 强制从节点只读,防止误写入造成主从不一致;masterauth 配置的是主节点的密码,很多人只配了主节点的 requirepass,忘了配从节点的 masterauth,主从断线重连时从节点会因为认证失败一直处于握手状态。
验证主从状态用 INFO replication:
redis-cli -p 6380 -a yourpass INFO replication输出里主要看 role 是 slave(或 replica),master_link_status 是 up。如果显示 down,先去查从节点日志里的认证失败和时间偏移。主从延迟也是关注点:INFO replication里的 master_repl_offset 和从节点的 slave_repl_offset 差值就是延迟的字节数,持续增长说明从节点消费能力跟不上主节点的写入速度。
主从只是高可用的基础,不是高可用本身。主节点宕机后,需要人工把某个从节点提升为主节点,这个过程无法自动完成,所以要引入哨兵。
4.2 哨兵与 Cluster:什么时候用哪个
哨兵(Sentinel)解决的是主从架构的自动故障转移。生产环境至少要部署三个哨兵实例,quorum 设为 2,这样超过半数哨兵认定主节点客观下线时,才会触发选举:
# sentinel.conf 关键配置 sentinel monitor mymaster 192.168.1.10 6379 2 sentinel down-after-milliseconds mymaster 5000 sentinel failover-timeout mymaster 15000down-after-milliseconds 是哨兵判定主节点主观下线的超时时间,5000 意味着 5 秒内主节点没有响应才认为它挂了。时间设太短,网络抖动就会频繁触发切换;设太长,故障恢复时间变长。5 到 10 秒是比较常见的取值。
Redis Cluster 则是在数据量超过单机内存时引入的分片方案。数据分布在 16384 个哈希槽里,每个主节点负责一段槽范围。搭建一个三主三从的最小集群:
redis-cli --cluster create \ 192.168.1.10:7000 192.168.1.11:7000 192.168.1.12:7000 \ 192.168.1.13:7000 192.168.1.14:7000 192.168.1.15:7000 \ --cluster-replicas 1--cluster-replicas 1 表示每个主节点配备一个从节点,所以前三个是主,后三个是从。创建过程中会打印每个节点的角色和槽范围,确认后集群开始工作。集群模式下客户端连接任意一个节点,如果 key 不在该节点,会返回 MOVED 错误并告知正确的节点地址,正常客户端 SDK 会自动处理重定向。
选择哨兵还是 Cluster,我的判断标准是:单机内存能放下全部数据就用哨兵主从,结构简单,支持跨 key 事务;数据超过单机内存、或者需要线性扩容才上 Cluster。Cluster 最大的限制是跨槽的 multi-key 操作(MGET、事务、Lua 脚本)不被支持,业务代码如果大量依赖多 key 原子操作,迁移成本会比较高。
4.3 容器环境部署:Docker 和 k8s 的注意点
Docker 部署主从时,建议用 docker-compose 统一管理,否则每个容器手动 docker run 很容易漏配置。一个最小主从 compose 的关键片段:
services: redis-master: image: redis:7-alpine command: ["redis-server", "--requirepass", "yourpass", "--appendonly", "yes"] redis-slave: image: redis:7-alpine command: ["redis-server", "--replicaof", "redis-master", "6379", "--masterauth", "yourpass"] depends_on: - redis-master注意这里从节点通过容器名 redis-master 来解析主节点地址,服务启动顺序由 depends_on 控制,但 depends_on 只保证启动顺序,不保证主节点 Redis 已经就绪,所以从节点可能启动后连接失败重试几次,这是正常的。
k8s 里部署 Redis 要避免一个常见误区:直接 Deployment + 默认存储,Pod 重建后数据落在新存储上,老数据全丢。必须用 StatefulSet 配合 PVC,给每个 Pod 稳定的网络标识和独立存储卷。如果是集群模式,还要注意 Cluster 节点发现依赖固定的 IP 或域名,StatefulSet 的 headless service 能提供这种稳定标识。另一个坑是容器 CPU 限制过低时,fork 生成 RDB 快照可能触发 cgroup 的 CPU 限制,导致 fork 时间异常长,建议给 Redis 容器预留 1-2 核的余量。
5. 缓存治理与排障:穿透、击穿、雪崩和 Command timed out
5.1 缓存穿透:布隆过滤器与空值缓存
缓存穿透指的是查询一个根本不存在的数据,缓存和数据库都没有,请求每次都会打到数据库。如果攻击者伪造大量不存在的 key,数据库会被打挂。最典型的场景是用户查询一个已删除的商品 ID,或者恶意遍历 ID。
解决思路有两个。最简单的做法是空值缓存:查询数据库发现没有,就把空值也写入缓存,设置 60 秒过期:
String value = redis.get("product:" + id); if (value == null) { Product p = productDao.selectById(id); if (p == null) { redis.set("product:" + id, "", 60); // 空值也缓存 return null; } redis.set("product:" + id, JSON.toJSONString(p), 3600); return p; } if (value.isEmpty()) { return null; }这里有一个细节:空值缓存要区分“缓存里没有”和“缓存里是空串”,我习惯用 null 表示未命中,空字符串表示命中了空值。另外空值的过期时间要比正常数据短,比如 60 秒,因为可能下次查询就有数据了。
布隆过滤器是更彻底的方案,把所有可能存在的 key 预先放入布隆过滤器,查询前先判断 key 是否可能存在,不存在直接返回。用 Redisson 的 RBloomFilter 实现:
RBloomFilter<String> filter = redisson.getBloomFilter("product_keys"); filter.tryInit(1000000L, 0.01); if (!filter.contains("product:" + id)) { return null; // 一定不存在,直接拦截 }tryInit 的两个参数是 expectedInsertions(预期数据量)和 falseProbability(误判率)。误判率 0.01 表示有 1% 的概率会把不存在的 key 误判为存在,误判的请求会继续打到数据库,但这种量级已经可以接受。注意布隆过滤器不支持删除,业务里如果有大量删除 key 的操作,过滤器会逐渐失真,需要定期重建。
5.2 缓存击穿与雪崩:热点 key 过期和批量过期
击穿和穿透名字像,场景完全不同。击穿是指某个热点 key 突然过期,大量并发请求同时发现缓存未命中,全部打到数据库。
互斥锁方案是行业内最常见的做法:缓存未命中时,先尝试获取分布式锁,只有拿到锁的线程去查数据库,其他线程自旋等待后重新读缓存:
String value = redis.get(key); if (value == null) { String lockKey = "lock:" + key; boolean locked = redis.set(lockKey, "1", "NX", "EX", 5); if (locked) { try { value = db.query(key); redis.set(key, value, 300); } finally { redis.del(lockKey); } } else { Thread.sleep(50); value = redis.get(key); // 自旋重试 } }这个场景里 SET 的 NX 参数保证只有一个线程能拿到锁,EX 5 防止持有锁的线程崩溃导致死锁。sleep 50 毫秒是自旋间隔,太短会放大 CPU 消耗,太长增加响应延迟。另一个常用手段是“逻辑过期”:缓存里额外存一个过期时间戳,每次读取时发现逻辑过期就后台异步刷新,让旧数据先返回,这个方案不会阻塞请求但实现更复杂。
雪崩是更大范围的击穿:大量 key 在同一时刻过期,或者 Redis 整个挂了。解决批量过期的方法是给过期时间加随机偏移:
SET product:1001 {json} EX 3600 SET product:1002 {json} EX 3673 SET product:1003 {json} EX 3548把过期时间从固定 3600 秒变成 3600 加减一个随机值,错开过期高峰。Redis 挂了的兜底方案是多级缓存:本地进程内缓存(Caffeine)挡一层,Redis 挡一层,数据库最后兜底。本地缓存的优点是每个应用实例自己持有,不依赖网络,但要注意本地缓存更新时多个实例间的数据一致性问题。
5.3 RedisCommandTimedOut:Lettuce 客户端超时排查
Spring Boot 默认用 Lettuce 作为 Redis 客户端,遇到最多的报错是:
io.lettuce.core.RedisCommandTimeoutException: Command timed out after 5 second(s)这个异常出现时,第一反应不要改客户端参数,先确认 Redis 服务端是否有慢请求。查看慢日志:
SLOWLOG GET 10 SLOWLOG RESET慢日志会显示执行时间超过 slowlog-log-slower-than(默认 10000 微秒)的命令。常见元凶是 KEYS *、SMEMBERS 一个百万成员的 set、HGETALL 一个超大 hash。这类命令在单线程模型下会阻塞后续所有命令,Lettuce 客户端等待超过 timeout 就抛异常。
我的排查顺序是:先跑redis-cli --bigkeys找出大 key,再配合 SLOWLOG 确认是否有耗时命令,最后看INFO CPU确认服务端 CPU 是否被打满。如果服务端正常,再检查客户端连接池:
spring: redis: timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 3000msLettuce 是异步框架,底层用 Netty 管理连接,单连接就能处理高并发。但 Spring Data Redis 的同步封装会在线程池里等待响应,如果单个连接上的命令排队过长,依然会超时。调大 max-active 可以让更多命令并行分发到不同连接上,但要注意连接数过多会增加服务端文件描述符压力。
还有一种场景是 Redis 在跑 BGSAVE,fork 瞬间主线程阻塞,所有命令延迟飙高。这个可以从 Redis 日志里看到forked child的时间戳,或者用INFO persistence查看当前是否在持久化。应对方法是错峰备份,以及给 Redis 主机预留足够内存,减少 fork 时 copy-on-write 的页表复制开销。
5.4 可视化工具连不上、ACL 配置错误和内存打满
可视化工具方面,RedisInsight 和 Another Redis Desktop Manager(另一个 Redis 桌面管理器)是现在用得比较多的客户端。连接失败的排查路径基本一致:先redis-cli -h host -p port -a password ping在命令行验证连通性,再检查工具里的配置。
命令行能连上但工具连不上,最可能是工具的 SSH 隧道配置或者 TLS 选项问题。需要注意,如果 Redis 配置了bind 127.0.0.1,外部机器无论用什么工具都连不上,必须把 bind 改为内网 IP 或 0.0.0.0,同时设置密码并开启 protected-mode。
Redis 6.0 之后引入了 ACL(访问控制列表),很多人在配置 ACL 用户时踩坑。比如默认用户被FLUSHALL限制了,但业务代码还在用默认用户执行写入,就会报NOPERM错误。我一般会单独建一个业务用户,只授权需要的命令:
ACL SETUSER bizuser on >strongpass ~* +@all -FLUSHALL -KEYS这条命令的含义是:启用 bizuser,设置密码 strongpass,允许访问所有 key,所有命令组,但禁止 FLUSHALL 和 KEYS。~*是 key 模式匹配,生产环境如果业务只访问特定前缀的 key,可以改成~cache:*收窄范围。
内存打满也是高频事故。当 maxmemory 触发后,如果客户端执行写命令,会报OOM command not allowed when used memory > 'maxmemory'。这个报错说明 maxmemory-policy 失效或者配置成了 noeviction。处理方法不是简单调大内存,而是先看INFO memory里的 used_memory 和redis-cli --bigkeys找出内存大头,把无效 key 清掉,再把 maxmemory-policy 改成 allkeys-lru。
6. 进阶实战:分布式锁、Redisson 与 Redis 面试冲刺
6.1 分布式锁:从 SET NX 到 Lua 脚本
分布式锁是 Redis 在缓存之外用得最多的能力。经典的实现是单命令加锁:
# 加锁:只有 key 不存在时才能设置成功,NX + EX 保证原子性 SET lock:order:1001 owner:thread-1 NX EX 30SET 命令的 NX 参数确保只有第一个请求能写入成功,EX 30 设置 30 秒自动过期,防止持有锁的进程崩溃后死锁。相比 SETNX 过期时间分两步设置的旧写法,这种单命令方案避免了中间状态。
加锁容易,解锁才是重灾区。直接 DEL 解锁的问题是:如果线程 A 的锁 30 秒过期了,线程 B 抢到锁,然后线程 A 执行完业务再 DEL,就会把 B 的锁删掉,分布式锁直接失效。正确做法是解锁前先校验持有者,校验和删除必须是原子的,用 Lua 脚本:
if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end脚本逻辑是:只有当锁的值等于自己的标识时才删除,否则返回 0。Redis 执行 Lua 脚本是原子的,中间不会插入其他命令,所以不会出现“判断之后、删除之前”被人抢锁的空档。调用时把锁 key 作为 KEYS[1],把线程唯一标识作为 ARGV[1] 传入。
生产环境我强烈建议直接用 Redisson 封装好的 RLock,而不是自己维护 Lua 脚本:
RLock lock = redisson.getLock("lock:order:1001"); lock.lock(30, TimeUnit.SECONDS); try { // 业务逻辑 } finally { lock.unlock(); }Redisson 的价值在于它内置了看门狗机制:默认锁的租期是 30 秒,只要业务没执行完,看门狗会每 10 秒自动续期一次,避免锁因为业务耗时过长而过期。调用 lock 时如果不传租期参数,就会启用看门狗;传了租期,看门狗不会续期,所以要么主动传一个足够大的值,要么不传让它自动续期。
6.2 锁续期、可重入与 RedLock 的争议
分布式锁还有一个现实问题:业务逻辑无法准确预估执行时间。我用过一个电商库存扣减场景,正常 200 毫秒,但遇到数据库慢查询时能跑 40 秒,30 秒的锁早就过期了,多个线程同时进入临界区,超卖了。用 Redisson 之后,看门狗自动续期,这个情况被解决了。但看门狗不是万能的——如果你的业务里调了外部 HTTP 接口且没有配置超时,锁永远不会释放,其他请求全部阻塞。所以使用锁时一定要给每个外部调用设置明确的超时时间。
Redisson 的 RLock 还支持可重入,同一个线程可以多次加锁,需要相应次数解锁。这个特性在处理嵌套锁的时候会省很多事。但也有个隐藏坑:加锁次数和释放次数不匹配,锁就永远不会释放,最终依赖看门狗或者租期兜底。
业界对 RedLock(多节点投票式锁)一直有争议。它的思路是向多个独立 Redis 节点加锁,超过半数成功才算加锁成功,降低单点故障对锁的影响。但在网络分区场景下,RedLock 仍然可能失效,而且维护成本很高。对于绝大多数业务,单节点或主从架构下的分布式锁已经足够了。我的态度是:先想清楚你的业务能不能接受锁偶尔失效,能接受就上单锁;必须极强一致(比如金融扣款),那就直接引入 etcd 或 ZooKeeper,不要在 Redis 上死磕。
6.3 面试高频:为什么快、什么时候阻塞、怎么保证一致性
Redis 相关的面试题,本质上是在考察你有没有踩过性能坑。先回答“为什么快”:
第一,数据在内存里,内存随机读是纳秒级,磁盘读是毫秒级,差距在三个数量级以上;第二,Redis 的事件循环是单线程的,用 IO 多路复用(epoll)同时监听大量连接,不存在线程切换和锁竞争的开销。这里有个容易答错的点:Redis 6.0 的网络处理引入了多线程,但命令执行仍然是单线程的,所以说“Redis 是单线程”要加前缀——执行核心命令的线程是单线程。
接着面试官大概率会问“单线程是不是 Redis 的瓶颈”,这时候你要能把第 5 章的慢日志排查串进去:单线程意味着任何耗时操作都会阻塞所有请求。大 key 的读取、删除、过期扫描,AOF 重写时 fork,这些都会让其余命令排队。大 key 删除在 4.0 之后有了 UNLINK(异步删除),它只把 key 从主键空间摘除,内存释放交给后台线程,但大 key 的读取依然是个大坑。生产环境要严格控制单个 key 的大小,超过 10KB 就要警惕。
“缓存一致性”也是必考题。最常考的 Cache Aside 模式里,是先更新数据库再删缓存,还是先删缓存再更新数据库,为什么。我的回答思路是:先更新数据库再删缓存,虽然极端情况下缓存删除失败会有一小段不一致窗口,但通过消息队列异步重试删除就能兜底。而先删缓存再更新数据库,并发读请求会把旧数据重新写进缓存,不一致的时间窗口更长。所以结论是:更新数据库 + 删除缓存 + 失败重试,是工程上性价比最高的做法。
最后分享一个我坚持了很久的习惯:每个 Redis 实例都开启慢日志监控,设置slowlog-log-slower-than 5000(5 毫秒),每周扫描一次大 key。Redis 出问题从来不是某一刻突然发生的,慢日志和大 key 就是最前置的预警信号,提前处理掉,线上才不会半夜把你叫醒。希望这篇笔记能帮你把 Redis 从“会 SET/GET”推进到“能独当一面”,下次遇到性能问题,不用再靠重启大法续命。
本文还有配套的精品资源,点击获取