Redis核心技术与应用 —— 从入门到企业级实战

Redis核心技术与应用

从入门到企业级实战 作者:brian wang (wangxydlut@gmail.com)


📖 目录

  1. Redis简介与安装 --- 走进高性能键值存储的世界 2. 基本数据结构:字符串与哈希 --- 最常用的数据存储模型 3. 基本数据结构:列表、集合与有序集合 --- 灵活的数据组织方式 4. 高级数据结构:位图、HyperLogLog与GEO --- Redis的独门绝技 5. Redis持久化:RDB与AOF --- 数据不丢失的保障 6. Redis高可用:主从复制与哨兵 --- 构建可靠的Redis服务 7. Redis集群:分布式架构 --- 水平扩展的艺术 8. Redis缓存设计与过期策略 --- 缓存为王 9. Redis Lua脚本与事务 --- 原子操作的利器 10. Redis Pub/Sub与消息队列 --- 实时消息通信 11. Redis性能调优与监控 --- 让Redis飞起来 12. Redis实战应用案例 --- 从理论到工程实践

> 本文由 AI 生成,涵盖完整目录与核心知识点。

📄 正文

第1章

Redis简介与安装

chap:introduction

走进高性能键值存储的世界

Redis的历史与发展

sec:redis-history

Redis的诞生

Redis(Remote Dictionary Server)是由意大利开发者Salvatore Sanfilippo(网名antirez)在2009年创建的开源内存数据库项目。antirez原本是嵌入式系统工程师和Lua爱好者,他开发Redis的初衷是为了解决LLOOGG(一款实时Web日志分析服务)的性能问题。当时现有的解决方案(如MySQL + Memcached)无法满足他对实时统计和分析的低延迟需求,于是他决定从零构建一个内存键值存储系统。

> 💡 Redis名称的由来:虽然现在普遍将其理解为Remote Dictionary Server的缩写,但最初antirez只是为自己的项目取了一个简短易记的名字。后来社区才将其释义为Remote Dictionary Server。

2009年5月,Redis发布了第一个版本(0.01),当时仅支持字符串类型和基本的SET/GET操作。虽然功能简陋,但其极致的性能表现迅速吸引了大量开发者关注。VMware公司在2010年开始赞助Redis的开发,使antirez能够全职投入Redis的研发工作。

> 定义: Redis是一种基于内存的键值(Key-Value)存储系统,支持持久化、多种数据结构、主从复制、哨兵模式及集群模式,被广泛用于缓存、消息队列、实时计数、排行榜等场景。

版本演进

Redis经历了多个重要版本的迭代,每个版本都带来了革命性的新特性:

  • Redis 1.0(2009年) :初始版本,支持字符串类型和基本持久化。 - Redis 2.0(2010年) :引入哈希、列表、集合等丰富的数据结构,并增加了事务支持。 - Redis 2.4(2011年) :引入虚拟内存功能(后废弃),改进复制协议。 - Redis 2.6(2012年) :引入Lua脚本支持(EVAL命令),大幅提升原子操作的灵活性。 - Redis 2.8(2013年) :引入哨兵(Sentinel)模式,提供高可用方案。 - Redis 3.0(2015年) :引入Redis Cluster,实现原生的分布式分片能力。 - Redis 4.0(2017年) :引入模块系统、延迟删除机制(UNLINK/FLUSHDB ASYNC)。 - Redis 5.0(2018年) :引入Stream数据类型,改进RDB格式。 - Redis 6.0(2020年) :引入多线程I/O、RESP3协议、ACL访问控制、SSL支持。 - Redis 7.0(2022年) :引入函数(Functions)、改进内存效率、AOF优化。 - Redis 7.2(2023年) :引入向量搜索(Vector Search)、增强的自动故障转移。 - Redis 7.4(2024年):性能提升,新的内存管理策略。

> 💡 学习Redis时,建议关注Redis 6.0及之后版本引入的特性,因为多线程I/O和ACL等能力对生产环境部署影响深远。但如果维护的是老版本系统,也需要了解早期版本的限制。

Redis核心特性

sec:redis-features

内存存储与持久化

Redis将所有数据存储在内存中,因此读写速度极快,官方数据显示读写性能可达10万+ QPS(Queries Per Second)。但纯内存存储意味着一旦进程退出,数据就会丢失。为解决这一问题,Redis提供了两种持久化机制:

  • RDB(Redis Database) :按指定的时间间隔生成数据集的时间点快照(snapshot),适合备份和灾难恢复。 - AOF(Append Only File):记录每个写操作的日志,重启时通过重新执行这些命令来恢复数据,数据安全级别更高。

两种持久化方式可以同时使用,也可以根据需求单独开启。RDB适合大规模数据恢复,AOF适合精细化的数据保护(通常配置为每秒同步一次)。

丰富的数据结构

与传统键值存储不同,Redis不仅支持字符串类型,还提供多种高级数据结构,使得开发者可以用Redis直接解决复杂的业务问题:

  • 字符串(String) :最基础的类型,可以存储字符串、整数、浮点数、二进制数据等。 - 哈希(Hash) :类似于编程语言中的映射表(Map/Dictionary),适合存储对象。 - 列表(List) :基于链表的字符串集合,支持从两端插入和弹出。 - 集合(Set) :无序且元素唯一的字符串集合,支持交集、并集、差集运算。 - 有序集合(ZSet) :带分数的有序集合,每个元素关联一个浮点数分数,按分数排序。 - 位图(Bitmap) :基于字符串的位操作数据结构,适合大数据量的布尔值统计。 - HyperLogLog :用于基数估算的概率型数据结构,占用极小内存(约12KB)即可统计海量数据的独立访客数。 - 地理空间(GEO) :存储经纬度坐标,支持半径查询和距离计算。 - 流(Stream):类似消息日志的追加式数据结构,支持消费者组。

高可用与分布式

Redis提供了从单机到集群的完整高可用方案:

\item主从复制(Replication) 一主一从或多从,从节点同步主节点数据,实现读写分离。 \item哨兵模式(Sentinel) 在复制基础上增加自动故障检测和故障转移能力,实现高可用。 \itemRedis Cluster 无中心的分布式架构,数据自动分片到多个节点,支持水平扩展和部分故障容错。

> 💡 Redis Cluster使用哈希槽(hash slot)进行数据分片,总共16384个槽位。每个键通过CRC16算法计算哈希值后对16384取模,确定其所属的槽位,进而定位到具体节点。

Redis安装与部署

sec:redis-install

Linux源码安装

在Linux系统上,最灵活的安装方式是从源码编译安装。以下是在Ubuntu/Debian系统上的安装步骤:

复制代码
## 安装编译依赖

sudo apt-get update sudo apt-get install -y build-essential tcl libssl-dev

## 下载Redis源码

wget https://download.redis.io/releases/redis-7.2.5.tar.gz tar xzf redis-7.2.5.tar.gz cd redis-7.2.5

## 编译安装

make make test

## 运行测试,确保环境正确

sudo make install

## 安装到 /usr/local/bin/

## 启动Redis服务器

redis-server

## 验证安装

redis-cli ping

## 输出: PONG

> 💡 编译安装时,如果只需要基本功能,可以不安装TCL依赖(跳过make test步骤)。但强烈建议运行测试,以确保系统环境与Redis兼容。

Docker部署

对于开发和测试环境,使用Docker部署Redis是最快捷的方式:

复制代码
## 拉取Redis镜像并启动

docker run --name my-redis -d \\ -p 6379:6379 \\ redis:7.2.5

## 使用自定义配置文件

docker run --name my-redis -d \\ -p 6379:6379 \\ -v /path/to/redis.conf:/usr/local/etc/redis/redis.conf \\ redis:7.2.5 redis-server /usr/local/etc/redis/redis.conf

## 启用了密码认证

docker run --name my-redis -d \\ -p 6379:6379 \\ redis:7.2.5 redis-server --requirepass mypassword

## 使用Docker Compose

cat \> docker-compose.yml \<\< 'EOF' version: '3.8' services: redis: image: redis:7.2.5 container_name: redis-server ports: - "6379:6379" volumes: - ./data:/data command: redis-server --appendonly yes restart: always EOF

docker-compose up -d `````

## 配置入门

Redis的配置文件是`redis.conf`,位于源码包的根目录中。以下是关键配置项的介绍:

绑定地址(默认只允许本机访问,生产环境需谨慎)

bind 127.0.0.1

端口号

port 6379

守护进程模式(前台/后台运行)

daemonize no

PID文件路径

pidfile /var/run/redis_6379.pid

日志级别(debug/verbose/notice/warning)

loglevel notice

日志文件路径

logfile ""

数据库数量(默认16个)

databases 16

持久化 - RDB快照配置

以下表示:每900秒至少1个key改变,300秒至少10个key改变,60秒至少10000个key改变时触发快照

save 900 1 save 300 10 save 60 10000

持久化 - AOF配置

appendonly no appendfsync everysec

everysec/always/no

密码认证(强烈建议生产环境设置)

requirepass yourpassword

最大内存限制(生产环境必须配置)

maxmemory 2gb

内存淘汰策略

maxmemory-policy allkeys-lru `````

> ⚠️ 生产环境中,切勿将protected-mode设置为no,且务必设置requirepass密码。未配置安全防护的Redis实例极易被恶意利用,导致数据泄露或服务器被入侵。

Redis命令行工具

sec:redis-cli

redis-cli基础用法

redis-cli是Redis官方提供的命令行客户端工具,支持交互式模式和直接命令模式:

复制代码
## 交互式模式

redis-cli 127.0.0.1:6379\> SET name "Redis Book" OK 127.0.0.1:6379\> GET name "Redis Book" 127.0.0.1:6379\> PING PONG 127.0.0.1:6379\> QUIT

## 直接命令模式

redis-cli SET name "Redis" redis-cli GET name

## 输出: "Redis"

## 指定主机和端口

redis-cli -h 192.168.1.100 -p 6380 -a password PING

## 选择数据库

redis-cli -n 5 SET db "database 5"

## 监控模式(查看所有实时命令)

redis-cli MONITOR

## 慢查询日志

redis-cli SLOWLOG GET 10 redis-cli SLOWLOG LEN `````

\> 💡 `redis-cli --help`可以查看所有可用选项。其中`--raw`选项可以避免中文字符被转义输出,在处理编码问题时很有用。

## redis-benchmark性能测试

`redis-benchmark`是Redis自带的基准性能测试工具,用于评估Redis服务器的吞吐量:

基础性能测试(默认100个并发连接,100000个请求)

redis-benchmark

指定并发数和请求数

redis-benchmark -c 50 -n 100000

仅测试特定命令

redis-benchmark -t set,get,incr,lpush,lpop

使用管道技术进行批量测试

redis-benchmark -P 16

测试特定数据大小的SET/GET

redis-benchmark -d 100 -t set,get

输出CSV格式结果

redis-benchmark --csv `````

典型的benchmark测试结果如下:

====== SET ====== 100000 requests completed in 0.88 seconds 50 parallel clients 3 bytes payload keep alive: 1 113636.36 requests per second 复制代码
====== GET ====== 100000 requests completed in 0.87 seconds 50 parallel clients 3 bytes payload 114942.53 requests per second

====== INCR ====== 100000 requests completed in 0.86 seconds 50 parallel clients 3 bytes payload 116279.07 requests per second `````

\> 💡 基准测试的结果受硬件配置、网络延迟、数据大小、并发数等多个因素影响。在生产环境中,建议使用与业务负载相近的参数进行测试,以获得更有参考价值的数据。

## 数据类型总览

sec:data-types-overview

## 五种基本数据类型

Redis提供了五种基本数据类型,每种类型都有对应的操作命令和底层编码方式:

\[H\] \\centering \|l\|l\|l\|l\| \\hline **类型** \& **命令前缀** \& **内部编码** \& **应用场景** \\\\ \\hline String \& SET/GET \& int, embstr, raw \& 缓存、计数器、分布式锁 \\\\ \\hline Hash \& HSET/HGET \& ziplist, hashtable \& 对象存储、用户信息 \\\\ \\hline List \& LPUSH/RPUSH \& quicklist \& 消息队列、时间线 \\\\ \\hline Set \& SADD/SMEMBERS \& intset, hashtable \& 标签、社交关系 \\\\ \\hline ZSet \& ZADD/ZRANGE \& ziplist, skiplist \& 排行榜、优先级队列 \\\\ \\hline

Redis五种基本数据类型概览 tab:data-types

每种类型的内部编码不是固定的,Redis会根据存储的数据特征动态选择最合适的编码方式。例如,当Hash中的字段数量较少且字段值较短时,Redis会使用ziplist编码以节省内存;当数据量增大后,会自动转换为hashtable编码。

## 高级数据结构

除了五种基本类型,Redis还提供了几种高级数据结构,进一步扩展了其应用领域:

\\item\[位图(Bitmap)\] 基于String类型的位操作,每个比特位表示一个布尔值,适合存储海量用户的签到状态、在线状态等。 \\item\[HyperLogLog\] 使用概率算法实现海量数据的基数统计,占用的内存固定为约12KB,误差率约0.81\\%。 \\item\[地理空间索引(GEO)\] 基于ZSet实现的地理位置数据结构,支持经纬度存储、半径查询和距离计算。 \\item\[流(Stream)\] 类似Kafka的追加日志结构,支持消息持久化、消费者组和消息确认机制。 \\item\[布隆过滤器(Bloom Filter)\] 通过Redis模块提供,用于判断元素是否存在于集合中,支持误判(假阳性)但绝不会有假阴性。

## Redis与传统键值存储对比

sec:redis-vs-others

## Memcached对比

Memcached是Redis出现之前最流行的内存缓存系统。两者虽然都是内存键值存储,但在设计理念和功能上有显著差异:

\[H\] \\centering \|l\|l\|l\| \\hline **特性** \& **Redis** \& **Memcached** \\\\ \\hline 数据结构 \& 多种数据结构 \& 仅支持String \\\\ \\hline 持久化 \& RDB / AOF \& 不支持 \\\\ \\hline 主从复制 \& 支持 \& 不支持(需第三方工具) \\\\ \\hline 事务 \& 支持(MULTI/EXEC) \& 不支持 \\\\ \\hline Lua脚本 \& 支持 \& 不支持 \\\\ \\hline 内存淘汰策略 \& 多种策略(LRU/LFU/TTL等) \& LRU \\\\ \\hline 多线程 \& I/O多线程(6.0+) \& 多线程架构 \\\\ \\hline 集群 \& 原生Redis Cluster \& 不支持(客户端分片) \\\\ \\hline

Redis与Memcached对比 tab:redis-vs-memcached

\> 💡 虽然Redis功能远多于Memcached,但不意味着Redis在任何场景下都优于Memcached。在纯缓存场景(仅需简单的键值存取),Memcached的多线程架构在超大并发下仍有其优势。不过,随着Redis 6.0引入多线程I/O,差距已大幅缩小。

## 其他NoSQL对比

除了Memcached,Redis还需要与其他类型的NoSQL数据库进行区分:

\\item\[与RocksDB对比\] RocksDB是嵌入式LSM-Tree存储引擎,数据存储在磁盘上,适合数据量大但可接受较低读写速度的场景。Redis是内存优先,适合对延迟极度敏感的场景。 \\item\[与MongoDB对比\] MongoDB是文档型数据库,支持丰富的查询语法(范围查询、聚合管道等),适合复杂的数据查询场景。Redis的数据结构更简洁但速度更快。 \\item\[与Cassandra对比\] Cassandra是列式存储的分布式数据库,强调高可用性和分区容错性(AP系统)。Redis Cluster更适合对一致性要求较高的缓存和实时计算场景。

下面是一个简单的Python示例,展示如何使用redis-py连接Redis并执行基本操作:

````` import redis

## 连接Redis服务器

r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

## 字符串操作

r.set('hello', 'world') print(r.get('hello'))

## 输出: world

## 列表操作

r.lpush('mylist', 'a', 'b', 'c') print(r.lrange('mylist', 0, -1))

## 输出: \['c', 'b', 'a'\]

## 设置过期时间

r.setex('temp_key', 10, 'will expire in 10 seconds')

## 检查键是否存在

print(r.exists('hello'))

## 输出: 1

print(r.type('hello'))

## 输出: string

> 💡 安装redis-py库:pip install redis。该库是Python社区中最流行的Redis客户端,广泛支持Redis的所有功能。

\section*本章小结

本章介绍了Redis的发展历程、核心特性、安装部署方法、命令行工具的使用以及数据类型概览。Redis从一个简单的键值存储发展成为功能丰富的内存数据平台,其成功源于极致的性能追求和对开发者需求的深刻理解。

在后续章节中,我们将深入探讨每种数据结构的详细用法、底层实现原理和实战应用模式。无论你是刚接触Redis的新手,还是希望系统化梳理知识的老手,这本书都将为你提供有价值的参考。

请在继续阅读之前,确保你已经成功安装了Redis并能够通过redis-cli与之交互。动手实践是掌握Redis的最佳方式。


第2章

基本数据结构:字符串与哈希

chap:strings-hashes

字符串(String)基础命令

sec:string-commands

字符串是Redis中最基础、最灵活的数据类型。在Redis中,字符串不仅可以存储文本数据,还可以存储二进制数据(如图片、序列化对象),并且支持数值运算。单个字符串的最大容量为512MB。

SET与GET命令

SET和GET是最核心的字符串操作命令:

> 定义: SET key value NX\|XX EX seconds\|PX milliseconds :将键key的值设置为value。如果键已存在,默认会覆盖旧值。支持可选参数:

  • NX:仅在键不存在时设置(Not eXists) - XX:仅在键已存在时设置 - EX:设置过期时间,单位为秒 - PX:设置过期时间,单位为毫秒

GET key :获取键key对应的值。如果键不存在,返回nil。

复制代码
## 通过redis-py操作

import redis r = redis.Redis(decode_responses=True)

## 基本SET/GET

r.set('name', 'Redis Book') print(r.get('name'))

## 输出: Redis Book

## 带过期时间的SET

r.set('session:token:abc123', 'user_42', ex=3600)

## 1小时后过期

print(r.ttl('session:token:abc123'))

## 输出剩余秒数

## NX选项(仅在键不存在时设置)

result = r.set('lock:resource', 'locked', nx=True, ex=30) print(result)

## 第一次返回True

result = r.set('lock:resource', 'locked_again', nx=True, ex=30) print(result)

## 第二次返回None(因为键已存在)

> 💡 Redis 6.0及之后版本中,SET命令的NX/XX/EX选项已经替代了早期的SETNX/SETEX/SETPX命令。虽然旧命令仍然兼容,但新代码建议统一使用SET命令。

MSET与MGET批量操作

网络I/O通常是Redis操作的瓶颈。批量操作允许在单次网络请求中执行多个命令,显著减少往返时间(Round-Trip Time, RTT):

复制代码
## 批量设置多个键值对

r.mset( 'user:1:name': 'Alice', 'user:1:age': '30', 'user:1:email': 'alice@example.com' )

## 批量获取多个键的值

values = r.mget(\['user:1:name', 'user:1:age', 'user:1:email'\]) print(values)

## 输出: \['Alice', '30', 'alice@example.com'\]

## 也可以使用管道(Pipeline)进一步优化批量操作

pipe = r.pipeline() pipe.set('count:1', 10) pipe.set('count:2', 20) pipe.set('count:3', 30) results = pipe.execute() print(results)

## 输出: \[True, True, True\]

> 💡 使用MSETMGET相比多次调用SET/GET,减少了网络往返次数。在需要同时操作大量键时,性能提升非常明显。但需要注意,MSET/MGET是原子操作吗?MSET是原子的(要么全部成功,要么全部失败),而MGET只是批量获取,不涉及原子性。

INCR/DECR与数值操作

Redis的字符串类型支持将值解释为整数或浮点数进行原子运算,这是实现计数器功能的基础:

复制代码
## 原子自增/自减

r.set('counter', 0) r.incr('counter')

## counter = 1

r.incr('counter', 5)

## counter = 6

r.decr('counter', 3)

## counter = 3

print(r.get('counter'))

## 输出: 3

## 浮点数递增

r.set('pi', '3.14') r.incrbyfloat('pi', 0.001)

## pi ≈ 3.141

print(r.get('pi'))

## 输出: 3.141

## 原子操作的线程安全性

import threading

def increment(): for _ in range(1000): r.incr('safe_counter')

threads = \[threading.Thread(target=increment) for _ in range(10)\] for t in threads: t.start() for t in threads: t.join()

print(r.get('safe_counter'))

## 输出: 10000(精确,无竞争条件)

计数器应用场景 :假设我们需要统计某个API接口的每日调用次数,使用Redis计数器可以轻松实现: ````` import redis import datetime

r = redis.Redis(decode_responses=True)

def record_api_call(api_name): """记录API调用""" today = datetime.date.today().isoformat() key = f"api:count:api_name:today" return r.incr(key)

def get_api_calls(api_name, date=None): """获取API调用次数""" if date is None: date = datetime.date.today().isoformat() key = f"api:count:api_name:date" result = r.get(key) return int(result) if result else 0

模拟调用

record_api_call("/users") record_api_call("/users") record_api_call("/orders")

print(get_api_calls("/users"))

输出: 2

print(get_api_calls("/orders"))

输出: 1

复制代码
## 过期时间管理

Redis为每个键提供了独立的过期时间管理,过期后键会自动被删除:

设置过期时间

r.set('temp_data', 'some value') r.expire('temp_data', 60)

60秒后过期

设置过期时间到指定时间戳

import time r.expireat('temp_data', int(time.time()) + 3600)

1小时后过期

查看剩余生存时间

ttl = r.ttl('temp_data')

返回秒数,-1表示无过期时间,-2表示键不存在

pttl = r.pttl('temp_data')

返回毫秒数

移除过期时间(持久化)

r.persist('temp_data')

设置键的同时指定过期时间

r.setex('session_token', 7200, 'user_abc')

SET + EXPIRE原子操作

字符串追加

r.append('message', 'Hello, ') r.append('message', 'World!') print(r.get('message'))

输出: Hello, World!

获取字符串长度

print(r.strlen('message'))

输出: 13

复制代码
\> ⚠️ `EXPIRE`命令的精度问题:在Redis 2.6之前,过期时间的精度可能受到定时器精度的限制。从2.6版本开始,Redis使用毫秒级精度来管理过期时间。但请记住,过期是惰性删除+定期删除的组合策略,并不意味着键会在过期时间精确到达的那一刻立即被删除。

## 字符串应用模式

sec:string-patterns

## 缓存实现

缓存是Redis最经典的应用场景。通过将热点数据存储在内存中,可以大幅降低后端数据库的压力:

````` import redis import json

r = redis.Redis(decode_responses=True)

def get_user(user_id): """Cache-Aside模式:先查缓存,未命中则查数据库并回填""" cache_key = f"user:user_id"

## 1. 尝试从缓存获取

user_data = r.get(cache_key) if user_data is not None: print("Cache hit!") return json.loads(user_data)

## 2. 缓存未命中,从数据库查询

print("Cache miss, querying database...") user = query_database(user_id)

## 模拟数据库查询

## 3. 回填缓存(设置过期时间防止缓存雪崩)

r.setex(cache_key, 3600, json.dumps(user)) return user

def query_database(user_id): """模拟数据库查询""" return "id": user_id, "name": f"User_user_id", "email": f"useruser_id@example.com"

## 第一次调用:缓存未命中

user = get_user(42)

## 第二次调用:缓存命中

user = get_user(42) `````

\> 💡 缓存穿透、缓存击穿和缓存雪崩是缓存系统常见的三大问题。**缓存穿透** 指查询不存在的数据导致请求直达DB,可通过布隆过滤器解决。**缓存击穿** 指热点key过期瞬间大量并发请求直击DB,可通过互斥锁或永不过期策略解决。**缓存雪崩**指大量key同时过期导致DB压力激增,可通过添加随机过期时间缓解。

## 限流器(Rate Limiter)

利用Redis的原子计数能力,可以实现高效的滑动窗口限流器:

````` import redis import time

r = redis.Redis(decode_responses=True)

def rate_limit(user_id, max_requests=10, window_seconds=60): """ 滑动窗口限流 :param user_id: 用户标识 :param max_requests: 窗口内最大请求数 :param window_seconds: 窗口大小(秒) :return: True表示允许请求,False表示限流 """ key = f"ratelimit:user_id:int(time.time() // window_seconds)" current = r.incr(key)

## 第一次创建时设置过期时间(避免内存泄漏)

if current == 1: r.expire(key, window_seconds + 1)

return current \<= max_requests

## 模拟用户请求

for i in range(15): allowed = rate_limit("user_42", max_requests=10, window_seconds=60) print(f"Request i+1: '✓' if allowed else '✗ 被限流'") `````

\> 💡 上述实现使用的是固定窗口限流,在窗口边界处可能存在流量毛刺。更精确的滑动窗口限流可以使用有序集合(ZSet)或Redis的`TIME`命令配合Lua脚本实现。

## 分布式锁(SETNX)

Redis的SET命令配合NX选项是实现分布式锁的常用方案:

````` import redis import uuid import time

class RedisDistributedLock: """基于Redis的分布式锁"""

def __init__(self, redis_client, lock_name, ttl=30): self.redis = redis_client self.lock_name = f"lock:lock_name" self.ttl = ttl self.lock_value = None

def acquire(self): """获取锁""" self.lock_value = str(uuid.uuid4()) acquired = self.redis.set( self.lock_name, self.lock_value, nx=True, ex=self.ttl ) return acquired is not None

def release(self): """释放锁(使用Lua脚本保证原子性)""" lua_script = """ if redis.call("GET", KEYS\[1\]) == ARGV\[1\] then return redis.call("DEL", KEYS\[1\]) else return 0 end """ unlock = self.redis.register_script(lua_script) return unlock(keys=\[self.lock_name\], args=\[self.lock_value\])

## 使用示例

r = redis.Redis(decode_responses=True) lock = RedisDistributedLock(r, "resource_1", ttl=10)

if lock.acquire(): try: print("获得锁,执行临界区代码...") time.sleep(2) finally: lock.release() print("锁已释放") else: print("未能获得锁") `````

\> ⚠️ 实现分布式锁时,必须确保释放锁时验证持有者身份(如上例中通过唯一UUID验证),防止误删其他客户端持有的锁。此外,建议使用Redlock算法在Redis集群环境中获得更高的安全性。

## 哈希(Hash)数据结构

sec:hash-data-structure

哈希类型是Redis中用于存储对象的最佳选择。它将字段(field)和值(value)映射起来,类似于编程语言中的字典(Dictionary)或映射(Map)。

## HSET/HGET/HGETALL命令

\> **定义:** **HSET key field value \[field value ...\]** :将哈希表`key`中的字段`field`设置为`value`。如果字段已存在,旧值将被覆盖。

**HGET key field** :获取哈希表`key`中指定字段的值。

**HGETALL key** :返回哈希表`key`中的所有字段和值。

**HDEL key field \[field ...\]**:删除一个或多个字段。

````` import redis r = redis.Redis(decode_responses=True)

## 存储用户信息(使用Hash比String更合理)

r.hset('user:1001', mapping= 'name': '张三', 'age': '28', 'city': '北京', 'email': 'zhangsan@example.com' )

## 获取单个字段

name = r.hget('user:1001', 'name') print(name)

## 输出: 张三

## 获取多个字段

values = r.hmget('user:1001', \['name', 'email'\]) print(values)

