首页 / 文章

Redis缓存基础:模式、陷阱与面试要点

Node.js中的Redis缓存:旁路缓存、TTL设置、挤兑防护与驱逐策略,以及相关系统设计面试问题。

发布者:REACTAPP.TOP阅读约 11 分钟2516 词

来源: Redis Isn’t Just a Cache: The Concepts Every Backend Developer Should Know

既然已经有数据库,为什么还需要缓存?

想象一下你应用程序中的一个接口是这样的:

GET /products/123

客户端访问该路径,服务器便会去查询 MongoDB:

API → MongoDB → Product

数据库返回产品记录,一切正常。

现在想象10,000人几乎同时请求同一款产品的情况。

你将不得不重复执行完全相同的查询流程数千次:

Request
   ↓
Database
   ↓
Same Product

数据库不断为相同的问题重复做同样的工作。

如果能把答案存储起来不是更好吗?

这其实就是缓存的作用。

什么是缓存?

缓存是一种更快速的存储层,用于暂时保存频繁被请求的数据,这样应用程序就不必每次都去访问速度较慢的主数据存储。

不同于常规流程:

Request → Database → Response

我们引入了一条快捷路径:

Request
   ↓
Cache
   ↓
Hit? ── Yes → Response
   │
   No
   ↓
Database
   ↓
Cache
   ↓
Response

目的并非完全摒弃数据库。

而是为了减少那些无需重复执行的数据库操作。

缓存命中与缓存未命中

这两个概念经常出现,因此值得提前了解。

缓存命中

当所需数据已经存在于缓存中时,就会发生这种情况。

Request
  ↓
Redis
  ↓
Found ✅
  ↓
Response

缓存未命中

当缓存中没有对应数据时,就会发生这种情况。

Request
  ↓
Redis
  ↓
Not Found ❌
  ↓
Database
  ↓
Store in Redis
  ↓
Response

一个实用的指标是缓存命中率:

Cache Hit Ratio =
Cache Hits / Total Cache Requests

较高的比率通常意味着有更多请求直接从缓存中获取响应,而非来自底层数据库。

不过,数值越大并不一定代表成功。如果缓存中充斥着过时或价值较低的条目,即使命中率很高也并无实际意义。

那 Redis 在这里起什么作用呢?

Redis 是一种常用于缓存的内存数据存储工具,但其功能远不止于此。

它提供了多种数据结构,包括:

  • 字符串
  • 哈希表
  • 列表
  • 集合
  • 有序集合
  • 流
  • JSON
  • 以及其他专用类型

对于简单的缓存应用,可以这样理解它:

Key                 Value
────────────────────────────
product:123         Product data
user:42             User data

由于 Redis 将热数据存储在内存中,从缓存中读取数据的速度可以非常快。

Node.js 应用中的 Redis

要使用 node-redis 客户端,可通过以下命令进行安装:

npm install redis

建立基本连接只需几行代码即可:

import { createClient } from "redis";
const redis = createClient();
await redis.connect();

之后,存储和获取数据就变得很简单了:

await redis.set("user:42", "Hamza");
const user = await redis.get("user:42");

Redis 的官方文档目前推荐 node-redis 作为 Node.js 项目的客户端。

不过,仅掌握 SET 和 GET 命令是远远不够的。

真正需要解答的问题是:

如何围绕 Redis 设计应用程序?

最重要的模式:缓存旁路

如果说每位后端开发者都应该首先掌握的一种模式,那就是缓存旁路模式。

在这种模式下,应用程序本身负责管理缓存。

Request
   ↓
Check Redis
   ↓
 ┌───────────────┐
 │ Cache Hit?    │
 └───────────────┘
    ↓ Yes    ↓ No
 Response   Database
              ↓
           Redis
              ↓
           Response

代码中的简化版本如下:

const cached = await redis.get(key);
if (cached) return JSON.parse(cached);const product = await Product.findById(id);await redis.set(key, JSON.stringify(product), {
    EX: 300
});

EX: 300选项会为缓存条目设置300秒的TTL值。

可以说,缓存旁路模式是Redis中使用最广泛的缓存模式。

TTL:缓存并非永恒存在

假设你已经缓存了以下内容:

product:123

如果之后产品的价格发生了变动,应该怎么办?

如果没有设置过期时间,用户可能会无限期地看到旧价格。

这就是TTL,即生存时间存在的理由。

Product stored
     ↓
TTL = 300 seconds
     ↓
Expires
     ↓
Next request → Database → Fresh cache

TTL限制了过时数据可以被提供的最长时间。

即便如此,也请避免设置过长 的TTL值。

正确的数值完全取决于您的应用程序能够承受多大的数据过时程度。

缓存失效:那个著名的难题

考虑以下场景:

Database:
price = ₹799
Redis:
price = ₹799

