Java 后端面试3

记录我的面试

Java 后端面试 3

这个是某黄袍加身的大厂。我自己还没面,因为听说 9 月下旬 HC 会多一点,进的概率大点,而且自己现在也抽不开身,在一个很不正规的厂里打工。

面试题目来自某客,应该靠谱。我先评论一下,如果所有非项目面试题都在了,那我只能说真的纯八股。这么问,怎么能有区分度,怎么能把人筛出来。选出来的人都是只会背书的文科生吧(无意冒犯文科生,这里指没有代码能力,只会背八股的公式人)。

  1. 登录如何实现登录,使用 redis 替代 session,登录逻辑如何

    答:首先用户输入手机号,并请求发送短信验证码。服务器首先验证账号是否合法且可用,验证通过后,调用 API 的接口发送短信验证码,并将验证码存入 redis 中(设置一个几分钟的过期时间)。用户输入验证码,点击登录按钮发送请求,服务器验证验证码是否正确。若失败,则提示用户验证码错误;若正确,则使用 JWT 生成 token,并返回给用户。用户登录成功后,将 token 存入 redis 中,并设置一个较长的过期时间,用来表征用户处于登录状态。此外,如果用户是第一次登录,也就是注册,那么我们会将用户信息存入数据库。

    Session 的缺点比较显著,一是加大服务器的存储负担,因为所有信息的存储都需要在服务器端,二是无法实现分布式,如果有多台服务器,则需要在每台服务器上都保存一份 session,这会造成资源浪费。而 Token 的话,解决了前者的痛点,每次访问都是客户端携带 Token,服务器只需要验证 Token 就能判断用户身份信息和是否登录,不需要在服务器端存储用户信息。而使用 Redis 则解决了后一问题,通过在多个服务器前加一个中间层来集中管理 Token,可以较好地实现分布式。

  2. 秒杀服务如何保证高并发,怎么使用 lua 脚本,lua 脚本的作用

    答:秒杀服务的逻辑是这样的。首先我们需要一个统一的全局唯一 ID 生成器,用来标识每一个订单号。然后,我们需要一个秒杀队列,用来处理秒杀请求,队列中的请求会被逐个处理。对于已登录用户而言,第一点,需要先判断库存是否充足,这里可以使用 CAS 来实现,对于只读操作 CAS 是较为高效的解法;第二点,我们需要确认当前时间是否在秒杀时间段内,如果不在,则直接返回秒杀失败;第三点,我们需要检查用户是否已经下过单了(出于每个用户最多只能下 n 单的限制),这个也用 CAS 处理;最后我们进行订单的生成和仓库的扣减,并将订单信息存入数据库,这个处理需要用上 Redisson 和 lua 脚本来实现分布式锁,保证原子性。因为普通的 synchronized 锁是无法保证分布式下的原子性的,每个机器都会生成一个新的 JVM,而 lua 脚本则可以在 Redis 中执行,可以保证原子性。

    然后我们可以发现,对于整个秒杀业务其实大体上可以被分为两个部分,一个是 秒杀资格判定(读),一个是 更新(写),并且最重要的两点,一是前者快后者慢,而是前者通过才有后者。所以我们可以异步地处理,类似前者可以直接把所有的请求都处理完,放到一个队列里面进行排队,而后者则可以异步地处理,比如使用 lua 脚本来保证原子性。这也是引入 kafka 处理的原因。

    对于 lua 脚本,我们首先要明确它是什么。它其实是用来实现 原子性事务性 的一种编程语言,可以让我们在 Redis 中执行一些复杂的操作,它是单线程的所以比较安全,同时它要么做完要么完全不做。其次,再来说我们要怎么利用它,我们要用它来减少库存数量。

  3. 如何实现秒杀

    答:秒杀的场景特点是短时间大量用户并发地进行集中的读写操作,我们的目的是为了保证系统不至于崩溃,最主要的实现方式就是 层层拦截请求,将请求尽量在上游进行拦截,避免过多数据打到数据库。

    拦截其实分为两部分,一部分是过滤,一部分是代理。过滤就是请求通过到服务器端接收的中间层,对当前信息进行筛选,筛出部分传给下一级;代理可能不是那么贴切,意思是利用一些高速的机制先行处理部分请求,比如使用 Redis 防止部分热点 key 的查询。

    1. 首先代码设计逻辑上,我们可以为秒杀按钮设计一个冷却时间,可以是直接按钮置灰;也可以是不显式地,几秒内重复按只发送一次请求,这样可以避免用户连续快速多次点击造成的重复请求。
    2. 在站点层面用 uid 对用户请求进行去重,uid 代表了用户的个人身份标识,可以设置一个 uid5 秒内只能发送一个请求。
    3. Redis 处理热点信息的查询。
    4. 后端网关进行请求拦截,或是负载均衡,或是限流、降级、熔断等操作。
    5. 后端服务使用 MQ 进行请求排队,也可以设置任务队列的长度,避免请求积压。
  4. Redis 的过期淘汰策略与内存淘汰策略

    答:Redis 当中的过期淘汰可以用 expire <key> <time> 来指定,也可以在创建的时候就直接指定过期时间。同时我们也可以用 ttl <key> 来查看过期时间 n,若大于 0,则标识还有 n 秒过期;若等于-1,则标识永不过期,没有被设置过期时间;若等于-2,则标识已经过期。redis 每隔一段时间,就会查看被设置 expire 的 key,并将过期的 key 删除。

    而对于内存淘汰策略就有挺多的了:

    • no-eviction:当内存不足以容纳新写入数据时,新写入操作会报错。

    • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key。

    • allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key。

    • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key。

    • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。

    • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。

  5. redis 集群有哪些,介绍一下主从、分片、哨兵分别解决了什么问题

    答:Redis 集群是一种通过将多个 Redis 节点连接在一起以实现高可用性、数据分片和负载均衡的技术。它允许 Redis 在不同节点上同时提供服务,提高整体性能和可靠性。Redis 集群有主从模式、分片模式、哨兵模式。

    • 主从模式:主从模式是 Redis 的一种集群架构,主节点负责处理写操作,从节点负责处理读操作。当主节点挂掉时,从节点会自动提升为主节点。主从模式下,读写分离,可以提高读的吞吐量。

    • 分片模式:分片模式是 Redis 的另一种集群架构,它将数据分布到多个节点上,每个节点负责存储一部分数据。当数据量较大时,可以有效地解决单机内存不足的问题。分片模式下,数据存储在不同的节点上,可以有效地提高容量。

    • 哨兵模式:哨兵模式是在主从复制基础上加入了哨兵节点,实现了自动故障转移。哨兵节点是一种特殊的 Redis 节点,它会监控主节点和从节点的运行状态。当主节点发生故障时,哨兵节点会自动从从节点中选举出一个新的主节点,并通知其他从节点和客户端,实现故障转移。

  6. AOF 和 RDB 持久化,这两者是否会阻塞主线程

    答:由于 Redis 是存储在内存当中的,为了数据的持久化,我们还是需要一种方式存回到硬盘这种持久化存储器当中。Redis 提供了两种持久化方式,第一种是 RDB 持久化,它会将内存中的数据以快照的方式,按照固定的时间间隔(如果修改操作越频繁,则该时间间隔越短)写入磁盘,恢复时会恢复到最近一次快照的状态。第二种是 AOF 持久化,它会将内存中的数据以日志的形式写入磁盘,恢复时会根据日志中的指令来恢复数据。

    还可以通过 save 命令,手动触发 RDB 持久化,也可以通过 bgsave 命令,后台异步执行 RDB 持久化。

    此外对于 AOF 持久化,它是通过追加的方式来实现的,所以不会阻塞主线程。但是对于 RDB 持久化,它是通过 fork 子进程来实现的,所以会阻塞主线程。

  7. 缓存一致性有哪些策略,分别怎么做,怎么优化

    答:缓存一致性问题是指多个节点缓存的数据可能不一致,需要通过某种策略来解决。常见的策略有以下几种:

    • 基于版本号:每个缓存都有自己的版本号,当数据更新时,版本号加 1,缓存更新时,先比较版本号,若相同则更新缓存,否则丢弃缓存。
    • 基于超时:设置缓存的超时时间,当缓存超时时,则更新缓存。
    • 基于分布式锁:当多个客户端同时操作缓存时,通过分布式锁来避免缓存击穿。
    • 基于消息队列:当缓存更新时,通过消息队列通知其他节点更新缓存。

    对于基于版本号的策略,需要客户端和服务端都实现版本号的维护,比较麻烦。对于基于超时的策略,需要客户端和服务端都设置超时时间,比较麻烦。对于基于分布式锁的策略,需要客户端和服务端都实现分布式锁,比较麻烦。对于基于消息队列的策略,需要客户端和服务端都实现消息队列,比较麻烦。

    1. 缓存穿透问题:缓存和数据库都没有,每次查询都要去数据库,这样会导致数据库压力过大,造成系统崩溃。
    2. 缓存空值:在第一次到缓存未命中到达数据库后,发现符合缓存穿透条件,设置一个空值缓存(有过期时间),当查询不到数据时,直接返回空值。 2. 布隆过滤器: 这样的话结构本来是发出查询-> redis 缓存-> 数据库,要在查询和缓存间加一层 BloomFilter。如果检测到查询的 key 不存在于数据库中,则直接报错;如果在数据库中,则更布隆过滤器。
    3. 缓存雪崩问题:缓存服务器宕机或同一时段大量缓存失效,导致大量请求直接落到数据库,数据库压力过大,造成系统崩溃。
      1. 缓存失效时间设置随机值:随机设置缓存过期时间,避免缓存雪崩。
    4. Redis 集群: 避免缓存雪崩,可以将缓存分布到多个 Redis 节点上,避免单点故障。 3. 降级限流: 直接限制对服务器的查询请求,返回错误,不对数据库产生进一步的压力。 4. 添加多级缓存
    5. 缓存击穿问题:缓存击穿是指对于某个被高并发且缓存构建业务比较复杂的 key,缓存中没有,但是数据库中有,每次查询都要去数据库,造成数据库压力过大,造成系统崩溃。
      1. 互斥锁: 对于查询某个 key 的请求,当缓存没有命中时,加互斥锁,查询数据库返回请求,并在缓存中设置这个 key,再释放锁,避免其他线程在查询重建时期的多次访问。
      2. 逻辑过期: 对于那些已经判断为热点高并发的资源,直接把它定死在 redis 当中,保证热点资源每时每刻都在缓存中,虽然可能会有旧的没更新的。对于过期时间,直接以值的形式存到 redis 的 value 一栏当中,这样即使到了过期时间它也不会被 redis 删除。如果过期了,则开另外一个线程去查询数据库,更新缓存。
  8. 分布式锁

    答:一个最基本的分布式锁需要满足:

    1. 互斥:任意一个时刻,锁只能被一个线程持有。
    2. 高可用:锁服务是高可用的,当一个锁服务出现问题,能够自动切换到另外一个锁服务。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。这一般是通过超时机制实现的。
    3. 可重入:一个节点获取了锁之后,还可以再次获取锁。实现一般会通过一个计数器来实现。

    异常情况(三个异常将隔离级别分成四种):

    1. 脏读(Dirty Read): 一个事务读了另一个事务还未提交的变更,如果另一个事务发生了回滚,就会导致事务一读取的结果无效。
    2. 不可重复读(Unrepeatable Read): 一个事务先后对进行读,但是在这之间有另一个事务进行写,导致两次重复读取的结果不一致。
    3. 幻读(Phantom Read): 一个事务进行全表操作,完成之后另一个事务进行了插入操作,导致事务一全表扫描发现多出了一个 Tuple。

    四种隔离级别(从上往下安全性逐渐增强):

    • Read Uncommitted (RU): 允许脏读,也就是可能读到其他事务未提交的变更。放开索引锁,并且删除 Shared 锁(读锁)。
    • Read Committed (RC): 禁止脏读,只能读到已经提交的事务,也就是只能读到其他事务提交的最新数据。放开索引锁,并且 Shared 锁(读锁)会立即解锁。
    • Repeatable Read (RR): 幻读有可能发生,保证同一事务的多个实例在并发环境中返回同样的结果,禁止脏读和不可重复读,也就是在同一事务中,同样的查询语句返回的结果必须是一致的。放开索引锁。
    • Serializable (S): 最严格的隔离级别,确保事务之间的数据完全一致,避免脏读、不可重复读、幻读。需要加所有锁,并执行严格 2PL。