多线程异常、MQ、Kafka(八股)

多线程问题

对于mysql里面上万条数据,我开多线程,比如10个,要是实现第一个线程处理前k条,后面的依次处理后k条,这怎么实现?如果第3个线程处理k到k+n条数据出问题的时候,我应该记录这批次数据的id到一个单独的地方,我觉得这数据量小,可以存在List里面,然后该批次(批次大小200)的所有数据都进行事务回滚,不提交。最后全部都处理完了,再去处理这些异常的list列表里面的数据,

数据分片和多线程分配

我希望10个线程去处理不同的数据段,比如每个线程处理1万条数据,这是数据分片或者说范围划分

实现方式:

  1. 主线程预先分片:在主线程中,首先确定总数据量(比如一万条)和分片规则,一个简单的有效的方法就是基于主键ID进行范围划分(假设ID是连续的)
    • 查询出最小和最大的ID
    • 计算每个线程需要处理的数据量sliceSize = totalRecords / numThreads
    • 为每个线程分配一个ID范围Thread 1: [minId, minId + sliceSize),Thread 2: [minId + sliceSize, minId + 2 * sliceSize)
  2. 每个线程内部分批:每个线程负责处理分配给它的哪个大范围,在线程内部,为了控制事务大小和内存使用,会为这个大范围的数据按照批次大小(比如200条)进行处理
    • 线程3会从数据库分页查询WHERE id >= start_id AND id<end_id LIMIT 200 OFFSET 0,处理第一批200条
    • 然后OFFSET 200 处理下一批,直到处理完

这样做的好处就是避免了线程间对同一条数据的竞争,因为每个线程处理的数据范围是互斥的。