随后数据库被更新:

Database:
price = ₹899

但Redis仍然保留着旧数据:

price = ₹799

此时,您的应用程序会根据读取数据的来源提供不一致的数据。

一种常见的解决方案是在底层数据发生变化时立即清除缓存:

await Product.findByIdAndUpdate(id, update);
await redis.del(`product:${id}`);

之后,新的请求将沿着这条路径处理:

Redis → Miss
   ↓
Database → ₹899
   ↓
Redis → Store fresh value

对于基于缓存旁路机制构建的系统,每次写入时直接删除受影响的缓存条目,通常比试图让缓存始终与数据库保持同步更为简单且安全。

你需要了解的三个缓存问题

这些都是面试中很好的讨论话题。

1. 缓存雪崩

想象一个被频繁使用的键即将过期:

product:123 → EXPIRED

紧接着,就有1,000个请求同时到来。

这些请求全部无法从缓存中获取数据。

它们全都不得不直接查询数据库。

1000 requests
      ↓
1000 DB queries 💥

缓存本应用来减轻数据库的负载,但过期事件本身却引发了负载激增。

防止这种情况的常见方法包括:

  • 采用锁定机制或单次处理请求的方式,确保只有一個请求更新缓存,其他请求则等待
  • 设置不同的过期时间,避免所有键同时失效
  • 采用概率性提前刷新机制,在键实际过期前稍作更新
  • 提前预热频繁被请求的数据
  • Redis的官方文档明确介绍了针对热门键的防挤兑策略。

    2. 缓存穿透

    当客户端不断请求根本不存在的数据时会发生什么?

    GET /users/999999
    

    Redis会返回未找到。

    数据库则会返回“未找到”。

    下一次相同的请求会重复完全相同的流程。

    恶意用户或出现故障的客户端可能利用这一点,通过无意义的查询不断冲击数据库。

    一种有效的应对措施是负缓存——即缓存“某数据不存在”这一事实:

    user:999999 → NOT_FOUND
    TTL → short
    

    通过这种方式,对于同一条不存在的记录的重复查询可以直接在 Redis 中得到响应,而无需每次都访问数据库。

    3. 缓存雪崩

    现在想象有一大批缓存键在几乎同一时间到期。

    Key A → expired
    Key B → expired
    Key C → expired
    Key D → expired
           ↓
    Massive DB traffic
    

    这种情况被称为缓存雪崩。

    降低这种风险的一种典型方法是为大量键避免设置相同的过期时间——例如,为每个 TTL 添加一些随机抖动。

    缓存淘汰:当 Redis 内存耗尽时会发生什么?

    Redis 的内存并非无限。

    一旦内存使用量达到设定的上限,Redis就会根据您配置的驱逐规则开始删除键。Redis自带了几种这样的驱逐模式:可以在整个键空间中或仅在设置了过期时间的键中删除最久未使用的条目,在上述两种范围中分别删除使用频率最低的条目,根据键的过期时间接近程度来决定删除对象,或者一旦内存已满就完全拒绝新的写入操作。

    LRU 的本质含义是:

    删除那些长时间未被访问的条目。

    LFU 的本质含义是:

    删除那些不被频繁访问的条目。

    哪种模式最合适取决于您的具体访问模式。

    Redis 不仅仅是缓存

    在面试中经常会出现这个问题。

    由于 Redis 提供了多种内置数据结构,它的用途远不止缓存:

    Strings       → Caching
    Hashes        → Objects / sessions
    Lists         → Queues
    Sets          → Unique collections
    Sorted Sets   → Leaderboards
    Streams       → Event processing
    

    因此,以下说法:

    “Redis 是一种缓存数据库”

    只能说明部分事实。

    更准确的描述是:

    Redis 是一种内存中的数据结构存储系统,通常用于缓存,同时也能处理会话、队列、排行榜、速率限制、发布/订阅消息等功能。

    Redis 的官方文档中介绍了这些数据结构以及它所支持的其他专用类型。

    那 Redis 的持久化功能呢?

    既然 Redis 存在于内存中,重启后会丢失所有数据吗?

    不一定。

    Redis提供了多种持久化策略:

    • RDB — 定期对数据集进行时间点快照
    • AOF — 记录每一项写入操作的日志
    • RDB + AOF — 两种方式结合使用
    • 不进行任何持久化

    如果Redis仅作为缓存使用,重启后丢失内容通常没有问题,因为应用程序可以从主数据库重新加载数据。

    但如果Redis存储的是本身就非常重要的数据,那么持久化设置就会变得极为重要。

    常见的面试陷阱

    “为什么不把所有数据都存放在Redis中?”

    因为与传统的持久化存储相比,Redis的内存是一种有限且成本较高的资源。

    这意味着不应随意进行缓存操作。

    适合缓存的候选对象具有以下特点:

    • 经常被请求
    • 获取或计算成本较高
    • 允许一定程度的数据过时
    • 其内存占用值得,因为可以将其保存在附近以便快速访问

    应将 Redis 视为位于系统旁的性能提升层,而非主数据库的完全替代品。

    本地缓存与分布式缓存

    想象一个在三台独立服务器上运行的 Node.js 应用程序:

    Server A → Cache A
    Server B → Cache B
    Server C → Cache C
    

    每台服务器最终都会维护自己的独立缓存。

    用户可能首先访问服务器 A 并获得缓存命中,而之后访问服务器 B 时却无法找到相同数据。

    引入共享的 Redis 实例则改变了这一状况:

                 ┌→ Server A ─┐
    Client ──────┼→ Server B ─┼→ Redis
                 └→ Server C ─┘
    

    现在每个应用程序实例都可以从同一个共享缓存中获取数据。

    这种共享状态的优势正是 Redis 在应用程序进行水平扩展时表现尤为出色的重要原因。

    高级面试场景

    假设面试官提出这样的问题:

    “为高流量产品 API 设计缓存策略。”

    较弱的回答仅会停留在:

    “我会使用 Redis。”

    更完善的回答则会逐步阐述推理过程:

    Request
                         ↓
                      Redis
                    ↙       ↘
                 Hit         Miss
                  ↓            ↓
               Response      Database
                                ↓
                             Redis
                                ↓
                             Response
    

    接着会详细说明每一项设计决策:

    1. 缓存模式

    选择旁路缓存模式。

    2. TTL

    根据产品数据所能容忍的过时程度来设置。

    3. 数据失效

    每当底层数据更新时,立即删除对应的键。

    4. 防止挤兑

    采用加锁、请求合并或提前刷新等技术。

    5. 驱逐机制

    为该工作负载选择合适的LRU或LFU策略。

    6. 键设计

    product:{id}
    user:{id}
    

    7. 监控

    需关注以下指标:

    • 命中率
    • 未命中率
    • 延迟
    • 内存使用情况
    • 驱逐次数
    • 高频使用的键
    • Redis错误

    通过这种分层分析得出的系统设计方案,远比单纯声称“Redis速度很快”更有说服力。

    快速面试回顾

    为何要使用缓存?

    它能降低延迟,同时避免主数据存储反复处理那些成本高昂的读取操作。

    为何特别选择Redis?

    它提供快速的内存访问、过期时间支持、多种数据结构,以及为分布式系统设计的功能。

    “缓存旁路”是什么意思?

    应用程序首先查询缓存;如果未找到,则向数据库请求数据,然后将结果写回缓存中。

    TTL是什么意思?

    它指的是缓存条目在自动过期之前的有效时长。

    “缓存失效”是什么意思?

    它指的是在源数据发生变化时清除或刷新缓存条目。

    什么是“缓存挤兑”?

    指大量请求同时未能从缓存中获取数据,进而全部同时向数据库发起请求的情况。

    LRU与LFU有何区别?

    LRU会移除最近最少使用的条目。LFU则移除整体使用频率最低的条目。

    每个查询都值得缓存吗?

    并非如此。决策时应考虑数据被访问的频率、获取数据的成本、可接受的过期时间以及可用的内存资源。

    值得牢记的思维模型

    不必一开始就死记Redis命令,而应先掌握其底层架构:

                  ┌─────────────┐
    Request ─────→│    Redis    │
                  └──────┬──────┘
                         │
                  Cache Hit?
                   ↙         ↘
                 YES           NO
                  ↓             ↓
              Response       Database
                                ↓
                             Redis
                                ↓
                             Response
    

    接着思考以下四个问题:

    什么内容应该放入缓存?

    它应在缓存中保留多久?

    何时需要使缓存失效?

    在负载过重时缓存出现故障或过期会怎样?

    通过思考这些问题,才能将浅层的缓存知识提升为符合实际生产环境的需求的思维方式。

    总结

    缓存并不仅仅是:

    "在MongoDB前面加上Redis。"

    一个完善的缓存策略本质上关乎权衡取舍。

    你需要在以下方面进行交换:

    用内存换取速度

    有时还需:

    用数据新鲜度换取性能

    Redis提供了相关机制,但如何运用这些机制则由你的应用逻辑决定。

    如果正在准备面试,切勿只满足于回答:

    "Redis是一种内存数据库。"

    应准备好详细阐述:

    缓存旁路、TTL设置、数据失效机制、命中率、数据驱逐策略、雪崩效应、穿透攻击、分布式缓存以及一致性问题。

    一旦你理解了这些概念之间的关联,就不再只是单纯了解Redis而已。

    你现在明白高流量系统如何在保持快速响应的同时保护其数据库。