如何降低接口的响应时间(RT)

news2024/10/4 0:22:50

概述

接口的响应时间(RT, Response Time)是衡量系统性能的一个重要指标。在本篇文档中,我们介绍几种常用的降低接口响应时间的策略和思想,包括 缓存、池化、异步处理、任务拆解等

缓存

缓存是提升系统性能最常见的方法之一,尤其适用于那些计算复杂且不经常变化的数据。缓存可以避免重复计算,减少数据库和其他IO操作的次数。

本地缓存

•	使用场景:当数据访问频率高且不容易发生变化时,可以考虑在应用服务器本地进行缓存。
•	实现方式:
•	常用的本地缓存工具类有 Caffeine 和 Guava Cache。
•	配置 TTL(存活时间)和最大缓存大小,防止缓存雪崩和内存溢出。
LoadingCache<String, Data> cache = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .maximumSize(1000)
    .build(key -> getDataFromDatabase(key)); // 延迟加载

缓存估量

在本地缓存中,内存始终是服务器珍贵资源,而有些场景下,我们不能缓存所有业务数据。之前也说了,缓存的数据应该是高频访问且低频修改的,从业务的角度看,基本上就是基础类档案数据。

那么要估算 JVM 缓存的内存占用,首先需要了解以下几个要素:

  1. 缓存对象的大小:每个缓存的对象在内存中占据的大小。
  2. 缓存的条目数:要缓存的数据总条目数。
  3. 缓存管理的开销:缓存框架本身(如 Caffeine 或 Guava)的元数据开销。
  4. JVM 内存结构:堆内存、非堆内存以及垃圾回收对内存的影响。
估算步骤
确定每个缓存对象的大小

对于缓存的业务数据,首先需要知道每个对象在 JVM 中的占用大小。可以通过以下方式进行估算:

  • 手动估算:根据对象的属性大小进行估计。每种 Java 类型在内存中的大小如下:

假设你有一个 BasicData 对象,如下:

public class BasicData {
    private String id;        // 16 字节(对象头)+ 8 字节(引用) + 字符串内部数据大小
    private int value;        // 4 字节
    private Date timestamp;   // 8 字节(引用) + Date 内部的具体占用
    // 其他属性...
}

