自学内容网 自学内容网

如何针对项目中的技术难点准备面试?——黑马点评为例

最核心的,包装和准备

个人项目,怎么包装?一定要写出代码才可以吗?

你可以在系统A中实现就可以,了解其中实现的细节,怎么跟面试官对线等等,这些话术到位了之后,再把它融入到系统B,这样即可。

举个例子

一个大前提,你要想好怎么跟面试官对线?

知道怎么对线后,自然就知道,怎么去提前准备这块内容,举例子:

你的简历写了这句话,那么你要怎么准备?

  • 对热点数据做缓存,针对可能的缓存穿透,同时使用缓存空值布隆过滤器解决;针对热点数据过期,根据不同的数据一致性要求,采用不同的缓存构建方案,防止缓存击穿;

你的简历写了异步秒杀业务,你又该怎么介绍?

1、业务大致逻辑的介绍

业务是用户可以抢购大额代金券,来抵扣购买课程所需金额,一个用户只能抢购一张大额优惠券

相关的表结构

平价券表

自增id、代金券标题、副标题、使用规则、支付金额、抵扣金额、类型 0普通 1秒杀、状态 1 2 3、创建时间、更新时间

秒杀券表

在平价优惠券基础上,秒杀优惠券有其他字段,独立成一张表。

关联平价券的自增id、库存、秒杀开始时间、秒杀结束时间、创建时间、更新时间

订单表

Id 订单编号(全局id)、下单用户id、购买的优惠券id、支付方式 1 2 3、订单状态 1 2 3 4 5 6 7、抢购时间、支付时间、核销时间、退款时间、更新时间

有啥难点?

一人一单、不超卖、保证并发量 等等

2、代码一步步实现的过程介绍

方案的比较

选择哪个锁?

整体逻辑的 初步设计是怎么样的?

使用基于数据库的锁 以及 JVM的锁实现功能

初步设计存在什么问题呢?

多集群部署时,JVM不能看到同一把锁

后续又基于什么、或者通过什么方式进行完善优化?

业务迁移到 redis 来做 、由最初的 JVM层面的队列,到引入redis的stream,再到引入MQ等等

那么优化了多少?

数据呈现!qps等等

怎么迭代优惠券秒杀功能?

业务场景是:用户可以抢购数量有限的大额优惠券,并且每个用户最多只能抢一张

怎么解决超卖问题?方案对比,选择乐观锁

所以这个功能首先要完成的是:不要出现库存超卖的情况,

有两个解决方案:悲观锁syn & 乐观锁 cas

悲观锁的思想:认为我在减库存的时候,一定有其他用户也在减,为了防止这种现象,减库存时,加了一个同步锁synchronized,来解决并发问题

乐观锁的思想:乐观锁是认为我在减库存的时候,不一定会发生并发问题,就算有,我就放弃此次操作,再重新尝试减一次。实现这一机制:

就是在减库存的时候,判断 库存是否 > 0即可,只要是 > 0,就可以卖

当出现 <= 0时,就减库存失败

基于乐观锁的性能比悲观锁要好,因为

悲观锁只允许一个线程在同步代码块执行,其余线程必须等待锁释放,性能差

而基于库存是否 > 0的乐观锁,只有在库存真的 <= 0,才会并发失败,性能远远比悲观锁好。

经过以上方案的比较,项目采用乐观锁来解决超买问题。

接下来是要解决每个用户只能抢一张优惠券的问题

怎么保证每个用户只能抢一张优惠券呢?

项目是这样解决的,首先确定无法使用乐观锁来解决

因为用户抢到优惠券,在他没抢到之前,数据库并没有记录,无法根据字段进行乐观锁。

所以采用悲观锁的方案,因为目前是在解决单个用户发起的并发请求,只需要针对单个用户进行加锁,

确定锁的粒度为每个用户,锁对象为用户id,String 类型,为了防止加锁的对象不是同一个,采用的是toString().intern(),不同的请求,才会从字符串常量池中返回同一个对象,才能解决单个用户并发问题。

确定加锁范围判断用户是否已抢购 -> 乐观锁解决减库存问题 -> 把抢购记录,写入数据库

