Redis 定义

Redis 全称 Remote Dictionary Server,是一个使用 ANSI C 编写的支持网络、基于内存、分布式、可选持久性的键值对数据结构服务器。Redis 具备极高的性能,支持多种核心数据类型,易于扩展,常见于现代高性能应用的构建。

极致的性能

Redis 为性能而生,它的设计特点和实现方式均以性能为第一要义。以下因素造就了 Redis 的极致性能:

  • Redis 基于 C 语言实现,底层代码高效,能够充分发挥硬件性能;
  • Redis 基于纯内存操作,所有数据存放在内存中进行读写,避免了磁盘 I/O 读写效率瓶颈;
  • 简洁的 RESP (Redis Serialization Protocol,Redis 序列化协议)协议,客户端和服务端交互简单,解析开销极低;
  • Redis 采用单线程模型,I/O 多路复用处理任务。一个线程负责执行 Redis 命令,通过 I/O 多路复用同时监听大量客户端连接。避免锁竞争、上下文切换和多线程管理,给出超高读写性能和吞吐。当然,整个 Redis 服务已使用多线程进行进一步优化,但是核心访问机制仍然没变。

基于以上设计特点和实现方式,Redis 能够提供很高的吞吐量与较低的访问延迟。实际性能取决于 Redis 版本、命令类型、数据大小、连接数、是否使用 pipeline、持久化配置、网络和硬件环境。Redis 提供了 redis-benchmark 工具用于在目标环境中测试具体工作负载;任何 QPS 数字都应连同完整的测试条件一起解读。

参考:Redis benchmark 官方文档

支持多种常规数据类型

Redis 不只是一个保存字符串的键值数据库,更准确地说,它是一个数据结构服务器。Redis 中的键都是字符串,值则可以使用不同的数据结构。开发者可以直接通过 Redis 命令操作这些结构,不需要先把它们完整读取到应用程序中再处理。

Redis 最常用的五种数据类型如下:

数据类型 数据结构与特点 常用命令 典型应用
String(字符串) 二进制安全的字节序列,可以保存文本、序列化对象、整数或二进制数据 SETGETMSETINCRDECR 缓存、计数器、分布式锁的值
List(列表) 按插入顺序排列的字符串序列,可以从列表两端添加或移除元素 LPUSHRPUSHLPOPRPOPLRANGE 栈、队列、最新记录列表
Hash(哈希) 一个键下保存多个字段和值,适合表示结构较平坦的对象 HSETHGETHMGETHGETALLHINCRBY 用户信息、商品属性、对象字段
Set(集合) 无序且元素唯一的字符串集合,支持成员判断和集合运算 SADDSREMSISMEMBERSINTERSUNION 标签、去重、共同关注关系
Sorted Set(有序集合) 元素唯一,每个元素关联一个分数,并按分数排序 ZADDZRANGEZRANKZREMZINCRBY 排行榜、优先级队列、延迟任务

同一条 Redis 命令由服务端直接对数据结构进行操作。例如,可以使用字符串的原子递增命令实现访问计数器:

SET page:view:redis-definition 0
INCR page:view:redis-definition
GET page:view:redis-definition

也可以使用哈希将一个对象的多个字段保存在同一个键下,并只读取或修改其中需要的字段:

HSET user:1001 name "Guang" role "author"
HGET user:1001 name
HSET user:1001 role "admin"

数据类型决定了一个键支持哪些命令。如果 user:1001 已经保存为 Hash,再对它执行只适用于 StringGET 命令,Redis 会返回 WRONGTYPE 错误。因此,设计键名时通常会使用 业务:实体:标识 这样的命名方式,并让同一类键始终保存相同类型的数据。

除以上五种常规数据类型外,Redis 还提供了多种面向特定场景的数据结构和操作能力:

  • BitmapBitfield:在 String 上进行位操作,适合保存签到、在线状态、权限标记或紧凑计数器;
  • 地理空间索引(Geospatial index):保存经纬度并进行距离、半径或范围查询;
  • HyperLogLog:以固定且较小的空间近似统计大量不重复元素的数量;
  • Stream:按照追加顺序保存事件,支持消费者组,适合事件流和消息处理。

