Redis 学习笔记
参考文档
Redis 介绍
为什么需要 Redis
关系型数据库 MySQL 的性能瓶颈主要在于磁盘 I/O 性能较低,数据关系复杂时扩展性较差,不便于构建大规模集群。
Redis 基于内存存储,可以减少 I/O 次数。它没有关系型数据库的表结构,也不使用 SQL。
| 使用场景 | 作用 |
|---|---|
| 缓存 | 高性能访问热点数据 |
| 分布式数据共享 | 实现分布式集群架构中的 Session 共享 |
| 分布式锁 | 协调多个节点对共享资源的访问 |
| 消息队列 | 实现简单的异步消息传递 |
| Lua 脚本 | 将多个 Redis 操作组合执行 |
Redis 特点
Redis 是基于内存的键值对存储系统。key 是字符串,value 支持多种数据类型。
Redis 安装与配置
在 Linux 系统安装
安装编译工具并解压源码:
1
2
3
4
5
yum install gcc
cd /opt
tar zxvf redis-6.2.6.tar.gz
cd /opt/redis-6.2.6
make install
复制配置文件,启用后台运行后启动 Redis:
1
2
3
4
cp /opt/redis-6.2.6/redis.conf /etc/
vi /etc/redis.conf
# 将 daemonize 修改为 yes
redis-server /etc/redis.conf
常用工具:
redis-benchmark:性能测试工具。redis-check-aof:修复有问题的 AOF 文件。redis-check-dump:修复有问题的dump.rdb文件。redis-sentinel:启动 Redis Sentinel。redis-server:启动 Redis 服务端。redis-cli:Redis 命令行客户端。
连接和关闭服务:
1
2
3
4
5
6
7
# 连接本机默认端口 6379
redis-cli
redis-cli -p 6379
# 关闭 Redis
redis-cli shutdown
redis-cli -p 6379 shutdown
/usr/local/bin 通常已在环境变量中,因此可以直接执行上述命令。
修改 Redis Server 端口
编辑 redis.conf:
1
port 6379
Redis 指令
Redis CLI 基础指令
| 指令 | 说明 |
|---|---|
SET k1 v1 |
设置键值;如果 k1 已存在则覆盖。包含空格等特殊字符时,值需要加引号。 |
GET k1 |
获取 k1 对应的值。 |
CLEAR |
清除终端屏幕。 |
QUIT / EXIT |
退出客户端,不关闭 Redis Server。 |
HELP GET |
查看 GET 命令的帮助。 |
Key 操作
| 指令 | 说明 |
|---|---|
KEYS * |
查看所有 key。支持通配符,例如 KEYS *1 匹配以 1 结尾的 key。 |
EXISTS k1 |
判断 k1 是否存在;存在返回 1,否则返回 0。 |
TYPE k1 |
查看值类型;key 不存在时返回 none。 |
DEL k1 |
同步删除 key;成功删除返回 1,未删除返回 0。 |
UNLINK k1 |
非阻塞删除:先从 keyspace 移除,再异步释放内存。 |
EXPIRE k1 20 |
设置 k1 在 20 秒后过期;成功返回 1。 |
TTL k1 |
查看剩余过期时间。-2 表示不存在,-1 表示永不过期。 |
数据库操作
Redis 默认提供 16 个逻辑库(0-15),启动后默认使用 0 号库,各库数据相互隔离。
| 指令 | 说明 |
|---|---|
SELECT 1 |
切换到 1 号库。 |
DBSIZE |
查看当前库的 key 数量。 |
FLUSHDB |
清空当前库,属于危险操作。 |
FLUSHALL |
清空所有库,属于危险操作。 |
Redis 数据类型
数据存储格式
- Redis 本身可以理解为一个 Map,所有数据都以 key-value 形式存储。
- key 是字符串,value 支持多种数据类型和结构。
String
String 是 Redis 最基本的数据类型,一个 key 对应一个 value。它是二进制安全的,可以保存文本、图片或序列化对象,单个 value 最大为 512 MB。
| 指令 | 说明 |
|---|---|
SET key value |
添加或覆盖键值对。 |
GET key |
查询对应的值。 |
APPEND key value |
将内容追加到原值末尾,返回追加后的字符串长度。 |
STRLEN key |
获取值的长度。 |
SETNX key value |
仅当 key 不存在时设置值。 |
INCR key / DECR key |
对数字字符串执行加一或减一。 |
INCRBY key step / DECRBY key step |
按指定步长增加或减少。 |
MSET k1 v1 k2 v2 |
批量设置多个键值对。 |
MGET k1 k2 |
批量获取多个值。 |
MSETNX k1 v1 k2 v2 |
仅当所有 key 都不存在时批量设置,操作具有原子性。 |
GETRANGE key start end |
按闭区间截取字符串,支持负索引。 |
SETRANGE key offset value |
从指定偏移量开始覆写字符串。 |
SETEX key seconds value |
设置值的同时指定过期时间。 |
GETSET key value |
设置新值并返回旧值。 |
1
2
3
4
5
6
7
SET key "hello world"
GETRANGE key 0 4
# "hello"
SETRANGE key 6 redis
GET key
# "hello redis"
List
List 用于保存多个有序元素,元素可以重复。底层采用双向链表,两端操作性能较高,按索引访问中间节点的性能相对较低。
1
2
3
4
5
Node {
Node pre
Node next
T data
}
| 指令 | 说明 |
|---|---|
LPUSH key value... / RPUSH key value... |
从左侧或右侧插入一个或多个值。 |
LPOP key / RPOP key |
从左侧或右侧删除并返回一个值。 |
LRANGE key start stop |
按索引范围获取元素;0 -1 表示全部元素。 |
RPOPLPUSH source destination |
从源列表右侧弹出元素,并插入目标列表左侧。 |
LINDEX key index |
根据索引获取元素,-1 表示最后一个元素。 |
LSET key index value |
修改指定索引的值。 |
LLEN key |
获取列表长度。 |
LINSERT key BEFORE\|AFTER pivot value |
在指定元素前后插入新值。 |
LREM key count value |
删除指定数量的匹配值。 |
List 适合保存有先后顺序的数据,例如通知流。作为队列时可使用 RPUSH + LPOP,作为栈时可使用 LPUSH + LPOP。
Set
Set 是无序且元素唯一的集合,底层使用字典/哈希表实现。
| 指令 | 说明 |
|---|---|
SADD key member... |
添加一个或多个成员,已存在的成员会被忽略。 |
SISMEMBER key member |
判断成员是否存在。 |
SMEMBERS key |
获取集合中的所有成员。 |
SCARD key |
获取集合基数;空集合或 key 不存在时返回 0。 |
SREM key member... |
删除一个或多个成员。 |
SPOP key |
随机删除并返回一个成员。 |
SRANDMEMBER key count |
随机返回成员,但不删除。 |
SMOVE source destination member |
将成员从一个集合移动到另一个集合。 |
SINTER key1 key2 |
返回交集。 |
SUNION key1 key2 |
返回并集。 |
SDIFF key1 key2 |
返回差集,即属于 key1 但不属于 key2 的成员。 |
Hash
Hash 是 field-value 键值对集合,适合存储对象,类似 Java 中的 Map<String, Object>。
| 指令 | 说明 |
|---|---|
HSET key field value... |
设置一个或多个字段;字段存在时更新值。 |
HGET key field |
获取指定字段。 |
HMGET key field... |
批量获取字段值。 |
HEXISTS key field |
判断字段是否存在。 |
HKEYS key |
列出所有字段。 |
HVALS key |
列出所有字段值。 |
HINCRBY key field increment |
对整数字段增加指定值,增量可以为负数。 |
HSETNX key field value |
仅当字段不存在时设置值。 |
1
2
HSET user:1001 id 1001 name zangxin age 20 gender male
HGET user:1001 name
Sorted Set / ZSet
ZSet 与 Set 类似,成员不能重复;区别在于每个成员都关联一个可重复的分数 score,集合会按分数排序。
| 指令 | 说明 |
|---|---|
ZADD key score member... |
添加成员及其分数。 |
ZRANGE key start stop [WITHSCORES] |
按索引升序返回成员,可同时返回分数。 |
ZREVRANGE key start stop [WITHSCORES] |
按索引降序返回成员。 |
ZSCORE key member |
获取成员分数。 |
ZRANGEBYSCORE key min max |
按分数从小到大获取成员。 |
ZREVRANGEBYSCORE key max min |
按分数从大到小获取成员。 |
ZINCRBY key increment member |
增加或减少成员分数。 |
ZREM key member |
删除成员。 |
ZCOUNT key min max |
统计分数区间内的成员数量。 |
ZRANK key member |
返回成员排名,索引从 0 开始。 |
1
2
3
ZADD dota2_hero 1 dushe 2 feiji 3 xiaoniu 4 xiaohei 5 zeous
ZRANGE dota2_hero 0 -1 WITHSCORES
ZSCORE dota2_hero feiji
Redis 配置
参考资料
常规配置
| 配置项 | 示例或默认值 | 说明 |
|---|---|---|
daemonize |
daemonize yes |
是否以守护进程方式在后台运行。no 表示占用当前终端。 |
pidfile |
pidfile /var/run/redis_6379.pid |
保存 Redis 进程号。运行多个实例时,每个实例应使用不同的 pid 文件。 |
loglevel |
loglevel notice |
设置日志级别,生产环境一般使用 notice。 |
logfile |
logfile "" |
空字符串表示输出到控制台;也可以指定日志文件路径。 |
databases |
databases 16 |
数据库数量,默认使用 0 号库,可通过 SELECT <dbid> 切换。 |
requirepass |
requirepass root |
在配置文件中设置密码,重启后仍然有效。 |
日志级别
| 级别 | 使用场景 |
|---|---|
debug |
输出信息最多,适合开发和测试,不建议用于生产环境。 |
verbose |
输出较详细的信息,但比 debug 精简。 |
notice |
适合生产环境,也是常见默认值。 |
warning |
只记录警告和严重问题。 |
将日志写入文件时,可以配置:
1
logfile /var/log/redis/redis.log
需要确保目录存在,并且 Redis 进程拥有写入权限。
密码管理
| 操作 | 命令 | 持久性 |
|---|---|---|
| 配置文件设置 | requirepass root |
永久,重启后仍然有效 |
| 客户端认证 | AUTH root |
仅用于当前连接 |
| 运行时设置 | CONFIG SET requirepass root |
临时,重启后恢复配置文件中的值 |
| 查看当前密码配置 | CONFIG GET requirepass |
只读查询 |
Units 单位
1
2
3
4
5
6
# 1k => 1000 bytes
# 1kb => 1024 bytes
# 1m => 1000000 bytes
# 1mb => 1024*1024 bytes
# 1g => 1000000000 bytes
# 1gb => 1024*1024*1024 bytes
配置大小单位,开头定义了一些基本的度量单位,只支持 bytes,不支持 bit 不区分大小写
Include
- 多实例的情况可以把公用的配置文件提取出来, 然后 include
1
2
# include /path/to/local.conf
# include /path/to/other.conf
网络
| 配置项 | 示例或默认值 | 说明 |
|---|---|---|
bind |
bind 127.0.0.1 -::1 |
指定监听地址。默认只接受本机请求。 |
protected-mode |
protected-mode yes |
开启保护模式,避免未配置认证时暴露到公网。 |
port |
port 6379 |
Redis 服务端口。 |
timeout |
timeout 0 |
空闲客户端超时时间,单位为秒;0 表示不主动关闭。 |
tcp-keepalive |
tcp-keepalive 60 |
TCP 心跳检测间隔,单位为秒;0 表示关闭检测。 |
远程访问不应只依靠注释
bind或关闭保护模式。生产环境还应配置密码或 ACL,并通过防火墙、安全组限制访问来源。
为什么需要 TCP Keepalive
短连接完成数据交互后会主动释放连接。长连接可能因客户端断网、进程异常等原因未能正常关闭,服务端也无法立即感知,长期积累会形成大量半打开连接。
Keepalive 可以帮助服务端更快发现无效连接,减少系统资源浪费,也避免继续向已经失效的连接发送业务数据。
限制
客户端连接数
| 配置项 | 默认值 | 说明 |
|---|---|---|
maxclients |
10000 |
Redis 可同时连接的客户端数量。达到上限后,新连接会收到 max number of clients reached 错误。 |
1
maxclients 10000
最大内存
maxmemory 用于限制 Redis 可使用的最大内存:
1
maxmemory <bytes>
- 32 位 Redis 实例通常最多使用约
3 GB内存。 - 64 位实例默认没有该限制,但生产环境仍建议显式设置上限。
- 实际值取决于数据规模、持久化方式以及同机运行的其他服务,不能使用一个固定值套用所有环境。
| 使用场景 | 设置建议 |
|---|---|
| Redis 作为主要数据库 | 可从物理内存的 1/2 开始评估,预留操作系统和其他服务所需空间。 |
| Redis 作为纯缓存 | 小型站点可从较小值开始,根据命中率和淘汰情况逐步调整。 |
| 开启 RDB 快照 | 建议预留较多空闲内存,避免 fork 和写时复制期间内存压力过大。 |
| 与 Java、NGINX 等服务混部 | 必须为其他进程保留足够内存,并通过监控逐步调优。 |
核心原则是从保守值开始测试,通过实际监控调整,确保 Redis 扩容不会影响系统中其他程序的正常运行。
内存淘汰策略
maxmemory-policy 决定 Redis 达到内存上限后如何处理新的写入请求。
| 策略 | 作用范围 | 淘汰方式 |
|---|---|---|
volatile-lru |
设置了过期时间的 key | 淘汰最近最少使用的 key |
allkeys-lru |
所有 key | 淘汰最近最少使用的 key |
volatile-random |
设置了过期时间的 key | 随机淘汰 |
allkeys-random |
所有 key | 随机淘汰 |
volatile-ttl |
设置了过期时间的 key | 优先淘汰剩余 TTL 最短的 key |
noeviction |
不淘汰 | 拒绝可能增加内存占用的写操作并返回错误 |
noeviction是传统默认策略。纯缓存场景通常更关注allkeys-lru等可淘汰所有 key 的策略,具体选择应根据数据是否允许丢失决定。
淘汰采样数量
LRU 和最小 TTL 淘汰使用近似算法。maxmemory-samples 控制每次选择候选 key 时的采样数量:
1
maxmemory-samples 5
通常可以设置为 3~7。采样数量越大,结果越接近理想算法,但 CPU 开销也会增加。
发布与订阅 / 消息队列
Redis 发布订阅(Pub/Sub)是一种消息通信模式:发布者发送消息,订阅者接收消息。
发布者向频道发送消息后,当前正在订阅该频道的客户端会立即收到消息。
消息队列模型
| 角色 | 作用 | 类比 |
|---|---|---|
| Publisher | 向一个或多个频道发送消息 | 电台 |
| Channel | 对消息进行主题划分 | 不同频率的频道 |
| Subscriber | 订阅一个或多个频道并接收消息 | 收音机 |
从机制上看,Pub/Sub 更接近广播系统。它与可靠任务队列不同:消息不会持久化,也不会为离线消费者保留。
发布订阅模式分类
| 模式 | 说明 | 典型用途 |
|---|---|---|
| 一个发布者,多个订阅者 | 同一条消息广播给多个消费者 | 通知、公告 |
| 多个发布者,一个订阅者 | 多个应用向同一频道发送数据,由一个消费者统一处理 | 排行榜、投票、计数 |
| 多个发布者,多个订阅者 | 发布者和订阅者围绕不同频道进行通信 | 群聊、聊天 |
命令行实现发布和订阅
| 命令 | 说明 |
|---|---|
PUBLISH channel message |
向频道发送消息,返回当前接收消息的订阅者数量。 |
SUBSCRIBE channel [channel ...] |
订阅一个或多个频道。 |
UNSUBSCRIBE [channel ...] |
取消频道订阅;不指定频道时取消全部频道订阅。 |
PSUBSCRIBE pattern [pattern ...] |
按模式订阅频道,例如 news.*。 |
PUNSUBSCRIBE [pattern ...] |
取消模式订阅;不指定模式时取消全部模式订阅。 |
注意
- 发布的消息没有持久化
- 订阅的客户端, 只能收到订阅后发布的消息
Jedis
Jedis 是 Java 程序操作 Redis 的客户端工具。
文档
Maven 依赖
1
2
3
4
5
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>5.2.0</version>
</dependency>
允许 Redis 远程连接
- 注释掉
bind配置。 - 将保护模式设置为
protected-mode no。 - 开放 Linux 的
6379端口。
连接
1
2
3
4
5
6
7
// 连接redis
Jedis jedis = new Jedis("192.168.2.85", 6379);
jedis.auth("root");
String res = jedis.ping();
System.out.println("res = " + res); // pong
// 关闭连接
jedis.close();
操作 Key
1
2
3
4
5
6
7
8
9
jedis.set("k1", "v1");
jedis.set("k2", "v2");
jedis.set("k3", "v3");
Set<String> keys = jedis.keys("*");
keys.forEach(System.out::println);
System.out.println(jedis.exists("k1"));// true
System.out.println(jedis.ttl("k2"));// -1
System.out.println(jedis.get("k3")); // v3
操作 String
1
2
3
jedis.mset("k1", "vv1", "k2", "vv2", "k3", "vv3","k4", "vv4");
List<String> stringKeyList = jedis.mget("k1", "k2", "k3", "k4");
stringKeyList.forEach(System.out::println);
操作 List
1
2
3
4
5
// 添加数据
jedis.lpush("name_list", "tom", "jerry", "zangxin", "alice");
// 取出数据
List<String> nameList = jedis.lrange("name_list", 0, -1);
nameList.forEach(System.out::println);
操作 Set
1
2
3
jedis.sadd("heroes","dushe","xiaohei","feiji","zeous","luna");
Set<String> heroes = jedis.smembers("heroes");
heroes.forEach(System.out::println);
操作 Hash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
jedis.hset("zangxin", "name", "zangxin");
jedis.hset("zangxin", "age", "20");
jedis.hset("zangxin", "gender", "male");
jedis.hset("zangxin", "game", "dota2");
String game = jedis.hget("zangxin", "game");
System.out.println(game);
// 第二种放入方式-->map
HashMap<String, String> map = new HashMap<>();
map.put("name", "zangxin");
map.put("age", "22");
jedis.hset("zangxin2", map);
List<String> values = jedis.hmget("zangxin", "name", "age", "gender", "game");
values.forEach(System.out::println);
操作 ZSet
1
2
3
4
5
6
7
8
9
10
jedis.zadd("role", 1, "桐人");
jedis.zadd("role", 3, "亚丝娜");
jedis.zadd("role", -1, "诗乃");
jedis.zadd("role", 2, "莉法");
List<String> role = jedis.zrange("role", 0, -1);
role.forEach(System.out::println);// 诗乃,桐人,莉法,亚丝娜
// 逆序
List<String> reverse = jedis.zrevrange("role", 0, -1);
reverse.forEach(System.out::println);// 亚丝娜,莉法,桐人,诗乃
Spring Boot 整合 Redis
相关内容见 Spring Boot 学习笔记。
Redis 持久化
Redis 持久化是将内存中的数据写入磁盘。
持久化策略
RDB(Redis Database)
RDB 会在指定时间将内存数据生成快照并写入磁盘,恢复时再将快照文件加载到内存。
执行流程
- 客户端执行
BGSAVE,或由配置自动触发。 - 主进程检查是否已有持久化子进程在运行。
- 如果没有,主进程通过
fork()创建子进程。fork()期间会短暂阻塞,随后主进程继续处理请求。 - 子进程借助 Copy-On-Write 将数据写入临时 RDB 文件。
- 写入完成后,临时文件原子替换旧的
dump.rdb,并通知主进程。
参考:Linux fork 与 Copy-On-Write。
常用配置
1
2
3
4
5
6
7
8
9
10
dbfilename dump.rdb
dir ./
save 3600 1
save 300 100
save 60 10000
stop-writes-on-bgsave-error yes
rdbcompression yes
rdbchecksum yes
dbfilename:RDB 文件名。dir:RDB 文件保存目录,可以配置绝对路径。save 60 10000:60 秒内至少有 10000 个 key 变化时创建快照。stop-writes-on-bgsave-error:快照写入失败时是否停止写操作。rdbcompression:是否使用 LZF 压缩快照。rdbchecksum:是否使用 CRC64 校验文件完整性。
正常关闭时,SHUTDOWN 或 SHUTDOWN SAVE 会保存 RDB;SHUTDOWN NOSAVE 不保存。
手动操作
| 指令 | 说明 |
|---|---|
SAVE |
同步创建快照,会阻塞其他操作,不建议在生产环境使用。 |
BGSAVE |
后台异步创建快照,主进程可以继续响应请求。 |
LASTSAVE |
返回上次成功保存快照的 Unix 时间戳。 |
CONFIG SET save "" |
临时禁用自动快照;重启后失效。 |
CONFIG GET dir |
查看持久化文件目录。 |
FLUSHALL |
清空所有数据,并可能生成内容为空的 RDB 文件,谨慎使用。 |
恢复数据时,将备份文件复制为 dump.rdb 并放入配置目录,然后重启 Redis。
优点
- 文件紧凑,节省磁盘空间。
- 适合大规模数据恢复,恢复速度通常快于 AOF。
- 主进程不直接执行磁盘 I/O,运行性能较好。
缺点
- 异常宕机时可能丢失最后一次快照之后的数据。
- 数据量较大时,
fork()和 Copy-On-Write 会消耗额外资源。
AOF(Append Only File)
AOF 以日志形式追加记录写命令,例如 SET 和 DEL,不会记录 GET 等读命令。Redis 重启时会重新执行 AOF 中的命令来恢复数据。
执行流程
- 写命令追加到 AOF 缓冲区。
- 根据
appendfsync策略将缓冲区同步到磁盘。 - 文件达到重写条件时执行 Rewrite,去除冗余命令并压缩文件。
- Redis 重启时加载 AOF 文件并重建数据。
开启 AOF
1
2
appendonly yes
appendfilename "appendonly.aof"
AOF 与 RDB 同时开启时,Redis 通常优先使用 AOF 恢复数据。
同步策略
| 配置 | 说明 |
|---|---|
appendfsync always |
每条写命令都同步,数据完整性高,但性能开销最大。 |
appendfsync everysec |
每秒同步一次,默认配置;宕机时最多丢失约一秒数据。 |
appendfsync no |
由操作系统决定同步时机,不推荐。 |
恢复与修复
- 备份损坏的 AOF 文件。
- 执行
/usr/local/bin/redis-check-aof --fix appendonly.aof。 - 重启 Redis 并重新加载 AOF。
修复可能造成部分数据丢失,因此应先保留原文件。
Rewrite
Rewrite 会删除无效的历史命令,并将多条可合并命令压缩为更少的命令。可以手动执行 BGREWRITEAOF,也可以自动触发:
1
2
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
auto-aof-rewrite-min-size:AOF 文件达到该大小后才允许自动重写。auto-aof-rewrite-percentage:当前文件相对上次重写后的增长比例达到该值时触发重写。
优点
- 备份机制更稳健,丢失数据的概率更低。
- 日志可读,便于排查和修复误操作。
缺点
- 占用磁盘空间通常大于 RDB。
- 恢复速度通常慢于 RDB。
- 频繁同步会带来额外性能开销。
其他策略
- No persistence:不启用持久化,适合纯缓存场景。
- RDB + AOF:同时启用两种机制,兼顾恢复速度与数据完整性。
选择持久化策略
参考 Redis 持久化官方文档。
| 使用场景 | 建议 |
|---|---|
| 对数据安全要求较高 | 同时启用 RDB 和 AOF,兼顾数据安全、数据库备份和快速恢复 |
| 可以接受灾难发生时丢失几分钟的数据 | 单独使用 RDB |
| 只将 Redis 用作缓存 | 可以不使用持久化 |
官方不建议单独使用 AOF。定期创建 RDB 快照有利于数据库备份、快速重启,也能在 AOF 引擎出现问题时提供恢复手段。
Redis 事务与超卖问题
定义
- 1、Redis 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行
- 2、事务在执行的过程中,不会被其他客户端发送来的命令请求所打断
- 3、Redis 事务的主要作用就是串联多个命令防止别的命令插队
Redis 事务的三个特性
| 特性 | 说明 |
|---|---|
| 顺序执行 | 事务中的命令会按入队顺序执行,执行过程不会被其他客户端命令插入。 |
| 没有传统隔离级别 | 命令在 EXEC 前只进入队列,不会提前执行。 |
| 不保证原子性 | 某条命令在执行阶段失败时,其他命令仍可能成功,Redis 不会自动回滚。 |
事务指令
| 指令 | 作用 |
|---|---|
MULTI |
开启事务。后续命令进入队列,但暂不执行。 |
EXEC |
按顺序执行队列中的全部命令。 |
DISCARD |
清空事务队列并放弃执行。 |
案例/注意点
需求:使用 Redis 事务依次添加 tx1-x1、tx2-x2、tx3-x3。
全部成功
1
2
3
4
5
6
7
8
9
10
11
12
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> set tx1 x1
QUEUED
127.0.0.1:6379(TX)> set tx2 x2
QUEUED
127.0.0.1:6379(TX)> set tx3 x3
QUEUED
127.0.0.1:6379(TX)> exec
1) OK
2) OK
3) OK
放弃事务
1
2
3
4
5
6
7
8
9
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> set tx1 x1
QUEUED
127.0.0.1:6379(TX)> set tx2 x2
QUEUED
127.0.0.1:6379(TX)> DISCARD
OK
127.0.0.1:6379>
组队阶段发生错误
1
2
3
4
5
6
7
8
127.0.0.1:6379(TX)> set tx1 x1
QUEUED
127.0.0.1:6379(TX)> set tx2
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379(TX)> set tx3 x3
QUEUED
127.0.0.1:6379(TX)> exec
(error) EXECABORT Transaction discarded because of previous errors.
组队阶段出现语法错误时,EXEC 会失败,事务中的所有命令都不会执行。
一部分成功、一部分失败
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> set tx1 x1
QUEUED
127.0.0.1:6379(TX)> set tx2 x2
QUEUED
127.0.0.1:6379(TX)> INCRBY tx2 1
QUEUED
127.0.0.1:6379(TX)> set tx3 x3
QUEUED
127.0.0.1:6379(TX)> exec
1) OK
2) OK
3) (error) ERR value is not an integer or out of range
4) OK
127.0.0.1:6379>
如果命令成功入队,但执行时出现类型等运行错误,其他命令仍会继续执行。因此 Redis 事务不保证原子性,也不会自动回滚。
Redis 事务冲突解决方案
经典的抢票场景中,如果库存不足但多个请求同时扣减,没有并发控制就会造成超卖,库存甚至可能变为负数。
| 方案 | 工作方式 | 适用场景 |
|---|---|---|
| 悲观锁 | 操作前先加锁,其他请求需要等待锁释放。 | 写冲突较多、必须严格串行处理的场景 |
| 乐观锁 | 读取时不加锁,更新时通过版本号或状态检查判断数据是否被修改。 | 读多写少、冲突概率较低的场景 |
Redis 通过 WATCH 实现 check-and-set 风格的乐观锁:
| 命令 | 说明 |
|---|---|
WATCH key [key ...] |
在 MULTI 前监视 key。如果执行 EXEC 前 key 被其他客户端修改,事务会被取消。 |
UNWATCH |
取消当前连接监视的所有 key。执行 EXEC 或 DISCARD 后会自动取消监视。 |
乐观锁演示
Client A:
1
2
3
4
5
6
7
8
9
10
11
127.0.0.1:6379> set lock 100
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> set share_data ggstar
QUEUED
127.0.0.1:6379(TX)> incr lock
QUEUED
127.0.0.1:6379(TX)> exec
1) OK
2) (integer) 101
Client B:
1
2
3
4
5
6
7
8
9
10
11
12
127.0.0.1:6379> get lock
"100"
127.0.0.1:6379> watch lock
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> set share_data mmstar
QUEUED
127.0.0.1:6379(TX)> incr lock
QUEUED
127.0.0.1:6379(TX)> exec
(nil)
Client A 先提交事务并修改了 lock。Client B 监视到版本已变化,因此 EXEC 返回 nil,事务执行失败。
火车票抢票案例(Java)
迭代 1:实现基本功能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public boolean doSecKill(String userId, String ticketNo) {
// 库存key
String stockKey = "sk:" + ticketNo + ":ticket";
// 用户key, 购买的userId放入到这个set集合中
String userKey = "sk:" + ticketNo + ":user";
// 获取库存, 如果为null, 说明未配置库存, 直接返回
Jedis jedis = getRedis();
String stock = jedis.get(stockKey);
if (StringUtils.isEmpty(stock)) {
System.out.println("活动还未开始, 库存还未配置");
release(jedis);
return false;
}
// 判断用户是否重复购买
boolean isUserBuy = jedis.sismember(userKey, userId);
if (isUserBuy) {
System.out.println(userId + " 已经参加过秒杀了");
release(jedis);
return false;
}
// 判断库存是否充足
if (Integer.parseInt(stock) <= 0) {
System.out.println("已售罄...");
release(jedis);
return false;
}
// 抢票扣减库存
jedis.decr(stockKey);
// 将已经抢到的用户加入set中, 用来判断是否重复购买
jedis.sadd(userKey, userId);
System.out.println(userId + "抢票成功");
return true;
}
问题分析:
- 判断库存和扣减库存不是原子操作。
- 多个线程可能同时判断库存充足,然后连续扣减,最终造成负库存。
迭代 2:使用乐观锁解决超卖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public boolean doSecKill(String userId, String ticketNo) {
JedisPool jedisPool = JedisUtil.getInstance();
Jedis jedis = jedisPool.getResource();
// 库存key
String stockKey = "sk:" + ticketNo + ":ticket";
// 用户key, 购买的userId放入到这个set集合中
String userKey = "sk:" + ticketNo + ":user";
// 监视库存
jedis.watch(stockKey);
String stock = jedis.get(stockKey);
if (stock == null) {
System.out.println("秒杀还没有开始,请等待..");
jedis.close();
return false;
}
// 判断用户是否重复购买
boolean isUserBuy = jedis.sismember(userKey, userId);
if (isUserBuy) {
System.out.println(userId + " 已经参加过秒杀了");
jedis.close();
return false;
}
// 判断库存是否充足
if (Integer.parseInt(stock) <= 0) {
System.out.println("已售罄...");
jedis.close();
return false;
}
// 开始事务
Transaction multi = jedis.multi();
multi.decr(stockKey);
multi.sadd(userKey, userId);
List<Object> result = multi.exec();
if (CollectionUtils.isEmpty(result)) {
System.out.println("抢票失败");
jedis.close();
return false;
}
return true;
}
遗留问题:
- 600 张票、1000 个请求时,可能仍有库存没有卖出。
- 并发量越大,越多请求会因
WATCH检测到版本变化而失败。
1
2
3
ab -n 1000 -c 100 -p ~/postfile \
-T application/x-www-form-urlencoded \
http://192.168.2.42:8080/seckill/seckill
迭代 3:使用 Lua 保证原子性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public boolean doSecKill(String userId, String ticketNo) {
JedisPool jedisPool = JedisUtil.getInstance();
Jedis jedis = jedisPool.getResource();
// 库存key
String stockKey = "sk:" + ticketNo + ":ticket";
// 用户key, 购买的userId放入到这个set集合中
String userKey = "sk:" + ticketNo + ":user";
String lua_script = null;
try {
lua_script = IOUtils.resourceToString("seckill.lua", Charset.forName("utf8"), this.getClass().getClassLoader());
} catch (IOException e) {
throw new RuntimeException(e);
}
// 将脚本加载到redis内存中
String sha1 = jedis.scriptLoad(lua_script);
// evalsha是根据sha1码来执行缓存在服务器的脚本
Object result = jedis.evalsha(sha1, 2, userId, ticketNo);
String resultStr = String.valueOf(result);
boolean ret = false;
switch (resultStr) {
case "-1":
System.out.println("活动未开始");
ret = false;
case "0":
System.out.println("已售罄");
ret = false;
case "1":
System.out.println("抢票成功");
ret = true;
case "2":
System.out.println("用户已经购买过了");
ret = false;
}
jedis.close();
return ret;
}
seckill.lua:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
local userid = KEYS[1];
local ticketno = KEYS[2];
local stockKey = 'sk:' .. ticketno .. ':ticket';
local usersKey = 'sk:' .. ticketno .. ':user';
if stockKey == nil then
return -1
end
local userExists = redis.call('sismember', usersKey, userid);
if userExists ~= nil and tonumber(userExists) == 1 then
return 2;
end
local num = redis.call("get", stockKey);
if tonumber(num) <= 0 then
return 0;
else
redis.call("decr", stockKey);
redis.call("sadd", usersKey, userid);
end
return 1
Redis 连接池(Java)
定义
- 节省每次连接 redis 服务带来的消耗,把连接好的实例反复利用。
连接池参数
MaxTotal:控制连接池最多可分配多少个 Jedis 实例;设置为-1表示不限制。maxIdle:控制连接池最多保留多少个空闲 Jedis 实例。MaxWaitMillis:获取 Jedis 实例时的最大等待毫秒数;超时后抛出JedisConnectionException。testOnBorrow:获取 Jedis 实例时是否通过ping()检查连接可用性。
连接池工具类
Maven 依赖:
1
2
3
4
5
6
7
8
9
10
11
<!-- Spring 2.x 集成 Redis 所需 common-pool -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- Jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
- 工具类,单例模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class JedisUtil {
// volatile
// 1.线程的可见性: 当一个线程去修改一个共线变量时, 另一外一个线程可以读取到整儿修改的值, 而不是副本
// 2.禁止指令重排: 执行顺序和代码顺序一致
private static volatile JedisPool jedisPool = null;
private JedisUtil() {
}
// 单例模式-懒汉式-双检锁DCL
public static JedisPool getInstance() {
if (jedisPool == null) {
synchronized (JedisUtil.class) {
if (jedisPool == null) {
// 连接池配置
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
// 最大连接数量
jedisPoolConfig.setMaxTotal(200);
// 最大空闲数量
jedisPoolConfig.setMaxIdle(32);
// 连接时最大等待时间60s
jedisPoolConfig.setMaxWaitMillis(60 * 1000);
// 耗尽连接时bocked
jedisPoolConfig.setBlockWhenExhausted(true);
// 检查连接是否可用
jedisPoolConfig.setTestOnBorrow(true);
jedisPool = new JedisPool(jedisPoolConfig, "192.168.2.85", 6379);
}
}
}
return jedisPool;
}
// 释放redis连接资源
public static void release(Jedis jedis) {
if (null != jedis) {
// 如果jedis是从连接池中获取, 则close是把连接还给pool, 而不是销毁掉
jedis.close();
}
}
}
- 使用
1
2
JedisPool jedisPool = JedisUtil.getInstance();
Jedis jedis = jedisPool.getResource();
Lua 脚本
定义
- 1、Lua 是一个小巧的脚本语言,Lua 脚本可以很容易的被 C/C++ 代码调用,也可以反过来调用 C/C++的函数,Lua 并没有提供强大的库,一个完整的 Lua 解释器不过 200k,所以 Lua 不适合作为开发独立应用程序的语言,而是作为嵌入式脚本语言。
-
2、很多应用程序、游戏使用 LUA 作为自己的嵌入式脚本语言,以此来实现可配置性、 可扩展性。
-
3、将复杂的或者多步的 Redis 操作,写为一个脚本,一次提交给 redis 执行,减少反复 连接 redis 的次数。提升性能。
-
4、LUA 脚本是类似 Redis 事务,有一定的原子性,不会被其他命令插队,可以完成一 些 redis 事务性的操作
- 5、Redis 的 lua 脚本功能,只有在 Redis 2.6 以上的版本才可以使用
- 6、通过 lua 脚本解决争抢问题,实际上是 Redis 利用其单线程的特性,用任务队列的 方式解决多任务并发问题
Lua 脚本的原子性
Lua 脚本类似 Redis 事务,具有一定的原子性。脚本执行期间不会被其他命令插队,可以完成事务性的操作。
使用 Lua 脚本解决争抢问题时,Redis 会利用单线程特性将请求形成任务队列,从而处理多任务并发问题。
Lua 脚本语法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
local userid=KEYS[1]; -- 获取传入的第一个参数
local ticketno=KEYS[2]; -- 获取传入的第二个参数
local stockKey='sk:'..ticketno..':ticket'; -- 拼接 stockKey
local usersKey='sk:'..ticketno..:user; -- 拼接 usersKey
local userExists=redis.call(sismember,usersKey,userid); -- 查看在 redis 的 usersKey set 中是否有该用户
if userExists ~= nil and tonumber(userExists)==1 then
return 2; -- 如果该用户已经购买, 返回 2
end
local num= redis.call("get" ,stockKey); -- 获取剩余票数
if tonumber(num)<=0 then
return 0; -- 如果已经没有票, 返回 0
else
redis.call("decr",stockKey); -- 将剩余票数-1
redis.call("sadd",usersKey,userid); -- 将抢到票的用户加入 set
end
return 1 -- 返回 1 表示抢票成功
- 参考:https://blog.csdn.net/qq_41286942/article/details/124161359
- lua脚本在java中写, 在redis中执行
Redis 调用 Lua 脚本
1
2
3
4
5
// 将脚本加载到redis内存中
String sha1 = jedis.scriptLoad(lua_script);
// evalsha是根据sha1码来执行缓存在服务器的脚本
Object result = jedis.evalsha(sha1, 2, userId, ticketNo);
String resultStr = String.valueOf(result);
Redis 主从复制
读写分离
工作方式
- 主节点数据更新后,会自动同步到从节点。
- Master 以写为主,Replica 以读为主,实现读写分离并降低单个 Redis 实例的压力。
- 从节点异常时可以切换到其他从节点,提高容灾恢复能力。
- 主从复制通常采用一主多从结构。
- 主节点的高可用可以通过 Sentinel 或 Redis Cluster 实现。
搭建一主多从
先复制基础 redis.conf 并关闭 AOF。主节点 6379 的配置如下:
1
2
3
4
include /zxredis/redis.conf
pidfile /var/run/redis_6379.pid
port 6379
dbfilename dump6379.rdb
- 从服务:
6380、6381
1
2
3
4
include /zxredis/redis.conf
pidfile /var/run/redis_6380.pid
port 6380
dbfilename dump6380.rdb
-
在从服务上执行
SLAVEOF 127.0.0.1 6379,指定主服务。 -
使用
INFO replication查看当前节点角色。 -
启动
1
2
3
redis-server redis_6379.conf
redis-server redis_6380.conf
redis-server redis_6381.conf
- 只能在主服务上进行写入操作, 在从服务写会报错, 写: 增加,删除,修改
主从复制原理
- Replica 成功连接 Master 后会发送同步命令。
- Master 启动后台持久化进程,同时收集新的写命令;持久化完成后,将完整数据文件发送给 Replica。
- Replica 接收数据文件后写入磁盘并加载到内存,这一过程属于全量复制。
- 后续 Master 会将新的写命令发送给 Replica,完成增量复制。
- Replica 重新连接 Master 时,可能再次触发全量复制。
一主二从
- 如果从服务器 down 了, 重新启动(需要重新执行SLAVEOF 127.0.0.1 6379) , 仍然可以获取 Master 的最新数据
- 如果主服务器 down 了, 从服务器并不会抢占为主服务器, 当主服务器恢复后, 从服务器仍然指向原来的主服务器.
- 主节点:
6379 - 从节点:
6380,执行SLAVEOF 127.0.0.1 6379 - 从节点:
6381,执行SLAVEOF 127.0.0.1 6379
薪火相传
上一个 Slave 可以作为下一个 Slave 的 Master,并接收其他 Slave 的连接和同步请求。这种链式结构可以减轻主节点的写压力。使用 SLAVEOF <master_ip> <master_port> 指定上游节点。
| 节点 | 角色与上游 |
|---|---|
6379 |
主节点 |
6380 |
从节点,上游为 6379 |
6381 |
从节点,上游为 6380 |
这种结构的风险是:某个 Slave 宕机后,其下游节点将无法继续同步;主节点宕机后,从节点仍然是从节点,无法写入数据。
反客为主
- 在薪火相传的模式下,当一个 master 宕机后, 指向 Master 的 slave 可以升为 master, 其后面的 slave 不用做任何修改
- 用 slaveof no one 将从机变为主机 (后面可以使用哨兵模式, 自动完成切换.)
哨兵模式(Sentinel)
Sentinel 可以监控主节点状态。当主节点故障并满足判定条件时,Sentinel 会自动选择一个从节点升级为新的主节点。
配置步骤
- 搭建一主二从结构:主节点为
6379,从节点为6380、6381。 - 创建
/zxredis/sentinel.conf:
1
sentinel monitor redis_master 127.0.0.1 6379 1
其中:
redis_master是被监控主节点的名称。1表示至少需要一个 Sentinel 同意故障转移。
- 使用
redis-sentinel sentinel.conf启动 Sentinel,默认端口为26379。 - 主节点故障后,Sentinel 从可用副本中选出新的主节点。
- 原主节点恢复后,会作为新主节点的从节点重新加入。
新主节点选择顺序
| 优先级 | 判断条件 | 规则 |
|---|---|---|
| 1 | replica-priority |
值越小,优先级越高。默认值通常为 100。 |
| 2 | 复制偏移量 | 数据越完整,优先级越高。 |
| 3 | Run ID | 前两个条件相同时,使用 Run ID 进行最终选择。 |
Redis 集群
为什么需要集群:高可用与水平扩容
生产环境主要面临三个问题:
- 单机容量不足时需要水平扩容。
- 单个节点无法长期承担持续增长的读写压力。
- 主从切换后,应用不应依赖固定的单机 IP 和端口。
| 方案 | 工作方式 | 优点 | 缺点 |
|---|---|---|---|
| 代理转发 | 客户端先访问代理,再由代理转发到后端 Redis 节点 | 客户端接入简单 | 代理可能成为瓶颈;高可用部署成本高,维护复杂 |
| Redis Cluster | 节点之间互相通信,任意节点都可作为请求入口,并根据 slot 转发请求 | 无中心化、支持数据分片和故障转移 | 部署和运维复杂度高于单机或普通主从 |
Redis Cluster 将 key 映射到不同 slot,再把 slot 分配到不同主节点,从而分散容量和访问压力。生产环境中的主从节点应尽量部署在不同机器或故障域中。
集群搭建与使用
Redis Cluster 通过启动多个节点实现水平扩容,将数据分布到不同节点中。即使部分节点失效或无法通信,满足条件时集群仍可以继续处理请求。
搭建集群
- 删除旧的
*.rdb和*.aof文件。 - 为每个节点创建独立配置文件。
- 启动六个 Redis 实例。
- 使用
redis-cli --cluster create组建集群。
redis_6379.conf 示例:
1
2
3
4
5
6
7
include /zxredis/redis.conf
pidfile "/var/run/redis_6379.pid"
port 6379
dbfilename "dump6379.rdb"
cluster-enabled yes
cluster-config-file nodes-6379.conf
cluster-node-timeout 15000
复制配置文件:
1
2
3
4
5
cp redis_6379.conf redis_6380.conf
cp redis_6379.conf redis_6381.conf
cp redis_6379.conf redis_6389.conf
cp redis_6379.conf redis_6390.conf
cp redis_6379.conf redis_6391.conf
分别替换配置文件中的端口,例如在 Vim 中执行 :%s/6379/6381/g。
启动六个 Redis 服务:
1
2
3
4
5
6
redis-server redis_6379.conf
redis-server redis_6380.conf
redis-server redis_6381.conf
redis-server redis_6389.conf
redis-server redis_6390.conf
redis-server redis_6391.conf
将六个节点组成三主三从集群:
1
redis-cli --cluster create --cluster-replicas 1 192.168.2.85:6379 192.168.2.85:6380 192.168.2.85:6381 192.168.2.85:6389 192.168.2.85:6390 192.168.2.85:6391
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> Performing hash slots allocation on 6 nodes...
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
[WARNING] Some slaves are in the same host as their master
M: ff1dd3947e4a180775f09b7257ca84518fae27ef 192.168.2.85:6379
slots:[0-5460] (5461 slots) master
M: 841e721bc0f6c2378303b7544ae49e4fb70c7511 192.168.2.85:6380
slots:[5461-10922] (5462 slots) master
M: 9ade621ffcf8bf2e9fee7f511892ef35b2a560b3 192.168.2.85:6381
slots:[10923-16383] (5461 slots) master
S: b70b04658b23780962d1b9c1965f5a83ce7f3f61 192.168.2.85:6389
replicates ff1dd3947e4a180775f09b7257ca84518fae27ef
S: 4af8384eff0836cbfd1d0bedf5f55d7961c709a9 192.168.2.85:6390
replicates 841e721bc0f6c2378303b7544ae49e4fb70c7511
S: 070aa53b49980775f49b01c08a569bd7985f190d 192.168.2.85:6391
replicates 9ade621ffcf8bf2e9fee7f511892ef35b2a560b3
注意:
- 组建集群前,确认所有实例已启动,并正常生成
nodes-xxxx.conf。 - 使用真实 IP,不要使用
127.0.0.1。 --cluster-replicas 1表示每个主节点配置一个从节点。- 一个集群至少需要三个主节点。
- 主从节点应尽量部署在不同机器上,避免单机故障导致同一组主从同时失效。
| 从节点 | 主节点 | 插槽范围 |
|---|---|---|
192.168.2.85:6389 |
192.168.2.85:6379 |
0-5460 |
192.168.2.85:6390 |
192.168.2.85:6380 |
5461-10922 |
192.168.2.85:6391 |
192.168.2.85:6381 |
10923-16383 |
使用 redis-cli -c -p 6379 连接集群,执行 CLUSTER NODES 查看节点和主从关系。
插槽
- 集群启动时会给 Master 节点分配插槽(slot)。
- Redis Cluster 包含 16384 个插槽,编号为
0-16383。 - Redis 使用
CRC16(key) % 16384计算 key 所属的插槽。 - 每个 Master 节点负责一部分插槽。
| 节点 | 插槽范围 |
|---|---|
| A | 0-5460 |
| B | 5461-10922 |
| C | 10923-16383 |
在集群中写入数据
Redis 每次读写 key 时都会计算对应插槽。如果当前节点不负责该插槽,服务端会返回目标节点的地址和端口。
1
2
3
4
5
6
7
[root@localhost zxredis]# redis-cli -c -p 6379
127.0.0.1:6379> set k1 v1
-> Redirected to slot [12706] located at 192.168.2.85:6381
OK
192.168.2.85:6381> get k1
"v1"
192.168.2.85:6381>
redis-cli -c -p 6379 中的 -c 参数用于自动重定向。
不同 slot 下的 key 不能直接使用 MGET、MSET 等多 key 操作:
1
2
192.168.2.85:6381> mset k2 v2 k3 v3 k4 v4
(error) CROSSSLOT Keys in request don't hash to the same slot
可以使用 Hash Tag {} 让多个 key 分配到同一个 slot:
1
2
3
4
5
6
7
192.168.2.85:6381> mset k2{order} v2 k3{order} v3 k4{order} v4
OK
192.168.2.85:6381> keys *
1) "k1"
2) "k4{order}"
3) "k3{order}"
4) "k2{order}"
查询集群
CLUSTER KEYSLOT <key> 返回 key 对应的 slot:
1
2
192.168.2.85:6381> CLUSTER KEYSLOT k2{order}
(integer) 16025
CLUSTER COUNTKEYSINSLOT <slot> 返回 slot 中的 key 数量:
1
2
192.168.2.85:6381> CLUSTER COUNTKEYSINSLOT 16025
(integer) 3
CLUSTER GETKEYSINSLOT <slot> <count> 返回指定数量的 key:
1
2
3
4
192.168.2.85:6381> CLUSTER GETKEYSINSLOT 16025 3
1) "k2{order}"
2) "k3{order}"
3) "k4{order}"
集群故障恢复
- 主节点下线后,从节点会自动晋升为主节点。需要等待
cluster-node-timeout判定超时。 - 原主节点恢复后,会作为从节点重新加入集群。
某一段插槽的主从节点全部不可用时,集群行为由
cluster-require-full-coverage决定:
| 配置 | 集群行为 |
|---|---|
yes |
整个集群停止服务 |
no |
只有对应插槽的数据不可用 |
Jedis 集群开发
- 客户端即使连接的不是目标主节点,也可以根据重定向完成读写。
- 使用集群时,需要开放所有相关节点端口,否则可能出现
No more cluster attempts left。
示例代码:
1
2
3
4
5
6
HashSet<HostAndPort> hostAndPorts = new HashSet<>();
hostAndPorts.add(new HostAndPort("192.168.2.85", 6389));
JedisCluster jedisCluster = new JedisCluster(hostAndPorts);
jedisCluster.set("name", "hyy");
String name = jedisCluster.get("name");
System.out.println("name = " + name);
优点
- 支持水平扩容。
- 可以分摊访问压力。
- 无中心化配置相对简单。
缺点
- 不支持跨 slot 的多 key 操作。
- 不支持跨 slot 的 Redis 事务和 Lua 脚本。
- 从其他集群方案迁移到 Redis Cluster 时,通常需要整体迁移,复杂度较高。
缓存模型
请求链路:请求 -> Redis 缓存 -> 数据库。
缓存穿透
问题描述
- 前端请求查询了一个数据库中不存在的数据。连数据库里面都没有,那么缓存中肯定更没有
- 由于缓存穿透必然执行数据库查询,所以会增加数据库压力。严重时会把数据库压垮。
解决方案
| 方案 | 说明 |
|---|---|
| 缓存空对象 | 数据库查询为空时也写入短期缓存,过期时间通常不超过几分钟。 |
| 白名单 | 使用集合或 Bitmap 保存允许访问的 ID,不在范围内的请求直接拦截。 |
| 布隆过滤器 | 在查询缓存和数据库前判断数据是否可能存在。空间和查询效率高,但存在误判且删除困难。 |
| 实时监控 | 命中率快速下降时排查异常参数和恶意请求,并配合黑名单、限流等措施。 |
缓存击穿
问题描述
数据库中存在对应数据,但 Redis 中的 key 已过期。请求无法命中缓存,只能查询数据库。
热点数据失效后,针对该数据的数据库访问会激增,显著增加数据库压力。
此时 Redis 表面运行正常,但数据库可能因瞬时压力过大而不可用。
解决方案
| 方案 | 说明 |
|---|---|
| 缓存预热 | 流量高峰前将热点数据提前写入 Redis,并设置合理的过期时间。 |
| 动态调整 | 根据监控结果识别热点 key,实时调整过期时间。 |
| 互斥更新 | 缓存失效后先通过 SET NX 获取锁;只有拿到锁的线程查询数据库并回写缓存,其他线程短暂等待后重试。 |
缓存雪崩
问题描述
大量缓存数据同时失效,原本可以从缓存获取数据的请求集中查询数据库,导致数据库压力激增。
严重时数据库会瞬间崩溃。
解决办法
| 方案 | 说明 |
|---|---|
| 分散失效时间 | 在基础过期时间上增加随机值,例如随机增加 1~5 分钟,避免 key 集中失效。 |
| 多级缓存 | 组合使用 NGINX、本地缓存和 Redis,降低单层缓存失效的影响。 |
| 锁或队列 | 控制回源数据库的并发量,但需要评估等待时间和高并发下的吞吐量。 |
| 逻辑过期 | 缓存中记录逻辑过期时间,由后台线程异步更新,前台请求继续读取旧值。 |
分布式锁
问题描述
系统从单机部署演进为分布式集群后,请求会跨线程、进程和机器运行,单机 Java 锁无法协调所有节点。分布式锁通过跨 JVM 的互斥机制控制共享资源访问,只有成功获得锁的节点才能执行对应业务。
主流实现方案
| 实现方式 | 特点 |
|---|---|
| 数据库 | 实现直观,但性能和可用性受数据库影响。 |
| Redis | 性能较高,适合高频加锁场景,需要正确处理过期时间和锁所有权。 |
| ZooKeeper | 一致性和可靠性较强,适合对锁语义要求较高的场景。 |
基于 Redis 实现分布式锁
| 命令 | 作用 |
|---|---|
SETNX key value |
key 不存在时写入成功,可用于获取锁。 |
DEL key |
删除锁 key,相当于释放锁。释放前必须校验锁的所有权。 |
EXPIRE key seconds |
为锁设置过期时间,避免客户端异常导致死锁。 |
TTL key |
查看锁的剩余过期时间。 |
SET key value NX EX seconds |
原子地完成加锁和设置过期时间,优先使用这种写法。 |
Java 实现
业务流程:
- 尝试获取锁。
- 获取成功后读取
num,执行加一并写回。 - 在
finally中校验锁值并释放锁。 - 获取失败时等待
100 ms后重试。
Java 代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@RestController
public class RedisDistributedLock {
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/lock")
public String lock() throws InterruptedException {
// 获取随机的uuid避免锁被误删除
String uuid = UUID.randomUUID().toString();
// 设置3秒的过期时间,3秒后自动释放锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS);
if (lock) { // true说明获取到锁
Object value = redisTemplate.opsForValue().get("num");
// 没有值直接返回
if (!StringUtils.hasText(value.toString())) {
return "failed";
}
// 执行更新num+1的业务操作
int num = Integer.parseInt(value.toString());
redisTemplate.opsForValue().set("num", ++num);
// 释放-lock,只有获取的锁和自己锁是同一把锁才删除
String atomicDelLua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(atomicDelLua);
redisScript.setResultType(Long.class);
Long result = (Long) redisTemplate.execute(redisScript, Arrays.asList("lock"), uuid);
if (result.equals(1L)) {
System.out.println("删除成功");
}
// -这里删除操作缺乏原子性,使用lua脚本原子性
// if (uuid.equals(redisTemplate.opsForValue().get("lock"))) {
// redisTemplate.delete("lock");
// }
return "ok";
} else {
// 没有获取到锁, 睡眠100ms
Thread.sleep(100);
// 递归调用自己
lock();
}
return "ok";
}
}
Lua 解锁脚本
1
2
3
4
5
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
压力测试
1
ab -n 1000 -c 100 http://192.168.2.42:8080/seckill/lock
分布式锁要求
| 要求 | 说明 |
|---|---|
| 互斥性 | 任意时刻只能有一个客户端持有同一把锁。 |
| 避免死锁 | 锁必须设置合理的过期时间,客户端异常后仍能自动释放。 |
| 所有权校验 | 只能由加锁客户端释放,不能误删其他客户端的锁。 |
| 原子性 | 加锁和解锁都必须以原子操作完成。 |
锁的 key 应按业务资源划分。例如操作商品库存时,可以为每个 SKU 使用独立的锁。
Redis ACL(Redis 6)
Access Control List
ACL 可以限制用户能够执行的命令和能够访问的 key。
细粒度权限控制
| 控制维度 | 说明 |
|---|---|
| 身份认证 | 使用用户名和密码连接 Redis。 |
| 命令权限 | 限制用户可以执行的命令或命令类别。 |
| Key 权限 | 通过模式限制用户可以访问的 key。 |
ACL LIST
ACL LIST 展示所有用户及其权限规则。默认用户示例:
1
user default on nopass ~* &* +@all
| 片段 | 含义 |
|---|---|
default |
用户名 |
on / off |
启用或禁用用户 |
nopass |
不需要密码 |
~* |
可以访问所有 key |
&* |
可以访问所有 Pub/Sub 频道 |
+@all |
可以执行所有命令类别 |
ACL CAT
ACL CAT 查看命令类别,ACL CAT string 查看 String 类别包含的命令。
ACL WHOAMI
ACL WHOAMI 查看当前连接使用的用户名。
用户管理
| 操作 | 命令 | 说明 |
|---|---|---|
| 创建未启用用户 | ACL SETUSER tom |
新用户默认未启用且没有命令权限。 |
| 创建受限用户 | ACL SETUSER jack on >password ~cached:* +get |
启用 jack,密码为 password,只能读取 cached:* key。 |
| 用户认证 | AUTH jack password |
切换到 jack 用户。 |
| 增加权限 | ACL SETUSER jack +set |
为 jack 增加 SET 权限。 |
| 删除用户 | ACL DELUSER jack |
删除指定用户。 |
创建 jack 后,权限规则大致如下:
1
user jack on #<password-hash> ~cached:* &* -@all +get
认证后尝试执行未授权命令会收到错误:
1
2
127.0.0.1:6379> set cached:k1 v1
(error) NOPERM this user has no permissions to run the 'set' command or its subcommand
Redis 6 新特性
I/O 多线程
I/O 多线程是指与客户端交互时,网络 I/O 处理模块使用多线程,并不是使用多线程执行 Redis 命令。
Redis 6 执行命令时仍然是单线程。
客户端交互采用多线程,命令执行仍然是单线程配合 I/O 多路复用。
I/O 多线程默认不开启,需要在配置文件中设置:
1
2
io-threads-do-reads no
io-threads 4
Cluster 工具
- 旧版本 Redis 搭建集群需要 Ruby 环境。
- Redis 5 将
redis-trib.rb的功能集成到了redis-cli。 redis-benchmark是性能测试工具,可以用于集群压测。
RESP3
RESP3 是新的 Redis 通信协议,用于优化服务端和客户端之间的通信。
客户端缓存
客户端缓存基于 RESP3 协议实现,可以提高性能并减少 TCP 网络交互。
集群代理
代理模式让 Cluster 拥有类似单实例的接入方式,降低使用集群的门槛。