# Redis 应用指南

# 一、Redis 简介

Redis 是速度非常快的非关系型(NoSQL)内存键值数据库,可以存储键和五种不同类型的值之间的映射。

键的类型只能为字符串,值支持的五种类型数据类型为:字符串、列表、集合、有序集合、散列表。

# Redis 使用场景

  • 缓存 - 将热点数据放到内存中,设置内存的最大使用量以及过期淘汰策略来保证缓存的命中率。
  • 计数器 - Redis 这种内存数据库能支持计数器频繁的读写操作。
  • 应用限流 - 限制一个网站访问流量。
  • 消息队列 - 使用 List 数据类型,它是双向链表。
  • 查找表 - 使用 HASH 数据类型。
  • 交集运算 - 使用 SET 类型,例如求两个用户的共同好友。
  • 排行榜 - 使用 ZSET 数据类型。
  • 分布式 Session - 多个应用服务器的 Session 都存储到 Redis 中来保证 Session 的一致性。
  • 分布式锁 - 除了可以使用 SETNX 实现分布式锁之外,还可以使用官方提供的 RedLock 分布式锁实现。

# Redis 的优势

  • 性能极高 – Redis 能读的速度是 110000 次/s,写的速度是 81000 次/s。
  • 丰富的数据类型 - 支持字符串、列表、集合、有序集合、散列表。
  • 原子 - Redis 的所有操作都是原子性的。单个操作是原子性的。多个操作也支持事务,即原子性,通过 MULTI 和 EXEC 指令包起来。
  • 持久化 - Redis 支持数据的持久化。可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。
  • 备份 - Redis 支持数据的备份,即 master-slave 模式的数据备份。
  • 丰富的特性 - Redis 还支持发布订阅, 通知, key 过期等等特性。

# Redis 与 Memcached

Redis 与 Memcached 因为都可以用于缓存,所以常常被拿来做比较,二者主要有以下区别:

数据类型

  • Memcached 仅支持字符串类型;
  • 而 Redis 支持五种不同种类的数据类型,使得它可以更灵活地解决问题。

数据持久化

  • Memcached 不支持持久化;
  • Redis 支持两种持久化策略:RDB 快照和 AOF 日志。

分布式

  • Memcached 不支持分布式,只能通过在客户端使用像一致性哈希这样的分布式算法来实现分布式存储,这种方式在存储和查询时都需要先在客户端计算一次数据所在的节点。
  • Redis Cluster 实现了分布式的支持。

内存管理机制

  • Memcached 将内存分割成特定长度的块来存储数据,以完全解决内存碎片的问题,但是这种方式会使得内存的利用率不高,例如块的大小为 128 bytes,只存储 100 bytes 的数据,那么剩下的 28 bytes 就浪费掉了。
  • 在 Redis 中,并不是所有数据都一直存储在内存中,可以将一些很久没用的 value 交换到磁盘。而 Memcached 的数据则会一直在内存中。

# Redis 为什么快

Redis 单机 QPS 能达到 100000。

Redis 是单线程模型(Redis 6.0 已经支持多线程模型),为什么还能有这么高的并发?

  • Redis 完全基于内存操作。
  • Redis 数据结构简单。
  • 采用单线程,避免线程上下文切换和竞争。
  • 使用 I/O 多路复用模型(非阻塞 I/O)。

I/O 多路复用

I/O 多路复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。

# 二、Redis 数据类型

Redis 基本数据类型:STRING、HASH、LIST、SET、ZSET

Redis 高级数据类型:BitMap、HyperLogLog、GEO

💡 更详细的特性及原理说明请参考:Redis 数据类型和应用

# 三、Redis 内存淘汰

# 内存淘汰要点

  • 最大缓存 - Redis 允许通过 maxmemory 参数来设置内存最大值。

  • 失效时间 - 作为一种定期清理无效数据的重要机制,在 Redis 提供的诸多命令中,EXPIREEXPIREATPEXPIREPEXPIREAT 以及 SETEXPSETEX 均可以用来设置一条键值对的失效时间。而一条键值对一旦被关联了失效时间就会在到期后自动删除(或者说变得无法访问更为准确)。

  • 淘汰策略 - 随着不断的向 Redis 中保存数据,当内存剩余空间无法满足添加的数据时,Redis 内就会施行数据淘汰策略,清除一部分内容然后保证新的数据可以保存到内存中。内存淘汰机制是为了更好的使用内存,用一定得 miss 来换取内存的利用率,保证 Redis 缓存中保存的都是热点数据。

  • 非精准的 LRU - 实际上 Redis 实现的 LRU 并不是可靠的 LRU,也就是名义上我们使用 LRU 算法淘汰键,但是实际上被淘汰的键并不一定是真正的最久没用的。

