Skip to main content

Redis

Redis 即 Remote Dictionary Service,远程字典服务。它是一个高性能键值(Key-Value)存储数据库,广泛应用于缓存、消息队列、实时统计等场景。与传统的关系型数据库不同,Redis 在内存中进行数据存储,提供了极高的读写性能,同时也支持多种复杂的数据结构和操作。

尽管 Redis 近期修改了开源协议,但不可否认它已经是同类中间件的标准。如果对开源有强需求,可以考虑相互兼容的其它中间件,如 KeyDB。

我们需要用 SQL 操纵关系型数据库,同样的,Redis 也有专门的命令来操纵。当然,也有 ORM 进行进一步包装。接下来我们介绍 Redis 的常用数据结构与命令,作为缓存和分布式锁的用法。

Redis 可以直接部署为集群,集群的操作与单体一致。

启动 Redis

version: '3.8'

services:
redis:
image: redis:latest
container_name: redis_container
ports:
- "6379:6379"
volumes:
- ./data:/data

启动后,使用 redis-cli 连接到 Redis 容器:

docker exec -it redis_container redis-cli

这个命令会打开一个 redis-cli 会话,你可以在其中执行 Redis 命令。如果你只是需要简单的测试,执行以下命令来确保 Redis 正常工作:

SET key "Hello Redis"
GET key

这会将键 key 设置为 "Hello Redis",然后通过 GET 命令返回该键的值,输出应该是:

"Hello Redis"

要退出 redis-cli,使用

exit

Redis 常用数据结构与命令

字符串(String)

Redis 中最基本的数据类型,它可以存储任何类型的数据,例如整数、浮点数、二进制数据等。注意,Redis 中没有数值类型,所有数值都按字符串存储。

  • SET:设置键的值。
  • GET:获取键的值。
  • DEL:删除一个或多个键。
  • INCR/DECR:自增/自减一个整数值。
SET username "john_doe"  # 设置键值
GET username # 获取键值
INCR counter # 增加counter的值

还可以批量设置,

MSET 命令允许一次设置多个键值对。它的语法如下:

MSET key1 value1 key2 value2 key3 value3

例如,批量设置三个键值对:

MSET username "john_doe" email "john@example.com" age 30

MGET 命令允许一次获取多个键的值。它的语法如下:

MGET key1 key2 key3

例如,批量获取之前设置的三个键值:

MGET username email age

如果某个键不存在,返回 nil(空值),例如:

MGET username email non_existing_key  # 返回 (john_doe, john@example.com, nil)

DEL 也支持删除多个键。其语法如下:

DEL key1 key2 key3

哈希(Hash)

Redis 的哈希是一个键值对集合,它特别适合存储对象类型的数据。每个哈希表有一个键和多个字段,每个字段都有一个值。

  • HSET:设置哈希表字段的值。
  • HGET:获取哈希表字段的值。
  • HGETALL:获取哈希表所有字段和值。
  • HDEL:删除哈希表中的字段。
HSET user:1000 name "Alice" age 25
HGET user:1000 name
HGETALL user:1000
HDEL user:1000 age

注意,hash 类型的值不能是 hash 类型。即不能嵌套 hash。但其它数据类型是可以的。

这里用 user:1000 是作为 hash 表的名称。因为不支持嵌套 hash,一种常见的 trick 是使用冒号分割索引。例如这里 user:1000 可以认为是 ID 为 1000 的用户。注意,这不是标准,具体使用哪个字符分割请参照开发规范。

列表(List)

Redis 的列表是一个简单的链表数据结构。你可以在列表的两端进行插入、删除等操作,非常适合实现消息队列。

  • LPUSH:将一个或多个元素插入到列表的左边。
  • RPUSH:将一个或多个元素插入到列表的右边。
  • LPOP:从列表的左边弹出一个元素。
  • RPOP:从列表的右边弹出一个元素。
  • LRANGE:获取列表中的一个范围。
RPUSH mylist "apple" "banana" "cherry"
LPOP mylist
LRANGE mylist 0 -1

负索引和 python 中负索引行为一致。

集合(Set)

