黑马点评项目总结

Posted wazouu

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了黑马点评项目总结相关的知识,希望对你有一定的参考价值。

黑马点评

一、短信登陆功能

1.基于session实现

2.基于session实现登陆的问题

单体应用时用户的会话信息保存在session中,session存在于服务器端的内存中,由于前前后后用户只针对一个web服务器,所以没啥问题。但是一到了web服务器集群的环境下(我们一般都是用nginx做负载均衡,若是使用了轮询等这种请求分配策略),就会导致用户小a在A服务器登录了,session存在于A服务器中,但是第二次请求被分配到了B服务器,由于B服务器中没有用户小a的session会话,导致用户小a还要再登陆一次,以此类推。这样用户体验很不好。当然解决办法也有很多种,比如同一个用户分配到同一个服务处理、使用cookie保持用户会话信息等。
因此,要解决这样的问题必须满足以下条件:

  • 数据共享
  • 内存存储
  • key、value结构

3.基于redis实现短信登陆

发送验证码:

/**
 * 发送手机验证码
 */
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) 
    return userService.sendCode(phone,session);


@Override
public Result sendCode(String phone, HttpSession session) 
    //1.校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) 
        //2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");

    
    //3.符合则生成验证码
    final String code = RandomUtil.randomNumbers(6);
    //4.保存验证码到redis
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
    //5.发送验证码
    log.debug("发送短信验证码成功,验证码:",code);
    //6.返回null
    return Result.ok();

验证登陆功能:
login方法会把生成的token返回给前端,浏览器会将其保存到session中。

/**
 * 登录功能
 * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
 */
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session)
    return userService.login(loginForm,session);


@Override
public Result login(LoginFormDTO loginForm, HttpSession session) 
    //1.校验手机号
    final String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) 
        //2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");

    
    //2.校验验证码,从redis中获取
    final String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
    final String code = loginForm.getCode();
    if(cacheCode==null||!cacheCode.equals(code))
        //3.不一直,报错
        return Result.fail("验证码错误");
    
    //4.一致,根据手机号查询用户
    User user = query().eq("phone", phone).one();
    //5.判断用户是否存在
    if (user == null) 
        //6.不存在,创建新用户并保存
        user = createUserWithPhone(phone);
    


    //7.保存用户信息到redis中
    //7.1随机生成token,作为登陆令牌
    String token = UUID.randomUUID().toString(true);
    //7.2将User对象转为HashMap存储
    UserDTO userDTO = BeanUtil.copyProperties(user,UserDTO.class);
    final Map<String, Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>(),
            CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName,fieldValue)->
                return fieldValue.toString();
            )
    );

    //7.3存储
    stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,map);
    //7.4设置token有效期
    stringRedisTemplate.expire(LOGIN_USER_KEY+token,3000,TimeUnit.MINUTES);

    //8.返回token
    return Result.ok(token);



private User createUserWithPhone(String phone) 
    User user = new User();
    user.setPhone(phone);
    user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(5));
    save(user);
    return user;

这里使用redis的hash结构存储user信息,原因是:

  • 若使用String结构,以JSON字符串来保存,比较直观
  • 但Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD,并且内存占用更少


拦截器:

  • 首先,对于每个请求,我们首先根据token判断用户是否已经登陆(是否已经保存到ThreadLocal中),如果没有登陆,放行交给登陆拦截器去做,如果已经登陆,刷新token的有效期,然后放行。
  • 之后来到登陆拦截器,如果ThreadLocal没有用户,说明没有登陆,拦截,否则放行。

定义UserHolder工具类:

public class UserHolder 
    private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();

    public static void saveUser(UserDTO user)
        tl.set(user);
    

    public static UserDTO getUser()
        return tl.get();
    

    public static void removeUser()
        tl.remove();
    

刷新token拦截器:

@Slf4j
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
        final String token = request.getHeader("authorization");
        if (token == null) 
            return true;
        
        //2.获取redis中的用户
        final Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
        //3.判断用户是否存在
        if (userMap.isEmpty()) 
            return true;
        
        //5.将查询到的Hash数据转换为UserDto对象
        final UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        //6.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);
        //7.刷新token有效期
        stringRedisTemplate.expire(LOGIN_USER_KEY+token,3000, TimeUnit.MINUTES);
        //8.放行
        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 
        //1.判断是否需要拦截(ThreadLocal中是否有用户)
        if(UserHolder.getUser()==null)
            response.setStatus(401);
            return false;
        
        //8.放行
        return true;
    

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception 
        UserHolder.removeUser();
    


在配置类中配置拦截器:


@Configuration
public class MvcConfig implements WebMvcConfigurer 
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) 
        //登陆拦截器
        registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
                "/user/code","/user/login","/blog/hot","/shop/**","/shop-type/**","/upload/**"
                ,"/voucher/**"
        ).order(1);
        //token属性的拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    


4.补充ThreadLocal相关知识

a.ThreadLocal的数据结构

  • Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,也就是说每个线程有一个自己的ThreadLocalMap。

  • ThreadLocalMap有自己的独立实现,可以简单地将它的key视作ThreadLocal,value为代码中放入的值(实际上key并不是ThreadLocal本身,而是它的一个弱引用)。

  • 每个线程在往ThreadLocal里放值的时候,都会往自己的ThreadLocalMap里存,读也是以ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。

  • ThreadLocalMap有点类似HashMap的结构,只是HashMap是由数组+链表实现的,而ThreadLocalMap中并没有链表结构。

  • 我们还要注意Entry, 它的key是ThreadLocal<?> k ,继承自WeakReference, 也就是我们常说的弱引用类型。

b.内存泄露问题

由于ThreadLocal的key是弱引用,故在gc时,key会被回收掉,但是value是强引用没有被回收,所以在我们拦截器的方法里必须手动remove()。

二、redis缓存

1.选择缓存更新策略


项目选择了主动更新策略,相对较好,主动更新又有以下三种方式:

选择在更新数据库的同时更新缓存。
操作缓存和数据库时有三个问题需要考虑:

  • 删除缓存还是更新缓存?
    更新缓存:每次更新数据库都更新缓存,无效写操作较多
    删除缓存:更新数据库时让缓存失效,查询时再更新缓存
  • 如何保证缓存与数据库的操作的同时成功或失败?
    单体系统,将缓存与数据库操作放在一个事务
    分布式系统,利用TCC等分布式事务方案
  • 先操作缓存还是先操作数据库?

若先删除缓存,再操作数据库:

请求1先把缓存中的A数据删除,请求2从db中读数据,请求1再把db中的A更新

若先操作数据库,再删除缓存:

请求1从db中读取数据A,请求2随后更新db中的数据(缓存中由于没有数据,所以不需要删除),最后请求1更新缓存。

可以看出两种方法都有各自的问题,但是由于写的时间要远大于读的时间,所以先操作db再删除cache的出现问题的几率非常小。

2.业务逻辑

  • 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
  • 根据id修改店铺时,先修改数据库,再删除缓存

3.缓存存在的问题

a.缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种:

  1. 缓存空对象
    优点:实现简单,维护方便
    缺点:额外的内存消耗,可能造成短期的不一致
    适合命中不高,但可能被频繁更新的数据
  2. 布隆过滤
    优点:内存占用较少,没有多余key
    缺点:实现复杂,存在误判可能
    适合命中不高,但是更新不频繁的数据

    解决方案:
/**
* 缓存穿透方法
 * @param id
 * @return
 */
public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Long time, TimeUnit unit,Function<ID,R> dbFallback)
    String key = keyPrefix+id;
    //1.从redis查询商铺缓存
    String json = stringRedisTemplate.opsForValue().get(key);
    //2.判断是否存在
    if (StrUtil.isNotBlank(json)) 
        //3.存在,直接返回
        return JSONUtil.toBean(json, type);
    
    //命中的是否是空值
    if (json != null) 
        return null;
    

    //4.不存在,根据id查询数据库
    R r = dbFallback.apply(id);
    //5.不存在,返回错误
    if(r==null)
        //将空值写入reddis
        stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
        return null;
    
    //6.存在,写入redis
    this.set(key,r,time,unit);
    //7.返回
    return r;

