黑马商城学习笔记(二)

一. 商户查询缓存

1. 什么是缓存?

缓存就是数据交换的缓冲区(称作Cache),是存贮数据的临时地方,一般读写性能较高。

缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力。

但是缓存也会增加代码复杂度和运营的成本:

2. 添加商户缓存

当我们根据id查询商户信息时,我们是直接操作从数据库中去进行查询的,所以我们需要增加缓存,

代码思路

先从redis中查询数据,如果名命中,直接返回,如果没有,从数据库中查询,如果有的话添加到redis,直接返回,如果没有,返回查询失败或其他

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

@Autowired
private StringRedisTemplate stringRedisTemplate;

@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
if (StrUtil.isNotBlank(shopJson)) {
JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shopJson);
}
Shop shop = getById(id);
if (shop == null) {
return Result.fail("商铺不存在");
}
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}
}

对于店铺详细这类变化较为频繁的数据,我们是直接存入Redis中,后面还会进行优化,设置合适的缓存更新策略,确保Redis和MySQL的数据一致性,以及解决缓存常见的三大问题。

3. 查询店铺类型缓存

对于店铺类型数据,一般变动会比较小,所以这里我们直接将店铺类型的数据持久化存储到Redis中

代码

a. 使用String类型的key-value结构缓存店铺类型数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
@Autowired
private StringRedisTemplate stringRedisTemplate;

@Override
public Result queryTypeList() {
String shopTypeJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_TYPE_KEY);
List<ShopType> shopTypeList;
if (StrUtil.isNotBlank(shopTypeJson)) {
shopTypeList = JSONUtil.toList(shopTypeJson, ShopType.class);
return Result.ok(shopTypeList);
}
shopTypeList = this.query().orderByAsc("sort").list();
if (shopTypeList == null || shopTypeList.isEmpty()) {
return Result.fail("商铺类型不存在");
}
stringRedisTemplate.opsForValue().set(CACHE_SHOP_TYPE_KEY, JSONUtil.toJsonStr(shopTypeList), CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES);
return Result.ok(shopTypeList);
}
}

如果对比一下,可以发现第一次查询跟后面查询差距大概有1s左右

b. 使用List类型缓存店铺类型数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//List存储类型
ListOperations<String, String> ops = stringRedisTemplate.opsForList();
List<ShopType> shopTypeList;
// 0到-1表示查询List中所有元素
List<String> shopTypeJson = ops.range(CACHE_SHOP_TYPE_KEY, 0, -1);
if (CollUtil.isNotEmpty(shopTypeJson)) {
// 将 List<String> 转换为 List<ShopType> 返回
shopTypeList = shopTypeJson.stream()
.map((shopTypeJsonList) -> JSONUtil.toBean(shopTypeJsonList, ShopType.class))
.collect(Collectors.toList());
return Result.ok(shopTypeList);
}
shopTypeList = this.query().orderByAsc("sort").list();
if (shopTypeList == null || shopTypeList.isEmpty()) {
return Result.fail("商铺类型不存在");
}
// 将 List<ShopType> 转换为 List<String> 存入 Redis
ops.rightPushAll(CACHE_SHOP_TYPE_KEY, shopTypeList.stream().map(JSONUtil::toJsonStr).collect(Collectors.toList()));
stringRedisTemplate.expire(CACHE_SHOP_TYPE_KEY, CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES);
return Result.ok(shopTypeList);

由于在店铺类型ShopType中定义的createTime和updateTime是LocalDateTime类型。如果不自定义配置RedisConfig,那么日期类型存入Redis中会序列化为无意义的数字。

1
2
3
4
5
6
7
8
9
10
11
/**
* 创建时间
*/
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;

/**
* 更新时间
*/
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime updateTime;

因此我们可以写一个RedisConfig配置类,在里面配置序列化器与反序列化器,这次value和hashValue使用配置好的json对象映射器

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
package com.hmdp.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
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.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
// 创建RedisTemplate<String, Object>对象
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 设置redis连接工厂
template.setConnectionFactory(connectionFactory);
// 使用StringRedisSerializer来序列化和反序列化Redis的key值
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

// 配置对象映射器
Jackson2JsonRedisSerializer<Object> jsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
// 序列化时将类的数据类型存入json,以便反序列化的时候转换成正确的类型
ObjectMapper mapper = new ObjectMapper();
// 指定要序列化的域,field,get和set,以及修饰符范围。ANY指包括private和public修饰符范围
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 指定序列化输入类型,类的信息也将添加到json中,这样才可以根据类名反序列化。没有这行,将存储为纯json字符串
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.WRAPPER_ARRAY);
// 解决jackson2无法反序列化LocalDateTime的问题
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.registerModule(new JavaTimeModule());
//mapper.registerModule(new Jdk8Module()).registerModule(new JavaTimeModule()).registerModule(new ParameterNamesModule());
// 将对象映射器添加到序列化器中
jsonRedisSerializer.setObjectMapper(mapper);

// 设置Key的序列化,使用String类型的序列化工具 StringRedisSerializer
template.setKeySerializer(stringRedisSerializer);
template.setHashKeySerializer(stringRedisSerializer);

// 设置Value的序列化,使用JSON类型的序列化工具 Jackson2JsonRedisSerializer
template.setValueSerializer(jsonRedisSerializer);
template.setHashValueSerializer(jsonRedisSerializer);

template.afterPropertiesSet();
return template;
}
}