# 主键过期时间

Redis 可以为每个键设置过期时间,当键过期时,会自动删除该键。

对于散列表这种容器,只能为整个键设置过期时间(整个散列表),而不能为键里面的单个元素设置过期时间。

可以使用 EXPIREEXPIREAT 来为 key 设置过期时间。

🔔 注意:当 EXPIRE 的时间如果设置的是负数,EXPIREAT 设置的时间戳是过期时间,将直接删除 key。

示例:

redis> SET mykey "Hello"
"OK"
redis> EXPIRE mykey 10
(integer) 1
redis> TTL mykey
(integer) 10
redis> SET mykey "Hello World"
"OK"
redis> TTL mykey
(integer) -1
redis>

# 淘汰策略

内存淘汰只是 Redis 提供的一个功能,为了更好地实现这个功能,必须为不同的应用场景提供不同的策略,内存淘汰策略讲的是为实现内存淘汰我们具体怎么做,要解决的问题包括淘汰键空间如何选择?在键空间中淘汰键如何选择?

Redis 提供了下面几种内存淘汰策略供用户选:

  • noeviction - 当内存使用达到阈值的时候,所有引起申请内存的命令会报错。这是 Redis 默认的策略。
  • allkeys-lru - 在主键空间中,优先移除最近未使用的 key。
  • allkeys-random - 在主键空间中,随机移除某个 key。
  • volatile-lru - 在设置了过期时间的键空间中,优先移除最近未使用的 key。
  • volatile-random - 在设置了过期时间的键空间中,随机移除某个 key。
  • volatile-ttl - 在设置了过期时间的键空间中,具有更早过期时间的 key 优先移除。

# 如何选择淘汰策略

  • 如果数据呈现幂等分布(存在热点数据,部分数据访问频率高,部分数据访问频率低),则使用 allkeys-lru
  • 如果数据呈现平等分布(数据访问频率大致相同),则使用 allkeys-random
  • 如果希望使用不同的 TTL 值向 Redis 提示哪些 key 更适合被淘汰,请使用 volatile-ttl
  • volatile-lruvolatile-random 适合既应用于缓存和又应用于持久化存储的场景,然而我们也可以通过使用两个 Redis 实例来达到相同的效果。
  • 将 key 设置过期时间实际上会消耗更多的内存,因此建议使用 allkeys-lru 策略从而更有效率的使用内存

# 内部实现

Redis 删除失效主键的方法主要有两种:

  • 消极方法(passive way),在主键被访问时如果发现它已经失效,那么就删除它。
  • 主动方法(active way),周期性地从设置了失效时间的主键中选择一部分失效的主键删除。
  • 主动删除:当前已用内存超过 maxmemory 限定时,触发主动清理策略,该策略由启动参数的配置决定主键具体的失效时间全部都维护在 expires 这个字典表中。

# 四、Redis 持久化

Redis 是内存型数据库,为了保证数据在宕机后不会丢失,需要将内存中的数据持久化到硬盘上。

Redis 支持两种持久化方式:RDB 和 AOF。

  • RDB - RDB 即快照方式,它将某个时间点的所有 Redis 数据保存到一个经过压缩的二进制文件(RDB 文件)中
  • AOF - AOF(Append Only File) 是以文本日志形式将所有写命令追加到 AOF 文件的末尾,以此来记录数据的变化。当服务器重启的时候会重新载入和执行这些命令来恢复原始的数据。AOF 适合作为 热备

💡 更详细的特性及原理说明请参考:Redis 持久化

# 五、Redis 事件