## 输出: \['张三', 'zhangsan@example.com'\]

## 获取所有字段和值

all_data = r.hgetall('user:1001') print(all_data)

## 输出: 'name': '张三', 'age': '28', 'city': '北京', 'email': 'zhangsan@example.com'

## 检查字段是否存在

print(r.hexists('user:1001', 'phone'))

## 输出: False

## 获取所有字段名

print(r.hkeys('user:1001'))

## 获取所有字段值

print(r.hvals('user:1001'))

## 获取字段数量

print(r.hlen('user:1001'))

## 输出: 4

> 💡 在存储对象时,使用Hash比将整个对象序列化为JSON字符串存储在String中更好。Hash允许单独读写对象的某个字段,节省网络带宽,也避免了频繁反序列化的开销。

HINCRBY与计数器

与String的INCR类似,Hash也支持对字段值进行原子递增操作:

复制代码
## 原子递增某个字段的值

r.hincrby('user:1001', 'login_count', 1)

## 登录次数+1

r.hincrby('user:1001', 'login_count', 1)

## 登录次数再+1

print(r.hget('user:1001', 'login_count'))

## 输出: 2

## 浮点数递增

r.hincrbyfloat('user:1001', 'balance', 100.50) r.hincrbyfloat('user:1001', 'balance', -25.00) print(r.hget('user:1001', 'balance'))

## 输出: 75.5

## 批量递增多个计数器

pipe = r.pipeline() for day in range(1, 32): pipe.hincrby('stats:2024:01', str(day), 100) pipe.execute() `````

**博客文章统计** :使用Hash存储文章的阅读量、点赞数和评论数,每个字段对应一个计数器。 ````` def increment_article_stat(article_id, stat_field): """原子递增文章统计字段""" key = f"article:article_id:stats" r.hincrby(key, stat_field, 1)

def get_article_stats(article_id): """获取文章所有统计""" key = f"article:article_id:stats" return r.hgetall(key)

## 模拟用户互动

increment_article_stat(101, 'views')

## 阅读量+1

increment_article_stat(101, 'likes')

## 点赞+1

increment_article_stat(101, 'comments')

## 评论+1

increment_article_stat(101, 'views')

## 再阅读一次

print(get_article_stats(101))

## 输出: 'views': '2', 'likes': '1', 'comments': '1'

哈希内存优化

sec:hash-optimization

Redis为哈希类型设计了两种内部编码方式:ziplist和hashtable。Redis会根据数据特征自动选择最合适的编码方式。

ziplist编码

> 定义: **ziplist(压缩列表)**是一种紧凑的、连续内存块组成的数据结构。它将所有字段和值按顺序存储在连续的内存区域中,每个字段和值都紧挨着存放,没有额外的指针开销。当哈希表中的字段数量少且每个字段的值较短时,Redis会使用ziplist编码以节省内存。

ziplist的内存布局非常紧凑,每个条目由三个部分组成:

  • prevlen :前一个条目的长度,用于从后向前遍历。 - encoding :当前条目的编码类型和长度。 - data:实际存储的数据(字段名或字段值)。

ziplist的主要优点是内存利用率高,但查询性能与字段数量成正比(O(n)复杂度)。因此ziplist只适用于小规模数据。

hashtable编码

> 定义: **hashtable(哈希表)**是Redis使用的一种链地址法实现的哈希表。当哈希表中的字段数量超过阈值或字段值过大时,Redis会将编码从ziplist转换为hashtable。hashtable的查询、插入和删除操作的平均时间复杂度为O(1),但每个字段需要额外的指针开销。

Redis通过以下两个配置参数控制编码转换的阈值:

复制代码
## redis.conf配置

## 当字段数量小于等于512时,使用ziplist编码

hash-max-ziplist-entries 512

## 当每个字段的值小于等于64字节时,使用ziplist编码

hash-max-ziplist-value 64

## 当任一条件超出阈值时,自动转换为hashtable编码

> 💡 编码转换是单向的:从ziplist转换为hashtable后,即使字段数再次减少,也不会自动转回ziplist。这是因为hashtable无法无损转回ziplist,并且通常转换后数据量较大,不值得再做逆向转换。

使用Python的redis-pyDEBUG OBJECT命令查看内部编码: ````` def get_encoding(r, key): """获取键的内部编码方式""" info = r.debug_object(key) return info.get('encoding', 'unknown')

创建一个小的Hash(应使用ziplist)

r.hset('small_hash', mapping=f'fi': f'vi' for i in range(10)) print(get_encoding(r, 'small_hash'))

输出: ziplist(或listpack在新版中)

创建一个大的Hash(应转换为hashtable)

r.hset('large_hash', mapping=f'fi': f'vi' for i in range(1000)) print(get_encoding(r, 'large_hash'))

输出: hashtable

复制代码
\> 💡 从Redis 7.0开始,ziplist逐步被**listpack**(紧凑列表)取代。listpack解决了ziplist中因级联更新(cascading update)导致的性能问题,提供了更好的性能和更简单的实现。了解这些内部优化有助于在设计和调优Redis应用时做出更好的决策。

## 哈希实战应用

sec:hash-applications

## 对象存储

Hash最自然的应用场景是存储对象(如用户信息、商品详情等),每个字段对应对象的一个属性:

````` class ShoppingCart: """基于Hash的购物车实现"""

def __init__(self, redis_client, user_id): self.redis = redis_client self.key = f"cart:user_id"

def add_item(self, product_id, quantity=1): """添加商品到购物车""" self.redis.hincrby(self.key, product_id, quantity)

def remove_item(self, product_id): """移除商品""" self.redis.hdel(self.key, product_id)

def update_quantity(self, product_id, quantity): """更新商品数量""" if quantity \<= 0: self.remove_item(product_id) else: self.redis.hset(self.key, product_id, quantity)

def get_all_items(self): """获取所有商品""" items = self.redis.hgetall(self.key) return k: int(v) for k, v in items.items()

def get_item_count(self, product_id): """获取某商品数量""" result = self.redis.hget(self.key, product_id) return int(result) if result else 0

def clear(self): """清空购物车""" self.redis.delete(self.key)

## 使用示例

r = redis.Redis(decode_responses=True) cart = ShoppingCart(r, "user_42") cart.add_item("product_1001", 2) cart.add_item("product_1002", 1) cart.add_item("product_1003", 5) cart.update_quantity("product_1003", 3) print(cart.get_all_items())

## 输出: 'product_1001': 2, 'product_1002': 1, 'product_1003': 3

会话管理

Web应用通常需要存储用户会话数据。使用Redis Hash存储会话信息,可以高效地进行单字段读写:

import redis import uuid import time 复制代码
class SessionManager: """基于Redis Hash的会话管理器"""

def __init__(self, redis_client, session_ttl=3600): self.redis = redis_client self.session_ttl = session_ttl

def create_session(self, user_id, extra_data=None): """创建新会话""" session_id = str(uuid.uuid4()) key = f"session:session_id"

session_data = 'user_id': str(user_id), 'created_at': str(time.time()), 'last_access': str(time.time()), 'ip': '', 'user_agent': '' if extra_data: session_data.update(extra_data)

self.redis.hset(key, mapping=session_data) self.redis.expire(key, self.session_ttl) return session_id

def get_session(self, session_id): """获取会话信息""" key = f"session:session_id" data = self.redis.hgetall(key) if not data: return None

## 更新访问时间和过期时间

self.redis.hset(key, 'last_access', str(time.time())) self.redis.expire(key, self.session_ttl) return data

def update_session(self, session_id, field, value): """更新会话的某个字段""" key = f"session:session_id" return self.redis.hset(key, field, value)

def destroy_session(self, session_id): """销毁会话""" key = f"session:session_id" return self.redis.delete(key)

def get_active_sessions_count(self): """获取活跃会话数(粗略估计)"""

## 实际生产环境可用SCAN命令遍历

return "N/A (use SCAN for production)" `````

\> 💡 使用Hash存储会话比String更节省内存吗?不一定。当会话字段较小时,两者差距不大。但Hash的优势在于可以单独读写某个字段,而不需要整体序列化和反序列化,这对涉及大量会话操作的场景非常重要。

## 计数器聚合

Hash可以同时管理多个相关计数器,非常适合按维度聚合统计的场景:

````` class MultiDimensionCounter: """基于Hash的多维统计计数器"""

def __init__(self, redis_client, namespace): self.redis = redis_client self.namespace = namespace

def _key(self, dimension, period): return f"self.namespace:dimension:period"

def increment(self, dimension, period, field, count=1): """递增指定维度/周期的某个统计字段""" key = self._key(dimension, period) self.redis.hincrby(key, field, count)

## 设置过期时间(例如30天)

self.redis.expire(key, 30 \* 86400)

def get_stats(self, dimension, period): """获取指定维度/周期的统计""" key = self._key(dimension, period) return self.redis.hgetall(key)

def get_field(self, dimension, period, field): """获取单个字段的统计值""" key = self._key(dimension, period) result = self.redis.hget(key, field) return int(result) if result else 0

## 使用示例

counter = MultiDimensionCounter(r, "analytics")

## 按天记录各页面的访问量

counter.increment("page_views", "2024-01-15", "/home", 1) counter.increment("page_views", "2024-01-15", "/products", 3) counter.increment("page_views", "2024-01-15", "/cart", 1)

## 按小时记录各API的调用量

counter.increment("api_calls", "2024-01-15:14", "/api/users", 100) counter.increment("api_calls", "2024-01-15:14", "/api/orders", 50)

## 查询统计

print(counter.get_stats("page_views", "2024-01-15"))

## 输出: '/home': '1', '/products': '3', '/cart': '1'

print(counter.get_field("api_calls", "2024-01-15:14", "/api/users"))

## 输出: 100

> ⚠️ 使用Hash存储大量小对象时,请注意单个Hash的大小。如果某个Hash包含成千上万个字段,HGETALL操作会产生大量数据,可能导致网络阻塞和延迟飙升。对于超大Hash,建议根据业务逻辑进行分桶(sharding)。

\section*本章小结

本章详细介绍了Redis中最基础也最常用的两种数据结构------字符串(String)和哈希(Hash)。字符串提供了灵活的键值存储和原子计数能力,是缓存、分布式锁和限流器等经典模式的基础。哈希类型则更适合存储对象,能减少序列化开销并支持字段级别的操作。

我们深入探讨了底层编码机制(ziplist与hashtable),理解了何时以及为何需要关注内存优化。在实际应用中,合理选择字符串和哈希的使用场景,配置合适的过期策略和编码参数,可以显著提升系统的性能和资源利用率。

在下一章中,我们将继续探索列表(List)、集合(Set)和有序集合(ZSet)这三种强大的数据结构,掌握它们在消息队列、社交关系和排行榜等场景中的应用。


第3章

基本数据结构:列表、集合与有序集合

chap:lists-sets-zsets

列表(List)

sec:list

Redis的列表(List)是基于链表(Linked List)实现的字符串集合,支持从左右两端高效地插入和删除元素。这使得列表非常适合用作队列(Queue)和栈(Stack)。

LPUSH/RPUSH/LPOP/RPOP

列表的基础操作围绕两端进行:

> 定义: LPUSH key element element ...:将一个或多个元素插入到列表的左侧(头部)。返回插入后列表的长度。

RPUSH key element element ...:将一个或多个元素插入到列表的右侧(尾部)。

LPOP key count :移除并返回列表左侧的第一个元素。可选的count参数(Redis 6.2+)指定一次弹出多个元素。

RPOP key count:移除并返回列表右侧的最后一个元素。

import redis r = redis.Redis(decode_responses=True) 复制代码
## 左侧插入(栈行为)

r.lpush('mylist', 'c') r.lpush('mylist', 'b') r.lpush('mylist', 'a')

## 列表现在为: a -\> b -\> c

## 右侧插入(队列行为)

r.rpush('mylist', 'd') r.rpush('mylist', 'e')

## 列表现在为: a -\> b -\> c -\> d -\> e

## 左侧弹出

item = r.lpop('mylist') print(item)

## 输出: a

## 右侧弹出

item = r.rpop('mylist') print(item)

## 输出: e

## 同时插入多个元素

r.lpush('queue', 'task3', 'task2', 'task1')

## 列表现在为: task1 -\> task2 -\> task3

## 一次弹出多个元素

items = r.lpop('queue', 2) print(items)

## 输出: \['task1', 'task2'\]

> 💡 LPUSH和RPUSH在插入多个元素时,顺序会影响结果。例如LPUSH list a b c会从左到右依次插入,最终列表为 c -> b -> a(先插入a,再在a左侧插入b,再在b左侧插入c)。

LLEN与LRANGE

查看列表长度和范围获取列表中的元素:

复制代码
## 获取列表长度

length = r.llen('mylist') print(f"列表长度: length")

## 范围获取(从左到右,0表示第一个,-1表示最后一个)

items = r.lrange('mylist', 0, -1) print(items)

## 输出: \['b', 'c', 'd'\]

## 获取前3个元素

items = r.lrange('mylist', 0, 2) print(items)

## 获取最后2个元素

items = r.lrange('mylist', -2, -1) print(items)

## 修改指定位置的元素

r.lset('mylist', 1, 'X')

## 列表变为: b -\> X -\> d

## 修剪列表(保留指定范围内的元素,删除其余)

r.ltrim('mylist', 0, 1)

## 只保留前2个元素

print(r.lrange('mylist', 0, -1))

## 输出: \['b', 'X'\]

## 插入元素到某个元素的前面或后面

r.rpush('newlist', 'a', 'b', 'd') r.linsert('newlist', 'BEFORE', 'd', 'c') print(r.lrange('newlist', 0, -1))

## 输出: \['a', 'b', 'c', 'd'\]

> 💡 LRANGE的时间复杂度为O(S+N),其中S是偏移量,N是返回元素数量。因此,如果只需要列表的前几个元素,尽量指定较小的end索引,避免全表遍历。

阻塞操作BLPOP/BRPOP

Redis列表提供了阻塞式弹出操作,当列表为空时,客户端会进入等待状态,直到有新的元素加入或超时:

> 定义: BLPOP key key ... timeout:从给定的一个或多个列表中弹出左侧第一个非空列表的元素。如果所有列���都为空,则阻塞直到有元素可弹出或超时。

BRPOP key key ... timeout:与BLPOP类似,但从右侧弹出。

timeout:阻塞超时时间(秒),0表示无限等待。

import redis import threading import time 复制代码
r = redis.Redis(decode_responses=True)

def producer(): """生产者:向队列添加任务""" for i in range(5): task = f"task_i" r.rpush('task_queue', task) print(f"\[生产者\] 添加: task") time.sleep(0.5)

def consumer(): """消费者:阻塞式从队列获取任务""" while True:

## 阻塞等待任务(最多等待10秒)

result = r.blpop('task_queue', timeout=10) if result is None: print("\[消费者\] 超时,停止消费") break key, task = result print(f"\[消费者\] 处理: task") time.sleep(1)

## 启动消费者线程(后台运行)

consumer_thread = threading.Thread(target=consumer, daemon=True) consumer_thread.start()

## 启动生产者

producer()

## 等待消费者处理完剩余任务

time.sleep(3) print("队列剩余长度:", r.llen('task_queue')) `````

**可靠消息队列模式** :使用BRPOPLPUSH命令(或Redis 6.2+的BLMOVE)可以从一个列表弹出元素并推入另一个列表,实现消息确认机制: ````` def reliable_queue(): """使用BRPOPLPUSH实现可靠消息队列"""

## 从working_queue获取任务,放入backup_queue

## 如果消费者处理成功,从backup_queue删除

## 如果消费者崩溃,可以从backup_queue恢复

result = r.brpoplpush('working_queue', 'backup_queue', timeout=30) if result: try: process_task(result)

## 处理任务

r.lrem('backup_queue', 1, result)

## 处理成功,从备份队列移除

except Exception: pass

## 处理失败,保留在备份队列中待重试

return result `````

## 列表应用场景

sec:list-applications

## 消息队列

列表天然的FIFO(先进先出)特性使其成为实现简单消息队列的理想选择:

````` import redis import json import time

r = redis.Redis(decode_responses=True)

class SimpleMessageQueue: """基于Redis List的简单消息队列"""

def __init__(self, queue_name): self.queue_name = queue_name

def publish(self, message_dict): """发布消息""" message = json.dumps(message_dict) return r.lpush(self.queue_name, message)

def consume(self, timeout=0): """消费消息(阻塞获取)""" result = r.brpop(self.queue_name, timeout=timeout) if result: _, message = result return json.loads(message) return None

def get_length(self): """获取队列长度""" return r.llen(self.queue_name)

## 使用示例

mq = SimpleMessageQueue('email_queue')

## 生产者发送邮件任务

mq.publish( 'to': 'user@example.com', 'subject': '欢迎注册', 'body': '感谢您的注册!' ) mq.publish( 'to': 'admin@example.com', 'subject': '新用户通知', 'body': '有新用户注册' )

## 消费者处理邮件

email = mq.consume() if email: print(f"发送邮件至 email\['to'\]: email\['subject'\]") `````

\> ⚠️ 基于List的消息队列是\\"至少一次\\"(at-least-once)还是\\"至多一次\\"(at-most-once)语义?默认的LPOP/RPOP是\\"至多一次\\"------消息弹出后如果消费者崩溃,消息就丢失了。通过BRPOPLPUSH可以升级为\\"至少一次\\"语义,但需要额外的去重机制。对于要求严格的场景,建议使用Redis Stream或专业的消息队列系统。

## 时间线(Timeline)

列表可以存储按时间排序的内容,实现社交Feed或时间线功能:

````` class TimelineFeed: """基于List的时间线"""

def __init__(self, redis_client, feed_name, max_items=1000): self.redis = redis_client self.feed_key = f"feed:feed_name" self.max_items = max_items

def add_post(self, post_id, timestamp=None): """添加新帖子到时间线顶部""" import time as time_module score = timestamp or time_module.time()

## 将帖子ID和时间戳一起存储

entry = f"post_id:score" self.redis.lpush(self.feed_key, entry)

## 裁剪到最大长度,防止无限增长

self.redis.ltrim(self.feed_key, 0, self.max_items - 1)

def get_posts(self, start=0, count=20): """获取时间线帖子""" entries = self.redis.lrange(self.feed_key, start, start + count - 1) posts = \[\] for entry in entries: post_id, timestamp = entry.split(':') posts.append( 'post_id': post_id, 'timestamp': float(timestamp) ) return posts

def remove_post(self, post_id): """从时间线移除某个帖子"""

## 需要遍历查找,效率不高。生产环境建议用ZSet

entries = self.redis.lrange(self.feed_key, 0, -1) for entry in entries: if entry.startswith(f"post_id:"): self.redis.lrem(self.feed_key, 1, entry) break

## 使用示例

feed = TimelineFeed(r, 'global') feed.add_post('post_001') feed.add_post('post_002') feed.add_post('post_003')

posts = feed.get_posts(0, 3) for post in posts: print(f"帖子: post\['post_id'\]") `````

## 日志收集

列表可以用作轻量级的日志缓冲区,将日志实时写入Redis,再由后台任务批量写入磁盘:

````` class LogCollector: """基于List的日志收集器"""

def __init__(self, redis_client, log_name, max_batch=100): self.redis = redis_client self.log_key = f"logs:log_name" self.max_batch = max_batch

def write_log(self, level, message, extra=None): """写入日志""" import json, time log_entry = json.dumps( 'timestamp': time.time(), 'level': level, 'message': message, 'extra': extra or ) self.redis.lpush(self.log_key, log_entry) return self.redis.llen(self.log_key)

def flush_logs(self, batch_size=100): """批量取出日志(用于写入磁盘或发送到日志中心)""" logs = \[\] for _ in range(batch_size): log = self.redis.rpop(self.log_key) if log is None: break logs.append(log) return logs

## 使用示例

logger = LogCollector(r, 'app') logger.write_log('INFO', '服务启动成功') logger.write_log('WARN', '内存使用率超过80%') logger.write_log('ERROR', '数据库连接超时')

batch = logger.flush_logs(10) for log_json in batch: print(log_json) `````

## 集合(Set)

sec:set

集合(Set)是元素唯一且无序的字符串集合。集合之间支持交集、并集、差集等数学运算,非常适合用于标签系统、好友关系和去重场景。

## SADD/SMEMBERS

\> **定义:** **SADD key member \[member ...\]**:向集合添加一个或多个元素。如果元素已存在,则忽略。返回实际新增的元素数量。

**SMEMBERS key**:返回集合中的所有元素。

**SISMEMBER key member**:检查元素是否存在于集合中(时间复杂度O(1))。

**SCARD key**:返回集合的基数(元素数量)。

**SREM key member \[member ...\]**:从集合中移除一个或多个元素。

创建集合并添加元素

r.sadd('tech_stack', 'Python', 'Redis', 'Docker', 'Kubernetes') r.sadd('tech_stack', 'Python')

重复添加,被忽略

获取所有元素

technologies = r.smembers('tech_stack') print(technologies)

输出: 'Python', 'Docker', 'Redis', 'Kubernetes'

检查元素是否存在

print(r.sismember('tech_stack', 'Redis'))

输出: True

print(r.sismember('tech_stack', 'MongoDB'))

输出: False

获取集合大小

print(r.scard('tech_stack'))

输出: 4

移除元素

r.srem('tech_stack', 'Docker') print(r.scard('tech_stack'))

输出: 3

随机弹出一个元素(Redis 6.2+)

popped = r.spop('tech_stack') print(f"弹出: popped")

随机获取元素但不移除

sample = r.srandmember('tech_stack', 2) print(f"随机取样: sample") `````

SINTER/SUNION/SDIFF

集合最强大的功能是支持集合运算,这在关系型数据库中通常需要JOIN操作:

> 定义: SINTER key key ...:返回所有给定集合的交集。

SUNION key key ...:返回所有给定集合的并集。

SDIFF key key ...:返回第一个集合与其余集合的差集。

这些操作还支持STORE后缀版本(如SINTERSTORE),用于将结果保存到新集合中。

复制代码
## 创建几个标签集合

r.sadd('python_devs', 'alice', 'bob', 'charlie', 'david') r.sadd('redis_devs', 'alice', 'bob', 'eve', 'frank') r.sadd('docker_devs', 'charlie', 'david', 'eve', 'grace')

## 交集:同时掌握Python和Redis的开发者

common = r.sinter('python_devs', 'redis_devs') print(f"Python+Redis: common")

## 输出: 'alice', 'bob'

## 并集:掌握任一技术的开发者

all_devs = r.sunion('python_devs', 'redis_devs', 'docker_devs') print(f"所有开发者: all_devs")

## 输出: 'alice', 'bob', 'charlie', 'david', 'eve', 'frank', 'grace'

## 差集:只懂Python不懂Redis的开发者

only_python = r.sdiff('python_devs', 'redis_devs') print(f"仅Python: only_python")

## 输出: 'charlie', 'david'

## 将交集结果存储到新集合(用于缓存)

r.sinterstore('python_redis_devs', 'python_devs', 'redis_devs') print(r.smembers('python_redis_devs'))

## 输出: 'alice', 'bob'

> 💡 集合运算的时间复杂度为O(N),其中N是参与运算的集合总元素数。对于大型集合,建议使用STORE变体将结果缓存起来,避免重复计算。

集合应用场景

sec:set-applications

标签系统

集合非常适合实现标签系统,每个标签作为一个集合,包含了所有具有该标签的资源ID:

class TagSystem: """基于Set的标签系统""" 复制代码
def __init__(self, redis_client): self.redis = redis_client

def add_tags(self, article_id, tags): """为文章添加标签""" for tag in tags: self.redis.sadd(f"tag:tag", article_id)

## 同时也记录文章的标签列表

self.redis.sadd(f"article:article_id:tags", \*tags)

def get_articles_by_tag(self, tag): """获取具有某标签的所有文章""" return self.redis.smembers(f"tag:tag")

def get_articles_by_tags_and(self, tags): """获取同时具有所有指定标签的文章(交集)""" keys = \[f"tag:tag" for tag in tags\] if not keys: return set() return self.redis.sinter(\*keys)

def get_articles_by_tags_or(self, tags): """获取具有任一指定标签的文章(并集)""" keys = \[f"tag:tag" for tag in tags\] if not keys: return set() return self.redis.sunion(\*keys)

def get_article_tags(self, article_id): """获取文章的所有标签""" return self.redis.smembers(f"article:article_id:tags")

## 使用示例

tags = TagSystem(r) tags.add_tags('article:101', \['python', 'redis', 'tutorial'\]) tags.add_tags('article:102', \['python', 'flask'\]) tags.add_tags('article:103', \['redis', 'database'\])

print(tags.get_articles_by_tag('redis'))

## 输出: 'article:103', 'article:101'

print(tags.get_articles_by_tags_and(\['python', 'redis'\]))

## 输出: 'article:101'

print(tags.get_articles_by_tags_or(\['python', 'redis'\]))

## 输出: 'article:101', 'article:102', 'article:103'

好友关系

社交网络中的好友关系可以用集合完美建模:

class SocialGraph: """基于Set的社交关系图""" 复制代码
def __init__(self, redis_client): self.redis = redis_client

def _friends_key(self, user_id): return f"friends:user_id"

def _followers_key(self, user_id): return f"followers:user_id"

def _following_key(self, user_id): return f"following:user_id"

def add_friend(self, user_a, user_b): """添加双向好友关系""" self.redis.sadd(self._friends_key(user_a), user_b) self.redis.sadd(self._friends_key(user_b), user_a)

def remove_friend(self, user_a, user_b): """解除好友关系""" self.redis.srem(self._friends_key(user_a), user_b) self.redis.srem(self._friends_key(user_b), user_a)

def get_friends(self, user_id): """获取好友列表""" return self.redis.smembers(self._friends_key(user_id))

def get_mutual_friends(self, user_a, user_b): """获取共同好友""" return self.redis.sinter( self._friends_key(user_a), self._friends_key(user_b) )

def suggest_friends(self, user_id): """ 推荐好友(好友的好友,排除已是好友和自己) 使用SISMEMBER进行O(1)检查,高效过滤 """ friends = self.get_friends(user_id) suggestions = set()

for friend in friends: friends_of_friend = self.get_friends(friend) for candidate in friends_of_friend: if (candidate != user_id and candidate not in friends): suggestions.add(candidate)

return suggestions

## 使用示例

social = SocialGraph(r) social.add_friend('alice', 'bob') social.add_friend('alice', 'charlie') social.add_friend('bob', 'david') social.add_friend('charlie', 'david')

print(f"Alice的好友: social.get_friends('alice')")

## 输出: 'charlie', 'bob'

print(f"共同好友: social.get_mutual_friends('alice', 'david')")

## 输出: 'charlie', 'bob'

独立访客统计

集合的自动去重特性使其非常适合统计UV(Unique Visitor):

class UVTracker: """基于Set的独立访客统计""" 复制代码
def __init__(self, redis_client): self.redis = redis_client

def visit(self, page_id, user_id, date_str): """记录一次访问""" key = f"uv:page_id:date_str" return self.redis.sadd(key, user_id)

def get_uv(self, page_id, date_str): """获取独立访客数""" key = f"uv:page_id:date_str" return self.redis.scard(key)

def get_total_uv(self, page_id, date_start, date_end): """获取时间范围内的总独立访客数""" import datetime current = datetime.date.fromisoformat(date_start) end = datetime.date.fromisoformat(date_end) keys = \[\]