如果加锁范围只到乐观锁解决库存问题,是无法避免单个用户的并发请求问题的。

这是针对单个服务可用的方法,因为synchronized锁,基于JVM实例

如果部署多台服务,有多个JVM,synchronized无法做到分布式锁,

所以在集群部署下,还会出现一人一单并发问题

思考到集群下的JVM锁问题,采取分布式锁优化:

使用分布式锁,解决集群下的一人一单问题

为了解决上面说到的问题,决定使用跨JVM的锁,即分布式锁,redis就是很好的选择。

首先自定义了一个比较简单的分布式锁

存在的问题是锁超时释放,但是业务还未执行完毕

(想要更好的解决,可以使用redis分布式工具:redisson)

支持锁重入:利用hash结构,通过记录线程id、锁的数量,来达到重入

锁超时自动续费:保证是业务执行完毕,才释放的锁,不会被其他线程趁虚而入

每隔 1/3 的时间,会重置超时时间

支持锁等待:即获取不到锁时,利用发布订阅 & 信号量的机制,等锁释放了 再去重试,对CPU友好。

到目前位置,业务流程为查询优惠券信息 ->加分布式锁来解决同一用户的并发请求-> 进行一人一单的判断,需要查询数据库->进行乐观锁库存超卖的判断,需要更新数据库->抢购成功,创建订单,写入数据库。

可以看到目前的流程存在大量的IO& 锁,整体性能通过JMeter测试,

1000个用户,200库存的优惠券,处理请求的平均耗时接近500ms

存在许多耗时的数据库操作 & 锁,还可以怎么提高性能呢?

基于redis:秒杀资格判断异步写入数据库思路

通过定时任务把MySQL中参与秒杀的代金券,同步到Redis中做库存的预扣减,基于Redis解决库存超卖与一人一单,RocketMQ实现异步解耦,QPS从400提升至1200;

对业务进行拆分,决定将耗时的数据库操作,放到redis来做,具体为:秒杀资格的判断

新增秒杀优惠券的同时,将优惠券信息预热在redis中

在redis中判断用户是否已经下过单,

使用redis数据类型:Set,存放已经下过单的用户信息,

方便以O(1)复杂度判断用户是否下单sismember、sadd

key为:seckill:order:优惠券id

如果还未下过单,使用redis判断库存是否充足,如果库存充足,则需要减1

使用redis数据类型:hash,存储优惠券信息

get、incrby减库存

key为:seckill:stock:优惠券id

上述过程,是多条命令,无法保证这些命令执行的原子性,会出现并发问题,所以使用lua脚本

保证执行上述命令的原子性

相当于把之前的分布式锁解决一人一单、乐观锁解决库存超卖的问题,通过基于内存的redis解决了

大大提高性能


RocketMQ实现异步解耦,QPS从400提升至1200;


若判断用户有资格抢购,在这之前采用的是同步操作,同步等待信息写入数据库,

即用户请求需要等待抢购信息写入数据库,才可以返回

优化的解决方案是:向消息队列RocketMQ中添加消息(分布式id、优惠券id、用户id),立刻返回用户请求,

开启异步线程,实现异步写入数据库的操作。减少响应时间,提高用户体验。

一开始使用的是JDK自带的阻塞队列,耗时200ms

阻塞队列在获取消息时,如果没有消息,就阻塞住;等到有消息加入了,就被唤醒

使用jdk自带的阻塞队列缺点

  1. 使用的是JDK的阻塞队列,用的是JVM的内存,如果不加以限制,在高并发下,可能有无数的订单放到阻塞队列,可能会导致内存溢出,也就是内存受到限制

  2. 消息一旦取出,就消失了,不能保证一定被消费

  3. 不支持持久化,目前是基于内存保存订单信息,如果服务宕机,内存所有订单信息都丢失;

选择Stream消息队列替代JDK自带的阻塞队列

耗时100ms

比较redis 不同方式实现消息队列之间的优缺点,即为什么选择Stream而不是List?

最重要的是记住Stream的优点(持久化、全局ID、解决消息漏读、pendin-list保证消息至少消费一次、独立于JVM的内存、支持消费者组消费,减少消息挤压、可以阻塞读取)

