文章

Redis基础深度解析

Redis基础深度解析

一句话概括

Redis 是一个基于内存的键值存储系统,以极致的读写性能(单机 10万+ QPS)和丰富的数据结构成为现代 Web 应用缓存与数据层核心组件,从前端开发者视角掌握它等于掌握了后端性能优化的第一把钥匙。

背景与意义

作为一名前端工程师,你大概率在项目中遇到过这样的场景:页面加载速度慢、接口响应缓慢、高并发场景下后端被打爆。你可能尝试过加 CDN、上 SSR、做懒加载,但有没有想过——后端数据层为什么不能像前端做状态管理一样,把”热点数据”存到最快的地方?

答案是 Redis。

Redis(Remote Dictionary Server)是一个开源的内存数据结构存储系统,可以作为数据库、缓存和消息中间件使用。它的诞生背景要追溯到 2009 年,意大利开发者 Salvatore Sanfilippo 为了解决传统关系型数据库在高并发场景下的性能瓶颈而开发。当年的 Web 2.0 浪潮让动态内容成为标配,每个页面请求都要查询数据库,MySQL 撑不住了。Redis 的出现提供了一种全新的思路:把数据放到内存里,用极致的读写速度换取吞吐量

当前端项目越来越复杂——SSR 服务、API 网关、实时推送、Session 共享——这些场景的背后几乎都有 Redis 的影子。更直接地说,当你用 Node.js 写后端或 BFF 层时,Redis 几乎是必选项。

概念与定义

Redis 的核心概念可以归纳为几个层次:

内存数据库: 数据主要存储在内存中(RAM),读写速度通常是磁盘数据库的 10-100 倍。一个简单的 SET/GET 命令延迟通常在微秒级别,而 MySQL 查询至少是毫秒级。

键值存储: 数据以 key-value 形式存储。key 是字符串,value 可以是多种数据类型——这正是 Redis 最强大的地方。

单线程模型: Redis 使用单线程处理命令(6.0 后支持多线程 I/O,但核心命令处理仍是单线程)。这意味着它天然避免并发竞争,但同时也意味着单个慢命令会阻塞后续所有命令。

持久化: 虽然是内存数据库,但 Redis 支持将数据持久化到磁盘,重启后可以恢复数据。这也是它和简单的 Memcached 的核心区别之一。

发布/订阅(Pub/Sub): Redis 内置消息队列功能,支持发布/订阅模式,可以用作轻量级消息中间件。

核心知识点拆解

1. 五大基础数据类型

Redis 最核心的能力是它的数据类型。每种类型都有独特的用途和操作命令,理解它们的选择策略是使用 Redis 的第一步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# String - 最基础的类型
SET user:1:name "张三"
GET user:1:name
SET article:count 1000
INCR article:count   # 原子自增 → 1001

# Hash - 适合存储对象
HSET user:1 name "张三" age 28 email "zhangsan@example.com"
HGET user:1 name
HGETALL user:1

# List - 有序列表,适合消息队列
LPUSH messages "msg1"
RPUSH messages "msg2"
LPOP messages
LLEN messages

# Set - 无序集合,适合去重/交集
SADD tags "javascript"
SADD tags "react" "node.js"
SMEMBERS tags
SISMEMBER tags "javascript"   # 判断是否存在

# Sorted Set - 有序集合,适合排行榜
ZADD ranking 100 "user1"
ZADD ranking 200 "user2"
ZINCRBY ranking 50 "user1"    # 加分
ZRANGE ranking 0 -1 WITHSCORES

Node.js 中的选择策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// String → 缓存简单值
const Redis = require('ioredis');
const redis = new Redis();

// 缓存 API 响应
async function getArticle(id) {
  const cacheKey = `article:${id}`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
  
  const data = await db.queryArticle(id);
  await redis.setex(cacheKey, 3600, JSON.stringify(data)); // 1小时过期
  return data;
}

// Hash → 缓存对象字段级访问
// 用户信息只需要更新某个字段时,Hash 比全量 String 更高效
async function getUser(id) {
  const cacheKey = `user:${id}`;
  const exists = await redis.exists(cacheKey);
  if (!exists) {
    const user = await db.queryUser(id);
    await redis.hmset(cacheKey, user);
    await redis.expire(cacheKey, 1800);
    return user;
  }
  return await redis.hgetall(cacheKey);
}

