常见思路
1.批量思想:
这个其实是一个最容易想到的代码层次的修改,其实对业务上来说,结果都是一样的,只不过这个涉及到了一件事就是,像数据库中发请求,是发十次还是发一次的问题。其原因最根本的还是,每次数据库请求都会引入额外的网络开销和数据库连接管理开销,如果多次,性能会有一定的折扣,但是实际上批量查询并不像传的那么神,也会存在潜在的风险,比如说,可能会引入内存占用过高的问题,特别是数据量非常大的时候,需要谨慎处理以避免内存溢出的问题。当然,这种情况,可以把批量划分开多份,这样能做到尽可能避免内存占用过高问题,不过代价是适当增加一些连接请求。
优化前:
//for循环单笔入库 for(TransDetail detail:transDetailList){ insert(detail); }
优化后:
batchInsert(transDetailList);
大数据量:
List<List<TransDetail>> TransDetaillists = SplitList(transDetailList);// 将大批量分割成小批量 for(List<TransDetail> transDetailList : TransDetaillists){ batchInsert(transDetailList); }
这种方式同样适合调用类型,比如说如果是在某个接口
中需要获取2000个用户的信息,它考虑的就需要更多一些。
除了需要考虑远程调用接口的耗时之外,还需要考虑该接口本身的总耗时,也不能超时500ms。
可以使用多个线程异步调用:
List<List<Long>> allIds = Lists.partition(ids,200); final List<User> result = Lists.newArrayList(); allIds.stream().forEach((batchIds) -> { CompletableFuture.supplyAsync(() -> { result.addAll(remoteCallUser(batchIds)); return Boolean.TRUE; }, executor); })
2.异步思想:
耗时操作,考虑用异步处理,这样可以降低接口耗时。
其实这个优化点思路是这样的,之前在开发中为了方便,会将一些逻辑都放在接口中同步执行,这样势必会对接口性能造成一定影响,但是实际上通过梳理业务逻辑,会发现只有业务逻辑才是核心逻辑,其他功能都是非核心逻辑,那么就有一个原则:核心逻辑可以同步执行,同步写库。非核心逻辑,可以异步执行,异步写库。
上面这个例子中,发站内通知和用户操作日志功能,对实时性要求不高,即使晚点写库,用户无非是晚点收到站内通知,或者运营晚点看到用户操作日志,对业务影响不大,所以完全可以异步处理。
当然不仅仅只有这些,比如注册的时候,成功后,短信邮件通知消息,就是异步处理。
再以一个带超时时间的调用链举例:(来自阿里云分享)
场景:从链路上看A系统调用B系统,B系统调用C系统完成计算再把结论返回给A,A系统超时时间400ms,通常A系统调用B系统300ms,B系统调用C系统200ms。
现在C系统需要将调用结论返回给D系统,耗时150ms
此时A系统- B系统- C系统已有的调用链路可能会超时失败,因为引入D系统之后,耗时增加了150ms,整个过程是同步调用的,因此需要C系统将调用D系统更新结论的非强依赖改成异步调用。
而异步的实现方式,其中最典型的就是多线程(线程池)、消息队列。
线程池
发站内通知和用户操作日志功能,被提交到了两个单独的线程池中。
这样接口中重点关注的是业务操作,把其他的逻辑交给线程异步执行,这样改造之后,让接口性能瞬间提升了。
但使用线程池有个小问题就是:如果服务器重启了,或者是需要被执行的功能出现异常了,无法重试,会丢数据,这个应该怎么处理呢?
解决方案
持久化数据: 在执行功能之前,将需要执行的数据进行持久化存储,比如写入到数据库中。当服务器重启后,可以从持久化存储中重新加载需要执行的数据,并进行处理。
异常处理和重试机制: 在功能执行过程中,捕获可能出现的异常,并实现相应的重试机制。例如,可以在捕获到异常时将任务重新放回线程池队列中等待重新执行,直到执行成功为止。
监控和报警系统: 建立监控系统,及时发现功能执行异常或服务器重启等情况,并通过报警系统通知相关人员进行处理。
日志记录: 在功能执行过程中记录详细的日志信息,包括执行结果、异常信息等。当出现数据丢失时,可以通过日志进行故障排查和数据恢复。
幂等性设计: 对于可能重复执行的操作,设计具有幂等性的功能。这样即使因重试或重复执行而导致数据重复,也不会产生业务上的影响。
幂等性设计方案之一:
任务状态设计:为每个任务设计一个状态机,包括待执行、执行中、已完成、失败等状态。在执行任务之前,首先检查任务的状态。如果任务状态为待执行,则执行任务;如果任务状态为执行中或已完成,则直接返回执行结果;如果任务状态为失败,则检查失败原因,根据原因进行重试或记录错误信息。
消息队列
对于发站内通知和用户操作日志功能,在接口中并没真正实现,它只发送了mq消息到mq服务器。然后由mq消费者消费消息时,才真正的执行这两个功能。
这样改造之后,接口性能同样提升了,因为发送mq消息速度是很快的,我们只需关注业务操作的代码即可。
但是将异步消息发送给消息队列的话,也可能出现问题。
如何保证消息不丢(可用性一般是业务中最重要的)?如何处理重复消息(如何保证业务中的幂等性)?如何保证消息的有序性(业务场景要求顺序性怎么办)?如何应对消息堆积(内存是有限的,磁盘容量也是有限的,当消息堆积到一定量,消息消费速度,检索速度都会大打折扣)?
解决方案
如何保证消息不丢
一共三个阶段,生产消息,存储消息和消费消息,我们来看看这三个阶段怎么保证消息不丢失。
- 生产消息
生产者发消息到Broker,需要等待Broker的响应(不能用单向),不论是同步还是异步都要做好异常处理,如果Broker返回失败,需要重试发送。当多次发送失败,需要预警或日志记录,然后人工处理或者异步补偿调度处理发送失败的消息。这样可以保证生产消息阶段不丢消息。
- 存储消息
消息刷盘有两种策略:同步刷盘和异步刷盘。这里需要选择同步刷盘,也就是消息需要刷到文件里再返回给生产者响应。而且Broker需要集群部署,即消息不仅要写到master上,还要同步到slave上,这样当master宕机时,slave还可以补上。
**解释:**在消息队列中,消息刷盘是指将消息从内存中的缓冲区刷写到磁盘上的过程,以保证消息的持久化和可靠性。有两种策略可供选择:同步刷盘和异步刷盘。同步刷盘是指在消息被写入内存的缓冲区后,立即将消息刷写到磁盘上,然后再返回给生产者响应。这种方式可以确保消息被完整地写入磁盘,但可能会导致性能下降,因为需要等待磁盘操作完成。异步刷盘则是指在消息被写入内存的缓冲区后,立即返回给生产者响应,而将消息刷写到磁盘上的过程异步进行。这种方式可以提高性能,但可能会导致消息丢失,因为如果刷盘操作失败,消息可能没有被写入磁盘。在需要保证消息可靠性和持久性的场景中,通常选择同步刷盘策略。此外,如果消息队列需要集群部署,即消息不仅要写到主节点(master),还要同步到从节点(slave)上,以便在主节点宕机时,从节点可以补上。这种方式可以提高系统的可用性和容错性。
- 消费消息
如果消息获取到然后消费者宕机了怎么办?你需要保证消费者真正执行完业务逻辑后再返回给Broker消费成功的标识,这样的话消费者宕机了大不了其他消费者重新消费。
如果Broker宕机了怎么办?消费者这边维护了消费队列的索引,这样当Broker恢复之后也可以重新消费。
消息重复了怎么处理
先来分析下消息重复的场景:
- 发消息发重复:生产者往Broker发消息得等到Broker的响应,如果因为网络原因生产者迟迟收不到Broker的响应,生产者就会重发一次。
- 消费消息重复:消费者消费消息,业务逻辑走完事务也提交了,此时需要更新Consumer offset,此时这个消费者挂了,另一个消费者顶上,由于Consumer offset还没更新,于是又拿到了刚才的消息,业务又被执行了一遍
解释: 这句话描述了消息队列中的一种消费消息的重复情况。在这种情况下,消费者在消费消息后,完成了业务逻辑处理并提交了事务。然后,消费者需要更新自身的偏移量(Consumer offset),以便知道已经处理过的消息位置。但是,如果在更新偏移量之前,消费者意外地崩溃或停止工作,另一个消费者将顶替它继续处理消息。由于原消费者的偏移量尚未更新,新消费者将从上次消费的位置开始处理消息,这可能导致已经处理过的消息被再次消费,从而引发业务重复执行。为了避免这种情况,可以考虑使用幂等处理或去重逻辑来确保业务不会因为重复消费消息而出现问题。同时,可以考虑使用高可用和故障切换机制来确保消费者在崩溃或停止工作后能够正确地更新偏移量,从而避免重复消费消息。
处理消息重复关键是幂等(同样的参数调用同一个接口n次产生的结果都是一致的)
- 可以用数据库版本号控制,对比消息中的版本号和数据库中的版本号,相等才做更新,数据库乐观锁机制
- 通过数据库的约束例如唯一键,例如 insert into update on duplicate key…
- 或者记录某个业务id,有消息过来,先通过id查一下缓存或者数据库,如果id已经存在则表示已经处理过了
如何保证消息的有序性
看你是需要全局有序还是局部有序了
全局有序
这种情况,只能由一个生产者往Topic发送消息,并且Topic里只有一个队列/分区,消费者也必须单线程消费这个队列。这样的消息就是全局有序的。
部分有序
绝大部分需求都只是要求部分有序,这种情况下我们可以将Broker内部划分成我们需要的队列数,某一个Topic/Tag的消息发送到固定的队列中,然后这些队列对应一个单线程处理的消费者。
如何处理消息堆积
不考虑代码bug,消息堆积最常见的原因是:消费者的消费速度跟不上生产者的生产消息的速度。
还可能是因为消息消费失败反复重试造成的。因此我们要先定位消费慢的原因,如果是bug则处理bug,如果消费代码性能不佳就考虑优化逻辑。假如逻辑我们优化了消费的还是很慢,那就要考虑水平扩容了,增加Topic的队列数和消费者数量,注意队列数和消费者数一定要同时增加,不然新增加的消费者是没东西消费的,因为一个Topic中,一个队列只会分配给一个消费者。
后端接口性能优化分析-多线程优化(中):https://developer.aliyun.com/article/1413669