理解内部实现,来说明为什么有这些优点。

Stream相关的八股

具体落实到项目中,怎么实现?

创建一个Stream消息队列,不指定上限

lua脚本判断有资格后,向消息队列添加消息

项目启动时,开启异步线程,阻塞读取Stream消息队列中的消息,完成写入数据库操作

如果成功消费,那么发送ack确认给消息队列,消息才会从pending队列中移除

如果消费出现问题,就到该消费者的pending队列中,再次消费

专业消息队列RocketMQ

RocketMQ使用并发消费模式,并设置合理的线程数量(IO类型,写库存),快速处理队列中堆积的消息,使

用Redis的分布式锁+自旋锁,对商品的库存进行并发控制,把并发压力转移到Redis中,缓解DB压力;

因为并发消费,对数据库减库存操作,是不安全的

除非直接利用数据库乐观锁减

而不是先去读再减 ,直接减

但是对DB压力大

使用redis乐观锁 + sleep + 自旋来解决

3、未来展望 or 再次迭代 or 这个功能有什么可以完善的地方?

如果没下单,库存怎么还回去?

使用延时队列? 那么又引出 - 延时队列怎么实现的?

其实redis 的 stream同样的,又引出八股文,这些都是需要准备的 Stream相关的八股

.....

自定义的分布式锁,相比官方提供的,存在缺陷,如:

最严重的 业务未结束,锁先超时释放了,其他线程趁虚而入、

不支持 锁重入:用hash即可、

不支持 阻塞等待:用信号量、发布/订阅机制 即可、

在多redis实例下,即主从模式下因为是异步复制的,导致分布式锁不可靠性:官方提供的 红锁 解决

redisson 针对前面三个缺陷、RedLock 红锁

4、实现过程中遇到了什么难点?什么bug?

@Transational失效,因为不是代理对象调用。深入了理解Spring事务原理 -- Aop。

怎么解决?

  • 比较笨方法:新开一个类

  • 或者 自己注入自己,进行调用,也是代理对象的调用

  • 获取代理对象来解决。

JVM的syn悲观锁解决一人一单问题的时候:

用的是用户的id,忘记intern放到字符串常量池,

导致获取String对象的时候,每次都是新的对象,即 加 对象锁出现问题

还有syn锁范围设置的不够大,释放锁之后,事务还未写入,导致数据库记录还未变更,存在并发问题

.....

5、如果你的简历 关键字出现,分布式id、分布式锁、qps等等

心里就要思考到,哪些是会被提问的?

怎么进行压力测试的?

QPS、并发量、平均花费时间 等的关系:QPS和并发数和平均耗时的关系以及压测思路_qps和并发数的关系-CSDN博客

分布式id相关的准备

为什么不采用数据库自增id?

单一表的存储容量有上限

当分表存储时,会存在重复的id

规律性明显,容易看出订单销量等状态

分布式ID是什么?

是应用在分布式系统中,保证全局唯一的自增id。

它可以让一个业务,不管有多少个服务、多少张表,都可以拥有唯一的自增id。

全局唯一的分布式ID怎么实现?

使用redisString数据类型的incr自增命令,来帮助生成全局唯一id,有以下好处:

因为redis执行命令是单线程的,所以在执行自增命令生成自增id时,

不存在并发问题,自然不会导致id重复的问题;

并且是自增的,符合分布式id要求;

并且redis基于内存操作,性能极高

为了保证生成的id安全性,具体如下操作:

采用long类型存储id,long类型64位

· 第一个符号位,永远为0

· 接下来的31bit,采用精确到的时间戳进行存储

o 时间戳如何计算得来:定义一个初始时间,用当前下单时间减去初始时间,得到31bit

· 后面的32bit,是为了解决在一秒内重复的下单,足够容纳一秒内的订单量

如何运算?

先得到当前时间 - 初始时间的时间戳,然后左移32位,给一天的订单量让出32位bit

使用自增命令,得到自增值,要保证不会超过32bit,然后直接进行或运算

return timestamp << COUNT_BITS | count;

时间戳的代码

