12、【对线面试官】List

要不今天来讲讲Java的List吧,你对List了解多少?

  1. List在Java里边是一个接口,常见的实现类有ArrayList和LinkedList,在开发中用得最多的是ArrayList
  2. ArrayList的底层数据结构是数组,Linked List底层数据结构是链表。

那Java本身就有数组了,为什么要用ArrayList呢?

  1. 原生的数组会有一个特点:你在使用的时候必须要为它创建大小,而ArrayList不用。
  2. 在日常开发的时候,往往我们是不知道要给数组分配多大(不固定)
  3. 如果数组的大小指定多了,内存浪费;如果数组大小指定少了,装不下。
  4. 假设我们给定数组的大小是10,要往这个数组里边填充元素,我们只能添加10个元素。
  5. 而ArrayList不一样,ArrayList我们在使用的时候可以往里边添加20个,30个,甚至更多的元素
  6. 因为ArrayList是实现了动态扩容的
    1. 当我们new ArrayList()的时候,默认会有一个空的Object数组,大小为0。
    2. 当我们第一次add添加数据的时候,会给这个数组初始化一个大小,这个大小默认值为10
    3. 使用ArrayList在每一次add的时候,它都会先去计算这个数组够不够空间
    4. 如果空间是够的,那直接追加上去就好了。如果不够,那就得扩容

那怎么扩容?一次扩多少?

  1. 在源码里边,有个grow方法,每一次扩原来的1.5倍。比如说,初始化的值是10嘛。
  2. 现在我第11个元素要进来了,发现这个数组的空间不够了,所以会扩到15
  3. 空间扩完容之后,会调用arraycopy来对数组进行拷贝

我又想问问,为什么你在前面提到,在日常开发中用得最多的是ArrayList呢?

  1. 是由底层的数据结构来决定的,在日常开发中,遍历的需求比增删要多,即便是增删也是往往在List的尾部添加就OK了。
  2. 像在尾部添加元素,ArrayList的时间复杂度也就O(1)
  3. 另外的是,ArrayList的增删底层调用的copyOf()被优化过
  4. 现代CPU对内存可以块操作,ArrayList的增删一点儿也不会比LinkedList慢

了解,Vector你知道这个吗?

  1. 嗯,Vector是底层结构是数组,一般现在我们已经很少用了。
  2. 相对于ArrayList,它是线程安全的,在扩容的时候它是直接扩容两倍的
  3. 比如现在有10个元素,要扩容的时候,就会将数组的大小增长到20