Redis 服务器是一个事件驱动程序,服务器需要处理两类事件:

  • 文件事件(file event) - Redis 服务器通过套接字(Socket)与客户端或者其它服务器进行通信,文件事件就是对套接字操作的抽象。服务器与客户端(或其他的服务器)的通信会产生文件事件,而服务器通过监听并处理这些事件来完成一系列网络通信操作。
  • 时间事件(time event) - Redis 服务器有一些操作需要在给定的时间点执行,时间事件是对这类定时操作的抽象。

# 文件事件

Redis 基于 Reactor 模式开发了自己的网络时间处理器。

  • Redis 文件事件处理器使用 I/O 多路复用程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
  • 当被监听的套接字准备好执行连接应答、读取、写入、关闭操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器实现了高性能的网络通信模型。

文件事件处理器有四个组成部分:套接字、I/O 多路复用程序、文件事件分派器、事件处理器。

img

# 时间事件

时间事件又分为:

  • 定时事件:是让一段程序在指定的时间之内执行一次;
  • 周期性事件:是让一段程序每隔指定时间就执行一次。

Redis 将所有时间事件都放在一个无序链表中,每当时间事件执行器运行时,通过遍历整个链表查找出已到达的时间事件,并调用响应的事件处理器。

# 事件的调度与执行

服务器需要不断监听文件事件的套接字才能得到待处理的文件事件,但是不能一直监听,否则时间事件无法在规定的时间内执行,因此监听时间应该根据距离现在最近的时间事件来决定。

事件调度与执行由 aeProcessEvents 函数负责,伪代码如下:

def aeProcessEvents():

    ## 获取到达时间离当前时间最接近的时间事件
    time_event = aeSearchNearestTimer()

    ## 计算最接近的时间事件距离到达还有多少毫秒
    remaind_ms = time_event.when - unix_ts_now()

    ## 如果事件已到达,那么 remaind_ms 的值可能为负数,将它设为 0
    if remaind_ms < 0:
        remaind_ms = 0

    ## 根据 remaind_ms 的值,创建 timeval
    timeval = create_timeval_with_ms(remaind_ms)

    ## 阻塞并等待文件事件产生,最大阻塞时间由传入的 timeval 决定
    aeApiPoll(timeval)

    ## 处理所有已产生的文件事件
    procesFileEvents()

    ## 处理所有已到达的时间事件
    processTimeEvents()

将 aeProcessEvents 函数置于一个循环里面,加上初始化和清理函数,就构成了 Redis 服务器的主函数,伪代码如下:

def main():

    ## 初始化服务器
    init_server()

    ## 一直处理事件,直到服务器关闭为止
    while server_is_not_shutdown():
        aeProcessEvents()

    ## 服务器关闭,执行清理操作
    clean_server()

从事件处理的角度来看,服务器运行流程如下:

# 六、Redis 事务

Redis 提供的不是严格的事务,Redis 只保证串行执行命令,并且能保证全部执行,但是执行命令失败时并不会回滚,而是会继续执行下去

MULTIEXECDISCARDWATCH 是 Redis 事务相关的命令。

事务可以一次执行多个命令, 并且有以下两个重要的保证:

  • 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
  • 事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。

# MULTI

MULTI (opens new window) 命令用于开启一个事务,它总是返回 OK 。

MULTI 执行之后, 客户端可以继续向服务器发送任意多条命令, 这些命令不会立即被执行, 而是被放到一个队列中, 当 EXEC 命令被调用时, 所有队列中的命令才会被执行。

以下是一个事务例子, 它原子地增加了 foo 和 bar 两个键的值:

> MULTI
OK
> INCR foo
QUEUED
> INCR bar
QUEUED
> EXEC
1) (integer) 1
2) (integer) 1

# EXEC

EXEC (opens new window) 命令负责触发并执行事务中的所有命令。

  • 如果客户端在使用 MULTI 开启了一个事务之后,却因为断线而没有成功执行 EXEC ,那么事务中的所有命令都不会被执行。
  • 另一方面,如果客户端成功在开启事务之后执行 EXEC ,那么事务中的所有命令都会被执行。

# DISCARD

当执行 DISCARD (opens new window) 命令时, 事务会被放弃, 事务队列会被清空, 并且客户端会从事务状态中退出。

示例:

> SET foo 1
OK
> MULTI
OK
> INCR foo
QUEUED
> DISCARD
OK
> GET foo
"1"

# WATCH

