场景设计校招面经整理、热身

1. 用户登录系统:

  • 表结构设计
    • 为了支持多种方式用户登录,并且隔离敏感数据(如密码),防止接口暴露而泄露,我们需要分表
    • 将用户的一些非敏感信息集合在一个 Profile 表中,字段如下: user_id(主键) | name | birthday | …
    • 用户名和密码单独维护在一个 LocalAuth 表中,字段如下: user_id(主键) | userName | password
    • 其他 OAuth 协议登录方式可以集成在一张 OAuth 表中,因为一个用户可能对应多种认证登录方式,不能再以 user_id 作为主键,改用一个自增 id,字段如下: id(主键) | user_id | oauth_name(比如 weibo、qq 等) | oauth_id | oauth_access_token | oauth_expires
    • 其他登录方式类似,可以新建一个表,单独存储该种登录所需的相关信息,所有表均通过 user_id 关联到 Profile 表中
  • 认证实现过程
    • 用户选择任意一种登录方式(用户名密码、第三方 OAuth、HTTP Authorization Header、API-Token等),将用户导向到登录 URL,用户以该方式登录成功后,创建一个 Cookie,利用该 Cookie 标记用户
    • 设计一个统一的 Authenticator 接口
      1
      2
      3
      public interface Authenticator {
      User authenticate(HttpServletRequest request, HttpServletResponse response) throws AuthenticateException;
      }
    • 每一种类型的认证登录,都要用一个类实现这个接口,如表单登录 Cookie 认证
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      public LocalCookieAuthenticator implements Authenticator {
      User authenticate(HttpServletRequest request, HttpServletResponse response) throws AuthenticateException {
      // 向用户请求一个 Cookie
      String cookie = getCookieFromRequest(request, 'cookieName');
      if (cookie == null) {
      return null;
      }
      return getUserByCookie(cookie);
      }
      }
    • 然后用一个统一的入口接口,将这类登录接口以线性表形式串起来,依次尝试认证
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      public class GlobalFilter implements Filter {
      // 写一个 initAuthenticators() 方法,将前面各种 Authenticator 组织成一个数组后返回
      Authenticator[] authenticators = initAuthenticators();

      // 遍历各种 Authenticator 直到有一种方式认证登录成功
      public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
      User user = null;
      for (Authenticator auth : authenticators) {
      user = auth.authenticate(request, response);
      if (user != null) {
      break;
      }
      }

      // 把 User 绑定到 UserContext 中,UserContext 是一个与业务逻辑相关的类,关于绑定的实现详见下文
      try (UserContext context = new UserContext(user)) {
      chain.doFilter(request, response);
      }
      }
      }
  • 生成可信的 Cookie
    • 用一个单向的加密函数生成,如 md5
      • 以用户名密码登录为例, [userName] : [md5(password)] 形式的串构成的 Cookie 发送给客户端;
      • 服务器分解该 Cookie 得到用户名和 密码的 md5 值,从 LocalAuth 表中查到密码并计算出 md5 值,进行比对,若一致则有效。
      • 以上方案基本成型,缺点在于,一是即使是 md5 也容易被攻击破解(彩虹表等);二是用户名在 Cookie 中是明文存储,也会造成信息泄露;三是 Cookie 没有设置有效期,永久有效是不能接受的。
    • 改进:增加一些随机值进入 md5 的参数中(例如除了口令以外,增加当前时间戳 + 固定过期时长,增加一个随机产生的字符串),同时在 Cookie 明文中用 user_id 取代 userName
      • 例如参数可以设计成如下形式 [user_id] : [password] : [stamp] : [randomString]
      • 那么最后拼接而成的 Cookie 就是 [user_id] : [stamp] : [md5(前述参数)]
  • 绑定用户
    • 需要将 user 信息绑定到当前线程,方便我们进行如下调用
      1
      User user = UserContext.current.get();
    • 具体实现时,用一个 UserContext 类封装实现细节
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      public class UserContext implements AutoCloseable {
      static final ThreadLocal<User> current = new ThreadLocal<User>();

      public UserContext(User user) {
      current.set(user);
      }

      public static User getCurrentUser() {
      return current.get();
      }

      public void close() {
      current.remove();
      }
      }

2. 秒杀系统:

  • 系统大致可分为如下层次: 浏览器端、站点层、服务层、数据层(数据库如 MySQL 所在层),基本思路:请求拦截、缓存
    • 尽可能将请求拦截在上游(上游即靠前的层次,让访问请求尽可能少地到达数据层)
    • 秒杀系统特点是读多写少(最多执行等于商品数量的写次数,但读次数在用户数量的数量级),适合使用缓存
  • 实现逻辑
    • 浏览器层,请求拦截:用户点击请求按钮后置灰、JS限制用户一定时间内提交请求
    • 站点层,请求拦截与页面缓存:对同一个 uid,限制其访问频度,并缓存页面,对其一定时间内发出的请求,返回相同页面;对同一个商品的查询,也缓存页面,在一定时间内返回相同页面。
    • 服务层,请求拦截与数据缓存:对于写请求(即购买请求,与之相对应的,读请求为查询请求),使用一个请求消息队列存放,每次只释放一部分透过本层到达数据层,成功再接着放,若失败(库存不够),则对队列中剩余请求全部返回库存不足;对于读请求,使用缓存,如redis。
    • 数据层,几乎不需要做优化,因为大部分请求已经拦截,配合缓存层对写请求做好一致性即可,如分布式事务或消息队列。支付倒计时内锁定库存,支付成功扣减库存否则释放库存,这个功能可以放在数据层完成,使用悲观锁实现。
  • 表结构
    • 秒杀活动表,字段如下:[活动编码]、[活动名]、[商品编码]、[价格]、[计划售出数量]
    • 库存表,字段如下:[商品编码]、[商品名称]、[活动编码]、[总库存]、[预扣库存(用于支付锁定)]
    • 商品表,字段如下:[商品编码]、[商品名称]、[商品标题]、[商品价格]、[商品描述]
    • 订单表,字段如下:[订单编码]、[商品编码]、[数量]、[用户id]
    • 表行为如下:运营创建活动,从商品表中读取(read)商品数据,将相关信息一起写入(insert)秒杀活动表,写入(update)预扣库存到库存表;活动持续期间需要保持对库存表、订单表的读、写操作。

3.线程池设计:

  • 线程池的基本模型为生产者-消费者模型,可以用两个队列存放生产者(任务)和消费者(线程)。
  • 线程对象结构
    • terminate 状态字段
    • 所在的线程池对象字段
    • 方法
      • 构造器方法
      • 执行方法(通常为一个死循环):锁定互斥量,取任务队列中的任务,获取到任务后释放锁。然后调用任务的 handler 方法。
  • 线程池的对象结构如下
    • 字段
      • 线程队列
      • 任务队列
      • 互斥量(用于操作任务队列)
      • 信号量(用于操作线程)
    • 方法:
      • 构造器:初始化锁、信号量、线程队列、任务队列
      • 创建工作线程
        • 调用线程构造器方法
        • 为线程设置回调函数
      • 任务生产:
        • 获得互斥锁
        • 将任务添加至任务队列
        • 用信号量唤醒线程
        • 释放互斥锁