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 不只是一个保存字符串的键值数据库,更准确地说,它是一个数据结构服务器。Redis 中的键都是字符串,值则可以使用不同的数据结构。开发者可以直接通过 Redis 命令操作这些结构,不需要先把它们完整读取到应用程序中再处理。
Redis 最常用的五种数据类型如下:
| 数据类型 | 数据结构与特点 | 常用命令 | 典型应用 |
|---|---|---|---|
String(字符串) |
二进制安全的字节序列,可以保存文本、序列化对象、整数或二进制数据 | SET、GET、MSET、INCR、DECR |
缓存、计数器、分布式锁的值 |
List(列表) |
按插入顺序排列的字符串序列,可以从列表两端添加或移除元素 | LPUSH、RPUSH、LPOP、RPOP、LRANGE |
栈、队列、最新记录列表 |
Hash(哈希) |
一个键下保存多个字段和值,适合表示结构较平坦的对象 | HSET、HGET、HMGET、HGETALL、HINCRBY |
用户信息、商品属性、对象字段 |
Set(集合) |
无序且元素唯一的字符串集合,支持成员判断和集合运算 | SADD、SREM、SISMEMBER、SINTER、SUNION |
标签、去重、共同关注关系 |
Sorted Set(有序集合) |
元素唯一,每个元素关联一个分数,并按分数排序 | ZADD、ZRANGE、ZRANK、ZREM、ZINCRBY |
排行榜、优先级队列、延迟任务 |
同一条 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,再对它执行只适用于 String 的 GET 命令,Redis 会返回 WRONGTYPE 错误。因此,设计键名时通常会使用 业务:实体:标识 这样的命名方式,并让同一类键始终保存相同类型的数据。
除以上五种常规数据类型外,Redis 还提供了多种面向特定场景的数据结构和操作能力:
Bitmap和Bitfield:在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 的命令执行层会按顺序处理命令,因此一条 Redis 命令通常就是一个原子操作。
例如,多个客户端同时使用 INCR 更新同一个计数器时,每次递增都会完整执行,不会因为并发读写丢失计数:
INCR article:view:count
但是,多条独立命令组合起来并不天然具备原子性。以下逻辑先读取库存,再由应用程序计算并写回库存:
GET product:1001:stock
SET product:1001:stock 99
如果两个客户端同时读到相同的库存,它们可能写回相同的结果,导致一次扣减被覆盖。这类简单操作应优先使用 Redis 已经提供的原子命令,例如直接执行 DECR product:1001:stock。
Redis 事务
需要连续执行多条命令时,可以使用 MULTI 和 EXEC 创建 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
如果被监视的键在 WATCH 和 EXEC 之间被其他操作修改,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 Transactions、Scripting with Lua和Redis Functions官方文档。
支持发布/订阅模式与消息队列
Redis 不仅能够保存数据,还提供了多种消息传递能力。根据消息是否需要广播、持久保存、消费确认和失败重试,可以选择 Pub/Sub、基于 List 的简单队列或 Stream 消息流。
| 能力 | Pub/Sub | List 队列 |
Stream |
|---|---|---|---|
| 消费方式 | 广播给所有在线订阅者 | 一条消息由一个消费者取走 | 直接读取,或由消费者组分配 |
| 消息保存 | 不保存 | 保存在列表中,弹出后删除 | 保留在消息流中,直到删除或裁剪 |
| 离线重放 | 不支持 | 需要应用自行实现 | 支持按消息 ID 重新读取 |
| 消费确认 | 不支持 | 需要应用自行实现 | 支持 XACK 显式确认 |
| 典型交付语义 | 至多一次 | 取决于队列设计 | 消费者组通常为至少一次 |
| 典型场景 | 实时通知、状态广播 | 简单后台任务 | 可靠任务、事件流、操作记录 |
发布/订阅模式
Redis 通过 PUBLISH、SUBSCRIBE 和 UNSUBSCRIBE 实现发布/订阅模式。发布者把消息发送到频道,订阅者监听自己感兴趣的频道,双方不需要知道彼此的身份。
一个客户端可以订阅文章事件频道:
SUBSCRIBE article:events
另一个客户端向该频道发布消息:
PUBLISH article:events "article published"
Redis 会把消息推送给当前订阅 article:events 的所有客户端。还可以使用 PSUBSCRIBE 按模式订阅多个频道:
PSUBSCRIBE article:*
Pub/Sub 采用至多一次的消息交付语义。Redis 把消息发送给订阅者后不会再次投递,也不会把消息保存下来。如果订阅者暂时离线、网络中断或处理消息时失败,这条消息会永久丢失,并且无法查询历史消息。
因此,Pub/Sub 适合聊天室广播、在线通知、缓存失效和配置更新等实时性强、能够容忍少量消息丢失的场景,不适合订单处理、支付或必须完成的后台任务。Redis 7.0 还提供 SPUBLISH 和 SSUBSCRIBE,用于在 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 检查这些消息,并通过 XCLAIM 或 XAUTOCLAIM 将长时间未确认的消息转交给其他消费者,从而恢复崩溃 worker 遗留的任务。
消费者组通常提供至少一次交付:如果 worker 在业务处理完成后、执行 XACK 前崩溃,消息可能被重新投递。因此,消费端仍需要根据业务唯一 ID 设计幂等处理,避免重复发送邮件、重复扣款或重复写入数据。
同一个 Stream 可以建立多个消费者组。不同消费者组分别读取完整的消息流,适合多个业务系统独立处理相同事件;同一消费者组内的多个消费者则共同分担消息,适合横向扩展处理能力。为了避免 Stream 无限增长,还需要根据保留时间或最大长度定期使用 XTRIM,或在写入时设置裁剪策略。
简单来说,只关心在线实时广播时使用 Pub/Sub;任务简单且可以自行实现恢复机制时使用 List;需要消息留存、消费确认、失败恢复、消费者组或历史重放时使用 Stream。
参考:Redis Pub/Sub、Redis Lists和Redis 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 的分片模型比较直接,但横向扩展并不是没有成本:
- 增加或移除节点时需要重新分配哈希槽并迁移数据;
- 客户端必须支持集群拓扑,并正确处理
MOVED和ASK重定向; - 单个热键始终由一个主节点处理,增加节点不会自动拆分一个键的负载;
- 事务、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 replication、Redis Sentinel、Scale with Redis Cluster和Redis Cluster specification官方文档。