while current \<= end: keys.append(f"uv:page_id:current.isoformat()") current += datetime.timedelta(days=1)

if len(keys) == 1: return self.redis.scard(keys\[0\]) return self.redis.sunionstore('temp_uv_union', \*keys)

## 使用示例

uv = UVTracker(r) uv.visit('/home', 'user_a', '2024-01-15') uv.visit('/home', 'user_b', '2024-01-15') uv.visit('/home', 'user_a', '2024-01-15')

## 重复访问,不计入

print(uv.get_uv('/home', '2024-01-15'))

## 输出: 2

> ⚠️ 当独立访客数量非常大时,使用Set存储每个用户ID会消耗大量内存。例如1000万UV需要存储1000万个字符串,内存消耗可达数GB。对于超大UV统计场景,推荐使用HyperLogLog(见第4章)。Set方式适用于UV量级在百万以下的场景。

有序集合(ZSet)

sec:zset

有序集合(ZSet)是Redis中最强大的数据结构之一。它每个元素关联一个浮点数分数(score),按照分数进行排序。当分数相同时,按字典序排列。

ZADD/ZRANGE

> 定义: ZADD key score member score member ...:向有序集合添加一个或多个成员,或更新已有成员的分数。如果成员已存在,会更新其分数并重新排序。

ZRANGE key start stop WITHSCORES:按分数从小到大返回指定索引范围内的成员。

ZREVRANGE key start stop WITHSCORES:按分数从大到小返回成员。

复制代码
## 创建排行榜

r.zadd('leaderboard', 'player_a': 1500, 'player_b': 2300, 'player_c': 1800, 'player_d': 2100, 'player_e': 1950 )

## 按分数从小到大获取所有玩家

ranking_asc = r.zrange('leaderboard', 0, -1, withscores=True) print("排行榜(升序):") for player, score in ranking_asc: print(f" player: int(score)")

## 按分数从大到小获取(真正的排行榜)

ranking_desc = r.zrevrange('leaderboard', 0, -1, withscores=True) print("\\n排行榜(降序):") for player, score in ranking_desc: print(f" player: int(score)")

## 获取前三名(降序)

top3 = r.zrevrange('leaderboard', 0, 2, withscores=True) print("\\n前三名:") for player, score in top3: print(f" player: int(score)") `````

## ZRANGEBYSCORE与ZREVRANK

\> **定义:** **ZRANGEBYSCORE key min max \[WITHSCORES\] \[LIMIT offset count\]**:按分数范围返回成员。

**ZREVRANK key member**:返回成员的排名(从大到小,0表示最高分)。

**ZRANK key member**:返回成员的排名(从小到大)。

**ZSCORE key member**:返回成员的分数。

获取分数在1800到2100之间的玩家

players_in_range = r.zrangebyscore('leaderboard', 1800, 2100, withscores=True) print("分数1800~2100的玩家:") for player, score in players_in_range: print(f" player: int(score)")

带分页的分数范围查询

players_paged = r.zrangebyscore( 'leaderboard', 1800, 2100, withscores=True, start=0, num=2

每页2条

) print("\n第一页(1800~2100):") for player, score in players_paged: print(f" player: int(score)")

获取某个玩家的排名和分数

rank = r.zrevrank('leaderboard', 'player_b') score = r.zscore('leaderboard', 'player_b') print(f"\nplayer_b排名: #rank + 1,分数: int(score)")

统计分数范围内的人数

count = r.zcount('leaderboard', 1800, 2100) print(f"分数1800~2100区间人数: count")

删除分数范围外的成员

r.zremrangebyscore('leaderboard', '-inf', 1600) `````

ZINCRBY

原子递增成员的分数,是排行榜更新的核心操作:

复制代码
## 当玩家赢得比赛时增加分数

r.zincrby('leaderboard', 200, 'player_a')

## player_a +200分

r.zincrby('leaderboard', 100, 'player_e')

## player_e +100分

## 查看最新排名

print("更新后的排行榜:") for player, score in r.zrevrange('leaderboard', 0, -1, withscores=True): print(f" player: int(score)")

## 批量更新分数

pipe = r.pipeline() pipe.zincrby('leaderboard', 50, 'player_c') pipe.zincrby('leaderboard', -30, 'player_d')

## 扣分

pipe.execute()

## 获取分数总和、最大值、最小值

total = r.zcard('leaderboard') print(f"\\n总玩家数: total")

## 交集和并集操作

r.zadd('week1_ranking', 'alice': 100, 'bob': 80, 'charlie': 60) r.zadd('week2_ranking', 'alice': 90, 'bob': 95, 'david': 70)

## 合并两周的排名(分数求和)

r.zunionstore('total_ranking', \['week1_ranking', 'week2_ranking'\], aggregate='SUM') print("总排名:") for player, score in r.zrange('total_ranking', 0, -1, withscores=True): print(f" player: int(score)") `````

## 有序集合应用场景

sec:zset-applications

## 排行榜

排行榜是有序集合最经典的应用场景:

````` import redis import time

r = redis.Redis(decode_responses=True)

class GameLeaderboard: """游戏排行榜系统"""

def __init__(self, leaderboard_name): self.key = f"lb:leaderboard_name"

def record_score(self, player_id, score): """记录玩家分数(取最高分)"""

## 使用ZINCRBY累加,或ZADD覆盖

current = r.zscore(self.key, player_id) or 0 if score \> current: r.zadd(self.key, player_id: score)

def add_score(self, player_id, delta): """增加玩家分数""" r.zincrby(self.key, delta, player_id)

def get_top_n(self, n=10): """获取前N名""" results = r.zrevrange(self.key, 0, n-1, withscores=True) return \[(p, int(s)) for p, s in results\]

def get_rank(self, player_id): """获取玩家排名""" rank = r.zrevrank(self.key, player_id) if rank is not None: return rank + 1 return None

def get_score(self, player_id): """获取玩家分数""" score = r.zscore(self.key, player_id) return int(score) if score else 0

def get_around_player(self, player_id, range_n=3): """获取玩家附近的排名""" rank = r.zrevrank(self.key, player_id) if rank is None: return \[\]

start = max(0, rank - range_n) end = rank + range_n results = r.zrevrange(self.key, start, end, withscores=True) return \[(p, int(s)) for p, s in results\]

## 使用示例

lb = GameLeaderboard('global')

## 模拟多场比赛

lb.add_score('player_a', 1500) lb.add_score('player_b', 2300) lb.add_score('player_c', 1800) lb.add_score('player_d', 3200) lb.add_score('player_e', 2100)

print("排行榜前5:") for rank, (player, score) in enumerate(lb.get_top_n(5), 1): print(f" #rank player: score分")

print(f"\\nplayer_c排名: #lb.get_rank('player_c')") print(f"\\nplayer_d附近排名:") for rank_offset, (player, score) in enumerate(lb.get_around_player('player_d', 2)): print(f" player: score分") `````

## 分页与滑动窗口限流

有序集合按分数排序的特性使其适合实现分页和滑动窗口:

````` import redis import time

r = redis.Redis(decode_responses=True)

class SlidingWindowRateLimiter: """ 基于ZSet的滑动窗口限流器 窗口精度高,适合对限流精确度要求较高的场景 """

def __init__(self, redis_client): self.redis = redis_client

def is_allowed(self, user_id, action, max_requests=10, window_seconds=60): """ 检查是否允许请求 :param user_id: 用户标识 :param action: 操作名称 :param max_requests: 窗口内最大请求数 :param window_seconds: 窗口大小(秒) :return: (是否允许, 当前窗口请求数) """ key = f"ratelimit:zset:user_id:action" now = time.time() window_start = now - window_seconds

## 移除窗口外的过期记录

r.zremrangebyscore(key, '-inf', window_start)

## 获取当前窗口内的请求数

current_count = r.zcard(key)

if current_count \>= max_requests: return False, current_count

## 添加当前请求(使用当前时间戳作为分数和成员)

r.zadd(key, str(now): now)

## 设置过期时间,避免内存泄漏

r.expire(key, window_seconds + 1) return True, current_count + 1

## 使用示例

limiter = SlidingWindowRateLimiter(r)

## 模拟某个用户在一秒内连续请求

user = "user_42" for i in range(15): allowed, count = limiter.is_allowed(user, "api_call", max_requests=10, window_seconds=60) print(f"请求 i+1: '✓ 允许' if allowed else f'✗ 拒绝(当前计数: count)'") time.sleep(0.05) `````

\> 💡 ZSet滑动窗口限流比String固定窗口限流更精确,但消耗更多内存(每个请求需要存储一个唯一的member和score)。在Redis 6.2+中,也可以使用List的LMPOP + LINSERT组合实现类似功能。

## 内部数据结构

sec:ds-internals

## quicklist

\> **定义:** **quicklist**是Redis 3.2版本之后列表类型的底层实现。它是一个由多个ziplist(或listpack)节点组成的双向链表。每个节点是一个紧凑的ziplist,节点之间通过指针连接。

quicklist的设计哲学是兼顾时间和空间:

- **空间效率** :每个节点内部使用ziplist压缩存储,减少指针开销。 - **操作效率** :在两端操作时,只需操作头节点或尾节点,时间复杂度O(1)。 - **中间操作**:在列表中间插入/删除时,需要遍历到对应节点,时间复杂度O(N)。

Redis通过配置参数控制quicklist节点的行为: `````

## redis.conf配置

## 每个ziplist节点的最大大小(-1\~-5为压缩级别,正数为字节数)

list-max-ziplist-size -2

## 压缩深度(0表示不压缩,1表示压缩首尾节点外的所有节点)

list-compress-depth 0 `````

\> 💡 从Redis 7.0开始,quicklist内部逐步用listpack替代了ziplist。listpack比ziplist更简洁,避免了级联更新问题,并且性能更好。

## intset

\> **定义:** **intset(整数集合)**是集合类型在元素全部为整数时的底层编码方式。intset使用连续的内存空间存储整数,元素有序排列,支持二分查找。

intset的编码方式会根据整数范围自动升级:

- **INTSET\\_ENC\\_INT16** :所有元素在$-2\^15$到$2\^15-1$范围内。 - **INTSET\\_ENC\\_INT32** :所有元素在$-2\^31$到$2\^31-1$范围内。 - **INTSET\\_ENC\\_INT64**:所有元素在$-2\^63$到$2\^63-1$范围内。

当集合中所有元素都是整数且元素数量不超过`set-max-intset-entries`(默认512)时,Redis使用intset编码。一旦添加非整数元素或超过元素数量上限,就会升级为hashtable编码。

## skiplist

\> **定义:** **skiplist(跳跃表)**是一种支持快速查找的随机化数据结构,是ZSet底层实现的核心组件(与哈希表配合使用)。skiplist通过维护多层索引,实现了O(log N)的平均查找、插入和删除时间复杂度。

skiplist的设计特点:

- **多层索引** :底层是一个有序链表,上面每一层都是下一层的\\"高速公路\\",跳过部分节点。 - **概率平衡** :每个节点的高度随机决定(通常是1/4概率向上提升),无需像平衡树那样进行复杂的旋转操作。 - **实现简单**:相比红黑树,skiplist的实现代码更简洁,更容易理解和维护。

skiplist查询复杂度 \\quad \& O(\\log N) 平均 \\\\ skiplist空间复杂度 \\quad \& O(N \\log N) 平均

当ZSet元素较少时(小于`zset-max-ziplist-entries`默认为128),Redis会使用ziplist编码而非skiplist。这是因为ziplist在数据量小时更节省内存,且线性扫描的开销可以接受。当数据量增长后,再转换为skiplist+hashtable的组合编码。

````` def check_zset_encoding(r, zset_key): """查看ZSet内部编码""" info = r.debug_object(zset_key) encoding = info.get('encoding', 'unknown') print(f"ZSet 'zset_key' 编码: encoding")

## 小ZSet(应使用ziplist)

r.zadd('small_zset', f'memberi': i for i in range(10)) check_zset_encoding(r, 'small_zset')

## 输出: ziplist(或listpack在新版中)

## 大ZSet(应转换为skiplist)

r.zadd('large_zset', f'memberi': i for i in range(500)) check_zset_encoding(r, 'large_zset')

## 输出: skiplist

> 💡 了解内部编码有助于优化Redis内存使用。如果ZSet的成员数量接近编码转换阈值(128或64),可以微调配置参数,根据实际业务数据特征选择最合适的编码方式。但默认配置已经经过社区长时间验证,大多数情况下不需要修改。

\section*本章小结

本章深入介绍了Redis的三种重要数据结构:列表(List)、集合(Set)和有序集合(ZSet)。列表基于quicklist实现,适合做消息队列和时间线;集合提供了高效的唯一性保证和集合运算,适合标签、社交和去重场景;有序集合通过skiplist实现排序能力,是排行榜和滑动窗口限流的理想选择。

我们还深入分析了底层存储结构------ziplist、quicklist、intset和skiplist的设计原理与适用场景。理解这些内部机制有助于我们在实际开发中做出更好的数据结构选择,平衡功能需求与资源消耗。

在下一章中,我们将探索Redis的高级数据结构:位图、HyperLogLog和GEO等,这些\"独门绝技\"将进一步拓展Redis的应用边界。


第4章

高级数据结构:位图、HyperLogLog与GEO

chap:advanced-ds

位图(Bitmap)

sec:bitmap

位图(Bitmap)不是一种独立的数据类型,而是基于字符串(String)类型的位操作。Redis的字符串最大为512MB,因此位图最多可以存储2\^32个比特位(约42.9亿位)。每个比特位只能取0或1,适合存储海量布尔值数据。

SETBIT与GETBIT

> 定义: SETBIT key offset value :将位图中偏移量为offset的位设置为0或1。返回该位原来的值。

GETBIT key offset :返回位图中偏移量为offset的位的值(0或1)。

BITCOUNT key start end:统计位图中值为1的位数(popcount)。

import redis r = redis.Redis(decode_responses=True) 复制代码
## 设置第100位为1(表示用户ID=100的用户已签到)

r.setbit('signin:2024-01-15', 100, 1) r.setbit('signin:2024-01-15', 200, 1) r.setbit('signin:2024-01-15', 300, 1)

## 检查用户是否签到

is_signed = r.getbit('signin:2024-01-15', 100) print(f"用户100签到状态: is_signed")

## 输出: 1

is_signed = r.getbit('signin:2024-01-15', 101) print(f"用户101签到状态: is_signed")

## 输出: 0

## 统计当天总签到人数

total = r.bitcount('signin:2024-01-15') print(f"当天签到总人数: total")

## 输出: 3

## 统计指定字节范围内的签到人数

total_range = r.bitcount('signin:2024-01-15', 0, 50)

## 前50字节

print(f"前50字节中的签到人数: total_range")

## 查找第一个出现0或1的位置

first_one = r.bitpos('signin:2024-01-15', 1)

## 第一个1的位置

print(f"第一位签到的用户ID: first_one")

## 输出: 100

> 💡 位图的偏移量从0开始。如果要表示用户ID从1开始,可以将用户ID作为偏移量,或者做偏移转换。例如用户ID=1对应的偏移量为0,但更常见的做法是直接将用户ID作为偏移量,空出位置0。

BITOP位运算

BITOP命令可以对多个位图执行位运算(AND、OR、XOR、NOT),并将结果保存到新的键中:

> 定义: BITOP operation destkey key key ... :对一个或多个位图执行位运算,并将结果存储到destkey中。支持的操作:AND(交集)、OR(并集)、XOR(异或)、NOT(非)。

复制代码
## 假设我们有一个用户系统,用户ID范围1\~10000

## 记录用户在不同维度的状态

## 维度1: 已登录用户

r.setbit('active:login', 100, 1) r.setbit('active:login', 200, 1) r.setbit('active:login', 300, 1) r.setbit('active:login', 400, 1)

## 维度2: 已购买用户

r.setbit('active:purchase', 200, 1) r.setbit('active:purchase', 300, 1) r.setbit('active:purchase', 500, 1)

## 维度3: 30天内活跃用户

r.setbit('active:monthly', 100, 1) r.setbit('active:monthly', 200, 1) r.setbit('active:monthly', 500, 1) r.setbit('active:monthly', 600, 1)

## AND运算: 既登录又购买的用户(精准营销目标)

r.bitop('AND', 'active:target', 'active:login', 'active:purchase') print(f"精准营销目标数: r.bitcount('active:target')")

## 输出: 2

## OR运算: 登录或购买过的用户(总活跃用户)

r.bitop('OR', 'active:all', 'active:login', 'active:purchase') print(f"总活跃用户数: r.bitcount('active:all')")

## 输出: 4

## XOR运算: 登录但未购买 或 购买但未登录

r.bitop('XOR', 'active:exclusive', 'active:login', 'active:purchase') print(f"独占用户数: r.bitcount('active:exclusive')")

## 输出: 2

## NOT运算: 未登录用户(假设总用户在active:all中)

r.bitop('NOT', 'active:not_login', 'active:login')

## 注意:NOT运算会翻转所有位,包括超出范围的位

> 💡 位图运算在大规模用户数据分析中非常高效。例如,10亿用户的位图仅占用约125MB内存(10\^9 \\div 8 \\div 1024\^2 \\approx 119MB),而BITOP运算可以在毫秒级完成位运算。这比使用Set或数据库查询高效得多。

日活用户统计

位图在统计日活跃用户(DAU)、周活跃用户(WAU)和月活跃用户(MAU)等指标时非常高效:

import redis import datetime 复制代码
r = redis.Redis(decode_responses=True)

class DailyActiveUsers: """基于位图的日活用户统计"""

def __init__(self, redis_client): self.redis = redis_client

def _key(self, date_str): return f"dau:date_str"

def record_visit(self, user_id, date_str=None): """记录用户访问""" if date_str is None: date_str = datetime.date.today().isoformat() return self.redis.setbit(self._key(date_str), user_id, 1)

def get_dau(self, date_str): """获取日活用户数""" return self.redis.bitcount(self._key(date_str))

def get_wau(self, date_strs): """获取指定日期范围内的周活用户数""" keys = \[self._key(d) for d in date_strs\] temp_key = f"dau:temp_or:date_strs\[0\]_date_strs\[-1\]" self.redis.bitop('OR', temp_key, \*keys) wau = self.redis.bitcount(temp_key) self.redis.delete(temp_key) return wau

def get_retained_users(self, date1, date2): """获取两个日期都活跃的用户数(留存用户)""" key1 = self._key(date1) key2 = self._key(date2) temp_key = f"dau:temp_and:date1_date2" self.redis.bitop('AND', temp_key, key1, key2) retained = self.redis.bitcount(temp_key) self.redis.delete(temp_key) return retained

def get_new_users(self, date1, date2): """获取date2活跃但date1不活跃的用户(新增用户)""" key1 = self._key(date1) key2 = self._key(date2) temp_key = f"dau:temp_new:date1_date2"

## 先NOT得到date1的非活跃用户,再AND date2

not_key = f"dau:temp_not:date1" self.redis.bitop('NOT', not_key, key1) self.redis.bitop('AND', temp_key, not_key, key2) new_users = self.redis.bitcount(temp_key) self.redis.delete(temp_key, not_key) return new_users

## 使用示例

dau = DailyActiveUsers(r)

## 模拟一周的用户访问数据

import random random.seed(42)

week_dates = \[\] for day_offset in range(7): date = datetime.date(2024, 1, 15) + datetime.timedelta(days=day_offset) date_str = date.isoformat() week_dates.append(date_str)

## 每天有500\~800个随机用户访问

for _ in range(random.randint(500, 800)): user_id = random.randint(1, 2000) dau.record_visit(user_id, date_str)

## 查询各天DAU

for date_str in week_dates: print(f"date_str DAU: dau.get_dau(date_str)")

## 查询周活

wau = dau.get_wau(week_dates) print(f"\\n周活用户数: wau")

## 查询留存(两天都活跃的用户)

retained = dau.get_retained_users(week_dates\[0\], week_dates\[1\]) print(f"第一天和第二天的留存用户: retained") `````

\> ⚠️ 使用位图时,如果用户ID分布非常稀疏(例如用户ID采用UUID或哈希值),位图会占用大量不必要的内存。例如用户ID=1和用户ID=10000000各占1位,但位图需要分配至少10000001位(约1.25MB)。这种情况下,使用Set或HyperLogLog更合适。

## HyperLogLog

sec:hyperloglog

HyperLogLog(HLL)是一种概率型数据结构,用于高效统计独立元素数量(基数计数)。它的核心优势在于以固定的极小内存(约12KB)统计海量数据,尽管存在约0.81\\%的误差,但足以满足大多数业务场景。

\> **定义:** **HyperLogLog**是一种基���概率算法实现的数据结构,通过哈希函数将元素映射到比特串,利用比特串中前导零的最大数量来估算基数。其标准误差为:

标准误差 \\approx 1.04m

其中$m = 16384$(使用16384个寄存器),因此误差约为$1.04 / 16384 \\approx 0.81\\%$。

## PFADD与PFCOUNT

\> **定义:** **PFADD key element \[element ...\]**:向HyperLogLog结构中添加一个或多个元素。

**PFCOUNT key \[key ...\]**:返回HyperLogLog的基数估算值(独立元素数量)。可以传入多个key,此时会计算它们的并集基数。

**PFMERGE destkey sourcekey \[sourcekey ...\]**:将多个HyperLogLog结构合并到一个新的结构中。

````` import redis r = redis.Redis(decode_responses=True)

## 添加元素

r.pfadd('hll:unique_visitors', 'user_100', 'user_200', 'user_300') r.pfadd('hll:unique_visitors', 'user_100')

## 重复添加,不会重复计数

r.pfadd('hll:unique_visitors', 'user_400')

## 估算基数

count = r.pfcount('hll:unique_visitors') print(f"独立访客数(估算): count")

## 输出: 4(精确)

## 添加大量元素测试精度

for i in range(10000): r.pfadd('hll:test', f'element_i')

estimated = r.pfcount('hll:test') print(f"10000个元素的估算值: estimated") print(f"误差: abs(estimated - 10000) / 10000 \* 100:.2f%")

## 预期误差在0.81%左右

PFMERGE合并

HyperLogLog支持合并操作,可以方便地计算多个时间段的并集基数:

复制代码
## 记录每天的独立访客

r.pfadd('uv:2024-01-15', 'user_a', 'user_b', 'user_c') r.pfadd('uv:2024-01-16', 'user_b', 'user_c', 'user_d') r.pfadd('uv:2024-01-17', 'user_c', 'user_d', 'user_e')

## 计算某一天的UV

print(f"1月15日UV: r.pfcount('uv:2024-01-15')")

## 输出: 3

print(f"1月16日UV: r.pfcount('uv:2024-01-16')")

## 输出: 3

## 合并多天数据到周合计(只需12KB内存)

r.pfmerge('uv:week_3', 'uv:2024-01-15', 'uv:2024-01-16', 'uv:2024-01-17') weekly_uv = r.pfcount('uv:week_3') print(f"周UV(合并): weekly_uv")

## 输出: 5(user_a\~user_e,精确值)

## 也可以直接在PFCOUNT中传入多个key

weekly_uv_direct = r.pfcount( 'uv:2024-01-15', 'uv:2024-01-16', 'uv:2024-01-17' ) print(f"周UV(直接计算): weekly_uv_direct")

## 输出应与合并结果一致

> 💡 HyperLogLog的误差是双向的,可能多算也可能少算。在UV量级较小时(几百到几千),误差可能非常小甚至为零;但数据量越大,误差越趋近于0.81\%。如果需要精确计数,应使用Set或位图。

独立访客计数

HyperLogLog最常见的应用是大规模UV统计:

import redis import datetime 复制代码
r = redis.Redis(decode_responses=True)

class UVCounter: """基于HyperLogLog的UV统计"""

def __init__(self, redis_client): self.redis = redis_client

def _daily_key(self, app_id, date_str): return f"uv:daily:app_id:date_str"

def _weekly_key(self, app_id, year_week): return f"uv:weekly:app_id:year_week"

def _monthly_key(self, app_id, year_month): return f"uv:monthly:app_id:year_month"

def record_visit(self, app_id, user_id, timestamp=None): """记录一次用户访问""" today = (timestamp or datetime.date.today()).isoformat()

## 添加到日UV

self.redis.pfadd(self._daily_key(app_id, today), user_id)

def get_daily_uv(self, app_id, date_str=None): """获取日UV""" if date_str is None: date_str = datetime.date.today().isoformat() return self.redis.pfcount(self._daily_key(app_id, date_str))

def get_weekly_uv(self, app_id, year_week=None): """获取周UV""" if year_week is None: today = datetime.date.today() year_week = f"today.isocalendar()\[0\]-Wtoday.isocalendar()\[1\]:02d" week_key = self._weekly_key(app_id, year_week)

## 如果周数据已经合并过,直接使用

if self.redis.exists(week_key): return self.redis.pfcount(week_key)

return None

## 需要先调用 aggregate_weekly

def aggregate_weekly(self, app_id, dates): """将多天的UV数据合并为周UV""" year_week = f"dates\[0\].isocalendar()\[0\]-Wdates\[0\].isocalendar()\[1\]:02d" week_key = self._weekly_key(app_id, year_week) daily_keys = \[self._daily_key(app_id, d.isoformat()) for d in dates\] self.redis.pfmerge(week_key, \*daily_keys) return self.redis.pfcount(week_key)

def get_monthly_uv(self, app_id, year_month=None): """获取月UV""" if year_month is None: today = datetime.date.today() year_month = today.strftime("%Y-%m")

## 直接合并所有日数据

return self.redis.pfcount( self._daily_key(app_id, f"year_month-day:02d") for day in range(1, 32) if self.redis.exists(self._daily_key(app_id, f"year_month-day:02d")) )

## 使用示例

uv_counter = UVCounter(r)

## 模拟一周的用户访问

import random random.seed(42)

start_date = datetime.date(2024, 2, 1) dates = \[start_date + datetime.timedelta(days=i) for i in range(7)\]

for date in dates:

## 每天有2000\~3000个独立用户(从10000个用户池中选取)

for _ in range(random.randint(2000, 3000)): user_id = f"user_random.randint(1, 10000)" uv_counter.record_visit("my_app", user_id, date)

## 查看各天UV

print("每日UV:") for date in dates: uv = uv_counter.get_daily_uv("my_app", date.isoformat()) print(f" date: uv")

## 计算周UV

weekly_uv = uv_counter.aggregate_weekly("my_app", dates) print(f"\\n周UV(合并): weekly_uv") print(f"实际周UV范围: 2000\~10000(取决于用户重叠程度)") `````

\> 💡 HyperLogLog与位图的对比:

- **内存占用** :HLL固定12KB,位图取决于最大偏移量(1亿用户需要12.5MB)。 - **精度** :HLL有0.81\\%误差,位图精确无误。 - **适用场景** :HLL适合超大UV统计(如千万到亿级),位图适合百万级以下的精确统计。 - **额外能力**:位图支持按位运算(AND/OR/NOT),HLL只支持并集合并。

## GEO地理空间

sec:geo

Redis的GEO功能用于存储经纬度坐标,支持基于位置的服务(LBS)中常见的半径查询和距离计算。GEO底层使用有序集合(ZSet)实现,使用Geohash算法将二维坐标编码为一维分数。

## GEOADD与GEORADIUS

\> **定义:** **GEOADD key longitude latitude member \[longitude latitude member ...\]**:将指定的地理空间位置(经度、纬度、名称)添加到指定的key中。

**GEORADIUS key longitude latitude radius m\|km\|mi\|ft \[WITHCOORD\] \[WITHDIST\] \[COUNT count\]**:以给定经纬度为中心,查找半径范围内的成员。

**GEODIST key member1 member2 \[m\|km\|mi\|ft\]**:返回两个给定位置之间的距离。

````` import redis r = redis.Redis(decode_responses=True)

