拾星 · 计算机与后端

Redis 缓存:为什么快,怎么用,有哪些坑

单线程与 IO 多路复用、五种数据类型、旁路缓存模式与一致性、过期与淘汰、穿透击穿雪崩,以及持久化和高可用

约 10 分钟读完 · 配套视频 1:20
每个请求都去查数据库,流量一大就扛不住
每个请求都去查数据库,流量一大就扛不住

为什么需要缓存

数据库的数据存放在磁盘上,一次查询往往需要毫秒级的时间。当流量变大时,数据库的 CPU、连接数和磁盘 IO 都会成为瓶颈。

但大多数业务有一个特点:少量数据被反复读取(热点数据)。比如首页商品、热门文章、用户的基本信息。把这些数据放到内存里,读请求先查内存,查不到再去数据库,就能大幅减轻数据库的压力,同时让响应快得多。

Redis 就是最常用的内存缓存。衡量缓存效果最重要的指标是命中率:请求中能直接在缓存里找到数据的比例。

Redis 为什么这么快

Redis 快的四个原因
Redis 快的四个原因

1. 数据在内存里

内存的访问延迟大约在百纳秒级别,而固态硬盘的随机读取大约在百微秒级别,相差上千倍。这是 Redis 快的根本原因。

2. 单线程执行命令

Redis 用一个主线程按顺序执行所有命令。这听起来像是缺点,实际上却是优点:

因为瓶颈主要在内存和网络,而不在 CPU,单线程已经足够快。

3. IO 多路复用

一个线程怎么同时服务成千上万个客户端连接?靠的是操作系统提供的 IO 多路复用(Linux 上是 epoll):线程不去逐个等待每个连接,而是让内核告诉它「哪些连接有数据可读写了」,然后只处理这些连接。

Redis 6.0 起引入了多线程来处理网络数据的读写,但命令的执行仍然是单线程,所以原子性等特性不受影响。

4. 精心设计的数据结构

每种数据类型在底层都会根据数据量自动选择更合适的编码。比如数据少时用紧凑的连续内存结构(listpack 等),节省空间;数据多时换成哈希表、跳表等,保证操作效率。

五种常用数据类型

五种常用数据类型
五种常用数据类型
类型 适合的场景 常用命令
String 缓存对象(JSON)、计数器、分布式锁 SET GET INCR SETNX
Hash 存储对象的各个字段,可单独修改某个字段 HSET HGET HGETALL
List 最新动态列表、简单队列 LPUSH RPOP LRANGE
Set 去重、标签、共同好友(交集) SADD SISMEMBER SINTER
Sorted Set 排行榜、按分数或时间排序的列表 ZADD ZINCRBY ZREVRANGE

几个典型用法:

# 计数器:文章阅读量 +1,原子操作,不会并发出错
INCR article:1001:views

# 排行榜:给张三加 10 分,再取前 10 名
ZINCRBY rank 10 "张三"
ZREVRANGE rank 0 9 WITHSCORES

# 分布式锁:键不存在时才设置,30 秒后自动过期
SET lock:order:1001 <随机唯一值> NX PX 30000

分布式锁的值要用随机的唯一值,释放锁时先确认值是自己的再删除(通常用 Lua 脚本保证原子性),避免误删别人的锁。

此外还有 Bitmap(签到、在线状态)、HyperLogLog(海量去重计数)、GEO(附近的人)、Stream(消息流)等类型,适合更特定的场景。

最常用的模式:旁路缓存(Cache-Aside)

旁路缓存的读写流程
旁路缓存的读写流程

读

  1. 先查缓存,命中就直接返回;
  2. 没命中,查数据库;
  3. 把查到的结果写入缓存,并设置过期时间,再返回。

写

  1. 先更新数据库;
  2. 再删除缓存。

用伪代码表示:

def get_user(user_id):
    key = f"user:{user_id}"
    data = redis.get(key)
    if data is not None:
        return json.loads(data)                       # 命中
    user = db.query("SELECT * FROM user WHERE id = %s", user_id)
    redis.set(key, json.dumps(user), ex=600 + random.randint(0, 120))  # 写回,过期时间加随机值
    return user

def update_user(user_id, fields):
    db.execute("UPDATE user SET ... WHERE id = %s", user_id)
    redis.delete(f"user:{user_id}")                   # 删除缓存,下次读时再加载

为什么是「删除」缓存,而不是「更新」缓存

为什么先更新数据库,再删除缓存

如果反过来先删缓存,在你更新数据库之前,另一个读请求可能发现缓存为空,去数据库读到旧值并写回缓存,于是缓存中长期留着旧数据。

