面试项目技术点

1.1SpringSecurity认证流程

1.1.1注册

用户输入信息注册,使用PasswordEncoder的实现类BCryptPasswordEncoder对密码进行加密存储到数据库中。

BCrypt是一种基于哈希+随机盐值的方式进行加密。

1.1.2登录认证

登录的业务包括:用于输入用户名+密码–>登录

  1. SpringSecurity的核心是Filter Chain(过滤器链)
  2. 当用户登录发送请求时,首先会被UsernamePasswordAuthenticationFilter过滤器拦截请求,并根据用户名和密码,封装Authentication对象。
  3. 然后通过认证管理器AuthenticationManagerAuthentication对象进行认证。
  4. 认证的过程就是调用UserDetailsService接口的实现类当中的loadUserByUsername方法。
  5. loadUserByUsername方法当中可以根据用户名查询数据库查询到对应的用户信息和权限信息,最后封装为UserDetails对象返回。
  6. 通过DaoAuthenticationProvider对象将UserDetails对象进行处理。包括通过PasswordEncode对比UserDetails中的密码与Authentication中密码是否一致,如果不一致则认证失败。如果认证成功,填充用户信息和授权信息,封装为Authentication对象返回。
  7. 将从Authentication对象中获取到用户信息和权限信息存入redis缓存。
  8. 并根据用户主键生成Token加入返回给客户端浏览器。
  9. 用户登录成功。

下面是DaoAuthencitionProvider使用PasswordEncoder对用户密码进行验证。

image-20230827171046751

1.1.3后续请求认证

  1. 创建token拦截器。
  2. 从请求头当中获取到token,如果不存在token则放行(1.不需要认证的请求 2.需要认证但是未携带token)。
  3. 存在token,根据用户id查询redis缓存中对应的用户信息和权限信息,将其存入到SpringSecurity的上下文对象SecurityContextHolder当中。后续的授权就是FilterSecurityInterceptorSecurityContextHolder获取到对应的权限信息。

SpringSecurity认证流程

1.2SpringSecurity鉴权流程

鉴权就是使用的是SpringSecurity的过滤器链当中的FilterSecurityInterceptorSecurityContextHolder获取到对应的权限信息。开启鉴权的方案有两种。

  1. 方法级安全管控

    • 在SpringSecurity的配置类中添加注解@EnableGlobaMethodSecurity开启方法级安全管控。@EnableGlobalMethodSecurity注解的属性prePostEnabled = true 会解锁 @PreAuthorize @PostAuthorize 两个注解。**@PreAuthorize 注解会在方法执行前进行验证,而 @PostAuthorize 注解会在方法执行后进行验证。**
    • 其中对应的属性hasAuthority就可以设置对应的权限字符串。
  2. 配置文件配置接口鉴权

    • SpringSecurity的配置类(实现了WebSecurityConfigurerAdapter 接口)中的configure中配置对应接口的鉴权规则。

1.3自定义失败处理

认证授权失败都是由SpringSecurity的过滤器链当中的ExceptionTranslationFilter所捕获。

  1. 认证异常:实现AuthenticationEntryPoint接口来实现自定义认证异常处理。
  2. 授权异常:实现AccessDeniedHandler接口来处理授权异常处理。

2.下单业务

  • 负责订单模块下单业务,采用令牌机制保证下单接口的幂等性,采用延迟队列完成定时关单。

