1. 环境准备与依赖配置
1.1 Maven 依赖管理
为了在 Spring Boot 项目中使用 Redis 和 MySQL,我们需要在 pom.xml
中添加必要的依赖。主要包括以下几个依赖:
- Spring Data Redis:用于在 Spring Boot 中集成 Redis,提供 RedisTemplate 进行操作。
- MySQL JDBC 驱动:用于连接 MySQL 数据库。
- Spring Data JPA:用于简化与 MySQL 数据库的交互,提供面向对象的数据库操作支持。
具体依赖代码:
<!-- Spring Data Redis 依赖,用于集成 Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MySQL JDBC 驱动,用于连接 MySQL 数据库 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- Spring Data JPA 依赖,用于简化数据库访问 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
添加完这些依赖后,Spring Boot 项目就可以利用 Spring Data Redis 来操作 Redis,并通过 Spring Data JPA 连接和操作 MySQL 数据库。
1.2. 配置文件:设置 application.properties
文件
Spring Boot 项目中通常使用 application.properties
文件来配置应用所需的参数。以下是 Redis 和 MySQL 的详细连接参数设置说明。
Redis 配置
# Redis 主机地址,默认情况下是 localhost,本地使用无需修改
spring.redis.host=localhost
# Redis 服务端口,默认 6379
spring.redis.port=6379
# Redis 密码,如果没有设置密码,可以留空
spring.redis.password=your_redis_password
配置说明:
spring.redis.host
:Redis 服务器的主机地址,通常为localhost
(即本地)或 Redis 所在的服务器 IP。spring.redis.port
:Redis 服务器的端口号,默认是6379
,可以根据实际 Redis 配置进行调整。spring.redis.password
:Redis 的访问密码。如果 Redis 设置了密码保护(通常用于远程访问或安全性较高的场景),则在此处填写对应的密码。
MySQL 配置
# MySQL 数据库连接 URL,格式为:jdbc:mysql://[host]:[port]/[database_name]?参数
spring.datasource.url=jdbc:mysql://localhost:3306/your_database_name?useSSL=false&serverTimezone=UTC
# MySQL 数据库用户名
spring.datasource.username=your_mysql_username
# MySQL 数据库用户密码
spring.datasource.password=your_mysql_password
# JDBC 驱动类名称,Spring Boot 2.x 及以上版本中使用 com.mysql.cj.jdbc.Driver
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# JPA 和 Hibernate 配置
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
配置说明:
spring.datasource.url
:数据库连接 URL。其格式为:jdbc:mysql://
:指定使用 MySQL 数据库的 JDBC 驱动。[host]
:MySQL 服务器主机地址,本地为localhost
,如果是远程则填写 IP 地址。[port]
:MySQL 服务端口号,默认是3306
,可以根据实际情况调整。[database_name]
:具体使用的数据库名称,需要在 MySQL 中提前创建好。?useSSL=false
:指定是否启用 SSL 连接(一般本地开发设为false
)。&serverTimezone=UTC
:设置服务器的时区为 UTC,防止可能的时区问题。
spring.datasource.username
:MySQL 数据库用户名,用于连接数据库。spring.datasource.password
:对应的用户名密码,确保输入正确。spring.datasource.driver-class-name
:指定 JDBC 驱动类。MySQL 使用com.mysql.cj.jdbc.Driver
(这是 MySQL 8.0 及以上版本的驱动类)。spring.jpa.hibernate.ddl-auto=update
:表示在应用启动时,自动根据实体类更新数据库表结构。spring.jpa.show-sql=true
:设置为true
会打印 Hibernate 生成的 SQL 语句,便于调试。spring.jpa.properties.hibernate.dialect
:定义 Hibernate 使用的 MySQL 方言(MySQL8Dialect
),这会优化 Hibernate 与 MySQL 的交互。
1.3. 本地与远程服务搭建
1.3.1. Redis 服务启动
(1)本地 Redis 启动
安装 Redis:首先,你需要在本地环境中安装 Redis。根据操作系统的不同,可以通过 apt-get
(Linux)、brew
(macOS)或直接下载 Redis 可执行文件来安装。
启动 Redis 服务:
- 安装完成后,可以通过命令行启动 Redis 服务:
redis-server
- Redis 默认在
localhost
的6379
端口上监听请求。 - 启动后,你可以通过 Redis 客户端(
redis-cli
)测试连接。在终端输入以下命令:
redis-cli
- 然后执行
PING
命令。如果返回PONG
,表示 Redis 已经正常启动。
(2)远程 Redis 服务器配置
安全性设置:如果 Redis 部署在远程服务器上,建议为 Redis 设置密码。在 Redis 的配置文件(通常是 redis.conf
)中设置 requirepass your_password
,然后重新启动服务以生效。
防火墙配置:确保 Redis 端口(默认 6379
)在服务器的防火墙中开放,以允许远程访问。
网络连接测试:
- 确保你的应用主机能够通过 IP 地址和端口访问远程 Redis 服务器,可以在命令行测试连接:
redis-cli -h <远程IP> -p 6379 -a <密码>
- 如果连接成功,执行
PING
返回PONG
,则说明网络连接和认证都已正确配置。
(3)Redis 的远程访问限制
- 为了安全起见,Redis 的配置文件中默认限制了公网访问。可以在
redis.conf
文件中设置bind 0.0.0.0
以允许所有 IP 连接 Redis。 - 也可以在同一文件中设置
protected-mode yes
以启用受保护模式,如果不打算开放公网访问,可以禁用远程访问。
1.3.2. MySQL 服务启动
(1)本地 MySQL 启动
安装 MySQL:根据操作系统不同,可以通过 apt-get
、yum
、brew
或下载安装包来安装 MySQL。
启动 MySQL 服务:
- 安装后,启动 MySQL 服务。在不同系统上启动 MySQL 的命令可能不同,如在 Linux 中:
sudo service mysql start
- 使用以下命令行测试 MySQL 是否正常启动并连接:
mysql -u root -p
- 输入密码后,如果成功进入 MySQL 命令行界面,表示 MySQL 已正常运行。
(2)远程 MySQL 服务器配置
1.配置远程访问权限:默认情况下,MySQL 只允许本地连接。要开放远程访问权限,你需要修改 MySQL 的用户权限。在 MySQL 控制台执行以下命令:
CREATE USER 'your_user'@'%' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON your_database.* TO 'your_user'@'%';
FLUSH PRIVILEGES;
2.配置文件修改:修改 MySQL 配置文件(通常是 my.cnf
或 my.ini
),找到 [mysqld]
配置块,设置 bind-address
为 0.0.0.0
以允许所有 IP 地址访问:
[mysqld]
bind-address = 0.0.0.0
重新启动 MySQL 使配置生效。
3.防火墙配置:确保服务器上开放 MySQL 的默认端口(3306),允许远程访问。
4.测试远程连接:
使用以下命令测试远程连接(在另一台主机上):
mysql -h <远程IP> -P 3306 -u your_user -p
如果成功连接,表示 MySQL 的远程访问配置正确。
1.3.3. 网络连通性和认证设置
网络连通性测试:从应用主机上测试到 Redis 和 MySQL 服务的连通性。可以使用 ping
或 telnet
命令检查 IP 和端口的连接状态:
# 测试 Redis 连接
telnet <redis_ip> 6379
# 测试 MySQL 连接
telnet <mysql_ip> 3306
如果连接成功说明网络连通性没有问题。
认证配置:
- 确保 Redis 和 MySQL 的认证信息(如用户名、密码)正确配置在 Spring Boot 项目中,并且可以成功访问。
- 对于 Redis,使用密码认证的配置项是
spring.redis.password
。 - 对于 MySQL,认证配置项包括
spring.datasource.username
和spring.datasource.password
。
2.Spring Boot 与 Redis 的集成
2.1. RedisTemplate 配置
RedisTemplate
是一个通用的模板类,适用于操作 Redis 中的多种数据结构,包括字符串、哈希、列表、集合等。我们可以通过 RedisTemplate
来方便地执行 Redis 的增删查改操作。为了确保数据的可读性和兼容性,我们通常需要自定义 RedisTemplate
的序列化配置。
你的 Redis 配置中,
GenericJackson2JsonRedisSerializer
作为 value 和 hashValue 的序列化器,它的行为如下:
- 如果
value
不是String
类型(例如Integer
,Long
,List
,Map
,Object
等),会被序列化为 JSON 格式存入 Redis。- 如果
value
是String
类型,Redis 不会额外再做 JSON 序列化,而是 直接存入 Redis(与StringRedisSerializer
的效果相同)。
(1)创建 RedisTemplate
Bean
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
// 创建 RedisTemplate 实例
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 设置连接工厂
template.setConnectionFactory(redisConnectionFactory);
// 配置 key 的序列化器
template.setKeySerializer(new StringRedisSerializer());
// 配置 value 的序列化器
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
// 配置 hash key 的序列化器
template.setHashKeySerializer(new StringRedisSerializer());
// 配置 hash value 的序列化器
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
// 初始化 RedisTemplate 配置
template.afterPropertiesSet();
return template;
}
}
-
StringRedisSerializer:
- 用途:将键或简单的值序列化为字符串。
- 适用场景:通常用于键的序列化,确保键在 Redis 中以字符串存储,以便于直接查看和管理。
-
GenericJackson2JsonRedisSerializer:
- 用途:将对象序列化为 JSON 格式的字符串,并支持 JSON 反序列化回对象。
- 适用场景:通常用于值的序列化,尤其是需要存储复杂对象的情况。它可以确保数据的可读性,且 JSON 格式数据跨系统兼容性好。
(2)创建 StringRedisTemplate Bean
StringRedisTemplate
是 RedisTemplate
的一个变种,它专门用于存储字符串数据。由于它默认的键和值的序列化方式都是 StringRedisSerializer
,可以直接用于存储和读取字符串数据。
StringRedisTemplate 配置代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
@Configuration
public class RedisConfig {
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
return new StringRedisTemplate(redisConnectionFactory);
}
}
配置解析
StringRedisTemplate
继承自RedisTemplate
,主要用于操作 Redis 中的字符串类型数据。StringRedisTemplate
的键和值的序列化方式都是StringRedisSerializer
,这使得所有存储在 Redis 中的数据都是可读的字符串格式。- 适用于数据都是简单字符串的场景,由于
StringRedisTemplate
默认的序列化方式已满足大多数字符串操作的需求,不需要额外配置序列化器。 - 注册为 Spring Bean:同样地,
@Bean
注解将StringRedisTemplate
注册为 Spring 容器中的一个 Bean,其他类可以使用@Autowired
注入StringRedisTemplate
实例,进行字符串数据的操作。
2.2 RedisTemplate 常用方法
通用方法
除了特定数据结构的 opsFor...()
方法,RedisTemplate
还提供了一些通用的 Redis 操作方法:
delete(String key)
:删除键key
及其对应的值,返回是否成功删除。expire(String key, long timeout, TimeUnit unit)
:设置键key
的过期时间。hasKey(String key)
:检查键key
是否存在,返回true
或false
。keys(String pattern)
:获取所有符合模式pattern
的键。persist(String key)
:移除键key
的过期时间,使其永久有效。rename(String oldKey, String newKey)
:将键oldKey
重命名为newKey
。type(String key)
:返回键key
的数据类型。
2.2.1. opsForValue()
- 操作字符串(String)类型的数据
opsForValue()
用于操作 Redis 中的字符串数据,适用于简单的键值对操作。
常用方法:
-
set(String key, Object value)
:将键key
的值设置为value
,如果键已存在,则覆盖旧值。这个set方法不指定过期时间会永久保存。 -
set(String key, Object value, long timeout, TimeUnit unit)
:将键key
的值设置为value
,并指定过期时间timeout
和时间单位unit
。 -
get(String key)
:获取键key
对应的值。 -
increment(String key, long delta)
:将键key
的值增加delta
,返回增加后的值(适用于整数类型的值)。 -
decrement(String key, long delta)
:将键key
的值减少delta
,返回减少后的值。
2.2.2. opsForHash()
- 操作哈希(Hash)类型的数据
opsForHash()
用于操作 Redis 中的 Hash 数据结构,Hash 结构是一个键值对集合,每个键值对被称为一个字段(field)。这种结构适用于存储和管理对象数据,可以把对象的属性当作字段,进行方便的增删查改。
在 Spring Data Redis 中,
BoundHashOperations<K, HK, HV>
是一个接口,用于 绑定 Redis 的哈希(Hash)数据结构,并提供对该哈希结构的各种操作。BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(Key);
BoundHashOperations 是一种对特定哈希的封装,它提供了一些对特定哈希键的操作,但 opsForHash() 已经足够处理常规的哈希操作。
泛型参数 作用 你这里的具体类型 K
(String
)Redis 的键(Key)类型,用于存储 Hash 结构的键 CartConstant.CART_PREFIX+userKey
(购物车的 Redis Key)HK
(Object
)Hash 结构的字段(Hash Key)类型 购物车商品 ID(或者其他字段) HV
(Object
)Hash 结构的值(Hash Value)类型 购物车数据的 JSON 字符串
opsForHash()
方法
opsForHash()
是 RedisTemplate
中的一个方法,用于操作 Redis 哈希类型的数据。它返回一个 HashOperations
接口实例,允许你执行多种常见的哈希操作。
常用方法:
-
put(K key, HK hashKey, HV value)
:将一个键值对(hashKey
和value
)插入到 Redis 哈希中。 -
putAll(K key, Map<? extends HK, ? extends HV> map)
:将多个键值对一次性插入到 Redis 哈希中。 -
get(K key, HK hashKey)
:根据哈希键获取值。 -
delete(K key, Object... hashKeys)
:删除 Redis 哈希中的一个或多个哈希键。 -
entries(K key)
:获取整个哈希中的所有键值对。
// 使用 opsForHash() 操作 Redis 哈希结构
Map<String, String> map = new HashMap<>();
map.put("field1", "value1");
map.put("field2", "value2");
// 将整个 map 存入 Redis 哈希
redisTemplate.opsForHash().putAll("myHashKey", map);
// 获取某个字段的值
String value = (String) redisTemplate.opsForHash().get("myHashKey", "field1");
BoundHashOperations
BoundHashOperations
是对 Redis 哈希结构的封装,它与 opsForHash()
不同,它专注于某个特定哈希键。这种封装让你能够对特定的哈希结构进行更细粒度的操作。BoundHashOperations
使得某个特定的哈希可以在多个操作中被绑定和复用。
常用方法:
-
put( HK hashKey, HV value) :设置哈希表中指定字段的值,如果字段存在则覆盖。
-
putAll(Map<? extends HK, ? extends HV> map) :批量将
Map
数据存入哈希表中。 -
putIfAbsent(HK hashKey, HV value) :仅在字段不存在时设置值。
-
get( Object hashKey) :获取哈希表中指定字段的值。
-
multiGet(Collection<HK> hashKeys) :批量获取哈希表中多个字段的值。
-
delete(Object... hashKeys) :删除哈希表中的一个或多个字段。
-
hasKey( Object hashKey) :判断哈希表中是否存在指定字段。
-
size(String key) :获取哈希表中字段的数量。
-
entries(String key) :获取哈希表中的所有键值对,以
Map
形式返回。 -
keys(String key) :获取哈希表中的所有字段名。
-
values(String key) :获取哈希表中的所有字段值。
-
increment(String key, HK hashKey, long delta) :对哈希表中指定字段的数值进行原子性递增或递减。
// 通过 BoundHashOperations 操作特定哈希键
BoundHashOperations<String, String, String> boundHashOps = redisTemplate.boundHashOps("myHashKey");
// 向绑定的哈希结构中添加一个字段
boundHashOps.put("field1", "value1");
// 获取绑定哈希中某个字段的值
String value = boundHashOps.get("field1");
// 删除绑定哈希中的一个字段
boundHashOps.delete("field1");
2.2.3. opsForList()
- 操作列表(List)类型的数据
quicklist
是 Redis 从 3.2 版本起,用来实现 List 类型的新数据结构。它是 Redis 为了兼顾节省内存、提升性能而设计的一种结构,本质上是一个“双向链表,每个节点是一个压缩列表(ziplist)”。
quicklist 的底层结构组成:
(1)quicklist 节点(quicklistNode):
每个节点包含:
一个
ziplist
(压缩列表):可以包含多个 list 元素。前驱/后继指针:形成双向链表。
元素个数、ziplist 总字节数等元信息。
(2)ziplist(压缩列表):
是一种紧凑的连续内存结构,类似数组。
用于节省内存。
存储的是具体的字符串值或整数值。
opsForList()
用于操作 Redis 中的列表结构,列表数据类型可以存储一个有序的字符串列表(支持从左、右两端插入和弹出)。
常用方法:
正数索引(从左到右):
0
表示列表的第一个元素(最左侧)。
1
表示第二个元素,依此类推。如果索引超过列表长度,返回
null
(或抛出异常,取决于客户端)。负数索引(从右到左):
-1
表示列表的最后一个元素(最右侧)。
-2
表示倒数第二个元素,依此类推。如果负数索引超出范围(如列表长度为 3,但使用
-4
),返回null
。
leftPush(String key, Object value)
:将value
插入到列表key
的左侧。rightPush(String key, Object value)
:将value
插入到列表key
的右侧。leftPop(String key)
:移除并返回列表key
的左侧第一个元素。rightPop(String key)
:移除并返回列表key
的右侧第一个元素。-
index(String key, long index):
获取列表key
中指定索引位置的元素(支持负数索引,如-1
表示末尾元素)。 range(String key, long start, long end)
:获取列表key
中从start
到end
范围内的元素。redis的约定是0表示列表第1个元素,-1表示列表末尾元素size(String key)
:获取列表key
的长度。set(String key, long index, Object value)
:将列表key
中指定索引index
的元素设置为value
。
2.2.4. opsForSet()
- 操作集合(Set)类型的数据
opsForSet()
用于操作 Redis 中的集合结构,集合中的元素是唯一的,且无序。
常用方法:
add(String key, Object... values)
:向集合key
中添加一个或多个values
,返回添加的元素数量。remove(String key, Object... values)
:从集合key
中移除一个或多个元素。members(String key)
:获取集合key
中的所有元素。isMember(String key, Object value)
:判断value
是否是集合key
的成员。size(String key)
:获取集合key
的元素个数。intersect(String key, String otherKey)
:返回集合key
和otherKey
的交集。difference(String key, String otherKey)
:返回集合key
与otherKey
的差集。union(String key, String otherKey)
:返回集合key
与otherKey
的并集。
2.2.5. opsForZSet()
- 操作有序集合(Sorted Set/ZSet)类型的数据
opsForZSet()
用于操作 Redis 中的有序集合结构。有序集合中的每个元素都关联一个分数,按分数从小到大排序。
常用方法:
add(String key, Object value, double score)
:将value
添加到有序集合key
中,并设置分数score
。remove(String key, Object... values)
:从有序集合key
中移除一个或多个元素。score(String key, Object value)
:获取有序集合key
中元素value
的分数。- Double incrementScore(String key, Object member, double delta):对指定成员的
score
进行原子性增减。delta作为
分数增量(可正可负,支持小数),正就是增,负就是键。 size(String key)
:获取有序集合key
的元素个数。rank(String key, Object value)
:返回有序集合key
中元素value
的排名(从小到大)。reverseRank(String key, Object value)
:返回有序集合key
中元素value
的排名(从大到小)。range(String key, long start, long end)
:根据索引范围start
和end
获取有序集合key
中的元素(按照升序排序好的,start对应分数最小的)。这里返回的是一个Set(准确来说是LinkedHashSet),Set里面存放的是你存的元素。reverseRange(String key, long start, long end)是降序。rangeByScore(String key, double min, double max)
:根据分数范围min
和max
获取有序集合key
中的元素(按照升序排序好的,min对应最小的分数)。reverseRangeByScore(String key, double max, double min)是降序- Long count(K key, double min, double max);用于统计 有序集合 中 分数(score) 在给定的最小值(
min
)和最大值(max
)之间的成员数量。 - reverseRangeByScoreWithScores(String key, double min, double max):返回指定分数范围内的元素,并且按分数从高到低排序。返回一个
Set<ZSetOperations.TypedTuple<V>>
类型的集合,集合中的每个元素包含了ZSet中的一个成员和它的分数。
ZSetOperations.TypedTuple<V>
:
- 这个类型包含了ZSet集合中的元素以及它对应的分数。你可以通过
getValue()
方法获取元素,通过getScore()
方法获取元素的分数。
2.2.6. Bitmaps(位图)和 HyperLogLog 和 Geo(地理位置)和 Streams(流数据)
(1)Bitmaps(位图)
通过 opsForValue()
来操作,是 String
类型数据的一种特殊应用。位图(Bitmap)是通过对字符串类型的位进行操作来表示和管理大量的二进制数据。例如,你可以将一个比特位表示为布尔值(true
或 false
),并且对其进行操作。
常用方法:
setBit(String key, long offset, boolean value)
:设置key
对应字符串中指定偏移量(offset
)的位的值,value
为true
或false
。-
当你首次通过
setBit
方法设置某个位置的位时,Redis 会自动扩展该字符串的长度以适应你设置的位。因此,在初次设置时,你并不需要事先知道该字符串已经包含多少位。Redis 会根据设置的offset
自动调整字符串的长度(通过在需要时添加零位):如果offset
超过了当前字符串的最大可设置位(即当前字节数 * 8),Redis 会扩展字符串并在新增的字节中填充零,直到能够容纳该offset
所指向的位置。 getBit(String key, long offset)
:获取key
对应字符串中指定偏移量(offset
)的位的值。- List<Long> bitField(K key, BitFieldSubCommands subCommands):它通过位偏移量来获取或设置字符串中某些位的值。
bitField
允许你在 一个操作 中批量获取或设置多个位字段。 BitFieldSubCommands
是 RedisTemplate 提供的一个用于构建位字段子命令的类。它允许你创建不同的位操作,如获取(get
)、设置(set
)、和改变值等。valueAt(offset)
表示从 Redis 字符串值的第offset
位开始读取。bitCount(String key)
:返回key
中比特位为1
的个数。bitOp(BitOperation op, String destKey, String... keys)
:执行位操作(如 AND、OR、XOR),对多个key
执行按位操作并将结果存储到destKey
。
(2)HyperLogLog
通过 opsForValue()
来操作,是 String
类型数据的一种特殊应用。HyperLogLog 是一种用于 基数估算 的数据结构,能够快速估算不重复元素的数量,并且占用很小的内存空间。
常用方法:
pfAdd(String key, Object... values)
:将一个或多个值加入到 HyperLogLog 中。pfCount(String... keys)
:返回一个或多个 HyperLogLog 的基数估算值。pfMerge(String destKey, String... sourceKeys)
:将多个 HyperLogLog 合并到一个目标 HyperLogLog 中。
(3)Geo(地理位置)
opsForGeo()
:操作 Geo(地理位置) 类型,支持基于经纬度进行操作。Geo 是 Redis 用于存储和查询地理位置的专用数据结构。它支持通过经纬度信息来查询和处理地理数据。
常用方法:
add(String key, Point point, Object... values)
:将指定的经纬度(point
)和成员值加入到地理位置数据中。radius(String key, Point point, double radius)
:根据给定的经纬度和半径查询范围内的成员。distance(String key, Object member1, Object member2)
:计算两个地理位置成员之间的距离。position(String key, Object... values)
:获取一个或多个成员的经纬度信息。
(4)Streams(流数据)
opsForStream()
:操作 Stream 类型,支持流数据操作。Streams 是 Redis 5.0 引入的一种新的数据结构,用于处理实时数据流。它可以作为消息队列使用,并且支持高级特性,如消费者组和消息确认等。
常用方法:
add(String key, Map<String, String> fields)
:将一个消息添加到 Stream。range(String key, Range range)
:获取指定范围内的消息。read(String key, StreamOffset<String> offset)
:从指定的 Stream 中读取消息。acknowledge(String key, String group, String... messageIds)
:确认消费的消息。
2.3.RedisTemplate
中的 Lua 脚本执行方法
方法 使用场景 execute(RedisScript<T> script, List<K> keys, Object... args)
适用于 简单的字符串、数值操作 execute(RedisScript<T> script, RedisSerializer<?> argsSerializer, RedisSerializer<T> resultSerializer, List<K> keys, Object... args)
适用于 复杂数据类型(JSON、对象)
2.3.1. DefaultRedisScript<T>
(1)什么是 DefaultRedisScript<T>
?
DefaultRedisScript<T>
是 Spring Data Redis 提供的一个类,用于封装 Lua 脚本并指定脚本的返回类型。它是 RedisScript<T>
接口的默认实现类。
-
作用:将 Lua 脚本包装成一个可执行的对象,并指定脚本的返回类型。
-
泛型
T
:表示脚本执行结果的类型,例如String
、Long
、List
等。
(2)DefaultRedisScript<T>
的构造方法
DefaultRedisScript<T>
提供了以下常用的构造方法:
-
无参构造方法:
DefaultRedisScript<T> script = new DefaultRedisScript<>();
需要手动设置脚本内容和返回类型。
-
带脚本内容和返回类型的构造方法:
DefaultRedisScript<T> script = new DefaultRedisScript<>(luaScript, resultType);
-
luaScript
:Lua 脚本内容(字符串)。 -
resultType
:脚本返回值的类型(Class<T>
)。
-
(3)DefaultRedisScript<T>
的常用方法
-
setScriptText(String scriptText)
:设置 Lua 脚本内容。 -
setResultType(Class<T> resultType)
:设置脚本返回值的类型。 -
getScriptAsString()
:获取 Lua 脚本内容 -
getResultType()
:获取脚本返回值的类型
(4)DefaultRedisScript<T>
的使用示例
import org.springframework.data.redis.core.script.DefaultRedisScript;
public class DefaultRedisScriptExample {
public static void main(String[] args) {
// 1. 创建 DefaultRedisScript 对象
DefaultRedisScript<String> script = new DefaultRedisScript<>();
// 2. 设置 Lua 脚本内容
script.setScriptText("return redis.call('get', KEYS[1])");
// 3. 设置脚本返回类型
script.setResultType(String.class);
// 4. 获取脚本内容和返回类型
System.out.println("Script: " + script.getScriptAsString());
System.out.println("Result Type: " + script.getResultType());
}
}
2.3.2. execute(RedisScript<T> script, List<K> keys, Object... args)
详解
(1)方法说明
-
作用:执行 Lua 脚本,并返回脚本的执行结果。
-
参数:
-
script
:RedisScript<T>
对象,表示要执行的 Lua 脚本。 -
keys
:List<K>
类型,表示脚本中使用的 Redis 键(对应 Lua 脚本中的KEYS
表)。 -
args
:可变参数,表示传递给脚本的参数(对应 Lua 脚本中的ARGV
表)。
-
-
返回值:脚本的执行结果,类型由
RedisScript<T>
的泛型T
决定。
在 Spring Data Redis 的
redisTemplate.execute()
方法中,所有 Redis Lua 脚本所操作的 key(即KEYS
列表)都必须传递为List<String>
类型,而Collections.singletonList(key)
是 创建单个元素的List
的一种简洁方式。另一种创建
List
的方式是:Arrays.asList(key)
但
Collections.singletonList()
相比Arrays.asList()
更高效:
Collections.singletonList()
返回的是固定大小的List
,更轻量。Arrays.asList()
返回的List
仍然是基于数组的包装类,性能稍逊色。- 如果只需要一个 key,
singletonList()
是最佳选择。
(2)使用步骤
-
定义 Lua 脚本。
-
创建
DefaultRedisScript<T>
对象,并设置脚本内容和返回类型。 -
调用
execute
方法,传入脚本、键和参数。
(3)示例代码
示例 1:原子递增
// 创建 Lua 脚本
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText("return redis.call('incrby', KEYS[1], ARGV[1])");
script.setResultType(Long.class);
// 执行 Lua 脚本
Long result = redisTemplate.execute(script, Collections.singletonList("counter"), 10);
System.out.println("计数器值:" + result);
counter
是 Redis key。ARGV[1] = 10
表示 递增 10。- 返回 新的计数器值。
示例 2:防重令牌(去重操作)
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end");
script.setResultType(Long.class);
// 订单防重令牌
Long result = redisTemplate.execute(script, Collections.singletonList("order:token:12345"), "token123");
if (result == 1) {
System.out.println("防重令牌校验成功");
} else {
System.out.println("防重令牌无效或已被使用");
}
- 如果 Redis 存储的 令牌(
KEYS[1]
)和 用户提交的令牌(ARGV[1]
)相等,则删除令牌并返回1
。 - 如果不匹配,则返回
0
,表示令牌无效。
2.3.3. execute(RedisScript<T> script, RedisSerializer<?> argsSerializer, RedisSerializer<T> resultSerializer, List<K> keys, Object... args)
详解
(1)方法说明
-
作用:执行 Lua 脚本,并支持自定义参数的序列化和结果的序列化。
-
参数:
-
script
:RedisScript<T>
对象,表示要执行的 Lua 脚本。 -
argsSerializer
:用于序列化脚本参数的序列化器(比如StringRedisSerializer
)。。 -
resultSerializer
:用于反序列化脚本结果的序列化器(比如Jackson2JsonRedisSerializer<T>
)。 -
keys
:List<K>
类型,表示脚本中使用的 Redis 键。 -
args
:可变参数,表示传递给脚本的参数。
-
-
返回值:脚本的执行结果,类型由
RedisScript<T>
的泛型T
决定。
(2)使用步骤
-
定义 Lua 脚本。
-
创建
DefaultRedisScript<T>
对象,并设置脚本内容和返回类型。 -
调用
execute
方法,传入脚本、序列化器、键和参数。
(3)示例代码
示例 1:存储 JSON 并查询
// 创建 Lua 脚本
DefaultRedisScript<String> script = new DefaultRedisScript<>();
script.setScriptText("return redis.call('get', KEYS[1])");
script.setResultType(String.class);
// 定义序列化器
RedisSerializer<String> stringSerializer = redisTemplate.getStringSerializer();
// 存储 JSON 数据
redisTemplate.opsForValue().set("user:1001", "{ \"name\": \"张三\", \"age\": 25 }");
// 执行 Lua 脚本获取数据
String userData = redisTemplate.execute(script, stringSerializer, stringSerializer, Collections.singletonList("user:1001"));
System.out.println("查询到的用户数据:" + userData);
- 使用
StringRedisSerializer
处理 JSON 字符串,保证数据完整性。 get user:1001
获取存储的 JSON 数据。
2.4.Caffeine本地缓存的使用
Caffeine 是一个高性能、本地内存缓存库,专门用于 Java 应用程序。
它就像你在程序中加了一个小型的“内存数据库”,用来临时保存经常用到的数据(比如热门商品、字典项等),避免频繁访问数据库或外部接口。
Caffeine 和 Redis 的区别
对比点 | Caffeine(本地缓存) | Redis(远程缓存) |
---|---|---|
📍存储位置 | JVM 本地内存(Heap) | Redis 服务端(内存+网络) |
🚀访问速度 | 非常快(纳秒级) | 快(微秒级)但需网络通信 |
👥共享性 | 仅当前应用可见(单节点) | 多节点共享(分布式) |
🧠功能 | 专注本地缓存 | 分布式缓存、消息队列、锁等多功能 |
💥容错性 | JVM 崩溃则缓存消失 | Redis 重启可配置持久化 |
📊性能瓶颈 | 受限于 JVM 内存大小 | 可横向扩展 Redis 集群 |
🛠使用复杂度 | 简单(引入库即用) | 需部署 Redis 服务、网络配置等 |
🔁适用场景 | 热点数据、本地缓存、低延迟访问 | 共享缓存、分布式系统、跨服务缓存 |
添加依赖
<!-- pom.xml 中添加 Caffeine 依赖 --> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> </dependency> <!-- Spring Boot Cache 支持(通常已有) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>
在主类或配置类加上注解
@SpringBootApplication @EnableCaching // 开启缓存功能 public class YourApplication { public static void main(String[] args) { SpringApplication.run(YourApplication.class, args); } }
自定义缓存配置类
如果需要为不同缓存区域(如
users
,products
)设置不同的策略:import com.github.benmanes.caffeine.cache.Caffeine; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.caffeine.CaffeineCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.concurrent.TimeUnit; @Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager(); // 默认全局配置(可被具体缓存覆盖) cacheManager.setCaffeine(Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(30, TimeUnit.MINUTES) .recordStats()); // 为特定缓存自定义配置 cacheManager.registerCustomCache("users", Caffeine.newBuilder() .maximumSize(500) .expireAfterWrite(1, TimeUnit.HOURS) .build()); return cacheManager; }
在
Caffeine.newBuilder()
链式调用中可配置:Caffeine.newBuilder() .initialCapacity(100) // 初始容量 .maximumSize(1000) // 最大条目数 .expireAfterWrite(10, TimeUnit.MINUTES) // 写入后过期时间 .expireAfterAccess(5, TimeUnit.MINUTES) // 访问后过期时间 .refreshAfterWrite(1, TimeUnit.MINUTES) // 写入后自动刷新(需AsyncCache) .recordStats(); // 开启统计(命中率等)
监控缓存状态
通过
Cache
对象获取统计信息:import com.github.benmanes.caffeine.cache.stats.CacheStats; import org.springframework.cache.CacheManager; import org.springframework.beans.factory.annotation.Autowired; public class CacheMonitor { @Autowired private CacheManager cacheManager; public void printStats() { CaffeineCache caffeineCache = (CaffeineCache) cacheManager.getCache("users"); com.github.benmanes.caffeine.cache.Cache<Object, Object> nativeCache = caffeineCache.getNativeCache(); CacheStats stats = nativeCache.stats(); System.out.println("命中率: " + stats.hitRate()); System.out.println("平均加载时间: " + stats.averageLoadPenalty() + "ns"); } }
2.4.1. @Cacheable
注解
@Cacheable
注解用于将方法的返回值缓存起来。下次调用该方法时,如果参数相同,Spring 会直接从缓存中返回结果,而不再执行方法体。这可以减少对数据库或外部服务的重复访问,提升性能。
使用方法
@Cacheable(value = "userCache", key = "#id", unless = "#result == null")
public User getUserById(Long id) {
System.out.println("Executing getUserById for id: " + id);
return findUserInDatabase(id); // 模拟数据库查询
}
参数详解
-
value
:指定缓存区域的名称,相当于缓存的命名空间或前缀(例如userCache
)。所有userCache
缓存区的键都会以userCache::
开头。 -
key
:指定缓存的键。使用#参数名
来引用方法的参数,如#id
表示将id
参数作为缓存键的一部分。最终 Redis 中的键可能是userCache::1
(假设id=1
)。 -
unless
(可选):指定条件,当条件成立时不缓存结果。例如,unless = "#result == null"
表示如果返回值为null
,则不缓存。
缓存的工作原理
- 第一次调用:当调用
getResourceSet("123")
时,Spring 会检查缓存区域"resource"
中是否存在键为123
的缓存数据。如果不存在,则执行方法并将返回的Set<String>
存入缓存。 -
后续调用:当再次调用
getResourceSet("123")
时,Spring 会直接从缓存中获取数据,而不会执行方法。
2.4.2. @CachePut
注解
@CachePut
注解用于更新缓存数据,与 @Cacheable
不同的是,@CachePut
每次都会执行方法体,并将返回值更新到缓存中。它主要用于在更新数据的同时刷新缓存。
使用方法
@CachePut(value = "userCache", key = "#user.id")
public User updateUser(User user) {
System.out.println("Executing updateUser for id: " + user.getId());
return updateUserInDatabase(user); // 模拟更新数据库
}
参数详解
-
value
:指定缓存区域名称,通常为与查询操作相同的命名空间(例如userCache
)。 -
key
:指定缓存的键,通常是方法参数的某个属性,如#user.id
表示将user
对象的id
作为缓存键。
应用场景
-
数据更新操作:适用于更新数据库信息的场景。在数据库更新成功后,
@CachePut
会自动更新缓存中的数据,保证缓存和数据库的数据一致性。 -
缓存同步:
@CachePut
保证每次都更新缓存,适合在需要同步缓存与数据库的情况下使用。
2.4.3. @CacheEvict
注解
@CacheEvict
注解用于清除缓存中的数据。可以指定删除特定的缓存项,也可以通过配置 allEntries
参数清空整个缓存区域。
使用方法
@CacheEvict(value = "userCache", key = "#id", beforeInvocation = true)
public void deleteUserById(Long id) {
System.out.println("Executing deleteUserById for id: " + id);
deleteUserFromDatabase(id); // 模拟从数据库中删除用户
}
参数详解
-
value
:指定缓存区域的名称(如userCache
)。 -
key
:指定要清除的缓存项的键。例如,key = "#id"
表示删除userCache
中以id
为键的缓存项。 -
allEntries
(可选):当设置为true
时,清除整个缓存区域内的所有缓存项。默认为false
。 -
beforeInvocation
(可选):当设置为true
时,在方法执行前清除缓存。默认值是false
,即方法成功执行后再清除缓存。这在方法可能抛出异常时非常有用,避免方法失败导致缓存不一致。
应用场景
-
数据删除操作:适用于在从数据库删除数据时,确保清除对应的缓存项。
-
清空缓存:
allEntries = true
时,适用于清空整个缓存区域。例如,定期清空用户缓存区中的所有数据,以确保数据的时效性。 -
防止缓存不一致:
beforeInvocation = true
可以在方法执行前清除缓存,适合可能因异常导致数据不一致的场景。
3. 数据库与 Redis 的数据一致性保障
选择缓存数据的关键考量
在选择数据缓存到 Redis 时,需要平衡以下几个方面:
- 数据访问的频率:高频访问的数据更适合缓存,因为缓存可以显著降低频繁访问对数据库的负担。
- 数据更新的频率:频繁更新的数据可能不适合缓存,除非有专门的机制保持缓存数据和源数据的一致性。
- 数据的大小和结构:缓存的大小需要考虑 Redis 的内存限制。对于特别大的数据或复杂的数据结构,缓存设计需要小心,以避免 Redis 内存不足。
对于缓存的更新也要预防多线程问题。
问题 1:数据库更新成功,Redis 更新失败(如 Redis 宕机、网络异常)
场景:
- 业务逻辑先更新数据库,然后更新 Redis。
- 数据库更新成功,但Redis 更新失败(如 Redis 宕机、网络异常)。
- 这导致 Redis 仍然存储旧数据,下次读取时可能返回错误的数据。
方案 3.1.1:先更新数据库,再删除缓存
流程
- 先更新数据库
- 再删除 Redis 缓存
- 下次查询时,重新从数据库加载,并更新 Redis
@Transactional
public void updateData(Long id, String newValue) {
// 1. 更新数据库
dataMapper.updateValue(id, newValue);
// 2. 更新 Redis
redisTemplate.opsForValue().set("data:" + id, newValue, 10, TimeUnit.MINUTES);
}
优点:
- 读请求不会命中旧缓存,确保数据准确。
- 不会有数据丢失风险(缓存删除失败时,仍然能从数据库查询)。
缺点:
- 存在 缓存瞬间失效 问题,高并发下可能导致大量请求直击数据库(缓存击穿)。
方案 3.1.2:延迟双删策略
流程
- 先删除 Redis 缓存
- 等待一定时间(如 500ms),再删除一次
- 期间如果有读请求,则从数据库加载并更新缓存
@Transactional
public void updateData(Long id, String newValue) {
// 1. 先删除缓存
redisTemplate.delete("data:" + id);
// 2. 更新数据库
dataMapper.updateValue(id, newValue);
// 3. 延迟再删除一次,防止并发问题
new Thread(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
redisTemplate.delete("data:" + id);
}).start();
}
适用于:高并发场景,避免并发读写导致缓存脏数据。
方案 3.1.3 基于消息队列的最终一致性:
- 事务提交后,发送一条消息到消息队列(如 Kafka、RabbitMQ、RocketMQ)。
- 监听者(消费者) 订阅该消息,异步更新 Redis。
- 如果 Redis 更新失败,可通过重试机制(消息重试或补偿机制)重新同步。
问题 2:Redis 更新成功,数据库更新失败(事务回滚)
场景:
- 先更新 Redis,再更新数据库(有些业务为了提高读性能会这样做)。
- 但数据库更新失败(事务回滚、数据库宕机),Redis 已更新。
- 这样 Redis 里是新数据,但数据库里仍然是旧数据,导致数据不一致。
方案 3.2.1:TCC(Try-Confirm-Cancel)模式
TCC(Try-Confirm-Cancel)分布式事务模式可以帮助解决Redis 和数据库事务不一致的问题,关键点在于 Cancel
逻辑可以补偿 Redis 操作。
TCC 事务的执行流程
- Try:
- 预先执行数据库和 Redis 操作,但不提交
- Confirm:
- 如果事务执行成功,则提交数据库和 Redis 变更
- Cancel:
- 如果事务失败,则回滚数据库,并撤销 Redis 变更
适用场景:高并发订单、支付、库存等强一致性需求的业务。
调用 TCC 事务
在业务层,可以这样调用:
List<Resource> resourceList = trySyncResourceFavoriteToDB();
try {
confirmSyncResourceFavoriteToDB(resourceList);
} catch (Exception e) {
cancelSyncResourceFavoriteToDB(resourceList); // 事务失败时回滚
}
TCC 事务的 Try 阶段 需要一个标记 “该数据尚未最终确认”,
pending
字段的作用就是:
- Try 阶段:预写数据库,但 只标记为 “待提交”,不算正式更新。
- Confirm 阶段:标记
pending = false
,表示数据 确认成功。- Cancel 阶段:回滚
pending = true
的数据,避免错误的favorite_count
被错误提交。TCC的三个方法必需都单独持有
@Transactional
注解
TCC 要求每个阶段(Try、Confirm、Cancel)都能独立执行、独立回滚。如果把它们全都放在一个
@Transactional
方法中,一旦事务提交或回滚,就只会有“一个整体结果”,无法对不同阶段分别进行处理。
- 调度/入口方法(这里是
syncResourceFavoriteToDB()
)放在一个类(例如Scheduler
类)里,不加@Transactional
。- Try / Confirm / Cancel 三个方法放在 另一个类(例如
FavoriteTccService
),并且 分别加上@Transactional
。- 这样,当调度类调用这三个有事务注解的方法时,就能触发 Spring AOP 去创建 独立的事务,从而实现 TCC 所需的多阶段提交/回滚。
✅ 1. Try 阶段(预执行数据库和 Redis 操作)
@Transactional
public List<Resource> trySyncResourceFavoriteToDB() {
// 1. 获取 Redis 中的“待处理消息”集合,并移除(Try 阶段先拉后删)
String luaScript =
"local result = redis.call('SMEMBERS', KEYS[1]) " +
"redis.call('DEL', KEYS[1]) " +
"return result";
List<Object> recordSet = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, List.class),
Collections.singletonList(SystemConstants.PENDING_FAVORITE_MODIFY_KEY)
);
if (recordSet == null || recordSet.isEmpty()) {
return Collections.emptyList(); // 没有待处理数据,直接返回
}
// 2. 解析 JSON 数据
List<Map<String, Object>> recordList = recordSet.stream()
.map(json -> JSON.parseObject((String) json, new TypeReference<Map<String, Object>>() {}))
.toList();
// 3. 分组存储 subjectId -> List<resourceId>
Map<Long, List<Long>> subjectResourceMap = recordList.stream()
.collect(Collectors.groupingBy(
r -> Long.parseLong(r.get("subjectId").toString()),
Collectors.mapping(r -> Long.parseLong(r.get("resourceId").toString()), Collectors.toList())
));
List<Resource> resourceToUpdateList = new ArrayList<>();
// 4. 预取 Redis 最新的 favorite_count
for (Map.Entry<Long, List<Long>> entry : subjectResourceMap.entrySet()) {
Long subjectId = entry.getKey();
String subjectFavoriteKey = String.format(SystemConstants.SUBJECT_RESOURCES_FAVORITE_KEY, subjectId);
for (Long resourceId : entry.getValue()) {
Double favoriteCountDouble = redisTemplate.opsForZSet().score(subjectFavoriteKey, resourceId.toString());
if (favoriteCountDouble == null) continue;
int favoriteCount = favoriteCountDouble.intValue();
// 预存数据库,标记 "待提交"
Resource resource = new Resource();
resource.setId(resourceId);
resource.setFavoriteCount(favoriteCount);
resource.setPending(true); // 额外字段,标记数据是“待确认”状态
resourceToUpdateList.add(resource);
}
}
if (!resourceToUpdateList.isEmpty()) {
resourceService.updateBatchById(resourceToUpdateList); // 预写数据库,但未确认
}
return resourceToUpdateList; // 返回 Try 阶段数据,供 Confirm/Cancel 使用
}
✅ 2. Confirm 阶段(提交数据库变更)
@Transactional
public void confirmSyncResourceFavoriteToDB(List<Resource> resourceList) {
if (resourceList == null || resourceList.isEmpty()) {
return;
}
// 1. 取消 "待提交" 标记,并正式更新数据库
for (Resource resource : resourceList) {
resource.setPending(false); // 标记数据为“已确认”
}
resourceService.updateBatchById(resourceList);
// 2. 清理 Redis 过时的 favorite_count 记录
for (Resource resource : resourceList) {
String subjectFavoriteKey = String.format(SystemConstants.SUBJECT_RESOURCES_FAVORITE_KEY, resource.getSubjectId());
redisTemplate.opsForZSet().remove(subjectFavoriteKey, resource.getId().toString());
}
}
✅ 3. Cancel 阶段(回滚数据库变更 & 恢复 Redis 记录)
@Transactional
public void cancelSyncResourceFavoriteToDB(List<Resource> resourceList) {
if (resourceList == null || resourceList.isEmpty()) {
return;
}
// 1. 回滚数据库变更(只回滚 “待提交” 状态的数据)
for (Resource resource : resourceList) {
resource.setFavoriteCount(null); // 取消变更
resource.setPending(false); // 取消“待提交”标记
}
resourceService.updateBatchById(resourceList);
// 2. 恢复 Redis 过期的数据(补偿)
for (Resource resource : resourceList) {
String subjectFavoriteKey = String.format(SystemConstants.SUBJECT_RESOURCES_FAVORITE_KEY, resource.getSubjectId());
redisTemplate.opsForZSet().add(subjectFavoriteKey, resource.getId().toString(), resource.getFavoriteCount());
}
}
问题 3:并发更新,导致缓存和数据库状态不一致
场景:
- 多个线程同时修改同一条数据,导致数据库和 Redis 状态不一致。
方案 3.3.1:Redis 分布式锁
加锁防止并发写冲突
public void updateData(Long id, String newValue) {
String lockKey = "lock:data:" + id;
Boolean lock = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(lock)) {
try {
dataMapper.updateValue(id, newValue);
redisTemplate.delete("data:" + id);
} finally {
redisTemplate.delete(lockKey);
}
} else {
throw new RuntimeException("更新中,请稍后再试");
}
}
适用场景:
- 高并发写入,防止数据错乱。
方案 3.3.2:乐观锁(版本号)
public boolean updateData(Long id, String newValue, int version) {
return dataMapper.updateWithVersion(id, newValue, version) > 0;
}
适用场景:
- 适用于高并发读写,不适用于高并发写入。
4. 分布式锁的实现
1. ReentrantLock(本地锁)
机制:基于 JVM 内存的互斥锁,同一线程可重复获取锁(可重入)。
适用场景:
单机应用内的线程同步(如避免并发修改共享资源)。
无需跨进程或分布式环境的高性能场景。
局限性:
无法跨 JVM,分布式环境下无效。
无自动续期或超时机制(需手动避免死锁)。
2. RedisTemplate 实现的分布式锁
机制:通过 Redis 的
SET key value NX PX
命令实现锁的获取与超时。核心问题:
原子性:需用 Lua 脚本确保“获取锁+设置超时”和“释放锁+校验持有者”的原子性。
锁续期:若业务执行时间超过锁超时时间,需手动实现续期(如定时任务)。
误删锁:需存储唯一标识(如线程 ID),释放时校验避免误删其他线程的锁。
3. RedissonClient 实现的分布式锁
Redisson 是一个在 Redis 基础之上封装的高水平客户端,它不只是提供简单的操作 API,还提供各种基于 Redis 的分布式工具(如分布式 Map、分布式锁、分布式队列、AtomicLong 等)。
- 可重入:同一个线程多次加锁不会被阻塞。
- 自动续期:默认情况下,如果加锁线程还活着,锁的过期时间会被自动续期,避免锁意外过早过期。
- 看门狗机制:通过“锁看门狗”监控锁的持有情况,使得在一定超时时间后能安全释放等。
同时,Redisson 在集群环境下也实现了比如 RedLock、主从模式、哨兵模式、Cluster 模式等多种部署方式。它还可以保证 pub/sub 通知,Redis 宕机或节点故障时能及时感知并做相应动作。
4.1.使用 SETNX
和 EXPIRE
命令手动实现分布式锁
1. 分布式锁的原理
Redis 的 SETNX
和 EXPIRE
命令可以实现基础的分布式锁功能:
- SETNX(SET if Not Exists):尝试设置一个键(代表锁),如果该键不存在,则设置成功,表示锁被成功获取;如果键已存在,表示锁被占用。
- EXPIRE:为锁设置过期时间,确保锁在持有方崩溃或出错时能自动释放,避免死锁。
实现步骤如下:
- 获取锁:使用
SETNX
设置一个锁键。如果返回成功,表示当前客户端获取到锁;否则获取锁失败。 - 设置过期时间:在获取锁成功后,使用
EXPIRE
为锁设置一个过期时间,防止锁因意外情况无法释放。 - 释放锁:在操作完成后,客户端主动删除锁,释放资源。
2. 在 Spring Boot 中实现 Redis 分布式锁
我们可以通过 StringRedisTemplate
来操作 Redis,创建一个分布式锁服务类 RedisLockService
,用于管理锁的获取和释放。
代码示例
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class RedisLockService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LOCK_KEY = "lock_key"; // 锁的键名称
private static final int LOCK_EXPIRE = 10; // 锁的过期时间,10秒
// 尝试获取锁
public boolean tryLock() {
// 使用 SETNX 命令尝试获取锁
Boolean success = redisTemplate.opsForValue().setIfAbsent(LOCK_KEY, "1");
if (Boolean.TRUE.equals(success)) {
// 获取锁成功后,使用 EXPIRE 设置锁的过期时间
redisTemplate.expire(LOCK_KEY, LOCK_EXPIRE, TimeUnit.SECONDS);
return true; // 返回 true 表示成功获取到锁
}
return false; // 获取锁失败,返回 false
}
// 释放锁
public void unlock() {
redisTemplate.delete(LOCK_KEY); // 删除锁键,释放锁资源
}
}
代码详细解释
-
tryLock()
方法:用于尝试获取锁setIfAbsent(LOCK_KEY, "1")
:使用SETNX
命令尝试设置锁。如果成功返回true
,表示当前客户端获取到锁。如果返回false
,表示锁已被占用。expire(LOCK_KEY, LOCK_EXPIRE, TimeUnit.SECONDS)
:设置锁的过期时间为 10 秒,防止锁在客户端意外退出时长期占用。
-
unlock()
方法:用于释放锁delete(LOCK_KEY)
:删除锁键,释放锁资源。确保操作完成后锁被释放,让其他客户端可以继续获取该锁。
3. 使用示例:库存扣减操作
假设我们有一个商品库存扣减的场景,为了防止多个请求同时扣减库存导致超卖问题,可以通过分布式锁来确保同一时间只有一个请求可以扣减库存。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class InventoryService {
@Autowired
private RedisLockService redisLockService;
public String deductInventory(String productId) {
// 尝试获取锁
if (redisLockService.tryLock()) {
try {
// 执行扣减库存的逻辑
boolean success = reduceStock(productId);
return success ? "扣减成功" : "库存不足";
} finally {
// 确保操作完成后释放锁
redisLockService.unlock();
}
} else {
return "请稍后再试"; // 如果获取锁失败,提示用户稍后重试
}
}
private boolean reduceStock(String productId) {
System.out.println("扣减库存,产品ID: " + productId);
return true; // 假设库存扣减成功
}
}
使用示例的代码解释
tryLock()
:调用tryLock()
方法尝试获取锁,如果成功,则执行扣减库存操作。- 业务逻辑:调用
reduceStock(productId)
方法执行库存扣减操作,确保在持有锁的情况下完成操作。 - 释放锁:操作完成后无论成功与否,调用
unlock()
方法释放锁。
4. 锁的有效期与安全性
由于 SETNX
和 EXPIRE
不是原子操作,存在并发情况下锁的过期时间可能未设置的问题。虽然可以通过 Lua 脚本来实现原子性,但这种实现较为复杂。为确保分布式锁的可靠性和安全性,通常建议在高并发场景中使用 Redisson 来简化操作和提升安全性。
4.2.使用Redisson的RLock实现分布式锁
Redisson 是一个 Redis 客户端,封装了 Redis 的锁机制,并提供了 RLock
接口来管理分布式锁。相比使用 SETNX
和 EXPIRE
组合手动管理锁的方式,Redisson 的分布式锁具备以下优势:
- 自动续期:Redisson 的
RLock
支持锁的自动续期,确保在长时间持有锁时不意外失效。 - 可重入性:同一线程可以多次获取同一锁,不会造成死锁。
- 高可靠性:Redisson 提供了丰富的 API,可以灵活管理锁的超时和等待时间,适合高并发场景。
- Redisson 的自动续期机制设计的核心是确保锁在“正常持有”时不会意外释放,但一旦持有锁的线程中断或崩溃,续期停止,锁在过期时间到达后自动释放。这样既保证了锁在正常情况下的稳定性,也确保了线程异常时的自动释放。
- 在业务逻辑中,可能会出现递归调用或嵌套调用的情况,即一个持有锁的方法在调用的过程中又尝试获取同一锁。支持可重入性后,同一线程在持有锁的情况下可以再次获取锁,不会发生阻塞或死锁。
- 在分布式系统中,业务逻辑通常分为多个层次,每一层可能都有自己的锁需求。可重入性使得在多个层次调用同一锁时不需要担心冲突,同一线程可以在各个层次持有同一把锁。
4.2.1.使用步骤
1. 引入 Redisson 依赖
首先,在 Spring Boot 项目中添加 Redisson 的依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.15.3</version> <!-- 请使用最新版本 -->
</dependency>
2. 配置 RedissonClient Bean
在 application.properties
文件中配置 Redis 的连接信息:
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=yourpassword # 如果没有设置密码,可以省略这一行
spring.redis.timeout=3000 # 连接超时时间(毫秒)
接下来,创建一个 RedissonConfig
配置类,将 RedissonClient
配置为一个 Spring Bean。通过这个 Bean,可以在项目中方便地获取 RLock
对象,实现分布式锁。
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
return Redisson.create(config);
}
}
配置解释:
Config config = new Config();
:创建 Redisson 的配置对象。config.useSingleServer().setAddress("redis://localhost:6379");
:设置 Redis 服务器的地址。return Redisson.create(config);
:通过配置创建RedissonClient
实例,并注册为 Spring Bean。
3. 获取 RLock 对象
在业务代码中,可以通过 RedissonClient
获取 RLock
对象。 RLock
是一个分布式锁接口,提供了丰富的锁管理方法,支持可重入和自动续期功能。
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class LockService {
@Autowired
private RedissonClient redissonClient;
public void lockExample() {
RLock lock = redissonClient.getLock("my_lock");
// 使用 lock 进行分布式锁操作
}
}
4.2.2. RLock 的常用方法和使用示例
RLock 提供了多种方法来管理分布式锁的获取和释放,以下是常用方法及其使用示例:
(1)lock()
:阻塞获取锁
该方法会阻塞直到获取到锁,适合在没有超时等待要求的场景。
public void lockExample() {
RLock lock = redissonClient.getLock("my_lock");
lock.lock(); // 阻塞获取锁
try {
// 执行同步的业务逻辑
} finally {
lock.unlock(); // 释放锁
}
}
解释:
lock.lock()
:阻塞方式获取锁,如果锁已经被其他线程持有,则等待直至锁被释放。lock.unlock()
:释放锁,让其他线程可以获取该锁。
(2)tryLock()
:非阻塞获取锁
tryLock()
方法用于尝试获取锁。如果锁已经被持有,则立即返回 false
,不阻塞等待。
public boolean tryLockExample() {
RLock lock = redissonClient.getLock("my_lock");
boolean locked = lock.tryLock(); // 非阻塞获取锁
if (locked) {
try {
// 执行同步的业务逻辑
return true;
} finally {
lock.unlock(); // 释放锁
}
} else {
System.out.println("获取锁失败,锁已被持有");
return false;
}
}
解释:
tryLock()
:立即尝试获取锁,如果锁被持有则返回false
,表示获取锁失败;如果锁可用则返回true
,表示获取锁成功。
(3)tryLock(long waitTime, long leaseTime, TimeUnit unit)
:带超时的获取锁
这个方法支持等待和超时设置,可以在锁被其他线程持有时等待一定时间。如果在等待时间内未获取到锁则放弃请求,同时设置锁的持有时间。
- 等待时间:当前线程等待的最大时间。
- 持有时间:锁成功获取后自动释放的时间。
public boolean tryLockWithTimeoutExample() {
RLock lock = redissonClient.getLock("my_lock");
try {
// 等待时间为5秒,锁的持有时间为10秒
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
try {
// 执行同步的业务逻辑
return true;
} finally {
lock.unlock(); // 释放锁
}
} else {
System.out.println("获取锁超时,未能成功获取锁");
return false;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("获取锁时发生异常");
return false;
}
}
解释:
tryLock(5, 10, TimeUnit.SECONDS)
:尝试获取锁,等待最多 5 秒,如果在 5 秒内获取到锁,持有 10 秒后自动释放。
实际应用示例:在库存扣减中使用 RLock
假设一个电商系统中有一个库存扣减的操作,为了防止超卖,需要确保每次只有一个线程能够扣减库存,可以通过 RLock
实现这一功能。
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class InventoryService {
@Autowired
private RedissonClient redissonClient;
private static final String LOCK_KEY = "inventory_lock"; // 锁的键名称
public String deductInventory(String productId) {
RLock lock = redissonClient.getLock(LOCK_KEY);
try {
// 尝试加锁,等待时间5秒,锁定时间10秒
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
try {
// 执行扣减库存的逻辑
boolean success = reduceStock(productId);
return success ? "扣减成功" : "库存不足";
} finally {
lock.unlock(); // 释放锁
}
} else {
return "请稍后再试"; // 获取锁失败
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "获取锁失败";
}
}
private boolean reduceStock(String productId) {
System.out.println("扣减库存,产品ID: " + productId);
return true; // 假设库存扣减成功
}
}
代码解释:
redissonClient.getLock(LOCK_KEY)
:获取一个分布式锁对象RLock
,锁的名称为"inventory_lock"
。tryLock(5, 10, TimeUnit.SECONDS)
:尝试获取锁,等待时间 5 秒,持有时间 10 秒。- 释放锁:通过
lock.unlock()
确保在业务逻辑执行完成后释放锁。
4.3. 加锁后为什么缓存数据仍可能被其他线程修改
这是因为Redis 分布式锁和缓存并没有直接联系,它们是两个独立的系统。锁的机制只是确保在同一时间只有一个线程可以执行某段代码,并不保证锁住的内容不会被其他线程修改。
在分布式锁场景中,缓存和锁是分离的:
- 锁控制代码逻辑的独占访问:通过
lock.tryLock()
,确保只有一个线程可以访问数据库和缓存更新逻辑(即从数据库获取数据并更新缓存)。 - 缓存并没有被锁住:在 Redis 中,缓存键的读写是独立的,任何线程都可以访问缓存并进行修改,锁并不能限制其他线程对缓存的访问。
5. 缓存穿透、击穿与雪崩的防范
5.1. 缓存穿透
5.1.1什么是缓存穿透
缓存穿透指的是请求的数据在缓存和数据库中都不存在,这种请求直接穿过缓存访问数据库。当恶意请求大量涌入时,缓存层无法拦截这些无效请求,导致数据库承受大量压力,从而影响性能。比如,用户可能不断请求一个不存在的商品 ID,Redis 缓存中不存在此数据,数据库中也没有。当此类请求频繁出现时,会绕过缓存层直接请求数据库,造成缓存穿透。
5.1.2.什么是布隆过滤器
布隆过滤器(Bloom Filter)是一种概率性数据结构,用于判断一个元素是否存在于集合中。它可以在较少的内存占用下实现快速判断,并且在大多数情况下准确可靠。布隆过滤器能够有效防止缓存穿透,拦截无效请求,从而减少数据库访问压力。
布隆过滤器的工作原理
布隆过滤器由一个长度为 m
的位数组(bit array)和 k
个不同的哈希函数组成。布隆过滤器的基本操作步骤如下:
-
插入元素:当插入一个元素(如 key)时,布隆过滤器会对该元素通过
k
个哈希函数分别计算出k
个哈希值,然后将位数组中对应的k
个位置设为1
。 -
查询元素:当需要判断一个元素是否存在时,对该元素进行相同的
k
个哈希计算,检查位数组中对应的k
个位置。如果所有位置的值都是1
,则表示该元素“可能存在”;如果有任意一个位置的值为0
,则可以确定该元素不存在。
布隆过滤器的误判问题
由于布隆过滤器的概率性,如果查询结果是“存在”,并不能完全保证该数据确实存在(存在一定的误判概率);但如果查询结果为“不存在”,则可以确定该数据绝对不存在。因此,布隆过滤器非常适合用于防止缓存穿透,因为它能高效筛除不存在的数据请求。
布隆过滤器的优势
-
拦截无效请求:布隆过滤器可以有效拦截不存在的数据请求,将大量无效请求挡在缓存层之外,避免对数据库的冲击。
-
高效、低成本:布隆过滤器占用的空间非常小,通过位数组和哈希函数实现,查询速度快且内存占用少,适合大规模数据场景。
-
减少数据库负担:通过提前过滤掉不存在的数据请求,可以显著降低数据库的负载,提升系统性能。
5.1.3 BloomFilterConfig
负责创建 bloomFilter
(仅初始化布隆过滤器)
Spring Boot 中可以使用 Redisson 提供的布隆过滤器来实现防止缓存穿透。Redisson 是一个强大的 Redis 客户端,支持布隆过滤器等高级功能。我们将以下列代码来演示如何使用布隆过滤器。
代码示例
首先,确保项目中已经添加了 Redisson 依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.15.3</version> <!-- 使用最新版本 -->
</dependency>
然后,我们实现一个布隆过滤器服务 BloomFilterService
。在该服务中初始化布隆过滤器,将所有合法的 key 添加进去,并提供判断方法来检测 key 是否存在。
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class BloomFilterConfig {
private static final String RESOURCE_BLOOM_FILTER = "resource_bloom_filter";
@Autowired
private RedissonClient redissonClient;
@Bean
public RBloomFilter<String> bloomFilter() {
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter( RESOURCE_BLOOM_FILTER);
if (!bloomFilter.isExists()) {
bloomFilter.tryInit(10000000, 0.01);
}
return bloomFilter;
}
}
初始化布隆过滤器:
- 使用
redissonClient.getBloomFilter(BLOOM_FILTER_NAME)
获取布隆过滤器实例。 - 调用
tryInit
方法设置布隆过滤器的大小(即预期的插入量)和误判率。例如设置为 100 万个 key,误判率为 1%。 - 误判率越低,布隆过滤器的空间开销越大。因此误判率的选择要在内存占用和准确性之间找到平衡。
5.1.4 ResourceServiceImpl
在 @PostConstruct
里完成数据库查询并填充布隆过滤器
package com.example.bloomfilter.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.bloomfilter.entity.Resource;
import com.example.bloomfilter.mapper.ResourceMapper;
import com.example.bloomfilter.service.ResourceService;
import org.redisson.api.RBloomFilter;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.util.List;
@Service
public class ResourceServiceImpl extends ServiceImpl<ResourceMapper, Resource> implements ResourceService {
@Autowired
private RBloomFilter<String> bloomFilter;
/**
* 应用启动后,将数据库中的 `resourceId` 预加载到布隆过滤器
*/
@PostConstruct
public void initBloomFilter() {
List<String> resourceIds = this.lambdaQuery()
.select(Resource::getId) // 查询 id 字段
.list()
.stream()
.map(resource -> String.valueOf(resource.getId())) // 确保 Long -> String
.toList();
// ✅ 批量添加到布隆过滤器
resourceIds.forEach(bloomFilter::add);
System.out.println("布隆过滤器初始化完成,已加载 " + resourceIds.size() + " 个 resourceId");
}
/**
* 查询资源
*/
public boolean checkResource(String resourceId) {
if (!bloomFilter.contains(resourceId)) {
return false; // 直接返回,减少数据库查询
}
return lambdaQuery().eq(Resource::getResourceId, resourceId).one() != null;
}
/**
* 添加资源(数据库 & 布隆过滤器)
*/
public void addResource(Resource resource) {
save(resource); // 存入数据库
bloomFilter.add(resource.getResourceId()); // 同步存入布隆过滤器
}
}
ResourceServiceImpl
负责查询数据库,并在@PostConstruct
里填充bloomFilter
,保证resourceId
被正确加载。- 当
Bloom Filter
已经初始化时,bloomFilter.isExists()
避免重复初始化。
5.2.缓存击穿
5.2.1什么是缓存击穿?
缓存击穿指的是缓存中某个热点数据失效,导致大量请求直接访问数据库的情况。通常在高并发的场景下,一个热点数据被大量访问,突然失效后所有请求同时涌入数据库,增加数据库负担,可能导致性能瓶颈或崩溃。
典型场景
假设一个电商平台有一个非常热门的商品,所有用户都在查询该商品信息。当该商品的缓存突然过期后,大量请求会直接查询数据库,给数据库带来巨大压力。
缓存击穿的防范措施
-
加锁机制:在缓存失效时,通过加锁确保只有一个线程可以查询数据库并重建缓存,其他线程等待锁释放后再读取缓存。此方法适用于并发访问较高的热点数据。
-
热点数据永不过期:对极少数热点数据设置永不过期,手动更新这些数据的缓存,确保高频数据不会因缓存过期而直接冲击数据库。此方法适用于访问频繁且数据更新较少的场景。
5.2.2. 使用加锁机制解决缓存击穿
在缓存失效时,可以通过分布式锁确保只有一个线程访问数据库并更新缓存,其他线程等待锁释放后再读取缓存。这样可以避免大量并发请求直接冲击数据库。
实现原理
- 查询缓存:先查询缓存,如果缓存命中则直接返回结果。
- 加锁:如果缓存未命中,尝试获取分布式锁,确保只有一个线程可以访问数据库。
- 双重检查缓存:在获取锁后再次检查缓存,防止在获取锁期间缓存已被其他线程更新。
- 查询数据库并更新缓存:如果缓存仍然未命中,查询数据库并将数据写入缓存。
- 释放锁:数据库查询和缓存更新完成后,释放锁,让其他线程可以直接读取缓存。
这里为什么加锁之后还能让其他线程修改缓存:因为加锁的意思是对这段代码进行加锁,这段代码的操作只有被加锁的线程能够进行,但缓存是任何线程都可以修改,加锁只是对这段操作加锁,并不是对缓存加锁。
实现代码示例
以下是一个在 Spring Boot 中使用 Redisson
的加锁机制实现缓存击穿的防范示例:
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class CacheService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private RedissonClient redissonClient;
private static final String LOCK_KEY = "product_lock_";
private static final String CACHE_KEY = "product_cache_";
public String getProduct(String productId) {
// 1. 查询缓存
String cacheValue = redisTemplate.opsForValue().get(CACHE_KEY + productId);
if (cacheValue != null) {
return cacheValue; // 缓存命中,直接返回
}
// 2. 缓存未命中,尝试加锁
RLock lock = redissonClient.getLock(LOCK_KEY + productId);
try {
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) { // 获取锁,等待5秒,锁定10秒
// 3. 双重检查缓存是否已经被其他线程更新
cacheValue = redisTemplate.opsForValue().get(CACHE_KEY + productId);
if (cacheValue != null) {
return cacheValue; // 如果缓存已经被其他线程更新,直接返回
}
// 4. 查询数据库
String dbValue = queryDatabase(productId);
// 5. 更新缓存,设置过期时间
redisTemplate.opsForValue().set(CACHE_KEY + productId, dbValue, 10, TimeUnit.MINUTES);
return dbValue;
} else {
// 获取锁失败,等待缓存更新完成后重新获取
Thread.sleep(100); // 可以增加重试机制
return redisTemplate.opsForValue().get(CACHE_KEY + productId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
lock.unlock(); // 6. 释放锁
}
}
private String queryDatabase(String productId) {
// 模拟数据库查询
System.out.println("查询数据库,商品ID:" + productId);
return "Product data for " + productId;
}
}
代码详细解释
-
查询缓存:首先从缓存中读取数据。如果缓存命中则直接返回结果,避免了不必要的锁操作。
-
加锁查询数据库:
- 如果缓存未命中,使用
Redisson
获取分布式锁lock
,确保只有一个线程可以查询数据库并更新缓存。 - 双重检查缓存:在成功加锁后再次检查缓存,防止在等待锁的过程中其他线程已经更新了缓存。
- 如果缓存未命中,使用
-
重建缓存:持有锁的线程从数据库中读取数据并更新到缓存中,同时设置缓存的过期时间。
-
释放锁:缓存更新完成后,释放锁,确保其他线程可以从缓存读取最新数据。
优势
- 防止缓存击穿:加锁确保在缓存失效时,只有一个线程查询数据库并更新缓存,避免大量并发请求直接冲击数据库。
- 双重检查减少重复操作:锁内再次检查缓存,确保其他线程不会重复查询数据库,减少数据库压力。
5.2.3. 设置热点数据永不过期
对于少数非常高频访问的热点数据,可以将其缓存设置为永不过期,这样确保数据不会失效,从而避免缓存击穿。
实现原理
- 热点数据初始化:系统启动时将热点数据加载到缓存中,设置为永不过期。
- 数据更新策略:通过数据库更新事件或定时任务主动更新热点缓存数据,而不依赖过期机制。
这种方式适合稳定且访问频繁的数据,例如电商首页的推荐商品、分类信息等,可以有效减少缓存失效的概率。
代码示例
以下示例展示如何在 Spring Boot 中将一些热点数据设置为永不过期,并通过手动更新的方式维护缓存数据。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class HotDataCacheService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String CACHE_KEY = "hot_product_cache_";
public String getHotProduct(String productId) {
// 查询缓存
String cacheValue = redisTemplate.opsForValue().get(CACHE_KEY + productId);
if (cacheValue != null) {
return cacheValue; // 缓存命中,直接返回
}
// 缓存未命中,加载数据库并更新缓存(永不过期)
String dbValue = queryDatabase(productId);
// 设置热点数据永不过期
redisTemplate.opsForValue().set(CACHE_KEY + productId, dbValue); // 不设置过期时间
return dbValue;
}
private String queryDatabase(String productId) {
// 模拟数据库查询
System.out.println("查询数据库,商品ID:" + productId);
return "Hot product data for " + productId;
}
// 手动更新缓存
public void updateHotProductCache(String productId, String productData) {
redisTemplate.opsForValue().set(CACHE_KEY + productId, productData); // 不设置过期时间
}
}
代码详细解释
-
查询缓存:首先尝试读取缓存数据。如果缓存命中则直接返回结果,减少数据库访问。
-
更新缓存:如果缓存未命中,查询数据库并更新缓存,不设置过期时间,确保该热点数据不会因过期而失效。
-
手动更新缓存:提供
updateHotProductCache
方法,以便在热点数据有变动时手动更新缓存内容。可以通过数据库更新事件或定时任务主动更新缓存。
优势和适用场景
- 适合高频访问的热点数据:特别是访问频率很高、不会频繁变化的数据,例如首页推荐商品、热门分类等。
- 避免缓存失效带来的冲击:热点数据不会因为缓存失效而直接查询数据库,有效减少数据库负载。
5.3.缓存雪崩
5.3.1.什么是缓存雪崩?
缓存雪崩指的是在某个时间点,大量缓存同时失效,导致所有请求直接访问数据库,给数据库带来巨大的压力,甚至可能导致系统崩溃。这种情况通常发生在以下场景:
- 缓存服务器宕机:整个缓存服务不可用,所有请求直接落到数据库。
- 大量缓存集中在同一时间过期:大量缓存设置了相同的过期时间,导致在某一时刻同时失效。
为防止缓存雪崩,可以采取以下措施:
-
缓存数据的过期时间设置随机化:在缓存过期时间的基础上,加上一个随机值,避免大量缓存同时失效。
-
缓存预热:在系统启动或高峰期到来之前,提前将热点数据加载到缓存中。
-
多级缓存:在本地增加一级缓存,如 Guava Cache,减轻对远程缓存的依赖。
-
限流和降级:在缓存失效时,对数据库的访问进行限流,必要时进行服务降级。
5.3.2. 设置不同的缓存失效时间
原理
为每个缓存数据设置一个基础过期时间,并在此基础上添加一个随机的时间偏移量,使缓存的过期时间分布在一定的范围内。这样可以避免大量缓存数据在同一时刻失效,分散过期时间点,从而减轻数据库的瞬时访问压力。
实现代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@Service
public class CacheService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String CACHE_KEY_PREFIX = "product_cache_";
private static final int BASE_EXPIRE_TIME = 10; // 基础过期时间,单位:分钟
public void cacheProduct(String productId, String productData) {
// 生成随机过期时间,范围在 BASE_EXPIRE_TIME 到 BASE_EXPIRE_TIME + 5 分钟之间
int expireTime = BASE_EXPIRE_TIME + new Random().nextInt(5);
// 将数据存入 Redis,并设置不同的过期时间
redisTemplate.opsForValue().set(CACHE_KEY_PREFIX + productId, productData, expireTime, TimeUnit.MINUTES);
}
public String getProduct(String productId) {
// 从 Redis 中获取数据
return redisTemplate.opsForValue().get(CACHE_KEY_PREFIX + productId);
}
}
代码解释
-
基础过期时间:
BASE_EXPIRE_TIME
设置为 10 分钟,表示缓存的基础过期时间。 -
随机过期时间:在
BASE_EXPIRE_TIME
基础上,加上一个 0 到 4 分钟的随机数new Random().nextInt(5)
,使得缓存的失效时间在 10 到 14 分钟之间。这样不同的缓存条目会有略微不同的过期时间,避免在同一时刻集中失效。 -
缓存数据设置:
redisTemplate.opsForValue().set
方法将数据写入 Redis,并指定过期时间expireTime
。不同的缓存条目会有不同的失效时间,有效降低缓存雪崩的风险。
5.3.3. 缓存预热
缓存预热是指在系统启动时,提前将部分热点数据加载到缓存中,避免在系统运行后产生大量的缓存未命中情况。通过缓存预热,可以保证在系统启动或高峰期来临时,热点数据已经存在于缓存中,减少对数据库的访问压力。
实现思路
- 在系统启动时:加载一些常用的、访问频率高的数据到缓存中。
- 定时任务:定期更新缓存中的热点数据,确保缓存中的数据始终有效。
实现代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.util.Arrays;
import java.util.List;
@Service
public class CacheWarmUpService {
@Autowired
private CacheService cacheService;
@PostConstruct
public void warmUpCache() {
// 获取热点数据的ID列表
List<String> hotProductIds = getHotProductIds();
// 将每个热点数据加载到缓存中
for (String productId : hotProductIds) {
String productData = queryDatabase(productId);
cacheService.cacheProduct(productId, productData); // 将数据预先缓存
}
}
private List<String> getHotProductIds() {
// 从数据库或配置中获取热点数据ID列表
return Arrays.asList("1001", "1002", "1003"); // 示例数据
}
private String queryDatabase(String productId) {
// 模拟数据库查询
return "Product data for " + productId;
}
}
代码解释
-
@PostConstruct
注解:该注解会在 Spring Boot 容器启动后自动调用warmUpCache()
方法。这可以确保在系统启动时,热点数据就已经加载到缓存中。 - 获取热点数据:
getHotProductIds
方法用于获取高访问量的商品 ID 列表,可以从数据库或配置文件中获取。 - 缓存热点数据:遍历热点商品 ID,通过
queryDatabase
方法查询数据库,然后调用cacheService.cacheProduct
方法将数据缓存起来。
5.3.4. 其他防范方法
1.限流与降级
在缓存雪崩发生时,即大量缓存突然失效,访问量瞬间增加,可以通过限流和降级的方式来保护数据库,避免数据库被大量请求压垮。
限流
限流可以控制进入数据库的请求量,将超出部分的请求暂时阻止或延迟,避免瞬时高并发对数据库造成过大的压力。常用的限流算法包括令牌桶、漏桶算法等。
降级
当缓存不可用时,可以考虑提供简化的数据或延迟响应,避免数据库压力过大。例如:
- 返回缓存中的旧数据,虽然不够实时,但可以减少数据库的访问。
- 对非核心业务进行降级,暂时返回空结果或简单提示,确保核心业务的正常运行。
2.多级缓存
多级缓存通过本地缓存和远程缓存结合使用的方式,进一步减轻远程缓存的压力,提升系统的访问速度。多级缓存常见的实现方式是本地缓存(如 Caffeine 或 Guava Cache) + Redis 远程缓存:
- 本地缓存:将热点数据存储在应用服务器本地的内存中,访问速度极快,可以应对短期的缓存雪崩。
- 远程缓存:使用 Redis 作为分布式缓存,缓存更多数据。
6.Redis 高可用架构方案
在 Redis 的高可用技术方案中,高可用指的是在出现节点故障或网络波动时,系统能够持续提供缓存服务,避免服务中断或性能严重下降。高可用的设计保证了即使某个缓存节点故障,缓存服务仍然可以通过故障转移等机制正常工作,减少了单点故障的风险,保证了系统的可靠性和稳定性。
6.1. Redis Sentinel哨兵模式(主从复制和故障监控)
Redis Sentinel 是 Redis 官方提供的一种高可用解决方案,主要通过主从复制和故障监控来实现。在 Redis Sentinel 架构中,Sentinel 负责监控 Redis 集群中的主从节点,当主节点出现故障时,Sentinel 会自动将一个从节点提升为主节点,保证服务的持续运行。这种架构适用于不需要数据分片的场景,适合数据量较小且对高可用性有要求的系统。
6.1.1.Redis Sentinel 高可用架构原理
Redis Sentinel 通过三个主要功能来实现 Redis 集群的高可用性:
-
主从复制:在 Sentinel 架构中,Redis 主节点负责处理写请求,并将数据同步到从节点。多个从节点是主节点的备份,只负责同步数据,不参与写操作。当主节点故障时,Sentinel 可以将从节点提升为主节点,从而实现高可用。
-
故障转移:每个 Sentinel 实例不断发送
PING
命令来检测 Redis 主节点和其他 Sentinel 的状态。如果在设定的时间范围内没有收到主节点的响应,Sentinel 会认为主节点故障,发起故障转移(Failover),选举某个从节点为新主节点。 -
通知应用程序:当主节点发生变化时,Sentinel 会将新主节点的地址信息推送给 Redis 客户端(应用程序),让客户端感知主节点的变化,避免因主节点切换而导致的连接错误。
6.1.2.在 Spring Boot 中集成 Redis Sentinel
在 Spring Boot 项目中集成 Redis Sentinel 包括以下几个主要步骤:
- 配置 Redis Sentinel 集群:配置 Redis 主从节点并启动多个 Sentinel 实例监控主节点的状态。
- 在 Spring Boot 中配置 Sentinel 集群:在
application.properties
中指定 Sentinel 集群的节点信息和主节点名称。Spring Boot 会自动管理主节点切换。 - 应用程序使用:在 Spring Boot 中直接使用
RedisTemplate
或StringRedisTemplate
进行 Redis 操作,Sentinel 会自动切换主节点,无需手动干预。
1.搭建并配置 Redis Sentinel 集群
配置 Redis 主从复制
首先需要配置 Redis 的主从复制,使得数据可以从主节点同步到从节点。
-
主节点配置:假设 Redis 主节点运行在
127.0.0.1:6379
,无需额外配置。 -
从节点配置:在 Redis 从节点的配置文件(如
redis-slave.conf
)中,添加如下配置,将该节点设置为主节点的从节点。replicaof 127.0.0.1 6379
这样,从节点会自动从主节点复制数据,保证数据一致性。
配置并启动 Redis Sentinel
接着需要在 Redis 服务器上配置 Redis Sentinel。创建或编辑 sentinel.conf
文件,添加以下配置来监控 Redis 主节点。
# 监控的主节点名称和地址
sentinel monitor mymaster 127.0.0.1 6379 2
# 在 5 秒内未响应则判定节点不可达
sentinel down-after-milliseconds mymaster 5000
# 故障转移最大超时 10 秒
sentinel failover-timeout mymaster 10000
# 故障转移时同步新主节点的从节点数量
sentinel parallel-syncs mymaster 1
sentinel monitor
:mymaster
是主节点名称,127.0.0.1:6379
是主节点地址,2
表示至少两个 Sentinel 节点判定主节点不可达时才进行故障转移。sentinel down-after-milliseconds
:指定 Sentinel 判断主节点失效的超时时间(5 秒)。sentinel failover-timeout
:设置故障转移的最大等待时间(10 秒)。sentinel parallel-syncs
:指定故障转移完成后,允许多个从节点并行同步新主节点。
启动 Redis Sentinel 服务
在每台需要运行 Sentinel 的服务器上启动 Sentinel 服务。一般至少配置三个 Sentinel 节点,以保证高可用性和故障切换的准确性。
redis-server /path/to/sentinel.conf --sentinel
2.在 Spring Boot 中配置 Redis Sentinel 集成
在 Spring Boot 项目中,直接通过配置文件指定 Redis Sentinel 信息,Spring Boot 会自动识别并连接到 Redis Sentinel 集群。
配置 application.properties
在 application.properties
中配置 Redis Sentinel 的主节点名称和 Sentinel 节点地址,Spring Boot 将会自动管理主节点切换,无需手动更改主节点地址。
# Redis Sentinel 集群的主节点名称和 Sentinel 节点地址
spring.redis.sentinel.master=mymaster
spring.redis.sentinel.nodes=127.0.0.1:26379,127.0.0.1:26380,127.0.0.1:26381
spring.redis.password=yourpassword
spring.redis.sentinel.master
:指定 Redis 主节点名称,必须与sentinel.conf
中的名称一致。spring.redis.sentinel.nodes
:配置所有 Sentinel 节点的 IP 和端口,Spring Boot 会自动连接并监控这些 Sentinel 节点。spring.redis.password
:如果 Redis 设置了密码,可以在此处配置。
3.在 Spring Boot 中使用 Redis Sentinel
完成以上配置后,Spring Boot 会自动连接 Redis Sentinel 集群。当主节点发生故障时,Redis Sentinel 会自动将某个从节点提升为新的主节点,Spring Boot 无需手动干预,客户端会自动连接到新的主节点。
示例代码
在 Spring Boot 中,可以使用 StringRedisTemplate
或 RedisTemplate
来操作 Redis 数据。以下是一个简单的 Redis 服务类,展示了如何在应用中使用 Redis Sentinel:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class RedisService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
public void saveData(String key, String value) {
stringRedisTemplate.opsForValue().set(key, value);
}
public String getData(String key) {
return stringRedisTemplate.opsForValue().get(key);
}
}
在 RedisService
中,StringRedisTemplate
负责与 Redis Sentinel 集群交互,执行数据的读写操作。当 Redis Sentinel 发生故障转移时,Spring Boot 会自动更新主节点连接,因此无需额外的逻辑来处理主节点变化。
6.1.3.Redis Sentinel 的工作流程总结
-
故障检测:每个 Sentinel 节点不断向主节点、从节点和其他 Sentinel 节点发送
PING
命令,检测节点是否正常响应。 -
故障判定:当某个 Sentinel 节点在设定时间内未收到主节点的响应时,会将该主节点标记为主观下线状态(Subjectively Down,简称
SDOWN
)。当多个 Sentinel 节点认为主节点不可达时,主节点会被标记为客观下线(Objectively Down,简称ODOWN
),并触发故障转移。 -
故障转移:在主节点被判定故障后,Sentinel 集群会选举出一个健康的从节点,将其提升为新的主节点,并重新配置其他从节点连接到该新主节点。
-
通知客户端:故障转移完成后,Sentinel 将新主节点的信息通知给 Redis 客户端,使客户端应用自动切换到新的主节点。
6.1.4.Redis Sentinel 的特点
优势:
- 高可用性:当主节点故障时,Redis Sentinel 可以自动选择从节点进行故障转移,保证服务的持续可用。
- 自动主节点发现:应用程序无需手动调整主节点地址,Redis Sentinel 在主节点发生变化后自动通知客户端,Spring Boot 会自动连接新的主节点。
- 适合非分片场景:Redis Sentinel 不支持数据分片,因此适用于数据量相对较小、不需要水平扩展的场景。
局限性:
- 不支持分片:Redis Sentinel 只适用于单一主从结构,无法分片,数据量过大时需要 Redis Cluster 等其他分布式方案。
- 哨兵节点的高可用性依赖:为了确保故障切换的准确性和服务的稳定性,通常需要至少三个 Sentinel 节点。
6.2 Redis Cluster(数据分片和自动故障转移)
Redis Cluster 是 Redis 官方提供的一种高可用和高性能的分布式缓存方案,通过数据分片和自动故障转移来实现高可用性。Redis Cluster 将数据分片存储在多个主从节点上,支持水平扩展,适合大数据量和高并发需求的场景。在 Redis Cluster 中,每个主节点负责一部分数据,并有一个或多个从节点作为备份,当某个主节点发生故障时,从节点会自动提升为新的主节点,以确保缓存服务的正常运行。
6.2.1 Redis Cluster 高可用架构原理
Redis Cluster 通过以下几个主要功能来实现高可用性:
- 数据分片:Redis Cluster 将数据划分为 16384 个哈希槽(Hash Slots),每个节点负责一部分哈希槽,支持水平扩展。
- 自动故障转移:每个主节点拥有一个或多个从节点。当主节点出现故障时,从节点自动提升为主节点,确保数据的高可用性。
- 高并发支持:由于 Redis Cluster 分散数据存储,可以处理大规模数据请求,适合高并发场景。
6.2.2 在 Spring Boot 中集成 Redis Cluster
在 Spring Boot 项目中集成 Redis Cluster 包括以下几个主要步骤:
- 配置 Redis Cluster 集群:配置并启动多个 Redis 主从节点,创建 Redis Cluster 集群。
- 在 Spring Boot 中配置 Cluster 集群信息:在
application.properties
中指定 Redis Cluster 节点信息,Spring Boot 会自动管理节点连接和数据分片。 - 应用程序使用:在 Spring Boot 中直接使用
RedisTemplate
或StringRedisTemplate
进行 Redis 操作,Redis Cluster 会自动进行分片存储和主从切换。
1. 搭建并配置 Redis Cluster 集群
配置 Redis Cluster 的主从节点
在 Redis 集群中,每个节点需要独立的配置文件(例如 redis-7000.conf
、redis-7001.conf
等),以下是示例配置:
# redis-7000.conf
port 7000
cluster-enabled yes
cluster-config-file nodes-7000.conf
cluster-node-timeout 5000
appendonly yes
其他节点(7001、7002 等)配置类似,只需修改端口号即可。
启动 Redis 节点
启动所有 Redis 节点,例如 7000 至 7005 端口的 Redis 实例:
redis-server /path/to/redis-7000.conf
redis-server /path/to/redis-7001.conf
# ...依次启动其他节点
创建 Redis 集群
使用 redis-cli
命令行工具将这些节点添加到集群,并指定主从关系:
redis-cli --cluster create 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 --cluster-replicas 1
此命令会创建 3 个主节点(7000、7001、7002)和 3 个从节点(7003、7004、7005),实现主从配置和数据分片。
2. 在 Spring Boot 中配置 Redis Cluster 集成
在 Spring Boot 中,通过配置文件指定 Redis Cluster 的节点信息和连接属性,Spring Boot 会自动管理 Redis Cluster 集群。
配置 application.properties
# 配置 Redis Cluster 集群节点
spring.redis.cluster.nodes=127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002,127.0.0.1:7003,127.0.0.1:7004,127.0.0.1:7005
# 最大重定向次数(适用于分片数据重定向)
spring.redis.cluster.max-redirects=3
# Redis 密码(如果 Redis 设置了密码)
spring.redis.password=yourpassword
spring.redis.cluster.nodes
:指定 Redis Cluster 集群中所有节点的 IP 和端口,Spring Boot 会自动连接到这些节点。spring.redis.cluster.max-redirects
:设置最大重定向次数,用于分片数据的重定向处理。spring.redis.password
:如果 Redis 设置了密码,可以在这里配置。
3. 在 Spring Boot 中使用 Redis Cluster
完成配置后,开发者可以直接使用 RedisTemplate
或 StringRedisTemplate
操作数据。Spring Boot 会根据 Redis Cluster 的节点信息自动进行分片管理和主从切换。
示例代码
以下是一个 Redis 服务类示例,展示了如何在 Spring Boot 中使用 Redis Cluster 进行数据的增删改查操作:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class RedisClusterService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
// 存储数据到 Redis Cluster
public void saveData(String key, String value) {
stringRedisTemplate.opsForValue().set(key, value);
}
// 从 Redis Cluster 获取数据
public String getData(String key) {
return stringRedisTemplate.opsForValue().get(key);
}
// 删除 Redis Cluster 中的数据
public void deleteData(String key) {
stringRedisTemplate.delete(key);
}
}
- 数据存储:使用
StringRedisTemplate
的opsForValue().set()
方法将数据存储到 Redis Cluster,数据会自动分配到对应的哈希槽。 - 数据读取:使用
opsForValue().get()
方法读取数据,Redis Cluster 会根据数据的哈希槽位置定位并返回数据。 - 自动故障转移:当某个主节点发生故障时,Redis Cluster 会自动将从节点提升为主节点,Spring Boot 无需额外配置即可继续使用。
6.2.3 Redis Cluster 的特点
优势
- 数据分片:Redis Cluster 将数据分片存储在多个节点上,支持水平扩展,适合大数据量场景。
- 自动故障转移:当主节点故障时,Redis Cluster 会自动提升从节点为主节点,实现自动故障转移,保障服务的高可用性。
- 高并发支持:分片结构允许 Redis Cluster 处理更多的并发请求,是高并发场景的理想选择。
局限性
- 跨节点事务支持有限:Redis Cluster 不支持多键操作的跨节点事务,分布在不同节点上的键无法进行原子操作。
- 节点之间的网络通信:Redis Cluster 的节点间需要互相通信,因此在网络不稳定的环境中可能导致集群不一致。
- 最低节点要求:Redis Cluster 要求至少 6 个节点(3 个主节点和 3 个从节点)才能实现高可用。
适用场景
- 大规模缓存系统:在需要处理大数据量的缓存场景中,Redis Cluster 提供数据分片和自动容错机制。
- 高并发的会话管理:适合处理大量用户会话,特别是在高并发应用中。
- 需要水平扩展的场景:Redis Cluster 通过分片实现了水平扩展,适合随着业务增长扩展数据容量的场景。
6.2.4.关于槽点的重新分配
1. 添加或删除节点后的槽点分配
当 Redis Cluster 中添加或删除节点时,Redis 不会自动重新分配槽点,因此需要手动进行槽点迁移。解决方法如下:
- 手动重新分配槽点:
- 使用
redis-trib.rb
或redis-cli --cluster reshard
命令,将槽点重新分配到新的节点或从即将删除的节点迁出。 - 该操作需要管理员手动执行,可以通过指定源节点、目标节点和槽数进行槽点迁移。
- 使用
- 自动化方案(可选):
- 通过 Spring Boot 中的定时任务(
@Scheduled
)检查集群状态,检测到节点增减后,自动调用redis-trib.rb
或redis-cli
执行槽点重新分配操作。
- 通过 Spring Boot 中的定时任务(
2. 负载均衡的槽点分配
Redis Cluster 不支持自动基于负载的槽点分配调整,因此实现负载均衡也需要手动操作。解决方法如下:
- 手动负载均衡:
- 定期使用 Redis 客户端(如 Jedis)或监控工具检查各节点的负载情况。
- 如果发现某些节点负载过高,可以手动执行
redis-trib.rb
或redis-cli --cluster reshard
命令,将部分槽点从负载较高的节点迁移至负载较低的节点,达到负载均衡。
- 自动化方案(可选):
- 在 Spring Boot 中通过定时任务(
@Scheduled
)自动监控节点负载。 - 结合负载监控逻辑,使用
ProcessBuilder
调用redis-trib.rb
脚本或redis-cli
执行槽点迁移,从而实现更灵活的负载均衡自动化。
- 在 Spring Boot 中通过定时任务(
7. Redis Streams消息队列
7.1 Redis Streams 基础概念
Redis Streams 是Redis 5.0引入的一种日志型数据结构,专为消息队列场景设计。它支持:
-
持久化消息存储:消息按时间顺序追加到Stream,支持范围查询。
-
消费者组(Consumer Groups):允许多个消费者协同消费同一Stream,实现负载均衡。
-
消息确认(ACK)机制:确保消息被正确处理,避免丢失。
-
阻塞读取:消费者可以阻塞等待新消息,减少轮询开销。
7.2 Redis Streams 底层原理
(1)数据结构组成
Stream:由多个 消息条目(Entry) 组成,每个条目包含:
-
唯一ID:格式为
<时间戳>-<序列号>
(如1630000000000-0
)。 -
键值对数据:存储实际消息内容(如
{"resourceId": "123", "action": "like"}
)。
消费者组(Consumer Group):
-
Last Delivered ID:记录组内最后发送给消费者的消息ID。
-
Pending Entries List (PEL):已发送但未确认的消息列表。
-
消费者(Consumer):组内每个消费者维护自己的待处理消息列表。
(2)存储结构:Radix树(Rax)
Redis Streams使用 Radix树(基数树) 存储消息,这是一种压缩前缀树,具有以下特点:
-
高效范围查询:通过消息ID快速定位区间内的消息。
-
内存优化:共享相同前缀的消息条目节省存储空间。
-
快速插入和删除:时间复杂度为O(log N),适合高吞吐场景。
(3)消息ID生成规则
-
自动生成:使用
*
作为ID时,Redis自动生成<当前毫秒时间戳>-<序列号>
。 -
手动指定:需保证ID大于前一条消息的ID(否则报错)。
7.3 生产者
StringRecord
和ObjectRecord
的作用
StringRecord
和ObjectRecord
是 Spring Data Redis 中用于构建 Stream 消息的两种数据类型:
StringRecord
:用于存储 字符串类型 的消息值。StringRecord record = StreamRecords.string() .withStreamKey("my_stream") .withId("1526919030474-0") // 可选,手动指定ID .ofObject("value"); // 消息内容为字符串
ObjectRecord
:用于存储 Java 对象 的消息值(需配置 Redis 序列化器)。Map<String, String> message = new HashMap<>(); message.put("resourceId", "123"); message.put("action", "like"); ObjectRecord<String, Map<String, String>> record = StreamRecords.newRecord() .withStreamKey("my_stream") .ofObject(message); // 消息内容为对象
选择建议:
如果消息内容是简单键值对,使用
StringRecord
。如果消息内容是复杂对象,使用
ObjectRecord
(需确保对象可序列化)。
7.3.1 发送消息(自动生成ID)
@Component
public class StreamProducer {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 发送消息(自动生成消息ID)
*/
public void sendMessage(String streamKey, Map<String, String> message) {
ObjectRecord<String, Map<String, String>> record = StreamRecords.newRecord()
.ofObject(message)
.withStreamKey(streamKey);
String messageId = redisTemplate.opsForStream().add(record);
System.out.println("消息发送成功,ID: " + messageId);
}
}
7.3.2 发送消息(手动指定ID)
public void sendMessageWithId(String streamKey, String messageId, Map<String, String> message) {
StringRecord record = StreamRecords.string()
.withId(messageId)
.ofObject(message)
.withStreamKey(streamKey);
redisTemplate.opsForStream().add(record);
}
查询 Stream 长度
public Long getStreamSize(String streamKey) {
return redisTemplate.opsForStream().size(streamKey);
}
7.4 消费者组
7.4.1 消费者组初始化
@Component
public class StreamConsumerInitializer {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@PostConstruct
public void initConsumerGroups() {
// 初始化资源更新流的消费者组
createGroupIfNotExists("resource_stream", "resource_group");
}
private void createGroupIfNotExists(String streamKey, String groupName) {
try {
redisTemplate.opsForStream().createGroup(
streamKey,
ReadOffset.from("0-0"), // 从第一条消息开始消费
groupName
);
} catch (RedisSystemException e) {
System.out.println("消费者组已存在: " + groupName);
}
}
}
7.4.2 消费者实现(批量消费+双阈值)
ReadOffset
类型
from("0-0")
:从最早消息开始
lastConsumed()
:读取未消费的新消息(对应>
)
from("$")
:只读取未来到达的消息
StreamReadOptions
配置项
block(Duration)
:阻塞等待时间
count(long)
:每次读取最大消息数
noack()
:不自动确认消息(需手动 ACK)
@Component
public class ResourceStreamConsumer {
private static final String STREAM_KEY = "resource_stream";
private static final String GROUP_NAME = "resource_group";
private static final String CONSUMER_NAME = "consumer_1";
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 启动消费者(异步线程池)
*/
@Async("streamConsumerPool")
public void startConsumer() {
while (true) {
try {
List<MapRecord<String, Object, Object>> batch = fetchMessages();
if (!batch.isEmpty()) {
processBatch(batch);
acknowledgeMessages(batch);
}
} catch (Exception e) {
handleConsumerError(e);
}
}
}
/**
* 批量拉取消息(满足数量阈值100条或时间阈值5秒)
*/
private List<MapRecord<String, Object, Object>> fetchMessages() {
List<MapRecord<String, Object, Object>> messages = new ArrayList<>();
long startTime = System.currentTimeMillis();
Consumer consumer = Consumer.from(GROUP_NAME, CONSUMER_NAME);
StreamOffset<String> offset = StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed());
while (messages.size() < 100 && (System.currentTimeMillis() - startTime) < 5000) {
List<MapRecord<String, Object, Object>> chunk = redisTemplate.opsForStream().read(
consumer,
StreamReadOptions.empty()
.count(100 - messages.size())
.block(Duration.ofSeconds(1)),
offset
);
if (chunk != null) messages.addAll(chunk);
}
return messages;
}
/**
* 批量处理消息(合并更新)
*/
private void processBatch(List<MapRecord<String, Object, Object>> messages) {
Map<String, Integer> likeUpdates = new HashMap<>();
Map<String, Integer> downloadUpdates = new HashMap<>();
for (MapRecord<String, Object, Object> record : messages) {
String resourceId = (String) record.getValue().get("resourceId");
int deltaLike = Integer.parseInt((String) record.getValue().get("likeDelta"));
int deltaDownload = Integer.parseInt((String) record.getValue().get("downloadDelta"));
likeUpdates.merge(resourceId, deltaLike, Integer::sum);
downloadUpdates.merge(resourceId, deltaDownload, Integer::sum);
}
// 原子更新数据库
likeUpdates.forEach((id, total) ->
jdbcTemplate.update("UPDATE resources SET likes = likes + ? WHERE id = ?", total, id)
);
downloadUpdates.forEach((id, total) ->
jdbcTemplate.update("UPDATE resources SET downloads = downloads + ? WHERE id = ?", total, id)
);
}
/**
* 确认消息处理完成
*/
private void acknowledgeMessages(List<MapRecord<String, Object, Object>> messages) {
String[] ids = messages.stream()
.map(record -> record.getId().getValue())
.toArray(String[]::new);
redisTemplate.opsForStream().acknowledge(STREAM_KEY, GROUP_NAME, ids);
}
/**
* 异常处理(记录日志 + 告警)
*/
private void handleConsumerError(Exception e) {
System.err.println("消费者处理异常: " + e.getMessage());
// 发送告警通知
// alertService.send("Stream消费异常", e);
}
}
查询待处理消息
(1)什么是待处理消息(Pending Messages)?
-
当消费者从消费者组读取消息后,若未发送 ACK 确认,这些消息会进入 Pending Entries List (PEL)。
-
PEL 中的消息表示“已分配但未完成处理”的状态,可能因以下原因滞留:
-
消费者崩溃未发送 ACK。
-
消息处理失败需人工介入。
-
消费者处理时间过长导致超时。
-
(2)查询 PEL 的典型场景
-
监控告警:检测是否有消息长时间未被处理(如超过 30 分钟)。
-
故障排查:定位卡住业务流程的特定消息。
-
手动重试:将失败消息重新分配给其他消费者。
(3)示例:监控 PEL 并发送告警
@Scheduled(cron = "0 0/5 * * * ?") // 每5分钟检查一次
public void checkPendingMessages() {
PendingMessages pending = redisTemplate.opsForStream().pending(
"resource_stream",
"resource_group",
Range.unbounded(),
1000
);
if (pending.size() > 100) {
alertService.send("待处理消息堆积警告", "当前 PEL 数量: " + pending.size());
}
}
裁剪旧消息
Streams所有消息常驻内存(包括处理成功),需定时裁剪消息(还有根据消息阈值裁剪的方式)
public void trimStream() {
// 保留最新1000条消息
redisTemplate.opsForStream().trim(STREAM_KEY, 1000);
}
7.5 注意事项
(1)消费者组的初始化
在 Spring Boot 中,通常通过 @PostConstruct
在应用启动时创建消费者组:
@PostConstruct
public void initConsumerGroup() {
try {
redisTemplate.opsForStream().createGroup(
"my_stream",
ReadOffset.from("0-0"), // 从第一条消息开始消费
"my_group"
);
} catch (RedisSystemException e) {
// 消费者组已存在,忽略异常
}
}
(2)Redis 崩溃后的恢复机制
-
Redis 重启后:消费者组信息仍然存在(Redis 默认持久化到 RDB/AOF)。
-
Spring Boot 程序恢复:
-
自动重连:Spring Data Redis 会自动尝试重新连接 Redis。
-
无需重新创建消费者组:消费者组是 Redis 内部的持久化数据,无需手动重建。
-
-
容错处理建议:
@Scheduled(fixedDelay = 5000) // 定时检查消费者组是否存在 public void checkConsumerGroup() { try { StreamInfo.XInfoStream info = redisTemplate.opsForStream().info("my_stream"); if (!info.getGroups().contains("my_group")) { initConsumerGroup(); // 重新创建消费者组 } } catch (Exception e) { // Redis 不可用,记录日志并重试 } }
(3)消息传递模式
-
生产者发送消息:消息直接写入 Redis Stream,不会主动推送给消费者。
-
消费者拉取消息:消费者需主动调用
read()
方法从 Stream 中拉取消息。
(4)消费者组的负载均衡
-
同一组内多个消费者:Redis会自动将消息分配给不同消费者,实现并行处理。
-
消息分配策略:默认轮询分配,可通过
XCLAIM
手动接管未处理消息。
(5)消息重试与死信队列
-
处理失败的消息:若消费者崩溃,未ACK的消息会被重新分配给其他消费者。
-
死信队列:多次重试失败后,可将消息移至另一Stream进行人工干预。
8. Redis Pipeline 技术
8.1 什么是 Redis Pipeline?
-
核心概念:
Redis Pipeline 是一种客户端技术,允许将多个 Redis 命令一次性批量发送到服务器,并一次性接收所有响应。-
传统模式:客户端发送一个命令 → 等待响应 → 再发送下一个命令(多次网络往返,RTT 延迟高)。
-
Pipeline 模式:客户端将多个命令打包成一个批次发送 → 服务器按顺序处理 → 将所有响应打包返回(单次网络往返)。
-
-
性能对比:
操作方式 网络往返次数(N 条命令) 总耗时(假设网络延迟 10ms) 普通模式 N 次 N × (命令处理时间 + 10ms) Pipeline 模式 1 次 命令总处理时间 + 10ms
8.2 为什么需要 Pipeline?
-
高延迟场景优化:
当客户端与 Redis 服务器之间存在高网络延迟时(如跨机房访问),Pipeline 能显著减少总耗时。 -
吞吐量提升:
批量处理命令可提高单位时间内处理的命令数量(QPS),适合批量写入、批量查询等场景。 -
资源节省:
减少客户端和服务端的网络 I/O 压力,降低线程阻塞时间。
8.3. Pipeline 在 Java 中的使用(Spring Data Redis)
Redis Pipeline 的限制并不是不能执行条件判断,它的真正限制是无法根据前一个Redis命令的执行结果来作为条件动态调整后续的命令 。
SessionCallback<T>
是 Spring 提供的一个接口,用于将多个 Redis 操作封装在同一个 Redis 连接中执行,包括支持:
-
pipeline(管道模式)
-
事务(transaction 模式)
使用步骤
步骤 | 操作 |
---|---|
1️⃣ | 创建 SessionCallback 并在 execute() 方法中执行多个 Redis 命令 |
2️⃣ | 使用 redisTemplate.executePipelined(callback) 来批量执行 |
3️⃣ | 所有命令都会被打包为 pipeline 执行(一次请求发出去) |
4️⃣ | 返回的 List<Object> 与顺序一致,只包含有返回值的操作结果 |
5️⃣ | 可以继续通过 stream 处理、封装到对象中 |
传统写法(匿名内部类)
List<Object> results = redisTemplate.executePipelined(new SessionCallback<>() {
@Override
public Object execute(@NonNull RedisOperations operations) throws DataAccessException{
// 批量读取 key
operations.opsForValue().get("key1");
operations.opsForValue().get("key2");
// 批量设置过期时间
operations.expire("key1", Duration.ofMinutes(10));
operations.expire("key2", Duration.ofMinutes(10));
return null; // 固定写法,表示不关心 Session 返回值
}
});
特点:
-
SessionCallback
是 Spring 提供的操作 Redis 的“会话上下文”; -
所有命令会被打包成一组,通过 pipeline 一次性发给 Redis;
-
使用
opsForValue()
,opsForHash()
等 Spring 封装的方法; -
自动使用配置好的 RedisTemplate 序列化器;
-
expire()
的结果不会返回,只有有返回值的命令会出现在结果中。
List<Object> resourceInfoRawList = redisTemplate.executePipelined(new SessionCallback<Object>() {
@Override
public <K, V> Object execute(@NonNull RedisOperations<K, V> operations) throws DataAccessException {
// 此时K和V已根据RedisTemplate配置的类型确定
for (String key : resourceInfoKeys) {
operations.opsForHash().entries((K) key); // 转换key类型
operations.expire((K) key, 20, TimeUnit.MINUTES);
}
return null;
}
});
Pipeline 的注意事项(SessionCallback 版本)
注意事项 | 说明 |
---|---|
executePipelined(...) 方法用于启用 pipeline | 不要用 execute(...) ,否则不会批量 |
return null; 是固定写法 | 表示返回值在 executePipelined() 里获取 |
只有有返回值的命令会出现在结果中 | 如:get() 、entries() ,但 set() 、expire() 的返回结果不会包含在 List 中 |
使用 opsForXxx() 方法时,会自动使用配置的序列化器 | 无需手动序列化 byte[] |
命令顺序与返回值一一对应 | results.get(0) 对应第一次 get() 的返回值,以此类推 |
能用于事务吗? | 事务用 redisTemplate.execute(SessionCallback) ,pipeline 用 executePipelined(SessionCallback) ,两者不冲突 |
8.5 multiGet()
List<Object> values = redisTemplate.opsForValue().multiGet(listOfKeys);
-
这是 RedisTemplate 提供的 Value 操作中的批量获取接口;
-
你传入一个 key 列表(比如 10 个),Redis 会批量返回这些 key 对应的值;
-
它等价于 Redis 的原生命令:MGET key1 key2 key3 ...
-
本质上是一次 Redis 请求,在 Redis 服务端是原子执行的;
-
适合获取一组
String
类型的值(opsForValue()
操作)。
⚠️ 局限
-
只适用于 Redis 的
String
类型; -
不能用于
Hash
,List
,Set
等复杂结构; -
Redis 本身限制了
MGET
的 key 类型,不能跨集群 slot 使用(仅限单节点 Redis); -
如果你使用的是 Redis Cluster,并且 key 分布在不同 slot,
MGET
会失败(报CROSSSLOT
错误)。
9. 性能优化与监控
9.1. 连接池配置
连接池可以帮助管理 Redis 和 MySQL 的连接资源,避免频繁建立和销毁连接,从而减少系统开销,提高效率。合理的连接池配置可以防止资源耗尽,确保系统稳定运行。
9.1.1 Redis 连接池配置
在 Spring Boot 中可以通过 lettuce
或 jedis
来配置 Redis 连接池,以下示例基于 lettuce
配置连接池参数。
# Redis 连接配置
spring.redis.host=localhost
spring.redis.port=6379
# Lettuce 连接池配置
spring.redis.lettuce.pool.max-active=10 # 最大连接数
spring.redis.lettuce.pool.max-idle=5 # 最大空闲连接数
spring.redis.lettuce.pool.min-idle=2 # 最小空闲连接数
spring.redis.lettuce.pool.max-wait=1000 # 最大等待时间(毫秒)
代码解释
max-active
:最大连接数,设置 Redis 可以同时保持的最大连接数。max-idle
:最大空闲连接数,连接池中可以保持的最大空闲连接数,超过的连接将会被释放。min-idle
:最小空闲连接数,保持的最少空闲连接数,当连接数低于此值时会创建新连接。max-wait
:最大等待时间,连接池获取连接的等待时间(毫秒),如果超过时间未获取到连接,则抛出异常。
通过合理配置连接池参数,可以避免 Redis 的连接池资源耗尽,提高应用的并发处理能力。
9.1.2 MySQL 连接池配置
Spring Boot 默认使用 HikariCP 作为 MySQL 数据库的连接池,可以通过以下配置进行优化。
# MySQL 数据源配置
spring.datasource.url=jdbc:mysql://localhost:3306/mydatabase
spring.datasource.username=root
spring.datasource.password=password
# HikariCP 连接池配置
spring.datasource.hikari.maximum-pool-size=10 # 最大连接数
spring.datasource.hikari.minimum-idle=2 # 最小空闲连接数
spring.datasource.hikari.idle-timeout=30000 # 空闲连接的最大存活时间
spring.datasource.hikari.connection-timeout=30000 # 获取连接的最大等待时间(毫秒)
spring.datasource.hikari.max-lifetime=1800000 # 连接的最大存活时间(毫秒)
代码解释
maximum-pool-size
:最大连接数,即连接池中允许的最大连接数。minimum-idle
:最小空闲连接数,保持的最少空闲连接数,低于此值时会创建新连接。idle-timeout
:空闲连接的最大存活时间,超过此时间的空闲连接将被释放。connection-timeout
:连接池中获取连接的最大等待时间,如果超过此时间未获取到连接,则抛出异常。max-lifetime
:连接的最大存活时间,避免长时间使用的连接失效。
通过配置 HikariCP 参数,可以更好地管理数据库连接池,减少系统在高并发场景下的连接资源问题。
9.2. 性能监控
性能监控可以帮助实时查看系统运行状态,识别性能瓶颈。可以使用 Spring Boot Actuator、Redis CLI 和 MySQL 监控工具来跟踪关键指标,如缓存命中率、连接池状态、数据库响应时间等。
9.2.1 使用 Spring Boot Actuator
Spring Boot Actuator 提供了丰富的监控端点,可以帮助开发者快速了解应用的运行状态。
<!-- 引入 Spring Boot Actuator 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
配置 Actuator 端点
在 application.properties
中启用 Actuator 所需的端点,以便查看 Redis 和 MySQL 的状态。
management.endpoints.web.exposure.include=health,metrics,beans
management.endpoint.health.show-details=always
使用端点
/actuator/health
:查看应用的健康状态,包含 Redis 和 MySQL 连接状态。/actuator/metrics
:查看应用的各种性能指标,如内存使用、CPU、线程等信息。
9.2.2 Redis CLI 监控 Redis 状态
Redis 提供了 INFO
命令,可以查看缓存的命中率、内存使用等详细信息。
redis-cli INFO
输出中的重要字段:
keyspace_hits
和keyspace_misses
:表示缓存命中和未命中的次数,可以计算缓存命中率:命中率 = keyspace_hits / (keyspace_hits + keyspace_misses)
。connected_clients
:当前连接到 Redis 的客户端数量。used_memory
:Redis 使用的内存总量。expired_keys
:过期的键数量,有助于判断是否需要优化键的过期策略。
9.2.3 MySQL 监控工具
可以使用 MySQL 的 SHOW STATUS
命令来监控 MySQL 的性能指标,查看连接数、查询数等。
SHOW GLOBAL STATUS;
常用的状态字段:
Threads_connected
:当前连接的客户端数量。Connections
:成功连接的总次数。Queries
:服务器处理的查询总数。Slow_queries
:执行时间超过long_query_time
的慢查询总数。
这些信息有助于优化数据库性能,如增加连接池大小或优化查询语句。
9.3. Redis Keyspace Notifications
Redis Keyspace Notifications 是 Redis 提供的键变动通知功能,可以配置 Redis 将键的变动(如过期、删除、更新等)通知到客户端,以便实时监控缓存变化。
9.3.1.配置 Redis Keyspace Notifications
可以在 Redis 配置文件(redis.conf
)中启用 Keyspace Notifications,或使用命令行动态配置。
# 监听所有键的过期事件和删除事件
config set notify-keyspace-events Ex
Ex
:表示启用键的过期事件和删除事件通知。K
:表示所有键(keyspace)事件的通知。
9.3.2.Spring Boot 中使用 Redis Keyspace Notifications
在 Spring Boot 中使用 Redis Keyspace Notifications,可以创建一个监听器来接收 Redis 键变动的通知事件。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
@Configuration
public class RedisConfig {
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
MessageListenerAdapter listenerAdapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
// 监听所有键的过期事件
container.addMessageListener(listenerAdapter, new PatternTopic("__keyevent@0__:expired"));
return container;
}
@Bean
public MessageListenerAdapter listenerAdapter() {
return new MessageListenerAdapter(new RedisKeyExpirationListener());
}
public static class RedisKeyExpirationListener implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
String expiredKey = message.toString();
System.out.println("Key expired: " + expiredKey);
// 在这里处理键过期事件,例如日志记录或重新加载缓存
}
}
}
代码解释
RedisMessageListenerContainer
:配置 Redis 消息监听器容器,允许监听 Redis 中的事件。PatternTopic("__keyevent@0__:expired")
:监听 Redis 中expired
事件,即键过期事件。@0
表示 Redis 数据库索引。RedisKeyExpirationListener
:自定义监听器,监听 Redis 键的过期事件。收到过期事件时会调用onMessage
方法,并打印或记录过期的键。
通过 Keyspace Notifications,可以实时监控 Redis 键的变化,如缓存失效、删除等事件,适合于对缓存内容有严格要求的场景。