对象中除了基本类型外,像 StringDate 这样的对象也需要进一步估算其大小。

 `byte`、`boolean`:1 字节
  short`、`char`:2 字节
  int`、`float`:4 字节
  long`、`double`:8 字节
 对象引用(64 位 JVM):8 字节
  • 使用工具估算:可以使用工具如 Java Instrumentation APIApache Commons LangSizeOf 工具来直接计算对象的大小。例如:
public static long getObjectSize(Object object) {
    Instrumentation instrumentation = getInstrumentation();
    return instrumentation.getObjectSize(object);
}
计算缓存条目数

确定需要缓存多少条数据。如果你打算缓存 10,000 个 BasicData 对象,那么可以使用以下公式计算总占用内存:

总内存占用 = 每个对象大小 * 缓存的条目数
考虑缓存管理的开销

CaffeineGuava Cache 这样的缓存库会有一定的元数据开销,比如缓存条目的哈希表、时间戳、命中率统计等。这些管理开销通常是缓存对象总大小的一小部分,但需要考虑。

以 Caffeine 为例:

  • 大约需要 64 字节来管理每个缓存条目(包含键、值和相关的元数据)。
JVM 堆内存与非堆内存的分配

需要考虑 JVM 内存分配的整体情况:

  • 堆内存:用于存储缓存数据的主要部分。
  • 非堆内存:缓存框架的运行时元数据可能会消耗部分非堆内存。
示例估算

假设 BasicData 对象大小为 100 字节,并且你打算缓存 10,000 条数据,使用 Caffeine 缓存框架:

  1. 每个 BasicData 对象约占 100 字节。
  2. 每个缓存条目的元数据占 64 字节。

总内存占用计算为:

缓存对象总大小 = 100 字节 * 10,000 = 1,000,000 字节(约 1 MB)
缓存管理开销 = 64 字节 * 10,000 = 640,000 字节(约 0.64 MB)

总内存占用 = 1 MB + 0.64 MB = 1.64 MB

这个估算结果表明,缓存 10,000 条 BasicData 对象大约会占用 1.64 MB 的堆内存。

工具辅助监控

为了更精确地管理和监控 JVM 内存使用,可以使用以下工具:

  1. JVisualVM:JDK 自带的监控工具,可以监控堆内存、线程和垃圾回收。
  2. JProfilerYourKit:专业的 Java 性能分析工具,能够查看对象的内存占用情况,分析垃圾回收行为。

通过这些工具,你可以动态地观察缓存对内存的实际影响并进行优化。

分布式缓存

使用场景:当应用是分布式部署时,本地缓存无法满足需求,这时可以引入分布式缓存,如 Redis

实现方式

将热点数据存储在 Redis 中,减少直接访问数据库的压力。

设置合理的失效时间,避免缓存穿透或缓存击穿问题。

String key = "user_" + userId;
User user = redisTemplate.opsForValue().get(key);
if (user == null) {
    user = getUserFromDatabase(userId);
    redisTemplate.opsForValue().set(key, user, 10, TimeUnit.MINUTES);
}

建议使用redission:以下是常见 Redis 客户端(包括 redisTemplateRedisson)的优劣差异的对比表格:

客户端优点缺点适用场景
redisTemplate- Spring 官方提供,易于与 Spring 生态集成 - API 简单、直观,常用操作支持全面 - 支持多种 Redis 数据结构的直接操作,包括 StringListSetZSetHash- 无分布式锁、限流等高级特性,需要自行实现 - 不支持异步操作和复杂的 Redis 集群管理适用于 Spring 项目中的简单缓存操作场景
Redisson- 提供了丰富的分布式工具支持,如分布式锁、限流、信号量等高级功能 - 支持异步、反应式编程(Reactive) - 内置 Redis 集群和哨兵模式的支持 - 支持多种复杂数据结构,如分布式集合、列表、队列等- 相较于 redisTemplate,依赖库体积更大 - API 相对复杂,学习成本较高适用于需要分布式锁、限流、异步操作等复杂功能场景
Jedis- 性能较高,提供了对 Redis 协议的直接支持 - 支持异步编程和管道操作 - API 简单直接- 线程不安全,必须每个线程独立创建连接实例 - 不支持自动重连、集群模式下配置较复杂适用于对性能有较高要求,且能自己管理线程安全的场景
Lettuce- 线程安全,所有 Redis 操作都基于 Netty - 支持异步、响应式编程(与 Spring WebFlux 完美兼容) - 支持 Redis 集群模式和自动重连 - API 灵活,支持高级数据类型和功能- 较低级别的 API,相比于 Redisson 的高级分布式工具缺少封装 - 配置略微复杂适用于高并发、异步任务、响应式编程的场景

说明:

  1. redisTemplate 是 Spring 官方提供的 Redis 客户端,适合与 Spring 框架集成的简单 Redis 操作,但缺乏分布式相关的高级功能。
  2. Redisson 提供了丰富的分布式工具,特别适合需要分布式锁、异步操作等复杂应用场景,但相对学习成本较高。
  3. Jedis 是性能较高的 Redis 客户端,适合对性能有高要求的场景,但线程不安全,需要自己管理线程安全性。
  4. Lettuce 是 Netty 驱动的 Redis 客户端,线程安全且支持异步和响应式编程,适合高并发和 WebFlux 等响应式场景。

根据项目需求选择合适的 Redis 客户端,可以提高开发效率和系统的可维护性。

缓存设计注意事项

缓存穿透:针对大量请求访问不存在的数据,缓存层和数据库都会收到大量请求。解决方案可以使用布隆过滤器。

缓存雪崩:大量缓存同时过期,导致所有请求都打到数据库。可以通过设置缓存过期时间的随机偏移量来缓解。

缓存更新机制:定期更新缓存数据或使用缓存失效时更新策略,确保数据的实时性。

集群环境下的缓存策略

在集群环境中使用多级缓存(JVM 本地缓存 + Redis 分布式缓存)时,处理缓存一致性问题是关键,尤其是在数据变更时同步更新各级缓存。在我理解中,一级缓存是 JVM 本地缓存,二级缓存是 Redis,读多写少的场景下缓存一致性可以通过以下几种策略来保证:

方案设计目标

  • 多级缓存一致性:确保一级缓存(JVM 本地缓存)和二级缓存(Redis)在数据发生变更时同步更新。
  • 高效缓存使用:优先使用速度更快的本地缓存,减少对 Redis 和数据库的访问。
  • 避免缓存穿透、雪崩问题

方案 1:Cache Aside(旁路缓存)模式

Cache Aside 是最常见的缓存更新策略,即应用程序负责更新和删除缓存。基本流程如下:

数据读取流程:
  1. 先从 本地缓存(JVM 缓存)中查询数据。
  2. 如果本地缓存未命中,再从 Redis 中读取。
  3. 如果 Redis 也未命中,则从数据库中读取,数据读出后更新 Redis本地缓存
数据更新流程:
  1. 数据发生变更时,直接更新数据库。
  2. 更新成功后 清除 Redis 中的缓存,通过 del 操作删除。
  3. 通过消息机制或者广播通知其他服务 本地缓存失效(或者直接清除,可以考虑CAS乐观版本锁机制)。
  4. 在下次读取时,重新从数据库加载数据并更新 Redis 和本地缓存。

优点

  • 简单易理解,数据库是主导更新的一方。
  • 多级缓存只需要在变更时同步,不需要每次都去强制一致。

缺点

  • 写操作的性能开销较大,因为需要清理 Redis 和本地缓存。
  • 数据变更量较大时,可能频繁清理缓存导致缓存命中率下降。

实现步骤

  1. JVM 本地缓存更新:使用 Caffeine 或 Guava 作为本地缓存。
  2. Redis 二级缓存:数据变更时,通过 Redis 的 del操作清理缓存。
  3. 同步缓存失效:可以使用消息队列(如 Kafka、RocketMQ)或 Redis 发布订阅机制,通知其他节点清理本地缓存。
伪代码示例:
// 读取数据逻辑
public Object getData(String key) {
    // Step 1: 先从 JVM 本地缓存读取
    Object data = localCache.get(key);
    if (data != null) {
        return data;
    }

    // Step 2: 如果本地缓存没有,再从 Redis 中读取
    data = redisTemplate.opsForValue().get(key);
    if (data != null) {
        localCache.put(key, data); // 更新本地缓存
        return data;
    }

    // Step 3: 如果 Redis 也没有,从数据库加载
    data = database.load(key);
    if (data != null) {
        redisTemplate.opsForValue().set(key, data); // 更新 Redis
        localCache.put(key, data); // 更新本地缓存
    }
    return data;
}

// 更新数据逻辑
public void updateData(String key, Object newValue) {
    database.update(key, newValue); // 更新数据库
    redisTemplate.delete(key);      // 清理 Redis 缓存
    publishCacheInvalidation(key);  // 通知其他节点清理本地缓存
}

// 使用消息队列或发布订阅同步缓存失效
public void onCacheInvalidation(String key) {
    localCache.remove(key);         // 本地缓存失效
}

方案 2:消息队列实现缓存同步

利用 消息队列(如 Kafka、RabbitMQ、RocketMQ)在数据更新后同步缓存变更。这种方式比直接清理缓存要灵活,适合更复杂的集群环境。

实现思路:
  1. 更新数据库 时,生产一条消息到消息队列,消息中包含变更的键值。
  2. 集群中所有服务节点监听此消息,收到消息后主动 清除本地缓存,Redis 也可以选择更新或清除。
  3. 在下次访问该缓存时,各节点重新从 Redis 或数据库加载最新数据。

优点

  • 消息队列支持异步处理,性能影响较小。
  • 可以实现较精准的缓存同步,不需要主动频繁清理缓存。

缺点

  • 依赖消息队列,增加了系统复杂性。
  • 消息的可靠性和延迟需要保证,否则可能会造成短期的不一致性。
伪代码示例:
// 数据更新时生产缓存失效消息
public void updateData(String key, Object newValue) {
    database.update(key, newValue);          // 更新数据库
    redisTemplate.delete(key);               // 清除 Redis 缓存
    messageQueue.publish("cache_invalidate", key); // 发送缓存失效消息
}

// 监听消息队列,清除本地缓存
public void onMessage(String key) {
    localCache.remove(key);                  // 清除本地缓存
}

方案 3:基于 Redis 的发布/订阅机制

Redis 提供了 <font style="color:#0e0e0e;">pub/sub</font>(发布/订阅)机制,可以用来通知多个节点同步缓存失效。

实现思路:
  1. 数据更新时,清除 Redis 缓存后,通过 Redis 发布订阅系统,发布一条缓存失效的消息。
  2. 所有节点订阅该 Redis 频道,收到消息后,清除本地缓存。
  3. pub/sub是单向的,不能保证消息的可靠性,但对于实时性要求不高的场景是可行的。

优点

  • 简单直接,不需要引入额外的消息中间件。
  • 适合对一致性要求不高的场景。

缺点

  • `pub/sub 不支持持久化消息,消息可能丢失。
  • 如果有大量缓存同步需求,Redis 发布/订阅可能会有性能瓶颈。
