SpringBoot集成Redis—缓存穿透解决方案与哨兵模式实战

785 阅读8分钟

以下案例依然在SpringBootCase项目基础上完成。(Redis采用Redis-x64-3.2.100版本)

1、环境准备

1)pom.xml引入Redis依赖

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2) 演示业务场景

根据商品总数功能,先从Redis缓存中查找,如果找不到,再从MySQL数据库中查找,然后将数据放到Redis缓存。

2、SpringBoot集成Redis单机模式

1) 通过MyBatis逆向工程生成实体Bean和数据持久层

实体类:

package com.xintu.demo.entity;

import java.util.Date;

public class TProduct {
    private Integer id;

    private Integer categoryId;

    private String itemType;

    private String title;

    private String sellPoint;

    private String price;

    private Integer num;

    private String image;

    private Integer status;

    private Integer priority;

    private String createdUser;

    private Date createdTime;

    private String modifiedUser;

    private Date modifiedTime;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public Integer getCategoryId() {
        return categoryId;
    }

    public void setCategoryId(Integer categoryId) {
        this.categoryId = categoryId;
    }

    public String getItemType() {
        return itemType;
    }

    public void setItemType(String itemType) {
        this.itemType = itemType == null ? null : itemType.trim();
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title == null ? null : title.trim();
    }

    public String getSellPoint() {
        return sellPoint;
    }

    public void setSellPoint(String sellPoint) {
        this.sellPoint = sellPoint == null ? null : sellPoint.trim();
    }

    public String getPrice() {
        return price;
    }

    public void setPrice(String price) {
        this.price = price == null ? null : price.trim();
    }

    public Integer getNum() {
        return num;
    }

    public void setNum(Integer num) {
        this.num = num;
    }

    public String getImage() {
        return image;
    }

    public void setImage(String image) {
        this.image = image == null ? null : image.trim();
    }

    public Integer getStatus() {
        return status;
    }

    public void setStatus(Integer status) {
        this.status = status;
    }

    public Integer getPriority() {
        return priority;
    }

    public void setPriority(Integer priority) {
        this.priority = priority;
    }

    public String getCreatedUser() {
        return createdUser;
    }

    public void setCreatedUser(String createdUser) {
        this.createdUser = createdUser == null ? null : createdUser.trim();
    }

    public Date getCreatedTime() {
        return createdTime;
    }

    public void setCreatedTime(Date createdTime) {
        this.createdTime = createdTime;
    }

    public String getModifiedUser() {
        return modifiedUser;
    }

    public void setModifiedUser(String modifiedUser) {
        this.modifiedUser = modifiedUser == null ? null : modifiedUser.trim();
    }

    public Date getModifiedTime() {
        return modifiedTime;
    }

    public void setModifiedTime(Date modifiedTime) {
        this.modifiedTime = modifiedTime;
    }
}

 

数据层Mapper: 

package com.xintu.demo.mapper;

import com.xintu.demo.entity.TProduct;
import com.xintu.demo.entity.TProductExample;
import java.util.List;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper //方式一:添加@Mapper注解,等同于主类上加@MapperScan("com.demo.demo.mapper")
public interface TProductMapper {
    long countByExample(TProductExample example);

    int deleteByExample(TProductExample example);

    int deleteByPrimaryKey(Integer id);

    int insert(TProduct record);

    int insertSelective(TProduct record);

    List<TProduct> selectByExample(TProductExample example);

    TProduct selectByPrimaryKey(Integer id);

    int updateByExampleSelective(@Param("record") TProduct record, @Param("example") TProductExample example);

    int updateByExample(@Param("record") TProduct record, @Param("example") TProductExample example);

    int updateByPrimaryKeySelective(TProduct record);

    int updateByPrimaryKey(TProduct record);
}

 

2) application.yml 中配置redis连接信息

图片

完整application.yml配置文件如下:

#spring:
#  profiles:
#      active: test #激活对应环境配置,以测试环境为例
server:
  port: 8888 # 设置内嵌Tomcat端口号
  servlet:
    context-path: /springbootcase # 设置项目上下文根路径,这个在请求访问的时候需要用到

test:
  site: 35xintu.com #测试站点
  user: xintu #测试用户

spring:
  datasource: # mysql相关配置
    url: jdbc:mysql://localhost:3306/xintu?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password: xxx #根据自己的本地配置情况设置
  devtools:
    restart:
      enabled: true  #设置开启热部署
      additional-paths: src/main/java #重启目录
      exclude: WEB-INF/** #排除一些不需要自动重启的资源
      log-condition-evaluation-delta: false #关闭在什么情况下重启的日志记录,需要时可以打开

  thymeleaf:
    cache: false #使用Thymeleaf模板引擎,关闭缓存

  redis: #配置redis连接信息(单机模式)
    host: 192.168.92.134
    port: 6379
    password: #根据自己的本地配置情况设置


#在application.yml配置文件中指定映射文件的位置,这个配置只有接口和映射文件不在同一个包的情况下,才需要指定:
mybatis:
  mapper-locations: classpath:mapper/*.xml

 

3) 启动redis服务

图片

4)  XinTuProductRedisController类

package com.xintu.demo.controller;

import com.xintu.demo.service.XinTuProductRedisService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;

/**
 * @author XinTu
 * @classname XinTuProductRedisController
 * @description TODO
 * @date 2023年05月05日 5:21
 */

