返回文章列表
高并发项目
Redis缓存RedisTemplate高并发

12项目中使用redis加入缓存

一、什么是缓存

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

缓存类型

缓存的作用与成本并存:

  • 作用:降低后端负载、提高读写效率、降低响应时间
  • 成本:数据一致性成本、代码维护成本、运维成本

二、缓存作用模型

在业务系统中添加 Redis 缓存后,一次根据 id 查询商铺的典型流程如下:

  1. 客户端提交商铺 id,先判断缓存是否命中
  2. 命中:直接返回商铺信息
  3. 未命中:根据 id 查询数据库
    • 商铺不存在:返回 404
    • 商铺存在:将商铺数据写入 Redis,再返回数据

缓存作用模型

Redis 适合存储读多写少、临时性、需要快速访问的数据。我们的项目中符合这个条件的数据查询接口主要有:

  1. 商品服务(item-service):queryItemById、queryItemByIds
  2. 购物车服务(cart-service):处理购物车数据时需要远程调用商品服务查询商品
  3. 用户服务(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,到期后自动删除缓存,下次查询时更新缓存 一般 低
主动更新 编写业务逻辑,在修改数据库的同时更新缓存 好 高

业务场景选择:

  • 低一致性需求:使用内存淘汰机制,例如店铺类型、商品分类等查询缓存
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案,例如店铺详情、商品详情查询的缓存

主动更新的三种方案

  1. Cache Aside Pattern:由缓存的调用者,在更新数据库的同时更新缓存
  2. Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性,调用者调用该服务即可
  3. Write Behind Caching Pattern:调用者只操作缓存,由其它线程异步将缓存数据持久化到数据库,保证最终一致

其中 Cache Aside Pattern 最为常用。在操作缓存与数据库时需要考虑三个关键问题:

1. 删除缓存还是更新缓存?

  • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
  • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存(推荐)

2. 如何保证缓存与数据库的操作同时成功或失败?

  • 单体系统,将缓存与数据库操作放在一个事务中
  • 分布式系统,利用 TCC 等分布式事务方案
  • 缓存设置过期时间,作为兜底方案

3. 先操作缓存还是先操作数据库?

  • 先删除缓存,再操作数据库:并发场景下,线程 1 删除缓存后、更新数据库前,线程 2 查询缓存未命中,会从数据库读取旧值并写入缓存,造成数据不一致
  • 先操作数据库,再删除缓存:并发场景下出现不一致的概率较低,是更推荐的方案

缓存更新最佳实践

  • 低一致性需求:使用 Redis 自带的内存淘汰机制
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案
  • 读操作:缓存命中则直接返回;缓存未命中则查询数据库,并写入缓存,设定超时时间
  • 写操作:先写数据库,然后再删除缓存,要确保数据库与缓存操作的原子性

项目下载地址

gupengzu/high-concurrency-project at 12redis-use