Redis实战——缓存
本文最后更新于:2 个月前
引言
在现代软件系统中,缓存已成为提升性能、减少数据库压力和提高响应速度的重要手段。尤其是在高并发、分布式环境中,缓存往往是架构中的“加速器”。Redis由于其高性能、丰富的数据结构以及良好的生态支持,被广泛用作缓存层。本篇文章将围绕“Redis作为缓存”这一主题,详细探讨缓存的概念、适用场景、更新策略、常见问题以及与流行框架的结合。
缓存
概念
缓存是一个存储层,旨在存储部分数据的副本,以减少从数据库、文件系统或远程服务中获取数据的延迟。缓存一般存储的是常用且查询频繁的数据,数据过期或不常用时会被清除或更新。
作用
- 提高性能:通过减少对数据库或远程服务的访问,缓存可以显著降低响应时间,提高系统吞吐量。
- 减轻压力:通过缓存热点数据,减轻数据库和其他后端服务的压力,尤其在高并发访问时。
- 节省资源:在某些情况下,缓存可以减少计算开销,避免重复计算和冗余处理。
成本
- 内存占用:缓存数据需要占用内存,可能导致内存资源的浪费,尤其是缓存过多不常用或冗余的数据时。
- 一致性问题:缓存中的数据和数据库中的数据可能不一致,需要有缓存更新机制。
- 复杂性:缓存的管理、失效策略和同步机制增加了系统的复杂度。
适用场景
- 热点数据:例如用户信息、商品列表、常用配置等,这类数据访问频繁,使用缓存可以大大提升性能。
- 频繁查询、少更新的数据:如广告投放数据、天气信息等,这些数据变化频繁但查询量大,缓存可以减少重复查询的成本。
- 计算密集型操作的结果:如复杂的查询结果或统计数据,缓存这些结果可以避免重复计算。
- 分布式系统中:缓存可以减少跨服务调用的频率,降低系统的耦合度,提高性能。
不适用场景
- 数据变动频繁的数据:如实时交易数据、用户操作日志等频繁变动的场景,缓存中的数据需要频繁更新,会增加复杂性和一致性问题。
- 极小的数据集:如果数据集很小,使用缓存可能会浪费内存,反而不如直接访问原数据。
- 高一致性要求的业务:例如金融交易、支付系统等,需要严格保证数据一致性,使用缓存可能会带来脏数据问题。
- 不适用时延要求严格的操作:如果某些操作需要实时数据(例如实时监控系统),缓存可能引入不必要的延迟。
更新策略
内存淘汰
利用Redis的内存淘汰机制。设置Redis服务的使用内存,当内存不足时依据淘汰策略(LRU
、LFU
、随机淘汰)自动淘汰部分数据,下次查询时更新缓存。
- 一致性较差
- 基本无维护成本
超时剔除
利用Redis的TTL
机制,给缓存添加存活时间,到期后自动删除,或启用定时任务,定期扫描Redis中的数据进行删除,下次查询时更新缓存。
- 一致性一般
- 维护成本较低
主动更新
实现方案
更新:
- 服务端更新数据库数据
- 删除Redis缓存
读取:
- 服务端根据客户端的查询条件从Redis中读取数据,如果读取到则返回数据至客户端,如果未读取到则继续。
- 根据查询条件从数据库中读取数据。
- 将读取到的数据缓存至Redis中,并设置合理的过期时间。
- 返回数据至客户端。
特点
- 一致性好
- 维护成本较高
时序图
sequenceDiagram
participant 客户端
participant 服务端
participant Redis
participant 数据库
%% 更新流程
服务端->>数据库: 更新数据库数据
服务端->>Redis: 删除缓存
%% 读取流程
客户端->>服务端: 发送查询请求
服务端->>Redis: 根据查询条件读取数据
alt 数据存在于Redis
Redis->>服务端: 返回数据
服务端->>客户端: 返回数据
else 数据未找到
服务端->>数据库: 根据查询条件从数据库读取数据
数据库->>服务端: 返回数据
服务端->>Redis: 将数据缓存至Redis并设置过期时间
服务端->>客户端: 返回数据
end
代码示例
这里选择更常用的主动更新策略作为代码示例
1 |
|
注意点
- 相较于每次更新数据库都更新缓存,会导致无效写比较多,因此直接删除缓存、等待读取时再更新缓存会更合适。
- 读取完操作后更新缓存可用异步的方式,但需保证异步更新成功且需要有更新失败时的兜底机制,只适用于一致性要求不高的场景。
- 应先更新数据库,后删除缓存。在高并发场景下,对于先删除缓存、后更新数据库,可能会造成删除缓存后、更新数据库前这个期间,其他线程查询出旧数据并写入缓存;而对于先操作数据库、后更新缓存,则可能会有查询数据库后、写入缓存前,其他线程先更新了数据库并删除了缓存,本线程后将旧数据写入了缓存。但综合了两种问题的发生情况,第二种问题发生的概率应该是明显小于第一种问题发生的概率,因为写入缓存这个动作是非常快的,所以选择了先更新数据库、后删除缓存。
常见问题及优化
缓存穿透
缓存穿透是指请求的数据在缓存和数据库中都不存在,每次请求都绕过了缓存直接访问数据库,导致缓存失效、数据库压力增大,甚至可能对数据库造成攻击性压力。
主要有以下解决方案:
缓存空对象
服务端在第一次查询到数据为空以后,在Redis中存入一份空对象并设置存活时间TTL,下次再有相同的查询就从Redis中取出空对象返回。
优点:实现简单,维护方便。
缺点:额外的内存消耗。
时序图
sequenceDiagram
participant 客户端
participant 服务端
participant Redis
participant 数据库
客户端->>服务端: 发送请求
服务端->>Redis: 缓存未命中
服务端->>数据库: 查询数据为空
服务端->>Redis: 缓存空数据并设置过期时间
服务端->>客户端: 返回异常信息
客户端->>服务端: 发送请求
服务端->>Redis: 命中空数据
服务端->>客户端: 返回异常信息
代码示例
1 |
|
布隆过滤器
布隆过滤器是一个空间效率非常高的数据结构,可以用来快速判断一个元素是否存在。使用布隆过滤器可以在访问数据库前进行过滤,判断该数据是否存在于数据库中,如果不存在,则不进行数据库查询,直接返回空或错误信息,避免无效请求击穿缓存。
原理:
- 位数组(bit array):布隆过滤器内部包含一个大小为
m
的位数组,每个元素对应一个位置。 - 哈希函数:使用多个哈希函数
k
,每个哈希函数将元素映射到位数组的某些位置(哈希值)。 - 添加元素:每次添加元素时,通过
k
个哈希函数计算出k
个位置,将这些位置的位设为1
。 - 查询元素:查询元素时,通过
k
个哈希函数计算出k
个位置,如果所有这些位置的值都为1
,则返回 “存在”;如果有任意一个位置为0
,则返回 “不存在”。
优点:内存占用少,没有多余的Key。
缺点:实现复杂且存在误判可能。如果某个元素存在,布隆过滤器可能返回 “存在”,但有一定的误判概率(即假阳性)。如果布隆过滤器返回 “不存在”,则元素一定不在集合中。
时序图
sequenceDiagram
participant 客户端
participant 服务端
participant Redis
participant 数据库
客户端->>服务端: 发送请求
服务端->>服务端: 布隆过滤器判断结果
alt 布隆过滤器中不存在此值
服务端->>客户端: 返回错误信息
else 布隆过滤器中存在此值
服务端->>Redis: 查询缓存
服务端->>数据库: 查询数据
服务端->>Redis: 缓存空数据并设置过期时间
服务端->>客户端: 返回数据
end
代码示例
确保
RedisBloom
模块已安装,可以使用Redis Stack
镜像,它已经集成了RedisBloom
模块。使用
Docker
启动带有RedisBloom
的Redis实例:1
docker run -d --name redis-bloom -p 6379:6379 redis/redis-stack-server:latest
Redis依赖
1
2
3
4
5
6
7
8
9
10<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
</dependency>
</dependencies>工具类封装
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@Component
public class RedissonBloomUtil {
@Resource
private RedissonClient redisson;
private static final String BLOOM_FILTER_KEY = "myBloomFilter";
/**
* 创建布隆过滤器并设置误差率和容量
*/
@PostConstruct
public void createBloomFilter() {
redisson.getBloomFilter(BLOOM_FILTER_KEY).tryInit(100000000, 0.01);
}
/**
* 销毁
*/
@PreDestroy
public void destroy() {
redisson.getBloomFilter(BLOOM_FILTER_KEY).delete();
}
/**
* 添加元素到布隆过滤器
*/
public boolean addToBloomFilter(String value) {
return redisson.getBloomFilter(BLOOM_FILTER_KEY).add(value);
}
/**
* 判断元素是否存在于布隆过滤器
*/
public boolean containsInBloomFilter(String value) {
return redisson.getBloomFilter(BLOOM_FILTER_KEY).contains(value);
}
}业务代码修改,不再缓存空对象,而是在对象创建之后将id初始化至布隆过滤器,使用id查询时先判断布隆过滤器中是否含有此id
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@Override
public RestResult<Shop> queryById(Long id) {
String cacheKey = CACHE_SHOP_KEY + id;
// 布隆过滤器判断
boolean bloomExists = redissonBloomUtil.containsInBloomFilter(cacheKey);
// 数据库查询不存在的数据写入的空值
if (Boolean.FALSE.equals(bloomExists)) {
return RestResult.fail("店铺不存在");
}
// 查询redis
String json = stringRedisTemplate.opsForValue().get(cacheKey);
// 不为空值则返回
if (StrUtil.isNotBlank(json)) {
return RestResult.successWithData(JSONUtil.toBean(json, Shop.class));
}
// 不存在则查询数据库
Shop shop = getById(id);
if (shop == null) {
return RestResult.fail("店铺不存在");
}
// 写入redis
stringRedisTemplate.opsForValue().set(cacheKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 返回
return RestResult.successWithData(shop);
}
@Override
@Transactional
public RestResult<Void> save(Shop shop) {
// 数据校验
// ......
// 保存到数据库
save(shop);
// 删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
// 初始布隆过滤器
if (redissonBloomUtil.addToBloomFilter(CACHE_SHOP_KEY + shop.getId())) {
return RestResult.success();
}
return RestResult.fail("保存失败");
}
请求校验
加强对请求的限制和校验,防止恶意请求(如加强请求参数校验、引入验证码、限制请求频率)。
缓存雪崩
缓存雪崩是指在某一时刻,大量缓存数据同时过期或失效,导致大量请求直接访问后端数据库或服务,从而引发后端系统过载、性能下降甚至崩溃的现象。这种情况通常发生在高并发的系统中,尤其是在缓存失效时间设置不合理或没有采取有效防护措施时。
主要有以下解决方案:
缓存过期时间随机化
为不同的缓存设置不同的过期时间,避免大量缓存同时过期,同时可针对热点KEY设置更长的过期时间。例如,将TTL设置为基础值加上一个随机值。
1 |
|
限制单线程刷新缓存
使用互斥锁或信号量限制单个线程刷新缓存。当缓存失效时,只有一个线程去查询数据库并刷新缓存,其他线程等待缓存刷新完成后再从缓存中读取。
1 |
|
缓存预热
在系统启动时,提前将常用的数据加载到缓存中,避免系统启动后短时间内大量请求访问数据库。
1 |
|
使用定时任务定期刷新缓存中的热点数据,确保这些数据始终在缓存中。
1 |
|
使用多级缓存
在应用层引入本地缓存(如Caffeine
、Guava
),作为一级缓存,结合Redis作为二级缓存,减少对Redis的依赖和压力。
1 |
|
熔断、降级、限流
- 服务熔断:当检测到后端数据库压力过大时,暂时拒绝部分请求,保护数据库不被过载。
- 服务降级:返回默认值或友好提示,避免系统崩溃。
- 限流策略:限制单位时间内的请求数量,防止恶意或异常流量导致缓存雪崩。
使用集群和高可用架构
Redis集群:部署Redis集群,提高缓存系统的可用性和扩展性,防止单点故障导致的缓存雪崩。
主从复制和哨兵:配置Redis的主从复制和哨兵机制,确保Redis高可用,减少缓存失效的风险。
缓存击穿
缓存击穿问题也叫热点KEY问题,是指某个热点数据的缓存失效后,多个请求直接访问数据库,导致数据库压力骤增的现象。
与缓存雪崩不同,缓存击穿通常是由于缓存中的某个热点数据在某一时刻失效或者过期,而这个热点数据的查询频率非常高。
缓存击穿的应对策略可以复用缓存雪崩中的限制单线程访问数据库及刷新缓存、限流,降低因热点KEY访问数据库骤增而带来的压力。
结合框架
MyBatis二级缓存
MyBatis的二级缓存是指在同一SqlSessionFactory
下共享的缓存,可以用于存储查询结果,以减少数据库访问次数。默认情况下,MyBatis支持使用内存作为二级缓存,但也可以配置Redis作为缓存提供者,Redis可以让MyBatis二级缓存成为分布式缓存。
简单配置步骤如下:
开启二级缓存
1
mybatis.configuration.cache-enabled=true
实现MyBatis的Cache接口
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71@Slf4j
@Data
public class MyBatisRedisCache implements Cache {
private static final String COMMON_CACHE_KEY = "mybatis:cache:";
private final String namespace;
private final RedisTemplate<String, Object> redisTemplate;
public MyBatisRedisCache(String namespace) {
if (StrUtil.isBlank(namespace)) {
throw new IllegalArgumentException("Cache instances require an ID");
}
this.namespace = namespace;
this.redisTemplate = SpringUtil.getBean("redisTemplate");
}
@Override
public String getId() {
// 一个mapper对应一个mybatis的缓存操作对象
return this.getNamespace();
}
private String getKeys() {
return COMMON_CACHE_KEY + this.getNamespace() + ":*";
}
private String getKey(Object key) {
return COMMON_CACHE_KEY + this.getNamespace() + ":" + key;
}
@Override
public void putObject(Object key, Object value) {
// 缓存设置为1小时过期
redisTemplate.opsForValue().set(getKey(key), value, 3600, TimeUnit.SECONDS);
}
@Override
public Object getObject(Object key) {
try {
return redisTemplate.opsForValue().get(getKey(key));
} catch (Exception e) {
log.error("获取缓存失败", e);
return null;
}
}
@Override
public Object removeObject(Object key) {
try {
Object n = redisTemplate.opsForValue().get(getKey(key));
redisTemplate.delete(getKey(key));
return n;
} catch (Exception e) {
log.error("删除缓存失败", e);
return null;
}
}
@Override
public void clear() {
redisTemplate.delete(redisTemplate.keys(getKeys()));
}
@Override
public int getSize() {
return redisTemplate.keys(getKeys()).size();
}
}在Mapper文件中使用
1
<cache type="space.yangtao.config.MyBatisRedisCache"/>
Spring Cache
Spring Cache
是Spring框架的一个抽象缓存层,支持通过不同的缓存实现(如:Redis
、Ehcache
、Guava
等)来管理应用的缓存。
简单配置步骤如下:
启动类中开启
Spring Cache
支持1
2
3
4
5
6
7@SpringBootApplication
@EnableCaching
public class SpringcacheApplication {
public static void main(String[] args) {
SpringApplication.run(SpringcacheApplication.class, args);
}
}使用Redis作为缓存提供者
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19@Configuration
public class RedisCacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
// 设置缓存过期时间为10分钟
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
// 不缓存空值
.disableCachingNullValues();
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(config)
.build();
}
}业务代码中使用,在需要缓存的方法上使用
@Cacheable
、@CachePut
、@CacheEvict
等注解1
2
3
4@Cacheable(value = "products", key = "#id")
public Product getProductById(Long id) {
// 从数据库获取数据
}
总结
本篇文章从缓存的概念入手,介绍了其在系统中的作用以及可能带来的成本,重点阐述了如何使用Redis来实现缓存策略,并且针对 常见的问题(缓存穿透、缓存雪崩、缓存击穿)给出了相应的 解决方案。
在实际项目中,缓存层的设计需要结合具体的业务场景、数据特点以及一致性要求进行权衡。对于大部分以读多写少、关注高并发的系统,Redis通常是优选的缓存载体。但同时也要注意缓存一致性和内存占用等问题,合理设置过期策略、更新策略,必要时使用分布式锁、布隆过滤器、多级缓存等手段,方能在保证性能的同时,维护系统的稳定性与可扩展性。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!