Redis 集合是一个无序的字符串集合,集合中的每个元素都是唯一的。集合支持常见的集合操作,如并集、交集和差集等。

  • SADD:向集合添加一个或多个元素。
  • SMEMBERS:返回集合中的所有成员。
  • SISMEMBER:检查元素是否是集合的成员。
  • SPOP:随机弹出集合中的一个元素。
SADD fruits "apple" "banana" "cherry"
SMEMBERS fruits
SISMEMBER fruits "banana"
SPOP fruits

有序集合(ZSet)

有序集合是一个包含唯一元素的集合,每个元素都有一个分数(score)。根据分数的大小来为元素排序。它适用于排行榜、消息队列等场景。

  • ZADD:向有序集合添加一个或多个元素。
  • ZRANGE:返回有序集合指定区间的成员。
  • ZREM:从有序集合中移除一个或多个成员。
  • ZINCRBY:增加有序集合成员的分数。
ZADD leaderboard 100 "Alice" 150 "Bob"
ZRANGE leaderboard 0 -1 # 获取所有成员
ZINCRBY leaderboard 10 "Alice" # 更新分数
ZREM leaderboard "Bob" # 移除某个成员

Redis 作为缓存

前面我们讲命令时,没有讲命令的返回值,因为这是语言有关的。而对于命令行里,返回值是直接展示出来的。

Redis 作为缓存的思想很简单,即在从数据库取值前先从 Redis 取 (cache aside)。这里需要考虑的问题有,缓存淘汰,写一致性,雪崩风险,缓存穿透。缓存层通常部署在应用和数据库之间,减少数据库的访问压力,提高响应速度。

缓存淘汰策略

Redis 会在自己需要的内存超过设置的容量上线后自动淘汰数据,可以选择以下策略,

  • LRU(Least Recently Used):删除最久未被访问的键。
  • LFU(Least Frequently Used):删除使用频率最低的键。
  • TTL(Time to Live):设置每个缓存项的过期时间,超时自动删除。
  • 随机淘汰:随机选择一个键删除。

使用以下命令切换,

maxmemory-policy volatile-lru

此外,也可以手动设定过期时间,使用 EXPIRE 命令来为已有的键设置过期时间,单位是秒。

EXPIRE mykey 3600  # 设置 mykey 键在 3600 秒(1小时)后过期

如果你在设置键时同时指定过期时间,可以使用 SET 命令的 EX(过期时间,单位:秒)或 PX(过期时间,单位:毫秒)选项:

SET mykey "value" EX 3600  # 设置 mykey 键在 3600 秒后过期
SET mykey "value" PX 1800000 # 设置 mykey 键在 30 分钟后过期

使用 TTL 命令可以查看指定键的剩余生存时间。如果键已过期,TTL 会返回 -2;如果键没有设置过期时间,TTL 会返回 -1。

TTL mykey  # 返回 mykey 的剩余生存时间

如果你想取消已设置的过期时间,让键变成永久存在,可以使用 PERSIST 命令:

PERSIST mykey  # 取消 mykey 的过期时间

缓存一致性问题

只读场景策略很简单,但是涉及到写时,我们的缓存策略需要更复杂一些,常用的技术有以下几种,

  • Cache Aside(旁路缓存) 这种模式通常应用于数据库查询的场景。应用在查询时先尝试从缓存中获取数据,如果缓存中没有再从数据库中获取,并将数据库查询结果放入缓存。当缓存中的数据过期或被清除时,下一次查询会重新从数据库获取并更新缓存。
  • Read Through(读穿透) 在这个模式中,应用不需要显式查询数据库。如果缓存中没有数据,缓存会自动从数据库加载数据并缓存起来。缓存负责从数据库加载数据,类似于缓存和数据库之间的代理。
  • Write Through(写穿透) 写入数据时,首先会写入缓存,然后再写入数据库,确保缓存和数据库的数据一致。这种模式适用于数据更新频繁且需要保持缓存和数据库一致性的场景。
  • Write Behind(写后读) 数据首先写入缓存,然后由一个异步的过程更新数据库。这样可以减少数据库的写入压力,适用于一些对数据一致性要求不高的场景。