## 添加几个城市的地理位置

cities = '北京': \[116.397128, 39.916527\], '上海': \[121.473701, 31.230416\], '广州': \[113.264385, 23.129110\], '深圳': \[114.057868, 22.543099\], '杭州': \[120.155070, 30.274085\], '成都': \[104.065735, 30.572260\], '武汉': \[114.305392, 30.593099\], '西安': \[108.940175, 34.341568\],

for city, (lng, lat) in cities.items(): r.geoadd('china:cities', lng, lat, city)

## 查询北京到上海的距离

distance = r.geodist('china:cities', '北京', '上海', unit='km') print(f"北京到上海距离: distance:.1fkm")

## 查找北京周边1000公里内的城市

nearby = r.georadius( 'china:cities', 116.397128, 39.916527,

## 北京坐标

1000, 'km', withdist=True, withcoord=True, sort='ASC' ) print("\\n北京周边1000公里内的城市:") for city, dist, (lng, lat) in nearby: print(f" city: dist:.1fkm (lng:.4f, lat:.4f)") `````

\> 💡 GEO坐标的精度限制:经度范围$-180\^\\circ$到$180\^\\circ$,纬度范围$-85.05112878\^\\circ$到$85.05112878\^\\circ$。超过此纬度范围的坐标无法精确表示。另外,GEOADD命令在添加多个位置时,一次性添加比多次单条添加效率更高。

## GEODIST与附近位置查询

````` import redis import math import random

r = redis.Redis(decode_responses=True)

class GeoService: """基于Redis GEO的位���服务"""

def __init__(self, redis_client, key_prefix="geo"): self.redis = redis_client self.key_prefix = key_prefix

def add_location(self, category, member_id, longitude, latitude): """添加或更新位置""" key = f"self.key_prefix:category" return self.redis.geoadd(key, longitude, latitude, member_id)

def remove_location(self, category, member_id): """移除位置""" key = f"self.key_prefix:category" return self.redis.zrem(key, member_id)

def find_nearby(self, category, longitude, latitude, radius, unit='km', count=None, with_dist=True): """ 查找附近的位置 :param category: 类别(如restaurant, gas_station) :param longitude: 中心点经度 :param latitude: 中心点纬度 :param radius: 搜索半径 :param unit: 单位(m/km/mi/ft) :param count: 返回数量限制 :param with_dist: 是否返回距离 :return: 附近位置列表 """ key = f"self.key_prefix:category" kwargs = dict( withdist=with_dist, sort='ASC' ) if count is not None: kwargs\['count'\] = count

results = self.redis.georadius( key, longitude, latitude, radius, unit, \*\*kwargs ) return results

def get_distance(self, category, member1, member2, unit='km'): """获取两个位置之间的距离""" key = f"self.key_prefix:category" distance = self.redis.geodist(key, member1, member2, unit=unit) return distance if distance is not None else None

def get_coordinates(self, category, \*members): """获取位置的经纬度""" key = f"self.key_prefix:category" return self.redis.geopos(key, \*members)

def get_geohash(self, category, \*members): """获取位置的Geohash编码""" key = f"self.key_prefix:category" return self.redis.geohash(key, \*members)

## 使用示例 - 附近餐厅查询

geo = GeoService(r)

## 模拟添加餐厅数据

restaurants = \[ ("老北京炸酱面馆", 116.40, 39.92), ("川味火锅城", 116.42, 39.90), ("日式拉面屋", 116.38, 39.93), ("广东茶餐厅", 116.45, 39.88), ("西北羊肉馆", 116.35, 39.95), ("湘菜馆", 116.41, 39.91), \]

for name, lng, lat in restaurants: geo.add_location("restaurant", name, lng, lat)

## 用户当前位置(天安门广场附近)

user_lng, user_lat = 116.397128, 39.916527

## 查找3公里内的餐厅

nearby_restaurants = geo.find_nearby( "restaurant", user_lng, user_lat, radius=3, unit='km', with_dist=True )

print("附近3公里内的餐厅:") for name, dist in nearby_restaurants: print(f" name: dist:.2fkm")

## 获取两家餐厅之间的距离

dist = geo.get_distance("restaurant", "老北京炸酱面馆", "川味火锅城") print(f"\\n两家餐厅之间的距离: dist:.2fkm")

## 获取某家餐厅的坐标

coords = geo.get_coordinates("restaurant", "老北京炸酱面馆") print(f"\\n老北京炸酱面馆坐标: coords") `````

\> 💡 GEO的底层实现是ZSet,成员作为ZSet的member,Geohash编码作为score。因此我们可以使用ZSet的所有命令来操作GEO数据,例如ZREM删除位置、ZRANGE遍历所有位置等。但需要注意不要直接修改score,否则会破坏Geohash编码的正确性。

## Stream消息流

sec:stream

Redis Stream是Redis 5.0引入的类日志数据结构,它支持消息的追加写入、消费者组的消息分发、消息持久化和回溯消费等特性。Stream的设计理念借鉴了Kafka,但更加轻量。

## XADD与XREAD

\> **定义:** **XADD key ID field value \[field value ...\]** :向Stream追加一条新的消息。ID通常用`*`让Redis自动生成(基于时间戳和序列号)。

**XREAD \[COUNT count\] \[BLOCK milliseconds\] STREAMS key \[key ...\] ID \[ID ...\]** :从一个或多个Stream中读取消息。可以使用`BLOCK`进行阻塞读取。

````` import redis import time

r = redis.Redis(decode_responses=True, decode_responses=True)

## 向Stream添加消息(使用\*自动生成ID)

msg_id = r.xadd('mystream', 'temperature': '25.5', 'humidity': '60', 'sensor': 'sensor_01' ) print(f"消息ID: msg_id")

## 输出: 1705300000000-0(示例,实际为当前时间戳)

msg_id2 = r.xadd('mystream', 'temperature': '26.0', 'humidity': '58', 'sensor': 'sensor_01' )

msg_id3 = r.xadd('mystream', 'temperature': '24.8', 'humidity': '62', 'sensor': 'sensor_02' )

## 读取所有消息

messages = r.xrange('mystream', '-', '+') print("\\n所有消息:") for msg_id, msg_data in messages: print(f" \[msg_id\] msg_data")

## 从最新的消息开始读取(等待新消息)

print("\\n等待新消息(阻塞5秒)...") new_msgs = r.xread( streams='mystream': '0',

## 0表示从最早的消息开始读取

count=10, block=5000 ) for stream_name, msgs in new_msgs: for mid, mdata in msgs: print(f" \[mid\] mdata")

## 获取Stream长度

length = r.xlen('mystream') print(f"\\nStream长度: length")

## 删除消息

r.xdel('mystream', msg_id2) print(f"删除后长度: r.xlen('mystream')")

## 截断Stream(保留最近的N条消息)

r.xtrim('mystream', maxlen=100) `````

## 消费者组

消费者组(Consumer Group)是Stream最强大的特性,它允许多个消费者协同消费同一个Stream,每条消息只被组内的一个消费者处理:

\> **定义:** **XGROUP CREATE key groupname ID \[MKSTREAM\]** :创建消费者组。ID为`0`表示从最早的消息开始消费,`\$`表示只消费新消息。

**XREADGROUP GROUP groupname consumer \[COUNT count\] \[BLOCK milliseconds\] STREAMS key \[key ...\] ID \[ID ...\]**:以消费者组成员身份读取消息。

**XACK key groupname ID \[ID ...\]**:确认消息已被处理。

**XPENDING key groupname**:查看待确认的消息。

````` import redis import threading import time

r = redis.Redis(decode_responses=True)

def setup_stream(): """初始化Stream和消费者组"""

## 创建Stream并添加一些消息

for i in range(10): r.xadd('orders', 'order_id': f'ORD_i:04d', 'user_id': f'user_i % 5', 'amount': str(100 + i \* 10) )

## 创建消费者组(从开始消费)

try: r.xgroup_create('orders', 'processors', id='0', mkstream=True) except redis.ResponseError as e: if 'BUSYGROUP' in str(e): print("消费者组已存在") else: raise

def worker(consumer_id, num_messages=5): """消费者工作线程""" print(f"\[消费者consumer_id\] 启动...") processed = 0

while processed \< num_messages:

## 从消费者组读取消息

results = r.xreadgroup( groupname='processors', consumername=consumer_id, streams='orders': '\>', count=1, block=2000 )

if not results: print(f"\[消费者consumer_id\] 没有更多消息") break

for stream_name, messages in results: for msg_id, msg_data in messages:

## 处理消息

print(f"\[消费者consumer_id\] 处理订单: " f"msg_data\['order_id'\] " f"(msg_data\['user_id'\])")

## 模拟处理耗时

time.sleep(0.5)

## 确认消息处理完成

r.xack('orders', 'processors', msg_id) processed += 1

## 查看待处理消息数

pending = r.xpending('orders', 'processors') if pending: print(f" \[待确认: pending\['pending'\]\]")

print(f"\[消费者consumer_id\] 处理完成,共处理 processed 条消息")

## 运行示例

setup_stream()

## 启动多个消费者

threads = \[\] for i in range(3): t = threading.Thread(target=worker, args=(f'worker_i',), daemon=True) threads.append(t) t.start()

## 等待所有消费者完成

for t in threads: t.join(timeout=15)

## 查看Stream状态

info = r.xinfo_stream('orders') print(f"\\nStream状态:") print(f" 总消息数: info\['length'\]") print(f" 最后消息ID: info\['last-generated-id'\]")

## 查看消费者组信息

groups = r.xinfo_groups('orders') for g in groups: print(f" 消费者组 'g\['name'\]': " f"待确认=g\['pending'\], 消费者数=g\['consumers'\]") `````

**Stream vs 消息队列对比** : \[H\] \\centering \|l\|l\|l\|l\| \\hline **特性** \& **List** \& **Stream** \& **Kafka** \\\\ \\hline 消息确认 \& 无 \& 支持(XACK) \& 支持 \\\\ \\hline 消费者组 \& 无 \& 原生支持 \& 原生支持 \\\\ \\hline 消息回溯 \& 不支持 \& 支持 \& 支持 \\\\ \\hline 持久化 \& Redis持久化 \& Redis持久化 \& 磁盘持久化 \\\\ \\hline 性能 \& 极高 \& 高 \& 中等 \\\\ \\hline 适用规模 \& 小规模 \& 中小规模 \& 大规模 \\\\ \\hline

List、Stream与Kafka的消息队列特性对比

\> ⚠️ Stream的消息确认机制(XACK)是\\"至少一次\\"语义(at-least-once)的基础。如果消费者处理完消息但还未调用XACK时就崩溃了,这条消息会在超时后被重新分配给其他消费者。应用层需要做好幂等处理,防止重复消费导致的数据不一致。

## Bloom Filter布隆过滤器

sec:bloom-filter

布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否存在于集合中。它可能会误判(假阳性,False Positive),但绝不会漏判(假阴性,False Negative)。Bloom Filter不是Redis内置的数据类型,而是通过Redis Stack或RedisBloom模块提供的功能。

## BF.ADD与BF.EXISTS

\> **定义:** **BF.ADD key item**:向布隆过滤器添加一个元素。

**BF.EXISTS key item**:检查元素是否可能存在于过滤器中。返回1表示\\"可能存在\\",返回0表示\\"一定不存在\\"。

**BF.RESERVE key error\\_rate capacity**:创建一个布隆过滤器,指定期望的误判率和容量。

````` import redis

## 注意:使用Redis Stack或加���了RedisBloom模块的实例

r = redis.Redis(decode_responses=True)

## 创建布隆过滤器(设置期望误判率1%,容量10000)

try: r.bf().create('bloom:cache', 0.01, 10000) except redis.ResponseError as e: if 'EXISTS' in str(e): print("布隆过滤器已存在") else: raise

## 添加元素

r.bf().add('bloom:cache', 'user_1001') r.bf().add('bloom:cache', 'user_2002') r.bf().add('bloom:cache', 'user_3003')

## 检查元素是否存在

print(r.bf().exists('bloom:cache', 'user_1001'))

## 输出: 1(可能存在)

print(r.bf().exists('bloom:cache', 'user_9999'))

## 输出: 0(一定不存在)

## 批量添加和检查

r.bf().madd('bloom:cache', 'user_4004', 'user_5005', 'user_6006') results = r.bf().mexists('bloom:cache', 'user_4004', 'user_9999', 'user_6006') print(results)

## 输出: \[1, 0, 1\]

## 验证误判率

import random import string

## 向过滤器中添加10000个用户

for i in range(10000): r.bf().add('bloom:cache', f'real_user_i')

## 检查另外10000个不存在的用户

false_positives = 0 for i in range(10000): if r.bf().exists('bloom:cache', f'fake_user_i'): false_positives += 1

error_rate = false_positives / 10000 print(f"\\n误判率: error_rate:.4f (预期: 0.01)") `````

\> 💡 布隆过滤器的工作原���:当一个元素被添加时,通过k个哈希函数计算出k个位位置,将这些位设为1。查询时,检查这k个位是否都为1。如果任一位为0,元素一定不存在;如果全部为1,元素可能存在(可能与其他元素的位碰撞导致误判)。

## 缓存穿透防护

布隆过滤器最经典的应用是防止缓存穿透------当大量请求查询不存在的数据时,这些请求会穿透缓存直达数据库,造成巨大压力:

````` import redis import json

r = redis.Redis(decode_responses=True)

class CacheWithBloomFilter: """ 结合布隆过滤器的缓存层 在缓存前面加一道布隆过滤器,快速过滤不存在的key """

def __init__(self, redis_client, bloom_name="bloom:cache", expected_items=100000, error_rate=0.001): self.redis = redis_client self.bloom_name = bloom_name

## 初始化布隆过滤器

try: r.bf().create(bloom_name, error_rate, expected_items) except redis.ResponseError: pass

## 已存在

def load_data_to_bloom(self, all_keys): """将数据库中所有存在的key加载到布隆过滤器""" for key in all_keys: r.bf().add(self.bloom_name, key)

def get_data(self, key, query_db_func): """ 安全地获取数据 :param key: 数据键 :param query_db_func: 数据库查询函数 :return: 数据或None """

## 第一步:布隆过滤器快速检查

if not r.bf().exists(self.bloom_name, key): print(f"\[布隆\] key 一定不存在,直接返回None") return None

## 第二步:查Redis缓存

cached = self.redis.get(key) if cached is not None: print(f"\[缓存\] key 缓存命中") return json.loads(cached)

## 第三步:缓存未命中,查数据库

print(f"\[数据库\] key 缓存未命中,查询数据库") data = query_db_func(key)

if data is not None:

## 回填缓存

self.redis.setex(key, 3600, json.dumps(data))

## 注意:如果data为None,说明布隆过滤器发生了误判

## 可以在这里记录日志,优化布隆过滤器参数

return data

## 使用示例

cache_bloom = CacheWithBloomFilter(r)

## 模拟数据库中有user_1001\~user_2000

existing_users = \[f"user:i" for i in range(1001, 2001)\] cache_bloom.load_data_to_bloom(existing_users)

def query_database(key): """模拟数据库查询"""

## 假设只有user_1001\~user_2000存在

user_id = int(key.split(':')\[1\]) if 1001 \<= user_id \<= 2000: return "id": user_id, "name": f"User_user_id" return None

## 查询存在的用户

print("\\n--- 查询存在的用户 ---") result = cache_bloom.get_data("user:1500", query_database) print(f"结果: result")

## 查询不存在的用户(布隆过滤器拦截,无需查数据库)

print("\\n--- 查询不存在的用户 ---") result = cache_bloom.get_data("user:9999", query_database) print(f"结果: result")

## 布隆过滤器直接返回None,数据库无压力

## 查询不存在的用户(小概率误判通过布隆,查数据库后回填空值)

print("\\n--- 测试误判 ---")

## 假设"user:1000"恰好被布隆误判为存在

result = cache_bloom.get_data("user:1000", query_database) print(f"结果: result") `````

**布隆过滤器参数选择公式**:

所需位数 \\quad m \&= -n \\ln p(\\ln 2)\^2 \\\\ 哈希函数数量 \\quad k \&= mn \\ln 2 \\approx 0.7 \\times mn

其中$n$为预期元素数量,$p$为目标误判率。例如,预期存储100万个元素,目标误判率1\\%,需要约11.5MB的位空间和7个哈希函数。

\> 💡 Redis Stack(原名Redis Enterprise)集成了RedisBloom、RediSearch、RedisJSON等多个模块。在安装Redis Stack后,可以直接使用BF.\*等命令,无需单独安装模块。安装方式为`apt install redis-stack-server`或使用Docker镜像`redis/redis-stack-server`。

\\section\*本章小结

本章深入介绍了Redis的四种高级数据结构,这些是Redis区别于传统键值存储的\\"独门绝技\\":

- **位图(Bitmap)** :以极小的内存代价实现海量数据的布尔值存储和位运算,在DAU统计、用户画像等场景中表现卓越。 - **HyperLogLog** :固定12KB内存即可统计任意规模数据的独立元素数量,虽然存在0.81\\%的误差,但在UV统计场景中完全可接受。 - **GEO地理空间** :基于ZSet的地理位置服务,支持附近位置查询、距离计算,为LBS应用提供基础能力。 - **Stream消息流** :类Kafka的日志型数据结构,支持消费者组、消息确认和回溯消费,是构建消息系统的强大工具。 - **布隆过滤器(Bloom Filter)**:快速判断元素是否不存在,是防止缓存穿透的利器。

掌握这些高级数据结构,能够帮助我们用最少的内存、最好的性能解决复杂的业务问题。在实际工程中,选择恰当的数据结构往往比优化已有方案能带来更大的收益。

从下一章开始,我们将进入Redis的运维和进阶主题,首先探讨Redis的持久化原理与实践------RDB和AOF机制。

---

## 第5章

## Redis持久化:RDB与AOF

ch:persistence

Redis是一个基于内存的键值存储系统,其高性能的核心在于将所有数据存放在内存中。然而,一旦进程退出或服务器宕机,内存中的数据将全部丢失。为了解决这一问题,Redis提供了两种持久化机制:RDB(Redis Database)快照和AOF(Append Only File)日志。本章将深入分析这两种持久化技术的原理、配置、性能影响以及最佳实践。

## RDB快照持久化

RDB持久化通过生成内存数据的时间点快照(point-in-time snapshot)来实现数据持久化。它可以将某一时刻的Redis数据集完整地保存到磁盘上的一个二进制文件中,默认文件名为`dump.rdb`。

## 触发机制:save与bgsave

RDB持久化的触发分为手动触发和自动触发两种方式。手动触发使用`SAVE`和`BGSAVE`命令,自动触发则通过配置文件中的`save`参数设定。

\> **定义:** SAVE命令 `SAVE`命令会阻塞Redis服务器进程,直到RDB文件创建完毕。在阻塞期间,Redis无法处理任何客户端请求。对于数据量较大的实例,阻塞时间可能长达数秒甚至数十秒,因此`SAVE`命令通常仅用于线下环境或紧急维护场景。

\> **定义:** BGSAVE命令 `BGSAVE`命令通过fork出子进程来执行RDB文件的生成操作,父进程继续处理客户端请求,实现了持久化操作对服务的最小化影响。这是生产环境中推荐使用的RDB生成方式。

自动触发通过配置`save`指令来实现。例如:

900秒内至少有1个key发生变化,则触发bgsave

save 900 1

300秒内至少有10个key发生变化,则触发bgsave

save 300 10

60秒内至少有10000个key发生变化,则触发bgsave

save 60 10000 `````

上述配置的含义是:当满足任一条件时,Redis自动执行BGSAVE。这是一种基于写操作频率的自适应策略------写操作越频繁,备份间隔越短。

> 💡 在关闭RDB持久化时,只需删除所有save配置项,或使用save ""即可。这对于仅使用AOF持久化的场景非常有用。

写时复制机制

BGSAVE的核心技术是操作系统的写时复制(Copy-On-Write,COW)机制。当父进程执行fork()系统调用创建子进程时,父子进程共享同一份物理内存。操作系统将内存页标记为只读,当父进程或子进程试图修改某个内存页时,才会真正复制该页。

COW触发条件 &: 进程对共享内存页执行写操作 \nonumber \\ COW代价 &: 复制整页内存(通常为4KB) + 页表更新 \nonumber \\ 额外内存开销 &= 写入页数 \times 页大小 \nonumber

COW机制的优点在于:如果Redis在BGSAVE期间写入量很小,子进程几乎不产生额外的内存开销。反之,如果写入量很大,COW可能导致内存开销翻倍。

> 💡 在BGSAVE执行期间,如果写入操作非常密集,Linux内核需要大量复制内存页,可能导致lazyfree线程的CPU争用以及内存使用率的瞬时飙升。建议在内存紧张或写入量极高的实例上,适当调低request\_full\_sync\_at\_every\_fork等相关参数。

RDB文件结构与配置

RDB文件是一个高度紧凑的二进制格式文件,包含以下主要部分:

  • 魔数(Magic Number) :以REDIS开头,标识文件格式。 - RDB版本号 :标识生成该文件所使用的RDB格式版本。 - 辅助字段 :包含Redis版本、创建时间戳、内存使用量等元信息。 - 数据库数据 :按数据库编号组织的键值对数据,包含过期时间等附加信息。 - 校验和:文件末尾的CRC64校验和,用于验证文件完整性。

关键配置参数如下:

复制代码
## RDB文件名称

dbfilename dump.rdb

## RDB文件保存目录

dir /var/lib/redis

## 当bgsave出错时是否停止写入

stop-writes-on-bgsave-error yes

## 是否对RDB文件进行压缩(使用LZF算法)

rdbcompression yes

## 是否对RDB文件进行校验和检查

rdbchecksum yes `````

\> ⚠️ 务必开启`rdbchecksum`选项。虽然检查校验和会增加加载时的开销,但它能有效防止因磁盘损坏导致的静默数据损坏。

## AOF持久化

AOF(Append Only File)持久化以日志的形式记录每个写操作,将Redis执行过的所有写命令追加到AOF文件中。当Redis重启时,通过重新执行AOF文件中的命令来恢复数据。

## AOF的工作原理

AOF的工作流程分为三个步骤:命令追加(append)、文件写入(write)和文件同步(sync)。

- **命令追加** :Redis执行完一个写命令后,将命令以Redis协议格式追加到`server.aof\_buf`缓冲区。 - **文件写入** :事件循环结束前,将缓冲区内容写入操作系统内核的页缓存(page cache)。 - **文件同步** :根据`appendfsync`策略,调用`fsync()`或`fdatasync()`将数据从页缓存真正刷写到磁盘。

\> **定义:** Redis协议格式 AOF文件中保存的是Redis序列化协议(RESP)格式的文本内容。例如,`SET key value`命令在AOF中保存为: ````` \*3\\r\\n$3\\r\\nSET\\r\\n$3\\r\\nkey\\r\\n$5\\r\\nvalue\\r\\n ````` 这意味着AOF文件是人类可读的文本文件,可以直接使用`cat`命令查看。

## fsync策略

`appendfsync`配置项决定了AOF日志落盘的频率,直接影响数据安全性和性能之间的权衡。

\[htbp\] \\centering \|l\|l\|l\| \\hline **策略** \& **数据安全性** \& **性能** \\\\ \\hline `always` \& 最高,每写必刷 \& 最慢,约每秒数百次写入 \\\\ \\hline `everysec` \& 中,最多丢失1秒数据 \& 较快,约每秒数万次写入 \\\\ \\hline `no` \& 最低,由操作系统决定 \& 最快,约每秒数十万次写入 \\\\ \\hline

AOF fsync策略对比

\\item\[`always`\]:每次写入都执行`fsync`,最多丢失一个写操作,但吞吐量极低,仅适用于对数据安全性要求极高的金融场景。 \\item\[`everysec`\]:每秒执行一次`fsync`,由后台线程专门负责。这是Redis官方推荐的默认策略,在性能和数据安全之间取得了良好的平衡。 \\item\[`no`\]:不主动执行`fsync`,完全由操作系统内核根据脏页比例自行决定刷盘时机。此策略性能最佳,但宕机时可能丢失大量数据。

\> 💡 在绝大多数生产环境中,`everysec`策略是最优选择。如果业务对数据安全性特别敏感,可以考虑使用`always`策略并配合SSD硬盘以获得更好的写入性能。

## AOF重写机制

随着Redis的持续运行,AOF文件会变得越来越大。AOF重写(rewrite)机制通过创建当前数据集的精简版AOF文件来解决此问题。其核心思想是:将内存中的数据状态转换为等效的写命令,而不是在旧AOF文件上做增量修改。

\> **定义:** AOF重写原理 AOF重写不是读取旧AOF文件再优化,而是直接读取当前内存中的数据库状态,使用最优的方式重新生成AOF文件。例如,对同一个key执行了100次`INCR`操作,AOF文件中会记录100条命令,而重写后仅记录一条`SET`命令。

AOF重写的触发方式:

手动触发

在Redis客户端执行:BGREWRITEAOF

自动触发条件

auto-aof-rewrite-percentage 100 auto-aof-rewrite-min-size 64mb `````

自动触发条件解释如下:

  • 当AOF文件大小相比上次重写后的增长比例超过auto-aof-rewrite-percentage(默认100\%)时。 - 且AOF文件的当前大小大于auto-aof-rewrite-min-size(默认64MB)时。

同时满足上述两个条件,Redis自动触发BGREWRITEAOF

AOF重写的过程与BGSAVE类似,也是通过fork子进程完成:

  • 父进程fork一个子进程,共享内存数据。 - 子进程遍历内存中的数据集,将每个键值对转换为对应的写入命令,写入临时AOF文件。 - 在重写期间,父进程继续处理客户端请求,同时将新的写操作同时写入AOF缓冲区AOF重写缓冲区。 - 子进程重写完成后,向父进程发送信号。 - 父进程将AOF重写缓冲区中的内容追加到临时文件。 - 父进程原子的方式用新文件替换旧AOF文件。

> ⚠️ AOF重写期间,COW机制同样会导致内存使用上升。在内存资源紧张的环境中,需要特别注意监控内存使用率,避免触发OOM Killer。

混合持久化

Redis 4.0引入了混合持久化(Mixed Persistence)模式,结合了RDB和AOF两者的优点。该模式通过aof-use-rdb-preamble配置项启用。

> 定义: 混合持久化 混合持久化的核心思想是:在AOF重写时,不是生成纯AOF格式的文件,而是先生成当前数据集的RDB格式快照,再追加后续的增量AOF日志。最终的AOF文件格式为:[RDB数据][AOF增量日志]

混合持久化的优势体现在加载速度上:

  • 纯AOF :需要逐条执行所有写命令来恢复数据,加载速度较慢。 - 纯RDB :直接加载二进制快照,速度很快,但可能丢失两次快照之间的数据。 - 混合模式:先快速加载RDB部分恢复大部分数据,再执行AOF部分重放少量增量命令,兼具速度和完整性。
复制代码
## 启用混合持久化(Redis 4.0+)

aof-use-rdb-preamble yes

## 开启AOF持久化

appendonly yes