@RestController
public class XinTuProductRedisController {
    @Autowired
    private XinTuProductRedisService productRedisService;

    @GetMapping(value = "/productredis/allProductNumber")
    public String allProductNumber(HttpServletRequest request) {
        Long allProductNumber = productRedisService.allProduct();
        return "商品数量:" + allProductNumber;
    }
}

 

5) XinTuProductRedisService实现

package com.xintu.demo.service;

import com.xintu.demo.entity.TProductExample;
import com.xintu.demo.mapper.TProductMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

/**
 * @author XinTu
 * @classname XinTuProductRedisService
 * @description TODO
 * @date 2023年05月05日 5:22
 */
@Service
public class XinTuProductRedisService {

    @Autowired
    private TProductMapper productMapper;

    // 注入 spring data当中的 RedisTemplate 类
    @Autowired
    private RedisTemplate redisTemplate;

    public Long allProduct() {
        //设置redisTemplate对象key的序列化方式
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        //从redis缓存中获取总商品数
        Long productCount = (Long) redisTemplate.opsForValue().get("product_count");
        System.out.println("查询Redis数据库..."+productCount);
        //判断是否为空
        if (null == productCount) { //去mysql数据库查询,并存放到redis缓存中
            System.out.println("查询MySQL数据库...");
            TProductExample example = new TProductExample();
            productCount = productMapper.countByExample(example);
            redisTemplate.opsForValue().set("product_count",
                    productCount, 1, TimeUnit.SECONDS); // 会影响缓存穿透执行时长
        }
        return productCount;
    }

}

 

6)  启动类SpringbootApplication

package com.xintu.demo;

import com.xintu.demo.config.XinTuConfigInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@EnableTransactionManagement //开启事务
@RestController
@SpringBootApplication
public class SpringbootApplication {

	@Autowired
	private XinTuConfigInfo configInfo; //测试@ConfigurationProperties

	@Value("${test.site}")
	private String site;

	@Value("${test.user}")
	private String user;
	public static void main(String[] args) {
		SpringApplication.run(SpringbootApplication.class, args);
	}

	@GetMapping("/hello")
	public String hello(@RequestParam(value = "name", defaultValue = "World") String name) {
		return String.format("欢迎 %s 来到<a href="http://www.35xintu.com">35新途</a>!", name);
	}

	@GetMapping("/value")
	public String testValue() { //测试 @Value 注解
		return String.format("欢迎 %s 来到<a href="http://www.35xintu.com">%s</a>!" , user,site);
	}

	@GetMapping("/config")
	public String config() { //测试 @ConfigurationProperties 注解
		System.out.println("hello");
		return String.format("欢迎 %s 来到<a href="http://www.35xintu.com">%s</a>!" , configInfo.getUser(),configInfo.getSite());
	}

}

 

7)  启动SpringBootCase应用,访问测试

http://localhost:8888/springbootcase/productredis/allProductNumber 

图片

8)  打开Redis 客户端

启动命令:redis-cli.exe

查询命令:get product_count

 

图片

3、缓存穿透现象

 

1) 穿透测试

XinTuRedisPenetrateController测试类。 

package com.xintu.demo.controller;

import com.xintu.demo.service.XinTuProductRedisService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @author XinTu
 * @classname XinTuRedisPenetrateController
 * @description 模拟缓存穿透
 * @date 2023年05月05日 6:00
 */

@RestController
public class XinTuRedisPenetrateController {
    @Autowired
    private XinTuProductRedisService productRedisService;

    @GetMapping(value = "/productredispenetrate/allProductNumber")
    public String allProductNumber(HttpServletRequest request) {
        Long allProductNumber = productRedisService.allProduct();
        //线程池个数,一般建议是CPU内核数 或者 CPU内核数据*2
        ExecutorService executorService = Executors.newFixedThreadPool(8);
        for (int i = 0; i < 2000; i++) {
            executorService.submit(new Runnable() {
                @Override public void run() {
                    productRedisService.allProduct();
                }
            });
        }
        return "商品数量:" + productRedisService.allProduct();
    }
}

 

2 )启动应用程序,浏览器访问测试

 

图片

3)造成的问题

图片

多个线程都去查询数据库,这种现象就叫做缓存穿透,如果并发比较大,对数据库的压力过大,有可能造成数据库宕机。

 

4)解决方法

方案一:加同步锁

修改StudentServiceImpl中的代码