这些数据类型和专用命令让 Redis 不再局限于简单的 key-value 读写。开发者可以根据数据是否需要排序、去重、按字段访问、从两端操作或进行近似统计,选择更贴近业务模型的数据结构,从而用更少的应用层代码完成高效的数据处理。

支持持久化

Redis 的主要数据保存在内存中,进程退出或服务器断电后,内存中的数据会消失。为了在重启后恢复数据,Redis 可以将内存数据写入磁盘,这种机制称为持久化。Redis 主要提供 RDB 快照和 AOF 追加日志两种持久化方式,也可以同时启用两者。

RDB 快照

RDB(Redis Database)会在指定时刻生成数据集的完整快照,并将其保存为紧凑的二进制文件。可以在配置文件中设置自动生成快照的条件:

save 60 1000

以上配置表示:如果 60 秒内至少发生 1000 次数据修改,则生成一次 RDB 快照。也可以使用 BGSAVE 命令在后台主动生成快照。

生成 RDB 时,Redis 主进程通过 fork() 创建子进程。子进程将数据写入临时文件,写入完成后再用新文件替换旧的 RDB 文件;主进程则继续处理客户端请求。这个过程利用操作系统的写时复制(Copy-on-Write)机制,使持久化期间的大部分读写服务不需要停止。

RDB 文件体积较小,便于复制、备份和灾难恢复,并且通常能够较快地加载大规模数据。它的不足是只能恢复到最近一次快照:如果 Redis 在两次快照之间异常退出,这段时间产生的数据就会丢失。数据集较大时,创建子进程和写时复制也可能带来短暂延迟及额外内存占用。

AOF 追加日志

AOF(Append Only File)会记录 Redis 收到的写命令。服务重启时,Redis 按顺序重新执行日志中的命令,以重建原来的数据。可以通过以下配置启用 AOF:

appendonly yes

写命令先追加到 AOF 缓冲区,再根据 appendfsync 策略同步到磁盘:

策略 行为 特点
always 每批写操作都执行 fsync 数据安全性最高,但磁盘同步开销最大
everysec 大约每秒执行一次 fsync 性能与安全性的常用折中,异常时可能丢失约一秒的数据
no 由操作系统决定何时写入磁盘 性能较好,但异常时可能丢失更多数据

随着写操作增加,AOF 文件会不断变大。例如,对同一个计数器执行 100 次递增,日志中会留下 100 条命令,但恢复当前值并不一定需要保留全部历史命令。Redis 可以自动触发 AOF 重写,也可以使用 BGREWRITEAOF 手动触发,在后台生成能够恢复当前数据集的精简文件。Redis 7.0 以后采用多部分 AOF,由一个基础文件、一个或多个增量文件以及记录它们关系的 manifest 文件组成。

AOF 通常比 RDB 保存得更及时,能够减少异常停机造成的数据丢失;代价是文件一般更大,恢复时需要重放命令,写入性能也会受到刷盘策略的影响。

持久化方式的选择

Redis 支持以下四种使用方式:

  • 只使用 RDB:适合能够容忍最近几分钟数据丢失,并重视文件紧凑、备份方便和恢复速度的场景;
  • 只使用 AOF:适合更关注数据完整性,希望尽量减少异常停机数据损失的场景;
  • 同时使用 RDB 和 AOF:结合快照备份与追加日志的优点;两者都开启时,Redis 重启会优先使用通常更完整的 AOF 恢复数据;
  • 关闭持久化:适合数据可以随时重新生成的纯缓存场景。

持久化不等同于备份,也不能保证数据绝对不会丢失。磁盘损坏、文件误删或整台服务器故障仍可能同时破坏 Redis 和本地持久化文件。对于重要数据,还需要定期复制 RDB 或 AOF 备份,保存到其他机器或独立存储,并验证备份能够实际恢复。

参考:Redis persistence 官方文档

原子操作与支持脚本