/** * 初始时间的时间戳,本质是从1970-01-01 00:00:00 到2022-01-01 00:00:00 经过多少秒 */ private static final long BEGIN_TIMESTAMP = 1640995200L;

//测试时间戳 public static void main(String[] args) { LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0); System.out.println( time.toEpochSecond(ZoneOffset.UTC)); }

自增命令的key怎么设置比较好?

在自增中,采用的是32bit来存储自增值,也就是说自增值超过32bit存储容量,就会不符合我们的要求。

所以在设置key时,采用一天一个key,一天订单量很难超过32bit,也就是自增值不会超过

o 如:("icr:" + keyPrefix + ":"+"2022:03:20"),keyPrefix 为业务名称

o 还带来统计方便的好处

§ 比如某天的订单数,直接看对应key的自增数字就可以。这样做统计简单很多。

自增id生成器代码

@Component
public class RedisIdWorker {
  /**
   \* 初始时间的时间戳,本质是从1970-01-01 00:00:00 到2022-01-01 00:00:00 经过多少秒
   */
  private static final long BEGIN_TIMESTAMP = 1640995200L;

  //测试时间戳
  public static void main(String[] args) {
    LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
    System.out.println( time.toEpochSecond(ZoneOffset.UTC));
  }

  /**
   \* 序列号的位数
   */
  private static final int COUNT_BITS = 32;

  @Resource
  private StringRedisTemplate stringRedisTemplate;

  /**
   \* @param keyPrefix key前缀,不同业务有不同的key
   \* @return long型,作为id,占用更少空间,有利于索引建立
   */
  public long nextId(String keyPrefix) {
//   符号位不用管,只要保证正数就可以,怎么保证? 时间戳中,当前时间 - 初始时间,当前时间要 > 初始时间

​    // 1.生成当前时间的 时间戳
​    LocalDateTime now = LocalDateTime.now();
​    long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
//     当前时间 - 初始时间
​    long timestamp = nowSecond - BEGIN_TIMESTAMP;

​    // 2.生成序列号
​    // 2.1.获取当前日期,精确到天
​    String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
​    
​    long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

​    // 3.拼接并返回,如果直接拼接得到的是字符串,返回要long。所以这里采用位运算
//     先把时间戳挪到高位,在这里 左移32位。 再跟序列号进行 或运算
​    return timestamp << COUNT_BITS | count;
  }
}
你还了解哪些分布式ID生成算法?

除了基于redis生成的分布式id,还了解雪花算法、uuid、数据库自增id

雪花算法 同样采用64bit存储

o 第一位表示符号位,为0

o 接下来的41bit,用于表示精确到毫秒的时间戳

o 接下来的10bit,(这一部分可以灵活调整)

§ 前5位表示机器id,后5位表示机房id

o 剩下的12bit,用来表示一毫秒内,能够生成的id数量

优点:

生成速度快,有序递增、易于再此基础上改造

缺点:

依赖于时间,当机器的时间对应不上时,可能导致重复id

uuid 基于时间、机器id的生成方案

缺点是:

占用内存大,128bit

时间问题,导致id重复

可以保证唯一,但是不是自增的

若redis服务宕机,分布式id如何生成?

采用redis主从复制 + 哨兵机制,来达到服务的高可用

当主节点宕机时,自动故障转移

主从复制保证数据同步

6、分布式锁相关的准备

分布式锁是什么?

满足分布式或集群模式下,多线程可见互斥的锁。

怎么基于redis实现?

使用redis的 setnx命令,来实现分布式锁,非阻塞,获取失败,直接返回

加锁操作:setnx

因为redis执行命令是单线程,不会并发安全问题

并且为了防止死锁,加了key的过期时间

并且将value设置唯一标识,是为了防止锁误删的现象

解锁操作:基于lua脚本,因为不止一条命令

首先判断该锁是不是自己加的,即检查唯一标识get

如果是,才可以进行解锁del

锁误删现象是什么?

比如目前线程A,持有锁,当时因为阻塞,导致业务没执行完,锁超时释放了

此时线程B重新持有锁,进行业务处理,

在线程B还没处理完业务时,线程A处理好了,并且二话不说,直接把锁删除了