伪代码示例:
// 数据更新时发布缓存失效消息
public void updateData(String key, Object newValue) {
    database.update(key, newValue);          // 更新数据库
    redisTemplate.delete(key);               // 清除 Redis 缓存
    redisTemplate.convertAndSend("cache_invalidate", key); // 发布失效消息
}

// 订阅 Redis 频道,清除本地缓存
@EventListener
public void onMessage(String key) {
    localCache.remove(key);                  // 清除本地缓存
}

方案比较

方案优点缺点适用场景
Cache Aside实现简单,数据库是主导更新方更新时需要主动清除缓存,写操作性能较低读多写少的场景,缓存失效可以接受的场景
消息队列缓存同步可以异步处理缓存同步,适合复杂集群环境依赖消息队列,系统复杂性增加,需保证消息可靠性需要强一致性或大规模集群环境中
Redis 发布/订阅不需要引入额外中间件,基于 Redis 直接实现消息不可持久化,可能存在消息丢失,无法保证强一致性对一致性要求不高,变更量不大的场景

总结

对于读多写少的业务场景,使用 Cache Aside 模式结合 Redis 和 JVM 本地缓存是较为普遍且简单的做法。而在分布式集群环境中,消息队列Redis 发布/订阅可以帮助实现缓存的同步和一致性。

你可以根据业务的需求和性能要求,选择合适的缓存同步方案:

  • 强一致性场景:使用消息队列同步本地缓存和 Redis。
  • 弱一致性场景:使用 Redis 的发布/订阅机制通知缓存失效。

池化

池化(Pooling)是一种优化资源管理的设计模式,核心思想是重用资源而不是频繁创建和销毁资源。通过池化,系统可以将常用的对象或资源(如数据库连接、线程、文件句柄等)提前准备好,并在需要时从池中获取,使用后将资源返还池中,从而降低资源创建和销毁的开销。

常见的池化应用包括:数据库连接池线程池、以及在 I/O 模型中的NIOAIOBIO。池化不仅能节省系统开销,还能提升系统的吞吐能力和响应速度。

池化技术通过重复利用资源来减少创建和销毁开销,从而提升系统性能。连接池化在数据库、线程和对象创建方面尤为常见。

数据库连接池

数据库连接的创建和销毁是一个代价较大的操作。在没有连接池的情况下,每次进行数据库操作时,系统都会打开和关闭连接,导致大量的时间浪费。

连接池的工作原理

• 在系统启动时,连接池会创建一定数量的数据库连接,并将其存储在连接池中。

• 当需要执行数据库操作时,应用程序从连接池获取一个连接,使用完后不关闭连接,而是将其放回连接池中以供后续使用。

• 连接池会动态调整连接数量,例如在高负载时增加连接数,低负载时减少连接数。

连接池的优点

性能提升:减少频繁创建和关闭数据库连接的开销。

资源复用:有限数量的连接可以被多个请求重复使用,避免数据库资源枯竭。

自动管理:连接池通常支持连接超时、失效重连等机制。

应用到日常开发

• 常用的数据库连接池框架如 HikariCP、C3P0、DBCP 等都可以直接集成到项目中,提高数据库操作的效率。

NIO、AIO 和 BIO

NIO、AIO 和 BIO 是三种不同的 I/O 模型,用于处理网络通信或文件读写操作。

BIO(Blocking I/O):传统的阻塞式 I/O 模型,每次 I/O 操作都必须等待数据读写完成,线程被阻塞,适合小规模并发场景。

NIO(Non-blocking I/O):非阻塞式 I/O,基于多路复用(Selector)模型。线程可以在数据准备好之前执行其他任务,适合高并发场景。典型应用场景如 Netty。