嗯,那如果我们不用Vector,线程安全的List还有什么?

  1. 首先,我们也可以用Collections来将ArayList来包装一下,变成线程安全。`
  2. 在java.util.concurrent包下还有一个类,叫做CopyOnWriteArrayList
  3. 要讲CopyOnWriteArrayList之前,我还是想说说copy-on-write这个意思,下面我会简称为cow
  4. 比如说在Linux中,我们知道所有的进程都是init进程fork出来的
  5. 除了进程号之外,fork出来的子进程,默认跟父进程是一模一样的。
  6. 当使用了cow机制;子进程在被fork之后exec之前,两个进程用的是相同的内存空间的
  7. 这意味着子进程的代码段、数据段、堆栈都是指向父进程的物理空间
  8. 当父子进程中有更改的行为发生时,再为子进程分配相应物理空间。
  9. 这样做的好处就是,等到真正发生修改的时候,才去分配资源,可以减少分配或者复制大量资源时带来的瞬间延时。
  10. 简单来说,就可以理解为我们的懒加载,或者说单例模式的懒汉式。等真正用到的时候再分配
  11. 在文件系统中,其实也有cow的机制。
  12. 文件系统的cow就是在修改数据的时候,不会直接在原来的数据位置上进行操作,而是重新找个位置修改。
  13. 比如说:要修改数据块A的内容,先把A读出来,写到B块里面去。
  14. 如果这时候断电了,原来A的内容还在。这样做的好处就是可以保证数据的完整性,瞬间挂掉了容易恢复。

你还是回到CopyOnWriteArrayList上吧;你说的cow机制我了解了

  1. 额。CopyOnWriteArrayList是一个线程安全的List,底层是通过复制数组的方式来实现的。
  2. 要不我来简单说说它的add()方法的实现吧
  3. 在add()方法的实现里,首先他会加lock锁锁住然后会复制出一个新的数组,往新的数组里边add真正的元素最后把 array的指向改变为新的数组
  4. get()方法又或是size()方法只是获取array所指向的数组的元素或者大小
  5. 可以发现的是, CopyOnWriteArrayList
  6. 跟文件系统的COW机制是很像的

那你能说说CopyOnWriteArrayList有什么缺点吗?

  1. 很显然, CopyOnWriteArrayList是很耗费内存的,每次set()add()都会复制一个数组出来
  2. 另外就是 Copy On WriteArrayList只能保证数据的最终一致性,不能保证数据的实时一致性。
  3. 假设两个线程,线程A去读 CopyOnWri取 teArrayList的数据,还没读完
  4. 现在线程B把这个List给清空了,线程A此时还是可以把剩余的数据给读出来。
l

11、【对线面试官】CountDownLatch和CyclicBarrier

我现在有个场景:现在我有50个任务,这50个任务在完成之后,才能执行下一个「函数」,要是你,你怎么设计?

  1. 可以用JDK给我们提供的线程工具类,CountDownLatch和CyclicBarrier都可以完成这个需求。
  2. 这两个类都可以等到线程完成之后,才去执行某些操作

那既然都能实现的话?那CountDownLatch和CyclicBarrier有什么什么区别呢?

  • 主要的区别就是CountDownLatch用完了,就结束了,没法复用。而CyclicBarrier不一样,它可以复用。
  • 比如说,你得给我解释:CountDownLatch和CyclicBarrier都是线程同步的工具类
    od
  • CountDownLatch允许一个或多个线程一直等待,直到这些线程完成它们的操作
  • 而CyclicBarrier不一样,它往往是当线程到达某状态后,暂停下来等待其他线程等到所有线程均到达以后,才继续执行
  • 可以发现这两者的等待主体是不一样的。
  • CountDownLatch调用await()通常是主线程/调用线程,而CyclicBarrier调用await()是在任务线程调用的
  • 所以,CyclicBarrier中的阻塞的是任务的线程,而主线程是不受影响的
  • 简单叙述完这些基本概念后,可以特意抛出这两个类都是基于AQS实现的
  • countDownLatch
    1. 前面提到了CountDownLatch也是基于AQS实现的,它的实现机制很简单
    2. 当我们在构建CountDownLatch对象时,传入的值其实就会赋值给AQS的关键变量state
    3. 执行countDown方法时,其实就是利用CAS将state-1
      执行await方法时,其实就是判断state是否为0,不为0则加入到队列中,将该线程阻塞掉(除了头结点)
    4. 因为头节点会一直自旋等待state为0,当state为0时,头节点把剩余的在队列中阻塞的节点也一并唤醒
  • CycllicBarrier
    1. 从源码不难发现的是,它没有像CountDo wnLatch和ReentrantLock使用AQS的stat e变量,而CyclicBarrier是直接借助ReentrantLock加上Condition等待唤醒的功能进而实现的
    2. 在构建CyclicBarrier时,传入的值会赋值给CyclicBarrier内部维护count变量,也会赋值给parties变量(这是可以复用的关键)
    3. 每次调用await时,会将count-1,操作count值是直接使用ReentrantLock来保证线程安全性
    4. 如果count不为0,则添加则condition队列中
    5. 如果count等于0时,则把节点从condition队列添加至AQS的队列中进行全部唤醒,并且将parties的值重新赋值为count的值(实现复用)

那如果是这样的话,那我多次用CountDownLatch不也可以解决问题吗?

  1. 是这样的,我提出了个场景,它确实很像可以用CountDownLatch和CyclicBarrier解决
  2. 但是,作为面试者的你可以尝试向我获取更多的信息
  3. 我可没说一个任务就用一个线程处理哦
  4. 放一步讲,即便我是想考察CountDownLatch和CyclicBarrier的知识,但是过程也是很重要的。我会看你在这个过程中思考的以及沟通

总结

  1. CountDownlatch基于AQS实现,会将构造CountDownLatch的入参传递至state,countDown()就是在利用CAS将state减- 1,await()实际就是让头节点一直在等待s tate为0时,释放所有等待的线程
  2. 而CyclicBarrier则利用ReentrantLock和Condition,自身维护了count和parties变量。每次调用await将count-1,并将线程加入到condition队列上。等到count为0时,则将condition队列的节点移交至AQS队列,并全部释放。
l

13、【对线面试官】Map

今天来讲讲Map吧,你对Map了解多少?就讲JDK1.8就好咯

  • Map在Java里边是一个接口,常见的实现类有HashMap、 LinkedHashMap、TreeMap和ConcurrentHashMap
  1. 在Java里边,哈希表的实现由数组+链表所组成
  2. HashMap底层数据结构是数组+链表/红黑树
  3. LinkedHashMap底层数据结构是数组+链表+双向链表
  4. TreeMap底层数据结构是红黑树
  5. 而ConcurrentHashMap底层数据结构也是数组+链表/红黑树

我们先以HashMap开始吧,你能讲讲当你new一个HashMap的时候,会发生什么吗?

  1. 如果我们不指定,默认HashMap的大小为16,负载因子的大小为0.75
  2. 还有就是:HashMap的大小只能是2次幂的,假设你传一个10进去,实际上最终HashMap的大小是16,你传一个7进去,HashMap最终的大小是8,具体的实现在tableSizeFor可以看到。
  3. 我们把元素放进HashMap的时候,需要算出这个元素所在的位置(hash)
  4. 在HashMap里用的是位运算来代替取模,能够更加高效地算出该元素所在的位置
  5. 为什么HashMap的大小只能是2次幂,因为只有大小为2次幂时,才能合理用位运算替代取模。
  6. 而负载因子的大小决定着哈希表的扩容和哈希冲突。
  7. 比如现在我默认的HashMap大小为16,负载因子为0.75,这意味着数组最多只能放12个元素,一旦超过12个元素,则哈希表需要扩容
  8. 怎么算出是12呢?很简单,就是16*0.7 5。每次put元素进去的时候,都会检查HashMap的大小有没有超过这个阈值,如果有,则需要扩容。
  9. 鉴于上面的说法(HashMap的大小只能是2次幂),所以扩容的时候时候默认是扩原来的2倍
  10. 还有就是扩容这个操作肯定是耗时的,那能不能把负载因子调高一点,比如我要调至为1,那我的HashMap就等到16个元素的时候才扩容呢。
  11. 当然是可以的,但是不推荐。负载因子调高了,这意味着哈希冲突的概率会增高,哈希冲突概率增高,同样会耗时(因为查找的速度变慢了)

算了。我还想继续问下,在put元素的时候,传递的Key是怎么算哈希值的?

  1. 实现就在hash方法上,可以发现的是,它是先算出正常的哈希值,然后与高16位做异或运算,产生最终的哈希值。
  2. 这样做的好处可以增加了随机性,减少了碰撞冲突的可能性。

你简单再说下put和get方法的实现吧

  1. 在put的时候,首先对key做hash运算,计算出该key所在的index。
  2. 如果没碰撞,直接放到数组中,如果碰撞了,需要判断目前数据结构是链表还是红黑树,根据不同的情况来进行插入。
  3. 假设key是相同的,则替换到原来的值。最后判断哈希表是否满了(当前哈希表大小*负载因子),如果满了,则扩容
  4. 在get的时候,还是对key做hash运算,计算出该key所在的index,然后判断是否有hash冲突
  5. 假设没有冲突直接返回,假设有冲突则判断当前数据结构是链表还是红黑树,分别从不同的数据结构中取出。

那在HashMap中是怎么判断一个元素是否相同的呢?

  1. 首先会比较hash值,随后会用==运算符和equals()来判断该元素是否相同。
  2. 说白了就是:如果只有hash值相同,那说明该元素哈希冲突了,如果hash值和equals()|| ==都相同,那说明该元素是同个。

你说HashMap的数据结构是数组+链表/红黑树,那什么情况拿下才会用到红黑树呢?

  1. 当数组的大小大于64且链表的大小大于8的时候才会将链表改为红黑树,当红黑树大小为6时,会退化为链表。
  2. 这里转红黑树退化为链表的操作主要出于查询和插入时对性能的考量。
  3. 链表查询时间复杂度O(N),插入时间复杂度O(1),红黑树查询和插入时间复杂度O(logN)

你在日常开始中LinkedHashMap用的多吗?

  1. 在前面也提到了,LinkedHashMap底层结构是数组+链表+双向链表,实际上它继承了HashMap,在HashMap的基础上维护了一个双向链表
  2. 有了这个双向链表,我们的插入可以是有序的,这里的有序不是指大小有序而是插入有序
  3. LinkedHashMap在遍历的时候实际用的是双向链表来遍历的,所以LinkedHashMap的大小不会影响到遍历的性能

那TreeMap呢?

  1. TreeMap在现实开发中用得也不多,Tre eMap的底层数据结构是红黑树
  2. TreeMap的key不能为null(如果为null,那还怎么排序呢),TreeMap有序是通过Comparator来进行比较的,如果comparator为null,那么就使用自然顺序

再来讲讲线程安全的Map吧? HashMap是线程安全的吗?

  1. HashMap不是线程安全的,在多线程环境下,HashMap有可能会有数据丢失和获取不了最新数据的问题,比如说:线程Aput进去了,线程Bget不出来。
  2. 想要线程安全,一般使用ConcurrentHashMap
  3. ConcurrentHashMap是线程安全的Map 实现类,它在juc包下的。
  4. 线程安全的Map实现类除了ConcurrentHashMap还有一个叫做Hashtable。
  5. 当然了,也可以使用Collections来包装出一个线程安全的Map。
  6. 但无论是Hashtable还是Collections包装出来的都比较低效(因为是直接在外层套synchronize),所以我们一般有线程安全问题考量的,都使用ConcurrentHashMap
  7. ConcurrentHashMap的底层数据结构是数组+链表/红黑树,它能支持高并发的访问和更新,是线程安全的。
  8. ConcurrentHashMap通过在部分加锁和利用CAS算法来实现同步,在get的时候没有加锁,Node都用了volatile给修饰。
  9. 在扩容时,会给每个线程分配对应的区间,并且为了防止putVal导致数据不一致,会给线程的所负责的区间加锁

嗯,你可以给我讲讲JDK7和JDK8中HashMap和ConcurrentHashMap的区别吗?

  1. 我在学习的时候也看过JDK7的HashMap和ConcurrentHashMap,其实还是有很多不一样的地方
  2. 比如JDK7的HashMap在扩容时是头插法,在JDK8就变成了尾插法,在JDK7的HashMap还没有引入红黑树
  3. ConcurrentHashMap在JDK7还是使用分段锁的方式来实现,而JDK8就又不一样了。但JDK7细节我大多数都忘了。
  4. 我就没用过JDK7的API,我想着现在最低应该也是用JDK8了吧?所以我就没去仔细看了。
l

15、【对线面试官】Spring基础

要不你来讲讲Spring的IOC和AOP你是怎么理解的呗?

  1. 我个人理解下:SpringIOC解决的是对象管理和对象依赖的问题。
  2. 本来是我们自己手动new出来的对象,现在则把对象交给Spring的IOC容器管理
  3. IOC容器可以理解为一个对象工厂,我们都把该对象交给工厂,工厂管理这些对象的创建以及依赖关系
  4. 等我们需要用对象的时候,从工厂里边获取就好了

哦,你说的就是「控制反转」和「注入依赖」吧?

  1. 我认为「控制反转」指的就是:把原有自己掌控的事交给别人去处理
  2. 它更多的是一种思想或者可以理解为设计模式
  3. 比如:本来由我们自己new出来的对象,现在交由IOC容器,把对象的控制权交给它方了.
  4. 而「依赖注入」在我的理解下,它其实是「控制反转」的实现方式
  5. 对象无需自行创建或者管理它的依赖关系,依赖关系将被「自动注入」到需要它们的对象当中去

嗯,那我想问问,用SpringIOC有什么好处吗?

或者换个问法:本来我可以new出来的对象,为什么我要交由Spring IOC容器管理呢?

  1. 主要的好处在于「将对象集中统一管理」并且「降低耦合度」
  2. 如果面试官理解了「工厂模式」,那就知道为什么我们不直接new对象
  3. 要说理由的话,可以举很多例子,比如说:
  4. 我用SpringIOC可以方便单元测试、对象创建复杂、对象依赖复杂、单例等等的,什么都可以交给Spring IOC
  5. 理论上自己new出来的都可以解决上面的问题,Spring在各种场景组合下有可能不是最优解
  6. 但new出来的你要自己管理,可能你得自己写工厂,得实现一大套的东西才能满足需求
  7. 写着写着有可能还是Spring的那一套
  8. 但现在Spring现在已经帮你实现了啊!
  9. 如果项目里的对象都是就new下就完事了,没有多个实现类,那没事,不用Spring也没啥问题
  10. 并且Spring核心不仅仅IOC啊,除了把对象创建出来,还有一整套的Bean生命周期管理
  11. 比如说你要实现对象增强,AOP不就有了吗?不然你还得自己创建代理

那你继续来聊下Spring AOP呗?

  1. Spring AOP解决的是非业务代码抽取的问题
  2. AOP底层的技术是动态代理,在Spring 内实现依赖的是BeanPostProcessor
  3. 比如我们需要在方法上注入些「重复性」的非业务代码,就可以利用Spring AOP
  4. 所谓的「面向切面编程」在我理解下其实就是在方法前后增加非业务代码

那你在工作中实际用到过AOP去优化你的代码吗?

  1. ·有的。当时我用AOP来对我们公司现有的监控客户端进行封装
  2. 一个系统离不开监控,监控基本的指标有QPS、RT、ERROR等等
  3. 对外暴露的监控客户端只能在代码里写对应的上报信息(灵活,但会与业务代码掺杂在一起)
  4. 于是我利用注解+AOP的方式封装了一把,只要方法/类上带有我自定义的注解
  5. 方法被调用时,就会上报AQS、RT等信息实现了非业务代码与业务代码分离的效果

了解,你们项目一般是怎么把对象交给IOC容器管理的?

换个问法:一般是怎么定义Bean的?

  1. Spring提供了4种方式,分别是:
    1):注解2):XML3):JavaConfig 4):基于Groovy 的DSL配置
  2. 一般项目我们用注解或XML比较多,少部分用JavaConfig
  3. 日常写业务代码一般用注解来定义各种对象,责任链这种一般配置在XML「注解」解决不了的就用JavaConfig
  4. 总体而言,还是得看项目的代码风格吧
  5. 反正就是定义元数据,能给到Spring解析就好了

要不来聊聊你使用Spring的感受?

  1. 当我还是初学Spring的时候,我觉得Spring很麻烦,需要有一大堆的配置信息才能跑起来
  2. 光是搭建环境就需要耗费我好长的时间
  3. 毕竟版本冲突,依赖冲突什么的就可能个下午就过去了
  4. 但毕竟一个系统环境只搭一次嘛,所以还好(后来用上了SpringBoot这又更方便了)
  5. 回来,IOC和AOP在工作用的时候还是很爽的
  6. 毕竟搞个注解什么的,配置下就可以把对象交给Spring管理了
  7. 配合Spring的生态,@Transactional注解什么的,都好用得飞起
  8. 不过,Spring给我们封装得太好了
  9. 经常就会有奇奇怪怪的”bug”出现,也踩过很多的坑了
  10. Bean经常没办法创建成功,导致项目启动失败..
  11. 对象的循环依赖问题.
  12. 同一个接口,多个实现,识别不出我要创建哪个对象.
  13. 为什么catch了异常,Spring事务为什么还会自动回滚..
  14. 等等等

循环以来

  1. 到这里,Spring整个解决循环依赖问题的实现思路已经比较清楚了。对于整体过程,读者朋友只要理解两点:

    • Spring是通过递归的方式获取目标bean及其所依赖的bean的;
    • Spring实例化一个bean的时候,是分两步进行的,首先实例化目标bean,然后为其注入属性。

    结合这两点,也就是说,Spring在实例化一个bean的时候,是首先递归的实例化其所依赖的所有bean,直到某个bean没有依赖其他bean,此时就会将该实例返回,然后反递归的将获取到的bean设置为各个上层bean的属性的。

l

17、【对线面试官】Redis基础

你先来讲讲为什么要用Redis吧?

  1. 我个人是这样理解的:无论Redis也好、MySQL也好、HDFS也好、HBase也好
  2. 他们都是存储数据的地方
  3. 因为它们的设计理念的不同,我们会根据不同的应用场景使用不同的存储
  4. 像Redis一般我们会把它用作于缓存
  5. 当然啦,日常有的应用场景比较简单,用个HashMap也能解决很多的问题了,没必要上Redis
  6. 这就好比,有的单机限流可能应对某些场景就够用了,也没必要说一定要上分布式限流把系统搞得复杂

那你在项目里有用到Redis吗?怎么用的?

  1. Redis肯定是用到的,我负责的项目几乎都会有Redisl的踪影
  2. 我举几个我这边项目用的案例呗?
  3. 我这边负责消息管理平台,简单来说就是发消息的
  4. 那发完消息肯定我们是得知道消息有没有下发成功的,是吧?
  5. 于是我们系统有一套完整的链路追踪体系
  6. 其中实时的数据我们就用Redis来进行存储,有实时肯定就会有离线的嘛(离线的数据我们是存储到Hive的)
  7. 对消息进行实时链路追踪,我这边就用了Redis好几种的数据结构,分别有Set、List和Hash
  8. 我再稍微铺垫下链路追踪的背景吧~
  9. 要在消息管理平台发消息,首先得在后台新建一个「模板」,有模板自然会有一个模板ID
  10. 对模板D进行扩展,比如说加上日期和固定的业务参数,形成的ID可以唯一标识某个模板的下发链路
  11. 在系统上,我这边叫它为UMPID
  12. 在发送入口处会对所有需要下发的消息打上UMPID,然后在关键链路上打上对应的点位
  13. 接下来的工作就是清洗出统一的模型,然后根据不同维度进行处理啦。比如说:
  14. 我要看某一天下发的所有模板有哪些,那只要我把清洗出来后数据的,将对应UMPID扔到了Set就好了
  15. 我要看某一个模板的消息下发的整体链路情况,那我以UMPID为Key,Value是Hash结构,Key是state,Value则是人数
  16. 这里的state我们在下发的过程中打的关键点位,比如接收到消息打个51,消息被去重了打个61,消息成功下发了打个81…
  17. 以UMPID为Key,Hash结构的Key(State)进行不断的累加,就可以实现某一个模板的消息下发的整体链路情况
  18. 我要看某个用户当天下发的消息有哪些,以及这些消息的整体链路是如何。
  19. 这边我用的是List结构,Key是userld,Value则是UMPID+state(关键点位)+processTime(处理时间)
  • 简单来说,就是通过Redis丰富的数据结构来实现对下发消息多个维度的统计
  • 不同的应用场景选择不同的数据结构,再等到透出做处理的时候,就变得十分简单了
  • 消息下发过程中去重或者一般正常的场景就直接Key-Value就能符合需求了
  • 像bitmap、hyperloglogs、sortset、steam等等这些数据结构在我所负责的项目用得是真不多
  • 要是我有机会去到贵公司,贵公司有相关的应用场景,我相信我也很快就能掌握
  • 这些数据结构底层都由对应的object来支撑着,objecti记录对应的「编码」
  • 其实就是会根据key-value存储的数量或者长度来使用选择不同的底层数据结构实现
  • 比如说:ziplist压缩列表这个底层数据结构有可能上层的实现是list、hash和sortset
  • Hash结构的底层数据结构可能是hash和ziplist
  • 在节省内存和性能的考量之中切换,Redis还是有点屌的啊。

就你上面那个实时链路场景,可以用其他的存储替代吗?

  1. 嗯,理论上是可以的(或许可以尝试用HBase),但总体来说没这么好吧
  2. 因为Redis拥有丰富的数据结构,在透出的时候,处理会非常的方便。
  3. 如果不用Redis的话,还得做很多解析的工作
  4. 并且,我那场景的并发还是相当大的(就一条消息发送,可能就产生10条记录)
  5. 监控峰值命令处理数会去到20K+QPS,当然了,这场景我肯定用了Pipeline的(不然处理会慢很多)
  6. 综合上面并发量和实时性以及数据结构,用Redis:是一个比较好的选择。

你觉得为什么Redis可以这么快?

  1. 首先,它是纯内存操作,内存本身就很快
  2. 其次,它是单线程的,Redis服务器核心是基于非阻塞的O多路复用机制,单线程避免了多线程的频繁上下文切换问题
  3. 至于这个单线程,其实官网也有过说明(:表示使用Redis往往的瓶颈在于内与和网络,而不在于CPU
l

16、【对线面试官】SpringBean生命周期

今天要不来聊聊Spring对Bean的生命周期管理?

  1. 嗯,没问题的。
  2. 很早之前我就看过源码,但Spring源码的实现类都太长了
  3. 我也记不得很清楚某些实现类的名字
  4. 要不我大概来说下流程?
  5. 首先要知道的是:普通Java对象和Spring.所管理的Bean实例化的过程是有些区别的
    在普通Java环境下创建对象简要的步骤可以分为以下几步:
    • java源码被编译为被编译为class文件
    • 等到类需要被初始化时(比如说new、反射等)
    • class文件被虚拟机通过类加载器加载到JVM
    • 初始化对象供我们使用
  6. 简单来说,可以理解为它是用Class对象作为「模板」进而创建出具体的实例
  7. 而Spring所管理的Bean不同的是,除了Class.对象之外,还会使用BeanDefinition的实例来描述对象的信息
  8. 比如说,我们可以在Spring.所管理的Bean有一系列的描述:@Scope、@Lazy、@DependsOn等等
  9. 可以理解为:Class只描述了类的信息,而BeanDefinition:描述了对象的信息

你就是想告诉我,Spring有BeanDefinition来存储着我们日常给Spring Bean定义的元数据(@Scope、@Lazy、@DependsOn等等),对吧?

  1. Spring在启动的时候需要「扫描」在XML/注解/JavaConfig中需要被Spring管理的Bean信息
  2. 随后,会将这些信息封装成BeanDefinition,最后会把这些信息放到一个beanDefinitionMap中
  3. 我记得这个Map的key应该是beanName,value则是BeanDefinition.对象
  4. 到这里其实就是把定义的元数据加载起来,目前真实对象还没实例化
  5. 接着会遍历这个beanDefinitionMap,执行BeanFactoryPostProcessor这个Bean工厂后置处理器的逻辑
  6. 比如说,我们平时定义的占位符信息,就是通过BeanFactoryPostProcessor的子类PropertyPlaceholderConfigurer进行注入进去
  7. 当然了,这里我们也可以自定义BeanFactoryPostProcessor来对我们定义好的Bean元数据进行获取或者修改。只是一般我们不会这样干,实际上也很有少的使用场景。
  8. BeanFactoryPostProcessor/后置处理器执行完了以后,就到了实例化对象啦
  9. 在Spring.里边是通过反射来实现的,一般情况下会通过反射选择合适的构造器来把对象实例化
  10. 但这里把对象实例化,只是把对象给创建出来,而对象具体的属性是还没注入的。
  11. 比如我的对象是UserService,而UserService对象依赖着SendService对象,这时候的SendService还是null的
  12. 所以,下一步就是把对象的相关属性给注入
  13. 相关属性注入完之后,往下接着就是初始化的工作了
  14. 首先判断该Bean是否实现了Aware相关的接口,如果存在则填充相关的资源
    比如我这边在项目用到的:我希望通过代码程序的方式去获取指定的Spring Bean
  15. 我们这边会抽取成一个工具类,去实现ApplicationContextAware接口,来获取ApplicationContexti对象进而获取Spring Bean
  16. Aware相关的接口处理完之后,就会到BeanPostProcessor后置处理器啦
  17. BeanPostProcessor后置处理器有两个方法,一个是before,一个是after。
    (那肯定是before先执行、after)后执行)
  18. 这个BeanPostProcessor)后置处理器是AOP实现的关键
    关键子类AnnotationAwareAspectJAutoProxyCreator
  19. 所以,执行完Aware相关的接口就会执行,BeanPostProcessor相关子类的before方法。
  20. BeanPostProcessor相关子类的before方法执行完,则执行init相关的方法,比如说@PostConstruct、实现了InitializingBean接口、定义的init-method方法
  21. 当时我还去官网去看他们的被调用「执行顺序」分别是:@PostConstruct、实现了InitializingBean:接口以及init-nethod方法
  22. 这些都是Spring:给我们的「扩展」,像@PostConstruct我就经常用到
  23. 比如说:对象实例化后,我要做些初始化的相关工作或者就启个线程去Kafka拉取数据
  24. 等到init方法执行完之后,就会执行BeanPostProcessor的after方法
  25. 基本重要的流程已经走完了,我们就可以获取到对象去使用了
  26. 销毁的时候就看有没有配置相关的destroy方法,执行就完事了

你看过Spring:是怎么解决循环依赖的吗?如果现在有个A对象,它的属性是B对象,而B对象的属性也是A对象说白了就是A依赖B,而B又依赖A,Spring是怎么做的?

  1. 从上面我们可以知道,对象属性的注入在对象实例化之后的嘛。
  2. 它的大致过程是这样的:首先A对象实例化,然后对属性进行注入,发现依赖B对象
    B对象此时还没创建出来,所以转头去实例化B对象
  3. B对象实例化之后,发现需要依赖A对象,那A对象已经实例化了嘛,所以B对
    象最终能完成创建
  4. B对象返回到A对象的属性注入的方法上,A对象最终完成创建。这就是大致的过程。

哦?听起来你还会原理哦?

  1. 至于原理,其实就是用到了三级的缓存
  2. 所谓的三级缓存其实就是三个Map.…首先明确一定,我对这里的三级缓存定义是这样的:
    singletonObjects(一级,日常实际获取Bean的地方);
  3. earlySingletonObjects(二级,已实例化,但还没进行属性注入,由三级缓存放进来);
  4. singletonFactories(三级,Value:是一个对象工厂);
  5. 再回到刚才讲述的过程中,A对象实例化之后,属性注入之前,其实会把A对象放入三级缓存中
  6. key是BeanName,Value:是ObjectFactory
  7. 等到A对象属性注入时,发现依赖B,又去实例化B时
  8. B属性注入需要去获取A对象,这里就是从三级缓存里拿出ObjectFactory,从ObjectFactory得到对应的Bean(就是对象A)
  9. 把三级缓存的A记录给干掉,然后放到二级缓存中
  10. 显然,二级缓存存储的key是BeanName,value就是Bean(这里的Bean还没做完属性注入相关的工作)
  11. 等到完全初始化之后,就会把二级缓存给remove掉,塞到一级缓存中
  12. 我们自己去getBean的时候,实际上拿到的是一级缓存的
  13. 大致的过程就是这样

那我想问一下,为什么是三级缓存?

  1. 首先从第三级缓存说起(就是key是BeanName,Value为ObjectFactory)
  2. 我们的对象是单例的,有可能A对象依赖的B对象是有AOP的(B对象需要代理)
  3. 假设没有第三级缓存,只有第二级缓存(Value存对象,而不是工厂对象)
  4. 那如果有AOP的情况下,岂不是在存入第二级缓存之前都需要先去做AOP代理?这不合适嘛
  5. 这里肯定是需要考虑代理的情况的,比如A对象是一个被AOP增量的对象,B依赖A时,得到的A肯定是代理对象的
  6. 所以,三级缓存的Value是ObjectFactory,可以从里边拿到代理对象
  7. 而二级缓存存在的必要就是为了性能,从三级缓存的工厂里创建出对象,再扔到二级缓存(这样就不用每次都要从工厂里拿)

总结

  1. 首先是Spring Bean的生命周期过程,Sprng使用BeanDefinition:来装载着我们给Bean定义的元数据

  2. 实例化Bean的时候会遍历BeanDefinitionMap

  3. Springl的Bean实例化和属性赋值是分开两步来做的

  4. 在Spring Beanl的生命周期,Spring预留了很多的hook给我们去扩展

    1):Bean实例化之前有BeanFactoryPostProcessor
    2):Bean实例化之后,初始化时,有相关的Aware接口供我们去拿到Context相关信息
    3):环绕着初始化阶段,有BeanPostProcessor(AOP的关键)
    4):在初始化阶段,有各种的init方法供我们去自定义

  5. 而循环依赖的解决主要通过三级的缓存

  6. 在实例化后,会把自己扔到三级缓存(此时的key是BeanName,Value是ObjectFactory)

  7. 在注入属性时,发现需要依赖B,也会走B的实例化过程,B属性注入依赖A,从三级缓存找到A

  8. 删掉三级缓存,放到二级缓存

l

18、【对线面试官】Redis持久化

嗯,开始吧,今天要不来聊聊Redisl的持久化机制吧?

  1. 在上一次面试已经说过了Redis:是基于内存的

  2. 假设我们不做任何操作,只要Redis服务器重启(或者中途故障挂掉了),那内存的数据就会没掉

  3. 所以Redis:提供了持久化机制给我们用,分别是RDB和AOF

    1)RDB指的就是:根据我们自己配置的时间或者手动去执行BGSAVE或SAVE命令,Redisi就会去生成RDB文件

    2)这个RDB文件实际上就是一个经过压缩的二进制文件,Redis可以通过这个文件在启动的时候来还原我们的数据

    1)而AOF则是把Redis服务器接收到的所有写命令都记录到日志中

    2)Redis重跑一遍这个记录下的日志文件,就相当于还原了数据

那我就想问了,你上次不是说Redis是单线程吗?那比如你说的RDB,它会执行SAVE或BESAVE命令,生成文件。那不是非常耗时的吗,那如果只有一个线程处理,那其他的请求不就得等了?

  1. 嗯,没错,Redis是单线程的。
  2. 以RDB持久化的过程为例,假设我们在配置上是定时去执行RDB存储
  3. Redis有自己的一套事件处理机制,主要处理文件事件(命令请求和应答等等)和时间事件(RDB定时持久化、清理过期的Key等的)
  4. 所以,定时的RDB实际上就是一个时间事件
  5. 线程不停地轮询就绪的事件,发现RDB的事件可执行时,则调用BGSAVE命令
  6. 而BGSAVE命令实际上会fork出一个子进程来进行完成持久化(生成RDB文件)
  7. 在fork的过程中,父进程(主线程)肯定是阻塞的。
  8. 但fork完之后,是fork出来的子进程去完成持久化。处理请求的进程该干嘛的就干嘛
  9. 所以说啊,Redis:是单线程,理解是没错的,但没说人家不能fork进程来处理事情。
  10. 还有就是,其实Redis在较新的版本中,有些地方都使用了多线程来进行处理
  11. 比如说,一些删除的操作(UNLINK、FLUSHALL ASYNC等等)还有Redis6.x之后对网络数据的解析都用了多线程处理了。
  12. 只不过,核心的处理命令请求和响应还是单线程。

那AOF呢?AOF不是也要写文件吗?难道也是fork了个子进程去做的?

  1. emm,不是的。AOF是在命令执行完之后,把命令写在buffer缓冲区的(直接追加写)
  2. 那想要持久化,肯定得存盘嘛。Redis:提供了几种策略供我们选择什么时候把缓冲区的数据写到磁盘
  3. 我记得好像有:每秒一次/每条命令都执行从不存盘;一般我们会选每秒一次
  4. Redis会启一个线程去刷盘,也不是用主线程去干的

那如果把执行过的命令都存起来;等启动的时候是可以再把这些写命令再执行一遍,达到恢复数据的效果;这样会有什么样的问题吗?

  1. 嗯,问题就是,如果这些写入磁盘的「命令集合」不做任何处理,那该「命令集合」就会一直膨胀
  2. 其实就是该文件会变得非常大
  3. Redis当然也考虑了这一点,它会fork个子进程会对「原始」命令集合进行重写
  4. 说白了就是会压缩,压缩完了之后只要替换原始文件就好了

那我又想问了,既然它是fork一个进程来对AOF进行重写的;前面你也提到了再fork时,主进程是阻塞的,但fork后,主进程会继续接收命令;你是说重写完(压缩)会进行文件覆盖;那这样不会丢数据吗?毕竟主进程在fork之后是一直会接收命令的

  1. 其实做法很简单啊,在fork子进程之后,把新接收到命令再写到另一个缓冲区不就好了吗

那AOF和RDB用哪一个呢?

  1. 主要是看业务场景吧,我们这边是基于Redis使用了一套开源的key-value存储
  2. 使用Redis前,首先要去新增实例,在新增时会让你选择对应的使用场景
  3. 就是会让你通过不同的应用场景进行配置选择
  4. 比如说,业务上是允许重启时部分数据丢失的,那RDB就够用了
  5. RDB在启动的时候恢复数据会比AOF快很多
  6. 在Redis4.0以后也支持了AOF和RDB混合
  7. 至于AOF的话,官网是不建议仅仅只使用AOF的,如果对数据丢失容忍度是有要求的,建议是开启AOF+RDB一起用
  8. 总的来说,不同的场景使用不同的持久化策略吧
  9. 我们公司也是不建议把Redis当做存储去使用的(毕竟没有事务保证,也还是可能导致数据丢失)

顺便我想问下,假如Redisl的内存满了,但业务还在写数据,会怎么样?

  1. 嗯,这个问题我也遇到过
  2. 一般来说,我们会淘汰那些「不活跃」的数据,然后把新的数据写进去
  3. 更多情况下,还是做好对应的监控和容量的考量吧。等容量达到阈值的时候,及时发现和扩容

那要不来讲讲扩容和Redisl的架构吧?

下次吧

那要不来讲讲扩容和Redisl的架构吧?

  1. Redis的官网啊,看了这么多技术官网,我觉得Redis的官网弄得是真不错
  2. 《Redis设计与实现》这本书也挺不错的
l

14、【对线面试官】SpringMVC

今天要不来聊聊SpringMVC吧?

  1. 我先简单说下我对SpringMVC的理解哈
  2. SpringMVC我觉得它是对Servlet的封装,屏蔽掉Servlet很多的细节卫
  3. 接下来我举几个例子
  4. 可能我们刚学Servlet的时候,要获取参数需要不断的getParameter
  5. 现在只要在SpringMVC方法定义对应的J avaBean,只要属性名与参数名一致,SpringMVC就可以帮我们实现「将参数封装到JavaBean」上了
  6. 又比如,以前使用Servlet 「上传文件」,需要处理各种细节,写一大堆处理的逻辑(还得导入对应的jar)
  7. 现在一个在SpringMVC的方法上定义出MultipartFile接口,又可以屏蔽掉上传文件的细节了。
  8. 例子还有很多,我就不赘述了。

既然你说SpringMVC是对Servlet的封装,你了解SpringMVC请求处理的流程吗?

  1. 总体流程大概是这样的
    • 首先有个统一处理请求的入口
    • 随后根据请求路径找到对应的映射器
    • 找到处理请求的适配器
    • 4):拦截器前置处理
    • 5):真实处理请求(也就是调用真正的代码)
    • 6):视图解析器处理
    • 7):拦截器后置处理

嗯,了解,可以再稍微深入点吗?

  1. 统一的处理入口,对应SpringMVC下的源码是在DispatcherServlet下实现的
  2. 该对象在初始化就会把映射器、适配器、视图解析器、异常处理器、文件处理器等等给初始化掉
  3. 至于会初始化哪些具体实例,看下DispatcherServlet.properties就知道了,都配置在那了
  4. 所有的请求其实都会被doService方法处理,里边最主要就是调用doDispatch方法
  5. 通过doDispatch方法我们就可以看到整个SpringMVC处理的流程
  6. 查找映射器的时候实际就是找到「最佳匹配」的路径,具体方法实现我记得好像是在lookupHandlerMethod方法上
  7. 从源码可以看到「查找映射器」实际返回的是HandlerExecutionChain,里边有映射器Handler+拦截器List
  8. 前面提到的拦截器前置处理和后置处理就是用的HandlerExecutionChain中的拦截器List
  9. 获取得到HandlerExecutionChain后,就会去获取适配器,一般我们获取得到的就是RequestMappingHandlerAdapter
  10. 在代码里边可以看到的是,经常用到的@ResponseBody和@Requestbody的解析器
  11. 就会在初始化的时候加到参数解析器List中
  12. 得到适配器之后,就会执行拦截器前置处理
  13. 拦截器前置处理执行完后,就会调用适配器对象实例的hanlde方法执行真正的代码逻辑处理
  14. 核心的处理逻辑在invokeAndHandle方法中,会获取得到请求的参数并调用,处理返回值
  15. 参数的封装以及处理会被适配器的参数解析器进行处理,具体的处理逻辑取决于HttpMessageConverter的实例对象

嗯,了解了。要不你再压缩下关键的信息

  1. DispatcherServlet (入口)

  2. DispatcherServlet.properties(会初始化的对象)

  3. HandlerMapping (映射器,

  4. HandlerExecutionChain(映射器最终实例+拦截器List)

  5. HttpRequestHandlerAdapter(适配器

  6. HttpMessageConverter(数据转换

l

19、【对线面试官】kafka基础

今天要不来聊聊消息队列吧?我看你项目不少地方都写到Kafka了.你简单说明下你使用Kafka的场景吧

  1. 使用消息队列的目的总的来说可以有三种情况:解耦、异步和削峰

  2. 比如举我项目的例子吧,我现在维护一个消息管理平台系统,对外提供发送接口给各个业务方调用

  3. 他们调用接口之后,实际上『不是同步』下发了消息。

  4. 在接口处理层只是把该条消息放到了消息队列上,随后就直接返回结果给接口调用者了。

  5. 这样的好处就是:

    1)接口的吞吐量会大幅度提高(因为未做真正实际调用,接口RT会非常低)【异步】

    2)即便有大批量的消息调用接口都不会让系统受到影响(流量由消息队列承载)【削峰】

有点抽象,再举个实际案例?

  1. 又比如说,我这边还有个项目是广告订单归因工程,主要做的事情就是得到订单数据,给各个业务广告计算对应的佣金。

  2. 订单的数据是从消息队列里取出的

  3. 这样设计的好处就是:

    1)交易团队的同学只要把订单消息写到消息队列,该订单数据的Topic由各个业务方自行消费使用【解耦】【异步】

    2)即便下单QPS猛增,对下游业务无太大的感知(因为下游业务只消费消息队列的数据,不会直接影响到机器性能)【削峰】

那我想问下,你觉得为什么消息队列能削峰?或者换个问法,为什么Kafka能承载这么大的QPS?

  1. 消息队列「最核心的功能就是把生产的数据存储起来,然后给各个业务把数据再读取出来。

  2. 跟我们处理请求时不一样,我们在业务处理时可能会调别人的接口,可能会需要去查数据库…等等等一系列的操作才行

  3. 这些业务操作都是非常耗时的,像Kafka在「存储」和「读取」这个过程中又做了很多的优化

  4. 举几个例子,比如说:

    1)我们往一个Topic发送消息或者读取消息时,实际内部是多个Partition在处理【并行】

    2)在存储消息时,Kafka内部是顺序写磁盘的,并且利用了操作系统的缓冲区来提高性能【append+cache】

    3)在读写数据中也减少CPU拷贝的次数【零拷贝】

嗯,你既然提到减少CPU拷贝的次数,可以给我说下这项技术吗?

  1. 嗯,可以的,其实就是零拷贝技术。

  2. 比如我们正常调用read函数时,会发生以下的步骤(以读磁盘的数据为例):

    1)DMA把磁盘数据拷贝到读内核缓存区

    2)CPU把读内核缓冲区的数据拷贝到用户空间

  3. 正常调用write函数时,会发生以下的步骤(数据写到网卡为例):

    1)CPU把用户空间的数据拷贝到Socket内核缓存区

    2)DMA把Socket内核缓冲区的数据拷贝到网卡

  4. 可以发现完成「一次读写」需要2次DMA拷贝,2次CPU拷贝。

  5. 而DMA拷贝是省不了的,所谓的零拷贝技术就是把CPU的拷贝给省掉

  6. 并且为了避免用户进程直接操作内核,保证内核安全,应用程序在调用系统函数时,会发生上下文切换(上述的过程一共会发生4次)

  7. 目前零拷贝技术主要有:mmap和sendfile

  8. 比如说:mmap是将读缓冲区的地址和用户空间的地址进行映射,实现读内核缓冲区和应用缓冲区共享

  9. 从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝

  10. 使用mmap的后一次读写就可以简化为:、

    一、DMA把硬盘数据拷贝到读内核缓冲

    二、CPU把读内核缓存区拷贝至Socket内核缓冲区。

    三、DMA把Socket内核缓冲区拷贝至网

  11. 由于读内核缓冲区与用户空间做了映射,所以会省了一次CPU拷贝

  12. 而sendfile+DMA Scatter/Gather!则是把读内核缓存区的文件描述符/长度信息发到Socket内核缓冲区,实现CPU零拷贝

  13. 使用sendfile+DMA Scatter/Gather一次读写就可以简化为:

    1)DMA把硬盘数据拷贝至读内核缓冲区

    2)CPU把读缓冲区的文件描述符和长度信息发到Socket缓冲区。

    3)DMA根据文件描述符和数据长度从读内核缓冲区把数据拷贝至网卡

  14. 回到kafka上吧

  15. 从Producer-》Broker,Kafka是把网卡的数据持久化硬盘,用的是mmap(从2次
    CPU拷贝减至1次)

  16. 从Broker-》Consumer,Kafka是从硬盘的数据发送至网卡,用的是sendFile(实
    现CPU零拷贝)

总结

Kafka能这么快的原因就是实现了并行、充分利用操作系统cache、顺序写和零拷贝

l

20、【对线面试官】使用kafka会考虑什么问题

你提到了你这边会从交易的消息报获取到订单的数据,然后做业务的处理;也提到了你用的是Kafka,我想问下,Kafka会丢数据吗?

  1. 嗯,使用Kafkal时,有可能会有以下场景会丢消息

  2. 比如说,我们用Producer发消息至Broke的时候,就有可能会丢消息

  3. 如果你不想丢消息,那在发送消息的时候,需要选择带有callBack的api进行发送

  4. 其实就意味着,如果你发送成功了,会回调告诉你已经发送成功了。如果失败了,那收到回调之后自己在业务上做重试就好了。

  5. 等到把消息发送到Brokerl以后,也有可能丢消息

  6. 一般我们的线上环境都是集群环境下嘛,但可能你发送的消息后broker就挂了,这时挂掉的broker还没来得及把数据同步给别的broker,数据就自然就丢了

  7. 发送到Broker之后,也不能保证数据就一定不丢了,毕竟Broker会把数据存储到磁盘之前,走的是操作系统缓存

  8. 也就是异步刷盘这个过程还有可能导致数据会丢

  9. 嗯,到这里其实我已经说了三个场景了,分别是:producer-》broker,broker-》broker之间同步,以及broker-》磁盘

  10. 要解决上面所讲的问题也比较简单,这块也没什么好说的…

  11. 不想丢数据,那就使用带有callback的api设置acks、retries、factor等等些参数来保证Producer发送的消息不会丢就好啦。

一般来说,还是client消费broker丢消息的场景比较多;那你们在消费数据的时候是怎么保证数据的可靠性的呢?

  1. 首先,要想client端消费数据不能丢,肯定是不能使用autoCommit的,所以必须是手动提交的。

  2. 我们这边是这样实现的:

    一、从Kafka拉取消息、(一次批量拉取500条,这里主要看配置)
    二、为每条拉取的消息分配一个msgld(递增)
    三、将msgld存入内存队列(sortSet)中
    四、使用Map存储msgld.与msg(有offset相关的信息)的映射关系

    五、当业务处理完消息后,ack时,获取当前处理的消息nsgld,然后从sortSet删除该msgld(此时代表已经处理过了)
    六、接着与sortSet队列的首部第一个ld比较(其实就是最小的msgld),如果当前msgld<=sort Set第一个ID,则提交当前offset

    七、系统即便挂了,在下次重启时就会从sortSet队首的消息开始拉取,实现至少处理一次语义
    八、会有少量的消息重复,但只要下游做好幂等就OK了

嗯,你也提到了幂等,你们是怎么实现幂等性的呢?

  1. 嗯,还是以处理订单消息为例好了。
  2. 幂等Key我们由订单编号+订单状态所组成(一笔订单的状态只会处理一次)
  3. 在处理之前,我们首先会去查Redis:是否存在该Key,如果存在,则说明我们已经处理过了,直接丢掉
  4. 如果Redis没处理过,则继续往下处理,最终的逻辑是将处理过的数据插入到业务DB上,再到最后把幂等Key插入到Redis上
  5. 显然,单纯通过Redis是无法保证幂等的
  6. 所以,Redis其实只是一个「前置」处理,最终的幂等性是依赖数据库的唯一Key来保证的(唯一Key实际上也是订单编号+状态)
  7. 而插入DB是依赖事务的,所以是没问题的
  8. 总的来说,就是通过Redis做前置处理,DB唯一索引做最终保证来实现幂等性的

你们那边遇到过顺序消费的问题吗?

  1. 嗯,也是有的,我举个例子

  2. 订单的状态比如有支付、确认收货、完成等等,而订单下还有计费、退款的消息报

  3. 理论上来说,支付的消息报肯定要比退款消息报先到嘛,但程序处理的过程中可不一定的嘛

  4. 所以在这边也是有消费顺序的问题(先处理了支付,才能退款啊)

  5. 但在广告场景下不是「强顺序」的,只要保证最终一致性就好了。

  6. 所以我们这边处理「乱序」消息的实现是这样的:

    1)宽表:将每一个订单状态,单独分出一个或多个独立的字段。消息来时只更新对应的字段就好,消息只会存在短暂的状态不一致问题,但是状态最终是一致的

    2)消息补偿机制:另一个进行消费相同topicl的数据,消息落盘,延迟处理。将消息与DB进行对比,如果发现数据不一致,再重新发送消息至主进程处理

    3)还有部分场景,可能我们只需要把相同userld/orderld.发送到相同的partition(因为一个partition由一个Consumer消费),又能解决大部分消费顺序的问题了呢。

l