核心用法
开篇:Redis 不只是缓存
很多人对 Redis 的印象停留在"缓存"二字。实际上,Redis 更像一把瑞士军刀——五大基本类型、三大特殊类型,外加分布式锁、布隆过滤器、限流器……几乎每个后端高频场景都能找到 Redis 的身影。
这篇文章不罗列命令手册,而是按照"数据结构 → 使用场景 → 实战方案"的思路,把 Redis 的核心用法串起来。
一、五大基本类型与使用场景
Redis 有九大数据类型。其中五个是经典类型:String、Hash、List、Set、Sorted Set。三个常用特殊类型:Bitmap、HyperLogLog、GEO。还有一个 Stream(消息队列场景,但生产环境一般用 Kafka/RocketMQ)。
小提示:Redis 的命令不区分大小写,但 Key 是区分大小写的。
GET myKey和GET mykey是两个不同的 Key。
1.1 String:最简单也最常用
String 是 Redis 最基础的类型,一个 Key 对应一个 Value。但别小看它,计数器、分布式锁、Session 存储都靠它。
常用命令:
# 基本读写
SET name "redis"
GET name
# 批量操作,减少网络往返
MSET k1 v1 k2 v2 k3 v3
MGET k1 k2 k3
# 原子计数(线程安全)
INCR page_views # 自增 1
INCRBY page_views 10 # 自增指定值
DECR stock_count # 自减 1
# 分布式锁的基础
SET lock_key owner_id NX EX 30
# NX = 不存在时才设置,EX = 过期时间(秒)典型场景:
- 计数器:文章阅读数、视频点赞数。
INCR article:1001:views一条命令搞定,原子操作不怕并发。 - 分布式 Session:把用户 Session 以 JSON 序列化后存入 String,多个应用实例共享同一份 Session。
- 缓存:最经典的用法,热点数据放 Redis,减少数据库压力。
1.2 Hash:天然的对象存储
Hash 就像一个 Map,一个 Key 下面可以有多个 field-value 对。非常适合存储对象。
# 存储用户信息
HSET user:1001 name "张三" age 28 city "杭州"
# 读取单个字段
HGET user:1001 name
# 读取所有字段
HGETALL user:1001
# 修改某个字段(不需要把整个对象取出来再存回去)
HSET user:1001 age 29Hash vs String 存对象的区别:
用 String 存对象,需要把整个对象 JSON 序列化后存成一个字符串。修改某个字段时,需要取出来 → 反序列化 → 修改 → 序列化 → 存回去,多次网络交互,还有并发问题。
用 Hash 存对象,每个字段独立存储,修改某个字段一条命令就够了。操作更简单,网络交互更少,天然避免并发冲突。
典型场景:
- 购物车:Key 是
cart:用户id,field 是商品 id,value 是数量。
# 添加商品
HSET cart:uid1024 product_334488 1
HSET cart:uid1024 product_334477 1
# 增加数量
HINCRBY cart:uid1024 product_334477 1
# 查看商品总数
HLEN cart:uid1024
# 查看所有商品
HGETALL cart:uid10241.3 List:有序的消息队列
List 是一个双向链表,支持从两端插入和弹出元素。
# 左边插入
LPUSH timeline:user1 "article_01" "article_02"
# 右边插入
RPUSH queue:tasks "task_a" "task_b"
# 查看列表(分页)
LRANGE timeline:user1 0 9 # 前 10 条
# 获取列表长度
LLEN queue:tasks典型场景:
- 消息队列(轻量级):生产者 LPUSH,消费者 RPOP。但 List 做消息队列有局限——没有 ACK 机制、没有消费者组,生产环境建议用专业的消息中间件。
- Timeline 信息流:用户关注的作者发布了新文章,
LPUSH到用户的 timeline 列表中,展示时LRANGE分页取出。
1.4 Set:无序集合,交集并集利器
Set 是无序、不重复的集合。它最强大的地方在于集合运算——交集、并集、差集。
# 添加元素
SADD user:1001:follows "user_A" "user_B" "user_C"
SADD user:1002:follows "user_B" "user_C" "user_D"
# 共同关注(交集)
SINTER user:1001:follows user:1002:follows
# 结果: "user_B" "user_C"
# 推荐关注(差集)
SDIFF user:1002:follows user:1001:follows
# 结果: "user_D"(1002 关注了但 1001 没关注的)
# 随机抽奖
SRANDMEMBER lucky_draw 3 # 随机取 3 个,不删除
SPOP lucky_draw 3 # 随机取 3 个并删除典型场景:
- 共同好友/共同关注:两个用户的关注列表取交集。
- 抽奖:把参与者放入 Set,
SPOP随机抽取中奖者。 - 去重:判断某个元素是否存在于集合中,
SISMEMBER时间复杂度 O(1)。
1.5 Sorted Set(ZSet):带分数的有序集合
ZSet 是 Redis 最强大的数据结构之一。每个元素关联一个分数(score),元素按分数排序。底层用跳表+哈希表实现,查找和范围查询都很高效。
# 添加带分数的元素
ZADD leaderboard 95 "player_A" 87 "player_B" 92 "player_C"
# 按分数从高到低排名
ZREVRANGE leaderboard 0 9 WITHSCORES # Top 10
# 查看某人的排名
ZREVRANK leaderboard "player_A" # 从大到小的排名
# 查看某人的分数
ZSCORE leaderboard "player_A"
# 增加分数
ZINCRBY leaderboard 5 "player_B" # player_B 加 5 分
# 按分数范围查询
ZRANGEBYSCORE leaderboard 90 100 # 分数 90-100 的元素典型场景:
- 排行榜:游戏积分排行、商品销量排行、热搜榜。ZSet 天生就是为排行榜而生的。
- 延迟队列:把消息的执行时间戳作为 score,消息内容作为 member。定时扫描
ZRANGEBYSCORE取出到期的消息。 - 滑动窗口限流:把每个请求的时间戳作为 score 和 member,用
ZREMRANGEBYSCORE移除窗口外的记录,用ZCARD统计窗口内的请求数。
在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,优先考虑 ZSet。
二、三大特殊类型
2.1 Bitmap:用一个比特位记录状态
Bitmap 本质上是一个 bit 数组,每个位置只有 0 和 1 两个状态。非常适合做二值统计。
# 设置某个偏移量的值
SETBIT login:2024-01-15 1001 1 # 用户 1001 在 1月15日 登录了
# 查询某个偏移量的值
GETBIT login:2024-01-15 1001
# 统计值为 1 的数量
BITCOUNT login:2024-01-15 # 当天登录用户数
# 多个 Bitmap 做位运算
BITOP AND active_both login:2024-01-15 login:2024-01-16
# active_both 中值为 1 的就是连续两天都登录的用户典型场景:签到打卡、日活统计、亿级用户登录状态。一个用户一个 bit,1 亿用户只需要约 12MB 内存。
2.2 HyperLogLog:估算去重计数
HyperLogLog 是一种概率数据结构,用于统计不重复元素的个数(基数)。它不存储具体数据,只记录数量,误差率约 0.81%。
# 添加元素
PFADD page_uv:2024-01-15 "user_A" "user_B" "user_C"
# 统计基数(去重后的数量)
PFCOUNT page_uv:2024-01-15
# 合并多天的数据
PFMERGE page_uv:week page_uv:2024-01-15 page_uv:2024-01-16典型场景:UV(独立访客)统计。如果用 Set 存储,1 亿个用户 ID 需要大量内存;HyperLogLog 只需要约 12KB 就能统计 2^64 个不同元素的基数。代价是有 0.81% 的误差,但对于 UV 统计来说完全可以接受。
2.3 GEO:地理位置
GEO 底层基于 ZSet 实现,把地理坐标编码后作为分数存储。
# 添加位置
GEOADD stores 120.155070 30.274084 "store_A" 120.190000 30.260000 "store_B"
# 计算两点距离
GEODIST stores "store_A" "store_B" km
# 查找附近的位置
GEOSEARCH stores FROMLONLAT 120.16 30.27 BYRADIUS 5 km ASC COUNT 10典型场景:"附近的人"、"附近的门店"。
三、分布式锁
分布式锁是 Redis 面试的高频考点,也是实战中最重要的场景之一。
3.1 从 SETNX 说起
分布式锁的核心思路很简单:大家去争抢同一个 Key,谁先抢到谁就获得锁。
# 加锁:Key 不存在才设置,同时设置过期时间
SET lock:order:1001 "owner_uuid" NX EX 30
# 返回 OK 表示加锁成功,返回 nil 表示锁已被其他人持有但一个简单的 SETNX 距离生产可用还有很多问题需要解决:
问题 1:异常情况下锁无法释放。 如果加锁后进程崩溃,没有走到解锁逻辑,锁就会一直存在,造成死锁。所以必须设置过期时间,而且加锁和设过期时间必须是原子操作(用 SET key value NX EX 而不是分开的 SETNX + EXPIRE)。
问题 2:误删别人的锁。 线程 A 加锁成功但执行太慢,锁过期了。线程 B 获得锁开始执行。线程 A 执行完后去解锁,把线程 B 的锁删了。解决方案:加锁时把自己的标识(UUID + 线程 ID)存入 value,解锁时先比较 value 是不是自己的。
问题 3:比较和删除不是原子操作。 "先 GET 判断是不是自己的锁,再 DEL 删除"这两步之间可能被打断。需要用 Lua 脚本保证原子性:
-- 解锁的 Lua 脚本
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end问题 4:锁过期时间难以预估。 如果业务执行时间超过锁的过期时间,锁会提前释放,导致并发问题。需要一个"续期"机制——这就是 Redisson 的 Watchdog。
3.2 Redisson 看门狗机制
Redisson 是 Redis 的 Java 客户端,提供了开箱即用的分布式锁实现,解决了上面所有问题。
RLock lock = redisson.getLock("myLock");
try {
lock.lock(); // 加锁,默认 30 秒过期,自动续期
// 执行业务逻辑
} finally {
lock.unlock();
}Watchdog 的核心原理:
- 加锁成功后,如果没有指定过期时间,Redisson 会启动一个后台定时任务
- 每 10 秒(默认过期时间 30 秒的 1/3)检查一次锁是否还被当前线程持有
- 如果仍然持有,就把过期时间重新续到 30 秒
- 当锁被释放(
unlock())或客户端实例关闭时,续期任务自动停止
注意:只有不指定过期时间时,Watchdog 才会生效。如果你主动设置了
lock.lock(30, TimeUnit.SECONDS),Redisson 不会帮你续期。
Watchdog 什么时候会停止续期?
- 主动调用
unlock():无论解锁成功还是失败,续期任务都会被取消 - 客户端进程挂了:续期任务是 JVM 中的定时任务,进程崩溃后任务自然消失。等锁过期后自动释放,不会死锁
- 主动设置了超时时间:Redisson 认为你自己管理超时,不再续期
lock vs tryLock:
lock():阻塞方法,拿不到锁就一直等,直到获取成功tryLock():非阻塞方法,尝试获取锁,拿不到立刻返回 false。也可以指定等待时间tryLock(waitTime, leaseTime, unit),在 waitTime 内重试
Redisson 的可重入实现:Redisson 的锁存储在 Redis 中是 Hash 结构。Key 是锁名,Hash 的 field 是 UUID:threadId,value 是重入次数。同一个线程再次加锁时,次数 +1;解锁时次数 -1,减到 0 才真正删除 Key。这样就实现了可重入,也防止了误删。
3.3 RedLock 与争议
单机 Redis 做分布式锁有一个致命问题:Redis 挂了,锁就失效了。
用主从模式?主节点加锁成功后,数据还没同步到从节点,主节点就挂了。新选出的主节点没有这个锁的数据,其他客户端又能加锁成功,出现了两个持有者。
为了解决这个问题,Redis 作者提出了 RedLock 算法:
- 准备 N 个(通常是 5 个)独立的 Redis 实例(不是主从,是完全独立的)
- 客户端依次向这 N 个实例请求加锁
- 如果在半数以上(N/2 + 1)的实例上加锁成功,就认为获取锁成功
- 解锁时向所有实例发送解锁请求
但 RedLock 一直存在争议。分布式系统专家 Martin Kleppmann 发表文章指出 RedLock 在网络延迟和时钟漂移场景下不可靠。Redis 作者 Antirez 也发文回应。两位大佬的论战是分布式系统领域的经典讨论。
最终结果是:Redisson 已经废弃了 RedLock 的实现。 官方不再推荐使用。
那现在怎么办?实际建议:
- 大多数场景:直接用 Redisson 的普通锁 + 业务层幂等控制就够了
- 对一致性要求极高:用 ZooKeeper 或 etcd 做分布式锁(基于 Raft/Paxos 共识算法)
- 实在担心 Redis 单点故障:在业务逻辑中做好幂等设计和对账机制,即使偶尔重复加锁,业务也不会出错
四、布隆过滤器
4.1 什么是布隆过滤器?
布隆过滤器是一种空间效率极高的概率数据结构,用于判断一个元素是否可能存在于集合中。
它的核心特点是:说不存在,就一定不存在;说存在,可能是误判。
原理:一个初始值全为 0 的 bit 数组 + 多个哈希函数。添加元素时,用多个哈希函数对元素计算出多个位置,把这些位置都置为 1。查询时,检查这些位置是否都为 1。如果有任何一个位置是 0,说明元素一定不存在;如果全部是 1,则元素可能存在(因为可能是其他元素把这些位恰好都置为 1 了)。
4.2 解决缓存穿透
布隆过滤器最经典的应用就是解决缓存穿透问题。
缓存穿透是指:查询一个不存在的数据,缓存中没有,每次都穿透到数据库。如果是恶意攻击,大量请求不存在的数据,数据库压力会非常大。
解决方案是在 Redis 前面加一层布隆过滤器:
请求 → 布隆过滤器判断 → 不存在?直接返回
→ 可能存在?继续查 Redis → 没有?查数据库 → 回写缓存# Redis 8.0 原生支持布隆过滤器
BF.ADD user_filter "user_1001"
BF.ADD user_filter "user_1002"
# 查询
BF.EXISTS user_filter "user_9999" # 返回 0,一定不存在
BF.EXISTS user_filter "user_1001" # 返回 1,可能存在布隆过滤器只能添加,不能删除(删除可能影响其他元素的判断结果)。如果需要删除功能,可以用 Cuckoo Filter。
4.3 缓存穿透 vs 缓存击穿
这两个概念经常搞混:
- 缓存穿透:查询的数据压根不存在。解决方案:布隆过滤器、空值缓存。
- 缓存击穿:热点 Key 突然过期,大量并发请求同时打到数据库。解决方案:热点 Key 永不过期、互斥锁(第一个请求加锁去查数据库,其他请求等锁释放后直接读缓存)。
五、其他实战场景
5.1 基于 ZSet 的滑动窗口限流
滑动窗口限流可以平滑地控制流量,比固定窗口更精确。实现思路:
- 每个请求到来时,用时间戳作为 score 和 member 存入 ZSet
- 移除窗口之外的记录(
ZREMRANGEBYSCORE) - 统计窗口内的请求数(
ZCARD) - 如果未超过阈值,允许请求;否则拒绝
为了保证原子性,用 Lua 脚本把这些步骤打包执行:
local window_start = ARGV[1] - 60000 -- 60 秒前的时间戳
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', window_start)
local current = redis.call('ZCARD', KEYS[1])
if current < tonumber(ARGV[2]) then
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1])
return 1 -- 允许
else
return 0 -- 拒绝
endRedisson 也提供了封装好的限流器 RRateLimiter,基于令牌桶算法实现:
RRateLimiter limiter = redisson.getRateLimiter("api:login");
limiter.trySetRate(RateType.OVERALL, 100, 60, RateIntervalUnit.SECONDS);
boolean allowed = limiter.tryAcquire();5.2 基于 ZSet 的延迟队列
ZSet 天然适合做延迟队列:把执行时间戳作为 score,任务内容作为 member。
# 添加延迟任务:30 分钟后关闭订单
ZADD delay_queue 1706001800 "order:1001"
# 定时扫描到期任务
ZRANGEBYSCORE delay_queue 0 <当前时间戳> LIMIT 0 10Redisson 对此做了更好的封装——RDelayedQueue:
RBlockingDeque<String> queue = redisson.getBlockingDeque("orderQueue");
RDelayedQueue<String> delayedQueue = redisson.getDelayedQueue(queue);
// 30 分钟后投递到队列
delayedQueue.offer("order:1001", 30, TimeUnit.MINUTES);
// 消费者阻塞等待
String orderId = queue.take();RDelayedQueue 底层就是基于 ZSet 实现的,但帮你封装好了定时扫描和消费者阻塞等待的逻辑,使用起来更方便。
5.3 发布订阅
Redis 的 Pub/Sub 支持实时消息推送。发布者向频道发送消息,所有订阅了该频道的客户端都会收到。
# 客户端 A 订阅频道
SUBSCRIBE news:tech
# 客户端 B 发布消息
PUBLISH news:tech "Redis 8.0 released!"
# 客户端 A 立即收到消息Pub/Sub 的优点是实时性高、实现简单。但缺点也很明显:消息不持久化,客户端断线期间发布的消息会丢失;没有 ACK 机制,无法保证消息被消费。如果需要可靠的消息队列,建议用 Redis Stream 或者专业的消息中间件。
5.4 乐观锁
Redis 通过 WATCH 命令实现乐观锁。WATCH 会监视指定的 Key,如果在事务执行(EXEC)之前这些 Key 被其他客户端修改了,事务就会失败。
WATCH counter
GET counter # 假设得到 100
MULTI
SET counter 101 # 基于之前读到的值做修改
EXEC # 如果 counter 在此期间被其他人改过,EXEC 返回 nil,事务不执行这和数据库的乐观锁(基于版本号的 CAS)思路一致:先读取当前值,修改时检查值是否被改过。
5.5 Pipeline 批量操作
Pipeline 不是事务,不保证原子性,但它可以大幅减少网络往返次数。
正常模式下,每个命令都需要一次"发送 → 等待响应"的往返。Pipeline 模式下,客户端一次性发送多个命令,服务端依次执行后一次性返回所有结果。
Pipeline pipeline = jedis.pipelined();
pipeline.set("k1", "v1");
pipeline.set("k2", "v2");
pipeline.incr("counter");
List<Object> results = pipeline.syncAndReturnAll();如果你要批量写入 1000 条数据,用 Pipeline 可以把 1000 次网络往返降低到 1 次,性能提升是数量级的。
六、缓存淘汰与删除策略
Redis 内存总会用满,这时候就需要淘汰策略来决定删掉哪些 Key。
6.1 三种过期删除策略
Redis 对于设置了过期时间的 Key,有三种删除方式:
- 立即删除:Key 一到期就立刻删除。对内存友好,但 CPU 开销大——需要为每个 Key 维护一个定时器。
- 惰性删除:Key 过期后不主动删除,等下次访问时检查是否过期,过期了再删。对 CPU 友好,但可能有大量过期 Key 占着内存。
- 定期删除:每隔一段时间随机抽查一批 Key,删除其中过期的。是前两种方案的折中。
Redis 实际使用的是惰性删除 + 定期删除的组合。
6.2 八种淘汰策略
当内存达到 maxmemory 上限时,Redis 按照配置的淘汰策略决定删除哪些 Key:
| 策略 | 说明 |
|---|---|
| noeviction | 不淘汰,写入直接报错(默认) |
| allkeys-lru | 对所有 Key 使用 LRU 淘汰最近最少使用的 |
| volatile-lru | 只对设置了过期时间的 Key 使用 LRU |
| allkeys-lfu | 对所有 Key 使用 LFU 淘汰最不经常使用的 |
| volatile-lfu | 只对设置了过期时间的 Key 使用 LFU |
| allkeys-random | 对所有 Key 随机淘汰 |
| volatile-random | 只对设置了过期时间的 Key 随机淘汰 |
| volatile-ttl | 淘汰即将过期的 Key(TTL 最小的优先) |
最常用的是 allkeys-lru:对所有 Key 使用 LRU 算法淘汰。适用于大多数缓存场景。
七、Key 和 Value 的设计原则
好的 Key 设计能减少很多麻烦,这里列出一些最佳实践。
Key 的设计:
- 用冒号分隔命名空间:
user:1001:profile、cache:product:2001 - 保持简洁但可读,避免过长(浪费内存)和过短(含义不清)
- 避免特殊字符,只用字母、数字、冒号、下划线、点号
- 不要使用
KEYS *遍历所有 Key(会阻塞主线程),用SCAN代替
Value 的设计:
- 根据场景选择合适的数据类型,不要什么都用 String
- 避免大 Key(单个 Value 几十 MB),会导致网络延迟和内存碎片
- 为缓存数据设置合理的过期时间
- 如果数据可压缩,存储前压缩可以节省内存
八、常见面试题
Q1:Redis 的 SETNX 为什么是原子性的?
因为 Redis 是单线程执行命令的。当一个客户端执行 SETNX 时,其他客户端无法执行任何命令,直到该命令执行完毕。不需要加锁,天然保证原子性。但注意,多个 Redis 命令的组合不是原子的,需要用 Lua 脚本或事务来保证。
Q2:Redisson 的 Watchdog 什么情况下会失效?
三种情况:(1) 主动设置了超时时间,Redisson 不再续期;(2) 续期任务没能及时执行(CPU 满载、进程崩溃);(3) 续期时 Redis 连不上(网络故障、Redis 宕机)。
Q3:为什么 Redisson 废弃了 RedLock?
核心原因是安全性存疑:分布式系统专家指出 RedLock 在网络分区和时钟漂移场景下不可靠。Redis 官方也不再推荐。替代方案是普通锁 + 业务幂等,或者用 ZooKeeper/etcd 等强一致组件。
Q4:布隆过滤器为什么不能删除元素?
因为一个 bit 位可能被多个元素共享。如果删除一个元素时把它对应的 bit 位置为 0,可能会影响其他元素的判断结果。如果需要删除功能,可以用 Cuckoo Filter。
Q5:Lua 脚本为什么能保证原子性?
Redis 把 Lua 脚本当作一个整体命令来执行,在脚本执行期间不会有其他命令插入。这保证了并发编程意义上的"不可拆分、不被中断"的原子性。但注意,如果脚本中某个命令报错,之前成功的命令不会回滚——这不是数据库 ACID 意义上的原子性。
Q6:缓存穿透和缓存击穿有什么区别?
穿透是查不存在的数据(每次都打到 DB),用布隆过滤器或空值缓存解决。击穿是热点 Key 突然过期导致大量请求涌入 DB,用永不过期或互斥锁解决。前者是"查了个寂寞",后者是"众人一起冲"。
九、缓存双写一致性
当你同时使用数据库和 Redis 缓存时,如何保证两者的数据一致?这是一个经典问题。
9.1 核心原则
先说结论:没有完美方案,只有最终一致性。 给缓存设置过期时间是保底策略——即使同步失败,过期后下次读取会自动从数据库加载最新数据。
9.2 三种更新策略
方案一:先更新数据库,再更新缓存。
问题:数据库更新成功,但缓存更新失败,后续请求读到缓存中的旧数据。
方案二:先删除缓存,再更新数据库。
问题:线程 A 删了缓存,还没更新完数据库。线程 B 来读缓存发现没有,从数据库读到旧值并写回缓存。线程 A 更新完数据库,但缓存里是 B 写入的旧值。
改进方案是"延时双删":线程 A 更新完数据库后,等一小段时间(比如 500ms,确保 B 的读请求已经完成),再删一次缓存。
方案三:先更新数据库,再删除缓存(推荐)。
这是实际使用最多的方案。问题:更新数据库后如果删缓存失败怎么办?
解决:借助 Canal 订阅 MySQL 的 binlog,当检测到数据变更时异步删除对应的缓存。如果删除失败,通过消息队列重试。
业务写入 MySQL → binlog 变更 → Canal 监听 → 删除 Redis 缓存
↓ 失败
消息队列 → 重试删除9.3 选择建议
- 强一致性要求不高(大多数场景):先更新数据库,再删缓存,配合合理的过期时间
- 强一致性要求高:用 Canal + 消息队列实现异步缓存失效
- 小数据/热点数据:直接同步双写,前台可以短暂降级
十、Redis 遍历的正确姿势
这一节虽然简单但非常实用。很多生产事故都和 KEYS * 有关。
KEYS 命令:千万别在生产环境用
KEYS * 会遍历整个数据库的所有 Key 然后返回。如果 Key 很多(比如几百万个),这个操作会阻塞 Redis 好几秒甚至更长。在这期间,其他所有客户端的请求都会排队等待。
SCAN 命令:安全的遍历方式
SCAN 基于游标分批次迭代 Key 集合,每次只返回一部分结果,不会阻塞服务器。
# 第一次调用,游标从 0 开始
SCAN 0 COUNT 100
# 返回值包含两部分:下次迭代的游标 和 本次结果
# 1) "17" ← 下次用这个游标继续
# 2) 1) "key1"
# 2) "key2"
# ...
# 继续迭代
SCAN 17 COUNT 100
# 当返回的游标为 "0" 时,遍历结束类似地,Hash 有 HSCAN,Set 有 SSCAN,ZSet 有 ZSCAN,都采用相同的游标迭代模式。
十一、分布式锁的完整实现细节
前面第三节讲了分布式锁的核心思路,这一节补充一些实现细节和进阶话题,方便面试时深入回答。
11.1 用 SETNX 实现可重入锁
可重入锁是指同一个线程可以多次获取同一把锁而不会死锁。Redis 的 SETNX 本身不支持可重入,但我们可以自己实现。
思路是在 value 中存储线程标识和重入次数,格式为 线程ID:次数。
加锁逻辑:
- 如果 Key 不存在,直接加锁,存入
线程ID:1 - 如果 Key 存在,检查 value 中的线程 ID 是否等于当前线程
- 是:次数 +1,更新 value
- 否:加锁失败
解锁逻辑:
- 次数 -1
- 如果次数减到 0,删除 Key(真正解锁)
- 如果次数大于 0,只更新 value(锁仍被持有)
为了保证原子性,加锁和解锁都应该用 Lua 脚本实现:
-- 加锁 Lua 脚本
local lockValue = redis.call('get', KEYS[1])
if lockValue == false then
redis.call('setex', KEYS[1], ARGV[2], ARGV[1] .. ':1')
return 1
end
local parts = {}
for match in (lockValue .. ":"):gmatch("(.-):") do
table.insert(parts, match)
end
if parts[1] == ARGV[1] then
local count = tonumber(parts[2]) + 1
redis.call('setex', KEYS[1], ARGV[2], ARGV[1] .. ':' .. count)
return 1
end
return 0当然,如果你用 Redisson,这些复杂性都被框架封装好了,直接用 RLock 就行。
11.2 Redisson 如何保证不误删?
Redisson 在 Redis 中存储锁时用的是 Hash 结构。Key 是锁名,Hash 的 field 是 UUID:threadId(UUID 是 Redisson 客户端实例的唯一标识),value 是重入次数。
加锁时,Lua 脚本会把当前客户端的 UUID:threadId 存入 Hash。解锁时,Lua 脚本先用 hexists 检查当前客户端的标识是否存在于 Hash 中:
- 如果不存在:说明锁不属于当前客户端,返回 nil,解锁失败
- 如果存在:重入次数 -1。减到 0 时
DEL删除整个 Key
这个设计同时解决了两个问题:防误删(通过 UUID:threadId 标识锁的持有者)和可重入(通过 Hash 的 value 记录重入次数)。
11.3 解锁失败会一直续期吗?
不会。Redisson 的 unlock 方法使用了 CompletionStage.handle(),这意味着无论解锁操作成功、失败还是抛异常,后续的 cancelExpirationRenewal() 都会执行。这个方法会从续期任务的 Map 中移除当前线程,终止后续的续期。
CompletionStage<Boolean> future = unlockInnerAsync(threadId);
future.handle((opStatus, e) -> {
cancelExpirationRenewal(threadId); // 无论成功失败,都取消续期
// ... 异常处理
});所以即使解锁失败,也不会出现"锁一直被续期"的情况。唯一的极端情况是 cancelExpirationRenewal 本身出错(从本地 Map 删除一个 Key),但这个概率极低。
小结
这篇文章覆盖了 Redis 核心用法的方方面面:
- 五大基本类型:String(计数器、锁)、Hash(对象存储、购物车)、List(消息队列、信息流)、Set(集合运算、抽奖)、ZSet(排行榜、延迟队列、限流)
- 三大特殊类型:Bitmap(状态统计)、HyperLogLog(UV 统计)、GEO(地理位置)
- 分布式锁:从 SETNX 到 Redisson Watchdog,再到 RedLock 的争议与废弃
- 布隆过滤器:解决缓存穿透
- 实战场景:滑动窗口限流、延迟队列、发布订阅、乐观锁、Pipeline
下一篇进入 Redis 的底层世界——SDS、哈希表、跳表、压缩列表,看看 Redis 快的秘密到底藏在哪些数据结构里。