AIO(Asynchronous I/O):异步 I/O 操作,由操作系统异步处理读写操作,任务完成后通过回调通知。AIO 适合超高并发场景,但相对复杂,且在 JVM 中应用较少。

池化思想在 NIO 和 AIO 中的体现

NIO 和 AIO 基于事件驱动模型,通过复用少量线程来处理大量的 I/O 请求。

• 线程池可以在 NIO 中与 Selector 配合使用,处理多个通道的 I/O 事件,从而提升并发处理能力。

应用到日常开发

• 如果系统需要处理大量 I/O(如 WebSocket 或 HTTP 服务),可以使用 Netty 框架,它基于 NIO,提供高性能的 I/O 处理能力。

• 针对高并发场景,也可以采用 异步编程(如 Java 的 CompletableFuture 或 Reactor 模型)。

线程池

线程的创建和销毁同样是一个消耗资源的操作,尤其在高并发的场景中,频繁创建和销毁线程会导致系统性能下降。

线程池的工作原理

• 系统初始化时,创建一个线程池,并预先分配一定数量的线程。

• 任务提交后,从线程池中分配线程来执行任务,任务完成后线程不会被销毁,而是返回到线程池中供后续任务使用。

• 线程池可以通过配置核心线程数、最大线程数、任务队列等参数来控制任务并发量。

线程池的优点

减少开销:复用已有的线程,减少频繁创建和销毁线程的开销。

提高性能:避免大量并发任务时创建过多线程导致系统资源耗尽。

自动调度:线程池会根据系统的负载情况自动调度线程执行任务,保证资源利用率。

应用到日常开发

• 可以使用 Java 提供的 ExecutorService、ThreadPoolExecutor 等进行线程池的管理。

• 针对 Web 服务,可以使用 Tomcat 或 Jetty 内置的线程池来优化请求的处理。

池化思想可以应用于多个层面,尤其在提高系统的资源利用率和接口响应效率方面,有着显著作用。

1.	数据库连接池:减少数据库连接的频繁创建和销毁,提升数据库操作的性能。
2.	对象池:例如缓存 StringBuilder、ByteBuffer 等可复用对象,避免重复创建对象,降低内存和垃圾回收压力。
3.	线程池:在线程密集型的场景中,通过线程池管理任务,避免线程资源的浪费。
4.	I/O 模型:通过 NIO 或 AIO 的异步 I/O 模型,提升大规模并发网络请求的处理能力。
5.	内存池化:可以通过复用内存块(如 Netty 的 ByteBuf)减少频繁的内存分配和回收,提升内存使用效率。

通过合理使用池化思想,可以显著提高系统的性能,减少接口的响应时间(RT),提升系统的并发处理能力。例如在高并发的 Web 服务中,可以通过线程池、数据库连接池、缓存等多层次的池化机制提升整体性能。

异步

异步处理可以有效地减少接口响应时间,将一些耗时的操作放到后台处理,避免阻塞主线程。

3.1 异步任务

使用场景:当接口需要执行耗时任务(如发送邮件、生成报告)时,可以通过异步任务减少主线程的负载。

实现方式

•在 Spring 中可以通过 @Async 注解轻松实现异步方法调用。

@Async
public void sendEmail(String email) {
    // 发送邮件的具体逻辑
}

@Async原理以及使用注意

@Async 注解是 Spring 中用来将方法异步执行的一个功能,它背后的原理确实是通过 AOP(Aspect-Oriented Programming,面向切面编程) 实现的。下

@Async 原理

  1. AOP 拦截:Spring 使用 AOP 机制拦截标注了 @Async 的方法调用,并将其转交给一个线程池进行异步处理。这样的方法不会立即执行,而是被放入一个任务队列,交由线程池在后台执行。
  2. 动态代理:Spring 在 @Async 实现中会创建该类的 代理对象,使用 JDK 动态代理CGLIB 动态代理 来管理异步任务的调用。
    • JDK 动态代理:如果目标类实现了接口,Spring 会使用 JDK 动态代理来创建代理对象。
    • CGLIB 动态代理:如果目标类没有实现接口,Spring 会使用 CGLIB 创建一个子类来实现代理。
  3. 线程池管理@Async 方法会在一个线程池中执行,默认使用 Spring 提供的 SimpleAsyncTaskExecutor 或者用户自定义的线程池(通过 @EnableAsync 配置)。每次调用 @Async 方法时,都会由线程池中的一个线程执行,主线程不会等待该方法的完成。
  4. 返回类型@Async 支持 voidFuture<T>CompletableFuture<T> 类型的返回值。对于返回 Future 的方法,Spring 会将异步执行的结果封装在 Future 中供主线程使用。

@Async 和 CGLIB 代理

  • CGLIB(Code Generation Library) 是 Java 中的一个字节码生成库。Spring 会在需要创建类的代理时使用 CGLIB 动态生成代理类。CGLIB 是基于继承的动态代理机制,它会生成目标类的子类并拦截方法调用。当使用 @Async 时,如果目标类没有实现接口,Spring 会使用 CGLIB 创建代理类,重写被 @Async 注解的方法,并将其放入线程池中异步执行。

@Async 的常见问题和注意事项

  1. 相同类内调用不生效
    这是由于 Spring AOP 的工作机制决定的。当在同一个类中调用 @Async 方法时,这个调用是对 原始对象 的直接调用,而不是通过 Spring 代理类,因此不会触发 AOP 拦截,导致 @Async 不生效。解决方案
    • @Async 方法的调用分离到另一个 Spring 管理的 Bean 中,从而确保通过代理类调用。
    • 使用 ApplicationContext 来获取代理对象,并调用异步方法:
@Autowired
private ApplicationContext applicationContext;

