增加索引 + 異步 + 不落地后,從 12h 優(yōu)化到 15 min
在開發(fā)中,我們經(jīng)常會(huì)遇到這樣的需求,將數(shù)據(jù)庫(kù)中的圖片導(dǎo)出到本地,再傳給別人。
一、一般我會(huì)這樣做:
- 通過(guò)接口或者定時(shí)任務(wù)的形式。
- 讀取Oracle或者M(jìn)ySQL數(shù)據(jù)庫(kù)。
- 通過(guò)FileOutputStream將Base64解密后的byte[]存儲(chǔ)到本地。
- 遍歷本地文件夾,將圖片通過(guò)FTP上傳到第三方服務(wù)器。
現(xiàn)場(chǎng)炸鍋了!
實(shí)際的數(shù)據(jù)量非常大,據(jù)統(tǒng)計(jì)差不多有400G的圖片需要導(dǎo)出。
現(xiàn)場(chǎng)人員的反饋是,已經(jīng)跑了12個(gè)小時(shí)了,還在繼續(xù),不知道啥時(shí)候能導(dǎo)完。
停下來(lái)呢?之前的白導(dǎo)了,不停呢?不知道要等到啥時(shí)候才能導(dǎo)完。
這不行啊,速度太慢了,一個(gè)簡(jiǎn)單的任務(wù),不能被這東西耗死吧?
@Value("${months}")
private String months;
@Value("${imgDir}")
private String imgDir;
@Resource
private UserDao userDao;
@Override
public void getUserInfoImg() {
try {
// 獲取需要導(dǎo)出的月表
String[] monthArr = months.split(",");
for (int i = 0; i < monthArr.length; i++) {
// 獲取月表中的圖片
Map<String, Object> map = new HashMap<String, Object>();
String tableName = "USER_INFO_" + monthArr[i];
map.put("tableName", tableName);
map.put("status", 1);
List<UserInfo> userInfoList = userDao.getUserInfoImg(map);
if (userInfoList == null || userInfoList.size() == 0) {
return;
}
for (int j = 0; j < userInfoList.size(); j++) {
UserInfo user = userInfoList.get(j);
String userId = user.getUserId();
String userName = user.getUserName();
byte[] content = user.getImgContent;
// 下載圖片到本地
FileUtil.dowmloadImage(imgDir + userId+"-"+userName+".png", content);
// 將下載好的圖片,通過(guò)FTP上傳給第三方
FileUtil.uploadByFtp(imgDir);
}
}
} catch (Exception e) {
serviceLogger.error("獲取圖片異常:", e);
}
}
二、誰(shuí)寫的?趕緊加班優(yōu)化,會(huì)追責(zé)嗎?
經(jīng)過(guò)1小時(shí)的深思熟慮,慢的原因可能有以下幾點(diǎn):
- 查詢數(shù)據(jù)庫(kù)
- 程序串行
- base64解密
- 圖片落地
- FTP上傳到服務(wù)器
使用 索引 + 異步 + 不解密 + 不落地 后,40G圖片的導(dǎo)出上傳,從 12+小時(shí) 優(yōu)化到 15 分鐘,你敢信?
差不多的代碼,效率差距竟如此之大。
下面貼出導(dǎo)出圖片不落地的關(guān)鍵代碼。
@Resource
private UserAsyncService userAsyncService;
@Override
public void getUserInfoImg() {
try {
// 獲取需要導(dǎo)出的月表
String[] monthArr = months.split(",");
for (int i = 0; i < monthArr.length; i++) {
userAsyncService.getUserInfoImgAsync(monthArr[i]);
}
} catch (Exception e) {
serviceLogger.error("獲取圖片異常:", e);
}
}
@Value("${months}")
private String months;
@Resource
private UserDao userDao;
@Async("async-executor")
@Override
public void getUserInfoImgAsync(String month) {
try {
// 獲取月表中的圖片
Map<String, Object> map = new HashMap<String, Object>();
String tableName = "USER_INFO_" + month;
map.put("tableName", tableName);
map.put("status", 1);
List<UserInfo> userInfoList = userDao.getUserInfoImg(map);
if (userInfoList == null || userInfoList.size() == 0) {
return;
}
for (int i = 0; i < userInfoList.size(); i++) {
UserInfo user = userInfoList.get(i);
String userId = user.getUserId();
String userName = user.getUserName();
byte[] content = user.getImgContent;
// 不落地,直接通過(guò)FTP上傳給第三方
FileUtil.uploadByFtp(content);
}
} catch (Exception e) {
serviceLogger.error("獲取圖片異常:", e);
}
}
4、異步線程池工具類
- 在方法上添加@Async,表示此方法是異步方法。
- 在類上添加@Async,表示類中的所有方法都是異步方法。
- 使用此注解的類,必須是Spring管理的類。
- 需要在啟動(dòng)類或配置類中加入@EnableAsync注解,@Async才會(huì)生效。
在使用@Async時(shí),如果不指定線程池的名稱,也就是不自定義線程池,@Async是有默認(rèn)線程池的,使用的是Spring默認(rèn)的線程池SimpleAsyncTaskExecutor。
默認(rèn)線程池的默認(rèn)配置如下:
- 默認(rèn)核心線程數(shù):8。
- 最大線程數(shù):Integet.MAX_VALUE。
- 隊(duì)列使用LinkedBlockingQueue。
- 容量是:Integet.MAX_VALUE。
- 空閑線程保留時(shí)間:60s。
- 線程池拒絕策略:AbortPolicy。
從最大線程數(shù)可以看出,在并發(fā)情況下,會(huì)無(wú)限制的創(chuàng)建線程,我勒個(gè)嗎啊。
spring:
task:
execution:
pool:
max-size: 10
core-size: 5
keep-alive: 3s
queue-capacity: 1000
thread-name-prefix: my-executor
也可以自定義線程池,下面通過(guò)簡(jiǎn)單的代碼來(lái)實(shí)現(xiàn)以下@Async自定義線程池。
@EnableAsync// 支持異步操作
@Configuration
public class AsyncTaskConfig {
/**
* com.google.guava中的線程池
* @return
*/
@Bean("my-executor")
public Executor firstExecutor() {
ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("my-executor").build();
// 獲取CPU的處理器數(shù)量
int curSystemThreads = Runtime.getRuntime().availableProcessors() * 2;
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(curSystemThreads, 100,
200, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), threadFactory);
threadPool.allowsCoreThreadTimeOut();
return threadPool;
}
/**
* Spring線程池
* @return
*/
@Bean("async-executor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
// 核心線程數(shù)
taskExecutor.setCorePoolSize(24);
// 線程池維護(hù)線程的最大數(shù)量,只有在緩沖隊(duì)列滿了之后才會(huì)申請(qǐng)超過(guò)核心線程數(shù)的線程
taskExecutor.setMaxPoolSize(200);
// 緩存隊(duì)列
taskExecutor.setQueueCapacity(50);
// 空閑時(shí)間,當(dāng)超過(guò)了核心線程數(shù)之外的線程在空閑時(shí)間到達(dá)之后會(huì)被銷毀
taskExecutor.setKeepAliveSeconds(200);
// 異步方法內(nèi)部線程名稱
taskExecutor.setThreadNamePrefix("async-executor-");
/**
* 當(dāng)線程池的任務(wù)緩存隊(duì)列已滿并且線程池中的線程數(shù)目達(dá)到maximumPoolSize,如果還有任務(wù)到來(lái)就會(huì)采取任務(wù)拒絕策略
* 通常有以下四種策略:
* ThreadPoolExecutor.AbortPolicy:丟棄任務(wù)并拋出RejectedExecutionException異常。
* ThreadPoolExecutor.DiscardPolicy:也是丟棄任務(wù),但是不拋出異常。
* ThreadPoolExecutor.DiscardOldestPolicy:丟棄隊(duì)列最前面的任務(wù),然后重新嘗試執(zhí)行任務(wù)(重復(fù)此過(guò)程)
* ThreadPoolExecutor.CallerRunsPolicy:重試添加當(dāng)前的任務(wù),自動(dòng)重復(fù)調(diào)用 execute() 方法,直到成功
*/
taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
taskExecutor.initialize();
return taskExecutor;
}
}
三、告別劣質(zhì)代碼,優(yōu)化從何入手?
我覺得優(yōu)化有兩個(gè)大方向:
- 業(yè)務(wù)優(yōu)化
- 代碼優(yōu)化
1、業(yè)務(wù)優(yōu)化
業(yè)務(wù)優(yōu)化的影響力非常大,但它一般屬于產(chǎn)品和項(xiàng)目經(jīng)理的范疇,CRUD程序員很少能接觸到。
比如上面說(shuō)的圖片導(dǎo)出上傳需求,經(jīng)過(guò)產(chǎn)品經(jīng)理和項(xiàng)目經(jīng)理的不懈努力,這個(gè)需求不做了,這優(yōu)化力度,史無(wú)前例啊。
2、代碼優(yōu)化
- 數(shù)據(jù)庫(kù)優(yōu)化
- 復(fù)用優(yōu)化
- 并行優(yōu)化
- 算法優(yōu)化
四、數(shù)據(jù)庫(kù)優(yōu)化
- inner join 、left join、right join,優(yōu)先使用inner join
- 表連接不宜太多,索引不宜太多,一般5個(gè)以內(nèi)
- 復(fù)合索引最左特性
- 操作delete或者update語(yǔ)句,加個(gè)limit或者循環(huán)分批次刪除
- 使用explain分析你SQL執(zhí)行計(jì)劃
- ...
五、復(fù)用優(yōu)化
寫代碼的時(shí)候,大家一般都會(huì)將重復(fù)性的代碼提取出來(lái),寫成工具方法,在下次用的時(shí)候,就不用重新編碼,直接調(diào)用就可以了。
這個(gè)就是復(fù)用。
數(shù)據(jù)庫(kù)連接池、線程池、長(zhǎng)連接也都是復(fù)用手段,這些對(duì)象的創(chuàng)建和銷毀成本過(guò)高,復(fù)用之后,效率提升顯著。
1、連接池
連接池是一種常見的優(yōu)化網(wǎng)絡(luò)連接復(fù)用性的方法。連接池管理著一定數(shù)量的網(wǎng)絡(luò)連接,并且在需要時(shí)將這些連接分配給客戶端,客戶端使用完后將連接歸還給連接池。這樣可以避免每次通信都建立新的連接,減少了連接的建立和銷毀過(guò)程,提高了系統(tǒng)的性能和效率。
在Java開發(fā)中,常用的連接池技術(shù)有Apache Commons Pool、Druid等。使用連接池時(shí),需要合理設(shè)置連接池的大小,并根據(jù)實(shí)際情況進(jìn)行調(diào)優(yōu)。連接池的大小過(guò)小會(huì)導(dǎo)致連接不夠用,而過(guò)大則會(huì)占用過(guò)多的系統(tǒng)資源。
2、長(zhǎng)連接
長(zhǎng)連接是另一種優(yōu)化網(wǎng)絡(luò)連接復(fù)用性的方法。長(zhǎng)連接指的是在一次通信后,保持網(wǎng)絡(luò)連接不關(guān)閉,以便后續(xù)的通信繼續(xù)復(fù)用該連接。與短連接相比,長(zhǎng)連接在一定程度上減少了連接的建立和銷毀過(guò)程,提高了網(wǎng)絡(luò)連接的復(fù)用性和效率。
在Java開發(fā)中,可以通過(guò)使用Socket編程實(shí)現(xiàn)長(zhǎng)連接??蛻舳嗽诮⑦B接后,通過(guò)設(shè)置Socket的Keep-Alive選項(xiàng),使得連接保持活躍狀態(tài)。這樣可以避免頻繁地建立新的連接,提高網(wǎng)絡(luò)連接的復(fù)用性和效率。
3、緩存
緩存也是比較常用的復(fù)用,屬于數(shù)據(jù)復(fù)用。
緩存一般是將數(shù)據(jù)庫(kù)中的數(shù)據(jù)緩存到內(nèi)存或者Redis中,也就是緩存到相對(duì)高速的區(qū)域,下次查詢時(shí),直接訪問(wèn)緩存,就不用查詢數(shù)據(jù)庫(kù)了,緩存主要針對(duì)的是讀操作。
4、緩沖
緩沖常見于對(duì)數(shù)據(jù)的暫存,然后批量傳輸或者寫入。多使用順序方式,用來(lái)緩解不同設(shè)備之間頻繁地、緩慢地隨機(jī)寫,緩沖主要針對(duì)的是寫操作。
六、并行優(yōu)化
1、異步編程
上面的優(yōu)化方式就是異步優(yōu)化,充分利用多核處理器的性能,將串行的程序改為并行,大大提高了程序的執(zhí)行效率。
異步編程是一種編程模型,其中任務(wù)的執(zhí)行不會(huì)阻塞當(dāng)前線程的執(zhí)行。通過(guò)將任務(wù)提交給其他線程或線程池來(lái)處理,當(dāng)前線程可以繼續(xù)執(zhí)行其他操作,而不必等待任務(wù)完成。
2、異步編程的特點(diǎn)
- 非阻塞:異步任務(wù)的執(zhí)行不會(huì)導(dǎo)致調(diào)用線程的阻塞,允許線程繼續(xù)執(zhí)行其他任務(wù);
- 回調(diào)機(jī)制:異步任務(wù)通常會(huì)注冊(cè)回調(diào)函數(shù),當(dāng)任務(wù)完成時(shí),會(huì)調(diào)用相應(yīng)的回調(diào)函數(shù)進(jìn)行后續(xù)處理;
- 提高響應(yīng)性:異步編程能夠提高程序的響應(yīng)性,尤其適用于處理IO密集型任務(wù),如網(wǎng)絡(luò)請(qǐng)求、數(shù)據(jù)庫(kù)查詢等;
Java 8引入了CompletableFuture類,可以方便地進(jìn)行異步編程。
3、并行編程
并行編程是一種利用多個(gè)線程或處理器同時(shí)執(zhí)行多個(gè)任務(wù)的編程模型。它將大任務(wù)劃分為多個(gè)子任務(wù),并發(fā)地執(zhí)行這些子任務(wù),從而加速整體任務(wù)的完成時(shí)間。
4、并行編程的特點(diǎn)
- 分布式任務(wù):并行編程將大任務(wù)劃分為多個(gè)獨(dú)立的子任務(wù),每個(gè)子任務(wù)在不同的線程中并行執(zhí)行;
- 數(shù)據(jù)共享:并行編程需要考慮多個(gè)線程之間的數(shù)據(jù)共享和同步問(wèn)題,以避免出現(xiàn)競(jìng)態(tài)條件和數(shù)據(jù)不一致的情況;
- 提高性能:并行編程能夠充分利用多核處理器的計(jì)算能力,加速程序的執(zhí)行速度。
5、并行編程如何實(shí)現(xiàn)?
- 多線程:Java提供了Thread類和Runnable接口,用于創(chuàng)建和管理多個(gè)線程。通過(guò)創(chuàng)建多個(gè)線程并發(fā)執(zhí)行任務(wù),可以實(shí)現(xiàn)并行編程。
- 線程池:Java的Executor框架提供了線程池的支持,可以方便地管理和調(diào)度多個(gè)線程。通過(guò)線程池,可以復(fù)用線程對(duì)象,減少線程創(chuàng)建和銷毀的開銷;
- 并發(fā)集合:Java提供了一系列的并發(fā)集合類,如ConcurrentHashMap、ConcurrentLinkedQueue等,用于在并行編程中實(shí)現(xiàn)線程安全的數(shù)據(jù)共享。
異步編程和并行編程是Java中處理任務(wù)并提高程序性能的兩種重要方法。
異步編程通過(guò)非阻塞的方式處理任務(wù),提高程序的響應(yīng)性,并適用于IO密集型任務(wù)。
而并行編程則是通過(guò)多個(gè)線程或處理器并發(fā)執(zhí)行任務(wù),充分利用計(jì)算資源,加速程序的執(zhí)行速度。
在Java中,可以使用CompletableFuture和回調(diào)接口實(shí)現(xiàn)異步編程,使用多線程、線程池和并發(fā)集合實(shí)現(xiàn)并行編程。通過(guò)合理地運(yùn)用異步和并行編程,我們可以在Java中高效地處理任務(wù)和提升程序的性能。
6、代碼示例
public static void main(String[] args) {
// 創(chuàng)建線程池
ExecutorService executor = Executors.newFixedThreadPool(10);
// 使用線程池創(chuàng)建CompletableFuture對(duì)象
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
// 一些不為人知的操作
return "result"; // 返回結(jié)果
}, executor);
// 使用CompletableFuture對(duì)象執(zhí)行任務(wù)
CompletableFuture<String> result = future.thenApply(result -> {
// 一些不為人知的操作
return "result"; // 返回結(jié)果
});
// 處理任務(wù)結(jié)果
String finalResult = result.join();
// 關(guān)閉線程池
executor.shutdown();
}
7、Java 8 parallel
(1)parallel()是什么
Stream.parallel() 方法是 Java 8 中 Stream API 提供的一種并行處理方式。在處理大量數(shù)據(jù)或者耗時(shí)操作時(shí),使用 Stream.parallel() 方法可以充分利用多核 CPU 的優(yōu)勢(shì),提高程序的性能。
Stream.parallel() 方法是將串行流轉(zhuǎn)化為并行流的方法。通過(guò)該方法可以將大量數(shù)據(jù)劃分為多個(gè)子任務(wù)交由多個(gè)線程并行處理,最終將各個(gè)子任務(wù)的計(jì)算結(jié)果合并得到最終結(jié)果。使用 Stream.parallel() 可以簡(jiǎn)化多線程編程,減少開發(fā)難度。
需要注意的是,并行處理可能會(huì)引入線程安全等問(wèn)題,需要根據(jù)具體情況進(jìn)行選擇。
(2)舉一個(gè)簡(jiǎn)單的demo
定義一個(gè)list,然后通過(guò)parallel() 方法將集合轉(zhuǎn)化為并行流,對(duì)每個(gè)元素進(jìn)行i++,最后通過(guò) collect(Collectors.toList()) 方法將結(jié)果轉(zhuǎn)化為 List 集合。
使用并行處理可以充分利用多核 CPU 的優(yōu)勢(shì),加快處理速度。
public class StreamTest {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
list.add(i);
}
System.out.println(list);
List<Integer> result = list.stream().parallel().map(i -> i++).collect(Collectors.toList());
System.out.println(result);
}
}
我勒個(gè)去,什么情況?
(3)parallel()的優(yōu)缺點(diǎn)
① 優(yōu)點(diǎn):
- 充分利用多核 CPU 的優(yōu)勢(shì),提高程序的性能;
- 可以簡(jiǎn)化多線程編程,減少開發(fā)難度。
② 缺點(diǎn):
- 并行處理可能會(huì)引入線程安全等問(wèn)題,需要根據(jù)具體情況進(jìn)行選擇;
- 并行處理需要付出額外的開銷,例如線程池的創(chuàng)建和銷毀、線程切換等,對(duì)于小數(shù)據(jù)量和簡(jiǎn)單計(jì)算而言,串行處理可能更快。
(4)何時(shí)使用parallel()?
在實(shí)際開發(fā)中,應(yīng)該根據(jù)數(shù)據(jù)量、計(jì)算復(fù)雜度、硬件等因素綜合考慮。
比如:
- 數(shù)據(jù)量較大,有1萬(wàn)個(gè)元素;
- 計(jì)算復(fù)雜度過(guò)大,需要對(duì)每個(gè)元素進(jìn)行復(fù)雜的計(jì)算;
- 硬件夠硬,比如多核CPU。
七、算法優(yōu)化
在上面的例子中,避免base64解密,就應(yīng)該歸類于算法優(yōu)化。
程序就是由數(shù)據(jù)結(jié)構(gòu)和算法組成,一個(gè)優(yōu)質(zhì)的算法可以顯著提高程序的執(zhí)行效率,從而減少運(yùn)行時(shí)間和資源消耗。相比之下,一個(gè)低效的算法就可能導(dǎo)致運(yùn)行非常緩慢,并占用大量系統(tǒng)資源。
很多問(wèn)題都可以通過(guò)算法優(yōu)化來(lái)解決,比如:
1、循環(huán)和遞歸
循環(huán)和遞歸是Java編程中常見的操作,然而,過(guò)于復(fù)雜的業(yè)務(wù)邏輯往往會(huì)帶來(lái)多層循環(huán)套用,不必要的重復(fù)循環(huán)會(huì)大大降低程序的執(zhí)行效率。
遞歸是一種函數(shù)自我調(diào)用的技術(shù),類似于循環(huán),雖然遞歸可以解決很多問(wèn)題,但是,遞歸的效率有待提高。
2、內(nèi)存管理
Java自帶垃圾收集器,開發(fā)人員不用手動(dòng)釋放內(nèi)存。
但是,不合理的內(nèi)存使用可能導(dǎo)致內(nèi)存泄漏和性能下降,確保及時(shí)釋放不再使用的對(duì)象,避免創(chuàng)建過(guò)多的臨時(shí)對(duì)象。
3、字符串
我覺得字符串是Java編程中使用頻率最高的技術(shù),很多程序員恨不得把所有的變量都定義成字符串。
然而,由于字符串是不可變的,每次執(zhí)行字符串拼接、替換時(shí),都會(huì)創(chuàng)建一個(gè)新的字符串。這會(huì)占用大量的內(nèi)存和處理時(shí)間。
使用StringBuilder來(lái)處理字符串的拼接可以顯著的提高性能。
4、IO操作
IO操作通常是最耗費(fèi)性能和資源的操作。在處理大量數(shù)據(jù)IO操作時(shí),務(wù)必注意優(yōu)化IO代碼,提高程序性能,比如上面提高的圖片不落地就是徹底解決IO問(wèn)題。
5、數(shù)據(jù)結(jié)構(gòu)的選擇
選擇適當(dāng)?shù)臄?shù)據(jù)結(jié)構(gòu)對(duì)程序的性能至關(guān)重要。
比如Java世界中用的第二多的Map,比較常用的有HashMap、HashTable、ConcurrentHashMap。
- HashMap,底層數(shù)組+鏈表實(shí)現(xiàn),可以存儲(chǔ)null鍵和null值,線程不安全;
- HashTable,底層數(shù)組+鏈表實(shí)現(xiàn),無(wú)論key還是value都不能為null,線程安全,實(shí)現(xiàn)線程安全的方式是在修改數(shù)據(jù)時(shí)鎖住整個(gè)HashTable,效率低,ConcurrentHashMap做了相關(guān)優(yōu)化;
- ConcurrentHashMap,底層采用分段的數(shù)組+鏈表實(shí)現(xiàn),線程安全,通過(guò)把整個(gè)Map分為N個(gè)Segment,可以提供相同的線程安全,但是效率提升N倍,默認(rèn)提升16倍。
Hashtable的synchronized是針對(duì)整張Hash表的,即每次鎖住整張表讓線程獨(dú)占,ConcurrentHashMap允許多個(gè)修改操作并發(fā)進(jìn)行,其關(guān)鍵在于使用了鎖分離技術(shù)。