Redis¶
Redis(Remote Dictionary Server)是一个基于内存的高性能键值存储,支持丰富的数据结构,常用作缓存、消息队列和分布式锁。其单线程模型(命令执行层面)配合 I/O 多路复用,在避免锁竞争的同时达到极高的吞吐量。
数据类型与典型场景¶
| 类型 | 底层实现 | 典型场景 |
|---|---|---|
| String | SDS(Simple Dynamic String) | 缓存、计数器(INCR)、分布式锁 |
| Hash | ziplist / hashtable | 对象属性存储(如用户信息) |
| List | quicklist(ziplist + 链表) | 消息队列、最新动态流 |
| Set | intset / hashtable | 标签系统、共同好友(SINTER) |
| Sorted Set (ZSet) | ziplist / skiplist + hashtable | 排行榜、延迟队列 |
扩展类型:HyperLogLog(基数统计)、Bitmap(签到/布隆过滤器)、Stream(消息流,5.0+)、GEO(地理位置,底层为 ZSet)。
内存淘汰¶
常见淘汰算法¶
- LRU(Least Recently Used):淘汰最近最少使用的 key——优先移除最久未被访问的数据。
- LFU(Least Frequently Used):淘汰使用频率最低的 key——根据访问次数而非时间判断。
- FIFO(First In First Out):先进先出——按写入顺序淘汰最早的 key。
- TTL:在设有过期时间的 key 中,淘汰剩余存活时间最短的 key。
- Random:随机淘汰。
Redis 淘汰策略(maxmemory-policy)¶
当内存使用达到 maxmemory 上限时,Redis 根据配置的策略决定如何处理新写入:
| 策略 | 说明 |
|---|---|
noeviction |
默认。拒绝写入并返回错误,读操作不受影响 |
allkeys-lru |
在所有 key 中使用近似 LRU 淘汰 |
volatile-lru |
仅在设有过期时间的 key 中使用近似 LRU 淘汰 |
allkeys-lfu |
在所有 key 中使用 LFU 淘汰(Redis 4.0+) |
volatile-lfu |
仅在设有过期时间的 key 中使用 LFU 淘汰(Redis 4.0+) |
allkeys-random |
在所有 key 中随机淘汰 |
volatile-random |
仅在设有过期时间的 key 中随机淘汰 |
volatile-ttl |
淘汰 TTL 最小(即最快过期)的 key |
近似 LRU:Redis 并非维护全局链表,而是在淘汰时随机采样
maxmemory-samples(默认 5)个 key,从中淘汰最久未使用的。样本量越大越精确,但开销也更大。
选型建议:缓存场景通常选择 allkeys-lru;若有部分数据不应被淘汰(如配置类 key),可选择 volatile-lru 并确保可淘汰的 key 都设置了过期时间。
过期删除策略¶
Redis 对已过期的 key 采用惰性删除 + 定期删除的组合策略:
- 惰性删除:访问某个 key 时才检查是否过期,过期则删除。节省 CPU 但可能导致大量过期 key 长期占用内存。
- 定期删除:每隔一段时间(默认每秒 10 次)随机抽取一批设有过期时间的 key 进行检查,删除已过期的。在 CPU 开销和内存释放之间取得平衡。
两者配合使用:定期删除兜底清理大部分过期 key,惰性删除确保被访问的过期 key 不会返回脏数据。
持久化¶
| 维度 | RDB(快照) | AOF(追加日志) |
|---|---|---|
| 原理 | 按时间间隔将内存数据快照写入磁盘(BGSAVE fork 子进程) |
记录每条写命令,追加到日志文件 |
| 数据安全 | 可能丢失最后一次快照之后的数据 | 取决于 fsync 策略:always(每条命令)/ everysec(每秒)/ no(交给 OS) |
| 恢复速度 | 快(直接加载二进制快照) | 慢(需重放所有写命令) |
| 文件体积 | 小(压缩二进制) | 大(文本格式,可通过 BGREWRITEAOF 重写压缩) |
| 适用场景 | 灾难恢复、全量备份 | 对数据安全性要求高的业务 |
生产建议:同时开启 RDB + AOF。AOF 保障数据安全,RDB 用于快速灾难恢复和从节点全量同步。Redis 4.0+ 支持混合持久化(aof-use-rdb-preamble yes),AOF 文件前半段为 RDB 快照、后半段为增量 AOF 日志,兼顾恢复速度与数据完整性。
主从同步¶
主从同步分为全量同步与增量同步:
- 全量同步:从节点首次连接或复制积压缓冲区不足以覆盖断连期间的数据时触发。主节点执行
BGSAVE生成 RDB 快照并发送给从节点(选用 RDB 是因为二进制格式体积小、加载快,而 AOF 文件大且需逐条重放,效率较低)。BGSAVE期间产生的新写命令暂存于复制缓冲区,待 RDB 传输完成后再发送给从节点。 - 增量同步:从节点短暂断连后重新连接时,主节点根据复制偏移量(offset)从复制积压缓冲区(repl_backlog)中提取断连期间的增量命令发送给从节点,无需重新生成 RDB。如果断连时间太长,则从节点会清空自己的数据,从主节点完整同步。
故障转移¶
Redis Sentinel(哨兵)负责监控主从节点的健康状态并在主节点故障时自动完成切换,流程如下:
- 主观下线:哨兵通过心跳检测发现主节点无响应,将其标记为主观下线(SDOWN)。单个哨兵的判断可能受自身网络问题影响,因此不能直接触发故障转移。
- 客观下线:该哨兵向其他哨兵发起询问,若超过半数(quorum)的哨兵都认为主节点不可达,则标记为客观下线(ODOWN)。
- 选举 Leader 哨兵:哨兵之间通过 Raft 协议选举出一个 Leader 哨兵来主导本次故障转移。
- 选择新主节点:Leader 哨兵根据从节点的优先级、复制偏移量、运行 ID 等条件,选出最优的从节点晋升为新的主节点。
- 切换与通知:其他从节点改为从新主节点同步数据;哨兵持续监控旧主节点,当其重新上线时自动降级为从节点。
集群¶
Redis Cluster 用于解决单机内存容量和吞吐量的瓶颈,通过数据分片将数据分布到多个节点上,同时提供一定程度的高可用能力。
哈希槽(Hash Slot):Redis Cluster 将整个键空间划分为 16384 个哈希槽,每个 key 通过 CRC16(key) % 16384 计算所属槽位,再路由到负责该槽的节点。用槽而不是直接哈希到节点,是为了扩容时能按槽粒度做迁移,只搬一部分数据。16384 这个数是因为节点间心跳要带槽位图,2KB 一个包,再大带宽吃不消。客户端访问到错误节点时,会收到 MOVED 重定向指令。
节点通信:集群节点之间通过 Gossip 协议进行去中心化通信,周期性交换节点状态、槽位映射和故障检测信息,无需中心协调节点。
Cluster 的代价是不支持跨槽的多 key 操作和事务,要用 hash tag {} 把相关 key 强制放同一个槽。
缓存问题与应对¶
缓存穿透¶
问题:查询一个数据库中不存在的数据,缓存无法命中,请求每次都穿透到数据库。
应对方案:
- 拦截非法请求:对于已知的异常用户、IP 做限流、黑名单处理
- 缓存空值:数据库未命中时,在 Redis 中缓存一个空值(设置较短 TTL),避免重复查库。
- 布隆过滤器:在缓存层前增加布隆过滤器,将所有合法 key 预载入过滤器(Redis 提供了 bitmap)。请求到达时先经过过滤器,不存在的 key 直接拦截。缺点则是布隆过滤器有假阳性和不支持删除操作。
缓存击穿¶
问题:某个热点 key 过期的瞬间,大量并发请求同时穿透到数据库。
应对方案:
- 互斥锁:缓存未命中时,使用分布式锁(如
SETNX)让一个请求去重建缓存,其他请求等待或返回兜底数据。 - 逻辑过期:不设置 Redis 过期时间,而是在 value 中存储逻辑过期时间。发现逻辑过期后,异步更新缓存,当前请求仍返回旧数据。
区分穿透与击穿
穿透是缓存和DB都没有,击穿是DB有。
缓存雪崩¶
问题:大量 key 在同一时刻过期(或 Redis 宕机),导致请求集中涌入数据库。
应对方案:
- 过期时间打散:在基础 TTL 上添加随机偏移,避免集中过期。
- 多级缓存:本地缓存(如 Caffeine)+ Redis,即使 Redis 不可用,本地缓存仍可兜底。
- 高可用架构:使用 Redis Sentinel 或 Cluster 模式,避免单点故障。
缓存与数据库一致性¶
缓存与数据库是两个独立的数据源,任何非原子的更新操作都可能导致二者数据不一致。以下是四种常见的更新策略:
1. Cache Aside(旁路缓存,推荐)¶
最经典、最常用的缓存策略:
- 读:先读缓存 → 命中则返回;未命中则查数据库 → 写入缓存 → 返回。
- 写:先更新数据库 → 再删除缓存(而非更新缓存)。
写操作流程:
┌────────┐ 1. UPDATE DB ┌────────┐
│ │ ───────────────→ │ DB │
│ Client │ 2. DEL Cache ├────────┤
│ │ ───────────────→ │ Cache │
└────────┘ └────────┘
为什么删除而非更新缓存?
- 避免并发写场景下的覆盖问题:线程 A 和 B 同时更新,可能导致缓存被旧值覆盖。
- 删除是幂等操作,更新则不是。
- 遵循懒加载思想:缓存只在下次读取时按需重建,避免写多读少场景下的无效计算。
如果删除缓存失败如何处理?
把删除动作交由MQ,失败重试。或者监听MySQL的binlog来同步(对业务代码零入侵)。
残留的不一致窗口:在"更新数据库"与"删除缓存"之间的极短时间内,其他请求可能读到旧缓存。对于绝大多数业务场景,这个毫秒级窗口是可接受的。
2. Read/Write Through(读写穿透)¶
由缓存层(Cache Provider)统一代理数据库读写,应用只与缓存交互:
- Read Through:缓存未命中时,由缓存层自动从数据库加载并回填。
- Write Through:写操作先写入缓存,由缓存层同步写入数据库,两者在同一操作中完成。
优点是应用逻辑简单,一致性由缓存层保证;缺点是写操作延迟较高(同步写库),且需要缓存中间件支持(如 Guava LoadingCache、NCache 等)。
3. Write Behind / Write Back(异步回写)¶
与 Write Through 类似,但缓存层对数据库的写入是异步批量的:
- 写操作只更新缓存,立即返回。
- 缓存层在后台定期将脏数据批量刷入数据库。
优点是写入性能极高(类似操作系统的 Page Cache);缺点是存在数据丢失风险(缓存宕机时未刷盘的数据会丢失),且实现复杂度高,适用于写密集且允许短暂不一致的场景(如点赞计数、PV 统计)。
策略对比¶
| 维度 | Cache Aside | Read/Write Through | Write Behind |
|---|---|---|---|
| 一致性 | 短暂不一致窗口 | 强一致(同步写) | 最终一致(异步写) |
| 写入性能 | 中(直接写库) | 低(同步写库) | 高(仅写缓存) |
| 实现复杂度 | 低 | 中(需缓存层支持) | 高(异步刷盘 + 容灾) |
| 数据安全 | 高 | 高 | 有丢失风险 |
| 适用场景 | 通用,读多写少 | 希望封装数据源访问的场景 | 写密集、容忍短暂丢失 |
进阶:延迟双删¶
在旁路缓存中,高并发场景下会有脏数据回填的问题,比如线程A先更新缓存,然后删除缓存,接着又更新数据库,如果此时线程B来读缓存,读到的就是旧数据,然后写回缓存,缓存就变脏了。为进一步缩小不一致窗口,可采用延迟双删策略:
① 删除缓存
② 更新数据库
③ 延迟 N 毫秒(略大于一次读请求的耗时)
④ 再次删除缓存 ← 清除期间可能被旧读请求回填的脏数据
延迟时间 N 的选取需要根据业务读操作的耗时来估算。该方案增加了一次缓存删除操作,但能有效应对"读请求在步骤①②之间回填旧值"的竞态场景。
延迟双删尽量不用
在绝大多数情况下,旁路缓存就已经能够满足业务需求,在更高要求时,会采用binlog、MQ重试等方式,而不是演示双删。
进阶:基于 Binlog 的异步同步¶
通过订阅 MySQL Binlog(如使用 Canal、Debezium),将数据变更事件异步推送到消费者,由消费者负责删除或更新对应的缓存:
┌────────┐ UPDATE ┌────────┐ Binlog ┌─────────┐ DEL Cache ┌─────────┐
│ Client │ ───────→ │ MySQL │ ───────→ │ Canal │ ──────────→ │ Redis │
└────────┘ └────────┘ └─────────┘ └─────────┘
优点:业务代码完全解耦,不侵入写操作逻辑;天然幂等(可重复消费)。缺点:引入额外中间件,存在秒级延迟,且需要保障消息队列的可靠性。适用于对一致性要求较高但允许秒级延迟的核心业务。
分布式锁¶
setnx¶
Redis 分布式锁的核心命令:
# 加锁:NX 保证互斥,EX 设置超时防止死锁,通过watchdog来避免锁过期
SET lock_key unique_value NX EX 30
# 解锁:必须先验证 value 是否为自己持有,再删除(Lua 脚本保证原子性)
# KEYS[1] = lock_key, ARGV[1] = unique_value
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
关键要点:
- 唯一标识:value 使用 UUID 等唯一值,防止误删其他客户端的锁。
- 自动过期:必须设置超时时间,防止持锁客户端崩溃导致死锁。
- 原子解锁:验证 + 删除必须通过 Lua 脚本保证原子性,避免 "检查后删除" 的竞态条件。
- 锁续期:对于执行时间不确定的任务,可使用看门狗机制(如 Redisson)在锁即将过期时自动续期。
- Redlock:在多个独立 Redis 实例上同时获取锁,解决单节点故障导致的锁失效问题(但在实际生产中存在争议,需权衡复杂度与一致性需求)。
分布式锁的渐进式演进:
- 基本加锁:使用
SETNX实现互斥。释放锁时需先判断持有者再删除,防止误删其他线程的锁。 - 原子解锁:判断 + 删除是两步操作,存在竞态条件 → 使用 Lua 脚本将二者合并为一个原子操作。
- 防止死锁:若持锁线程崩溃,锁将永远无法释放 → 为锁设置过期时间,确保即使线程异常退出也能自动释放。
- 锁续期:若业务执行时间超过锁的过期时间,锁会被提前释放 → 引入看门狗(Watchdog)机制,在锁即将过期时自动续期。
- 看门狗退出:若业务线程崩溃而看门狗仍在续期,锁将一直无法释放 → 将看门狗设为守护线程,使其生命周期依赖于业务线程;业务线程退出后守护线程随之终止,锁自然过期释放。
- 锁竞争优化:未获取到锁的线程如何高效等待? → Redis 通过发布/订阅(Pub/Sub)机制实现:等待线程订阅锁释放事件,持锁线程完成任务后发布消息,订阅者被唤醒并重新竞争锁,避免低效的轮询。
Redlock¶
Redlock 的设计动机是解决主从架构下锁尚未同步到从节点时主节点宕机导致锁丢失的问题。其核心思路是向多个独立的 Redis 实例同时加锁,只有过半节点加锁成功才视为获取锁成功(若要求全部节点成功,在网络分区或部分节点故障时可用性过低,因此放宽为过半机制)。然而 Redlock 需要部署多个独立 Redis 实例,运维成本较高,实际生产中通常仍使用单节点分布式锁并配合业务层幂等设计。
使用场景¶
- string:缓存、共享session
- setnx:分布式锁
- set(无需无重复):社交中的共同好友、粉丝、点赞、抽奖、白名单
- zset(有序无重复):排行榜
- 原子计数器:视频播放量、商品浏览量
设计一个滑动窗口限流器¶
Q:假设某个API接口每30s值允许500次请求
A:使用有序集合,把API作为key,时间戳作为score,uuid作为member
在线用户列表¶
Q:展示当前在线的用户。客户端每 30 秒发一次心跳,超过 90 秒没心跳视为离线。需要支持:查询当前在线总人数、判断某个指定用户是否在线。用户量百万级。
A:使用zset的90s滑动窗口,score 是最后心跳时间,member是uid。 zrangebyscore查询当前在线用户并滑动窗口剔除离线用户,scard查询当前在线总人数,zismember查询指定用户是否在线
防重复提交¶
Q:用户点"提交订单",网络卡顿时可能连点三次。要求同一用户对同一订单在 10 秒内只有第一次请求能通过,后续直接拒绝。注意:不是限流,是幂等。
A:由于提交订单时尚未创建订单号,因此服务端返回结算页面数据时,携带一个token作为唯一标识,然后把token作为key,uid作为value,并设置10s的过期时间,必须使用setnx一步完成。
秒杀库存扣减¶
Q:1000 件商品,10 万人抢。要求:不能超卖,不能少卖(失败要归还),单用户限购 1 件。说清楚你的方案在什么情况下会出问题。
A:把商品预热到Redis中,库存用string存储,然后通过decr/incr来原子扣减或归还库存(每个商品一个string可以让商品key分散到集群的各个节点上,避免热点key)。然后用户通过setnx+过期时间确保只有一个人下单。Redis扣减成功后,通过MQ异步持久化到DB中,DB最终的结果作为准。如果有用户下单未支付,则重新归还库存。 存在的风险: 1. Redis主节点宕机,从节点尚未同步到扣减的库存,导致超买,应该最终以DB的stock>=0作为最终结果,只有数据库状态确认,才返回给用户最终结果 2. 避免热点key,避免将所有商品的信息都放到同一个分片上
优惠券双重限额¶
Q:活动期间发放优惠券,两个限制同时生效:全局总量 5 万张发完即止;单用户每天最多领 3 张(自然日,零点重置)。两个计数怎么保证一起成功或一起失败?
A:先用一个集合存放5万张优惠券,然后针对每个领取优惠券的用户创建一个以uid为key的集合,集合内存放优惠券的id,并设置key的过期时间为次日0点,如果用户集合>=3,则提示不能领取,否则放入(用Lua保证原子性)。每次领取的时候通过lua保证总优惠券的扣减和用户优惠券增加的一致性。
最近浏览记录¶
Q:展示用户"最近浏览的 20 个商品",按浏览时间倒序。重复浏览同一商品只保留最新一次、且要排到最前面,不占额外位置。超过 20 个淘汰最旧的。
A:由于这里涉及到有序、唯一,因此采用有序集合,score为浏览时间的时间戳,member为商品id,如果zcard>20,则移除大于第20个时间的记录
缓存穿透防护¶
Q:商品详情走 Redis 缓存,有人拿随机不存在的商品 ID 狂刷,请求全部打穿到 DB。商品总量 5000 万。要求内存占用尽可能小,允许少量误判(把不存在的判成存在),但绝不能把存在的判成不存在。
A:可以使用布隆过滤器来做过滤,布隆过滤器只有假阳性问题,非常适合判断不存在的场景。由于布隆过滤器不支持删除,可以通过定期全量重建更新。
每小时 UV + 任意区间合并¶
Q:统计网站每小时的独立访客数(UV)。除了查单小时,还要能查"过去 6 小时"或"某天全天"的去重后总 UV —— 注意不是各小时相加。日活千万级,允许 1% 误差,要求内存尽量小。
A:由于允许少量误差,并且要对任意区间高效查询,非常适合HyperLogLog
实时热搜榜¶
Q:统计最近 10 分钟搜索次数 TOP 10 的关键词,每分钟刷新一次。关键词总量大(几十万),要求"最近 10 分钟"是滚动的,不是整点分段。这题比看起来难,注意想清楚滚动窗口怎么做。
A:核心思路是按分钟分桶 Sorted Set + ZUNIONSTORE 滚动合并。
- 写入:每来一次搜索,
ZINCRBY search:min:{minute_ts} 1 keyword,把关键词的搜索次数作为 score 累加。minute_ts是当前分钟的时间戳(如1723804800),每分钟一个桶。 - 滚动窗口合并:每分钟由定时任务(或惰性触发)执行
ZUNIONSTORE search:hot 10 search:min:{t} search:min:{t-1} ... search:min:{t-9},将最近 10 个分钟桶合并到search:hot,score 自动求和。 - 取 TOP 10:
ZREVRANGE search:hot 0 9 WITHSCORES,直接拿到搜索次数最高的 10 个关键词。 - 过期清理:对每个分钟桶设置
EXPIRE search:min:{minute_ts} 660(11 分钟),超出滚动窗口后自动回收,避免内存泄漏。
这样做到了「滚动窗口」而非整点分段:每分钟向前滑动一格,合并的始终是最近 10 个完整分钟。ZUNIONSTORE 的复杂度为 O(N·log N),N 是所有桶中不同关键词的总数,几十万量级完全可以接受。如果关键词量再大,可以在写入时只 ZINCRBY 到分钟桶、同时维护一个 Bloom Filter 或 Count-Min Sketch 做预筛,只让达到一定阈值的关键词进入 Sorted Set,减少合并开销。
共同关注 + 可能认识的人¶
Q:微博类关注关系。需要支持:A 和 B 的共同关注列表、A 关注但 B 没关注的人、给 A 推荐"你关注的人也关注了谁"。单用户关注数最多几千。说清楚这个方案的规模上限在哪。
A:可以用集合的交集、差集来实现共同关注,A关注但B没有关注。规模上限在于如果用户有很多关注者(比如明星),那么计算会非常耗时,另外,如果关注者跨分片,那么无法在Redis中完成
令牌桶限流¶
Q:接口限流:桶容量 100,每秒匀速补充 20 个令牌,请求来了取 1 个,取不到就拒绝。允许瞬时突发(桶满时能一次放行 100 个),这是它和滑动窗口的区别。注意:不能开定时任务去补令牌,必须是请求到来时惰性计算。
A:用HSET(原HMSET)来批量设置哈希,分别存放令牌数量和最后补充令牌的时间,这个命令可以确保即使在集群环境下,令牌数量也能与最后补充时间在同一个slot中。通过lua脚本判断是否需要补充令牌及扣减令牌。
延时队列 / 定时任务¶
将任务以 ZADD queue <执行时间戳> <taskId> 写入 Sorted Set,score 即为期望执行时间。消费者轮询 ZRANGEBYSCORE queue 0 <now> LIMIT 0 1 取出到期任务,取到后用 ZREM 原子删除(删除成功者获得执行权,天然防止多消费者重复消费)。相比 List 阻塞队列,优势在于支持任意延迟时长且无需额外的定时线程。
滑动窗口限流¶
每次请求到来时,以 ZADD key <当前时间戳> <唯一ID> 记录请求,然后 ZREMRANGEBYSCORE key 0 <now - windowSize> 清除窗口外的旧记录,最后 ZCARD key 获取窗口内请求数,超过阈值则拒绝。整个流程通过 Lua 脚本保证原子性。与令牌桶的区别在于:滑动窗口匀速限流,不允许突发;令牌桶允许桶满时瞬时突发。
带过期的排行榜¶
将 score 拆分为高位和低位两部分:高位存放实际分数,低位存放过期时间戳。例如 score = 实际分数 * 10^13 + (MAX_TS - 过期时间戳),这样 ZREVRANGE 既能按分数降序排列,分数相同时又能让先过期的排在前面。清理时定期扫描低位,移除已过期的成员。这种编码方式避免了为每个成员单独设置 TTL 的开销。