public void callAsyncMethod() {
    MyClass myClassProxy = applicationContext.getBean(MyClass.class);
    myClassProxy.asyncMethod(); // 通过代理对象调用,确保 AOP 生效
}



//当然作为事务是一样的
@Override
    public void deleteNoApprovalExitsData(String mainFormulaId) {
        List<CalculationApproval> currentDelApprovals = this.lambdaQuery().eq(CalculationApproval::getId, mainFormulaId)
                .isNull(CalculationApproval::getWorkflowInstanceId).list();
        if (CollectionUtils.isEmpty(currentDelApprovals)) {
            return;
        }
        SpringUtils.getAopProxy(this).deleteInfos(currentDelApprovals);
    }

    @Transactional
    public void deleteInfos(List<CalculationApproval> currentDelApprovals) {
        List<String> ids = currentDelApprovals.stream().map(CalculationApproval::getId).collect(Collectors.toList());
        this.removeBatchByIds(ids);
        QueryWrapper<CalculationApprovalDetail> queryWrapper = new QueryWrapper<>();
        queryWrapper.in("parentId", ids);
        detailMapper.delete(queryWrapper);
    }
  1. 线程池配置
    默认情况下,Spring 的 @Async 注解使用 SimpleAsyncTaskExecutor,这是一个并不是真正的线程池。为了更好的性能,你应该自定义一个线程池来处理异步任务:
@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(25);
        executor.setThreadNamePrefix("Async-");
        executor.initialize();
        return executor;
    }
}
  1. 异常处理
    异步方法发生的异常不会直接抛给调用者,需要通过 Future.get() 方法捕获。如果返回类型是 void,需要配置异常处理机制,例如:
@Async
public void asyncMethod() {
    try {
        // 业务逻辑
    } catch (Exception e) {
        handleException(e);
    }
}
  1. 返回值注意事项
    如果异步方法返回 CompletableFutureFuture,可以使用 get() 方法等待任务完成,或通过回调机制处理结果。
  2. 事务支持
    @Async 方法默认不在同一个事务上下文中。如果希望在异步任务中使用事务,需要手动声明事务管理。

使用 @Async 的最佳实践

  • 分离异步方法调用:避免在同一个类中调用 @Async 方法,确保使用代理对象。
  • 配置线程池:在高并发场景中,应合理配置线程池的大小、队列容量等参数,以提升性能和资源利用率。
  • 处理异常:异步方法中的异常不会直接反馈给调用方,需显式捕获或使用 Future 获取。
  • 保证线程安全:异步任务执行时,要注意线程安全问题,避免并发修改共享资源。

总结:@Async 通过 AOP 实现异步方法调用,在实际使用时应注意相同类内调用的无效问题、合理配置线程池,以及通过代理对象调用异步方法。在适当的场景下使用 @Async,可以显著提高应用的并发处理能力和性能。

异步消息队列

使用场景:当需要处理大量异步任务时,可以使用消息队列(如 RabbitMQKafka)来解耦和异步化操作。

实现方式:生产者将任务发送到消息队列,消费者从消息队列中获取任务进行处理,任务的处理结果可以异步反馈给用户。

任务拆解与并行计算

任务拆解(Task Decomposition)是将复杂的大任务分解为可以独立执行的较小任务,然后将这些小任务并行执行。这个过程通常会遵循以下几个步骤:

•	任务分解:将一个复杂的大任务拆解为多个独立的子任务。
•	递归执行:每个子任务可以进一步拆解,直到达到可以直接执行的粒度。
•	任务合并:子任务完成后,将各个子任务的结果合并为最终结果。

在 Java 并发编程中,常用的工具类 ForkJoinPool 就是任务拆解的典型实现。

任务拆解

•	使用场景:当一个操作需要执行多个子任务时,可以将它们拆解并行执行。
•	实现方式:
•	通过多线程、线程池或并行流执行多个子任务。
•	常见使用场景包括批量处理、复杂计算等。
List<Future<Result>> futures = new ArrayList<>();
for (Task task : tasks) {
    Future<Result> future = executor.submit(() -> task.execute());
    futures.add(future);
}

for (Future<Result> future : futures) {
    Result result = future.get();  // 获取子任务结果
}

并行流处理

使用场景:对于可以并行处理的数据集(如集合),可以使用 Java 8 的并行流来提高处理速度。

实现方式

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> results = numbers.parallelStream()
    .map(n -> n * n)
    .collect(Collectors.toList());

ForkJoinPool

ForkJoinPool 是 Java 7 引入的一个并行任务执行框架,专为处理 任务拆解 场景设计。它通过递归拆解任务并使用多线程并行处理,能够充分利用多核 CPU 的能力。

工作原理

fork:将任务拆解成更小的子任务。

join:等待子任务完成,并将结果合并。

ForkJoinPool 的关键类是 RecursiveTask 和 RecursiveAction,前者用于有返回值的任务,后者用于没有返回值的任务。

class FibonacciTask extends RecursiveTask<Integer> {
    private final int n;

    FibonacciTask(int n) {
        this.n = n;
    }

    @Override
    protected Integer compute() {
        if (n <= 1) {
            return n;
        }
        FibonacciTask f1 = new FibonacciTask(n - 1);
        f1.fork(); // fork一个子任务
        FibonacciTask f2 = new FibonacciTask(n - 2);
        return f2.compute() + f1.join(); //等待f1完成并合并结果
    }
}

在这个例子中,计算斐波那契数列的任务被拆解为两个子任务,通过 fork() 并行执行,并使用 join() 方法等待子任务完成并获取结果。

Work-Stealing 机制

ForkJoinPool 使用 work-stealing 算法来优化线程的利用率。work-stealing 是指当一个线程完成了它的任务后,如果它的任务队列为空,它会从其他仍有未完成任务的线程的任务队列中“偷取”任务来执行。

