业务功能![](https://i-blog.csdnimg.cn/blog_migrate/0c143391d3707dbbe5c26f2e3cfdc84c.png)
1.短信登录功能
1.1 发送短信验证码
Controller层:
Service层:
UserServiceImpl:
1.浏览器端提交手机号
2.校验手机号
RegexUtils.isPhoneInvalid(phone) //正则表达式工具类
3.若手机号格式不正确,返回错误信息
4.如果格式正确,则生成验证码
RandomUtil.randomNumbers(6) //hutool包下的工具类
5.保存验证码到session
session.setAttribute("code",code)
6.发送验证码(暂未实现)
1.2 短信验证码登录、注册
1.校验手机号
登录时手机号应与验证码对应,以防出现修改了手机号,用同一个验证码还能登录的情况,所以写到session里面的key-value值应该是phone-code,所以在上一步保存验证码到session时,应修改为:
2.校验验证码
3.不一致,报错
4.一致,则根据手机号查询用户
query.eq()为MyBatis-plus中的功能,查询单表
5.若用户不存在,则创建新用户,保存用户到session
创建用户时需要为用户用户手机号和昵称,昵称按照固定格式随机生成,手机号按照前端传过来的phone参数。
6.用户存在,保存用户到session
1.3 登录校验
一、/utils中编写拦截器LoginInterceptor
1.获取session
2.从session中获取用户
3.判断用户是否存在
4.若存在,则将用户保存到TreadLocal中(线程安全,到Tomcat的每个请求都获得一个TreadLocal)
5.若不存在,拦截
6.放行
7.移除TreadLocal中的用户防止内存泄漏
//移除用户
二、/utils中UserHolder类中定义了静态的TreadLocal,直接调用
三、配置拦截器使拦截器生效,并排除拦截器不须拦截的路径。
1.4隐藏用户敏感信息
使服务端返回的信息中只有ID,昵称和头像信息。
定义UserDTO类,该类中只存储以上三种属性,以UserDTO代替User写入session,减少服务器端内存消耗,使保存到TreadLocal中的是UserDTO类,从TreadLocal中取出的也是UserDTO。
1.5集群的session共享问题
多台Tomcat不共享session存储空间,当切换到不同的Tomcat服务器时,导致数据丢失。
session的替代方案应满足:
数据共享
内存存储(保证高频次读写,满足高并发需求)
key-value存储
——————Redis
1.6基于Redis实现短信登录
1.6.1 发送短信验证码
修改部分为:将保存验证码到session修改为保存验证码到Redis。保存phone作为key值时需要添加业务前缀:LOGIN_CODE_KEY="login:code:",设置验证码有效期为两分钟(LOGIN_CODE_TTL).
1.6.2短信验证码登录、注册
修改部分:
1.将从session中获取验证码改为从redis中获取验证码
2.保存用户到session修改为保存用户到redis
~使用UUID生成随机token作为登录令牌
~将userDTO对象转换为Hash存储
~存储对象到redis
~设置token有效期
3.将token返回给客户端
1.6.3登录校验
修改部分:
1.添加一个拦截器拦截所有请求,每次收到请求都对token有效期进行一次刷新
2.获取cookie改为从请求头中获取token
3.基于token获取redis中的用户
4.将redis中获取的hash数据转换为userDTO
5.在第二个拦截器中只对treadlocal中的用户进行判空决定是否放行。
6.在MvcConfig中配置两个拦截器
2 商户查询缓存
2.1添加商户缓存
1.根据客户端携带的商铺ID从Redis中查询缓存
2.若缓存命中,则返回商铺信息
3.若未命中则根据id向数据库中查询信息
4.若数据库中未存在该商铺信息,返回错误信息
5.若存在该商铺信息,则将该信息写入redis,并返回商铺信息给客户端
2.1.1缓存更新策略
内存淘汰策略:自动淘汰哪部分数据不能确定,所以可能存在有的数据一直未被淘汰,当这部分数据在数据库中发生改变时,缓存中数据仍然是旧数据,所以造成数据库和缓存数据不一致。
超市剔除策略:给缓存数据添加过期时间,可以人为控制,但如果在缓存数据有效期内数据库数据发生变化仍然会导致数据的不一致。
主动更新策略:需要自己编码,所以维护成本较高
业务场景:
低一致性需求时采用内存淘汰策略。如商铺类型查询策略,在数据库中长期不变,可以长期存在在缓存中,直到redis主动淘汰。
高一致性需求时采用主动更新策略,并以超时剔除作为兜底方案,比如查询商铺详情缓存,商铺详情中优惠券、菜品、评价等信息可能经常变更,所以需要高一致性。
主动更新缓存策略
Cache Aside Pattern:需要自己编写代码实现在更新数据库的同时更新缓存。
Read/Write Through Pattern:维护该服务相对困难,且市面上无法找到类似的服务,开发困难。
Write Behind Caching Pattern:可以多次写入缓存后再写入数据库,效率较高。但在多次写入缓存的时间内,与数据库中的数据存在数据不一致的情况。其次,若在操作缓存期间宕机,但这部分数据还没有写入数据库,那么该部分数据就会完全丢失,存在数据丢失的风险。
操作缓存和数据库需要考虑的三个问题:
1.删除缓存还是更新缓存
更新缓存:每次更新数据库都更新缓存,若这多次更新操作期间没有查询操作,则产生了多次的无效写操作。
删除缓存:更新数据库时让缓存失效,等到查询时再对缓存更新。
2.如何保证缓存与数据库的操作同时成功或同时失败?
单体系统:将缓存与数据库操作放在一个事务中。
分布式系统:TCC等分布式事务
3.线程安全问题,先操作缓存还是先操作数据库?
先删缓存,再操作数据库:
正常情况下,先删除缓存,再更新数据库,查询时缓存未命中,则查询数据库,并写入缓存。
多线程情况下,可能存在线程1先删除缓存,此时线程2进行查询,未命中,到数据库中查询并写入缓存,此时写入缓存的数据仍然是旧的数据,此后线程1再操作数据库后数据库中的数据与缓存中的数据不一致。由于操作数据库的时间远远长于操作缓存的时间,所以这种情况非常常见。
先操作数据库再删缓存:
正常情况下,先操作数据库,再删除缓存,查询时缓存未命中,则查询数据库,并写入缓存。
多线程情况下,考虑一种特殊情况。线程1进行查询操作,假如恰好此时缓存失效,那么查询未命中,到数据库中查询数据,但还未写入缓存时,切换到线程2,线程2对数据库操作,然后删除缓存,但此时中缓存中没有对应的数据,所以删除无效,切换到线程1,将查询到的数据写入缓存,但此时查询到的仍然是旧数据,所以写入缓存中的仍然是旧数据,导致两方数据不一致。由于操作数据库的时间远远长于操作缓存的时间,所以这种情况并不常见。
2.1.2给查询商铺的缓存添加主动更新的超时剔除策略
修改ShopController的业务逻辑
1.根据id查询店铺,如果未命中,则到数据库查询商铺信息,并写入缓存,设置超时时间。
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
2.先操作数据库,在删除缓存。
@Override
public Result update(Shop shop) {
Long id = shop.getId();
if(id==null){
return Result.fail("商铺不存在!");
}
//1.先操作数据库
updateById(shop);
//2.再删缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
}
2.2 商铺类型缓存
思路同上,注意从缓存中取出时以JSON的格式,返回商铺信息时需要转化成列表,从数据库中查找得到的使列表,写入redis时应转换成JSON的格式。
public Result queryShopType() {
//1.从redis中获取数据
String shopTypeJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_TYPE_KEY);
//2.如果存在,则直接返回
if (StrUtil.isNotBlank(shopTypeJson)){
List<ShopType> shopTypes = JSONUtil.toList(shopTypeJson, ShopType.class);
return Result.ok(shopTypes);
}
//3.如果不存在,则到数据库中查找
List<ShopType> shopTypes=query().orderByAsc("sort").list();
//4.数据库中没有,则报错
if (shopTypes==null){
return Result.fail("首页加载出错!");
}
//5.数据库中存在,则写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_TYPE_KEY,JSONUtil.toJsonStr(shopTypes));
//6.返回
return Result.ok(shopTypes);
}
2.3 缓存穿透
缓存穿透是指客户端请求的数据在缓存中不存在,在数据库中也不存在,这样缓存永远不会生效,这些请求都会被打到数据库。
解决方案:
1.缓存空对象:当出现缓存穿透时就向缓存中写入一个空对象
优点:实现简单,维护方便
缺点:额外的内存消耗,当多次出现缓存穿透时,缓存中就会写入多条空对像,额外占用内存。当写入空对象时,可以为该空对象设置一个有效期,过期自动清除,减少部分内存的消耗。
可能造成短期的数据不一致。假设这样一种情况,将某个不存在的商铺ID并写入缓存。但当该ID的真正的店铺插入数据库时,就是造成数据库与缓存中的数据不一致,直至缓存中该数据到期自动删除。
2.布隆过滤:其余不变,仅在客户端与Redis添加一个布隆过滤器,若数据存在则放行正常执行查询操作,若不存在直接拒绝。
优点:内存占用较少,没有多余key,仅用二进制位的形式存储。
缺点:实现复杂
存在误判可能。布隆过滤器中显示存在的数据在redis中和数据库中可能不存在,由布隆过滤器放行后仍然会出现缓存穿透。
解决缓存穿透:如果在缓存中和数据库中都未查询到数据,这时返回404发生了缓存穿透,需要将此处修改为将空值写入redis。同时在查询数据时,若缓存命中,则需要判断命中的信息是否是空值,若是空值则返回错误信息。
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从Redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否命中
if (StrUtil.isNotBlank(shopJson)) {//StrUtil.isNotBlank()只有在字符串不为空的时候才是true null和""都是false
//3.若命中,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
Result.ok(shop);
}
if(shopJson!=null){ //shopJson==""
//返回错误信息
Result.fail("商铺不存在!");
}
//4.若未命中,根据id查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if (shop==null){
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
Result.fail("店铺不存在!");
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
//7.返回
return Result.ok(shop);
}
2.4缓存雪崩
缓存雪崩是指在同一时段大量的key同时失效或者redis服务宕机,导致大量请求到达数据库,给数据库带来巨大的压力。
解决方案:
1.给不同key的TTL设置随机值。
2.利用redis集群提高服务的可用性
3.给缓存业务添加降级限流的策略
4.给业务添加多级缓存
2.5缓存击穿
缓存击穿问题又叫热点key问题,是一个被高并发访问并且缓存重建业务比较复杂的key突然失效,无数的请求访问会在瞬间给数据库带来巨大冲击。、
如某个热点key突然失效,线程1查询缓存未命中,查询数据库重建数据,其他查询该key的线程执行同样的过程,由于是热点key,同一时间访问的线程可能相当多,这些请求都会同时到达数据库,造成数据库的崩溃。
解决方案:
互斥锁
逻辑过期
互斥锁
假设某个热点key失效,线程1此时查询换粗,未命中,则获取互斥锁进行缓存重建,在写入缓存后释放互斥锁,在此期间对其他线程查询失败后想获取互斥锁失败,就会循环重试,直至线程1释放锁,其他线程命中缓存。因此只有第一个查询缓存失败的线程才会进行缓存重建工作,其他线程均等待,若线程1重建工作耗时较久,其他线程等待时间也越长,性能较差。
根据ID查询商铺,基于互斥锁解决缓存击穿
public Shop queryWithMutex(Long id){
String key = CACHE_SHOP_KEY + id;
//1.从Redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否命中
if (StrUtil.isNotBlank(shopJson)) {//StrUtil.isNotBlank()只有在字符串不为空的时候才是true null和""都是false
//3.若命中,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
if(shopJson!=null){ //shopJson==""
//返回错误信息
return null;
}
//4.实现缓存重建
//4.1获取互斥锁
String lockKey="lock:shop:"+id;
//4.2判断是否获取成功
boolean islock = tryLock(lockKey);
//4.3若失败,则休眠并重试
Shop shop = null;
try {
if(!islock){
Thread.sleep(50);
return queryWithMutex(id);
}
String shopJson2=stringRedisTemplate.opsForValue().get(key);
//再次查询缓存,若命中则直接返回
if(StrUtil.isNotBlank(shopJson2)){
return JSONUtil.toBean(shopJson2,Shop.class);
}
//4.4若成功,根据id查询数据库
shop = getById(id);
//5.不存在,返回错误
if (shop==null){
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
return null;
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//7.释放互斥锁
unLock(lockKey);
}
//8.返回
return shop;
}
逻辑过期
为某个热点key设置一个逻辑过期时间,而不是设置有效期,即这个key在缓存中长期存在,逻辑过期时间只是作为存储对象的一个字段,是当前时间加上过期时间之后的时间,所以只要查询缓存一定能命中,但对比当前查询时间超过逻辑过期时间时,则说明当前数据已过期,需要查询数据库进行更新。假设线程1查询缓存,发现逻辑时间已过期,则获取互斥锁,开启一个新线程进行缓存重建,写入缓存时需要重置逻辑过期时间,释放互斥锁。在线程2进行缓存重建的时间内,线程1返回旧数据,若有其他线程在发现逻辑时间已过期获取互斥锁失败时,说明已有线程在进行缓存的重建,此时他们同样返回旧数据。直至线程2释放锁,在此之后访问缓存的线程访问到的才是新数据。
根据ID查询商铺,基于逻辑过期解决缓存击穿
private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(10);
public Shop queryWithLogicExpire(Long id){
String key = CACHE_SHOP_KEY + id;
//1.从Redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否命中
if (StrUtil.isBlank(shopJson)) {
//3.若未命中,直接返回null
return null;
}
//4.若命中,判断缓存是否过期
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())){
//5.未过期,返回商铺信息
return shop;
}
//6.已过期,进行缓存重建
//6.1获取互斥锁
String lockKey=LOCK_SHOP_KEY+id;
boolean islock = tryLock(lockKey);
//6.2判断互斥锁获取成功或失败
if(islock){
//6.3成功,开启独立线程进行缓存重建
//线程池
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//6.4释放锁
unLock(lockKey);
}
});
}
//7.返回旧信息
return shop;
}
2.6封装工具类
1.将任意java对象序列化为json并存储在String类型的key中,并设置TTL过期时间