## 同时建议保留RDB作为补充备份

save 900 1 save 300 10 save 60 10000 `````

\> 💡 混合持久化生成的文件仍然以`.aof`为扩展名。Redis在加载时会自动检测文件头部的RDB格式标志,从而正确识别混合格式。

## 备份与灾难恢复策略

持久化只是数据安全的第一步,完善的备份策略和灾难恢复方案才是保障数据不丢失的最终防线。

## 备份策略设计

一个健壮的备份策略应遵循3-2-1原则:

- **3** :保留至少3份数据副本。 - **2** :使用至少2种不同的存储介质。 - **1**:至少1份副本存储在异地。

以下是一个实用的RDB自动备份脚本:

````` #!/usr/bin/env python3 """Redis RDB自动备份脚本 --- 支持定时备份与远程同步""" import os import sys import time import shutil import subprocess from datetime import datetime

REDIS_CLI = "/usr/bin/redis-cli" BACKUP_DIR = "/backup/redis" REMOTE_HOST = "backup@remote-server" REMOTE_PATH = "/remote/backup/redis" RETENTION_DAYS = 30

def trigger_bgsave(): """触发BGSAVE并等待完成""" result = subprocess.run( \[REDIS_CLI, "BGSAVE"\], capture_output=True, text=True ) if result.returncode != 0: print(f"BGSAVE触发失败: result.stderr") return False

## 轮询LASTSAVE直到完成

while True: last_save = subprocess.run( \[REDIS_CLI, "LASTSAVE"\], capture_output=True, text=True ) current_time = int(time.time()) if current_time - int(last_save.stdout.strip()) \< 2: break time.sleep(1) return True

def copy_rdb(): """复制RDB文件到备份目录""" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") dest = os.path.join(BACKUP_DIR, f"redis_timestamp.rdb")

## 获取RDB文件路径

result = subprocess.run( \[REDIS_CLI, "CONFIG", "GET", "dir"\], capture_output=True, text=True ) redis_dir = result.stdout.strip().split("\\n")\[1\]

result = subprocess.run( \[REDIS_CLI, "CONFIG", "GET", "dbfilename"\], capture_output=True, text=True ) rdb_name = result.stdout.strip().split("\\n")\[1\]

src = os.path.join(redis_dir, rdb_name) shutil.copy2(src, dest) print(f"备份完成: dest") return dest

def clean_old_backups(): """清理过期备份""" now = time.time() for f in os.listdir(BACKUP_DIR): path = os.path.join(BACKUP_DIR, f) if os.path.isfile(path) and f.startswith("redis_"): if now - os.path.getmtime(path) \> RETENTION_DAYS \* 86400: os.remove(path) print(f"清理过期备份: f")

if __name__ == "__main__": os.makedirs(BACKUP_DIR, exist_ok=True) print(f"\[datetime.now()\] 开始Redis备份...")

if trigger_bgsave(): backup_file = copy_rdb() clean_old_backups()

## 可选:同步到远程服务器

## subprocess.run(\["rsync", "-avz", backup_file,

## f"REMOTE_HOST:REMOTE_PATH"\])

print("备份流程完成") else: print("备份失败") sys.exit(1) `````

## 灾难恢复流程

当Redis服务器发生灾难性故障时,按以下步骤进行恢复:

- **停止Redis服务** :确保没有进程正在写入数据文件。 - **检查备份完整性** :使用`redis-check-rdb`或`redis-check-aof`工具检查备份文件的完整性。 - **复制备份文件** :将最近的完好备份文件复制到Redis的数据目录。 - **启动Redis服务** :Redis启动时自动加载数据文件。 - **验证数据** :使用`DBSIZE`和`INFO`命令确认数据完整性。 - **切换读写流量**:验证无误后,将客户端流量切换到恢复后的实例。

检查RDB文件

redis-check-rdb /var/lib/redis/dump.rdb

检查AOF文件

redis-check-aof /var/lib/redis/appendonly.aof

修复有问题的AOF文件(截断尾部损坏数据)

redis-check-aof --fix /var/lib/redis/appendonly.aof `````

> ⚠️ redis-check-aof --fix会删除AOF文件尾部无法解析的内容。在运行此命令前,务必先备份原始AOF文件,以免丢失尾部未被识别的合法数据。

性能影响分析

理解持久化对Redis性能的影响,对于正确配置和容量规划至关重要。

RDB性能影响

RDB持久化的主要性能开销来源于fork()系统调用和COW机制。

  • fork耗时 :与Redis实例的内存大小正相关。在较大的实例上(如数十GB),fork耗时可能达到数百毫秒甚至秒级。 - COW内存开销 :在BGSAVE期间,如果写入量达到实例内存的20\%\~30\%,总内存使用可能增加30\%\~40\%。 - CPU消耗 :RDB文件的LZF压缩过程消耗大量CPU。如果CPU资源紧张,可以关闭压缩(rdbcompression no)。

RDB性能测试 以下脚本测试BGSAVE期间对Redis吞吐量的影响:

import redis import time import threading 复制代码
r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def write_continuously(): """持续写入数据""" count = 0 start = time.time() while time.time() - start \< 30: try: r.set(f"test:count", "x" \* 1024)

## 1KB数据

count += 1 except redis.exceptions.ConnectionError: pass elapsed = time.time() - start print(f"总写入: count 次, 吞吐量: count/elapsed:.1f ops/s")

def trigger_bgsave(): """触发BGSAVE""" r.execute_command("BGSAVE")

## 先建立基线性能

print("基线性能测试(无BGSAVE)...") t1 = threading.Thread(target=write_continuously) t1.start() t1.join()

## 测试BGSAVE期间的性能

print("\\nBGSAVE期间性能测试...") t2 = threading.Thread(target=write_continuously) t3 = threading.Thread(target=trigger_bgsave) t2.start() time.sleep(0.5)

## 确保写入线程先启动

t3.start() t2.join() t3.join() `````

## AOF性能影响

AOF的性能开销主要来自`fsync`调用和磁盘I/O:

- `appendfsync always`:每次写操作都等待磁盘I/O完成,吞吐量通常不超过1000 ops/s(取决于磁盘性能)。 - `appendfsync everysec`:仅每秒调用一次`fsync`,吞吐量可达数万到数十万ops/s。 - `appendfsync no`:完全依赖操作系统刷盘,性能最高但数据安全性最低。

\> 💡 在SSD上,`everysec`策略的AOF写入性能与无持久化场景相差不超过5\\%\\\~10\\%。但在HDD上,AOF重写时的顺序写入可能被HDD的随机I/O拖累,建议在高写入场景下使用SSD。

## RDB与AOF的对比总结

\[htbp\] \\centering \|l\|l\|l\| \\hline **特性** \& **RDB** \& **AOF** \\\\ \\hline 数据完整性 \& 可能丢失两次快照间数据 \& 最多丢失1秒数据(everysec) \\\\ \\hline 文件大小 \& 紧凑、较小 \& 通常比RDB大 \\\\ \\hline 恢复速度 \& 快(直接加载二进制数据) \& 慢(逐条执行写命令) \\\\ \\hline 性能影响 \& fork+COW周期性影响 \& fsync持续影响 \\\\ \\hline 可读性 \& 二进制,不可读 \& 文本格式,可读 \\\\ \\hline 适用场景 \& 备份、全量恢复 \& 数据安全优先、增量恢复 \\\\ \\hline

RDB与AOF对比

## 最佳实践建议

根据不同的业务场景,推荐以下持久化方案:

\\item\[**缓存场景(可丢失)** \]:关闭所有持久化(`save ""`、`appendonly no`),追求极致性能。 \\item\[**数据存储场景** \]:同时开启RDB和AOF(混合持久化),兼顾数据安全与恢复速度。 \\item\[**高写入场景** \]:AOF使用`everysec`策略,配合SSD磁盘;RDB调低`save`触发频率或仅在低峰期手动`BGSAVE`。 \\item\[**金融级场景** \]:AOF使用`always`策略,结合异地多副本备份,数据零丢失。

\> 💡 在生产环境中,建议同时开启RDB和AOF。RDB用于定期备份和快速恢复,AOF用于保证数据不丢失。Redis 4.0+的混合持久化模式进一步降低了两者配合使用的复杂度。

\\section\*本章小结

本章详细介绍了Redis的两大持久化机制。RDB通过内存快照提供了紧凑的备份格式和快速的恢复能力,适合定期备份场景;AOF通过写命令日志提供了更高的数据安全性,适合对数据完整性要求严格的场景。Redis 4.0引入的混合持久化结合了两者优点,是生产环境的首选方案。在实际应用中,应根据业务对数据安全性和性能的不同要求,选择合适的持久化策略,并配合完善的备份脚本和灾难恢复流程,构建可靠的数据保护体系。 \\endinput

---

## 第6章

## Redis高可用:主从复制与哨兵

ch:replication

在单机Redis架构中,一旦服务器发生故障,将导致服务完全不可用。为提升系统的可用性,Redis提供了主从复制(Replication)和哨兵(Sentinel)两套机制。主从复制解决了数据冗余和读写分离的问题,哨兵则在主从复制的基础上实现了自动故障转移。本章将深入剖析这两大高可用技术的原理、配置和最佳实践。

## 主从复制原理

Redis的主从复制是实现高可用的基石。通过将主节点(Master)的数据同步到多个从节点(Replica),实现了数据的热备份。

## 复制的历史演进

Redis的复制机制经历了三个重要版本的演进:

- **Redis 2.8之前** :仅支持全量复制(sync)。从节点连接主节点时,主节点执行`BGSAVE`生成RDB快照并发送给从节点。该方案在断线重连时需要重新传输全部数据,效率极低。 - **Redis 2.8\\\~** 4.0:引入部分同步(PSYNC)。通过复制积压缓冲区(replication backlog)和支持断点续传,大大减少了断线重连时的数据传输量。 - **Redis 4.0+(PSYNC2)**:进一步改进了部分同步机制,在主从角色切换后仍然能够支持部分同步,解决了主从切换后全量复制的性能问题。

## 全量同步流程

当从节点第一次连接到主节点,或无法执行部分同步时,会触发全量同步。其详细流程如下:

- 从节点向主节点发送`PSYNC ? -1`命令,表示请求全量复制。 - 主节点收到命令后,执行`BGSAVE`生成RDB快照。 - 主节点将生成的RDB文件发送给从节点。 - 从节点清空当前所有数据,加载接收到的RDB文件。 - 主节点将`BGSAVE`之后产生的所有写命令,通过复制积压缓冲区发送给从节点。 - 从节点执行这些缓冲的写命令,完成数据同步。

\> 💡 全量同步期间,主节点将消耗大量资源:fork子进程生成RDB、传输RDB文件占用网络带宽、从节点清空数据时可能阻塞。因此,在生产环境中应尽量避免频繁的全量同步。

## 部分同步与PSYNC2

部分同步机制使得从节点在断线重连后,仅需同步断开期间丢失的增量数据,避免了全量复制的巨大开销。

\> **定义:** 复制积压缓冲区 复制积压缓冲区(replication backlog)是主节点维护的一个固定大小的环形缓冲区,默认大小为1MB。它缓存了最近执行的写命令,供从节点断线重连后补发丢失的数据。通过`repl-backlog-size`配置项可调整其大小。

部分同步的工作流程如下:

- 从节点断线后,重连时发送`PSYNC `命令。 - 主节点检查`master\_run\_id`是否匹配,以及`offset`指定的位置是否仍在复制积压缓冲区中。 - 如果两者都满足,主节点发送`+CONTINUE`响应,并从`offset`之后开始发送增量数据。 - 如果不满足(如缓冲区已覆盖该offset),则触发全量同步。

\> **定义:** 复制ID与偏移量 复制ID(replication ID,简称replid)是标识数据集历史的唯一字符串。每个主节点在启动时生成一个随机ID。偏移量(offset)记录了复制流的字节位置。两者共同组成复制状态机的核心状态,用于判断主从之间的数据差异。

PSYNC2引入的关键改进是:当主节点发生故障切换时,新的主节点会继承旧主节点的replid和offset历史。从节点在连接新主节点时,可以使用旧replid2进行匹配,从而在新主节点上继续执行部分同步,无需全量复制。

查看复制状态 使用`INFO replication`命令查看主从复制的详细状态:

````` import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True) info = r.info('replication')

print(f"角色: info\['role'\]") print(f"已连接从节点数: info\['connected_slaves'\]") print(f"复制ID: info.get('master_replid', 'N/A')") print(f"复制偏移量: info.get('master_repl_offset', 0)")

if info\['role'\] == 'slave': print(f"主节点: info\['master_host'\]:info\['master_port'\]") print(f"复制状态: info\['master_link_status'\]") print(f"复制滞后: info\['slave_repl_offset'\] bytes") `````

## 复制拓扑与配置

Redis支持多种复制拓扑结构,不同的拓扑适用于不同的业务场景。

## 配置主从复制

主从复制的基本配置方式是在从节点配置文件中设置`replicaof`指令,或在运行时通过命令动态设置。

方式1:在redis.conf中配置从节点

replicaof 192.168.1.100 6379

方式2:运行时动态配置(临时生效)

redis-cli> REPLICAOF 192.168.1.100 6379

方式3:取消复制(从节点变为主节点)

redis-cli> REPLICAOF NO ONE

复制代码
## 复制拓扑结构

常见的复制拓扑有以下三种:

\\item\[**一主一从** \]:最简单的拓扑,一个主节点对应一个从节点。适用于数据备份和基本的读写分离。 \\item\[**一主多从** \]:一个主节点对应多个从节点。适用于读多写少的场景,通过多个从节点分担读取压力。 \\item\[**树形拓扑(级联复制)**\]:从节点下挂载子从节点。避免了主节点因连接过多从节点而产生的网络传输压力。

树形拓扑配置 假设有三个Redis节点:A(主节点)、B(A的从节点)、C(B的从节点):

节点A (主节点,IP: 192.168.1.100)

redis.conf - 无需复制相关配置

节点B (A的从节点,IP: 192.168.1.101)

replicaof 192.168.1.100 6379

节点C (B的从节点,IP: 192.168.1.102)

replicaof 192.168.1.101 6379 `````

> ⚠️ 级联复制中,从节点B同时扮演着从节点和"主节点"的双重角色。如果A宕机,B不会被自动提升为主节点------这需要哨兵或手动干预。此外,B上的数据写入会被C复制的操作覆盖,因此不要在B上执行写操作。

复制安全性配置

为了提升复制的安全性和效率,可以使用以下配置:

复制代码
## 要求从节点使用密码认证

masterauth your_password

## 主节点在无从节点连接时,是否接受写操作

## 如果设为yes,且min-replicas-to-write未满足,则拒绝写操作

min-replicas-to-write 1 min-replicas-max-lag 10

## 从节点是否只读(默认yes,建议保持)

replica-read-only yes

## 复制积压缓冲区大小(根据网络状况调整)

## 计算公式: 2 *平均写入速率* 期望断线容忍秒数

repl-backlog-size 64mb

## 无从节点连接时,积压缓冲区保留时间(秒)

repl-backlog-ttl 3600 `````

\> 💡 `min-replicas-to-write`和`min-replicas-max-lag`的组合可以防止主节点在网络分区时继续写入数据,避免数据在分区恢复后产生严重的不一致。

## 读写分离模式

主从复制天然支持读写分离,主节点承担写操作,从节点分担读操作。

## 读写分离架构

在读写分离架构中,应用程序将写请求发送到主节点,读请求则根据负载均衡策略分发到多个从节点。

Python读写分离实现 以下代码演示了如何在应用层实现读写分离:

````` import redis import random from typing import Optional

class RedisReadWriteSeparator: """Redis读写分离封装"""

def __init__(self, master: dict, replicas: list\[dict\]): self.master = redis.Redis(\*\*master, decode_responses=True) self.replicas = \[ redis.Redis(\*\*cfg, decode_responses=True) for cfg in replicas \]

def execute_write(self, command, *args,* \*kwargs): """写操作 -\> 主节点""" return self.master.execute_command(command, *args,*\*kwargs)

def execute_read(self, command, *args,* \*kwargs): """读操作 -\> 随机选择一个从节点""" if not self.replicas: return self.master.execute_command(command, *args,* \*kwargs) replica = random.choice(self.replicas) return replica.execute_command(command, *args,*\*kwargs)

def set(self, key, value): """设置键值(写操作)""" return self.execute_write('SET', key, value)

def get(self, key): """获取键值(读操作)""" return self.execute_read('GET', key)

def check_health(self) -\> dict: """检查所有节点的健康状态""" status = try: status\['master'\] = self.master.ping() except Exception as e: status\['master'\] = str(e)

for i, replica in enumerate(self.replicas): try: status\[f'replica_i'\] = replica.ping() except Exception as e: status\[f'replica_i'\] = str(e) return status

## 使用示例

rw = RedisReadWriteSeparator( master='host': '192.168.1.100', 'port': 6379, replicas=\[ 'host': '192.168.1.101', 'port': 6379, 'host': '192.168.1.102', 'port': 6379, \] )

## 写操作

rw.set('user:1001', 'Alice')

## 读操作

print(rw.get('user:1001'))

## 健康检查

print(rw.check_health()) `````

## 读写分离的注意事项

读写分离虽然能提升读取能力,但也带来了数据一致性的挑战:

- **复制延迟** :主节点的写入需要时间传播到从节点。如果应用在写入后立即读取,可能读取到旧数据。可以通过`WAIT`命令等待同步完成。 - **数据过期** :从节点的过期键删除策略可能滞后于主节点。 - **故障感知**:应用层需要能够检测从节点故障并自动切换。

\> 💡 对于强一致性的读取需求,建议直接读取主节点。读写分离适用于对最终一致性(eventual consistency)容忍度较高的场景。

## Redis Sentinel架构

Redis Sentinel是Redis官方提供的高可用解决方案,它基于主从复制之上,实现了自动故障检测和自动故障转移。

## Sentinel的核心功能

Redis Sentinel是一个分布式系统,运行在独立的进程中,通常以集群方式部署(建议至少3个Sentinel节点)。它提供以下核心功能:

- **监控(Monitoring)** :持续检查主节点和从节点是否正常运行。 - **通知(Notification)** :当被监控的Redis节点发生故障时,通过API通知管理员或其他应用程序。 - **自动故障转移(Automatic Failover)** :当主节点故障时,自动将一个从节点提升为新的主节点。 - **配置提供(Configuration Provider)**:客户端通过Sentinel获取当前主节点的地址。

\> **定义:** 主观下线与客观下线 Sentinel使用两种故障判定级别:

\\item\[**SDOWN(Subjectively Down,主观下线)** \]:单个Sentinel实例认为某个Redis节点不可达。当Sentinel向该节点发送`PING`命令并在`down-after-milliseconds`时间内未收到有效回复时,判定为SDOWN。 \\item\[**ODOWN(Objectively Down,客观下线)** \]:多个Sentinel实例一致认为某个主节点不可达。当Sentinel集群中达到`quorum`数量的一致判定后,SDOWN升级为ODOWN,触发故障转移。

## Sentinel配置

以下是最小化的3节点Sentinel集群配置示例:

监听端口

port 26379

监视主节点,命名为mymaster,最后2为quorum数量

sentinel monitor mymaster 192.168.1.100 6379 2

判定节点主观下线的超时时间(毫秒)

sentinel down-after-milliseconds mymaster 5000

故障转移超时时间(毫秒)

sentinel failover-timeout mymaster 60000

同时向新主节点发起复制的从节点数量

sentinel parallel-syncs mymaster 1

保护模式(生产环境建议开启)

protected-mode no

Sentinel间认证密码

sentinel auth-pass mymaster your_password

日志文件

logfile /var/log/redis/sentinel.log `````

> ⚠️ quorum参数建议设置为Sentinel节点总数的一半加一(即多数派)。例如3个Sentinel节点,quorum设为2。同时Sentinel节点总数建议为奇数,以确保能形成多数派决策。

故障转移流程

当Sentinel判定主节点ODOWN后,执行以下故障转移流程:

  • Leader选举 :Sentinel集群通过Raft-like算法选举一个Leader来执行故障转移。 - 从节点选择 :Leader根据以下优先级选出一个从节点提升为新的主节点: - 优先级(slave-priority)最高的从节点。 - 复制偏移量最大的从节点(数据最新)。 - run ID最小的从节点(作为最终裁决)。 - 角色切换 :Leader向选中的从节点发送SLAVEOF NO ONE,使其成为新的主节点。 - 重新配置 :Leader让其他从节点向新的主节点执行REPLICAOF。 - 旧主处理:当旧主节点恢复上线后,Sentinel将其降级为从节点,并连接到新的主节点。

Sentinel故障转移监控 以下Python代码使用Redis Sentinel客户端自动发现主节点:

from redis.sentinel import Sentinel 复制代码
## 连接Sentinel集群

sentinel = Sentinel(\[ ('192.168.1.10', 26379), ('192.168.1.11', 26379), ('192.168.1.12', 26379), \], socket_timeout=0.1)

## 获取当前主节点(自动故障感知)

master = sentinel.master_for('mymaster', socket_timeout=0.1, decode_responses=True)

## 获取从节点(用于读操作)

slave = sentinel.slave_for('mymaster', socket_timeout=0.1, decode_responses=True)

## 写操作(自动路由到当前主节点)

master.set('foo', 'bar')

## 读操作(自动路由到某个从节点)

print(slave.get('foo')) `````

## SDOWN与ODOWN的判定细节

Sentinel对SDOWN和ODOWN的判定涉及多个关键因素:

SDOWN判定 \&: PING超时次数 \\geq down-after-milliseconds / 间隔时间 \\nonumber \\\\ ODOWN判定 \&: \\sum Sentinel报告SDOWN \\geq quorum \\nonumber \\\\ 故障转移触发条件 \&: ODOWN \\land Leader选举成功 \\nonumber

其中,Sentinel之间通过发布/订阅频道交换对节点的健康观察信息,并使用内部消息协议进行选举和协调。

\> 💡 `down-after-milliseconds`的值需要在快速检测故障和避免误判之间取得平衡。建议设为5000\\\~10000毫秒,可根据网络稳定性适当调整。

## 客户端Sentinel集成

应用层通过Sentinel发现和访问Redis主节点,是实现高可用架构的关键一环。

## 连接策略

推荐使用支持Sentinel的Redis客户端库,这些库封装了主节点发现和故障切换的逻辑:

````` import redis from redis.sentinel import Sentinel from typing import Optional

class SentinelHighAvailabilityClient: """基于Sentinel的高可用Redis客户端"""

def __init__(self, service_name: str, sentinel_hosts: list\[tuple\], password: Optional\[str\] = None): self.service_name = service_name self.sentinel = Sentinel( sentinel_hosts, password=password, socket_timeout=0.5 )

@property def master(self): """懒加载获取主节点连接""" return self.sentinel.master_for( self.service_name, socket_timeout=1.0, decode_responses=True )

@property def slave(self): """懒加载获取从节点连接""" return self.sentinel.slave_for( self.service_name, socket_timeout=1.0, decode_responses=True )

def set(self, key, value, \*\*kwargs): return self.master.set(key, value, \*\*kwargs)

def get(self, key): try: return self.slave.get(key) except redis.exceptions.ReadOnlyError:

## 从节点可能故障,降级到主节点读取

return self.master.get(key)

def execute_with_retry(self, cmd, \*args, max_retries=3, \*\*kwargs): """带重试的命令执行""" for attempt in range(max_retries): try: func = getattr(self.master, cmd.lower()) return func(*args,*\*kwargs) except (redis.exceptions.ConnectionError, redis.exceptions.TimeoutError) as e: if attempt == max_retries - 1: raise continue

## 使用示例

client = SentinelHighAvailabilityClient( service_name='mymaster', sentinel_hosts=\[ ('10.0.0.1', 26379), ('10.0.0.2', 26379), ('10.0.0.3', 26379), \], password='redis_auth_pass' )

## 应用层完全无需感知主节点切换

client.set('session:abc123', 'user_data') print(client.get('session:abc123')) `````

## Sentinel高可用部署实践

部署Sentinel高可用架构时,应遵循以下最佳实践:

- **奇数节点** :部署3个或5个Sentinel节点,确保多数派可用。 - **物理隔离** :Sentinel节点应部署在不同的物理机器或不同的可用区中。 - **资源分配** :Sentinel进程资源消耗很低(每个约30MB内存),可以复用现有服务器资源。 - **监控告警** :对Sentinel进程本身进行监控,一旦Sentinel集群失联,Redis高可用能力将失效。 - **定期演练**:定期进行故障转移演练,验证Sentinel集群的配置和可用性。

\> ⚠️ 不要将Sentinel和Redis实例部署在同一台机器上。如果机器整体宕机,Sentinel和Redis会同时失效,无法触发故障转移。

## 常见问题与排查

\\item\[**脑裂问题** \]:网络分区导致出现两个主节点。解决方法是合理配置`min-replicas-to-write`,防止分区后的旧主继续写入。 \\item\[**复制断连** \]:网络抖动导致主从复制断开。检查`repl-backlog-size`是否过小,适当增加缓冲区大小。 \\item\[**故障转移失败**\]:Sentinel选举失败或提升从节点失败。检查Sentinel节点间的网络连通性和时钟同步。

\\section\*本章小结

主从复制和哨兵机制是Redis高可用架构的两大支柱。主从复制通过数据冗余和读写分离提升了系统的容错能力和读取性能;哨兵则在主从复制的基础上实现了自动故障检测和故障转移,大大减少了人工运维的复杂度。在实际部署中,建议遵循3节点Sentinel集群搭配一主二从的Redis架构,配合可靠的网络基础设施和完备的监控告警体系,构建具有自动故障恢复能力的Redis高可用系统。 \\endinput

---

## 第7章

## Redis集群:分布式架构

ch:cluster

当单机Redis的内存、CPU或网络吞吐量达到瓶颈时,就需要通过分布式架构来实现水平扩展。Redis Cluster是Redis官方提供的分布式解决方案,它通过数据分片、自动故障转移和去中心化设计,实现了大数据集的高可用和高性能。本章将深入剖析Redis Cluster的架构设计、核心机制和实战部署。

## 集群架构设计

Redis Cluster是一个去中心化的分布式系统,所有节点通过Gossip协议进行通信,共同维护集群的元数据。

## 哈希槽分片

Redis Cluster采用哈希槽(hash slot)机制进行数据分片。整个键空间被划分为16384个哈希槽,每个键通过CRC16算法计算哈希值,再对16384取模,确定其所属的槽。

slot \&= CRC16(key) \\bmod 16384 \\nonumber \\\\ CRC16 \&: 循环冗余校验算法,生成16位哈希值 \\nonumber \\\\ 槽总数 \&: 16384 \\ (固定值,不可配置) \\nonumber

这16384个槽被均匀地分布在集群的各个主节点上。例如,一个3节点的集群中,每个节点负责约5461个槽。

\> **定义:** 哈希槽的意义 哈希槽是Redis Cluster数据分布的基本单元。相比于一致性哈希,哈希槽方案的优势在于:

- **简化数据迁移** :只需要迁移槽(以及槽内的所有键),而非逐个键迁移。 - **控制粒度** :槽的数量(16384)在节点规模增长时提供了足够的分片粒度。 - **元数据可控**:每个节点只需要维护16384个槽的映射关系,内存开销极小。

## Gossip协议

Redis Cluster中的节点之间通过Gossip(流言)协议交换集群状态信息,实现了去中心化的集群管理。

\> **定义:** Gossip协议 Gossip协议是一种去中心化的通信协议。每个节点周期性地(每秒10次)随机选择若干个其他节点,交换彼此的节点列表、状态信息和槽分配情况。通过这种"流言传播"的方式,集群状态最终会在所有节点间达成一致。

Gossip消息有以下几种类型:

- **MEET** :请求将某个节点加入集群。 - **PING/PONG** :节点间的心跳检测和状态交换。PING消息中包含发送者已知的其他节点状态。 - **FAIL** :当某个节点判定另一个节点不可达时,广播FAIL消息。 - **PUBLISH**:在集群范围内广播发布/订阅消息。

\> 💡 Gossip协议的优点是去中心化,无单点故障。但其缺点是状态传播存在一定的延迟,尤其在节点数量较多时,达到最终一致需要多个通信轮次。

## 节点管理

Redis Cluster提供了丰富的命令来管理集群中的节点。

## 创建集群

使用`redis-cli --cluster create`命令可以快速创建集群:

创建3主3从的集群

redis-cli --cluster create \ 192.168.1.101:6379 \ 192.168.1.102:6379 \ 192.168.1.103:6379 \ 192.168.1.104:6379 \ 192.168.1.105:6379 \ 192.168.1.106:6379 \ --cluster-replicas 1

每个主节点分配一个从节点,自动分配16384个槽

复制代码
## 节点加入与退出

\> **定义:** CLUSTER MEET `CLUSTER MEET `命令用于将一个新节点加入到现有集群中。发起该命令的节点会向目标节点发送MEET消息,目标节点接受后开始参与Gossip协议通信。

将新节点加入集群

redis-cli -h 192.168.1.100 -p 6379 CLUSTER MEET 192.168.1.107 6379

将节点设置为从节点(指定主节点ID)

redis-cli -h 192.168.1.107 -p 6379 CLUSTER REPLICATE \

从集群中移除节点(先迁移空的槽)

redis-cli --cluster del-node 192.168.1.100:6379 \

查看集群节点信息

redis-cli -h 192.168.1.100 -p 6379 CLUSTER NODES `````