2.1接口幂等性

  1. 接口幂等性概述

    接口幂等性就是用户的一次请求和多次请求产生的结果是相同的,不会因为多次点击差生不同的结果。

  2. 哪些情况需要保证接口幂等性

    • 用户的多次点击
    • 页面回退再提交
  3. 如何保证接口幂等性

    • 令牌机制

      • 服务端提供了发送 token 的接口。我们在分析业务的时候,哪些业务是存在幂等问题的, 就必须在执行业务前,先去获取 token,服务器会把 token 保存到 redis 中。

      • 然后调用业务接口请求时,把 token 携带过去,一般放在请求头部。

      • 服务器判断 token 是否存在 redis 中,存在表示第一次请求,然后删除 token,继续执行业务。

      • 如果判断 token 不存在 redis 中,就表示是重复操作,直接返回重复标记给 client,这样就保证了业务代码,不被重复执行。

      • 但是令牌机制存在一些问题,在高并发情况下应该确保查询缓存、对比令牌和删除缓存是原子性的,可以采用Lua脚本来实现。

      if redis.call('get', KEYS[1]) == ARGV[1] 
          then 
          	return redis.call('del', KEYS[1]) 
          else 
              return 0 
          end
    • 锁机制

      • 数据库悲观锁

      • 数据库乐观锁机制

      • 分布式锁

        采用分布式锁的方案就是,当执行一个下单的请求的时候,会获取到分布式锁,同一订单只能被处理一次,从而确保了下单接口的幂等性。在释放锁之前,其他请求将会被阻塞,等待当前请求完成。

    • 数据库层面的唯一约束

      • 唯一约束
      • 主键约束

image-20230830121129637

下面的业务就是当响应订单页的时候,返回前端防重令牌

    /**
     * 返回确认订单页所需的数据
     *
     * @return {@link OrderConfirmVO}
     */
    @Override
    public OrderConfirmVO confirmOrder() throws ExecutionException, InterruptedException {
//        获取到当前线程的请求数据
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        OrderConfirmVO orderConfirmVO = new OrderConfirmVO();
//        1.获取到当前登录用户的id
        MemberTO memberTO = LoginInterceptor.threadLoginUser.get();
//        2.远程调用会员服务,根据当前用户id查询用户的收货地址列表
        ...
//        3.远程调用购物车服务,得到当前的购物项列表
        ...
//        4.设置商品总额
        ...

//        5.设置用户积分
        ...

//        6.设置防重令牌
        String token = IdUtil.simpleUUID();
//        7.缓存防重令牌
        stringRedisTemplate.opsForValue().set(CacheConstants.USER_ORDER_TOKEN_CACHE + memberTO.getId(),
                token,
                30,
                TimeUnit.MINUTES);
        //8.响应给前端
        orderConfirmVO.setOrderToken(token);

        CompletableFuture.allOf(receiveAddressFuture,
                orderItemListFuture,
                skuTotalPriceFuture).get();
        return orderConfirmVO;
    }
/**
     * 提交订单
     *
     * @param orderSubmitDTO 订单提交dto
     * @return {@link SubmitOrderResponseVO}
     */
    @Transactional
    @Override
    public SubmitOrderResponseVO submitOrder(OrderSubmitDTO orderSubmitDTO) {
        threadLocal.set(orderSubmitDTO);
        SubmitOrderResponseVO submitOrderResponseVO = new SubmitOrderResponseVO();
//        1.获取到当前登录的用户
        MemberTO memberTO = LoginInterceptor.threadLoginUser.get();
        String orderToken = orderSubmitDTO.getOrderToken();
//        2.使用lua脚本验证订单token(查询令牌、验证令牌和删除令牌保证原子性)
//        检查 Redis 中存储在指定键中的值是否等于提供的参数。
//        如果相等,它将删除该键并返回 1(因为 DEL 命令返回被删除键的数量),
//        如果不相等,则返回 0 表示没有执行删除操作。
        String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Long executeResult = stringRedisTemplate.execute(
                new DefaultRedisScript<Long>(luaScript, Long.class),
                Arrays.asList(CacheConstants.USER_ORDER_TOKEN_CACHE + memberTO.getId()),
                orderToken);
        if (executeResult == 0L) {
            submitOrderResponseVO.setCode(0);
//            2.1令牌验证失败
            return submitOrderResponseVO;
        } else {
//            2.2令牌验证成功,创建订单

//        3.保存订单和订单项
//            3.1保存订单

//            3.2保存订单项
            ...
//        4.锁定库存
//            4.1封装WareSkuLockTO对象
            ...
//            4.2调用库存服务,锁定库存
            ...
//          5.订单创建成功,发送消息到rabbitmq的延时队列
                ...

        }
        return submitOrderResponseVO;
    }

