價值
21世紀初期有個非常具有時代標誌性的詞叫做互聯網,在這短短二十年的時間裡,鋪天蓋地的產品如雨後春筍般破土而出。
工業時代到互聯網時代的跨越,給我們普通人帶來的最大改變是,井噴式的海量信息開始充斥到我們每個人的生活中。這些信息以各種各樣的形式在我們的生活中呈現,而其最主要的聚集點便是我們的終端智能設備——智能手機,和家庭/辦公設備——電腦,我們將這二者統稱為客戶端。
互聯網時代有一個非常大的賽道,每個互聯網產品都是賽道上的一名選手,衡量選手成績的兩個最核心的指標是「產品功能」和「用戶體驗」。「產品功能」是互聯網產品的從0到1,決定了產品的底層架構;「用戶體驗」是互聯網產品的從1到N,決定了產品的上層建設。早期的互聯網產品更側重的是「產品功能」,眼前只有饅頭白米飯的時候,想吃就只能選這些;現階段的互聯網產品更側重的是「用戶體驗」,同樣免費的饅頭白米飯和山珍佳餚都一樣能填飽肚子,用戶當然更願意選擇後者。
互聯網產品中,客戶端的用戶體驗,在當下行業競爭白熱化的時代正在變得格外重要。在用戶獲取每條信息的背後,都是一行行的代碼,如果互聯網產品是一座高樓大廈,那這些代碼便是構建這個龐然大物的基石。
服務端查詢接口的代碼質量,將直接決定產品的用戶體驗,對用戶最直觀的體驗感受是,打開一個頁面後的加載等待時長。回想一下當你打開手機App上的一個頁面,等了三秒甚至更久都還加載不出來頁面時的心情,這正是本文核心要剖析和解決的問題。
業務價值
產品視角
一個好的查詢接口會讓用戶在使用App過程中感受到絲般順滑,無形之中給產品加分,提升用戶的使用體驗;反之一個糟糕的接口會讓用戶有種味如嚼蠟般的體感,這種類似的語言是不是似曾相似:“XX的App真難用,打開一個頁面就要等半天”,“我手機連的是WIFI,為什麼還是這麼慢”......
時間視角
這是一個很有意思的命題,試想一下,我們App的DAU是100W,平均每個DAU產生的有效接口調用量是10,基於這個背景,如果我在查詢接口的代碼優化使得這個接口的耗時減少1s,便能使得我們的用戶節省總計 100W * 10 * 1 ≈ 116天
的無效等待時間。而這僅僅是1天的節省成本,如果是1年,能使用戶節省116年的時間,甚至超過了一個普通人的一生,使命感油然而生。
技術價值
技術進階
“還能不能更優”的習慣性自我審問,催生著我們一遍遍的去思考,思考解決一個場景化問題中更接近完美的方案。恰恰是思考,是促使人們不斷進步的核心動力。
分享傳播
科學無國界,編碼一樣沒有,所以 Github
才會成為全世界最大的男性網站。分享和傳播使得我們和世界建立更多的連接,從而有著更多的人生意義。
各維度去說明了優化的必要性之後,接下來我們從一個具體的編碼案例引入。
一個例子
功能訴求
很簡潔的一個例子,背景就是根據內容ID來查詢內容信息(如下),訴求就是通過編碼優化使得這個查詢效率變快,減少上游(客戶端App或外部服務)的等待時間。
public interface ContentService {
List<ContentVO> queryList(List<Long> contentIds);
}
模型
-
內容主體
public class ContentVO { private Long id; private String title; private String text; private Long userId; private String userNick; private String userAvatar; private Long cityId; private String cityName; private String cityDescription; private List<GoodsDTO> goodsList; }
其中,
Content
在DB裡面只有userId
、cityId
和goodsIds
這些關聯信息的外鍵,並沒有冗餘諸如userNick
、userAvatar
等這些附屬字段,這些都需要我們通過調用外部服務進行額外的查詢和組裝。 -
附屬信息
分別對應
UserDTO
、CityDTO
和GoodsDTO
三個外部服務模型。public class UserDTO { private Long id; private String nick; private String avatar; }
public class CityDTO { private Long id; private String name; private String description; }
public class GoodsDTO { private Long id; private String name; private String image; }
都是一個ID主鍵對應兩個具象字段,比較明瞭,無需贅述。
現有接口
-
內部DB查詢主接口
public interface ContentMapper { List<ContentEntity> selectBatchIds(List<Long> contentIds); }
-
外部服務接口
public interface UserService { UserDTO queryById(Long id); }
另外的
CityService
和GoodsService
也保持一致,這裡不列了。
編碼現狀
考慮到篇幅問題,我省略了類聲明和 Bean
注入這些無關緊要的部分,當前的現狀如下。
@Override
public List<ContentVO> queryList(List<Long> contentIds) {
// query from db
List<ContentEntity> contents = contentMapper.selectBatchIds(contentIds);
return contents.stream()
.map(content -> {
// entity => vo
ContentVO vo = new ContentVO()
.setId(content.getId())
.setTitle(content.getTitle())
.setText(content.getText());
// fill user info
Optional.ofNullable(content.getUserId())
.map(userService::queryById)
.ifPresent(user -> vo
.setUserId(user.getId())
.setUserNick(user.getNick())
.setUserAvatar(user.getAvatar())
);
// fill city info
Optional.ofNullable(content.getCityId())
.map(cityService::queryById)
.ifPresent(city -> vo
.setCityId(city.getId())
.setCityName(city.getName())
.setCityDescription(city.getDescription())
);
// fill goods info
Optional.ofNullable(content.getGoodsIds())
.map(StringUtil::str2LongList)
.filter(CollectionUtil::isNotEmpty)
.map(ids -> new ArrayList<>(goodsService.batchQuery(ids).values()))
.ifPresent(vo::setGoodsList);
return vo;
})
.collect(Collectors.toList());
}
測試用例
我們用size為1000的數據進行測試,當前的接口耗時為 163152ms
,接近三分多鐘,也就意味著用戶要在App頁面中近3分鐘才能顯示內容(前提是客戶端在Http請求中沒有設置超時時間)。
這個結果看上去非常不可思議,但實際上這個現狀在很多業務場景中很極有可能存在,只是沒有這麼明顯而已。這裡最關鍵的一點在於size的設置,普通的列表查詢可能一次只會查找10條,即size=10,而此時,頁面加載也就只有大概1.6s的耗時,我相信這個量級在我們的使用App的過程中應該是屢見不鮮的。屏幕前不耐煩等待的背後,可能正隱藏著類似上面的“整齊代碼”。
接下來,我們將從編碼角度,針對於這個1000條數據的查詢場景,進行多個維度的代碼優化。
優化路徑
我們將按照優化的綜合效果陸續展開,其中的每一步都會基於上一步的優化結果進行遞進。為了便於理解,每一步優化我們都按照分析和改進進行展開,同時會在每項優化的最好採用類比的方式進行舉例,以儘可能降低理解成本。
批量查詢優化
分析
分析上面的代碼,首先找到最耗時的幾個調用,分別是:
contentMapper.selectBatchIds
userService.queryById
cityService.queryById
goodsService.queryById
單獨看每個方法的單次耗時,大概都在幾十毫秒左右,這本身無足輕重。而一旦我們要查詢的數據量變成了1000,當前代碼寫法的弊端就馬上展現出來了。userService.queryById
這種查詢是在每條數據中都單獨進行一次,也就意味著1000次就要乘以1000倍,毫秒級別立刻變成秒級別。這裡有個原則,服務接口的編碼中絕對不能包含與外部入參有著線性關係的代碼邏輯。
改進
將 queryById
接口替換為 queryList
,在for循環外部進行批量查詢,然後再在for內部進行數據填充。
@Override
public List<ContentVO> queryList(List<Long> contentIds) {
// query from db
List<ContentEntity> contents = contentMapper.selectBatchIds(contentIds);
// collect related ids from contents
Set<Long> userIds = new HashSet<>();
Set<Long> cityIds = new HashSet<>();
Set<Long> goodsIds = new HashSet<>();
contents.forEach(content -> {
Optional.ofNullable(content.getUserId()).ifPresent(userIds::add);
Optional.ofNullable(content.getCityId()).ifPresent(cityIds::add);
Optional.ofNullable(content.getGoodsIds())
.map(StringUtil::str2LongList)
.ifPresent(goodsIds::addAll);
});
// query user info
Map<Long, UserDTO> userId2User = userService.batchQuery(new ArrayList<>(userIds));
// query city info
Map<Long, CityDTO> cityId2City = cityService.batchQuery(new ArrayList<>(cityIds));
// query goods info
Map<Long, GoodsDTO> goodsId2Goods = goodsService.batchQuery(new ArrayList<>(goodsIds));
return contents.stream()
.map(content -> {
// entity => vo
ContentVO vo = new ContentVO()
.setId(content.getId())
.setTitle(content.getTitle())
.setText(content.getText())
.setUserId(content.getUserId())
.setCityId(content.getCityId());
// fill user info
Optional.ofNullable(content.getUserId())
.map(userId2User::get)
.ifPresent(user -> vo
.setUserNick(user.getNick())
.setUserAvatar(user.getAvatar())
);
// fill city info
Optional.ofNullable(content.getCityId())
.map(cityId2City::get)
.ifPresent(city -> vo
.setCityName(city.getName())
.setCityDescription(city.getDescription())
);
// fill goods info
Optional.ofNullable(content.getGoodsIds())
.map(StringUtil::str2LongList)
.filter(CollectionUtil::isNotEmpty)
.map(ids -> ids.stream()
.map(goodsId2Goods::get)
.collect(Collectors.toList())
)
.ifPresent(vo::setGoodsList);
return vo;
})
.collect(Collectors.toList());
}
優化對比:163152ms
=> 623ms
(本機測試數據,僅供參考,下同)
Tip1:代碼改進過程中,通常需要注意對附屬外鍵(如
userId
)的判空保護,儘量防止傳入null
進而引起外部服務的查詢異常。Tip2:通常意義上講,提供批量查詢接口是作為服務提供者的基本共識。
類比
我人在北京,想來杭州買1000件衣服
- 優化前,我從北京到杭州來回1000次,每次買1件
- 優化後,我從北京到杭州來回1次,1次買1000件,節省了999次來回的時間
異步調用優化
分析
此時我們把焦點繼續鎖定在外部服務調用部分:
// query user info
Map<Long, UserDTO> userId2User = userService.batchQuery(new ArrayList<>(userIds));
// query city info
Map<Long, CityDTO> cityId2City = cityService.batchQuery(new ArrayList<>(cityIds));
// query goods info
Map<Long, GoodsDTO> goodsId2Goods = goodsService.batchQuery(new ArrayList<>(goodsIds));
雖然經過上一步的改進,我們取得了具體的成功,但在這裡三行代碼裡,依然存在著巨大的優化空間。
調度外部服務本質是一種IO型任務,這類型任務的顯著特點就是不會消耗CPU資源,但是會在當前線程進行阻塞等待。對於IO型耗時任務的優化通常是,通過引入多線程並行調度來提高任務的整體執行效率。
改進
這裡只貼出附屬信息查詢的邏輯
// query user info
CompletableFuture<Map<Long, UserDTO>> userId2UserFuture = CompletableFuture.supplyAsync(
() -> userService.batchQuery(new ArrayList<>(userIds))
);
// query city info
CompletableFuture<Map<Long, CityDTO>> cityId2CityFuture = CompletableFuture.supplyAsync(
() -> cityService.batchQuery(new ArrayList<>(cityIds))
);
// query goods info
CompletableFuture<Map<Long, GoodsDTO>> goodsId2GoodsFuture = CompletableFuture.supplyAsync(
() -> goodsService.batchQuery(new ArrayList<>(goodsIds))
);
// wait all query task end
CompletableFuture.allOf(userId2UserFuture, cityId2CityFuture, goodsId2GoodsFuture).join();
// get every extra info
Map<Long, UserDTO> userId2User = userId2UserFuture.getNow(Collections.emptyMap());
Map<Long, CityDTO> cityId2City = cityId2CityFuture.getNow(Collections.emptyMap());
Map<Long, GoodsDTO> goodsId2Goods = goodsId2GoodsFuture.getNow(Collections.emptyMap());
優化對比:623ms
=> 455ms
Tip:
CompletableFuture
對於併發編程比較友好,併發場景中優先推薦使用該方式。
類比
我要買奶茶,買炸雞,買電影票
- 優化前,買奶茶排隊10分鐘,買炸雞排隊5分鐘,買電影票排隊7分鐘,全部買完花了22分鐘
- 優化後,找3個人分別幫我排隊,等他們全部買好通知我,全部買完花了10分鐘,節省了12分鐘
熔斷處理優化
分析
來看這行代碼:
// wait all query task end
CompletableFuture.allOf(userId2UserFuture, cityId2CityFuture, goodsId2GoodsFuture).join();
在等待所有外部服務調度結束再往下走,這樣寫在絕大多數情況下沒有問題,但有個致命的問題,把自己業務主體邏輯與外部依賴的服務強關聯了。倘若在其中的一次請求中,外部服務出現抖動,導致該次調用耗時遠遠大幅度增加,那我們的服務就也會遇到同樣的問題,因為我們強依賴外部了,是一根繩上的螞蚱。
改進
// wait all query task with limit time
try {
CompletableFuture.allOf(userId2UserFuture, cityId2CityFuture, goodsId2GoodsFuture)
.get(2000, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
if (!userId2UserFuture.isDone()) {
log.warn("Fetch user info timeout, data size is {}", userIds.size());
}
if (!cityId2CityFuture.isDone()) {
log.warn("Fetch city info timeout, data size is {}", cityIds.size());
}
if (!goodsId2GoodsFuture.isDone()) {
log.warn("Fetch goods info timeout, data size is {}", goodsIds.size());
}
} catch (InterruptedException | ExecutionException e) {
log.error("Fetch extra info error", e);
}
優化對比:455ms
=> 350ms
Tip1:這裡常常容易犯錯,對每個
CompletableFuture
使用get(2000, TimeUnit.MILLISECONDS)
進行等待,這樣就會導致實際等待的時長為6s,而不是預期的2s。Tip2:異常問題的預案很重要,技術側要做好監控告警,業務側要做好產品交互。
類比
同樣是上面排隊的例子
- 優化前,製作奶茶的器械突然壞了,以至於維修好之後,已經從原本的10分鐘等待變成了1小時
- 優化後,我就等15分鐘時間,如果15分鐘還沒買好我就不要了,只帶走買好的東西,在異常情況下,這能節省大量的等待時間
分拆並行優化
分析
一句通常容易被忽視的代碼:
// query from db
List<ContentEntity> contents = contentMapper.selectBatchIds(contentIds);
如果此時的 contentIds
不再是1000,而是1W,甚至10W、20W,想想會怎樣。DB中 in
語句的查詢效率,在達到DB能承載的一定閾值(具體值視機器而定)的情況下,查詢效率會出現斷崖時下跌,理解起來也不奇怪,服務器的內存和CPU資源畢竟是有限的。
改進
// query from db
List<ContentEntity> contents = Lists.partition(contentIds, 100).parallelStream()
.flatMap(batchIds -> contentMapper.selectBatchIds(batchIds).stream())
.collect(Collectors.toList());
優化對比:350ms
=> 280ms
Tip1:這裡對size為1000的
contentIds
拆分效果並不明顯,主要是因為我電腦的閾值轉折點要遠在1000之上Tip2:除了對
in
的數據進行分拆之外,這裡還做了異步並行的處理。
類比
我要搬100塊磚
- 優化前,一次性搬100塊,累的夠嗆,走一會兒歇一會兒,一共用時1小時
-
優化後
- 分拆,分5次搬,每次搬20塊,搬得少自然搬得快,一共用時15分鐘
- 並行,找5個幫忙一起搬,每個人搬20塊,這5個人有的快有的慢,等他們全部搬完,一共用時5分鐘
引進緩存優化
分析
對於高併發和查詢耗時比較嚴重的場景,還有一個終極優化方案——緩存。
改進
// 僅供參考
public static <T> List<T> getList(String domain, List<Long> ids, Function<List<Long>, List<T>> queryFunc) {
List<Long> distinctIds = ids.stream().distinct().collect(Collectors.toList());
Map<Long, Object> cacheMap = Cache.getCache(domain);
List<Long> absentIds = distinctIds.stream()
.filter(id -> !cacheMap.containsKey(id))
.collect(Collectors.toList());
if (CollectionUtil.isNotEmpty(absentIds)) {
List<T> absentList = queryFunc.apply(absentIds);
absentList.forEach(absent -> {
try {
Field idField = absent.getClass().getDeclaredField("id");
idField.setAccessible(true);
cacheMap.put((Long) idField.get(absent), absent);
} catch (Exception e) {
e.printStackTrace();
}
});
}
return distinctIds.stream()
.map(id -> (T) cacheMap.get(id))
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
// 改進前
contentMapper.selectBatchIds(batchIds)
// 改進後
CacheUtil.getList("queryContentFromDB", batchIds, contentMapper::selectBatchIds)
// 改進前
userService.batchQuery(new ArrayList<>(userIds)
// 改進後
CacheUtil.getMap("queryUser", new ArrayList<>(userIds), userService::batchQuery)
優化對比:280ms
=> 21ms
=> 1ms
(添加不同粒度的緩存,對應不同的效果)
Tip1:添加緩存時,使用儘可能細粒度的緩存策略,可以有效提高緩存命中率。
Tip2:使用該方案首先要考慮的就是時效性,時效性分為兩種,系統內和系統外。對於系統內的緩存,我們可以感知失效時機,通常問題不大;而對於系統外的緩存,我們要結合具體場景去分析。
Tip3:該方案優化效果顯著,但同時弊端也很明顯,很多線上問題均由它直接或間接引起的,務必做好CodeReview,以及保證跟產品經理進行充分的badcase溝通。
類比
不斷有人問我,今天下雨了沒
- 優化前,問我一次,我出去看一次,一次花費1分鐘
- 優化後,問我一次,我出去看一次,接下來5分鐘內還有人問我,我告訴他最近一次看的結果;5分鐘之後再有人問我,我再出去看,以此類推。這樣在緩存命中時,我幾乎不花時間就能告訴他結果,但可能不準
總結
歸納梳理
我們對上面所有的優化過程進行反覆審視,最終抽象和提煉出以下支撐整個優化過程的三個原則。
降低調用邊際成本
主線程調用IO型任務,通常都會伴隨著CPU資源的浪費,此時異步處理是這類型問題的通解。
充分利用CPU資源
某種意義上來說,這也是上一條的延續,既然異步了就完全可以考慮並行,多一個線程處理任務對於整個任務的提效很顯著。
空間換時間
一個讓查詢效率有質的飛躍的優化策略,該原則在算法的場景中更多被使用到。
關於優化
回到本文開始,做代碼優化,就是在做用戶體驗,就是在做更好的互聯網產品,就是做每個用戶的 Time Savior。