c. 改用RedisTemplate来缓存店铺类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RedisTemplate redisTemplate;
//RedisTemplate存储类型
ListOperations ops = redisTemplate.opsForList();
// 由于配置了序列化和反序列化器,存入Java对象,取出时也为Java对象
List<ShopType> shopTypeList = ops.range(CACHE_SHOP_TYPE_KEY, 0, -1);
if (CollUtil.isNotEmpty(shopTypeList)) {
return Result.ok(shopTypeList);
}
shopTypeList = this.query().orderByAsc("sort").list();
if (shopTypeList == null || shopTypeList.isEmpty()) {
return Result.fail("商铺类型不存在");
}
ops.rightPushAll(CACHE_SHOP_TYPE_KEY, shopTypeList);
stringRedisTemplate.expire(CACHE_SHOP_TYPE_KEY, CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES);
return Result.ok(shopTypeList);

这样可以简化代码的编写,但会在value中存入反序列化时需要的全类名,虽然存储空间增加了,但也解决了日期类LocalDateTime序列化和反序列化时格式不正确的问题。

d. 注解配置Redis

配置RedisConfig解决日期类序列化和反序列化问题可以一劳永逸,如果不配置序列化器,还可以在日期属性上添加这三个注解。(但是需要每个LocalDateTime日期属性上都添加,可维护性差)

1
2
3
4
5
6
7
// 格式化LocalDateTime在Json中的日期格式
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
// @JsonDeserialize:json反序列化注解,用于字段或set方法上,作用于setter()方法,将json数据反序列化为java对象
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
// @JsonSerialize:json序列化注解,用于字段或set方法上,作用于getter()方法,将java对象序列化为json数据
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime updateTime;

4. 缓存更新策略

缓存一致性问题

使用缓存的好处:降低了后端负载,提高了读写的效率,降低了响应的时间。
缓存带来的问题:缓存的添加提高了系统的维护成本,同时也带来了数据一致性问题。

由于我们的缓存的数据源来自数据库,而数据库的数据是会发生变化的,因此如果当数据库中数据发生变化,而缓存却没有同步 ,此时就存在缓存数据一致性问题

缓存数据一致性问题的根本原因是 缓存和数据库中的数据不同步

那么我们该如何让 缓存数据库中的数据尽可能的保证同步?首先需要选择一个比较好的缓存更新策略

常见的缓存更新策略

  1. 内存淘汰(自动): 利用 Redis的内存淘汰机制 实现缓存更新,Redis的内存淘汰机制是当Redis发现内存不足时,会根据一定的策略自动淘汰部分数据(可以自己设置策略方式)。

Redis中常见的淘汰策略:

noeviction(默认):当达到内存限制并且客户端尝试执行写入操作时,Redis 会返回错误信息,拒绝新数据的写入,保证数据完整性和一致性
allkeys-lru:从所有的键中选择最近最少使用(Least Recently Used,LRU)的数据进行淘汰。即优先淘汰最长时间未被访问的数据
allkeys-random:从所有的键中随机选择数据进行淘汰
volatile-lru:从设置了过期时间的键中选择最近最少使用的数据进行淘汰
volatile-random:从设置了过期时间的键中随机选择数据进行淘汰
volatile-ttl:从设置了过期时间的键中选择剩余生存时间(Time To Live,TTL)最短的数据进行淘汰
  1. 超时剔除(半自动):手动给缓存数据设置过期时间TTL,到期后Redis自动删除超时的数据,方便咱们继续使用缓存。
  2. 主动更新(手动):手动编码实现缓存更新,在修改数据库的同时更新缓存。我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题。

主动更新策略的三种方案

a. 双写方案(Cache Aside Pattern):人工编码方式,缓存调用者在更新完数据库后再去更新缓存。维护成本高,灵活度高。
b. 读写穿透方案(Read/Write Through Pattern):将数据库和缓存整合为一个服务,由服务来维护缓存与数据库的一致性,调用者无需关心数据一致性问题,降低了系统的可维护性,但是实现困难,也没有较好的第三方服务供我们使用。
c. 写回方案(Write Behind Caching Pattern):调用者只操作缓存,其他独立的线程去异步处理数据库,将待写入的数据放入一个缓存队列,在适当的时机,通过批量操作或异步处理,将缓存队列中的数据持久化到数据库,实现最终一致。

双写方案读写穿透方案 在写入数据时都会直接更新缓存,以保持缓存和底层数据存储的一致性。

写回方案 延迟了缓存的更新操作,又由于异步更新机制,将多次对数据库的写合并成一次写,将多次对数据库的更新以最后一次更新的结果作为有效数据,去更新数据库。

主动更新策略中三种方案的应用场景 :

1. 双写方案 较适用于读多写少的场景,数据的一致性由应用程序主动管理
2. 读写穿透方案 适用于数据实时性要求较高、对一致性要求严格的场景
3. 写回方案 适用于追求写入性能的场景,对数据的实时性要求相对较低、可靠性也相对低,延迟写入的数据是在内存中的。
综合考虑使用方案一,虽然双写方案需要缓存调用者手动编码维护,但可控性更高。

如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来

  • 删除缓存还是更新缓存?

    • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
    • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
  • 如何保证缓存与数据库的操作的同时成功或失败?

    • 单体系统,将缓存与数据库操作放在一个事务
    • 分布式系统,利用TCC等分布式事务方案

应该具体操作缓存还是操作数据库,我们应当是先操作数据库,再删除缓存,原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。

  • 先操作缓存还是先操作数据库?
    • 先删除缓存,再操作数据库
    • 先操作数据库,再删除缓存

业务场景

低一致性需求:使用Redis自带的内存淘汰机制 + 超时更新。
高一致性需求:主动更新,并以超时剔除作为兜底方案。