public Long allProduct() {
        //设置redisTemplate对象key的序列化方式
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        //从redis缓存中获取总商品数
        Long productCount = (Long) redisTemplate.opsForValue().get("product_count");
        System.out.println("查询Redis数据库..."+productCount);
        //判断是否为空
        if (null == productCount) { //去mysql数据库查询,并存放到redis缓存中
            //设置同步代码块
            synchronized (this) { //加同步锁
                productCount = (Long) redisTemplate.opsForValue().get("product_count");
                if (null == productCount) { // 双重验证
                    System.out.println("查询MySQL数据库...");
                    TProductExample example = new TProductExample();
                    productCount = productMapper.countByExample(example);
                    redisTemplate.opsForValue().set("product_count",
                            productCount, 1, TimeUnit.SECONDS); // 会影响缓存穿透执行时长
                }
            }
        }
        return productCount;
}

 

启动应用程序,浏览器访问测试,查看控制台输出只有第一个线程查询数据库,其它线程查询Redis缓存,这样的解决的小问题就是第一批进来的用户会有一个等待,但是这样的影响可以忽略。

① 为什么要做双层验证?

防止线程获取到cpu执行权限的时候,其他线程已经将数据放到Redis中了,所以再次判断;

不能将synchronized范围扩大,因为如果Redis缓存中如果有数据,线程不应该同步,否则影响效率。

② 加同步锁是否是最优方案?

如何是在集群模式下,这种方式依然会有问题。这个时候就需要考虑采用redis分布式锁了,具体方案大家可以自行研究。

 

4、SpringBoot集成Redis哨兵模式(一主三从三哨兵)

6379是主节点,6380和6381是从节点。

图片

分别修改每个redis.windows.conf和redis.windows-service.conf中的端口号为:6379(主节点保持不变)、6380、6381。

图片

从节点配置文件需要加:

slaveof localhost 6379

从节点整体配置文件:

# 端口配置
port 6380
# 日志文件名
logfile "redis_log_6380.log"
# rdb持久化文件名字
dbfilename "dump6380.rdb"
# 本地ip
bind 127.0.0.1
# 绑定主从关系【该设置说明端口6380的服务为从机,它的主机为:6379】

# 从机是否只能读 默认是yes
slave-read-only no
  1. 分别启动三台Redis服务器

主节点启动,

图片

从节点启动,

图片

图片

验证主节点,

图片

从节点验证,

图片

 

 

5. 哨兵配置

图片

#哨兵模式redis集群配置(哨兵模式)

  redis: #配置redis连接信息(单机模式)
    host: localhost
#    port: 6379 #f哨兵模式下不要写端口号
#    password: 123456
    sentinel:  #哨兵模式redis集群配置(哨兵模式)
      master: mymaster #与哨兵中的sentinel monitor xxx 保持一致
      nodes: localhost:26379,localhost:26380,localhost:26381

哨兵直接复制Redis文件目录即可。老王这里分为Redis-Sentinel-26379、Redis-Sentinel-26380、Redis-Sentinel-26381。

图片

 

三个哨兵节点分别增加 sentinel.conf 文件, 文件内容如下:

# 哨兵sentinel实例运行的端口 默认26379
port 26379

# 保护模式
protected-mode no

# 本地ip
bind 127.0.0.1

# 哨兵监听的主服务器 后面的1表示主机挂掉以后进行投票,只需要2票就可以从机变主机
sentinel myid 9c65a6f7aad9e2419a6abce1ce56ff28cb81df34

# 设置主机的密码(无密码可以省略)
# sentinel auth-pass mymaster 35xintu

# 设置未得到主机响应时间,此处代表5秒未响应视为宕机
sentinel monitor mymaster 127.0.0.1 6380 2

# 设置等待主机活动时间,此处代表15秒主机未活动,则重新选举主机
sentinel down-after-milliseconds mymaster 5000

# 设置重新选举主机后,同一时间同步数据的从机数量,此处代表重新选举主机后,每次2台从机同步主机数据,直到所有从机同步结束
sentinel failover-timeout mymaster 15000

 

现在我们启动3个哨兵.

注意,启动redis主备集群时要先启动主,后启动从,哨兵先启动哪个都可以。

启动哨兵命令:redis-server.exe sentinel.conf --sentinel

 

分被启动成功之后,就可以进行测试了。

 

图片

 

1)验证主从数据同步

客户端连接命令:redis-cli.exe -h 127.0.0.1 -p 6380。

图片

2) 主节点选举

停掉主节点:

图片

验证主节点是否关闭,

图片

哨兵模式中,进行重新选举,

图片

然后看SpringBoot控制台,页切换为了6380. 

图片

此时,说明哨兵模式已经生效,关于主从复制和哨兵机制的原理部分,会在后面的redis相关课程当中给大家详细分析,本篇注重的是SpringBoot集成Redis实战。

以上!

持续输出优质内容!