一、概述
Redis 是一个开源(BSD许可)的内存中的高性能Key-Value数据结构存储系统,它可以用作数据库、缓存和消息中间件。支持多种类型数据结构,包括字符串(String)、列表(List)、集合(Set)、有序集合(Sorted Set)和散列(Hash)等。
为什么要用缓存
项目中为什么非得使用缓存?可以从高性能和高并发上来看,用户第一次访问某些数据,是从硬盘上读取的,如果将这些数据存在缓存中,下一次再访问这些数据的时候就可以直接从缓存中获取了,操作缓存就是操作内存,所以速度相当快。直接操作缓存能够承受的请求是远远大于直接访问数据库的,所以可以把数据库中的部分数据转移到缓存中去,这样用户的请求会直接到缓存而不用经过数据库。
一般项目中常用的缓存操作流程如下,需要注意的是,在写流程中,当业务修改了数据库数据后,可以将修改的数据同步到缓存中,也可以直接将这部分缓存清除。
二、Redis使用
Redis有很多操作是平时项目里面容易忽略的,后面我们使用Spring整合Redis来进行一系列操作实践,Spring官方提供了Spring Data Redis来更简单的在Spring应用中集成Redis基础组件,在此之前你必须掌握Spring的基础知识。Spring Data Redis提供了Lettuce和Jedis两个客户端框架来集成Redis,至于这两个框架有什么优缺点大家可以上网查询。首先搭建一个简单的SpringBoot项目,并连接好Redis服务器(可以是集群)。
1. 新建项目添加相关依赖
添加的依赖主要有SpringBoot框架支持、Apache线程池支持和Lombok工具。
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>2.1.0.RELEASE</version> </parent> <groupId>com.ajn</groupId> <artifactId>springboot-redis</artifactId> <version>1.0.0-SNAPSHOT</version> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <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> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <scope>provided</scope> </dependency> </dependencies> <build> <plugins> <plugin> <artifactId>maven-compiler-plugin</artifactId> <configuration> <source>${java.version}</source> <target>${java.version}</target> </configuration> </plugin> </plugins> </build> </project>
2. 配置SpringBoot项目环境
添加Spring项目入口类,这里我们的实验代码在单元测试里面运行,所以可以不用写main方法,只需要配置一个 RedisTemplate
类并注入到Spring容器中即可。RedisTemplate
是基础操作接口,两个泛型参数中,K
是Redis的Key的数据类型,一般取 String
;V
是Redis的Value数据类型,可以根据自己的需要定义。在配置Bean的时候,可以配置Redis存储的Key、Value、HashKey和HashValue的序列化方式,Spring默认提供了所有类型为String序列化的实现类 StringRedisTemplate
。Spring默认提供的对象序列化方式有:JDK序列化、String序列化和JSON序列化。在这里为了方便,所有的Key使用String序列化,所有Value使用JSON序列化,再选用Lettuce客户端框架,配置如下:
// RedisApplication.java @SpringBootApplication public class RedisApplication { @Bean public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory factory) { RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>(); redisTemplate.setKeySerializer(RedisSerializer.string()); redisTemplate.setValueSerializer(RedisSerializer.json()); redisTemplate.setHashKeySerializer(RedisSerializer.string()); redisTemplate.setHashValueSerializer(RedisSerializer.json()); redisTemplate.setConnectionFactory(factory); return redisTemplate; } }
Spring在 org.springframework.data.redis.serializer
包下提供了多种序列化方式,至于它们有什么功能和优缺点可以上网拓展。
添加单元测试抽象类如下,所有的单元测试类都需要继承它,配置前面的 RedisApplication
类为Spring的上下文(ApplicationContext),因为实验测试不需要Web环境,配置Web环境为 NONE
。
// BaseTest.java @RunWith(SpringRunner.class) @SpringBootTest(classes = RedisApplication.class, webEnvironment = SpringBootTest.WebEnvironment.NONE) public abstract class BaseTest { protected final Logger logger = LoggerFactory.getLogger(getClass()); }
3. 添加Redis配置属性
添加SpringBoot的默认配置文件,并添加Redis的连接信息和连接池的配置,更多配置信息可以参考SpringBoot文档。
# application.properties spring.redis.url=redis://user:password@codeartist.cn:6379 spring.redis.timeout=30s spring.redis.lettuce.pool.min-idle=0 spring.redis.lettuce.pool.max-idle=8 spring.redis.lettuce.pool.max-wait=-1ms spring.redis.lettuce.pool.max-active=8 spring.redis.lettuce.shutdown-timeout=100ms
此时这个SpringBoot项目就已经集成好了Redis,可以开始写实验代码了。
基础操作
新建一个 People
实体类用于测试,再建一个测试类继承 BaseTest
抽象类,完成数据类型的基础操作,RedisTemplate
类提供了针对几种数据类型的操作接口,分别为 ValueOperations
、HashOperations
、ListOperations
、SetOperations
和 ZSetOperations
等等。通过调用 RedisTemple
接口的 opsForXXX()
方法来获取对应的类型操作接口。下面编写Redis基础操作测试类,完成部分操作如下:
// People.java @Data public class People { private Long id; private Integer age; private String name; } // RedisOperateService.java public class RedisOperateService extends BaseTest { @Autowired private RedisTemplate<String, Object> redisTemplate; @Test public void setValue() { People people = new People(); people.setId(1L); people.setAge(24); people.setName("艾江南"); redisTemplate.opsForValue().set("ajn", people); } @Test public void getValue() { People people = (People) redisTemplate.opsForValue().get("ajn"); logger.info("people: {}", people); } @Test public void setHashValue() { People people = new People(); people.setId(1L); people.setAge(24); people.setName("艾江南"); redisTemplate.opsForHash().put("people", "ajn", people); } @Test public void getHashValue() { People people = (People) redisTemplate.opsForHash().get("people", "ajn"); logger.info("people: {}", people); }
管道
Redis是一种基于客户端 - 服务端模型以请求/响应协议的TCP服务,这意味着每一次请求的执行过程是:客户端向服务端发送一个查询请求,并监听Socket返回,通常是以阻塞模式,等待服务端响应,服务端处理命令,并将结果返回给客户端。但如果客户端需要在一个批处理中执行多次请求时,性能肯定会有所限制,所以Redis提供了管道(Pipelining)技术来改善这种情况,一次请求/响应服务器能实现处理新的请求即使旧的请求还未被响应,这样就可以将多个命令发送到服务器,而不用等待回复,最后在一个步骤中读取该答复。编写一个Redis管道测试类如下:
// RedisPipelineService.java public class RedisPipelineService extends BaseTest { @Autowired private RedisTemplate<String, Object> redisTemplate; @Test public void executePipelineSet() { redisTemplate.executePipelined(new SessionCallback<Object>() { @Override public Object execute(RedisOperations operations) throws DataAccessException { for (int i = 0, n = 10; i < n; i++) { operations.opsForHash().put("people", i + "", i); } return null; } }); } @Test public void executePipelineGet() { List<Object> list = redisTemplate.executePipelined(new SessionCallback<Object>() { @Override public Object execute(RedisOperations operations) throws DataAccessException { for (int i = 0, n = 10; i < n; i++) { operations.opsForHash().get("people", i + ""); } return null; } }); logger.info("Result: {}", list); }
运行第一个测试方法,会在Redis服务器上存储一个键为 people
的Hash列表,运行第二个测试方法,会执行读取Hash列表的管道,最后统一返回一个 List
返回结果。
注意:使用管道发送命令时,服务器将被迫回复一个队列答复,占用很多内存。所以,如果你需要发送大量的命令,最好把它们按照合理数量分批处理。
发布订阅
Redis支持发布/订阅功能,发布者不是给特定接收者发送消息,而是将消息发布到不同的渠道(Channel),不需要知道哪些客户端订阅了。订阅者可以对一个或多个渠道感兴趣,只需要接收自己感兴趣的消息,不需要知道是哪些发布者发送的。这种发布者和订阅者的解耦可以带来更大扩展性和更加动态的网络拓扑。Redis还支持客户端通过渠道名称进行模式匹配订阅,比如订阅的渠道名称为 f*
,可以接受到渠道名为 food
和 fruit
的消息。
编写一个测试类用来发布消息如下:
// RedisPubSubService.java public class RedisPubSubService extends BaseTest { @Autowired private RedisTemplate<String, Object> redisTemplate; @Test public void publishing() { redisTemplate.convertAndSend("hello", "ajn"); } }
然后在主类里面配置一个Redis消息监听器Bean如下,Spring提供了两个 Topic
实现类 ChannelTopic
和 PatternTopic
用来监听正常渠道和模式匹配渠道。
// RedisApplication.java @Bean public RedisMessageListenerContainer redisMessageListenerContainer(LettuceConnectionFactory factory) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(factory); container.addMessageListener((message, pattern) -> System.out.println("Receive Message: " + message), new ChannelTopic("hello")); return container;
运行测试类,我们会在控制台看到发送的消息,此时项目既充当了发布者,也充当了订阅者。
如果需要区分某些渠道,可以在渠道名称前面加上所在环境的名称(例如:开发环境、测试环境、生产环境)。
事务
Redis支持事务操作,提供了一些事务相关的指令,用来实现执行多个命令的原子性,包括以下命令:
MULTI
:开启一个事务,执行后客户端可以向服务器发送任意条命令,这些命令不会立即执行,而是放到一个队列中。EXEC
:触发并执行事务中的所有命令,也就是队列中的所有命令。DISCARD
:该事务放弃执行,事务队列会被清空,并且客户端从事务状态中退出。WATCH
:为事务提供Check And Set(CAS)行为,该命令可以监视某些键,这些键如果被修改了,整个事务都会被取消。
RedisTemplate
中的 RedisOperations
类提供了对应的 multi()
、exec()
、discard()
和 watch()
方法来支持事务,编写测试类如下实现事务功能:
// RedisTransactionService.java public class RedisTransactionService extends BaseTest { @Autowired private RedisTemplate<String, Object> redisTemplate; @Test public void transaction() { redisTemplate.execute(new SessionCallback<Object>() { @Override public Object execute(RedisOperations operations) throws DataAccessException { operations.multi(); HashOperations ops = operations.opsForHash(); ops.put("people", "1", 1); // throwException(); ops.put("people", "2", 2); return operations.exec(); } }); } private void throwException() { throw new NullPointerException(); } }
在Hash中写入两个值,当中途抛出异常的时候,两个操作都不会执行。
持久化
Redis是基于内存的数据库,所以每当服务器重启或出现故障时,所有数据都会丢失。Redis提供的持久化方案有RDB和AOF,RDB持久化方式能够在指定的时间间隔能对数据进行快照存储。AOF持久化方式记录每次对服务器写的操作,当服务器重启的时候会重新执行这些命令来恢复原始的数据。这两种持久化方式可以同时开启,当Redis重启的时候会优先载入AOF文件来恢复原始的数据,因为通常情况下AOF文件保存的数据比RDB文件保存的数据要完整。RDB是通过时间段进行持久化的,所以在Redis意外停止工作的情况下,会丢失该时间段内的数据,使用AOF可以使数据丢失最少,但AOF文件存储所占的体积要大于RDB文件。
集群
Redis集群是一个提供在多个Redis节点间共享数据的程序集,集群并不支持处理多个Keys的命令,因为这需要在不不同的节点间移动数据,从而达不到像Redis那样的性能,在高负载的情况下可能会导致不可预料的错误,但它可以自动分割数据到不同的节点上,整个集群的部分节点失败或者不可达的情况下能够继续处理命令,提供了一定程序的可用性。
三、Redis问题
缓存和数据库双写一致性问题
使用缓存就必定会遇到一致性问题,因为涉及到缓存与数据库的双存储双写。我们所做的所有方案从根本上来讲,只能降低不一致发生的概率,因此,有强一致性需要的数据,不能放缓存。采取正确的更新策略,先更新数据库,然后再删除缓存,这样可以使产生不一致的情况大大减少,因为可能存在删除缓存失败的问题,提供一个补偿措施即可,例如使用消息队列。
缓存雪崩问题
当缓存同一时间大面积的失效,后面的请求都落在数据库上,造成数据库短时间内承受大量请求而崩掉。遇到这种情况尽量保证整个Redis集群的高可用性,发现机器宕机尽快修复,选择合适的内存淘汰策略,还可以使用本地缓存或者服务熔断降级来限流,避免数据库崩掉,最后利用Redis持久化机制保存的数据尽快恢复缓存。
缓存击穿问题
当存在黑客故意请求缓存中不存在的数据,导致所有请求都落在数据库上,也会造成数据库崩掉。有很多种方法可以有效地解决缓存穿透问题,如下:
- 利用互斥锁,缓存失
- 效时先去获得锁,成功得到锁,再去请求数据库,没得到锁,则休眠一段时间重试。
- 采用异步更新策略,无论Key是否取到值,都直接返回,Value 值中维护一个缓存失效时间,缓存如果过期,异步起一个线程去读数据库,更新缓存。需要做缓存预热(项目启动前,先加载缓存)操作。
- 提供一个能迅速判断请求是否有效的拦截机制,比如布隆过滤器,内部维护一系列合法有效的Key,迅速判断出请求的Key是否合法有效。
缓存的并发竞争问题
缓存的并发竞争就是多个系统同时对一个Key进行操作,但最后执行的顺序和我们期望的顺序不同,因此导致结果不同。这种情况可以使用分布式锁来解决(如果不存在缓存并发竞争,不要使用分布式锁,这样会影响性能),比如使用基于Zookeeper临时有序节点实现的分布式锁。