先更新数据库再删缓存,出现不一致的窗口要小得多,但仍然存在极端情况。常见的补充手段:

缓存和数据库之间只能做到最终一致。如果业务要求强一致(比如账户余额),就不要依赖缓存的数据做决定。

过期与淘汰

过期键怎么删

Redis 结合两种策略:

内存满了怎么办

通过 maxmemory 设置内存上限,maxmemory-policy 决定内存满了以后怎么办:

策略 行为
noeviction(默认) 不淘汰,写入时直接报错
allkeys-lru 在所有键中淘汰最近最少使用的
allkeys-lfu 在所有键中淘汰使用频率最低的
volatile-lru / volatile-lfu 只在设置了过期时间的键中淘汰
volatile-ttl 优先淘汰快要过期的键
allkeys-random / volatile-random 随机淘汰

纯缓存场景,通常选择 allkeys-lru 或 allkeys-lfu。

缓存的三大经典问题

穿透、击穿、雪崩
穿透、击穿、雪崩

缓存穿透:查不存在的数据

现象:请求的数据在数据库里根本不存在,缓存自然也没有,于是每次请求都会打到数据库。如果有人恶意用大量不存在的 ID 来请求,数据库就会被压垮。

解决:

缓存击穿:热点 key 突然过期

现象:某个访问量极高的 key 恰好过期,瞬间大量请求同时发现缓存为空,一起涌向数据库。

解决:

互斥锁的核心逻辑:

def get_hot(key):
    data = redis.get(key)
    if data is not None:
        return data
    if redis.set(f"lock:{key}", "1", nx=True, ex=10):   # 抢到锁的请求负责重建
        try:
            data = load_from_db(key)
            redis.set(key, data, ex=600)
        finally:
            redis.delete(f"lock:{key}")
        return data
    time.sleep(0.05)                                    # 没抢到锁:稍等再查缓存
    return get_hot(key)

缓存雪崩:大面积失效

现象:大量 key 在同一时间过期(比如都在系统启动时写入、过期时间相同),或者 Redis 本身宕机,请求全部落到数据库上。

解决:

一句话区分

问题 关键词 核心解法
穿透 数据不存在 缓存空值、布隆过滤器
击穿 一个热点 key 过期 互斥锁、逻辑过期
雪崩 大量 key 同时失效或 Redis 宕机 随机过期时间、高可用、限流降级

其他需要注意的问题

持久化与高可用

持久化与高可用
持久化与高可用

持久化

方式 原理 优点 缺点
RDB 定期把内存数据生成一个快照文件 文件紧凑,恢复快 两次快照之间的数据可能丢失
AOF 把每条写命令追加到日志文件 数据更安全 文件更大,恢复较慢

AOF 的刷盘策略由 appendfsync 控制:always(每条命令都刷盘,最安全最慢)、everysec(每秒一次,默认,最多丢约 1 秒数据)、no(交给操作系统)。Redis 4.0 起还支持 RDB + AOF 混合持久化,兼顾恢复速度和数据安全。

高可用

无论怎样配置,缓存都只是加速层。数据库里永远要有一份权威数据,缓存丢了可以重建。

高频面试题速答

Q:Redis 是单线程的,为什么还这么快? 数据在内存中;单线程避免了锁和上下文切换;使用 IO 多路复用处理大量连接;底层数据结构高效。

Q:如何保证缓存和数据库的一致性? 常用旁路缓存:先更新数据库,再删除缓存,并设置过期时间兜底;要求更高时,可以延迟双删或基于 binlog 异步删除缓存。只能做到最终一致。

Q:缓存穿透、击穿、雪崩的区别? 穿透是查不存在的数据;击穿是单个热点 key 过期;雪崩是大量 key 同时失效或缓存服务宕机。

Q:RDB 和 AOF 怎么选? 能接受分钟级数据丢失、追求恢复速度,用 RDB;更看重数据安全,用 AOF(everysec);实际中常两者结合。

总结

快、用对、防坑
快、用对、防坑

参考资料

  • Redis 官方文档:Data types;Key eviction;Persistence;Replication;High availability with Redis Sentinel;Scale with Redis Cluster;Distributed Locks with Redis.
  • Facebook (2013). Scaling Memcache at Facebook. NSDI.(关于缓存失效与一致性的经典实践)
  • 黄健宏.《Redis 设计与实现》.
← 拾星首页▶ 看配套视频
← 上一章:事务与 ACID目录下一章:MySQL 的锁 →