原子操作是指一个操作在执行期间不会被其他操作插入。对于其他客户端来说,只能看到操作执行前或执行后的状态,不能访问执行过程中的中间状态。Redis 的命令执行层会按顺序处理命令,因此一条 Redis 命令通常就是一个原子操作。

例如,多个客户端同时使用 INCR 更新同一个计数器时,每次递增都会完整执行,不会因为并发读写丢失计数:

INCR article:view:count

但是,多条独立命令组合起来并不天然具备原子性。以下逻辑先读取库存,再由应用程序计算并写回库存:

GET product:1001:stock
SET product:1001:stock 99

如果两个客户端同时读到相同的库存,它们可能写回相同的结果,导致一次扣减被覆盖。这类简单操作应优先使用 Redis 已经提供的原子命令,例如直接执行 DECR product:1001:stock

Redis 事务

需要连续执行多条命令时,可以使用 MULTIEXEC 创建 Redis 事务:

MULTI
INCR article:view:count
SADD article:readers user:1001
EXEC

MULTI 之后的命令不会立即执行,而是进入当前连接的事务队列。调用 EXEC 后,Redis 按顺序连续执行队列中的命令,其他客户端的命令不会插入事务中间。如果不再执行该事务,可以在 EXEC 前使用 DISCARD 清空队列。

Redis 还提供 WATCH 实现乐观锁。客户端可以先监视相关键,再读取数据并计算新的值:

WATCH product:1001:stock
GET product:1001:stock
MULTI
SET product:1001:stock 99
EXEC

如果被监视的键在 WATCHEXEC 之间被其他操作修改,EXEC 不会执行事务,并返回空结果。客户端需要重新读取数据并重试。

Redis 事务保证命令顺序执行且不被其他客户端插入,但不支持错误回滚。如果事务中的某条命令在 EXEC 阶段发生运行时错误,此前已经成功执行的命令不会被撤销。因此,事务中的命令和参数仍然需要由应用程序提前校验。

Lua 脚本

当业务逻辑包含读取、条件判断和多个写操作时,可以把逻辑写成 Lua 脚本,通过 EVAL 交给 Redis 执行。下面的脚本只在库存大于零时扣减库存:

local stock = tonumber(redis.call('GET', KEYS[1]) or '0')

if stock <= 0 then
    return 0
end

redis.call('DECR', KEYS[1])
return 1

调用 EVAL 时,需要依次提供脚本、键的数量、键以及普通参数:

EVAL "<以上脚本>" 1 product:1001:stock

脚本使用 KEYS 接收键名,使用 ARGV 接收其他参数。redis.call() 可以在脚本中执行 Redis 命令,遇到运行时错误时终止脚本;redis.pcall() 则把错误作为结果返回,使脚本能够自行处理。

Redis 保证 Lua 脚本的原子执行。脚本运行期间,其他客户端的普通命令不能插入,也看不到脚本执行到一半的状态。因此,上例中的“读取库存、检查库存、扣减库存”构成一个完整的原子操作,同时避免了多次网络往返。

脚本的原子执行同样不表示错误回滚。如果脚本先成功修改了数据,随后因 redis.call() 报错而终止,已经完成的写入不会被自动撤销。脚本应先完成参数、类型和业务条件检查,再集中执行写操作,避免留下不符合预期的部分结果。

经常执行相同脚本时,可以先使用 SCRIPT LOAD 将脚本加入缓存,再通过 EVALSHA 使用脚本的 SHA1 摘要调用它。脚本缓存并不属于持久化数据,可能在 Redis 重启、故障切换或执行 SCRIPT FLUSH 后消失;客户端收到 NOSCRIPT 错误时,需要重新加载脚本。

Redis 7.0 开始提供 Redis Functions。函数通过 FUNCTION LOAD 注册为具名的函数库,再通过 FCALL 调用。与由客户端维护、缓存在服务器中的 EVAL 脚本相比,Functions 由 Redis 作为数据库的一部分管理,并可随数据一起持久化和复制,更适合长期维护和复用服务端逻辑。