> ⚠️ 移除节点前必须确保该节点上的所有槽已被迁移到其他节点。如果节点上仍有数据,直接移除将导致数据丢失。使用redis-cli --cluster del-node命令会执行必要的检查。

在线重分片

在线重分片(resharding)是Redis Cluster最强大的功能之一,它允许在不停止服务的情况下,将数据从部分节点迁移到其他节点。

槽迁移流程

槽迁移(slot migration)是一个复杂的原子操作过程,涉及源节点和目标节点的协同工作:

  • 设置迁移状态 :目标节点将槽标记为importing状态,源节点将槽标记为migrating状态。 - 遍历键空间 :源节点遍历该槽中的所有键。 - 迁移键值对 :对每个键,源节点执行MIGRATE命令,将键值对原子地传输到目标节点。 - 更新路由表:键全部迁移完成后,集群广播槽的新归属信息。
复制代码
## 通过redis-cli执行在线重分片

redis-cli --cluster reshard 192.168.1.100:6379

## 交互式操作示例:

## How many slots do you want to move (from 1 to 16384)? 4096

## What is the receiving node ID?

## Source node #1:

## Source node #2: all

## Do you want to proceed with the proposed reshard plan (yes/no)? yes

## 也支持非交互式方式:

redis-cli --cluster reshard 192.168.1.100:6379 \\ --cluster-from \\ --cluster-to \\ --cluster-slots 4096 \\ --cluster-yes `````

监控重分片进度 使用`redis-cli --cluster check`命令检查集群状态和重分片进度:

````` import redis import subprocess import json

def check_cluster_health(host: str, port: int = 6379): """检查Redis集群健康状态""" r = redis.Redis(host=host, port=port)

## 获取集群信息

info = r.execute_command('CLUSTER INFO') print("=== 集群信息 ===") for line in info.splitlines(): print(line.decode())

## 获取节点信息

nodes = r.execute_command('CLUSTER NODES') print("\\n=== 节点列表 ===") for line in nodes.decode().splitlines(): parts = line.split() node_id = parts\[0\] addr = parts\[1\] role = 'master' if 'master' in parts\[2\] else 'slave' slots = ' '.join(parts\[8:\]) if len(parts) \> 8 else 'no slots' print(f" node_id\[:8\]... addr \[role\] slots: slots")

## 检查是否有失败的节点

cluster_state = info.decode().split('\\n')\[0\] if 'fail' in cluster_state: print("\\n⚠ 警告:集群状态异常!") else: print("\\n✓ 集群状态正常")

def migrate_slot_manually( host: str, port: int, slot: int, target_id: str ): """手动迁移单个槽(用于精细控制)""" r = redis.Redis(host=host, port=port)

## 设置目标节点为importing状态

r.execute_command( 'CLUSTER SETSLOT', slot, 'IMPORTING', target_id )

## 设置源节点为migrating状态

## 注意:这需要在源节点上执行

source_r = redis.Redis(host=host, port=port) source_r.execute_command( 'CLUSTER SETSLOT', slot, 'MIGRATING', target_id )

## 获取槽中的键并逐个迁移

while True: keys = source_r.execute_command( 'CLUSTER GETKEYSINSLOT', slot, 10 ) if not keys: break for key in keys: source_r.execute_command( 'MIGRATE', target_host, target_port, key, 0, 5000 )

## 通知所有节点槽已迁移

for node in all_nodes: node.execute_command( 'CLUSTER SETSLOT', slot, 'NODE', target_id )

if __name__ == '__main__': check_cluster_health('192.168.1.100')

## 输出集群拓扑图

result = subprocess.run( \['redis-cli', '--cluster', 'check', '192.168.1.100:6379'\], capture_output=True, text=True ) print(result.stdout) `````

## 重分片的影响

重分片期间,涉及迁移的键在迁移过程中可能暂时不可用。在此期间,客户端对迁移中键的访问会收到`ASK`重定向,引导客户端到目标节点查询。

\> 💡 在业务低峰期执行重分片操作,可以最大程度减少对线上服务的影响。对于大键(如包含百万个元素的集合),`MIGRATE`命令的耗时较长,可能阻塞Redis事件循环。

## 集群命令处理

Redis Cluster的客户端需要处理不同于单机模式下的命令路由和错误响应。

## MOVED与ASK重定向

当客户端向错误的节点发送请求时,会收到重定向错误:

\> **定义:** MOVED重定向 当键所属的槽已经被永久迁移到另一个节点时,收到`MOVED`错误。客户端需要更新本地的槽映射缓存,并向正确的节点重新发送请求。MOVED是永久性的重定向。

\> **定义:** ASK重定向 当键所属的槽正在迁移过程中,源节点收到对该键的请求时,如果该键已经被迁移到目标节点,源节点返回`ASK`错误。客户端需要先向目标节点发送`ASKING`命令(仅一次),然后重新发送请求。ASK是临时性的重定向。

````` import redis

class SmartRedisClusterClient: """智能Redis集群客户端,自动处理重定向"""

def __init__(self, startup_nodes: list\[tuple\]): self.nodes = startup_nodes self.slot_cache =

## slot -\> (host, port)

self._build_slot_cache()

def _build_slot_cache(self): """从集群获取槽映射并构建缓存""" for host, port in self.nodes: try: r = redis.Redis(host=host, port=port) slots = r.execute_command('CLUSTER SLOTS') for entry in slots: start_slot, end_slot = entry\[0\], entry\[1\] master = entry\[2\] for slot in range(start_slot, end_slot + 1): self.slot_cache\[slot\] = ( master\[0\].decode(), master\[1\] ) break except Exception: continue

def _get_node(self, key: str) -\> tuple: """根据key获取目标节点"""

## 计算槽

slot = redis.cluster.keyslot(key) return self.slot_cache.get(slot, self.nodes\[0\])

def execute(self, command, \*args): """执行命令,自动处理重定向""" if command.upper() in ('SET', 'GET', 'DEL'): key = args\[0\] host, port = self._get_node(key) r = redis.Redis(host=host, port=port, decode_responses=True) try: return r.execute_command(command, \*args) except redis.exceptions.ResponseError as e: error_msg = str(e) if 'MOVED' in error_msg or 'ASK' in error_msg:

## 解析重定向地址

parts = error_msg.split() new_host, new_port = parts\[2\].split(':') r = redis.Redis(host=new_host, port=int(new_port), decode_responses=True) if 'ASK' in error_msg: r.execute_command('ASKING') return r.execute_command(command, \*args) raise else:

## 对其他命令随机选择一个节点

host, port = self.nodes\[0\] r = redis.Redis(host=host, port=port, decode_responses=True) return r.execute_command(command, \*args)

## 使用示例

client = SmartRedisClusterClient(\[ ('10.0.0.1', 6379), ('10.0.0.2', 6379), ('10.0.0.3', 6379), \]) client.execute('SET', 'user:1001', 'Alice') print(client.execute('GET', 'user:1001')) `````

## 跨Slot操作限制

Redis Cluster不支持跨多个slot的原子操作。以下操作在集群模式下受到限制:

- **MULTI/EXEC事务** :事务中所有键必须属于同一个slot。 - **Lua脚本** :脚本中操作的键必须使用`KEYS`参数传入,且必须属于同一个slot。 - **集合操作** :如`SUNION`、`SDIFF`、`ZINTERSTORE`等涉及多个键的命令,要求所有键属于同一个slot。 - **RENAME/RENAMENX**:源键和目标键必须在同一个slot。

## Hash Tags

为了解决上述跨slot限制,Redis Cluster引入了哈希标签(hash tag)机制。

\> **定义:** Hash Tag 哈希标签允许用户强制将一个键映射到特定的slot。规则是:键中的第一个`\\`包围的内容被用作CRC16计算的输入,而不是整个键。因此,所有包含相同哈希标签的键都会被映射到同一个slot。

不使用hash tag --- 不同键可能在不同slot

SET user:1001:name "Alice" SET user:1001:email "alice@example.com"

这两个键可能在不同slot,无法在同一个事务中操作

使用hash tag --- 强制映射到相同slot

SET user:1001:name "Alice" SET user:1001:email "alice@example.com"

现在两个键都在同一个slot,可以执行事务

多键操作

SUNION group:admins:users group:admins:backup ZINTERSTORE ranking:global:result 2 \ ranking:global:score ranking:global:time `````

> 💡 合理使用hash tag可以大幅提升集群模式下的多键操作效率。但过度使用可能导致数据倾斜(hot spot),因为大量键被映射到少量slot上。在设计中应谨慎权衡。

集群故障转移

Redis Cluster在节点层面实现了自动故障检测和故障转移,无需依赖外部组件。

故障检测

集群中每个节点通过Gossip协议持续交换PING/PONG消息。如果某个节点在node-timeout时间内未被其他节点联系到,该节点会被标记为PFAIL(疑似故障)。当集群中超过半数的主节点都将该节点标记为PFAIL时,该节点被正式标记为FAIL

> 💡 PFAILFAIL类似于Sentinel中的SDOWN和ODOWN,但实现机制不同。集群中的PFAIL是单个节点的观察,FAIL是集群范围的共识。

从节点提升

当一个主节点被标记为FAIL后,其从节点尝试发起故障转移:

  • 从节点检查自己是否有资格发起选举(数据必须足够新)。 - 从节点向集群中所有主节点发送FAILOVER\_AUTH\_REQUEST投票请求。 - 每个主节点在同一个纪元(epoch)内只能投一票。 - 获得超过半数主节点投票的从节点胜出,执行SLAVEOF NO ONE成为新的主节点。 - 新主节点接管故障主节点的所有槽,并通过Gossip广播槽的更新信息。

手动故障转移 以下演示如何进行手动故障转移:

复制代码
## 在目标从节点上执行

redis-cli -h 192.168.1.105 -p 6379 CLUSTER FAILOVER

## 强制故障转移(即使主节点仍在运行)

redis-cli -h 192.168.1.105 -p 6379 CLUSTER FAILOVER FORCE

## 带数据一致性的故障转移(先等待复制追赶)

redis-cli -h 192.168.1.105 -p 6379 CLUSTER FAILOVER TAKEOVER `````

\> ⚠️ `CLUSTER FAILOVER TAKEOVER`会跳过投票环节,直接从节点接管。这可能导致数据丢失(如果从节点没有完全同步主节点的最新数据),仅应在紧急情况下使用。

## 副本迁移

副本迁移(replica migration)是一种自动平衡机制,用于解决从节点分布不均的问题。

\> **定义:** 副本迁移 当某个主节点失去所有从节点时,集群会自动从从节点较多的主节点下"借用"一个从节点,迁移到该主节点下,以维持高可用能力。这是集群自我修复的重要机制。

## 集群扩展策略

随着业务增长,集群需要扩容、缩容或调整配置。

## 扩容流程

向集群中添加新节点(扩容)的流程如下:

步骤1:启动新Redis实例

redis-server /path/to/new/redis.conf

步骤2:将新节点加入集群

redis-cli --cluster add-node \ 192.168.1.108:6379 \

新节点

192.168.1.100:6379

集群中任意现有节点

步骤3:如果新节点作为从节点

redis-cli --cluster add-node \ 192.168.1.109:6379 \ 192.168.1.100:6379 \ --cluster-slave \ --cluster-master-id

步骤4:为新主节点分配槽(迁移部分槽到新节点)

redis-cli --cluster reshard \ 192.168.1.100:6379 \ --cluster-from \ --cluster-to \ --cluster-slots 4096 \ --cluster-yes `````

缩容流程

缩容需要先将目标节点上的数据迁出,再移除节点:

复制代码
## 步骤1:将目标节点上的槽迁移到其他节点

redis-cli --cluster reshard \\ 192.168.1.100:6379 \\ --cluster-from \\ --cluster-to \\ --cluster-slots \\ --cluster-yes

## 重复上述操作,直到目标节点上没有任何槽

## 步骤2:移除节点

redis-cli --cluster del-node \\ 192.168.1.100:6379 \\ `````

## 集群限制与应对方案

Redis Cluster并非万能,了解其限制有助于在架构设计中做出正确取舍。

## 主要限制

- **多键操作受限** :跨slot的MGET/MSET/DEL等操作虽然支持(客户端发起多个请求),但无法保证原子性。 - **事务受限** :MULTI/EXEC事务内的键必须在同一slot。 - **Lua脚本受限** :脚本中的键必须通过`KEYS`传入且在同一slot。 - **数据库数量限制** :集群模式下仅支持一个数据库(db 0),不支持`SELECT`命令。 - **键长度限制** :键名最大512MB(与单机相同),但过长的键名会增加哈希冲突概率。 - **批量操作性能**:Pipeline中的大量请求可能分散到不同节点,失去单节点pipeline的批量加速效果。

## 实用部署建议

- **节点规模** :建议集群节点数不超过1000个,Gossip协议的通信开销会随节点数平方增长。 - **网络要求** :节点间网络延迟应低于10ms,建议部署在同一个数据中心。 - **数据倾斜** :通过hash tag和合理的键命名规则,避免大量数据集中到少量节点。 - **批量操作优化** :将相关数据通过hash tag聚合到相同slot,以充分利用pipeline能力。 - **客户端选择**:使用官方推荐的客户端库(如redis-py-cluster),它们内置了槽缓存和重定向处理逻辑。

\> 💡 在评估是否使用集群时,可以遵循以下原则:如果单机Redis(配合持久化和哨兵)能够满足性能和容量需求,优先使用单机方案。只有单机达到瓶颈(如内存超过64GB、QPS超过10万)时,才考虑引入集群的复杂度。

\\section\*本章小结

Redis Cluster通过哈希槽分片、Gossip协议和自动故障转移,提供了一套完整的分布式Redis解决方案。本章从集群架构设计出发,深入分析了节点管理、在线重分片、命令路由、故障转移等核心机制,并探讨了集群的限制与应对策略。Redis Cluster虽然增加了系统的复杂度,但在数据量超过单机能力时,它是实现水平扩展的必经之路。在架构设计中,应根据实际业务需求和数据规模,合理选择单机、哨兵或集群方案。 \\endinput

---

## 第8章

## Redis缓存设计与过期策略

ch:caching

Redis最为广泛的应用场景就是缓存。优秀的缓存设计可以大幅降低数据库负载、提升系统响应速度。然而,不良的缓存设计可能导致缓存穿透、缓存雪崩、缓存击穿等严重问题。本章将深入探讨缓存设计模式、过期与淘汰策略,以及企业级的缓存实践方案。

## 缓存设计模式

不同的应用场景需要适配不同的缓存模式。理解并正确选择缓存模式,是构建高性能缓存系统的第一步。

## Cache-Aside模式

Cache-Aside(旁路缓存)是最常用的缓存模式,应用程序同时与缓存和数据库交互。

\> **定义:** Cache-Aside模式 在Cache-Aside模式中,应用程序负责管理缓存的读写。读取时先查缓存,缓存命中则直接返回;缓存未命中则查询数据库,将结果写入缓存后返回。写入时先更新数据库,再删除缓存(或更新缓存)。

````` import redis import psycopg2 import json

class CacheAside: """Cache-Aside缓存模式实现"""

def __init__(self, redis_client, db_connection): self.cache = redis_client self.db = db_connection self.default_ttl = 300

## 5分钟

def get_user(self, user_id: int) -\> dict: """读取用户信息(Cache-Aside读策略)""" cache_key = f"user:user_id"

## 1. 先查询缓存

cached = self.cache.get(cache_key) if cached is not None: return json.loads(cached)

## 2. 缓存未命中,查数据库

cursor = self.db.cursor() cursor.execute( "SELECT id, name, email FROM users WHERE id = %s", (user_id,) ) row = cursor.fetchone() cursor.close()

if row is None: return None

user_data = 'id': row\[0\], 'name': row\[1\], 'email': row\[2\]

## 3. 写入缓存并设置过期时间

self.cache.setex( cache_key, self.default_ttl, json.dumps(user_data) ) return user_data

def update_user(self, user_id: int, name: str, email: str) -\> bool: """更新用户信息(Cache-Aside写策略)""" cache_key = f"user:user_id"

## 1. 先更新数据库

cursor = self.db.cursor() try: cursor.execute( """UPDATE users SET name = %s, email = %s WHERE id = %s""", (name, email, user_id) ) self.db.commit() except Exception: self.db.rollback() return False finally: cursor.close()

## 2. 删除缓存(而非更新)

## 删除比更新更安全:避免并发写导致的缓存与数据库不一致

self.cache.delete(cache_key) return True `````

\> 💡 Cache-Aside模式中,更新操作选择"删除缓存"而非"更新缓存"是经过深思熟虑的。删除缓存是幂等的,不会发生数据一致性问题;而更新缓存需要额外的计算,且可能引入并发竞争条件。

## Read-Through模式

Read-Through(通读)模式中,缓存自身负责从数据库加载数据。应用程序只与缓存交互,缓存相当于数据库的代理。

````` class ReadThroughCache: """Read-Through缓存模式"""

def __init__(self, cache_client, db_loader, ttl=300): self.cache = cache_client self.loader = db_loader

## 数据加载函数

self.ttl = ttl

def get(self, key: str): """读取数据,缓存未命中时自动加载""" value = self.cache.get(key) if value is not None: return value

## 缓存未命中,通过加载函数从数据库获取

value = self.loader(key) if value is not None: self.cache.setex(key, self.ttl, value) return value `````

## Write-Through模式

Write-Through(通写)模式中,应用程序将数据写入缓存,缓存负责将数据同步到数据库。

````` class WriteThroughCache: """Write-Through缓存模式"""

def __init__(self, cache_client, db_writer, ttl=300): self.cache = cache_client self.writer = db_writer

## 数据写入函数

self.ttl = ttl

def set(self, key: str, value): """写入数据,同时更新缓存和数据库"""

## 同步写入数据库

success = self.writer(key, value) if success:

## 缓存更新

self.cache.setex(key, self.ttl, value) return success `````

\> 💡 Read-Through和Write-Through模式将缓存与数据库的交互逻辑封装在缓存层内部,简化了应用程序的代码。但在分布式环境中,Write-Through可能成为性能瓶颈,因为每次写入都要等待数据库操作完成。

## 缓存三大问题与解决方案

在深度使用缓存的系统中,缓存穿透、缓存雪崩和缓存击穿是三个最常见也是最棘手的问题。

## 缓存穿透

\> **定义:** 缓存穿透 缓存穿透是指查询一个根本不存在的数据。由于缓存中不存在该数据,每次请求都会穿透缓存直达数据库。在高并发场景下,大量不存在的键请求可能导致数据库被击垮。

解决方案包括:

- **缓存空对象** :对于数据库查询为空的结果,也在缓存中保存一个空值标记,TTL设置较短(通常30\\\~60秒)。 - **布隆过滤器(Bloom Filter)** :在缓存之前增加一个布隆过滤器,快速判断请求的键是否存在。 - **参数校验**:在应用层对请求参数进行合法性校验,过滤掉明显不合法的请求。

布隆过滤器实现 布隆过滤器是一种空间效率极高的概率型数据结构,它可以判断"某个元素一定不存在"或"可能存在"。

````` import redis import mmh3

## MurmurHash3

from bitarray import bitarray import math

class BloomFilter: """布隆过滤器实现"""

def __init__(self, size: int, hash_count: int): self.size = size self.hash_count = hash_count self.bit_array = bitarray(size) self.bit_array.setall(0)

def add(self, item: str): """向布隆过滤器中添加元素""" for i in range(self.hash_count): digest = mmh3.hash(item, i) % self.size self.bit_array\[digest\] = 1

def might_contain(self, item: str) -\> bool: """判断元素是否可能存在""" for i in range(self.hash_count): digest = mmh3.hash(item, i) % self.size if self.bit_array\[digest\] == 0: return False return True

@staticmethod def optimal_params( expected_elements: int, false_positive_rate: float = 0.01 ) -\> tuple: """计算最优的布隆过滤器参数""" size = -expected_elements \* math.log(false_positive_rate) \\ / (math.log(2) \*\* 2) hash_count = (size / expected_elements) \* math.log(2) return int(size), int(hash_count) + 1

class BloomFilterCache: """带布隆过滤器的缓存"""

def __init__(self, redis_client, db_loader, expected_keys: int = 1000000): self.cache = redis_client self.loader = db_loader size, hash_count = BloomFilter.optimal_params( expected_keys ) self.bloom = BloomFilter(size, hash_count) self._initialize_bloom()

def _initialize_bloom(self): """从数据库加载所有键到布隆过滤器""" all_keys = self.loader('__all_keys__') for key in all_keys: self.bloom.add(key)

def get(self, key: str): """带布隆过滤器的缓存读取"""

## 布隆过滤器快速判断

if not self.bloom.might_contain(key): return None

## 一定不存在,直接返回

## 查缓存

value = self.cache.get(key) if value is not None: return value

## 查数据库

value = self.loader(key) if value is not None: self.cache.setex(key, 300, value) else:

## 缓存空值,防止穿透

self.cache.setex(key, 60, '__NULL__') return value `````

## 缓存雪崩

\> **定义:** 缓存雪崩 缓存雪崩是指大量缓存在同一时间过期失效,导致所有查询请求同时落到数据库上,造成数据库瞬间负载过高甚至宕机。

解决方案:

- **过期时间随机化** :为缓存过期时间增加随机偏移量,避免大量key同时过期。 - **多级缓存** :引入本地缓存(如Guava Cache、Caffeine)作为Redis缓存的前置保护。 - **缓存预热** :在系统上线前,提前将热点数据加载到缓存中。 - **互斥锁**:在缓存失效时,使用互斥锁控制只有一个线程去加载数据。

过期时间随机化 通过对基础TTL增加随机偏移量,避免大量key同时过期:

````` import random import time

def set_with_jitter(cache, key: str, value, base_ttl: int = 300, jitter: float = 0.2): """ 设置带随机抖动的缓存过期时间 :param base_ttl: 基础过期时间(秒) :param jitter: 抖动比例(0.0\~1.0) """ jitter_range = base_ttl \* jitter actual_ttl = base_ttl + random.uniform( -jitter_range, jitter_range ) cache.setex(key, int(actual_ttl), value)

def batch_set_with_jitter(cache, items: list, base_ttl: int = 300): """ 批量设置带抖动的缓存 :param items: \[(key, value), ...\] """ for key, value in items: jitter = random.randint(0, int(base_ttl \* 0.3)) cache.setex(key, base_ttl + jitter, value)

## 使用示例:为一批用户数据设置缓存

batch_set_with_jitter( redis_client, \[(f"user:uid", user_data) for uid in range(1, 10001)\], base_ttl=3600

## 1小时基础TTL

) `````

## 缓存击穿

\> **定义:** 缓存击穿 缓存击穿是指一个热点key在过期失效的瞬间,大量并发请求同时访问该key,所有请求均穿透到数据库。与雪崩的区别在于,击穿是单个热点key的问题,雪崩是大批key同时过期的问题。

解决方案:

\\item\[**互斥锁(Mutex Lock)** \]:第一个发现缓存过期的线程获取锁去加载数据,其他线程等待或返回默认值。 \\item\[**逻辑过期** \]:为缓存设置逻辑过期时间(而非物理过期),后台异步更新。 \\item\[**永不过期**\]:对于极其热点的key,不设置过期时间,通过后台定时任务更新。

````` import redis import threading import time

class HotKeyProtector: """热点key保护器,防止缓存击穿"""

def __init__(self, cache_client, db_loader, lock_ttl: int = 5): self.cache = cache_client self.loader = db_loader self.lock_ttl = lock_ttl self.local_locks =

def _acquire_lock(self, key: str) -\> bool: """获取分布式锁""" lock_key = f"lock:key"

## 使用SET NX + EX实现原子锁操作

result = self.cache.set( lock_key, 1, nx=True, ex=self.lock_ttl ) return result is True

def _release_lock(self, key: str): """释放分布式锁""" lock_key = f"lock:key" self.cache.delete(lock_key)

def get(self, key: str, ttl: int = 300, stale_ttl: int = 3600): """ 获取缓存数据,带击穿保护 :param stale_ttl: 旧数据的最大存活时间 """

## 1. 先查缓存

cached = self.cache.get(key) if cached is not None: return cached

## 2. 尝试获取分布式锁

if self._acquire_lock(key): try:

## 双重检查:可能在上一个持有锁的线程已经加载了数据

cached = self.cache.get(key) if cached is not None: return cached

## 从数据库加载

value = self.loader(key) if value is not None: self.cache.setex(key, ttl, value) return value finally: self._release_lock(key) else:

## 3. 未获取到锁,等待并重试

time.sleep(0.05)

## 50ms

cached = self.cache.get(key) if cached is not None: return cached

## 降级返回None或默认值

return None

def get_with_logical_expiry(self, key: str, logical_ttl: int = 300): """ 使用逻辑过期防止击穿 缓存永不过期,但附带逻辑过期时间 """ cached = self.cache.get(key) if cached is None: return None

data, expire_time = cached.split('\|') if float(expire_time) \> time.time(): return data

## 逻辑过期时间未到

## 逻辑已过期,尝试获取锁异步更新

if self._acquire_lock(f"refresh:key"): try: new_data = self.loader(key) new_expiry = time.time() + logical_ttl self.cache.set(key, f"new_data\|new_expiry") finally: self._release_lock(f"refresh:key")

## 返回旧数据(允许短暂的不一致)