b.缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:

  • 给不同的Key的TTL添加随机值
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

c.缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期

4.基于逻辑过期解决缓存击穿问题

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
 * 逻辑过期解决缓存击穿
 * @param id
 * @return
 */
public <R,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type, Long time, TimeUnit unit,Function<ID,R> dbFallback)
    String key = keyPrefix+id;
    //1.从redis查询商铺缓存
    String json = stringRedisTemplate.opsForValue().get(key);
    //2.判断是否存在
    if (StrUtil.isBlank(json)) 
        //3.不存在,直接返回
        return null;
    

    //4.命中,先把json反序列化
    RedisData redisData = JSONUtil.toBean(json, RedisData.class);
    JSONObject data = (JSONObject) redisData.getData();
    R r = JSONUtil.toBean(data, type);
    LocalDateTime expireTime = redisData.getExpireTime();
    //5.判断是否过期
    if(expireTime.isAfter(LocalDateTime.now()))
        //5.1未过期,直接返回
        return r;
    

    //5.2已过期,需要缓存重建

    //6.缓存重建
    //6.1获取互斥锁
    String lockkey = LOCK_SHOP_KEY + id;
    boolean lock = tryLock(lockkey);
    //6.2判断是否获取锁成功
    if(lock)
        //6.3成功,开启独立线程,实现缓存重建
        CACHE_REBUILD_EXECUTOR.submit(()->
            try 
                //查询数据库
                R r1 = dbFallback.apply(id);
                //写入redis
                this.setWithLogicalExpire(key,r1,time,unit);
             catch (Exception e) 
                e.printStackTrace();
             finally 
                //释放锁
                unlock(lockkey);
            
        );
    

    //6.4返回商铺信息
    return r;

三、优惠券秒杀

1.优惠券秒杀下单

一般流程:

2.超卖问题

请求a查询库存,发现库存为1,请求b这时也来查询库存,库存也为1,然后请求a让数据库减1,这时候b查询到的仍然是1,也继续让库存减1,就会导致超卖。

超卖问题有以下几个解决方案:

  • 乐观锁:认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。如果没有修改则认为是安全的,自己才更新数据。如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。
  • 悲观锁:认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock都属于悲观锁

实现乐观锁主要有以下两种方法:

  1. 版本号法

每次更新数据库的时候按照版本查询,并且要更新版本。

  1. CAS

CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换
CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。
更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。

CAS的缺点:

1.CPU开销较大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。

2.不能保证代码块的原子性
CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。

3.一人一单功能

要求同一个优惠券,一个用户只能下一单


这样的方式会产生并发安全问题:

通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了(每个jvm都有自己的锁监视器,集群模式下各个服务器的锁不共享)。
因此,我们的解决方案就是实现一个共享的锁监视器,即:
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

4.基于redis的分布式锁

a.setnx命令

setnx = SET if Not eXists

  • 将 key 的值设为 value ,当且仅当 key 不存在。

  • 若给定的 key 已经存在,则 SETNX 不做任何动作

黑马瑞吉外卖项目部分知识总结,对项目中的难点、不熟悉内容、项目亮点以及bug进行记录与分享。以及项目中用到的设计思想进行总结

黑马瑞吉外卖项目部分知识总结,对项目中的难点、不熟悉内容、项目亮点以及bug进行记录与分享。以及项目中用到的设计思想进行总结

以上是关于黑马点评项目总结的主要内容,如果未能解决你的问题,请参考以下文章

Redis | 黑马点评 + 思维导图分布式锁

微服务 SpringBoot 整合 Redis GEO 实现附近商户功能

黑马瑞吉外卖——

[刘阳Java]_美团点评2018届校招面试总结_Java后台开发转载

Java开发快速学习!黑马java项目实战

黑马程序员项目传智健康项目预约管理-套餐管理