脚本和函数执行时会阻塞其他客户端,应保持逻辑简单且执行时间短,避免在其中扫描大量数据或运行耗时循环。在 Redis Cluster 中,一个脚本或事务涉及的多个键通常还需要位于同一个哈希槽,可以使用相同的哈希标签设计相关键名。

参考:Redis TransactionsScripting with LuaRedis Functions官方文档。

支持发布/订阅模式与消息队列

Redis 不仅能够保存数据,还提供了多种消息传递能力。根据消息是否需要广播、持久保存、消费确认和失败重试,可以选择 Pub/Sub、基于 List 的简单队列或 Stream 消息流。

能力 Pub/Sub List 队列 Stream
消费方式 广播给所有在线订阅者 一条消息由一个消费者取走 直接读取,或由消费者组分配
消息保存 不保存 保存在列表中,弹出后删除 保留在消息流中,直到删除或裁剪
离线重放 不支持 需要应用自行实现 支持按消息 ID 重新读取
消费确认 不支持 需要应用自行实现 支持 XACK 显式确认
典型交付语义 至多一次 取决于队列设计 消费者组通常为至少一次
典型场景 实时通知、状态广播 简单后台任务 可靠任务、事件流、操作记录

发布/订阅模式

Redis 通过 PUBLISHSUBSCRIBEUNSUBSCRIBE 实现发布/订阅模式。发布者把消息发送到频道,订阅者监听自己感兴趣的频道,双方不需要知道彼此的身份。

一个客户端可以订阅文章事件频道:

SUBSCRIBE article:events

另一个客户端向该频道发布消息:

PUBLISH article:events "article published"

Redis 会把消息推送给当前订阅 article:events 的所有客户端。还可以使用 PSUBSCRIBE 按模式订阅多个频道:

PSUBSCRIBE article:*

Pub/Sub 采用至多一次的消息交付语义。Redis 把消息发送给订阅者后不会再次投递,也不会把消息保存下来。如果订阅者暂时离线、网络中断或处理消息时失败,这条消息会永久丢失,并且无法查询历史消息。

因此,Pub/Sub 适合聊天室广播、在线通知、缓存失效和配置更新等实时性强、能够容忍少量消息丢失的场景,不适合订单处理、支付或必须完成的后台任务。Redis 7.0 还提供 SPUBLISHSSUBSCRIBE,用于在 Redis Cluster 中实现分片 Pub/Sub,减少消息在整个集群中的传播开销。

使用 List 实现简单队列

Redis 的 List 支持从两端添加和移除元素,可以用来构造先进先出的任务队列。生产者使用 RPUSH 将任务加入队尾,消费者使用 BLPOP 从队首等待并取出任务:

RPUSH jobs "send-email:1001"
BLPOP jobs 0

BLPOP 的超时时间设置为 0 时,如果队列为空,连接会持续阻塞,直到新任务到达。多个消费者同时监听一个列表时,每条任务只会由其中一个消费者取得,可以组成简单的后台 worker 队列。

但是,BLPOP 返回任务时会立即将它从列表删除。如果 worker 取得任务后、处理完成前崩溃,这条任务就会丢失。需要提高可靠性时,可以用 BLMOVE 将任务原子地移动到处理中列表:

BLMOVE jobs jobs:processing LEFT RIGHT 0

worker 处理成功后,再将任务从处理中列表删除,作为消费确认:

LREM jobs:processing 1 "send-email:1001"

如果 worker 崩溃,任务仍然保留在 jobs:processing,应用程序可以检测超时任务并重新入队。使用这种方式时,应为每个任务提供唯一 ID,避免内容相同的任务在确认时被错误删除。超时检测、重试次数和死信任务仍需要应用程序自行实现。

使用 Stream 实现可靠消息流

Redis 5.0 引入了 Stream 数据类型。Stream 类似只追加的日志,每条消息由若干字段和值组成,并具有唯一且有序的消息 ID:

XADD jobs * type send-email userId 1001

* 表示由 Redis 自动生成消息 ID。与 Pub/Sub 不同,Stream 中的消息会保留下来,可以通过消息 ID 查询、顺序读取或重新处理。Stream 与其他 Redis 数据结构一样,可以进入 RDB、AOF 和复制链路;它的实际持久性仍取决于 Redis 的持久化和复制配置。