WATCH (opens new window) 命令可以为 Redis 事务提供 check-and-set (CAS)行为。

被 WATCH 的键会被监视,并会发觉这些键是否被改动过了。 如果有至少一个被监视的键在 EXEC 执行之前被修改了, 那么整个事务都会被取消, EXEC 返回 nil-reply 来表示事务已经失败。

WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC

使用上面的代码, 如果在 WATCH 执行之后, EXEC 执行之前, 有其他客户端修改了 mykey 的值, 那么当前客户端的事务就会失败。 程序需要做的, 就是不断重试这个操作, 直到没有发生碰撞为止。

这种形式的锁被称作乐观锁, 它是一种非常强大的锁机制。 并且因为大多数情况下, 不同的客户端会访问不同的键, 碰撞的情况一般都很少, 所以通常并不需要进行重试。

WATCH 使得 EXEC 命令需要有条件地执行:事务只能在所有被监视键都没有被修改的前提下执行,如果这个前提不能满足的话,事务就不会被执行。

WATCH 命令可以被调用多次。对键的监视从 WATCH 执行之后开始生效,直到调用 EXEC 为止。

用户还可以在单个 WATCH 命令中监视任意多个键,例如:

redis> WATCH key1 key2 key3
OK

# 取消 WATCH 的场景

当 EXEC 被调用时, 不管事务是否成功执行, 对所有键的监视都会被取消。

另外, 当客户端断开连接时, 该客户端对键的监视也会被取消。

使用无参数的 UNWATCH 命令可以手动取消对所有键的监视。 对于一些需要改动多个键的事务, 有时候程序需要同时对多个键进行加锁, 然后检查这些键的当前值是否符合程序的要求。 当值达不到要求时, 就可以使用 UNWATCH 命令来取消目前对键的监视, 中途放弃这个事务, 并等待事务的下次尝试。

# 使用 WATCH 创建原子操作

WATCH 可以用于创建 Redis 没有内置的原子操作。

举个例子,以下代码实现了原创的 ZPOP 命令,它可以原子地弹出有序集合中分值(score)最小的元素:

WATCH zset
element = ZRANGE zset 0 0
MULTI
ZREM zset element
EXEC

# Rollback

Redis 不支持回滚。Redis 不支持回滚的理由:

  • Redis 命令只会因为错误的语法而失败,或是命令用在了错误类型的键上面。
  • 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。

# 七、Redis 管道

Redis 是一种基于 C/S 模型以及请求/响应协议的 TCP 服务。Redis 支持管道技术。管道技术允许请求以异步方式发送,即旧请求的应答还未返回的情况下,允许发送新请求。这种方式可以大大提高传输效率。

在需要批量执行 Redis 命令时,如果一条一条执行,显然很低效。为了减少通信次数并降低延迟,可以使用 Redis 管道功能。Redis 的管道(pipeline)功能没有提供命令行支持,但是在各种语言版本的客户端中都有相应的实现。

以 Jedis 为例:

Pipeline pipe = conn.pipelined();
pipe.multi();
pipe.hset("login:", token, user);
pipe.zadd("recent:", timestamp, token);
if (item != null) {
    pipe.zadd("viewed:" + token, timestamp, item);
    pipe.zremrangeByRank("viewed:" + token, 0, -26);
    pipe.zincrby("viewed:", -1, item);
}
pipe.exec();

🔔 注意:使用管道发送命令时,Redis Server 会将部分请求放到缓存队列中(占用内存),执行完毕后一次性发送结果。如果需要发送大量的命令,会占用大量的内存,因此应该按照合理数量分批次的处理。

# 八、Redis 发布与订阅

Redis 提供了 5 个发布与订阅命令:

命令 描述
SUBSCRIBE SUBSCRIBE channel [channel ...]—订阅指定频道。
UNSUBSCRIBE UNSUBSCRIBE [channel [channel ...]]—取消订阅指定频道。
PUBLISH PUBLISH channel message—发送信息到指定的频道。
PSUBSCRIBE PSUBSCRIBE pattern [pattern ...]—订阅符合指定模式的频道。
PUNSUBSCRIBE PUNSUBSCRIBE [pattern [pattern ...]]—取消订阅符合指定模式的频道。

订阅者订阅了频道之后,发布者向频道发送字符串消息会被所有订阅者接收到。