这就导致线程B的锁,被线程A删掉的情况。导致锁误删

这时,其他线程又可以趁虚而入了。

唯一标识怎么设置?

因为目前讨论的是项目在集群部署的环境下,线程id可能重复

所以基于每个线程的id + UUID来进行唯一标识的设置。

为什么解锁要使用lua脚本

因为解锁是两个操作get、del,必须保证解锁的原子性,否则可能出现以下现象:锁误删

判断该锁是我之前加的

进行解锁时,阻塞了

知道锁超时释放,接着其他线程进行加锁

自己从阻塞状态恢复,执行业务,dek把别人的锁又给删除了

自定义的分布式锁,存在什么问题?

锁误删问题解决了,但是还存在一个比较严重的问题,就是锁超时时间的设置

如果设置的太短,可能业务还没执行完 或者 业务阻塞,导致锁超时释放

其他线程趁虚而入,又导致了一人不止下一单问题的出现。

不支持锁重入、锁超时自动续费、锁等待、

主从模式下因为是异步复制的,导致分布式锁不可靠性

怎么解决自定义分布式锁问题?

使用redis分布式工具:redisson

· 支持锁重入:利用hash结构,通过记录线程id、锁的数量,来达到重入

· 锁超时自动续费:保证是业务执行完毕,才释放的锁,不会被其他线程趁虚而入

o 每隔 1/3 的时间,会重置超时时间

· 支持锁等待:即获取不到锁时,利用发布订阅 & 信号量的机制,等锁释放了 再去重试,对CPU友好。

Redis 如何解决集群情况下分布式锁的可靠性?

redis官方是实现了红锁RedLock,专门来解决集群模式下分布式锁不可靠的问题,

redis推荐使用5个独立的redis主服务器

加锁的过程如下:

记录开始访问的时间t1,线程依次访问5个主服务器,进行set nx px的操作,

会带上唯一标识

加上超时时间,是为了锁一定会被释放

并且还设定了获取锁的时间,一般设置为几十毫秒,

如果在时间内获取不到,那么就返回,不会再某个redis服务耗费太多的获取锁时间

最后统计线程成功获取了几把锁,要获取到一半以上,并且将获取锁的总时间 与 设置的锁过期时间对比

如果 获取锁的总时间>设置的锁过期时间,那么加锁失败

如果没有获取到一半以上的锁,在这里是3把锁,也是加锁失败

加锁成功要同时满足两个条件

· 获取到超过半数以上的锁

· 加锁的总耗时,不大于 锁的过期时间

并且在执行业务时,真正能够利用的锁时间为:设置的锁超时时间 - 获取锁的总耗时

如果觉得锁的时间已经来不及完成业务执行,那么可以直接释放全部锁,让下一个线程来操作

避免业务还没执行完,就出现释放锁的现象

解锁操作:

加锁失败后,会向所有redis主节点发起解锁操作,执行lua脚本保证解锁的原子性

完整代码,要稍微注意一下lua脚本怎么写

//   在项目一启动类加载时就加载static代码块,只加载一次,性能最好。
// DefaultRedisScript是实现类,泛型为脚本的返回值类型
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
//     因为要写不止一行,所以放到代码块
  UNLOCK_SCRIPT = new DefaultRedisScript<>();
//     去类路径下找
  UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
//     设置返回值类型
  UNLOCK_SCRIPT.setResultType(Long.class);
}


@Override
public void unlock() {
//     释放锁
//     stringRedisTemplate.delete(KEY_PREFIX + name);

  /*// 获取线程标示
  String threadId = ID_PREFIX + Thread.currentThread().getId();
  // 获取锁中的标示
  String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
  // 判断标示是否一致
  if(threadId.equals(id)) {
    // 释放锁
    stringRedisTemplate.delete(KEY_PREFIX + name);
  }*/
  // 调用lua脚本
  stringRedisTemplate.execute(
      UNLOCK_SCRIPT,
      // 生成单元素的集合:singletonList方法
      Collections.singletonList(KEY_PREFIX + name),
      ID_PREFIX + Thread.currentThread().getId());
}


原文地址:https://blog.csdn.net/qq_32792547/article/details/142886458

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!