一、使用Docker搭建Redis集群
在接口服务中,如果每一次都进行数据库查询,那么必然会给数据库造成很大的并发压力。所以需要为接口添加缓存,缓存技术选用Redis,并且使用Redis的集群,Api使用Spring-Data-Redis。
##搭建redis集群
#创建容器
docker create --name redis-node01 --net host -v /data/redis-data/node01:/data redis:latest --cluster-enabled yes --cluster-config-file nodes-node-01.conf --port 6379
docker create --name redis-node02 --net host -v /data/redis-data/node02:/data redis:latest --cluster-enabled yes --cluster-config-file nodes-node-02.conf --port 6380
docker create --name redis-node03 --net host -v /data/redis-data/node03:/data redis:latest --cluster-enabled yes --cluster-config-file nodes-node-03.conf --port 6381
#启动容器
docker start redis-node01 redis-node02 redis-node03
#进入redis-node01容器进行操作
docker exec -it redis-node01 /bin/bash
#{ip}改为主机的ip地址 显示6380连接失败时,检查安全组端口开启了没有 6379,6380,6381,16379,16380,16381都要开启
redis-cli --cluster create {ip}:6379 {ip}:6380 {ip}:6381 --cluster-replicas 0
#查看集群信息,如果6379的ip为内网IP需要去nodes-node-xx.conf文件中修改为外网id,不然项目启动时连不上redis:
redis-cli CLUSTER NODES
redis-cli CLUSTER INFO
root@itcast:/data# redis-cli
127.0.0.1:6379> CLUSTER NODES
46e5582cd2d96a506955cc08e7b08343037c91d9 {ip}:6380@16380 master - 01543766975796 2 connected 5461-10922
b42d6ccc544094f1d8f35fa7a6d08b0962a6ac4a {ip}:6381@16381 master - 01543766974789 3 connected 10923-16383
4c60f45d1722f771831c64c66c141354f0e28d18 {ip}:6379@16379 myself,master - 01543766974000 1 connected 0-5460
二、编写代码进行测试集群
1、导入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.7.11</version>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.8.0</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
2、编写配置文件
spring:
# Redis配置
redis:
timeout: 6000 # 连接超时时长(毫秒)
cluster:
max-redirects: 3
nodes:
- {ip}:6379
- {ip}:6380
- {ip}:6381
lettuce:
pool:
max-active: 1024 # 连接池最大连接数(默认为8,-1表示无限制 如果pool已经分配了超过max_active个jedis实例,则此时pool为耗尽)
max-wait: 10000 #最大等待连接时间,单位毫秒 默认为-1,表示永不超时,超时会抛出JedisConnectionException
max-idle: 10
min-idle: 5
shutdown-timeout: 100
3、编写配置类
RedisConfigProperties :
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Redis配置映射类
*
**/
@Component
@ConfigurationProperties(prefix = "spring.redis")
public class RedisConfigProperties {
private Integer timeout;
private Integer database;
private Integer port;
private String host;
private String password;
private cluster cluster;
public static class cluster {
private List<String> nodes;
public List<String> getNodes() {
return nodes;
}
public void setNodes(List<String> nodes) {
this.nodes = nodes;
}
}
public Integer getTimeout() {
return timeout;
}
public void setTimeout(Integer timeout) {
this.timeout = timeout;
}
public Integer getDatabase() {
return database;
}
public void setDatabase(Integer database) {
this.database = database;
}
public Integer getPort() {
return port;
}
public void setPort(Integer port) {
this.port = port;
}
public String getHost() {
return host;
}
public void setHost(String host) {
this.host = host;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public RedisConfigProperties.cluster getCluster() {
return cluster;
}
public void setCluster(RedisConfigProperties.cluster cluster) {
this.cluster = cluster;
}
}
4、注册Redis连接工厂
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* <p>
* Redis Template 配置
* </p>
*/
@Configuration
public class RedisTemplateConfig {
@Autowired
private RedisProperties redisProperties;
@Bean
public RedisConnectionFactory connectionFactory() {
RedisClusterConfiguration configuration = new
RedisClusterConfiguration(redisProperties.getCluster().getNodes());
configuration.setMaxRedirects(redisProperties.getCluster().getMaxRedirects());
return new JedisConnectionFactory(configuration);
}
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 自定义的string序列化器和fastjson序列化器
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// jackson 序列化器
GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
// kv 序列化
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setValueSerializer(jsonRedisSerializer);
// hash 序列化
redisTemplate.setHashKeySerializer(stringRedisSerializer);
redisTemplate.setHashValueSerializer(jsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
5、编写测试用例
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.Set;
@SpringBootTest
public class TestRedis {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Test
public void testSave() {
for (int i = 0; i < 100; i++) {
this.redisTemplate.opsForValue().set("key_" + i, "value_" + i);
}
Set<String> keys = this.redisTemplate.keys("key_*");
for (String key : keys) {
String value = this.redisTemplate.opsForValue().get(key);
System.out.println(value);
this.redisTemplate.delete(key);
}
}
}
三、添加缓存逻辑
实现缓存逻辑有2种方式:
- 每个接口单独控制缓存逻辑
- 统一控制缓存逻辑
我们采用第2种方式。
1、采用拦截器进行缓存命中
RedisCacheInterceptor:
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
@Slf4j
@Component
public class RedisCacheInterceptor implements HandlerInterceptor {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static ObjectMapper mapper = new ObjectMapper();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (StringUtils.equalsIgnoreCase(request.getMethod(), "OPTIONS")) {
return true;
}
//请求方式不是get
if (!StringUtils.equalsIgnoreCase(request.getMethod(), "get")) {
//请求路径不是/graphql
if (!StringUtils.equalsIgnoreCase(request.getRequestURI(), "/graphql")) {
return true;
}
}
String data = this.redisTemplate.opsForValue().get(createRedisKey(request));
log.debug("redisValue(from redis) : {}", data);
if (StringUtils.isEmpty(data)) {
return true;
}
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=utf-8");
// 支持跨域
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods",
"GET,POST,PUT,DELETE,OPTIONS");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Headers", "Content-Type,X-Token");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.getWriter().write(data);
return false;
}
public static String createRedisKey(HttpServletRequest request) throws IOException {
String paramStr = request.getRequestURI();
Map<String, String[]> parameterMap = request.getParameterMap();
if (parameterMap.isEmpty()) {
paramStr += IOUtils.toString(request.getInputStream(), "UTF-8");
} else {
paramStr += mapper.writeValueAsString(parameterMap);
}
log.debug("url : {}", paramStr);
String redisKey = "WED_DATA_" + DigestUtils.md5Hex(paramStr);
log.debug("redisKey : {}", redisKey);
return redisKey;
}
}
注册拦截器到Spring容器:
import cn.itcast.haoke.dubbo.api.interceptor.RedisCacheInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private RedisCacheInterceptor redisCacheInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(redisCacheInterceptor).addPathPatterns("/**");
}
}
2、测试拦截器
出现了错误:
错误分析:由于在拦截器中读取了输入流的数据,在request中的输入流只能读取一次,请求进去Controller时,输入流中已经没有数据了,导致获取不到数据。
3、通过包装request解决
编写HttpServletRequest的包装类MyServletRequestWrapper:
import org.apache.commons.io.IOUtils;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
/**
* 包装HttpServletRequest
*/
public class MyServletRequestWrapper extends HttpServletRequestWrapper {
private final byte[] body;
public MyServletRequestWrapper(HttpServletRequest request) throws IOException {
super(request);
body = IOUtils.toByteArray(super.getInputStream());
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
@Override
public ServletInputStream getInputStream() throws IOException {
return new RequestBodyCachingInputStream(body);
}
private static class RequestBodyCachingInputStream extends ServletInputStream {
private byte[] body;
private int lastIndexRetrieved = -1;
private ReadListener listener;
public RequestBodyCachingInputStream(byte[] body) {
this.body = body;
}
@Override
public int read() throws IOException {
if (isFinished()) {
return -1;
}
int i = body[lastIndexRetrieved + 1];
lastIndexRetrieved++;
if (isFinished() && listener != null) {
try {
listener.onAllDataRead();
} catch (IOException e) {
listener.onError(e);
throw e;
}
}
return i;
}
@Override
public boolean isFinished() {
return lastIndexRetrieved == body.length - 1;
}
@Override
public boolean isReady() {
return isFinished();
}
@Override
public void setReadListener(ReadListener listener) {
if (listener == null) {
throw new IllegalArgumentException("listener cann not be null");
}
if (this.listener != null) {
throw new IllegalArgumentException("listener has been set");
}
this.listener = listener;
if (!isFinished()) {
try {
listener.onAllDataRead();
} catch (IOException e) {
listener.onError(e);
}
} else {
try {
listener.onAllDataRead();
} catch (IOException e) {
listener.onError(e);
}
}
}
@Override
public int available() throws IOException {
return body.length - lastIndexRetrieved - 1;
}
@Override
public void close() throws IOException {
lastIndexRetrieved = body.length - 1;
body = null;
}
}
}
通过过滤器进行包装request对象:
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 替换Request对象
*/
@Component
public class RequestReplaceFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
response, FilterChain filterChain) throws ServletException, IOException {
if (!(request instanceof MyServletRequestWrapper)) {
request = new MyServletRequestWrapper(request);
}
filterChain.doFilter(request, response);
}
}
4、测试
可以看到,request对象已经经过了包装。
并且在Controller中也可以获取到数据,问题解决。
四、响应结果写入到缓存
前面已经完成了缓存命中的逻辑,那么在查询到数据后,如果将结果写入到缓存呢?
通过ResponseBodyAdvice进行实现。
ResponseBodyAdvice是Spring提供的高级用法,会在结果被处理前进行拦截,拦截的逻辑自己实现,这样就可以实现拿到结果数据进行写入缓存的操作了。
具体实现:
import cn.itcast.haoke.dubbo.api.controller.GraphQLController;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import java.io.IOException;
import java.time.Duration;
@Slf4j
@ControllerAdvice
public class MyResponseBodyAdvice implements ResponseBodyAdvice {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private ObjectMapper mapper = new ObjectMapper();
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
if (returnType.hasMethodAnnotation(GetMapping.class)) {
return true;
}
if (returnType.hasMethodAnnotation(PostMapping.class) &&
StringUtils.equals(GraphQLController.class.getName(),
returnType.getExecutable().getDeclaringClass().getName())) {
return true;
}
return false;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
try {
String redisKey = RedisCacheInterceptor.createRedisKey(((ServletServerHttpRequest)
request).getServletRequest());
String redisValue;
if (body instanceof String) {
redisValue = (String) body;
} else {
redisValue = mapper.writeValueAsString(body);
}
log.debug("redisValue(from DB) : {}", redisValue);
this.redisTemplate.opsForValue().set(redisKey, redisValue, Duration.ofHours(1));
} catch (IOException e) {
e.printStackTrace();
}
return body;
}
}
测试获取到数据:
完。