Stream 还支持消费者组。首先创建名为 workers 的消费者组:

XGROUP CREATE jobs workers 0 MKSTREAM

组内的 worker 可以阻塞等待尚未分配的新消息:

XREADGROUP GROUP workers worker-1 COUNT 10 BLOCK 5000 STREAMS jobs >

同一消费者组中的多个 worker 会分担消息。worker 完成业务处理后,需要使用消息 ID 显式确认:

XACK jobs workers <message-id>

已投递但尚未确认的消息会记录在 Pending Entries List(PEL)中。可以使用 XPENDING 检查这些消息,并通过 XCLAIMXAUTOCLAIM 将长时间未确认的消息转交给其他消费者,从而恢复崩溃 worker 遗留的任务。

消费者组通常提供至少一次交付:如果 worker 在业务处理完成后、执行 XACK 前崩溃,消息可能被重新投递。因此,消费端仍需要根据业务唯一 ID 设计幂等处理,避免重复发送邮件、重复扣款或重复写入数据。

同一个 Stream 可以建立多个消费者组。不同消费者组分别读取完整的消息流,适合多个业务系统独立处理相同事件;同一消费者组内的多个消费者则共同分担消息,适合横向扩展处理能力。为了避免 Stream 无限增长,还需要根据保留时间或最大长度定期使用 XTRIM,或在写入时设置裁剪策略。

简单来说,只关心在线实时广播时使用 Pub/Sub;任务简单且可以自行实现恢复机制时使用 List;需要消息留存、消费确认、失败恢复、消费者组或历史重放时使用 Stream

参考:Redis Pub/SubRedis ListsRedis Streams官方文档。

易于扩展

Redis 可以从单节点逐步扩展为包含主节点、副本、Sentinel 或多个分片的分布式系统。不同机制解决的问题并不相同:副本主要扩展读取能力并提供数据冗余,Sentinel 为非分片部署提供自动故障转移,Redis Cluster 则用于横向扩展数据容量和读写吞吐量。

部署方式 主要能力 主要限制
单节点 部署简单、访问延迟低 容量、吞吐和可用性受单机限制
主节点与副本 读扩展、数据冗余 写入和总数据容量仍受单个主节点限制
Sentinel 监控和自动故障转移 不进行数据分片,仍由一个主节点承担写入
Redis Cluster 数据分片、读写扩展、分片故障转移 客户端、键设计和集群运维更复杂

使用副本扩展读取能力

Redis 支持一个主节点连接多个副本。应用程序通常把写操作发送到主节点,主节点再将数据变更复制给副本。副本可以分担读请求,也可以在主节点故障后被提升为新的主节点。

Redis 复制默认是异步的:主节点通常不会等待副本确认写入便向客户端返回结果。这样可以保持较低的写入延迟,但副本可能暂时落后于主节点。从副本读取数据时,应用程序必须能够接受短时间的旧数据。

增加副本能够提高读取吞吐量并增强数据冗余,却不能突破单个主节点的写入吞吐和内存容量限制。需要扩展写入或保存超过单机容量的数据时,应使用 Redis Cluster 进行分片。

使用 Redis Cluster 横向扩展

Redis Cluster 将整个键空间划分为固定的 16384 个哈希槽。每个键根据以下规则映射到一个槽,再由负责该槽的主节点保存:

HASH_SLOT = CRC16(key) mod 16384

例如,一个包含三个主节点的集群可以按以下方式分配哈希槽:

Master A:     0 - 5460
Master B:  5461 - 10922
Master C: 10923 - 16383

支持 Redis Cluster 的客户端会缓存哈希槽与节点的映射,正常情况下直接把命令发送给正确的节点。增加主节点后,可以把部分哈希槽及其数据迁移到新节点,从而扩展集群的内存容量、网络处理能力和读写吞吐量。