// List → 消息队列
async function sendNotification(userId, content) {
  await redis.lpush(`notify:${userId}`, JSON.stringify(content));
  await redis.ltrim(`notify:${userId}`, 0, 99); // 只保留最近100条
}

// Set → 标签系统/去重
async function addTags(articleId, tags) {
  await redis.sadd(`article:${articleId}:tags`, ...tags);
}

// Sorted Set → 排行榜/限流
async function recordView(articleId) {
  await redis.zincrby('hot_articles', 1, articleId);
}

2. 过期策略与内存淘汰

Redis 的过期机制是缓存场景的基石。理解它才能避免”缓存雪崩”和”缓存穿透”。

1
2
3
4
5
6
7
8
// 设置过期时间
await redis.set('key', 'value', 'EX', 10);     // 10秒后过期
await redis.setex('key', 10, 'value');           // 同上
await redis.expire('key', 10);                   // 给已有 key 设过期

// 过期策略:消极过期+定期过期
// - 消极:访问时检查是否过期,过期则删除
// - 定期:每100ms随机抽取一批 key 检查并删除过期 key

内存淘汰策略(maxmemory-policy):

策略说明适用场景
noeviction不淘汰,写满报错不允许丢数据
allkeys-lru最近最少使用的 key 被淘汰最常用,通用缓存
volatile-lru仅淘汰设置了过期时间的 key 中的 LRU缓存+持久化混合
allkeys-random随机淘汰访问模式均匀
volatile-ttl淘汰剩余 TTL 最短的几乎不用
volatile-lfu淘汰访问频率最低的(4.0+)热点数据场景

Node.js 的最佳实践:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 缓存穿透预防:空值缓存
async function getData(id) {
  const key = `data:${id}`;
  const cached = await redis.get(key);
  if (cached !== null) return JSON.parse(cached);
  
  // 注意:cached 可能是 'null' 字符串(我们主动缓存的空值)
  if (cached === 'null') return null;
  
  const data = await db.query(id);
  // 无论有无数据都缓存,防止缓存穿透
  await redis.setex(key, data ? 300 : 60, JSON.stringify(data));
  return data;
}

// 缓存雪崩预防:过期时间加随机偏移
function randomExpire(baseSeconds) {
  return baseSeconds + Math.floor(Math.random() * 60);
}

3. 持久化机制

Redis 的持久化有两种方式,理解它们的差异对数据安全至关重要。

RDB(快照):

  • 在指定时间间隔生成数据集的快照
  • 文件紧凑,适合备份/灾难恢复
  • 可能丢失最后一次快照后的数据
  • save 900 1 → 900秒内至少1个key变更则保存

AOF(追加文件):

  • 记录每个写操作命令到文件
  • 有三种 fsync 策略:always(每次写)、everysec(每秒,默认)、no(由OS决定)
  • 文件体积大,恢复慢,但数据更安全
  • Redis 7.0+ 默认开启 AOF+RDB 混合持久化
1
2
3
4
5
6
// 在 Node.js 中通过配置决定持久化策略
// 安装时:redis-server --appendonly yes --save "" 
// 生产推荐:AOF everysec + 定期 RDB 备份

// 或者直接用 Redis config
await redis.config('SET', 'save', '900 1 300 10 60 10000');

4. 事务与管道

Redis 事务(MULTI/EXEC)保证原子性但不保证回滚。管道(Pipeline)则是批量发送命令提升性能。

1
2
3
4
5
6
7
8
9
10
11
12
// 管道:批量操作,减少网络往返
const pipeline = redis.pipeline();
for (let i = 0; i < 1000; i++) {
  pipeline.set(`batch:${i}`, `value${i}`);
}
await pipeline.exec(); // 一次网络往返完成1000次写入

// 事务:保证原子性
const result = await redis.multi()
  .decrby('account:balance', 100)
  .incrby('account:spent', 100)
  .exec();

5. Node.js 中的连接管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// ioredis 连接最佳实践
const Redis = require('ioredis');

const redis = new Redis({
  host: 'localhost',
  port: 6379,
  password: process.env.REDIS_PASSWORD,
  retryStrategy: (times) => {
    // 重试策略:指数退避
    const delay = Math.min(times * 50, 2000);
    return delay;
  },
  maxRetriesPerRequest: 3,
  enableReadyCheck: true,
  lazyConnect: true, // 手动连接
});

redis.on('error', (err) => {
  console.error('Redis 连接异常:', err.message);
});