雪崩风险

缓存雪崩是指缓存中的大量数据失效,导致大量请求直接访问数据库,给数据库带来巨大压力,从而导致数据库宕机。为了避免缓存雪崩,可以采取以下策略:

  • 合理设置缓存过期时间:不同的数据设置不同的过期时间,避免同一时间点大量缓存失效。
  • 缓存预热:在系统启动时,可以预先加载一些常用的数据到缓存中,避免首次请求时大量查询数据库。
  • 使用多级缓存:在 Redis 之外使用其他缓存机制(如本地缓存),避免完全依赖 Redis。
  • 使用互斥锁:在某些情况下,可以通过分布式锁机制,确保在缓存失效时只会有一个请求去加载数据,防止同一个数据被多次加载。

缓存穿透

缓存穿透指的是客户端请求的数据既不在缓存中,也不在数据库中,导致每次请求都直接查询数据库。为了避免缓存穿透,可以使用以下方法:

  • 缓存空值:当某个数据查询返回为空时,可以将这个空值缓存一段时间,避免重复查询数据库。
  • 参数校验:在查询数据之前,先进行参数校验,避免无效请求触发数据库查询。
  • Bloom Filter:使用布隆过滤器在缓存中预先判断请求的数据是否存在,防止无效数据访问数据库(布隆过滤器是一种空间效率高的概率型数据结构,用于测试某个元素是否在一个集合中。它的特点是查询操作非常快速,而且内存消耗极低,但它可能会产生假阳性,即错误地认为某个元素存在,但绝对不会误判某个元素不存在)。

Redis 作为分布式锁

Redis 作为分布式锁时,可以利用 Redis 的 SET 命令结合过期时间来实现。通过设置一个唯一的锁标识(通常是一个随机值),并设置一个过期时间(防止死锁),来确保在同一时间只有一个进程能获得锁。

我们需要这些命令。

SETNX:这是 Redis 中的一种命令,它只有在键不存在时才会设置值。可以用来实现锁的获取。 EXPIRE:设置一个过期时间,防止锁因为进程崩溃等问题导致死锁。 GET:检查当前锁是否存在。

假设我们需要一个简单的分布式锁,以下是使用 Node.js 和 ioredis 库实现的分布式锁示例:

const Redis = require('ioredis');
const redis = new Redis({
host: 'localhost', // Redis服务器地址
port: 6379, // Redis端口
});

// 锁的 key 和过期时间
const LOCK_KEY = 'myLock';
const LOCK_TIMEOUT = 10; // 10秒过期时间

// 获取分布式锁
async function acquireLock() {
const lockValue = Math.random().toString(36).substr(2); // 生成唯一的锁标识
const result = await redis.set(LOCK_KEY, lockValue, 'NX', 'EX', LOCK_TIMEOUT);

if (result === 'OK') {
console.log('Lock acquired successfully');
return lockValue;
} else {
console.log('Failed to acquire lock');
await new Promise(resolve => setTimeout(resolve, 500));
return acquireLock();
}
}

// 释放分布式锁
async function releaseLock(lockValue) {
const currentValue = await redis.get(LOCK_KEY);

// 只有当当前锁的值与传入的 lockValue 一致时才能释放
if (currentValue === lockValue) {
await redis.del(LOCK_KEY);
console.log('Lock released successfully');
} else {
console.log('Failed to release lock: Lock value mismatch');
}
}

// 模拟任务执行
async function performTask() {
const lockValue = await acquireLock();

if (lockValue) {
try {
// 执行任务
console.log('Task is being executed...');
await new Promise(resolve => setTimeout(resolve, 1000)); // 模拟任务执行时间
} finally {
await releaseLock(lockValue);
}
} else {
console.log('Cannot perform task, lock acquisition failed');
}
}

// 执行任务
for(let i = 0; i < 10; i++) {
performTask();
}

Redis 会确保只有一个任务能成功获得锁并执行,其他任务会尝试重新获取锁,直到锁被释放。

这种方式可以确保在分布式环境中只有一个进程执行某个关键任务,适用于限流、任务调度等场景。