某个客户端使用 SUBSCRIBE 订阅一个频道,其它客户端可以使用 PUBLISH 向这个频道发送消息。

发布与订阅模式和观察者模式有以下不同:

  • 观察者模式中,观察者和主题都知道对方的存在;而在发布与订阅模式中,发布者与订阅者不知道对方的存在,它们之间通过频道进行通信。
  • 观察者模式是同步的,当事件触发时,主题会去调用观察者的方法;而发布与订阅模式是异步的;

分割线以下为 Redis 集群功能特性

# 九、Redis 复制

关系型数据库通常会使用一个主服务器向多个从服务器发送更新,并使用从服务器来处理所有读请求,Redis 也采用了同样的方式来实现复制特性。

# 旧版复制

Redis 2.8 版本以前的复制功能基于 SYNC 命令实现。

Redis 的复制功能分为同步(sync)和命令传播(command propagate)两个操作:

  • 同步(sync) - 用于将从服务器的数据库状态更新至主服务器当前的数据库状态。
  • 命令传播(command propagate) - 当主服务器的数据库状态被修改,导致主从数据库状态不一致时,让主从服务器的数据库重新回到一致状态。

这种方式存在缺陷:不能高效处理断线重连后的复制情况。

# 新版复制

Redis 2.8 版本以后的复制功能基于 PSYNC 命令实现。PSYNC 命令具有完整重同步和部分重同步两种模式。

  • 完整重同步(full resychronization) - 用于初次复制。执行步骤与 SYNC 命令基本一致。
  • 部分重同步(partial resychronization) - 用于断线后重复制。如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只需接收并执行这些写命令,即可将主从服务器的数据库状态保持一致。

# 部分重同步

部分重同步有三个组成部分:

  • 主从服务器的复制偏移量(replication offset)
  • 主服务器的复制积压缓冲区(replication backlog)
  • 服务器的运行 ID

# PSYNC 命令

从服务器向要复制的主服务器发送 PSYNC <runid> <offset> 命令

  • 假如主从服务器的 master run id 相同,并且指定的偏移量(offset)在内存缓冲区中还有效,复制就会从上次中断的点开始继续。
  • 如果其中一个条件不满足,就会进行完全重新同步。

# 心跳检测

主服务器通过向从服务传播命令来更新从服务器状态,保持主从数据一致。

从服务器通过向主服务器发送命令 REPLCONF ACK <replication_offset> 来进行心跳检测,以及命令丢失检测。

💡 更详细的特性及原理说明请参考:Redis 复制

# 十、Redis 哨兵

Sentinel(哨兵)可以监听主服务器,并在主服务器进入下线状态时,自动从从服务器中选举出新的主服务器。

💡 更详细的特性及原理说明请参考:Redis 哨兵

# 十一、Redis 集群

分片是将数据划分为多个部分的方法,可以将数据存储到多台机器里面,也可以从多台机器里面获取数据,这种方法在解决某些问题时可以获得线性级别的性能提升。

假设有 4 个 Reids 实例 R0,R1,R2,R3,还有很多表示用户的键 user:1,user:2,... 等等,有不同的方式来选择一个指定的键存储在哪个实例中。最简单的方式是范围分片,例如用户 id 从 0~1000 的存储到实例 R0 中,用户 id 从 1001~2000 的存储到实例 R1 中,等等。但是这样需要维护一张映射范围表,维护操作代价很高。还有一种方式是哈希分片,使用 CRC32 哈希函数将键转换为一个数字,再对实例数量求模就能知道应该存储的实例。

主要有三种分片方式:

  • 客户端分片:客户端使用一致性哈希等算法决定键应当分布到哪个节点。
  • 代理分片:将客户端请求发送到代理上,由代理转发请求到正确的节点上。
  • 服务器分片:Redis Cluster(官方的 Redis 集群解决方案)。

# 十二、Redis Client

Redis 社区中有多种编程语言的客户端,可以在这里查找合适的客户端:Redis 官方罗列的客户端清单 (opens new window)

redis 官方推荐的 Java Redis Client:

# 扩展阅读

💡 Redis 常用于分布式缓存,有关缓存的特性和原理请参考:缓存基本原理 (opens new window)

# 参考资料