黑马点评
黑马点评
主要是redis考察
短信登录
短信登录/注册流程?
发送验证码:首先针对客户端的手机号,双端建立连接,服务端随机生成一个随机数然后保存到session中,通过一些别的服务发送到客户端。
登录/注册:客户端输入验证码登录后,服务端需要校验验证码是否正确,正确则根据手机号查询用户对象,不存在则创建用户对象(用户注册)。最终把用户休尼希保存到session中。
登录校验流程?
登录校验:首先根据sessionid查询session,如果存在用户则放在TreadLocal中,如果session中不存用户信息则返回错误信息。
传统基于session的登录校验在nginx集群中为什么不行?
首先客户端发送各请求时都会基于拦截器先发送cookie,服务器会根据cookie中携带的sessionid查询服务器内的session,看seesion内是否存在用户对象。然而大型项目服务器一般是nginx集群,这就导致nginx集群中别的无法获取session信息,所以session无法校验。
基于redis的登录?
区别1:发验证码时,生成的验证码保存到redis中而不是session中,而且redis可以配置过期时间,所以可以设置验证码的过期时间。
区别2:登录/注册时,查询用户对象保存到redis中而不是session中。一般采用redis中的Hash结构存储,以随机token作为key。(比json更存储内存消耗小,方便crud)。且需要将token传到前端(不同于seesion登录)
区别3:登录校验:前端将token存储在sessionStorage中,每次发送请求携带token。sessionStorage 是一种 Web 存储 API,它允许 Web 应用程序在用户浏览器中存储键值对数据,并且这些数据只在单个浏览器标签页中有效,关闭则消失,与之相对的是localStorage,不会消失。
登录校验拦截器实现?
首先需要自定义一个拦截器,实现HandlerInterceptor接口,实现preHandle方法。并且需要两个拦截器,一个拦截所有请求并且刷新token有效期的拦截器,一个拦截需要登录校验的拦截器(放在最后面)。
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 {
//1. 获取请求头中token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
//2. 基于token 获取redis用户
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
//3. 判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
// 刷新token有效期
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.HOURS);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
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;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
然后需要在配置类中注册拦截器:
@RequiredArgsConstructor
@Configuration
public class MvcConfig implements WebMvcConfigurer {
private final StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new loginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**"
).order(1);
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate));
}
}
WebMvcConfigurer 是 Spring MVC 提供的一个接口,用于通过 Java 配置来自定义 Spring MVC 的行为。
小知识点:非spring组件类,自己写的类中不能使用@Bean,@Resource注解进行注入,只能使用构造函数。
缓存
缓存商铺信息知识点
存入redis中需要把对象序列化:
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop));
其中序列化的结果仍然是String对象。
判断序列化后的String对象是否为空,可以使用hutool工具包:
return StrUtil.isBlank(shopJson);
缓存更新策略有哪些
总共分为三个:内存淘汰,超时淘汰,以及主动更新。其中只有主动更新可以维持一致性。维护成本逐步增加。
最佳实践方案 :
- 低一致性需求:使用自带的内存淘汰机制。
- 高一致性需求:主动更新,超时淘汰作为兜底。
- 读操作:缓存命中直接返回,缓存未命中,先查数据库,查到再写入缓存并设置超时时间。
- 写操作:先写数据库再写缓存,安全性更高。且需要标志事务,保持原子性。
什么是缓存穿透?
使用不存在的数据不断的访问数据库,造成资源消耗。
解决方案:
- 缓存空值:将null值写入缓存,下次查询直接返回null。缺点是redis里缓存了很多垃圾,造成了内存消耗。而且可能造成短期的不一致。
- 布隆过滤:在客户端和Redis中加一个布隆过滤器,将数据库中数据基于哈希算法存储到布隆过滤器中,当客户端查询数据时,先判断数据是否存在于布隆过滤器中。(因为存在哈希碰撞,所以布隆过滤器不能保证100%准确)
- 增加id复杂度,主动校验id合法性。
什么是缓存雪崩?
同一时间段大量数据过期,或者Redis服务器宕机,导致击穿。
解决方案:
- 给不同的key的ttl加入随机数。
- Redis集群。
- 多级缓存。
- 服务降级。(spring cloud)
什么是缓存击穿?
缓存击穿也叫热点keyMiss。高并发访问并且缓存复杂的key突然失效。无数的访问瞬间进入数据库。
解决方案:
- 互斥锁:只有拿到互斥锁的线程才能访问数据库并重建缓存,其他线程等待。缺点:线程需要等待,有死锁风险。
- 逻辑过期: 设置一个逻辑过期时间,线程先判断是否过期,未过期则返回缓存,过期则加锁,并让另一个线程重建缓存,自己仍然返回旧缓存。缺点:需要额外内存保存逻辑过期时间,不能保证一致性。
秒杀系统
基于Redis全局ID生成器
生成对象为Long类型,第一个为符号位,后面31位为时间戳,最后32位为自增ID。
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2. 生成序列号
String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
Long count = stringRedisTemplate.opsForValue().increment("inc" + keyPrefix + ":" + data);
//3. 拼接返回
return timestamp << 32 | count;
}
此外还有雪花算法。
超卖问题?
悲观锁,乐观锁:
悲观锁:认为线程安全问题一定会发生,因此操作之前都会获取锁,保证串行执行。例如Synchronized,Lock。
//需要索住整个函数,解决一人一单问题。
synchronized (UserHolder.getUser().getId().toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
需要加入注解:
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
启动类需要加入:@EnableAspectJAutoProxy(exposeProxy = true)
乐观锁:认为线程安全问题不一定会发生,因此不会加锁,这是更新数据时判断数据是否被修改过,如果被修改过则重试。常见的实现方法有两种:1.版本号,2. CAS法,先查询下库存。缺点:
成功率低,能成功的时候会失败。用分段锁解决。
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
// 乐观锁CAS
.eq("stock", voucher.getStock();)
.update();
分布式锁?
分布式锁:肯定不能再使用JVM的锁,使用外部锁也就是Redis。
- 利用Redis的setnx命令,设置过期时间,容易发生超时问题,会导致锁不住。
- 类似于乐观锁,redis额外是指一个随机key, 释放锁时判断key是否一致。但并不是原子操作,如果出现阻塞,仍然会锁不住。必须保持锁判断和锁释放的原子性。
- 使用lua脚本进一步优化原子性。
// 自定义分布锁
SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
if (redisLock.tryLock(20L)) {
try {
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
redisLock.unlock();
}
}
return Result.fail("获取锁失败");
以上三种基于Redis的分布式锁都存在以下问题:
- 不可重入,同一线程无法多次获取一把锁,可能会导致死锁。
- 不可重试,获取锁时候无法重试。
- 超时释放。
- 主从一致性,如果Redis提供主从集群,(写操作主节点,读操作从节点),主从数据同步存在延迟。
Redisson
Redisson是一个在Redis的基础上封装的Java对象,提供了各种分布式的集合、锁、队列等。
通过引入依赖并且配置配置类实现,使用步骤:getlock().tryLock(),unlock()。
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.5</version>
</dependency>
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.228.132:6379");
return Redisson.create(config);
}
}
Redisson原理:
如何解决可重入问题:利用hash结构记录线程id和冲入次数。基于ReentrantLock,在加锁的时候,会判断当前线程是否已经获得锁,如果已经获得锁,会判断是否是自己上的锁,如果是自己上的锁,则计数器加一,否则抛出异常。
当解锁时要判断计数器是否为0,如果为0,则释放锁并发布通知,否则计数器减一。
如何解决可重试问题:利用信号量和PubSub功能实现等待、唤醒、获取锁失败的重试机制。tryLock可加参数实现可重试,如果不给参数,默认30s,在等待事件中会订阅锁释放事件,如果释放了且在时间内,则返回true,否则判断是否有等于时间返回false。
如何解决锁超时问题:利用watchdog,每隔一段时间重置超时时间。如果获取锁的时候,发现过期了(基于一个map,里面现在的entry属性和oldentry属性,如果发现oldentry为空了就是过期了。)有一个反复递归本身的函数,
递归自己实现不断创建有效期,实现永不超时。当一个锁释放时,需要把map里的entry啥的全删掉。
如何解决主从一致性:多个独立的redis节点,必须所有的节点都获得重入锁,才算获取成功。主从一致性基于redis哨兵,采取redis集群加主从的架构:即多个主,多个从,主和从一一对应。
如何实现秒杀?
- 预热:提前将库存预热到Redis中,减少数据库的访问。
- 基于Lua脚本,判断秒杀库存,一人一单,决定用户是否抢购成功。
- 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列。
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单。
传统基于分布式锁的算法会导致数据库压力过大。
小功能实现
共同关注如何实现
首先是需要有一个关注存放的表,存两个用户id。然后需要有一个前置功能也就是关注,基于redis的set实现。
也就是redis中同时存储了关注信息,共同关注通过set的交集实现opsForSet().intersect。
点赞功能如何实现?
点赞功能基于redis的zset实现,主要是利用zset的排序功能。
Feed流实现
- 拉模式: 也叫读扩散,基于发件箱和收件箱,发件用户将内容发到发件箱内,需要读取的用户依据关注列表,发布时间从收件箱中拉去。
主要缺点是读取延迟较高,收件箱需要现拉。 - 推模式: 也叫写扩散,没有发件箱,只有收件箱,发件用户直接将内容发到粉丝的收件箱内。缺点是内存占用较大,一个消息要写n份给粉丝用户。
- 推拉结合模式: 也叫读写混合。普通人使用推模式,推到别人的收件箱内,而大V只将信息推到活跃粉丝的收件箱,其他粉丝则从发件箱中拉取。
Feed流的分页模式
因为Feed流数据不断更新,因此不能采用传统的分页方式,需要使用滑动窗口。查询不需要角标。
list数据结构无法实现。如果需要分页需要采用zset,因为其分数功能,每次查询可以查询一定分数范围内的数据。从而进行分页。
// 滚动分页包装
public class ScrollResult {
// 数据
private List<?> list;
// 每次记录的最小时间戳
private Long minTime;
// 偏移量,最小时间戳对应的数据量
private Integer offset;
}
// redis查询
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
用户签到怎么做?
用redis里的bitmap实现,需要注意的是,需要计算连续签到天数的问题,位运算符号应为>>>而不是>>,>>>会在左侧填加0,防止全是1导致无法判断截至。
UV统计?HyperLogLog的用法
UV,全称为Unique Visitor,即独立访客,看用户访问量。PV,全程为Page View,即页面浏览量,一个用户可以多次访问页面,造成多PV。
基于UV与PV求比,可以判断网站质量(用户点击量怎么样)。
hll算法是基于似然估计的算法,通过记录哈希后末尾连续0的次数来估计现在遇到了多少用户。