return data `````

\> 💡 逻辑过期方案的核心理念是"用短暂的数据不一致换取系统的可用性"。在高并发场景下,这往往是最实用的解决方案。同时,建议配合消息队列异步刷新缓存,确保数据最终一致。

## Redis过期策略

Redis为每个键值对维护了一个过期时间(TTL),并采用两种策略来清理过期数据。

## 惰性过期

惰性过期(Lazy Expiration)是Redis过期清理的第一道防线。

\> **定义:** 惰性过期 当客户端访问某个键时,Redis会检查该键是否已经过期。如果过期,则立即删除该键并返回nil。这种策略以访问为触发条件,不会额外占用CPU资源。

惰性过期的优点是CPU开销极小------只在访问时检查。但其缺点是:如果某个过期键再也没有被访问,它将一直占用内存(即"过期垃圾")。因此Redis引入了主动过期来弥补这一缺陷。

## 主动过期

主动过期(Active Expiration)是后台定期执行的清理任务。

\> **定义:** 主动过期 Redis每隔100毫秒(默认)执行一次主动过期检查。每次检查随机抽取20个设置了过期时间的键,删除其中已过期的键。如果本次删除的键超过25\\%,则重复此过程。

主动过期的算法流程如下:

- 从所有设置了过期时间的键空间中随机抽取20个键。 - 删除其中已过期的键。 - 如果删除的键数量超过5个(即25\\%),则重复步骤1\\\~2。 - 每次循环最多执行25毫秒,超过则暂停,等待下次检查周期。 - 每次主动过期最多扫描16个数据库。

这种机制确保了:

- 过期键不会无限期占用内存(主动删除兜底)。 - CPU消耗可控(随机采样+时间限制)。 - 在大量键同时过期时,不会导致CPU瞬间飙升(有25ms上限)。

监控过期键的删除情况 使用`INFO stats`命令查看过期键的删除统计:

````` import redis

r = redis.Redis(decode_responses=True) info = r.info('stats')

print(f"已过期key总数: info\['expired_keys'\]") print(f"已驱逐key总数: info\['evicted_keys'\]")

## 通过对比前后两次的expired_keys来计算过期速率

import time

def monitor_expiry_rate(interval: int = 10): """监控键过期速率""" prev = r.info('stats')\['expired_keys'\] time.sleep(interval) curr = r.info('stats')\['expired_keys'\] rate = (curr - prev) / interval print(f"过期速率: rate:.1f keys/s") return rate `````

## 内存淘汰策略

当Redis使用的内存超过`maxmemory`配置的限制时,Redis会根据配置的内存淘汰策略(eviction policy)自动删除部分键以释放内存。

## 淘汰策略详解

Redis 6.0+支持以下内存淘汰策略:

\[htbp\] \\centering \|l\|l\|l\| \\hline **策略** \& **作用范围** \& **淘汰目标** \\\\ \\hline `noeviction` \& --- \& 不淘汰,写操作返回错误 \\\\ \\hline `allkeys-lru` \& 所有键 \& 淘汰最近最少使用的键 \\\\ \\hline `allkeys-lfu` \& 所有键 \& 淘汰最不常用的键 \\\\ \\hline `volatile-lru` \& 仅设置了TTL的键 \& 淘汰最近最少使用的键 \\\\ \\hline `volatile-lfu` \& 仅设置了TTL的键 \& 淘汰最不常用的键 \\\\ \\hline `volatile-ttl` \& 仅设置了TTL的键 \& 淘汰即将过期的键(TTL最小的) \\\\ \\hline `allkeys-random` \& 所有键 \& 随机淘汰 \\\\ \\hline `volatile-random` \& 仅设置了TTL的键 \& 随机淘汰 \\\\ \\hline

Redis内存淘汰策略一览

\> **定义:** noeviction `noeviction`是Redis的默认淘汰策略。当内存达到上限时,所有写操作(SET、LPUSH等)都会返回错误,但读操作仍然正常。这种策略适合将Redis当作可靠数据库的场景。

## LRU与LFU的近似实现

Redis并未实现精确的LRU(Least Recently Used)或LFU(Least Frequently Used),而是使用近似算法以节省内存。

\> **定义:** 近似LRU Redis的LRU近似算法通过采样来实现。当需要淘汰键时,Redis从键空间中随机抽取`maxmemory-samples`个键(默认5个),然后淘汰其中访问时间最早的键。采样数越大,淘汰结果越接近精确LRU,但CPU开销也越大。

\> **定义:** 近似LFU Redis 4.0引入的LFU(Least Frequently Used)近似算法使用"莫里斯计数器(Morris Counter)"来记录键的访问频率,以对数方式增长,使得热门的键计数增长更快,旧的计数随时间衰减。

LFU的实现包含两个关键参数:

- **lfu-log-factor** :计数器增长的对数因子,值越大,热门键的计数值增长越慢。 - **lfu-decay-time**:计数器衰减时间(分钟),值越大,计数衰减越慢。

设置最大内存

maxmemory 4gb

设置淘汰策略(推荐:缓存场景使用allkeys-lru)

maxmemory-policy allkeys-lru

LRU/LFU采样数(默认5,范围1-64)

maxmemory-samples 10

LFU相关配置

lfu-log-factor 10 lfu-decay-time 1 `````

> 💡 缓存场景推荐使用allkeys-lru策略,因为它会淘汰那些确实不再使用的键,无论它们是否设置了过期时间。volatile-lru可能导致大量未设TTL的键永远无法被淘汰,最终将所有可用内存耗尽。

缓存代理与实战模式

在大规模分布式系统中,直接管理大量Redis实例(尤其是集群模式)的复杂度较高。缓存代理层可以帮助简化客户端的管理工作。

Twemproxy

Twemproxy(也称为nutcracker)是Twitter开源的高性能Redis代理,通过一致性哈希将请求分发到后端Redis实例。

复制代码
## nutcracker.yml

alpha: listen: 0.0.0.0:22121 hash: fnv1a_64 distribution: ketama auto_eject_hosts: true redis: true server_retry_timeout: 30000 servers: - 10.0.0.1:6379:1 redis-node-1 - 10.0.0.2:6379:1 redis-node-2 - 10.0.0.3:6379:1 redis-node-3 - 10.0.0.4:6379:1 redis-node-4 `````

Twemproxy的优势在于轻量级和低延迟,但它不支持故障自动转移和在线扩容。

## Codis

Codis是一个分布式Redis解决方案,提供了类似Redis Cluster的功能但更加易用。

Codis核心组件

codis-proxy: 代理层,路由客户端请求

codis-dashboard: 集群管理控制台

codis-fe: Web管理界面

codis-redis: 基于Redis改进的数据节点

ZooKeeper/etcd: 存储元数据和协调

配置示例(codis.json)

"product_name": "codis-demo", "proxy_id": "proxy-1", "net_timeout": 5, "proto": "tcp4", "session_timeout": 300, "max_buf_size": 262144, "jodis_name": "zookeeper", "jodis_addr": "10.0.0.10:2181,10.0.0.11:2181"

复制代码
\> 💡 Codis已被Redis Cluster逐步取代。对于新项目,建议优先考虑官方Redis Cluster方案。Codis适合已有大量遗留系统需要从Redis 2.x/3.x迁移的场景。

## 企业级缓存实战模式

以下是一些经过验证的企业级缓存模式:

\\item\[**会话缓存(Session Cache)** \]:使用Redis存储用户会话信息,配合Sentinel实现高可用。键格式:`session:\session\_id\`,TTL与用户登录有效期一致。 \\item\[**页面缓存(Page Cache)** \]:将整个页面渲染结果缓存在Redis中,适用于动态内容少而访问量大的页面。TTL通常设为30\\\~300秒。 \\item\[**API响应缓存(API Response Cache)** \]:缓存API的JSON响应,配合参数化键名。例如:`api:/users?page=1\&size=20`。 \\item\[**数据库查询缓存(Query Cache)**\]:缓存复杂的SQL查询结果,根据表名和查询条件构建唯一键。

````` import redis import hashlib import json

class EnterpriseCacheManager: """企业级缓存管理器"""

def __init__(self, redis_config: dict): self.cache = redis.Redis( \*\*redis_config, decode_responses=True )

def session_cache(self, session_id: str, ttl: int = 1800): """会话缓存装饰器""" def decorator(func): def wrapper(*args,* \*kwargs): key = f"session:session_id" data = self.cache.get(key) if data: return json.loads(data) result = func(*args,*\*kwargs) if result: self.cache.setex(key, ttl, json.dumps(result)) return result return wrapper return decorator

def api_cache(self, ttl: int = 60): """API响应缓存装饰器""" def decorator(func): def wrapper(*args,*\*kwargs):

## 根据函数名和参数生成缓存键

raw_key = f"func.__name__:args:kwargs" key = f"api:hashlib.md5( raw_key.encode() ).hexdigest()" cached = self.cache.get(key) if cached: return json.loads(cached) result = func(*args,* \*kwargs) if result is not None: self.cache.setex(key, ttl, json.dumps(result)) return result return wrapper return decorator

def query_cache(self, sql: str, params: tuple, ttl: int = 120): """数据库查询缓存""" key = f"query:hashlib.md5( (sql + str(params)).encode() ).hexdigest()" cached = self.cache.get(key) if cached: return json.loads(cached)

## 执行数据库查询

result = self._execute_query(sql, params) if result: self.cache.setex(key, ttl, json.dumps(result)) return result

def invalidate_by_prefix(self, prefix: str): """按前缀批量失效缓存(使用SCAN)""" cursor = 0 while True: cursor, keys = self.cache.scan( cursor, match=f"prefix:\*", count=100 ) if keys: self.cache.delete(\*keys) if cursor == 0: break

def warm_up(self, keys_and_loaders: dict): """缓存预热""" for key, (loader, ttl) in keys_and_loaders.items(): if not self.cache.exists(key): data = loader() if data is not None: self.cache.setex(key, ttl, json.dumps(data)) print(f"预热完成: key") `````

\\section\*本章小结

缓存设计是Redis应用中最核心也最具挑战性的环节。本章从经典的缓存设计模式出发,深入分析了缓存穿透、缓存雪崩、缓存击穿三大问题的成因和解决方案,详细介绍了Redis的过期策略和内存淘汰机制,并给出了企业级的缓存实战模式。优秀的缓存设计需要综合考虑数据一致性、系统可用性和性能开销之间的平衡。在实际项目中,建议结合业务特点选择合适的缓存模式和淘汰策略,做好缓存预热和监控,并针对热点数据设计专门的保护方案。 \\endinput

---

## 第9章

## Redis Lua脚本与事务

chap:lua-transactions

在现代分布式应用中,原子性和一致性是数据操作的核心诉求。Redis提供了两种机制来实现复杂的原子操作:事务(Transactions)和Lua脚本。事务提供了基础的批量执行保证,而Lua脚本则将服务端计算能力推向了新的高度。本章将深入探讨这两种技术的原理、用法和最佳实践,帮助读者在实际项目中做出正确的技术选型。

## Redis事务机制

sec:redis-transactions

Redis的事务模型与关系型数据库的事务有显著差异。Redis事务通过`MULTI`、`EXEC`、`DISCARD`和`WATCH`四个命令实现,其核心特点是:批量执行、原子提交、但不支持回滚。

## MULTI/EXEC/DISCARD

\> **定义:** Redis事务 Redis事务是一组命令的集合,通过`MULTI`命令开启,随后所有命令会进入队列而非立即执行,直到`EXEC`命令被调用时才批量按序执行。`DISCARD`命令用于清空事务队列并退出事务状态。

事务的基本工作流程如下:

````` import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

## 开启事务

pipe = r.pipeline(transaction=True) pipe.set('account:a', 500) pipe.set('account:b', 300) pipe.incrby('account:a', -100) pipe.incrby('account:b', 100)

## 提交事务,所有命令原子执行

result = pipe.execute() print(result)

## \[True, True, 400, 400\]

事务的核心特性在于:所有命令在EXEC之前仅做入队操作,Redis不执行任何命令逻辑。这意味着在事务执行过程中,不会有其他客户端的命令插入进来。

> 💡 使用Redis管道(Pipeline)时,可以通过设置transaction=True来开启事务模式。但请注意,管道并不等同于事务------管道仅优化网络往返,而事务保证原子性。

> ⚠️ Redis事务不支持回滚!如果在事务执行期间某条命令失败(例如对字符串类型的键执行列表操作),后续命令仍然会继续执行。这与传统关系型数据库的事务行为有本质区别。

乐观锁与WATCH命令

Redis通过WATCH命令实现乐观锁(Optimistic Locking),这是实现CAS(Compare-And-Swap)模式的基础。WATCH命令在事务开始前监控一个或多个键,如果这些键在事务执行前被其他客户端修改,则当前事务会被中止。

> 定义: 乐观锁 乐观锁假设数据冲突的概率较低,在读取数据时不加锁,在提交更新时检查数据是否被修改过。Redis的WATCH命令实现了这一机制:监控键的版本号(实际上是数据内容),一旦发生变化,后续的EXEC将返回nil

import redis 复制代码
r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def transfer(from_acct, to_acct, amount): """使用乐观锁实现安全转账""" with r.pipeline(transaction=True) as pipe: while True: try:

## 监控两个账户

pipe.watch(from_acct, to_acct) balance_from = int(pipe.get(from_acct) or 0) balance_to = int(pipe.get(to_acct) or 0)

if balance_from \< amount: raise ValueError("余额不足")

## 开始事务

pipe.multi() pipe.set(from_acct, balance_from - amount) pipe.set(to_acct, balance_to + amount) pipe.execute()

## 成功则退出循环

break except redis.exceptions.WatchError:

## 数据被修改,重试

continue

## 初始化账户

r.set('account:a', 1000) r.set('account:b', 500)

transfer('account:a', 'account:b', 200) print(f"a: r.get('account:a'), b: r.get('account:b')")

## 输出: a: 800, b: 700

> 💡 WATCH配合MULTI/EXEC实现了乐观锁,但它的工作方式是被动的------Redis不会阻塞其他客户端,而是通过重试机制来应对冲突。在高并发场景下,冲突率上升会导致大量的重试,此时应考虑使用Lua脚本替代。

Redis事务的限制

Redis事务虽然简单易用,但存在一些明显的局限性:

  • 不支持回滚 :命令错误不会导致事务回滚,已执行的命令无法撤销。 - 无条件执行 :所有命令在EXEC前只是排队,无法根据之前命令的结果动态调整后续命令。 - WATCH的竞态窗口 :从WATCH到EXEC之间,其他客户端可能修改数据导致事务频繁重试。 - 性能开销:WATCH机制需要在服务端维护监控状态,增加了内存开销。

事务与管道的区别 下面的代码对比了管道和事务的行为差异: ````` import redis import time

r = redis.Redis(decode_responses=True)

管道模式(非事务):命令发送即执行

pipe = r.pipeline(transaction=False) pipe.set('x', 1) pipe.set('y', 2) pipe.execute()

此时x=1, y=2

事务模式:所有命令在execute时一次性执行

pipe = r.pipeline(transaction=True) pipe.set('x', 10) pipe.set('y', 20)

此时x仍然为1, y仍然为2

pipe.execute()

此时x=10, y=20(原子切换)

复制代码
## Lua脚本编程

sec:lua-scripting

Lua脚本是Redis中实现复杂原子操作的更强大工具。Redis内嵌了Lua 5.1解释器,允许用户在服务端执行Lua脚本,从而将多个命令合并为一个原子操作。

## EVAL与EVALSHA

\> **定义:** Lua脚本 Redis Lua脚本是一段用Lua语言编写的程序,通过`EVAL`命令提交到Redis服务端执行。脚本在Redis的沙箱环境中运行,可以调用Redis的全部API,并且保证原子性------脚本执行期间,其他客户端的命令不会被处理。

````` import redis

r = redis.Redis(decode_responses=True)

## Lua脚本:原子地检查并设置值(SETNX的进阶版)

lua_script = """ local key = KEYS\[1\] local expected = ARGV\[1\] local new_value = ARGV\[2\]

local current = redis.call('GET', key) if current == expected then redis.call('SET', key, new_value) return 1 end return 0 """

## 执行脚本

r.set('mykey', 'old_value') result = r.eval(lua_script, 1, 'mykey', 'old_value', 'new_value') print(f"CAS结果: result")

## 输出: CAS结果: 1

print(f"当前值: r.get('mykey')")

## 输出: 当前值: new_value

脚本执行的关键要素:

  • KEYS数组 :传递Redis键名,用于在集群模式下正确路由到对应节点。 - ARGV数组 :传递参数值,不参与键路由。 - 数字参数 :第一个数字参数指定KEYS数组的长度。 - redis.call() :调用Redis命令,失败时抛出异常。 - redis.pcall():调用Redis命令,失败时返回错误对象而非抛出异常。

SCRIPT LOAD与脚本缓存

每次调用EVAL时,Redis都需要解析和编译Lua脚本,这会带来额外的性能开销。为了提高效率,Redis提供了脚本缓存机制。

> 💡 生产环境中应优先使用EVALSHA替代EVALEVALSHA通过脚本的SHA1摘要直接执行缓存中的脚本,避免了重复编译的开销。如果脚本未被缓存,Redis会返回NOSCRIPT错误,此时再回退到EVAL

import redis import hashlib 复制代码
r = redis.Redis(decode_responses=True)

SCRIPT = """ local count = redis.call('INCR', KEYS\[1\]) if count == 1 then redis.call('EXPIRE', KEYS\[1\], ARGV\[1\]) end return count """

## 加载脚本到缓存

script_hash = r.script_load(SCRIPT) print(f"脚本SHA1: script_hash")

## 使用EVALSHA执行

try: result = r.evalsha(script_hash, 1, 'counter:ip:192.168.1.1', '60') print(f"计数: result") except redis.exceptions.ResponseError as e: if 'NOSCRIPT' in str(e):

## 回退到EVAL(缓存可能被FLUSHALL清空)

result = r.eval(SCRIPT, 1, 'counter:ip:192.168.1.1', '60')

## 管理脚本缓存

## script_flush = r.script_flush() # 清空脚本缓存

## script_exists = r.script_exists(script_hash) # 检查脚本是否存在

> ⚠️ Redis脚本缓存不会被持久化。当Redis重启后,所有缓存的脚本都会丢失。因此,应用层必须实现EVALSHA失败时的回退逻辑,确保在任何情况下都能正常工作。

Lua沙箱与安全限制

为了防止恶意脚本影响Redis服务稳定性,Lua脚本运行在受限的沙箱环境中:

> 定义: Lua沙箱 Redis Lua沙箱限制了脚本可用的全局变量和函数。脚本不能访问文件系统、网络、操作系统接口,也不能加载外部库。脚本可用的全局函数被严格限制,例如mathstringtable等标准库可用,而osio等库被禁用。

关键限制包括:

  • 无全局状态 :脚本之间的全局变量不共享,每次执行都是独立的环境。 - 无副作用函数 :不能调用os.execute()io.open()等危险函数。 - 随机函数math.randommath.randomseed被限制使用。 - 超时保护 :默认Lua脚本最大执行时间为5秒,可以通过lua-time-limit配置调整。 - 日志限制:脚本不能写入日志文件或输出到标准流。

> 💡 从Redis 7.0开始,可以通过ACL规则对Lua脚本的调用进行细粒度权限控制,例如限制某些用户只能调用只读脚本。

Lua脚本实战案例

sec:lua-practical

Lua脚本的强大之处在于它能够将多个Redis命令封装为一个原子操作,消除竞态条件。本节将通过三个典型的实战案例展示Lua脚本的应用。

基于Lua的限流器

滑动窗口限流器 使用Lua脚本实现一个高效的滑动窗口限流器,相比传统的SETEX+NX模式,这种方式更加精确且原子: ````` import redis import time

r = redis.Redis(decode_responses=True)

RATE_LIMIT_SCRIPT = """ local key = KEYS1 -- 限流键名 local limit = tonumber(ARGV1) -- 最大请求数 local window = tonumber(ARGV2) -- 时间窗口(秒) local now = tonumber(ARGV3) -- 当前时间戳(毫秒)

-- 移除窗口外的过期记录 redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)

-- 获取当前窗口内的请求数 local current = redis.call('ZCARD', key)

if current < limit then -- 添加当前请求 redis.call('ZADD', key, now, now .. ':' .. math.random()) redis.call('EXPIRE', key, window) return 1 -- 允许请求 end return 0 -- 拒绝请求 """

加载脚本

rate_limit_sha = r.script_load(RATE_LIMIT_SCRIPT)

def rate_limit(user_id, limit=10, window=1): """检查用户是否超过限流阈值""" key = f"ratelimit:user_id" now_ms = int(time.time() * 1000) result = r.evalsha(rate_limit_sha, 1, key, str(limit), str(window), str(now_ms)) return result == 1

测试限流

user = "user:1001" for i in range(15): allowed = rate_limit(user, limit=10, window=5) if allowed: print(f"请求 i+1: 通过") else: print(f"请求 i+1: 限流") time.sleep(0.05) `````

基于Lua的分布式锁

分布式锁(含续期机制) 基于Lua脚本实现一个完整的分布式锁,包含自动续期和防误删机制: ````` import redis import uuid import time import threading

r = redis.Redis(decode_responses=True)

加锁脚本(SET NX + EXPIRE原子操作)

LOCK_SCRIPT = """ local key = KEYS1 local token = ARGV1 local ttl = tonumber(ARGV2)

local result = redis.call('SET', key, token, 'NX', 'PX', ttl) if result then return 1 end return 0 """

解锁脚本(仅当token匹配时才删除)

UNLOCK_SCRIPT = """ local key = KEYS1 local token = ARGV1

local current = redis.call('GET', key) if current == token then redis.call('DEL', key) return 1 end return 0 """

续期脚本

RENEW_SCRIPT = """ local key = KEYS1 local token = ARGV1 local ttl = tonumber(ARGV2)

local current = redis.call('GET', key) if current == token then redis.call('PEXPIRE', key, ttl) return 1 end return 0 """

lock_sha = r.script_load(LOCK_SCRIPT) unlock_sha = r.script_load(UNLOCK_SCRIPT) renew_sha = r.script_load(RENEW_SCRIPT)

class RedisLock: def init(self, key, ttl=30000): self.key = key self.ttl = ttl self.token = str(uuid.uuid4()) self.renew_thread = None self.running = False

def acquire(self): result = r.evalsha(lock_sha, 1, self.key, self.token, str(self.ttl)) if result == 1: self._start_renew() return True return False

def _start_renew(self): self.running = True def _renew(): while self.running: time.sleep(self.ttl / 3000)

在TTL的1/3处续期

r.evalsha(renew_sha, 1, self.key, self.token, str(self.ttl)) self.renew_thread = threading.Thread(target=_renew, daemon=True) self.renew_thread.start()

def release(self): self.running = False r.evalsha(unlock_sha, 1, self.key, self.token)

def enter(self): while not self.acquire(): time.sleep(0.01) return self

def exit(self, *args): self.release()

使用示例

with RedisLock("resource:order:1001", ttl=30000): print("持有锁,执行业务逻辑...") time.sleep(2) print("锁已释放") `````

批量操作优化

原子批量更新 使用Lua脚本实现原子批量操作,减少网络往返并保证数据一致性: ````` import redis

r = redis.Redis(decode_responses=True)

BATCH_UPDATE_SCRIPT = """ local result = for i = 1, #KEYS do local key = KEYSi local quantity = tonumber(ARGVi) local current = tonumber(redis.call('GET', key) or '0')

if current + quantity < 0 then -- 库存不足,回滚已修改的键 for j = 1, i-1 do local prev_quantity = tonumber(ARGVj) redis.call('SET', KEYSj, tonumber(redis.call('GET', KEYSj)) - prev_quantity) end return err = "insufficient stock at " .. key end

redis.call('SET', key, current + quantity) table.insert(result, current + quantity) end return result """

初始化库存

r.set('stock:sku:1001', 10) r.set('stock:sku:1002', 5) r.set('stock:sku:1003', 20)

批量扣减库存

result = r.eval(BATCH_UPDATE_SCRIPT, 3, 'stock:sku:1001', 'stock:sku:1002', 'stock:sku:1003', '-2', '-1', '-3') print(f"更新后库存: result")

尝试超卖(应该失败)

result = r.eval(BATCH_UPDATE_SCRIPT, 1, 'stock:sku:1002', '-10') print(f"超卖结果: result") `````

事务与Lua脚本的对比

sec:tx-vs-lua

在实际开发中,选择事务还是Lua脚本取决于具体场景。下表从多个维度对比了两者的差异:

H \centering |l|c|c| \hline 特性 & 事务 & Lua脚本 \\ \hline 原子性 & 是 & 是 \\ \hline 条件逻辑 & 不支持 & 完全支持 \\ \hline 错误回滚 & 不支持 & 可编程实现 \\ \hline 网络往返 & 多次(WATCH+MULTI+EXEC) & 一次 \\ \hline 可读性 & 高(Redis命令直写) & 中等(需Lua语法) \\ \hline 调试难度 & 低 & 较高 \\ \hline 缓存支持 & 无 & SCRIPT LOAD缓存 \\ \hline 性能 & 中等 & 高(减少网络IO) \\ \hline 集群兼容 & 键需在同一节点 & 键需在同一slot \\ \hline

事务与Lua脚本对比

> 💡 选择建议:如果操作仅涉及简单的批量SET/GET且不需要条件判断,使用事务即可;如果需要在服务端执行判断、循环或复杂的业务逻辑,Lua脚本是更好的选择。事实上,Redis官方也推荐在需要原子性的复杂操作中使用Lua脚本。

性能基准

在相同条件下,Lua脚本的性能优势非常明显:

延迟_事务 &= N \times t_网络往返 + t_执行 \\ 延迟_Lua &= t_网络往返 + N \times t_命令 \\ 加速比 &\approx N \times t_网络往返t_网络往返 = N

其中 N 是命令数量,t_网络往返 是单次网络延迟。当 N=10、网络延迟为 1ms 时,Lua脚本比事务快约10倍。

Redis 7.0函数特性

sec:redis-functions

Redis 7.0引入了函数(Functions)特性,这是对Lua脚本机制的全面升级,提供了更好的管理和复用能力。

函数与脚本的对比

> 定义: Redis函数 Redis函数是一种可管理的、持久化的服务端脚本。与EVAL/EVALSHA不同,函数通过FCALL命令调用,且函数库可以批量加载、删除和列出。函数的主要优势包括:

  • 函数库可以包含多个函数,组织更加清晰。 - 函数可以被持久化(通过RDB/AOF),重启后无需重新加载。 - 支持版本管理和代码演进。 - 更细粒度的ACL控制。
import redis 复制代码
r = redis.Redis(decode_responses=True)

## 定义函数库(包含多个函数)

function_library = """ #!lua name=mylib local function check_and_set(keys, args) local key = keys\[1\] local expected = args\[1\] local new_value = args\[2\] local current = redis.call('GET', key) if current == expected then redis.call('SET', key, new_value) return 1 end return 0 end

local function incr_with_ttl(keys, args) local key = keys\[1\] local ttl = tonumber(args\[1\]) local count = redis.call('INCR', key) if count == 1 then redis.call('EXPIRE', key, ttl) end return count end

redis.register_function('cas', check_and_set) redis.register_function('incr_ttl', incr_with_ttl) """

## 加载函数库

r.function_load(function_library)

## 调用函数