异常处理、回滚、重试

  1. 事务边界:每个批次200条作为一个独立的事务
    • 开始处理批次前,开启一个数据库事务。
    • 如果这200条中任何一条处理失败(抛出异常),整个事务会立即回滚,数据库会撤销该批次内所有已执行的操作,保证数据一致性
    • 如果全部成功就是commit事务
  2. 异常捕获和记录
    • 当第三个线程处理范围的某个批次失败时,会被try-catch捕获
    • 在catch块中,首先执行事务回滚,确保数据库干净
    • 然后不是记录单个失败的id,而是记录整个失败批次的标识信息,这个标识可以是起始ID和结束ID(failedRange: {start: 3000, end: 3200),或者是批次的偏移量信息(threadId: 3, batchIndex: 5)
    • 将这个标识符添加到一个全局的线程安全的异常批次列表中,比如ConcurrentLinkedQueue或者Collections.synchronizedList(new ArrayList<>)中,对于数据量小的,用List足够。
  3. 后续处理
    • 主线程使用ExecutorService和countDownLatch等工具,等待所有10个工作线程完成他们各自的数据范围
    • 所有正常批次处理完毕后,程序开始检查哪个全局的异常批次列表
    • 然后,可以启动新的线程或者复用原来的线程,专门对这些之前失效的批次进行重试,重试逻辑和之前一样,每个批次一个事务,成功则提交,失败则回滚然后再次记录,定义一个retryMaxCount,超过了就人工重试吧。

线程池

从问题入手,线程池拒绝策略怎么选才不会丢失任务?

线程池拒绝策略的触发,本质是:"任务提交速度超过了线程池的处理能力"

  1. 核心线程全忙
  2. 任务队列已存满待执行任务
  3. 线程数以达到最大线程数,无法再创建新线程。

JDK提供了4种拒绝策略,实现RejectedExecutionHandler接口,

AbortPolicy(默认策略):直接抛异常,任务必丢

  • 核心逻辑
    一旦触发拒绝,直接抛出RejectedExecutionException异常,提交的任务不会被执行。
java 复制代码
ThreadPoolExecutor executor = new ThreadPoolExecutor(
        5,10,60L,TimeUnit.SECONDS,
        new LinkedBlockingQueue<Runnable>(20),
        new ThreadPoolExecutor.AbortPolicy()//默认策略,可省略
 )
  • 适用场景
    适合任务丢失会直接导致业务异常,需要立即感知的场景,比如金融转账(丢了转账任务必须马上报错,不能默默吞掉)
  • 风险点
    如果没有捕获异常,会导致提交任务的线程崩溃;如果捕获了异常却不处理,任务还是会丢。

DiscardPolicy:默默丢弃任务

  • 核心逻辑
    触发拒绝时,直接丢弃任务,不抛出任何异常

DiscardOldestPolicy:丢弃队列最老任务,保留新任务

  • 核心逻辑
    触发拒绝时,先把任务队列种"最老的任务(队列头部任务)"丢弃,再把新提交的任务加入队列。
  • 使用场景
    适合新任务比老任务更重要的场景,比如实时数据统计(用户最新的行为数据比10秒前的更有价值,丢老数据保留新数据)。
  • 风险点
    会丢老任务,且如果队列种是"必须执行的核心任务"(比如订单创建),丢老任务会导致业务异常 。

CallerRunsPolicy: 让提交任务的线程自己执行,不丢任务

  • 核心逻辑
    触发拒绝时,不丢任务也不抛异常,而是让"提交任务的线程"(比如主线程)自己执行这个任务。
  • 使用场景
    比如秒杀下单,用户注册---即使线程池满了,也能让提交任务的线程执行任务,虽然会慢,但是不会丢失
  • 关键优势
    任务无丢失,只要提交线程不崩溃。任务一定可以执行。
    自带限流效果:提交线程执行任务时,无法再提交新任务,相当于"减缓任务提交速度",间接帮线程池减轻压力
  • 风险点
    如果提交任务的线程是"核心线程(如主线程)",执行任务时会阻塞主线程,导致其他业务受影响。

自定义拒绝策略:MQ

  • 核心逻辑
    触发拒绝时,不直接处理任务,而是把任务丢进消息队列种,然后由专门的消费者线程从MQ中拉取任务,重新提交到线程池,直到执行成功
  • 使用场景
    绝对不能丢任务的核心任务,比如订单支付、转账、库存扣减---即使线程池长期满负荷,任务也能在MQ中暂存,重试后执行。
  • 关键优势
    无任务丢失风险,且不阻塞提交线程

1. CallerRunsPolicy会阻塞提交线程,怎么避免?

CallerRunsPolicy确实可能阻塞线程,所以实际用的时候要加两个限制:第一,判断提交线程是否是核心线程,如果是核心线程,就不用CallerRunsPolicy,

改用MQ重试策略,避免阻塞核心业务;第二,给CallerRunsPolicy加超时控制,比如用Future.get(timeout),超时则丢弃任务。

2. 用MQ重试策略,怎么避免任务重复执行

要避免任务重复执行,需要考虑以下问题:第一,任务要带唯一标识(比如订单ID),提交到MQ时把唯一标识作为消息ID;第二,执行任务前先检查这个唯一标识的任务是否已经执行过(查mysql或者redis)

,如果已经执行过就直接返回成功,没执行过再执行---比如下单任务,用订单ID作为唯一标识,执行前查Redis是否有order:123:executed的key,有就跳过,没有就执行扣减库存,执行完再存key。

3. 线程池满了,除了拒绝策略,还有什么办法避免丢任务

除了拒绝策略,还要从源头减少拒绝的发生:第一:提前预热核心线程,避免首次任务延迟导致队列堆积;第二,合理设置线程池参数(核心线程数=业务峰值QPS/单线程QPS,队列容量 = 峰值持续时间*单线程QPS),比如秒杀峰值1000QPS,单线程每秒处理10个任务,核心线程数设置为100,

队列容量设置为500,基本能应对峰值;第三,在提交任务前检查线程池状态,如果队列满了,就提前返回系统繁忙稍后再试。

总结

核心业务,希望不丢任务,用MQ重试策略,非核心业务,实时场景,用DiscardOldestPolicy,简单场景(不阻塞核心线程),用CallerRunsPolicy,绝对不推荐AbortPolicy和DiscardPolicy

用CallerRunsPolicy别阻塞核心线程,用MQ重试别忘用幂等机制,用动态扩容别超过资源上限。

拒绝策略只是兜底方案,更重要的是,提前优化线程池参数+源头控制流量,从根本上减少拒绝的发生。

Kafka问题

基础

​ 首先,kafka包含生产者集群、broker集群、ZK集群、消费者集群,生产者集群发送消息到broker集群,broker集群收集后由消费者集群消费,broker集群会上报ZK信息,维持心跳。

​ broker可以理解为kafka本身,接收生产者消息,持久化到磁盘,然后处理消费者的一个拉取的请求。

​ ZK就是一个注册中心,用于管理broker集群,保存topic和partition的一个路由信息。

​ 从消息的角度来看,broker会通过topic去分类。生产者呢,把不同类型的消息发送给对应的topic然后订阅者去订阅对应的topic,然后进行消费topic。

​ 从存储的角度来看,topic内部其实是分区,也就是partition。

​ 这个partition就是消息队列。

消费

​ 一条消息可以被多个消费者消费,那么问题就来了,我怎么知道每个消费者的消费到哪里了呢,所以,partition中每个消息都有一个唯一标识,叫做偏移量offset。

​ 也就是个单调递增的整数,所以记录offset就知道消费到哪了。

消息的顺序

​ 如果一个topic下有多个partition,每个partition都只保存一部分的数据。broker是做集群的,所以一个topic下的不同的partition可以分布到不同的broker节点上,这就解决了单机的性能瓶颈。

​ 但是kafka只能保证同一个partition内部的消息是有序的,也就是说,单个队列内的消息是有序的,不同的partition之间,也就是不同的队列之间,消息的顺序性无法保证。

高可用/主从/数据备份

​ 那么问题来了,每个partition都只存一部分数据,那如果broker挂了,那存的这部分数据不就丢失了?kafka是如何保证高可用的呢?

​ kafka提供了一个多副本机制,就是每个partition的数据都会同步到其他的broker节点上,每一个partition在其他的broker上就有了多个副本,然后这些副本会选择一个leader出来,让leader去和生产者消费者去交互,然后leader收到了写请求,他就会把数据同步给所有的副本,其他副本都是备份。

​ 这里就是一个主从的概念,严格来说是主备,只不过kafka这里做的是partition层面的主备,而不是broker层面的主备。

​ 那为什么不读副本呢,如果说要读这个副本的partition的话,那你上次读这个partition,下次读哪个partition,offset就很难管理了。

消费者组

​ 那一个topic下面有多个分布到不同broker节点上的partition,那么多的partition,如果只有一个消费者消费太慢了吧。

​ 怎么办呢?

​ 多个消费者可以组成一个消费者组,然后消费者组内的多个消费者就可以并行的消费topic中不同的partition。

​ 注意,topic中的一个partition只能被一个消费者组的一个消费者消费。

消息的分发和获取

​ 那么说消费者组内的数量超过了topic的数量,那么多余的消费者就空闲了,就不能消费了,那么问题又来了,多个broker,生产者发消息的时候怎么知道应该发给哪个broker呢?

​ 消费者获取消息的时候怎么知道应该从哪个broker中获取消息呢?

​ 首先broker集群定时向zk发送心跳包,上报自身信息,那zk就掌管了所有的broker topic和partition的信息。

​ broker呢,会选举出一个大哥叫controller,他会监听zk中的一个topic的一个变化,一旦topic变化,那么controller就会从zk里面拉取最新数据。然后广播给其他的broker,所以每个broker中其实都会存储最新的集群信息和路由表。

生产者通过访问broker其实就能获取到路由信息,然后根据配置的一个分区策略把消息直接发送给目标topic对应的那个leader partition对应的broker,然后broker收到消息之后写到partition末尾,然后分配一个offset,然后leader会同步数据给自己的副本 。

​ 消息的一个消费者呢,通过访问broker就能获取到订阅这个topic的一个路由信息,就能知道应该去哪个broker中去拉取消息了,然后通过自己记录最新的offset就可以去读取信息了。

相关推荐
T___T3 小时前
从代码到页面:HTML/CSS/JS 渲染全解析
前端·面试
Cx330❀4 小时前
《C++ 搜索二叉树》深入理解 C++ 搜索二叉树:特性、实现与应用
java·开发语言·数据结构·c++·算法·面试
1.01^100016 小时前
[7-01-02].第10节:开发应用 - 配置Kafka中消费消息策略
kafka
007php00717 小时前
某游戏互联网大厂Java面试深度解析:Java基础与性能优化(一)
java·数据库·面试·职场和发展·性能优化·golang·php
狂炫冰美式18 小时前
QuizPort 1.0 · 让每篇好文都有测验陪跑
前端·后端·面试
沐怡旸19 小时前
【底层机制】垃圾回收(GC)底层原理深度解析
android·面试
Moonbit19 小时前
MoonBit Pearls Vol.12:初探 MoonBit 中的 Javascript 交互
javascript·后端·面试
沐怡旸19 小时前
【穿越Effective C++】条款13:以对象管理资源——RAII原则的基石
c++·面试