redis.on('ready', () => {
  console.log('Redis 已就绪');
});

// 连接池管理(ioredis 自带连接池)
// 对于高并发场景,复用连接实例即可,无需手动创建连接池

// 优雅退出
async function shutdown() {
  await redis.quit();
  process.exit(0);
}
process.on('SIGTERM', shutdown);

实战案例

场景:全栈博客系统的缓存层

假设你正在用 Next.js/Express 开发一个博客系统。我们来构建一个完整的 Redis 缓存层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// redis-cache.js — 通用缓存工具
const Redis = require('ioredis');

class CacheService {
  constructor() {
    this.redis = new Redis(process.env.REDIS_URL || {
      host: 'localhost',
      port: 6379,
    });
    this.DEFAULT_TTL = 3600; // 1小时
  }

  // 通用缓存包装器
  async getOrSet(key, fetchFn, ttl = this.DEFAULT_TTL) {
    const cached = await this.redis.get(key);
    if (cached !== null) {
      return JSON.parse(cached);
    }

    const data = await fetchFn();
    
    if (data) {
      // 随机 TTL 防止缓存雪崩
      const jitter = Math.floor(Math.random() * 60);
      await this.redis.setex(key, ttl + jitter, JSON.stringify(data));
    }
    
    return data;
  }

  // 文章缓存
  async getArticle(id) {
    return this.getOrSet(`article:${id}`, 
      () => db('articles').where({ id }).first(),
      7200 // 2小时
    );
  }

  // 文章列表缓存(带分页)
  async getArticleList(page = 1, pageSize = 20) {
    const key = `articles:list:${page}:${pageSize}`;
    return this.getOrSet(key, async () => {
      const articles = await db('articles')
        .select('id', 'title', 'summary', 'created_at')
        .orderBy('created_at', 'desc')
        .limit(pageSize)
        .offset((page - 1) * pageSize);
      const total = await db('articles').count('id as count').first();
      return { articles, total: total.count };
    }, 300); // 5分钟刷新一次
  }

  // 热点文章排行榜(带衰减)
  async recordView(articleId) {
    const today = new Date().toISOString().split('T')[0];
    const multi = this.redis.multi();
    
    // 当日 PV
    multi.incr(`pv:daily:${today}:${articleId}`);
    multi.expire(`pv:daily:${today}:${articleId}`, 86400 * 3);
    
    // 全局热度(含时间衰减)
    multi.zincrby('articles:hot', 1, articleId);
    
    await multi.exec();
  }

  // 获取热点文章 Top N
  async getHotArticles(topN = 10) {
    return this.redis.zrevrange('articles:hot', 0, topN - 1, 'WITHSCORES');
  }

  // 缓存失效
  async invalidateArticle(id) {
    await this.redis.del(`article:${id}`);
    // 同时让列表缓存过期(不用精确删除,设短 TTL 即可)
    await this.redis.incr('articles:cache:version'); // 缓存版本化
  }
}

module.exports = new CacheService();

在 Express 中的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// routes/articles.js
const cache = require('./redis-cache');

