多线程问题
对于mysql里面上万条数据,我开多线程,比如10个,要是实现第一个线程处理前k条,后面的依次处理后k条,这怎么实现?如果第3个线程处理k到k+n条数据出问题的时候,我应该记录这批次数据的id到一个单独的地方,我觉得这数据量小,可以存在List里面,然后该批次(批次大小200)的所有数据都进行事务回滚,不提交。最后全部都处理完了,再去处理这些异常的list列表里面的数据,
数据分片和多线程分配
我希望10个线程去处理不同的数据段,比如每个线程处理1万条数据,这是数据分片或者说范围划分
实现方式:
- 主线程预先分片:在主线程中,首先确定总数据量(比如一万条)和分片规则,一个简单的有效的方法就是基于主键ID进行范围划分(假设ID是连续的)
- 查询出最小和最大的ID
- 计算每个线程需要处理的数据量
sliceSize = totalRecords / numThreads - 为每个线程分配一个ID范围
Thread 1: [minId, minId + sliceSize),Thread 2: [minId + sliceSize, minId + 2 * sliceSize)
- 每个线程内部分批:每个线程负责处理分配给它的哪个大范围,在线程内部,为了控制事务大小和内存使用,会为这个大范围的数据按照批次大小(比如200条)进行处理
- 线程3会从数据库分页查询
WHERE id >= start_id AND id<end_id LIMIT 200 OFFSET 0,处理第一批200条 - 然后
OFFSET 200处理下一批,直到处理完
- 线程3会从数据库分页查询
这样做的好处就是避免了线程间对同一条数据的竞争,因为每个线程处理的数据范围是互斥的。
异常处理、回滚、重试
- 事务边界:每个批次200条作为一个独立的事务
- 开始处理批次前,开启一个数据库事务。
- 如果这200条中任何一条处理失败(抛出异常),整个事务会立即回滚,数据库会撤销该批次内所有已执行的操作,保证数据一致性
- 如果全部成功就是commit事务
- 异常捕获和记录
- 当第三个线程处理范围的某个批次失败时,会被try-catch捕获
- 在catch块中,首先执行事务回滚,确保数据库干净
- 然后不是记录单个失败的id,而是记录整个失败批次的标识信息,这个标识可以是起始ID和结束ID(failedRange: {start: 3000, end: 3200),或者是批次的偏移量信息(threadId: 3, batchIndex: 5)
- 将这个标识符添加到一个全局的线程安全的异常批次列表中,比如
ConcurrentLinkedQueue或者Collections.synchronizedList(new ArrayList<>)中,对于数据量小的,用List足够。
- 后续处理
- 主线程使用ExecutorService和countDownLatch等工具,等待所有10个工作线程完成他们各自的数据范围
- 所有正常批次处理完毕后,程序开始检查哪个全局的异常批次列表
- 然后,可以启动新的线程或者复用原来的线程,专门对这些之前失效的批次进行重试,重试逻辑和之前一样,每个批次一个事务,成功则提交,失败则回滚然后再次记录,定义一个retryMaxCount,超过了就人工重试吧。
线程池
从问题入手,线程池拒绝策略怎么选才不会丢失任务?
线程池拒绝策略的触发,本质是:"任务提交速度超过了线程池的处理能力"
- 核心线程全忙
- 任务队列已存满待执行任务
- 线程数以达到最大线程数,无法再创建新线程。
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就可以去读取信息了。