Redis Cluster 的分片模型比较直接,但横向扩展并不是没有成本:

  • 增加或移除节点时需要重新分配哈希槽并迁移数据;
  • 客户端必须支持集群拓扑,并正确处理 MOVEDASK 重定向;
  • 单个热键始终由一个主节点处理,增加节点不会自动拆分一个键的负载;
  • 事务、Lua 脚本和集合运算等多键操作通常要求相关键位于同一个哈希槽;
  • Redis Cluster 只支持数据库 0,不能使用 SELECT 切换逻辑数据库。

需要让多个相关键位于同一个哈希槽时,可以使用相同的哈希标签。Redis 只使用大括号中的内容计算这些键的槽位:

{user:1001}:profile
{user:1001}:messages

哈希标签可以支持相关键之间的原子操作,但也可能把过多数据集中到同一个槽中。设计键名时需要在数据局部性和负载均衡之间做出取舍。

使用 Sentinel 实现高可用

不需要数据分片时,可以使用 Redis Sentinel 管理一个主节点及其副本。Sentinel 提供以下能力:

  • 持续监控主节点和副本的运行状态;
  • 多个 Sentinel 共同判断主节点是否不可用;
  • 主节点故障后,选择一个副本并将其提升为新主节点;
  • 让其他副本改为复制新的主节点;
  • 向客户端提供当前主节点的地址并发送故障通知。

Sentinel 本身也是一个分布式系统。为了避免 Sentinel 成为新的单点故障,Redis 官方建议至少部署三个 Sentinel,并将它们放在故障相互独立的机器或可用区中。只有达到配置的故障判断 quorum,并获得多数 Sentinel 授权后,系统才会执行故障转移。

Sentinel 能够减少人工切换时间,但不能做到完全无中断。故障检测、选举、副本提升和客户端重新连接都需要时间,应用程序仍应实现连接重试,并通过支持 Sentinel 的客户端查询当前主节点。

Redis Cluster 的故障转移

Redis Cluster 中的每个主节点可以配置一个或多个副本。集群节点通过 Cluster Bus 交换状态,并共同判断节点是否发生故障。如果一个主节点被多数主节点确认不可用,它的副本可以参加选举,获胜的副本成为新主节点并接管原来的哈希槽。

Redis Cluster 能够在以下条件下继续提供服务:多数主节点仍然可以相互通信,并且每个失效的主节点至少有一个可达副本。如果某个主节点及其所有副本同时失效,对应的哈希槽就无法继续服务;发生较大的网络分区时,少数侧最终会停止接受请求,以限制脑裂造成的数据分叉。

Redis 高可用性的边界

Redis 的自动故障转移机制成熟,适合缓存、会话、排行榜、限流、实时状态和其他可重建或允许极小数据丢失窗口的数据。但 Redis 的高可用设计以低延迟和高性能为优先,不能直接等同于强一致数据库。

由于复制是异步的,可能出现以下过程:

客户端写入主节点
    -> 主节点返回 OK
    -> 主节点在写入到达副本前故障
    -> 未收到该写入的副本被提升为新主节点

在这种情况下,已经向客户端确认的写入仍可能丢失。客户端可以使用 WAIT 等待指定数量的副本确认此前的写入,从而显著缩小数据丢失窗口:

SET order:1001 paid
WAIT 1 1000

但是,WAIT 不会把 Redis 变成强一致的共识系统,也不能完全消除故障转移时的数据丢失。持久化、复制和异机备份同样解决不同问题:副本可能同步误删除,RDB 或 AOF 可能随服务器磁盘一起损坏,因此重要数据仍需要独立备份和恢复演练。

综合来看,Redis 具备清晰且高性能的横向扩展路径,也能够通过 Sentinel 或 Cluster 实现实用的自动故障转移;它的代价是分片键设计、集群客户端和运维复杂度,并且只提供最终一致、尽力保留写入的高可用保证。对于余额、账本和订单最终状态等不能接受已确认写入丢失的数据,通常应使用具备更强事务与持久性保证的数据库作为事实来源,将 Redis 用作缓存、派生状态或加速层。

参考:Redis replicationRedis SentinelScale with Redis ClusterRedis Cluster specification官方文档。