工作流程

每个线程有一个双端任务队列。线程将分解出来的子任务放在队列的末尾,并从队列的头部取任务执行。

当线程完成了自己队列中的任务后,它会尝试从其他线程的队列中“偷取”任务,从队列的末端取出并执行。

这种机制的优势在于:

• 避免某些线程在繁忙工作时,其他线程处于闲置状态,从而提升线程池的整体效率。

• 尤其在不均匀负载的情况下,能够动态平衡任务,充分利用系统资源。

  1. ForkJoinPool 与传统线程池的比较
特性ForkJoinPool传统线程池(ThreadPoolExecutor)
任务分解支持任务的递归拆解与合并不支持任务拆解,任务独立执行
工作窃取机制支持,通过窃取其他线程的任务提高利用率不支持,每个线程固定执行自己的任务
并行任务支持优化多核 CPU 的使用,适合并行任务适合执行独立任务
适用场景大任务拆解成小任务并行执行适合较为独立的并发任务
任务类型递归任务(RecursiveTask/RecursiveAction)Runnable 或 Callable

ForkJoinPool 的优势

高效处理并行任务:ForkJoinPool 特别适合需要递归拆解的并行任务,能够充分利用多核 CPU 进行计算。

动态任务平衡:通过 work-stealing 机制可以有效避免线程闲置,提高资源利用率。

可扩展性好:在大规模并发计算时,ForkJoinPool 可以通过任务拆解和工作窃取机制将任务合理分配到多个线程,具备更好的扩展性。

日常开发中的应用

复杂计算任务:如需要处理大量复杂计算的场景,例如递归的算法(斐波那契数列、合并排序等),ForkJoinPool 是一个理想的选择。

批量任务处理:对于需要处理大量子任务(如批量数据处理、文件解析等)时,任务拆解模式和 ForkJoinPool 可以有效提升任务处理的并行效率。

异步处理:在日常开发中,也可以使用 ForkJoinPool 来处理一些耗时的任务,使得接口响应更加快速。

缓存数据更新:当缓存数据较大时,可以将更新操作拆分为多个子任务并行执行,减少单一线程的负载。

文件/数据分片处理:如果需要处理大型文件或数据集,可以将文件或数据分成若干块,使用 ForkJoinPool 并行处理每一块,最后合并结果,提升整体处理速度。

使用 ForkJoinPool 的注意事项

任务的粒度:任务拆解不能过度或过少。过度拆解会导致线程管理的开销变大,降低效率;而拆解不足可能导致任务无法充分并行执行。

避免共享资源冲突:并行任务之间共享资源时要特别小心,可能会引发线程安全问题。建议尽量减少共享状态或使用合适的同步机制。

线程池配置:默认 ForkJoinPool 的线程数是基于可用处理器核心数,可以根据实际需要调整线程池的大小,避免过多的任务切换开销。

批量处理

批量处理是指在一次请求中处理大量的数据或操作,而不是逐条处理,从而提高效率和减少资源消耗。在数据库操作、文件处理、消息发送等场景中,批量处理是一种常见的优化方式。

批量处理的核心思想

批量处理的核心思想可以总结为以下几点:

  1. 减少交互次数
    • 无论是数据库操作还是与外部服务的交互,频繁地发送请求都会带来巨大的开销。批量处理的核心就是尽量减少这些请求次数,将多个操作合并成一次请求,从而降低频繁网络传输、IO 和连接管理的开销。
  2. 减少上下文切换
    • 每次处理单条数据时,系统都会发生上下文切换,消耗时间和资源。通过批量处理,可以减少 CPU 和内存的上下文切换,提升性能。
  3. 优化事务管理
    • 对于数据库的批量处理,可以将多条 SQL 语句放在一个事务中执行,减少事务的开启和提交次数,从而减少锁争用的开销,提高整体的吞吐量。
  4. 最大化利用资源
    • 通过将多个操作一起执行,可以充分利用网络带宽、CPU 计算能力和数据库连接等资源,减少资源的空闲时间,提高吞吐量。

批量处理在数据库中的应用

在数据库操作中,批量处理是一个非常有效的优化手段,特别是在处理大量数据时。单条操作与批量操作的区别可以体现在 SQL 执行的次数、网络交互的次数、事务提交的次数等方面。

  1. 单条插入与批量插入对比

优点

单条插入:

每次插入时都会发送一条 INSERT 语句,然后数据库执行一次事务提交。这样会频繁地打开、关闭数据库连接,并且每次插入一条记录都需要网络交互,效率较低。示例:

INSERT INTO users (id, name, age) VALUES (1, 'Alice', 30);
INSERT INTO users (id, name, age) VALUES (2, 'Bob', 25);
INSERT INTO users (id, name, age) VALUES (3, 'Charlie', 35);

批量插入:

将多个插入操作合并成一个 INSERT 语句,可以显著减少网络传输、数据库解析 SQL 语句的次数,以及事务提交的开销。示例:

INSERT INTO users (id, name, age) VALUES 
(1, 'Alice', 30),
(2, 'Bob', 25),
(3, 'Charlie', 35);
- 减少数据库交互次数,降低网络传输的延迟。
- 提高数据库的处理效率,尤其是在事务提交的场景中,一次提交多个操作,减少了事务开销。
  1. JDBC 批量处理:在 Java 中,使用 JDBC 操作数据库时可以利用 addBatch()executeBatch() 方法进行批量处理,避免频繁的数据库连接开销。示例代码:
Connection conn = DriverManager.getConnection(...);
String sql = "INSERT INTO users (id, name, age) VALUES (?, ?, ?)";
PreparedStatement pstmt = conn.prepareStatement(sql);