2.2锁定库存

下单成功后,需要去扣减库存,但是不能直接扣减库存,因为在用户下单之后,未付款之前,用户可能会取消订单或者是未付款直至订单超时。

所以应该先锁定库存,确保本次下单有足够的库存。

锁定库存操作采用乐观锁机制,stock - stock_locked >= #{count} 部分用于确保在更新数据时,所需的库存仍然充足。这样的确可以作为一种乐观锁的实现,因为它在更新之前检查了条件,以确保库存不会因为并发操作而不正确地减少。如果库存不足,更新操作就会失败,从而避免了超卖的问题。

<update id="lockWare">
    UPDATE wms_ware_sku
    SET stock_locked = stock_locked + #{count}
    WHERE
        sku_id = #{skuId}
      AND ware_id = #{wareId}
      AND stock - stock_locked >= #{count}
</update>

并将锁定的库存信息放入到消息队列当中,用于当订单取消订单未支付时解锁库存。

2.3可靠消息+最终一致性解决分布式事务

可靠消息+最终一致性解决方案是对BASE理论的实现,就是基于消息队列来完成的,适用于高并发的场景。

在业务处理服务中,事务未提交之前,将消息数据存入到消息队列当中,消息队列对消息数据进行处理,这种方式不能够保证数据的强一致性,但是只要能够保证消息的可靠消费,就能够保证最终一致性,并且这种方式是异步的,能够提升系统的并发性能。

未命名文件 (34)

上图中,当关单消费者关单后,将对应的订单消息发送到库存解锁队列当中,由对应的解锁库存消费者解锁库存,即如果库存工作单处于锁定状态就进行解锁。

目的是为了防止由于超时关单处理过慢,库存解锁先行 执行,发现订单状态为未支付,就不做处理,导致库存不能够解锁。

未命名文件 (2)

具体流程图如下:

未命名文件 (3)

2.4.1延迟队列定时关单

延迟队列就是基于死信队列实现的

  • 一共有一个交换机和两个队列,其中一个是延迟队列,一个是处理死信消息的队列
  • 交换机分别和两个队列绑定,并且在延迟队列当中配置死信交换机死信routing-key消息的TTL
  • 创建订单后,将订单的信息发送到交换机,然后路由到对应的延迟队列,当消息过期之后,发送到死信交换机(就是当前交换机),并路由到对应的处理死信消息的队列,最后由对应的消费者进行关单。

2.4.2延迟队列解锁库存

下单成功后会锁定库存,在锁定库存成功后,将锁定的库存信息(库存工作单:订单号、商品列表和商品锁定的库存)持久化并放入到延迟队列当中。

库存解锁的场景

  1. 下单成功,但是订单过期被系统自动取消,或被用户手动取消
  2. 下单成功,库存锁定成功,但是接下来的业务调用失败,导致订单回滚

消费者监听库存的延迟队列:

  1. 首先判断库存工作单是否存在,不存在,则不用释放库存,因为是库存服务自己回滚
  2. 判断库存工作单中的订单是否存在,不存在则表示订单在调用其他服务时出错回滚,释放库存
  3. 判断订单的状态,订单已支付,不用释放库存,订单已取消(自动取消or手动取消),释放库存

image-20230831163931903

3.项目模块

  1. 网关模块
  2. 曲谱模块
  3. 用户模块
  4. 检索模块
  5. 第三方服务模块
  6. 消息模块
  7. 订单模块
  8. 购物车模块
  9. 商品模块

面试项目技术点
https://xhablog.online/2023/02/22/1.面试项目技术点/
作者
Xu huaiang
发布于
2023年2月22日
许可协议