赞
踩
KEYS age:查询所有键为key的键,age可以使用通配符,生产环境下数据量庞大,一般不建议使用
DEL key1 key2 …:删除指定的键,可以同时删除多个,返回删除的个数
EXISTS key1 key2 …:判断键是否存在,可以同时查询多个,有几个键存在就返回几
EXPIRE key 时间(秒):给指定键加一个销毁时间,时间到了之后这个键会自动销毁
TTL key:查看一个指定键的有效期,有效期到了之后返回-2,没有设置有效期的键返回-1
在连接redis时,以上ip和端口正确的,但是出现连接不上,原因有可能是两种:
在FinalShell或者虚拟机终端使用命令时,需要先连接上服务端才能对redis进行操作,先使用redis-cli 命令创建一个命令行客户端连接redis,再使用auth 密码,输入密码,之后便可以在终端命令行中使用redis命令了
String类型有三种格式,分别是字符串、int、float
String类型的常用命令有:
INCRBY key 自增数字:让一个整型的键自增指定数字,例如让k2自增2
INCRBYFLOAT key 自增数字:让一个浮点型的数字自增指定步长
Redis允许有多个单词形成层级结构,多个单词使用 ‘:’ 隔开,格式如下:
项目名:业务名:类型:id
在resp中查看
根据我们输入的层级关系,redis会自动帮我们层级分类,这样就克服了区分不同类型的key的问题了
什么是Hash类型,Hash类型,也叫散列,类似于java中的HashMap结构,String类型是将对象序列化成JSON字符串后存储,但需要修改某个字段时很不方便,Hash结构可以将字段单独存储,可以对单个字段做curd
String结构:
Hash结构:
Hash结构的常见命令和String类型的常见命令相似,基本上都是在String命令的基础上加上H,代表操作的时Hash结构
Redis中的List类型是一个双向链表结构,具有有序可重复、插入删除快、查询速度一般的特点。
List的常见命令:
resp显示:
Redis的Set集合和java中的HashSet类似,可以看做是一个value为null的HashMap。
Set类型的常见命令:
SortedSet是Redis中的一个可排序的set集合,SortedSet集合中的每一个元素都有一个score属性,可以基于score属性对元素进行排序,底层是跳表加哈希表。
SortedSet类型常见命令:
resp中显示:
Jedis是以Redis命令作为方法名称的,学习成本低,简单使用,但是Jedis实例是线程不安全的,多线程环境下需要基于连接池来使用。
Jedis官方网址:GitHub - redis/jedis: Redis Java client
Jedis的基本使用:
<!--jedis-->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>4.4.3</version>
</dependency>
@BeforeEach
void setUp(){
//建立连接
jedis = new Jedis("192.168.190.134", 6379);
//设置密码
jedis.auth("123456");
//选择库
jedis.select(0);
}
@Test
//String类型
void contextLoads() {
//插入数据
String result = jedis.set("name", "lisi");
System.out.println("result="+result);
//获取数据
String name = jedis.get("name");
System.out.println("mame="+name);
}
@Test
//Hash类型
void testHash(){
//插入数据
jedis.hset("user:1","name","zhangsan");
jedis.hset("user:1","age","18");
//获取数据
Map<String, String> stringStringMap = jedis.hgetAll("user:1");
System.out.println(stringStringMap);
}
@AfterEach
void tearDown(){
//释放资源
if (jedis!=null){
jedis.close();
}
}
补充: @BeforeEach和@AfterEach注解的作用:分别为在执行测试类中的方之前和执行完成之后执行该方法
因为Jedis本身是线程不安全的,并且频繁的创建和销毁连接会有性能消耗,因此我们推荐大家使用Jedis连接池代替Jedis的直连方式。
编写连接池类
package com.lbc.redisdemo.util; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPoolConfig; /** * @program: Redisdemo * @Date: 2023/8/4 22:05 * @Author: Huang * @Description: */ public class JedisConnectionFactory { private static final JedisPool jedisPoll; static { JedisPoolConfig jedisPoolConfig = new JedisPoolConfig(); //最大连接 jedisPoolConfig.setMaxTotal(8); //最大空闲连接 jedisPoolConfig.setMaxIdle(8); //最小空闲连接 jedisPoolConfig.setMinIdle(0); //设置等待时长 ms jedisPoolConfig.setMaxWaitMillis(200); jedisPoll = new JedisPool(jedisPoolConfig, "192.168.190.134", 6379, 1000, "123456"); } //获取jedis对象 public static Jedis getJedis(){ return jedisPoll.getResource(); } }
测试类
package com.lbc.redisdemo; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.lbc.redisdemo.pojo.User; import com.lbc.redisdemo.util.JedisConnectionFactory; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import redis.clients.jedis.Jedis; @SpringBootTest class RedisdemoApplicationTests { private Jedis jedis; @BeforeEach void setUp(){ //建立连接 // jedis = new Jedis("192.168.190.134", 6379); jedis = JedisConnectionFactory.getJedis(); //设置密码 jedis.auth("123456"); //选择库 jedis.select(0); } @Test void contextLoads() { //插入数据 String result = jedis.set("name", "lisi"); System.out.println("result="+result); //获取数据 String name = jedis.get("name"); System.out.println("mame="+name); } @Test void testHash(){ //插入数据 jedis.hset("user:1","name","zhangsan"); jedis.hset("user:1","age","18"); //获取数据 Map<String, String> stringStringMap = jedis.hgetAll("user:1"); System.out.println(stringStringMap); } @AfterEach void tearDown(){ //释放资源 if (jedis!=null){ jedis.close(); } } }
SpringData是Spring中数据操作的模块,包含对各种数据的集成,其中对Redis集成的模块就叫SpringDataRedis。
SpringDataRedis的优势:
SpringDataRedis基本使用
<!--Jackson--> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <!--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>
spring:
redis:
host: 192.168.190.141
port: 6379
password: 123456
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: 100
package com.lbc.redisdemo; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.lbc.redisdemo.pojo.User; import com.lbc.redisdemo.util.JedisConnectionFactory; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; 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 redis.clients.jedis.Jedis; import java.util.List; import java.util.Map; import java.util.Set; @SpringBootTest class RedisdemoApplicationTests { @Autowired private RedisTemplate redisTemplate; @Test void testString(){ //插入String的一条数据 redisTemplate.opsForValue().set("name","Jack"); //读取一条String类型的数据 Object name = redisTemplate.opsForValue().get("name"); System.out.println(name); redisTemplate.opsForValue().set("User:10",new User("zhansan","19")); User o = (User) redisTemplate.opsForValue().get("User:10"); System.out.println("o="+o); } @Test void testHash2(){ //插入数据 redisTemplate.opsForHash().put("User:30","name","zhangsan"); redisTemplate.opsForHash().put("User:30","age","21"); //获取数据 //获取所有键值对 Map entries = redisTemplate.opsForHash().entries("User:30"); //获取所有键 Set keys = redisTemplate.opsForHash().keys("User:30"); //获取所有值 List values = redisTemplate.opsForHash().values("User:30"); System.out.println("键值对="+entries); System.out.println("键="+keys); System.out.println("值="+values); } }
在RedisTemplate中,对String类型的操作方法名与redis的名字一致,但是对Hash类型的操作是与Map集合的相似,因为Hash类型与java的Map类型一样的键值对存储结构。
RedisTemplate可以接收任意的Object作为值写入Redis,只不过写入之前会把Object序列化为字节形式,默认是采用JDK序列化,得到的结果是一段长字符,这样可读性差并且内存的占用比较大。
为解决上述问题,我们可以自定义RedisTemplate的序列化形式,编写的配置类如下代码
package com.lbc.redisdemo.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; /** * @program: Redisdemo * @Date: 2023/8/7 9:45 * @Author: Huang * @Description: */ @Configuration public class RedisConfig { @Bean public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){ //创建Template RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>(); //设置连接工厂 redisTemplate.setConnectionFactory(redisConnectionFactory); //设置序列化工具 GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer(); //key和hashKey采用String序列化 redisTemplate.setKeySerializer(RedisSerializer.string()); redisTemplate.setHashKeySerializer(RedisSerializer.string()); //value和hashValue采用JSON序列化 redisTemplate.setValueSerializer(jackson2JsonRedisSerializer); redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer); return redisTemplate; } }
重新运行上段测试代码,获得的结果为:
图中还有一段字段为该对象的类名,我们发现类型的所占内存甚至比我们存储的信息还要大,这是我们不希望看到的,于是我们需要手动的对对象进行序列化共和反序列化,代码如下:
package com.lbc.redisdemo; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.lbc.redisdemo.pojo.User; import com.lbc.redisdemo.util.JedisConnectionFactory; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; 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 redis.clients.jedis.Jedis; import java.util.List; import java.util.Map; import java.util.Set; @SpringBootTest class RedisdemoApplicationTests { private Jedis jedis; @Autowired private RedisTemplate redisTemplate; private static final ObjectMapper mapper = new ObjectMapper(); @Test void testString() throws JsonProcessingException { //创建一个对象 User user = new User("zhangsan","20"); //手动序列化 String json = mapper.writeValueAsString(user); System.out.println(json); redisTemplate.opsForValue().set("User:20",json); //获取数据 String jsonUser = (String) redisTemplate.opsForValue().get("User:10"); System.out.println(jsonUser); //手动反序列化 User user1 = mapper.readValue(jsonUser, User.class); System.out.println(user1); } @Test void testHash2(){ //插入数据 redisTemplate.opsForHash().put("User:30","name","zhangsan"); redisTemplate.opsForHash().put("User:30","age","21"); //获取数据 //获取所有键值对 Map entries = redisTemplate.opsForHash().entries("User:30"); //获取所有键 Set keys = redisTemplate.opsForHash().keys("User:30"); //获取所有值 List values = redisTemplate.opsForHash().values("User:30"); System.out.println("键值对="+entries); System.out.println("键="+keys); System.out.println("值="+values); } }
运行获得结果:
session共享问题 :多台Tomcat不会共享session存储空间,当多个请求切换到不同的Tomcat服务时会造成数据丢失的问题。
举个例子,当我们在使用短信验证码登录时,我们先点击发送验证码,这个请求由Tomcat1接收处理了,而产生的验证码存储在Tomcat1的session中,当我们输入验证码点击登录时,由Tomcat2接收处理,当我们需要获取先前产生的验证码与用户输入的验证码进行校验时,这时我们生成的验证码是保存在Tomcat1的session中,而处理这个请求的Tomcat2的session中是没有数据的,这就造成了session共享的数据丢失问题。
为了解决这个问题,我们可以使用Redis来对session的数据进行统一存储管理,这样每一台Tomcat都能获取同一份数据了。
以短信验证码登录为例:
在登录功能中主要存储的信息有两个,一是生成的验证码,二是登录的用户信息,前者需要拿来进行登录校验,后者用来进行拦截操作(部分功能需要登录之后才能使用)。
生成验证码:
@Override public Result sendCode(String phone, HttpSession session) { //1、校验手机号 if (RegexUtils.isPhoneInvalid(phone)){ //2、手机号不符合,返回错误信息 return Result.fail("手机号格式错误!"); } //3、手机号符合,生成验证码 String code = RandomUtil.randomNumbers(6); //4、保存验证码到redis中 设置验证码有效期为2分钟 stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES); //5、发送验证码 log.debug("发送短信验证码成功!验证码:{}",code); return Result.ok(); }
这里使用的数据类型是String类型,并且设置了验证码的有效期为2分钟,为防止产生的验证码过多而引起不必要的麻烦。
登录校验:
@Override public Result login(LoginFormDTO loginForm, HttpSession session) { //1、校验手机号码格式 String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)){ //2、手机号不符合,返回错误信息 return Result.fail("手机号格式错误!"); } //3、手机号码格式正确,校验验证码 从redis中获取验证码 String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone); String code = loginForm.getCode(); if (code == null || !cacheCode.equals(code)){ //不一致 return Result.fail("验证码错误"); } //一致 根据手机号查询用户 User user = query().eq("phone", phone).one(); //判断用户是否存在 if (user == null){ //不存在 根据电话号码创建用户 user = createUserWithPhone(phone); } //保存用户信息到redis中 //随机生成token,作为登录的令牌 String token = UUID.randomUUID().toString(true); //将User对象转化成Hash存储 UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); //这里UserDTO的id为long类型 需要将long类型转换成String类型才能在Redis中存储 Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(), CopyOptions .create().setIgnoreNullValue(true) .setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString())); //存储 stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,userMap); //设置有效期 stringRedisTemplate.expire(LOGIN_USER_KEY+token,LOGIN_USER_TTL,TimeUnit.MINUTES); return Result.ok(token); } private User createUserWithPhone(String phone) { User user = new User(); user.setPhone(phone); user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX+RandomUtil.randomString(10)); //保存用户 save(user); return user; }
这里我们把用户信息的键设置成一个随机生成的唯一的token,这样可以避免一些敏感信息保存在外
拦截器中从Redis中获取用户信息进行判断拦截:
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //获取请求头中的token String token = request.getHeader("authorization"); //判断token是否为空 if (StrUtil.isBlank(token)){ response.setStatus(401); //拦截 return false; } //基于token从redis中获取用户 Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token); //判断用户是否存在 if (userMap.isEmpty()){ response.setStatus(401); //拦截 return false; } //将查询到的hash数据转换成userDTO对象 UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); //存在 保存用户信息到ThreadLocal UserHolder.saveUser(userDTO); //刷新token有效时间 stringRedisTemplate.expire(LOGIN_USER_KEY + token,LOGIN_USER_TTL, TimeUnit.MINUTES); //放行 return true; }
这里对拦截器做一些优化,现在我们只有对那些需要拦截的操作才能刷新token的有效期,应该是用户做任何操作都应该刷新token的有效期,我们可以使用两个拦截器,一个用来对所有请求路径进行拦截刷新token的有效期,一个用来对无效的请求进行拦截。
原拦截模式:
双拦截模式:
代码:
package com.hmdp.utils; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.StrUtil; import com.hmdp.dto.UserDTO; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.Map; import java.util.concurrent.TimeUnit; import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY; import static com.hmdp.utils.RedisConstants.LOGIN_USER_TTL; /** * @program: hm-dianping * @Date: 2023/8/8 16:38 * @Author: Huang * @Description: */ public class RefreshTokenInterceptor implements HandlerInterceptor { private StringRedisTemplate stringRedisTemplate; public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //获取请求头中的token String token = request.getHeader("authorization"); //判断token是否为空 if (StrUtil.isBlank(token)){ return true; } //基于token从redis中获取用户 Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token); //判断用户是否存在 if (userMap.isEmpty()){ return true; } //将查询到的hash数据转换成userDTO对象 UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); //存在 保存用户信息到ThreadLocal UserHolder.saveUser(userDTO); //刷新token有效时间 stringRedisTemplate.expire(LOGIN_USER_KEY + token,LOGIN_USER_TTL, TimeUnit.MINUTES); //放行 return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { //移除用户 UserHolder.removeUser(); } }
package com.hmdp.utils; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * @program: hm-dianping * @Date: 2023/8/8 16:38 * @Author: Huang * @Description: */ public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //判断用户是否存在 if (UserHolder.getUser() == null){ response.setStatus(401); return false; } return true; } }
config配置类
package com.hmdp.config; import com.hmdp.utils.LoginInterceptor; import com.hmdp.utils.RefreshTokenInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import javax.annotation.Resource; /** * @program: hm-dianping * @Date: 2023/8/8 16:46 * @Author: Huang * @Description: */ @Configuration public class MvcConfig implements WebMvcConfigurer { @Resource StringRedisTemplate stringRedisTemplate; @Override public void addInterceptors(InterceptorRegistry registry) { //注册拦截器并设置放行路径 registry.addInterceptor(new LoginInterceptor()) .excludePathPatterns( "/shop/**", "/voucher/**", "/shop-type/**", "/upload/**", "/blog/hot", "/user/code", "/user/login" ).order(1); //order越小越先执行 registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0); } }
redis的读写效率高,在实际场景中能用来做缓存,那么什么是缓存呢?
缓存是数据交换的缓冲区,是存储数据的地方,一般读写性能要求较高。缓存有好的一面也有坏的一面。
redis的数据是存储在内存当中,所以它的读写效率很高,所以常用来做缓存。那么redis在项目中是如何作为缓存的呢?下面以商铺查询为例,介绍redis如何作为缓存在项目中提高项目的效率。
一般情况下,当前端发出一个查询的请求时,我们需要去数据库中查询,在数据库中查询是io操作,这使得查询速率会很慢,这时我们便需要redis作为缓存,当请求打到后端时,我们先从redis缓存中查询我们需要的数据,假如在redis缓存中查询到了我们需要的数据之后,我们可以直接返回,这个过程称为命中,假如redis缓存中没有我们需要的数据,我们则从数据中进行查询,查询到了之后需要将数据写入缓存中以便下一次查询,最后再返回给前端,当下一次发起同样的请求查询同样的数据时,便可以从redis缓存中查询,大大提高查询效率。
package com.hmdp.service.impl; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import com.hmdp.dto.Result; import com.hmdp.entity.Shop; import com.hmdp.mapper.ShopMapper; import com.hmdp.service.IShopService; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import javax.annotation.Resource; import static com.hmdp.utils.RedisConstants.CACHE_SHOP_KEY; @Service public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService { @Resource StringRedisTemplate stringRedisTemplate; @Override public Result queryShopById(Long id) { //1、从redis中读取缓存 String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); //2、判断redis中是否有缓存 if (StrUtil.isNotBlank(shopJson)){ //3、有,直接读取返回 return Result.ok(JSONUtil.toBean(shopJson,Shop.class)); } //4、没有 从数据库中读取 Shop shop = getById(id); //5、判断数据库中是否存在 if (shop == null){ //6、不存在 返回错误信息 return Result.fail("商铺不存在!"); } //7、存在 写入缓存 stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop)); return Result.ok(shop); } }
redis缓存是存储在内存当中,内存的大小是有限的,比较珍贵,所以我们需要有一个缓存的更新策略来保证缓存不会将内存撑爆。缓存的更新策略有以下三种:
不同的业务场景所使用的更新策略也不同。相对于内存淘汰和超时剔除,主动更新会更安全可靠一些。主动更新策略又有几种不同的方案。
而这三种方案中第一个方案相对于更有优势。在使用方案一时,我们会遇到三个问题。
第三个问题,是先删除缓存还是先操作数据库,下面看具体的分析。
先删缓存再操作数据库:
这种做法的弊端是:如图所示的运行顺序,当线程1删除缓存之后,由于更新数据库所需要花费的时间很长,所以当线程2来查询数据时会先查缓存,缓存中的数据已经被线程1删除了,线程2便会起查询数据库中的数据并且把数据重新写入缓存当中,在此期间,更新数据库的操作还没完成,缓存在被线程1删除之后,又被线程2重新写入一份旧数据,这样就造成了数据不同步的问题,在实际开发过程中,这样的情况挺常见。
先操作数据库,再删除缓存:
这种做法同样有弊端:如图所示,当线程1去查询数据,先查询缓存,发现未命中,之后去查询数据库,得到了一份数据,这时线程2进来更新数据库,将数据改变了,并且将缓存删掉了,线程1又将先前查询到旧数据写入缓存中,这时便造成了数据不同步的问题。
这两种方式都会出现数据不同步的问题,但是第二种出现问题的概率要比第一种低,所以推荐使用第二种,先操作数据库再删除内存。
缓存穿透是指当客户端请求的数据在缓存和数据库中都不存在时,这些请求就都会打到数据库当中对你数据库进行查询,当这类请求数量多的情况下,数据库会顶不住压力从而崩掉。
解决方案:
缓存空对象:当我们发现缓存和数据库中都不存在数据时,我们在缓存中添加一个空对象并设置短暂的TTL值,当下次请求打进来时从缓存中获取空对象返回,大大提高效率。
这方案的缺点就是会产生额外的内存消耗,可能造成短期的不一致(当数据库中添加了新的数据,如果先前保存的空缓存还未过期,请求获取的数据还是空对象,与数据库中的数据不一致,我们可以通过控制空缓存的TTL时间来限制这个问题)
代码:
//缓存穿透 public Shop queryWithPassThrough(Long id){ //1、从redis中读取缓存 String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); //2、判断redis中是否有缓存 if (StrUtil.isNotBlank(shopJson)){ //3、有,直接读取返回 return JSONUtil.toBean(shopJson,Shop.class); } //判断缓存中是否有空缓存 if (shopJson != null){ return null; } //4、没有 从数据库中读取 Shop shop = getById(id); //5、判断数据库中是否存在 if (shop == null){ //6、不存在 返回错误信息 //向redis缓存中写入空信息 stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } //7、存在 写入缓存 stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop)); return shop; }
布隆过滤:布隆过滤是在读取缓存之前再加一层过滤器,判断数据是否存在,如果不存在则直接拒绝访问。
缓存雪崩是指在某一段时间里大量的缓存同时过期或者Redis直接宕机,导致大量的请求到达数据库,给数据库带来巨大的压力。
解决方案:
缓存击穿也叫热点Key问题,当某个被高并发并且缓存重建业务复杂的key突然失效了,无数的i请求访问会在瞬间给数据库带来巨大的冲击。经常在商铺办活动抢购的情况下出现,这时会有很多的同样的请求打到服务器中,假如这时数据正在更新,缓存中没有数据,则这些大量的请求都会进行数据库。
解决方案:
方案一:互斥锁:互斥锁的思路是当线程1查询缓存没有命中后,再去获取互斥锁,加了这把锁之后其他的线程就无法进来操作数据库,只能在外面进行休眠等待,直到线程1完成了数据库的操作并且将数据存入缓存中,在外等着的线程就可以读取缓存返回了。
互斥锁结构图:
代码案例流程图:
代码:
//互斥锁解决缓存击穿 public Shop queryWithMutes(Long id){ Shop shop = null; try { //1、从redis中读取缓存 String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); //2、判断redis中是否有缓存 if (StrUtil.isNotBlank(shopJson)){ //3、有,直接读取返回 return JSONUtil.toBean(shopJson,Shop.class); } //判断缓存中是否有空缓存 if (shopJson != null){ return null; } //4、没有 从数据库中读取 //未命中 尝试获取互斥锁 if (!tryLock(LOCK_SHOP_KEY+id)){ //获取失败,说明有其他线程正在操作数据库 等待 Thread.sleep(50); //重新尝试读取缓存 递归 queryWithMutes(id);//可用循环代替 这里会一直读取redius直到redis中有数据为止 } /*获取锁成功 进行第二次读取缓存 二次读取缓存的原因是:如果当前一个线程刚刚操作完数据库并且释放了锁,后一个线程刚好读取完空缓存然后拿到了锁 这样就会再一次对数据库进行操作,在高并发的环境下可能有大量的线程出现这种情况,从而我们需要在拿到锁之后再做一次缓存读取,防止上述情况发生 */ //1、从redis中读取缓存 String newShopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); //2、判断redis中是否有缓存 if (StrUtil.isNotBlank(newShopJson)){ //3、有,直接读取返回 return JSONUtil.toBean(newShopJson,Shop.class); } //判断缓存中是否有空缓存 if (newShopJson != null){ return null; } shop = getById(id); //5、判断数据库中是否存在 if (shop == null){ //6、不存在 返回错误信息 //向redis缓存中写入空信息 stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } //7、存在 写入缓存 stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop)); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { //释放锁 unLock(LOCK_SHOP_KEY+id); } return shop; } //加锁 public boolean tryLock(String id){ //setIfAbsent方法对应String类型的SETNX key val命令,只有再当前key不存在时才能set成功,可以很好的充当锁的作用 Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(id, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS); return BooleanUtil.isTrue(lock); } //释放锁 public void unLock(String id){ stringRedisTemplate.delete(id); }
这里有两个注意点,第一是二次读取缓存,我们在请求刚打入时做了一次缓存读取,读取失败后去获取锁,在获取锁成功之后要进行第二次缓存读取,这样做的原因是:如果当前一个线程刚刚操作完数据库并且释放了锁,后一个线程刚好读取完空缓存然后拿到了锁,这样就会再一次对数据库进行操作,在高并发的环境下可能有大量的线程出现这种情况,从而还是有很多的线程去操作数据库,所以我们需要在拿到锁之后再做一次缓存读取,防止上述情况发生。
第二个注意点是,我们这里用的锁并不是常规的lock锁和synchronized,我们采用的是redis中的SETNX key val命令,只有再当前key不存在时才能set成功,可以很好的充当锁的作用,这就能使所有的线程共用同一把锁,分布式锁的思想,不用lock和synchronized的原因是synchronized和lock是单机锁,每个tomcat服务器都会有锁,每次负载均衡到不同的服务器,就会出现有多把锁的情况。
互斥锁解决缓存击穿问题没有额外的内存消耗,能保证数据的一致性,实现起来比较简单,但是每一条未获取锁的线程都需要等待,性能会受到影响,还有死锁的风险。
方案二:逻辑过期:逻辑过期的思想是在缓存对象中添加一个逻辑过期的属性,当线程1进来时,下读取缓存判断是否过期,如果过期则去获取锁,获取锁成功之后开启一条新的线程去操作数据库,自己则带着已经过期的旧数据直接返回,当其他线程进来获取锁失败直接返回过期信息,这样线程就不会一直等待数据更新,提高性能,
逻辑过期结构图:
案例代码流程图:
代码:
//逻辑日期解决缓存击穿问题 private Shop queryWithLogicalExpire(Long id) { //1、从redis中读取缓存 String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); //2、判断redis中是否有缓存 if (StrUtil.isBlank(shopJson)){ //未命中,直接返回空 return null; } //命中 将获取的JSON反序列化成对象 RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class); Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class); LocalDateTime expireTime = redisData.getExpireTime(); //判断缓存是否过期 if (expireTime.isAfter(LocalDateTime.now())){ //未过期 return shop; } //过期 尝试获取互斥锁 boolean isLock = tryLock(CACHE_SHOP_KEY + id); if (isLock){ //获取成功 开启线程 定义了一个线程池 CACHE_REBUILD_EXECUTOR.submit(()->{ try { this.saveShop2Redis(id,20L); } catch (Exception e) { throw new RuntimeException(e); } finally { //释放锁 unLock(CACHE_SHOP_KEY + id); } }); } //获取失败 再次读取缓存 //1、从redis中读取缓存 String newShopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); //2、判断redis中是否有缓存 if (StrUtil.isBlank(newShopJson)){ //未命中,直接返回空 return null; } RedisData NewRedisData = JSONUtil.toBean(shopJson, RedisData.class); Shop newShop = JSONUtil.toBean((JSONObject) NewRedisData.getData(), Shop.class); return newShop; } public void saveShop2Redis(Long id,Long expireSeconds){ //查询商店信息 Shop shop = getById(id); //封装过期时间 RedisData redisData = new RedisData(); redisData.setData(shop); redisData.setExpireTime(LocalDateTime.now().plusMinutes(expireSeconds)); //写入redis stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData)); }
这里需要注意的是,我们需要在原来的shop添加一个逻辑时间的属性,一般的按照规范来说,我们不能直接去修改原来的对象,降低耦合,我们采用的方案是重新创建一个类RedisData,将Shop类作为RedisDate的一个属性存进去,再在RedisDate中添加一个逻辑时间的属性,这样做可以很好的降低代码的耦合性。
package com.hmdp.utils;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
超卖问题是典型的线程安全问题,顾名思义,超卖问题就是商家需要卖出的一类数量有限的商品时,由于多线程同时进行操作数据库,使得数据库的中的商品数量数据出现负数的情况,具体线程执行流程如下图,假设现在某件商品的库存为1,当线程1去查询数据库中的商品库存是否大于0,之后再去对数据库的库存进行扣减操作,但在这时,线程2和线程3也进来了,并且在线程1对数据库进行库存减扣之前进行了查询库存的操作,这时他们拿到的库存数量也是1,同样也可以执行减扣的操作,这时便出现了商品超卖问题,一件商品有三个人买到。
解决超卖问题的常见方法就是加锁
乐观锁和悲观锁是两种常见的加锁理念,下面看看这这两种锁的区别
悲观锁:悲观锁认为线程安全一定会发生,所以在操作数据之前先获取锁,确保线程串行执行,例如Synchronized、Lock都属于悲观锁。
乐观锁:乐观锁认为线程安全不一定会发生,所以不加锁,只有当更新数据的时候去判断数据有没有被修改,如果没有被修改则认为是安全的,自己才可以跟更新数据,如果被修改了则说明出现了线程安全问题,此时可以重试或异常。
乐观锁的关键是判断得到的数据是否被修改过,常见的方法有两种:
版本号法:我们给每一条数据添加一个version版本字段,每次更新数据的时候对version进行加一更新并且使用where条件限制执行,必须当前的版本号与之前查询所得的版本号相同才能执行更新操作。
CAS法:CAS法与版本号法思想是一样的,只不过实现方法不同,CAS法没有给数据添加version字段,而是直接使用查出来的额数据作为限制条件,我们在对数据进行更新的时候,先判断当前的数据是否和先前查询得到的数据相同,用查出来的数据代替version的作用。
实战运用:
代码:
package com.hmdp.service.impl; import com.hmdp.dto.Result; import com.hmdp.entity.SeckillVoucher; import com.hmdp.entity.VoucherOrder; import com.hmdp.mapper.VoucherOrderMapper; import com.hmdp.service.ISeckillVoucherService; import com.hmdp.service.IVoucherOrderService; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.hmdp.utils.RedisIdWorker; import com.hmdp.utils.UserHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import java.time.LocalDateTime; @Service public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Resource private ISeckillVoucherService seckillVoucherService; @Resource private RedisIdWorker redisIdWorker; @Override @Transactional public Result seckillVoucher(Long voucherId) { //1、查询优惠卷 SeckillVoucher voucher = seckillVoucherService.getById(voucherId); //2、判断秒杀是否开始 if(voucher.getBeginTime().isAfter(LocalDateTime.now())){ //尚未开始 return Result.fail("秒杀尚未开始!"); } //3、判断秒杀是否结束 if(voucher.getBeginTime().isBefore(LocalDateTime.now())){ //已经结束 return Result.fail("秒杀已经结束!"); } //4、判断库存是否充足 if (voucher.getStock() < 1){ return Result.fail("库存不足!"); } //5、减扣库存 这里使用了乐观锁的CAS法 boolean success = seckillVoucherService.update() .setSql("stock = stock - 1") .eq("voucher_id", voucherId) //.eq("stock",voucher.getStock())//where id = ? and stock = ? //CAS法 .gt("stock",0) .update(); if (!success){ //减扣失败 return Result.fail("库存不足!"); } //6、创建订单 VoucherOrder voucherOrder = new VoucherOrder(); //6.1、订单id long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); //6.2、用户id Long userId = UserHolder.getUser().getId(); voucherOrder.setVoucherId(userId); //6.3、代金卷id voucherOrder.setVoucherId(voucherId); save(voucherOrder); //7、返回订单id return Result.ok(orderId); } }
这里做了优化,原来的方式是当stock等于先前查询的数据才进行删减库存的操作,这样做会导致效率低下的问题,所有我们将等于的条件改成大于0的条件,这样可以很好的提高效率。
未完待续
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。