12项目中使用redis加入缓存
一、什么是缓存
缓存就是数据交换的缓冲区(Cache),是存贮数据的临时地方,一般读写性能较高。在计算机系统中缓存无处不在,按位置可分为浏览器缓存、应用层缓存、数据库缓存、CPU 缓存、磁盘缓存等。

缓存的作用与成本并存:
- 作用:降低后端负载、提高读写效率、降低响应时间
- 成本:数据一致性成本、代码维护成本、运维成本
二、缓存作用模型
在业务系统中添加 Redis 缓存后,一次根据 id 查询商铺的典型流程如下:
- 客户端提交商铺 id,先判断缓存是否命中
- 命中:直接返回商铺信息
- 未命中:根据 id 查询数据库
- 商铺不存在:返回 404
- 商铺存在:将商铺数据写入 Redis,再返回数据

Redis 适合存储读多写少、临时性、需要快速访问的数据。我们的项目中符合这个条件的数据查询接口主要有:
- 商品服务(item-service):
queryItemById、queryItemByIds - 购物车服务(cart-service):处理购物车数据时需要远程调用商品服务查询商品
- 用户服务(user-service):
findMyAddresses查询当前用户地址列表
这些接口读多写少,且对实时性要求不高,适合加入缓存改造。
三、项目中增加Redis配置
引入依赖
在 hm-common 模块加入 Redis 与连接池依赖:
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>Nacos共享配置
在 nacos 中添加 shared-redis.yaml(host 改成自己的虚拟机或云服务器地址):
spring:
redis:
host: 0.0.0.1
port: 6379
password: 123456
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: 100ms需要使用 redis 的服务模块在 bootstrap 文件中引入该共享配置:
spring:
application:
name: item-service # 服务名称
profiles:
active: dev
cloud:
nacos:
server-addr: 127.0.0.1 # nacos地址
config:
file-extension: yaml # 文件后缀名
shared-configs: # 共享配置
- dataId: shared-jdbc.yaml
- dataId: shared-log.yaml
- dataId: shared-swagger.yaml
- dataId: shared-seata.yaml
- dataId: rabbitMQ.yaml
- dataId: shared-redis.yaml # 共享redis配置自定义RedisTemplate
默认的 RedisTemplate 采用 JDK 序列化,可读性差、内存占用大。在 hm-common 中添加 RedisConfig,将 value 改为 JSON 序列化:
package com.hmall.common.config;
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.RedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
// 创建RedisTemplate对象
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 设置连接工厂
template.setConnectionFactory(connectionFactory);
// 创建JSON序列化工具
GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
// 设置Key的序列化
template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
// 设置Value的序列化
template.setValueSerializer(jsonRedisSerializer);
template.setHashValueSerializer(jsonRedisSerializer);
// 返回
return template;
}
}然后在 Spring Boot 自动装配注册文件中添加该配置:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.hmall.common.config.MyBatisConfig,\
com.hmall.common.config.MvcConfig,\
com.hmall.common.config.MqConfig,\
com.hmall.common.config.RedisConfig,\
com.hmall.common.config.JsonConfig四、改造查询接口
改造思路统一为:先查 Redis,未命中再查数据库,并将数据库结果写入 Redis,设置 TTL 过期时间。
4.1 商品服务(item-service)
在 ItemController 中注入 RedisTemplate<String, Object> redisTemplate,改造 queryItemById 与 queryItemByIds:
@ApiOperation("根据id批量查询商品")
@GetMapping
public List<ItemDTO> queryItemByIds(@RequestParam("ids") List<Long> ids) {
List<ItemDTO> result = new java.util.ArrayList<>();
List<Long> missIds = new java.util.ArrayList<>();
// 1. 先批量查 Redis
List<Object> cachedList = redisTemplate.opsForValue().multiGet(
ids.stream().map(id -> "item:" + id).collect(java.util.stream.Collectors.toList()));
for (int i = 0; i < ids.size(); i++) {
Object obj = cachedList.get(i);
if (obj != null) {
result.add((ItemDTO) obj);
} else {
missIds.add(ids.get(i));
}
}
// 2. 查数据库并回写 Redis
if (!missIds.isEmpty()) {
List<ItemDTO> dbList = itemService.queryItemByIds(missIds);
for (ItemDTO item : dbList) {
String redisKey = "item:" + item.getId();
redisTemplate.opsForValue().set(redisKey, item, 1, java.util.concurrent.TimeUnit.HOURS);
result.add(item);
}
}
return result;
}
@ApiOperation("根据id查询商品")
@GetMapping("{id}")
public ItemDTO queryItemById(@PathVariable("id") Long id) {
String redisKey = "item:" + id;
// 1. 先查 Redis
ItemDTO itemDTO = (ItemDTO) redisTemplate.opsForValue().get(redisKey);
if (itemDTO != null) {
return itemDTO;
}
// 2. 查数据库
itemDTO = BeanUtils.copyBean(itemService.getById(id), ItemDTO.class);
// 3. 写入 Redis,设置过期时间(如1小时)
if (itemDTO != null) {
redisTemplate.opsForValue().set(redisKey, itemDTO, 1, java.util.concurrent.TimeUnit.HOURS);
}
return itemDTO;
}4.2 购物车服务(cart-service)
handleCartItems 改造为先批量查 Redis,未命中的 id 再远程调用商品服务:
private void handleCartItems(List<CartVO> vos) {
// 1.获取商品id
Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
// 2.批量查 Redis
List<String> redisKeys = itemIds.stream().map(id -> "item:" + id).collect(Collectors.toList());
List<Object> cachedList = redisTemplate.opsForValue().multiGet(redisKeys);
List<ItemDTO> items = new java.util.ArrayList<>();
List<Long> missIds = new java.util.ArrayList<>();
int idx = 0;
for (Long id : itemIds) {
Object obj = cachedList.get(idx++);
if (obj != null) {
items.add((ItemDTO) obj);
} else {
missIds.add(id);
}
}
// 3.查数据库并回写 Redis
if (!missIds.isEmpty()) {
List<ItemDTO> dbItems = itemClient.queryItemByIds(missIds);
for (ItemDTO item : dbItems) {
redisTemplate.opsForValue().set("item:" + item.getId(), item, 1, java.util.concurrent.TimeUnit.HOURS);
items.add(item);
}
}
if (CollUtils.isEmpty(items)) {
throw new BadRequestException("购物车中商品不存在");
}
// 4.转为 id 到 item 的 map
Map<Long, ItemDTO> itemMap = items.stream().collect(Collectors.toMap(ItemDTO::getId, Function.identity()));
// 5.写入 vo
for (CartVO v : vos) {
ItemDTO item = itemMap.get(v.getItemId());
if (item == null) {
continue;
}
v.setNewPrice(item.getPrice());
v.setStatus(item.getStatus());
v.setStock(item.getStock());
}
}4.3 用户服务(user-service)
findMyAddresses 改造为先查 Redis:
@ApiOperation("查询当前用户地址列表")
@GetMapping
public List<AddressDTO> findMyAddresses() {
Long userId = UserContext.getUser();
String redisKey = "address:list:" + userId;
// 1.先查 Redis
List<AddressDTO> cachedList = (List<AddressDTO>) redisTemplate.opsForValue().get(redisKey);
if (cachedList != null && !cachedList.isEmpty()) {
return cachedList;
}
// 2.查数据库
List<Address> list = addressService.query().eq("user_id", userId).list();
if (CollUtils.isEmpty(list)) {
return CollUtils.emptyList();
}
List<AddressDTO> result = BeanUtils.copyList(list, AddressDTO.class);
// 3.写入 Redis,设置过期时间(如1小时)
redisTemplate.opsForValue().set(redisKey, result, 1, java.util.concurrent.TimeUnit.HOURS);
return result;
}改造完成后,第一次调用接口时 Redis 中即会有数据,后续相同请求可直接命中缓存,查询耗时显著下降。
五、缓存读写一致性
加入缓存后会引入新问题:数据库数据更新了,缓存中数据未更新,导致数据不一致。常见缓存更新策略有三种:
| 策略 | 说明 | 一致性 | 维护成本 |
|---|---|---|---|
| 内存淘汰 | 利用 Redis 的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存 | 差 | 无 |
| 超时剔除 | 给缓存数据添加 TTL,到期后自动删除缓存,下次查询时更新缓存 | 一般 | 低 |
| 主动更新 | 编写业务逻辑,在修改数据库的同时更新缓存 | 好 | 高 |
业务场景选择:
- 低一致性需求:使用内存淘汰机制,例如店铺类型、商品分类等查询缓存
- 高一致性需求:主动更新,并以超时剔除作为兜底方案,例如店铺详情、商品详情查询的缓存
主动更新的三种方案
- Cache Aside Pattern:由缓存的调用者,在更新数据库的同时更新缓存
- Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性,调用者调用该服务即可
- Write Behind Caching Pattern:调用者只操作缓存,由其它线程异步将缓存数据持久化到数据库,保证最终一致
其中 Cache Aside Pattern 最为常用。在操作缓存与数据库时需要考虑三个关键问题:
1. 删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存(推荐)
2. 如何保证缓存与数据库的操作同时成功或失败?
- 单体系统,将缓存与数据库操作放在一个事务中
- 分布式系统,利用 TCC 等分布式事务方案
- 缓存设置过期时间,作为兜底方案
3. 先操作缓存还是先操作数据库?
- 先删除缓存,再操作数据库:并发场景下,线程 1 删除缓存后、更新数据库前,线程 2 查询缓存未命中,会从数据库读取旧值并写入缓存,造成数据不一致
- 先操作数据库,再删除缓存:并发场景下出现不一致的概率较低,是更推荐的方案
缓存更新最佳实践
- 低一致性需求:使用 Redis 自带的内存淘汰机制
- 高一致性需求:主动更新,并以超时剔除作为兜底方案
- 读操作:缓存命中则直接返回;缓存未命中则查询数据库,并写入缓存,设定超时时间
- 写操作:先写数据库,然后再删除缓存,要确保数据库与缓存操作的原子性