for (User user : userList) {
    pstmt.setInt(1, user.getId());
    pstmt.setString(2, user.getName());
    pstmt.setInt(3, user.getAge());
    pstmt.addBatch();  // 将 SQL 添加到批量
}

pstmt.executeBatch();  // 批量执行
conn.commit();
pstmt.close();
conn.close();
  1. Hibernate 批量操作:在 ORM 框架 Hibernate 中,批量处理也非常重要,特别是在处理大量数据时。通过设置 JPAHibernate 的批量处理参数,可以提升批量操作的效率。Hibernate 批量插入的设置:
hibernate.jdbc.batch_size=50
hibernate.order_inserts=true
hibernate.order_updates=true

批量插入的代码示例:

Session session = sessionFactory.openSession();
Transaction tx = session.beginTransaction();

for (int i = 0; i < 1000; i++) {
    User user = new User(i, "Name" + i, i % 30);
    session.save(user);

    if (i % 50 == 0) { // 每50个插入执行一次flush并清理缓存
        session.flush();
        session.clear();
    }
}

tx.commit();
session.close();

批量处理的其他场景

  1. 批量文件处理
    在处理大量文件时,可以将文件分批处理。例如,批量上传文件到服务器、批量读取大文件等。通过将文件切片并行处理,可以提高处理效率。
  2. 消息队列批量处理
    在消息队列系统(如 Kafka、RabbitMQ)中,消息生产者和消费者都可以批量处理消息。例如,消费者可以每次消费多个消息,然后批量提交确认(ack),这样可以减少消息的确认和网络交互次数,提高吞吐量。
  3. 批量更新缓存
    如果需要更新大量缓存数据,可以使用批量操作。例如,Redis 支持 pipeline 模式,将多个命令一起发送到 Redis 服务器,避免每条命令执行时的网络交互,显著提高 Redis 的性能。Redis Pipeline 示例:
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
    for (int i = 0; i < 1000; i++) {
        connection.set(("key" + i).getBytes(), ("value" + i).getBytes());
    }
    return null;
});

批量处理的注意事项

  1. 批量大小
    批量处理的大小需要根据具体场景来调整。批量过大可能导致内存溢出或数据库锁争用,而批量过小则不能发挥批量处理的优势。通常可以通过监控系统的资源利用率来调整批量的大小。
  2. 事务处理
    批量操作如果失败,可能会导致部分数据写入成功而部分失败,造成数据不一致。解决办法是将批量操作放入一个事务中,确保数据的一致性。
  3. 错误处理
    在进行批量操作时,需要考虑如何处理批量中的错误。例如,在数据库批量插入时,如果某条数据失败,可以选择回滚整个批次,或者跳过错误的数据,继续处理其他数据。
  4. 延迟问题
    批量处理会导致一定的延迟,因为需要等待足够的请求或数据积累到一定数量时才执行批量操作。需要权衡实时性和吞吐量之间的关系。

总结

  • 批量处理通过合并多个操作来减少网络交互、资源消耗和事务管理的开销。
  • 在数据库操作中,通过批量插入、更新等操作可以显著提升性能。
  • 批量处理还可以应用于消息队列、文件处理和缓存更新等多个场景。
  • 在实际应用中需要根据系统资源、延迟要求等进行调整,确保批量操作能够提升系统的吞吐量而不会引发其他性能问题。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2187089.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

python-斐波那契词序列/最大回文乘积/求最大最小k个元素

一:斐波那契词序列题目描述 编写一个程序&#xff0c;生成斐波那契词序列的前n个元素。 斐波那契词序列是一个词序列&#xff0c;其中每个词是通过连接前两个词形成的。 它以斐波那契序列命名&#xff0c;因为它是以类似的方式创建的&#xff0c;但是我们不是加数字&#xff0c…

美国游戏玩家的文化偏好

美国游戏市场是世界上最大、最多样化的市场之一&#xff0c;受到一系列文化、社会和经济因素的影响。美国游戏玩家通常以参与技术和娱乐为特征&#xff0c;表现出由个人主义、竞争和社交互动驱动的偏好。主要趋势和偏好在这个市场中脱颖而出&#xff1a; 游戏类型多样 美国玩…

毕业设计选题:基于ssm+vue+uniapp的教学辅助小程序

开发语言&#xff1a;Java框架&#xff1a;ssmuniappJDK版本&#xff1a;JDK1.8服务器&#xff1a;tomcat7数据库&#xff1a;mysql 5.7&#xff08;一定要5.7版本&#xff09;数据库工具&#xff1a;Navicat11开发软件&#xff1a;eclipse/myeclipse/ideaMaven包&#xff1a;M…

Resdis中关于字符串类型的基础命令

本文主要详解key-value中vaule为字符串类型的情况&#xff0c;value属于其他的数据类型不适应&#xff1b;有几个命令是通用命令 目录 1.set和get 2.keys 3.exists 4.del 5.expire 6.ttl 7.type 8.object encoding key 9.加减操作 10.字符串操作 11.命令小结 1.se…

拿下奇怪的前端报错:SyntaxError: Unexpected token ‘??=‘或‘xxx‘ - 浅谈Nodejs版本过高过低的部分问题

在前端开发时&#xff0c;如果同时维护多个项目&#xff0c;跨越的年度又比较大&#xff0c;难免会使用多个Nodejs版本。有时候版本不对&#xff0c;不仅仅是安装会报错 1 依赖无法安装 一般情况下nodejs又向后兼容较好&#xff08;除了部分三方包&#xff09;&#xff0c;所…

day03-js 作业

js基础 一.选择 1C 2C 3D 4C 5B 6B 7C 8A 二.编程 2 函数 数组与对象 一.选择 1D 2C 3D 4A 5D 6B 7B 8A 二.编程 1 2 4