r.set('mykey', 'hello') result = r.fcall('mylib', 'cas', 1, 'mykey', 'hello', 'world') print(f"CAS结果: result")

result = r.fcall('mylib', 'incr_ttl', 1, 'counter:test', '60') print(f"计数结果: result")

## 管理函数

## r.function_list() -- 列出所有已加载的函数

## r.function_delete('mylib') -- 删除函数库

## r.function_flush() -- 清空所有函数

> 💡 对于新项目,建议优先使用Redis 7.0函数替代传统的EVAL/EVALSHA脚本。函数提供了更好的管理能力、持久化支持和版本控制,是Redis脚本编程的未来方向。

函数的优势场景

函数特别适合以下场景:

  • 共享代码库 :多个应用共享同一套Redis脚本逻辑,通过函数库统一管理和发布。 - 微服务架构 :每个微服务可以加载自己的函数库,函数名天然形成命名空间。 - 运维自动化 :函数可以通过Redis命令在线加载和更新,无需重启应用。 - 安全审计:ACL权限控制可以精确到单个函数级别。

调试Lua脚本

sec:debug-lua

开发和调试Lua脚本相比调试应用代码更具挑战性,因为脚本运行在Redis沙箱中。

常用调试技巧

Lua脚本调试方法 以下是一些实用的调试技巧: ````` import redis

r = redis.Redis(decode_responses=True)

技巧1:使用redis.log写入Redis日志

DEBUG_SCRIPT = """ local key = KEYS1 local value = redis.call('GET', key)

redis.log(redis.LOG_WARNING, string.format( "Debug: key=%s, value=%s", key, tostring(value)))

if value then -- 记录value的长度 redis.log(redis.LOG_NOTICE, string.format( "Value length: %d", #value)) return tonumber(value) * 2 end return -1 """

查看日志:在Redis中执行 CONFIG SET loglevel debug

result = r.eval(DEBUG_SCRIPT, 1, 'test:key') print(f"结果: result") `````

> 💡 redis.log()函数支持四个日志级别:redis.LOG\_DEBUGredis.LOG\_VERBOSEredis.LOG\_NOTICEredis.LOG\_WARNING。日志会输出到Redis的日志文件中,需要将loglevel设置为debug才能看到LOG\_DEBUG级别的信息。

常见错误与处理

编写Lua脚本时的常见陷阱:

> ⚠️ Lua中的数组索引从1开始,而不是0!这是Lua脚本中最常见的错误来源。例如,KEYS[1]代表第一个键名,KEYS[0]nil

  • 返回值类型 :Lua脚本的返回值必须是Redis支持的格式(字符串、数字、数组、nil)。table类型会被自动转换为Redis数组。 - 浮点数精度 :Redis命令对浮点数的支持有限,建议在Lua中处理数值时使用整数或字符串形式的十进制数。 - 时间函数 :不要依赖os.time(),它在Redis沙箱中不可用。应使用redis.call('TIME')获取服务端时间。

Lua脚本反例 展示常见的错误写法: `````

错误示例1:使用os.time(在沙箱中不可用)

BAD_SCRIPT_1 = """ -- 错误!os.time()在Redis沙箱中不存在 -- local now = os.time() local now = redis.call('TIME')1 -- 正确 return now """

错误示例2:假定数组索引从0开始

BAD_SCRIPT_2 = """ -- 错误!KEYS0是nil -- local key = KEYS0 local key = KEYS1 -- 正确 return redis.call('GET', key) """

错误示例3:忘记转换类型

BAD_SCRIPT_3 = """ local count = redis.call('GET', 'counter') -- 错误!GET返回字符串,不能直接算术运算 -- count = count + 1 count = tonumber(count or '0') + 1 -- 正确 return count """ `````

> 💡 调试Lua脚本时,可以使用Redis Insights等可视化工具,它们提供了内置的Lua脚本编辑器和调试支持。此外,将复杂逻辑拆分为多个小脚本逐步测试也是很好的实践。

本章小结

本章深入探讨了Redis事务和Lua脚本两大原子操作机制。

  • Redis事务通过MULTI/EXEC/WATCH提供了基础的批量原子执行能力,适合简单的命令组合场景。其最大限制是不支持回滚和条件逻辑。 - Lua脚本是更强大的原子操作工具,支持复杂的业务逻辑、条件判断和循环,性能优于事务,是生产环境中的首选方案。 - 通过实战案例展示了限流器、分布式锁和批量操作三个典型场景的Lua实现。 - Redis 7.0引入的函数特性代表了脚本编程的未来方向,提供了更好的管理和复用能力。

在实际项目中,建议遵循以下原则:简单批量操作使用管道或事务,复杂原子操作使用Lua脚本,新项目优先考虑Redis 7.0函数特性。无论选择哪种方式,都需要充分理解其限制和权衡,才能构建出健壮的Redis应用。


第10章

Redis Pub/Sub与消息队列

chap:pubsub-streams

消息队列是分布式系统中不可或缺的基础组件,用于解耦服务、异步处理和流量削峰。Redis提供了两种消息通信机制:经典发布/订阅(Pub/Sub)模式和Redis 5.0引入的Stream数据类型。本章将详细讲解这两种机制的原理、用法和适用场景,并与其他主流消息队列进行对比分析。

Pub/Sub发布/订阅模式

sec:pubsub

Redis Pub/Sub是一种经典的观察者模式实现,支持消息的广播分发。它采用火急火燎(Fire-and-Forget)的语义------消息发送者将消息发布到频道,所有订阅该频道的客户端都能实时收到消息。

核心命令与模型

> 定义: 发布/订阅模型 Pub/Sub模型包含三个核心角色:发布者(Publisher)、频道(Channel)和订阅者(Subscriber)。发布者将消息发送到指定的频道,Redis负责将消息分发给所有订阅该频道的客户端。发布者和订阅者之间完全解耦,互不知晓对方的存在。

Pub/Sub的核心命令如下:

  • SUBSCRIBE channel [channel ...]:订阅一个或多个频道。 - UNSUBSCRIBE [channel ...]:退订频道。 - PUBLISH channel message:向频道发送消息。 - PSUBSCRIBE pattern [pattern ...]:按模式订阅频道。 - PUNSUBSCRIBE [pattern ...]:按模式退订。 - PUBSUB CHANNELS [pattern]:查看当前活跃频道。
import redis import threading import time 复制代码
r = redis.Redis(decode_responses=True)

def subscriber(): """订阅者:实时接收消息""" pubsub = r.pubsub() pubsub.subscribe('news:sports', 'news:tech')

print("订阅者已启动,等待消息...") for message in pubsub.listen(): if message\['type'\] == 'message': print(f"\[message\['channel'\]\] message\['data'\]")

## 启动订阅者线程

sub_thread = threading.Thread(target=subscriber, daemon=True) sub_thread.start()

time.sleep(0.5)

## 等待订阅者启动

## 发布者:发送消息

r.publish('news:sports', '湖人队赢得总冠军!') r.publish('news:tech', 'Redis 8.0发布原生向量搜索功能') r.publish('news:sports', 'NBA常规赛今日战报')

time.sleep(1) `````

## 模式订阅

模式订阅(Pattern Subscription)允许客户端通过通配符模式订阅一组频道,极大简化了多频道管理的复杂度。

模式订阅示例 使用`PSUBSCRIBE`订阅所有以`news:`为前缀的频道: ````` import redis

r = redis.Redis(decode_responses=True) pubsub = r.pubsub()

## 订阅所有news频道

pubsub.psubscribe('news:\*')

## 订阅所有用户通知

pubsub.psubscribe('user:\*:notification')

## 订阅所有系统事件

pubsub.psubscribe('event.\*')

def handle_message(message): if message\['type'\] == 'pmessage': pattern = message\['pattern'\] channel = message\['channel'\] data = message\['data'\] print(f"\[匹配模式: pattern\] 频道: channel -\> data")

for message in pubsub.listen(): handle_message(message)

## 在另一个进程中发布消息

## r.publish('news:finance', '股市收盘大涨')

## r.publish('user:1001:notification', '您有一条新消息')

## r.publish('event.login', '用户1001登录系统')

> 💡 模式订阅的内部实现使用了Radix Tree(基数树),能够高效地匹配大量频道模式。但需要注意,每个收到的消息需要与所有订阅模式进行匹配,过多的模式订阅会增加CPU开销。

Pub/Sub的局限性

Pub/Sub模型虽然简单易用,但在生产环境中存在一些显著的局限性:

> ⚠️ Redis Pub/Sub的消息不会持久化!如果订阅者在消息发布时不在线,消息将永远丢失。这与专业消息队列(如Kafka、RabbitMQ)的持久化机制有本质区别。

主要限制包括:

  • 无消息持久化 :消息发送后即被丢弃,不写入任何存储。 - 无消息积压 :订阅者消费速度跟不上发布速度时,超出内核缓冲区(默认为TCP backlog)的消息会被丢弃。 - 无确认机制 :无法确认订阅者是否成功处理了消息。 - 无顺序保证 :在高并发下,不同订阅者收到的消息顺序可能不一致。 - 水平扩展困难:发布者和订阅者必须连接到同一台Redis节点。

> 💡 Redis Pub/Sub适合对消息可靠性要求不高的实时通知场景,例如:实时聊天广播、配置变更通知、系统告警推送等。对于需要消息可靠投递的场景,应该使用Redis Streams或专业消息队列。

Redis Streams

sec:streams

Redis Streams是Redis 5.0引入的一种全新的数据类型,它借鉴了Kafka的消费者组模型,同时保持了Redis的简洁高效。Stream弥补了Pub/Sub在消息持久化和可靠性方面的不足,为Redis构建消息队列提供了原生支持。

Stream数据结构

> 定义: Stream Redis Stream是一个只追加的日志结构(Append-Only Log),每个条目都有一个唯一的64位整数ID。Stream可以保存任意数量的消息,消息以键值对集合(field-value pairs)的形式存储。Stream支持按时间范围查询、阻塞读取和消费者组管理。

核心命令一览:

  • XADD key * field value [field value ...]:追加消息到Stream,返回自动生成的ID。 - XREAD COUNT n BLOCK ms STREAMS key [key ...] id [id ...]:读取消息,支持阻塞。 - XRANGE key start end [COUNT n]:按ID范围查询消息。 - XREVRANGE key end start [COUNT n]:反向查询消息。 - XLEN key:获取Stream长度。 - XTRIM key MAXLEN ~ n:裁剪Stream长度。 - XDEL key id [id ...]:删除指定消息。
import redis 复制代码
r = redis.Redis(decode_responses=True)

## 追加消息到Stream

stream_key = 'mystream'

## XADD自动生成消息ID

msg_id = r.xadd(stream_key, 'user_id': '1001', 'action': 'login', 'ip': '192.168.1.1' ) print(f"消息ID: msg_id")

## 追加多条消息

r.xadd(stream_key, 'user_id': '1002', 'action': 'purchase', 'amount': '299') r.xadd(stream_key, 'user_id': '1003', 'action': 'logout')

## 查询Stream长度

length = r.xlen(stream_key) print(f"Stream长度: length")

## 按范围读取消息(从开始到结束,最多2条)

messages = r.xrange(stream_key, count=2) print("最近2条消息:") for msg_id, msg_data in messages: print(f" \[msg_id\] msg_data")

## 读取最新消息(阻塞模式,等待5秒)

messages = r.xread(streams=stream_key: '0', count=10, block=5000) for key, msgs in messages: for msg_id, msg_data in msgs: print(f"读取: \[msg_id\] msg_data") `````

## 消息ID与时间戳

Stream的消息ID由两部分组成:`<毫秒时间戳>-<序列号>`。这种设计使得消息天然按时间排序,并支持按时间范围查询。

消息ID \&= timestamp \\times 1000 + sequence \\\\ timestamp \&= Redis服务器本地毫秒时间戳 \\\\ sequence \&= 同一毫秒内的递增序号,从0开始

消息ID的精确控制 在某些场景下,开发者可能需要指定自定义消息ID: ````` import redis

r = redis.Redis(decode_responses=True)

## 使用自定义ID(必须单调递增)

## 格式: timestamp-sequence

r.xadd('events', 'event': 'start', id='0-1') r.xadd('events', 'event': 'config_loaded', id='0-2') r.xadd('events', 'event': 'ready', id='0-3')

## MAXID方式:自动使用当前时间

r.xadd('events', 'event': 'running', id='\*')

## 范围查询:查询所有ID \> '0-1' 的消息

messages = r.xrange('events', min='0-2', max='+') print("ID \>= 0-2 的消息:") for msg_id, data in messages: print(f" \[msg_id\] data")

## 按时间范围查询(2025年1月1日0点后的消息)

## min_id = '1735689600000-0', max_id = '+'

> 💡 除非有特殊需求(如消息迁移),否则建议始终使用自动生成的消息ID('*)。自动ID保证了全局唯一性和单调递增,且包含了精确的时间戳信息。

消费者组模型

消费者组(Consumer Group)是Stream最强大的特性,它将Stream转换为一个真正的消息队列,支持消息的负载均衡、确认机制和故障恢复。

> 定义: 消费者组 消费者组允许多个消费者协同消费同一个Stream中的消息。每条消息只会被组内的一个消费者处理,实现消息的负载均衡。消费者组维护了每个消费者的消费状态,包括已确认消息和待处理消息。

消费者组的核心命令:

  • XGROUP CREATE key groupname id [MKSTREAM]:创建消费者组。 - XREADGROUP GROUP group consumer [COUNT n] [BLOCK ms] STREAMS key [key ...] id [id ...]:在消费者组中读取消息。 - XACK key group id [id ...]:确认消息处理完成。 - XPENDING key group [start end count [consumer]]:查看待处理消息。 - XCLAIM key group consumer min-idle-time id [id ...]:声明消息所有权。
import redis import threading import time 复制代码
r = redis.Redis(decode_responses=True)

STREAM_KEY = 'task:queue' GROUP_NAME = 'workers' CONSUMER_ID = f'worker-threading.get_ident()'

## 创建消费者组(如果不存在)

try: r.xgroup_create(STREAM_KEY, GROUP_NAME, id='0', mkstream=True) except redis.exceptions.ResponseError as e: if 'BUSYGROUP' not in str(e): raise

def producer(): """生产者:向Stream添加任务""" for i in range(20): r.xadd(STREAM_KEY, 'task_id': f'task-i', 'type': 'email' if i % 2 == 0 else 'sms', 'payload': f'任务内容 #i' ) print(f"生产者: 添加任务 task-i") time.sleep(0.1)

def consumer(consumer_name): """消费者:从消费者组读取并处理任务""" r = redis.Redis(decode_responses=True) print(f"消费者 consumer_name 启动")

while True: try:

## 从消费者组读取消息

results = r.xreadgroup( GROUP_NAME, consumer_name, streams=STREAM_KEY: '\>', count=1, block=2000 )

if not results: print(f"消费者 consumer_name: 无新任务,等待中...") break

for stream, messages in results: for msg_id, data in messages: task_id = data.get('task_id', b'unknown').decode() task_type = data.get('type', b'unknown').decode() print(f"消费者 consumer_name: 处理 task_id (task_type)")

## 模拟处理时间

time.sleep(0.3)

## 确认消息已处理

r.xack(STREAM_KEY, GROUP_NAME, msg_id) print(f"消费者 consumer_name: 确认 task_id")

except Exception as e: print(f"消费者 consumer_name 异常: e") time.sleep(1)

## 启动生产者和两个消费者

prod_thread = threading.Thread(target=producer, daemon=True) cons1 = threading.Thread(target=consumer, args=('worker-1',), daemon=True) cons2 = threading.Thread(target=consumer, args=('worker-2',), daemon=True)

cons1.start() cons2.start() time.sleep(0.2) prod_thread.start()

time.sleep(5) print("\\n--- 示例结束 ---") `````

## PEL与消息确认

PEL(Pending Entries List,待处理条目列表)是消费者组机制的核心组件,它确保了消息的可靠投递。

\> **定义:** PEL PEL记录了每个消费者已接收但尚未确认的消息。当消费者使用`XREADGROUP`读取消息后,消息会被添加到该消费者的PEL中,直到消费者调用`XACK`确认处理完成。PEL的存在使得系统可以在消费者崩溃后重新分配消息(通过`XCLAIM`)。

````` import redis

r = redis.Redis(decode_responses=True) stream = 'orders' group = 'order-processors'

## 查看待处理消息

pending = r.xpending(stream, group) print(f"待处理消息统计: 总数=pending.get('pending', 0)")

## 查看特定消费者的待处理消息

pending_detail = r.xpending_range( stream, group, min='-', max='+', count=10 ) print("待处理消息详情:") for entry in pending_detail: msg_id = entry.get('message_id') consumer = entry.get('consumer') idle_time = entry.get('time_since_delivered', 0) delivery_count = entry.get('times_delivered', 0) print(f" ID=msg_id, 消费者=consumer, " f"空闲=idle_timems, 投递次数=delivery_count")

## XCLAIM:将消息重新分配给另一个消费者

## 参数:stream, group, new_consumer, min_idle_time, message_ids

claimed = r.xclaim( stream, group, 'worker-recovery', min_idle_time=30000,

## 消息空闲超过30秒才进行claim

message_ids=\['1735689600000-0'\] ) print(f"已声明消息: claimed") `````

\> 💡 PEL的管理至关重要。长时间不确认的消息会堆积在PEL中,导致内存占用增长。建议设置监控告警,当PEL大小超过阈值时及时预警。对于已确认无法处理的消息,可以考虑将其转移到死信队列。

## Stream深入应用

sec:stream-applications

## 消息积压监控

Stream作为日志结构,天然支持消息积压管理:

Stream积压监控 ````` import redis import time

r = redis.Redis(decode_responses=True) stream = 'log:events'

def check_backlog(): """检查消息积压情况"""

## Stream总长度

total = r.xlen(stream)

## 每个消费者组的延迟

groups = r.xinfo_groups(stream) for g in groups: name = g\['name'\] consumers = g\['consumers'\] pending = g\['pending'\]

## 未消费消息数 ≈ 总长度 - 最大已确认ID的位置

print(f" 组 'name': 消费者=consumers, 待处理=pending")

return total

def set_maxlen(limit=10000): """设置Stream最大长度(自动裁剪旧消息)""" r.xtrim(stream, maxlen=limit, approximate=True) print(f"已裁剪Stream到约limit条消息")

## 定期监控

while True: backlog = check_backlog() print(f"当前消息积压: backlog") if backlog \> 5000: print("⚠️ 积压超过阈值,建议扩容消费者!") time.sleep(5) `````

## 死信队列机制

当消息多次重试仍然失败时,应该将其移入死信队列(DLQ)以避免阻塞正常消息的处理。

死信队列实现 ````` import redis

r = redis.Redis(decode_responses=True) stream = 'tasks' group = 'workers' dlq = 'tasks:dead-letter' MAX_RETRIES = 3

def process_with_dlq(): """带死信队列的消息处理""" results = r.xreadgroup( group, 'worker-1', streams=stream: '\>', count=1, block=1000 )

if not results: return

for _, messages in results: for msg_id, data in messages:

## 检查重试次数

pending_info = r.xpending_range( stream, group, min=msg_id, max=msg_id, count=1 ) retry_count = 0 if pending_info: retry_count = pending_info\[0\].get('times_delivered', 1)

try:

## 处理消息

process_message(data)

## 处理成功,确认

r.xack(stream, group, msg_id) print(f"成功处理: msg_id")

except Exception as e: if retry_count \>= MAX_RETRIES:

## 超过最大重试次数,移入死信队列

r.xadd(dlq, 'original_stream': stream, 'original_id': msg_id, 'data': str(data), 'error': str(e), 'retries': retry_count ) r.xack(stream, group, msg_id)

## 从原队列确认

print(f"移入死信队列: msg_id (重试retry_count次)") else: print(f"处理失败,将重试: msg_id (retry_count/MAX_RETRIES)")

def process_message(data): """模拟消息处理,可能抛出异常""" import random if random.random() \< 0.3:

## 30%概率失败

raise ValueError("模拟处理失败") print(f"处理数据: data") `````

## Stream vs Pub/Sub vs 传统方案

sec:stream-comparison

在实际项目中选择消息通信机制时,需要综合考虑多个维度的因素。

\[H\] \\centering \|l\|c\|c\|c\|c\| \\hline **特性** \& **Pub/Sub** \& **Stream** \& **List队列** \& **Kafka** \\\\ \\hline 消息持久化 \& 否 \& 是(RDB/AOF) \& 是 \& 是(磁盘) \\\\ \\hline 消息可靠性 \& 无确认 \& ACK机制 \& BRPOPLPUSH \& Offset提交 \\\\ \\hline 消息积压 \& 否(丢失) \& 可设置MaxLen \& 有(内存) \& 有(磁盘) \\\\ \\hline 消费组 \& 不支持 \& 原生支持 \& 需自行实现 \& 原生支持 \\\\ \\hline 重试机制 \& 无 \& PEL+XCLAIM \& 需自行实现 \& Offset重置 \\\\ \\hline 顺序保证 \& 弱 \& 强 \& 强 \& 分区内强 \\\\ \\hline 性能(吞吐) \& 极高 \& 高 \& 高 \& 极高 \\\\ \\hline 运维复杂度 \& 低 \& 中 \& 低 \& 高 \\\\ \\hline 功能丰富度 \& 低 \& 高 \& 低 \& 极高 \\\\ \\hline

消息通信机制对比

## 技术选型建议

\> 💡 根据不同的业务需求,推荐以下选型方案:

- **实时广播通知** (如在线人数推送、系统告警)→ **Pub/Sub** - **可靠的任务队列** (如订单处理、异步任务)→ **Stream** - **简单的FIFO队列** (如日志收集缓冲)→ **List队列** - **大数据量消息管道** (如事件溯源、日志聚合)→ **Kafka**

## List队列的局限性

使用List(`LPUSH/BRPOP`)实现消息队列是一种常见但对简单做法:

````` import redis

r = redis.Redis(decode_responses=True)

## List队列模式(不推荐用于生产环境)

def list_queue_producer(queue, message): r.lpush(queue, message)

def list_queue_consumer(queue):

## BRPOP: 阻塞式弹出,支持超时

_, message = r.brpop(queue, timeout=0) return message

## List队列的问题:

## 1. 无确认机制 - 消费者崩溃导致消息丢失

## 2. 无消费者组 - 无法实现负载均衡

## 3. 单条消息 - 只能存储字符串,不能存储结构化数据

## 4. 无重试机制 - 需要自行实现

## Stream解决方案

def stream_producer(stream, data): r.xadd(stream, data)

def stream_consumer(stream, group, consumer): results = r.xreadgroup(group, consumer, stream: '\>', count=1) for _, messages in results: for msg_id, data in messages:

## 处理消息...

r.xack(stream, group, msg_id)

## 显式确认

> ⚠️ 使用List实现消息队列虽然简单,但在生产环境中风险较高。消息丢失、消费竞争和故障恢复等问题都需要自行实现,且实现复杂度远高于使用现成的Stream机制。对于新项目,建议优先考虑Stream。

高级模式与实践

sec:stream-patterns

事件通知系统

Stream是构建事件驱动架构的理想数据载体:

事件通知系统 ````` import redis import json import time

r = redis.Redis(decode_responses=True)

class EventBus: """基于Stream的事件总线"""

def init(self, stream_name='events'): self.stream = stream_name

def emit(self, event_type, payload): """发布事件""" event = 'type': event_type, 'timestamp': time.time(), 'payload': json.dumps(payload) return r.xadd(self.stream, event)

def on(self, group_name, consumer_name, event_types=None): """注册事件消费者""" try: r.xgroup_create(self.stream, group_name, id='$', mkstream=True) except redis.exceptions.ResponseError: pass

while True: results = r.xreadgroup( group_name, consumer_name, streams=self.stream: '>', count=10, block=2000 ) if results: for _, messages in results: for msg_id, data in messages: event_type = data.get('type', b'').decode() if event_types is None or event_type in event_types: yield msg_id, data else:

不感兴趣的事件,直接确认

r.xack(self.stream, group_name, msg_id)

def ack(self, group_name, msg_id): r.xack(self.stream, group_name, msg_id)

使用示例

bus = EventBus()

发布事件

bus.emit('user.registered', 'user_id': 1001, 'email': 'user@example.com') bus.emit('order.created', 'order_id': 'ORD-001', 'amount': 299)

消费者处理

def handle_user_events(): for msg_id, data in bus.on('user-service', 'consumer-1', 'user.registered'): payload = json.loads(data'payload') print(f"发送欢迎邮件给 payload'email'") bus.ack('user-service', msg_id) `````

任务队列的可靠设计

生产级任务队列 ````` import redis import time import hashlib import json

r = redis.Redis(decode_responses=True)

class TaskQueue: """基于Stream的可靠任务队列"""

def init(self, queue_name, max_retries=3): self.queue = queue_name self.group = f'queue_name:group' self.dlq = f'queue_name:dlq' self.max_retries = max_retries

初始化消费者组

try: r.xgroup_create(self.queue, self.group, id='0', mkstream=True) except redis.exceptions.ResponseError: pass

def enqueue(self, task_type, payload, delay=0): """添加任务到队列""" task = 'type': task_type, 'payload': json.dumps(payload), 'created_at': time.time(), 'dedup_key': hashlib.md5( json.dumps(payload, sort_keys=True).encode() ).hexdigest() return r.xadd(self.queue, task, maxlen=100000)

def dequeue(self, consumer_name, timeout=5000): """获取任务""" results = r.xreadgroup( self.group, consumer_name, streams=self.queue: '>', count=1, block=timeout ) if results: for _, messages in results: return messages0

(msg_id, data)

return None

def complete(self, msg_id): """确认任务完成""" r.xack(self.queue, self.group, msg_id)

def fail(self, msg_id): """处理失败的任务"""

检查重试次数

pending = r.xpending_range( self.queue, self.group, min=msg_id, max=msg_id, count=1 ) if pending and pending0'times_delivered' >= self.max_retries:

移入死信队列

msg_data = r.xrange(self.queue, min=msg_id, max=msg_id) if msg_data: r.xadd(self.dlq, msg_data01) r.xack(self.queue, self.group, msg_id) return False

已移入DLQ

return True

将自动重试

使用示例

tq = TaskQueue('email:tasks')

添加任务

tq.enqueue('send_email', 'to': 'user@example.com', 'subject': '欢迎注册', 'body': '感谢您注册我们的服务!' )

消费任务

msg = tq.dequeue('worker-email') if msg: msg_id, data = msg print(f"处理任务: msg_id -> data") try:

执行业务逻辑...

tq.complete(msg_id) except Exception: if not tq.fail(msg_id): print("任务已移入死信队列") `````

本章小结

本章全面介绍了Redis的消息通信机制:

  • Pub/Sub模式 适用于实时广播通知场景,具有极低的延迟和高吞吐量,但缺乏消息持久化和可靠性保证。 - Redis Streams 是构建生产级消息队列的推荐方案,支持消息持久化、消费者组、ACK确认和故障恢复等完整特性。 - 消费者组模型通过PEL和XCLAIM机制实现了消息的可靠投递和负载均衡。 - Stream在功能完整性上介于Pub/Sub和Kafka之间,适合中轻度消息队列场景。

在实际项目中,应根据消息可靠性要求、数据量和运维能力综合选择。对于核心业务的消息通道,Redis Streams是平衡功能、性能和运维复杂度的理想选择。


> 💡 完整PDF可联系作者获取