app.get('/api/articles/:id', async (req, res) => {
  try {
    const article = await cache.getArticle(req.params.id);
    if (!article) return res.status(404).json({ error: '文章不存在' });
    
    // 异步记录访问(不阻塞响应)
    cache.recordView(req.params.id).catch(console.error);
    
    res.json(article);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.get('/api/articles', async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const data = await cache.getArticleList(page);
  res.json(data);
});

app.post('/api/articles/:id/publish', async (req, res) => {
  // 更新文章后使缓存失效
  await db('articles').where({ id: req.params.id }).update(req.body);
  await cache.invalidateArticle(req.params.id);
  res.json({ success: true });
});

底层原理

单线程模型为什么这么快?

这是 Redis 被问得最多的问题。答案在于四个层面:

  1. 纯内存操作:所有数据在内存中,没有磁盘寻址开销
  2. IO 多路复用:使用 epoll/kqueue 高效处理大量并发连接
  3. 单线程避免竞争:没有锁和上下文切换的开销
  4. 数据结构优化:如 SDS(Simple Dynamic String)替代 C 字符串,减少内存分配

Redis 处理一个 GET 命令的完整路径:客户端发送命令 → epoll 事件通知 → 读取命令到输入缓冲区 → 解析命令 → 在哈希表中查找 key → 返回 value → 写入输出缓冲区 → epoll 发送给客户端。整个过程全部在微秒级完成。

哈希表渐进式 rehash

当 Redis 的哈希表负载因子超过阈值时,会进行 rehash(扩容)。但 Redis 不是一次性完成,而是采用渐进式 rehash

  1. 为 ht[1] 分配两倍于 ht[0] 的空间
  2. 维持一个 rehashidx 计数器(初始为 0)
  3. 每次 CRUD 操作时,顺便将 ht[0] 中 rehashidx 位置上的所有键值对迁移到 ht[1]
  4. rehashidx 递增
  5. 迁移完成后切换 ht[0] 和 ht[1],rehashidx 设为 -1

这保证了大 key 场景也不会卡死 Redis

高频面试题解析

Q1: Redis 为什么快?单线程如何应对高并发?

单线程指的是命令执行线程是单线程。但 Redis 通过 IO 多路复用(epoll)来处理大量并发连接。在 Linux 上,epoll 可以高效管理数十万个 socket。单线程的优势是避免了锁竞争和上下文切换的开销。对于纯内存操作,单线程的瓶颈在于 CPU 而非 IO,所以单线程完全够用。需要注意的是,Redis 6.0 引入了多线程 IO 来处理网络读写,但命令执行仍然是单线程。

Q2: 缓存穿透、缓存击穿、缓存雪崩的区别和解决方案?

缓存穿透: 查询一个根本不存在的数据,缓存和数据库都没有。大量请求直接打到数据库。

  • 解决:缓存空值;布隆过滤器(Bloom Filter)

缓存击穿: 一个热点 key 在过期瞬间,大量并发请求同时查询这个 key,直接打穿到数据库。

  • 解决:互斥锁(SETNX);永不过期+异步更新

缓存雪崩: 大量 key 在同一时间过期,导致大量请求打到数据库。

  • 解决:过期时间加随机值;多级缓存;限流降级

Q3: Redis 的 RDB 和 AOF 有什么区别?如何选择?

对比项RDBAOF
数据完整性可能丢失最后一次快照后的数据根据策略,最多丢失1秒数据
文件体积紧凑,小大(AOF重写可缩小)
恢复速度
对性能影响fork 子进程生成快照,大实例有性能影响写入频率高,对磁盘压力大

选择建议: 能接受少量数据丢失的场景用 RDB;对数据安全要求高的场景用 AOF + everysec。Redis 4.0+ 支持混合持久化(RDB + AOF),是最推荐的方式。

Q4: Redis 的淘汰策略有哪些?LRU 如何实现?

Redis 的 LRU 实现是近似 LRU,不是精确 LRU。它在淘汰时随机采样一批 key(可配置 maxmemory-samples,默认 5),淘汰其中最久未使用的。这样避免了维护全局 LRU 链表的性能开销。

Q5: 什么是 Redis 分布式锁?如何实现?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 最简单的实现:SETNX + EXPIRE
async function acquireLock(lockKey, ttl = 10) {
  const result = await redis.set(lockKey, 'locked', 'NX', 'EX', ttl);
  return result === 'OK';
}

async function releaseLock(lockKey) {
  await redis.del(lockKey);
}

// 生产级推荐:Redlock 算法(多节点)
// 可以使用 redlock 库:npm install redlock
const Redlock = require('redlock').default;
const redlock = new Redlock([redis1, redis2, redis3]); // 至少3个节点

async function doWithLock() {
  const lock = await redlock.acquire(['resource:lock'], 5000);
  try {
    // 执行临界区代码
  } finally {
    await lock.release();
  }
}

总结与扩展

Redis 远不止是一个”缓存工具”。从前端开发者的角度看,掌握 Redis 意味着你可以:

  1. 全栈能力跃升:理解缓存策略、数据一致性、性能瓶颈分析
  2. Node.js 后端优化:Session 共享、API 缓存、消息队列、实时数据
  3. 架构设计视野:分布式锁、限流、排行榜、计数器等场景的优雅实现

深入学习路径:

  • 源码阅读:ziplistskiplistSDS 的实现
  • 进阶特性:Redis Cluster、Redis Sentinel、Redis Stream
  • 替代方案:KeyDB(多线程 Redis 兼容)、Dragonfly(多线程)、Valkey(Redis 开源分支)

最后送你一句 Redis 作者的格言:”Redis is not just a cache, it’s a data structure server.” 理解数据结构的本质,比背命令重要一万倍。

本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量