Linux驱动开发(速记版)--设备树插件

第六十八章 设备树插件介绍 Linux 4.4之后引入了动态设备树&#xff0c;其中的设备树插件&#xff08;Device Tree Overlay&#xff09;是一种扩展机制&#xff0c;允许在运行时动态添加、修改或删除设备节点和属性。 设备树插件机制通过DTS&#xff08;设备树源文件&#xff0…

50.哀家要长脑子了!

1.1705. 吃苹果的最大数目 - 力扣&#xff08;LeetCode&#xff09; 贪心贪在哪里&#xff0c;用一个优先队列&#xff08;小根堆&#xff09;存储腐烂日期和该日期苹果腐烂的数量。优先吃掉距离腐烂日期最近的苹果。吃苹果分为两个阶段&#xff0c;第一个是在1-n天内&#xff…

MySQL 中的 LAST_INSERT_ID()函数详解

在 MySQL 数据库中&#xff0c;LAST_INSERT_ID()是一个非常有用的函数。它可以帮助我们获取最近一次插入操作所生成的自增 ID 值。本文将详细解释 MySQL 中的LAST_INSERT_ID()函数及其用途。 一、函数介绍 LAST_INSERT_ID()是 MySQL 中的一个内置函数&#xff0c;它返回最近一…

跨平台音乐播放器Feishin

什么是 Feishin &#xff1f; Feishin 是一款现代的自托管音乐播放器&#xff0c;支持多种音乐来源和功能。它是由Sonixd 的重构版本发展而来&#xff0c;能够与 Navidrome 或 Jellyfin API 音乐服务器兼容&#xff0c;目前还不支持 Subsonic API 。Feishin 具有现代化的用户界…

【mmsegmentation】Loss模块详解(入门)以调用FocalLoss为例

1、mmdet中损失函数模块简介 1.1. Loss的注册器 先来看段代码&#xff1a;mmseg/models/builder.py # mmseg/registry/registry.py # mangage all kinds of modules inheriting nn.Module # MODELS Registry(model, parentMMENGINE_MODELS, locations[mmseg.models])from mm…

风格迁移项目一:如何使用

前言 由于我不太会pr&#xff0c;所以直接新建的项目&#xff0c; 原项目地址&#xff1a;https://github.com/Optimistism/Style-transfer 原项目代码的讲解地址&#xff1a;https://www.bilibili.com/video/BV1yY4y1c7Cz/ 本项目是对原项目的一点点完善。 项目地址&…

C语言复习概要(一)

本文 C语言入门详解&#xff1a;从基础概念到分支与循环1. C语言常见概念1.1 程序的基本结构1.2 变量作用域和存储类1.3 输入输出1.4 编译与运行 2. C语言中的数据类型和变量2.1 基本数据类型2.2 变量的声明与初始化2.3 常量与枚举 3. C语言的分支结构3.1 if语句3.2 if-else语句…

Python笔记 - 函数、方法和类装饰器

前言 装饰器最早出现是Python2.4版本&#xff0c;为什么这个版本开始存在&#xff1f;这是因为最早标记类方法的做法是&#xff1a; def foo(self):perform method operation foo classmethod(foo)当方法体过长的时候很容易让人忽略掉最后的语句classmethod(foo)&#xff0c…

Win10之解决:设置静态IP后,为什么自动获取动态IP问题(七十八)

简介&#xff1a; CSDN博客专家、《Android系统多媒体进阶实战》一书作者 新书发布&#xff1a;《Android系统多媒体进阶实战》&#x1f680; 优质专栏&#xff1a; Audio工程师进阶系列【原创干货持续更新中……】&#x1f680; 优质专栏&#xff1a; 多媒体系统工程师系列【…

域内密码喷洒 Password Spray 实验

password spray 1. 实验网络拓扑 kali: 192.168.72.128win2008: 192.168.135.129 192.168.72.139win7: 192.168.72.149win2012:(DC) 192.168.72.131 2. 简单原理 Kerberos针对同一个用户&#xff0c;多次的密码尝试请求有锁定保护策略。 但是我们可以切换用户&#xff0c;…

【Streamlit案例】制作销售数据可视化看板

目录 一、案例效果 二、数据分析 三、加载数据 四、网站前端 &#xff08;一&#xff09;网页标题和图标 &#xff08;二&#xff09;侧边栏和多选框 &#xff08;三&#xff09;主页面信息 ​&#xff08;四&#xff09;主页面图表 &#xff08;五&#xff09;隐藏部…

基于四种网络结构的WISDM数据集仿真及对比:Resnet、LSTM、Shufflenet及CNN

在上节中&#xff0c;我们已经详细介绍了WISDM数据集及如何使用CNN网络训练&#xff0c;得到了六个维度的模型仿真指标及五个维度的可视化分析&#xff0c;那么现在我们将训练模型推广到其他网路结构中去&#xff0c;通过仿真实验来对比一下不同网络之间对于WISDM数据集的训练效…

【docker笔记8-镜像推送】

docker笔记8-镜像推送 一、基本命令二、案例1.Java demo2.打包镜像 一、基本命令 &#xff08;1&#xff09;推送镜像到远程仓库 docker tag local-image:tagname new-repo:tagname docker push new-repo:tagname这里首先要登录到docker&#xff0c;然后需要输入登录用户名和…

构建electron项目

1. 使用electron-vite构建工具 官网链接 安装构建工具 pnpm i electron-vite -g创建electron-vite项目 pnpm create quick-start/electron安装所有依赖 pnpm i其他 pnpm -D add sass scss1. 启动项目 2. 配置 package.json "dev": "electron-vite dev --…