背景:
給某銀行做一個實時計算項目,用戶交易記錄鏈路爲oracle->ogg->kafka->flink->elasticsearch,交易記錄最終到達Elasticsearch中並對外提供查詢服務,交易記錄中有個printNum字段,用來表示某條交易記錄的打印次數,現需要提供一個接口進行打印次數的更新。
方案:
首先想到的是,將這條記錄的printNum查詢出來然後進行+1操作,最後再寫入ES。但是,直覺告訴我事情並沒有那麼簡單。但凡涉及到+1操作,必定要考慮線程安全問題,詳情參考Integer和AtomicInteger的區別。經查詢,可以使用Elasticsearch自帶的_update_by_query
rest接口,進行查詢更新,請求url和入參如下:
http://host:port/index/_update_by_query
{
"query": {
"constant_score": {
"filter": {
"terms": {
"_id": ["AW4bpAqMhwUDavNpk6CE", "AW4bos1xhwUDavNpk6CD"]
}
}
}
},
"script": {
"lang": "painless",
"inline": "if(ctx._source.PRINT_NUM instanceof long || ctx._source.PRINT_NUM instanceof int){ctx._source.PRINT_NUM++;}else{int temp = Integer.parseInt(ctx._source.PRINT_NUM);ctx._source.PRINT_NUM = String.format('%d', new def[] {temp+1})}"
}
}
該請求的意思就是,將_id爲AW4bpAqMhwUDavNpk6CE, AW4bos1xhwUDavNpk6CD
的文檔的printNum字段進行+1操作,具體的painless腳本使用,可參考Elasticsearch官方文檔。_update_by_query
接口自帶樂觀鎖(版本控制),且版本控制是針對文檔的(類比mysql表鎖和行鎖,可以去更新_update
同一個索引下的兩個不同的文檔,看返回值中的版本號變化),在postman瘋狂請求,會報如下錯誤:
{
"took": 29,
"timed_out": false,
"total": 2,
"updated": 0,
"deleted": 0,
"batches": 1,
"version_conflicts": 2,
"noops": 0,
"retries": {
"bulk": 0,
"search": 0
},
"throttled_millis": 0,
"requests_per_second": -1,
"throttled_until_millis": 0,
"failures": [
{
"index": "ss_20191030",
"type": "x",
"id": "AW4bos1xhwUDavNpk6CD",
"cause": {
"type": "version_conflict_engine_exception",
"reason": "[x][AW4bos1xhwUDavNpk6CD]: version conflict, current version [696] is different than the one provided [695]",
"index_uuid": "Lo9q24n2SQ2z6peNxE8vsg",
"shard": "0",
"index": "ss_20191030"
},
"status": 409
}
]
}
因此,必須在樂觀鎖發生的時候,進行不斷重試,知道返回的json中failures
列表爲空纔行,代碼如下:
//ElasticQueryDao.java
/**
* @Description: 更新打印次數
* @param: [response]
* @return: void
* @throws:
*/
public void updatePrintNum(Response<JSONObject> response) {
StackTraceElement stack[] = (new Throwable()).getStackTrace();
String level1 = stack[1].getFileName() + "#" + stack[1].getMethodName() + "#" + stack[1].getLineNumber();
String level2 = stack[0].getFileName() + "#" + stack[0].getMethodName() + "#" + stack[0].getLineNumber();
//獲取所有_id
JSONArray idJsonArr = response.getContent().getJSONObject("hits").getJSONArray("hits");
Set<String> toBeAddArr = new HashSet<>();
for (int i = 0; i < idJsonArr.size(); i++) {
String idTemp = (String) ((JSONObject) idJsonArr.get(i)).get("_id");
toBeAddArr.add(idTemp);
}
//加線程池控制併發
ExecutorService pool = CacheThreadPoolConfig.getInstance();
pool.execute(() -> {
updatePrintNumCore(level1 + "->" + level2, toBeAddArr);
});
}
public void updatePrintNumCore(String stack, Set<String> toBeAddArr) {
//樂觀鎖發生的頻率遠比想象中高很多,因此不要用計數器,用了計數器如果設置的不夠大多半會丟更新次數
//直接在發生error時放棄本次更新即可
do {
String idStrTemp = "\"" + String.join("\",\"", toBeAddArr) + "\"";
String filterTemp = "{\"query\":{\"constant_score\":{\"filter\":{\"terms\":{\"_id\":[" + idStrTemp + "]}}}},\"script\":{\"lang\":\"painless\",\"inline\":\"if(ctx._source.PRINT_NUM instanceof long || ctx._source.PRINT_NUM instanceof int){ctx._source.PRINT_NUM++;}else{int temp = Integer.parseInt(ctx._source.PRINT_NUM);ctx._source.PRINT_NUM = String.format('%d', new def[] {temp+1})}\"}}";
String urlTemp = String.format("http://%s:%s/%s/_update_by_query", elasticConfiguration.getEsHost(), elasticConfiguration.getEsPort(), elasticConfiguration.getOfflineIndexPrefix() + "*," + elasticConfiguration.getRealtimeIndexPrefix() + "*");
//樂觀鎖頻率很高,因此禁止打日誌
Response<JSONObject> responseTemp = commonSearchWithoutLog(urlTemp, filterTemp);
if (responseTemp.getContent().getJSONObject("error") != null) {
log.error("【error】方法調用棧:{}, 更新打印次數:{}", stack, responseTemp.getContent());
break;
}
JSONArray failureJsonArrTemp = responseTemp.getContent().getJSONArray("failures");
if (failureJsonArrTemp.size() == 0) {
break;
}
Set<String> toBeArrTemp = new HashSet<>();
for (int j = 0; j < failureJsonArrTemp.size(); j++) {
if (failureJsonArrTemp.getJSONObject(j).get("id") == null) {
log.error("【未知fail】方法調用棧:{}, 更新打印次數:{}", stack, failureJsonArrTemp.getJSONObject(j));
continue;
}
toBeArrTemp.add(String.valueOf(failureJsonArrTemp.getJSONObject(j).get("id")));
}
toBeAddArr = toBeArrTemp;
} while (toBeAddArr.size() > 0);
}
中間遇到的問題:
銀行方反饋,請求日誌瘋狂刷屏,永不停止。
問題分析:
寫代碼的時候對樂觀鎖發生頻率缺乏概念,因此一次又一次地重新嘗試導致請求日誌瘋狂刷屏。問題反饋出來後,在本地進行了模擬併發請求的實驗,代碼如下:
@RestController
public class controller {
@Autowired
ElasticQueryDao elasticQueryDao;
@GetMapping(value = "/test", produces = "application/json")
public String test() {
long startTime = System.currentTimeMillis();
Response response = new Response();
JSONObject jsonObject = new JSONObject();
JSONArray jsonArray = new JSONArray();
jsonArray.add(new JSONObject().fluentPut("_id", "AW4bpAqMhwUDavNpk6CE"));
jsonArray.add(new JSONObject().fluentPut("_id", "AW4bos1xhwUDavNpk6CD"));
jsonObject.fluentPut("hits", jsonArray);
JSONObject jsonObject2 = new JSONObject();
jsonObject2.fluentPut("hits", jsonObject);
response.content(jsonObject2);
CountDownLatch countDownLatch = new CountDownLatch(20);
ExecutorService pool = CacheThreadPoolConfig.getInstance();
for (int i = 0; i < 20; i++) {
pool.execute(() -> {
countDownLatch.countDown();
elasticQueryDao.updatePrintNum(response);
});
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
long endTime = System.currentTimeMillis();
System.out.println("程序運行時間:" + (endTime - startTime) + "ms");
return "nidaye";
}
}
也許你會問,這裏爲什麼不用單元測試,而是用一個controller從前端發起請求?這是因爲Junit不支持多線程的測試,子線程會隨着主線程退出而強行退出,原因參考https://www.cnblogs.com/yanphet/p/5774291.html
其中,由線程池配置CacheThreadPoolConfig可以看出,該線程池是一個固定線程數,且緩衝隊列不設限的線程池,代碼如下:
/**
* @Description: 創建緩存公用線程池
* @author: daijiguo
* @Version: 1.0
* @Create: 2019-05-30-09:15
* @Update: 2019-05-30-09:15
*/
public class CacheThreadPoolConfig {
// 私有構造函數
private CacheThreadPoolConfig() {
}
// 單例對象
private static ExecutorService pool = new ThreadPoolExecutor(
10,10,10, TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(),
new ThreadPoolExecutor.CallerRunsPolicy());
// 靜態的工廠方法
public static ExecutorService getInstance() {
return pool;
}
}
將elasticQueryDao#updatePrintNum()中的線程池異步更新printNum的邏輯刪掉(原版代碼本來就是同步更新的,也是在這次問題分析後纔在代碼中加入異步更新邏輯的),轉而在單測中寫代碼模擬20個併發線程去進行printNum字段的自增操作,調節線程池線程數,得到如下結果:
併發量 | 線程數 | 耗時 |
---|---|---|
20 | 1 | 10770ms |
20 | 5 | 10416ms |
20 | 10 | 7822ms |
20 | 20 | 12ms |
20 | 40 | 11ms |
由實驗數據可知,在達到併發量之前,線程數越高越好,即使因爲線程數的增加會導致樂觀鎖發生的頻率更高,多線程帶來的收益也遠比降低樂觀鎖頻率帶來的收益高(樂觀鎖的版本衝突發生概率極大,本來想用線程池控制併發量,以減少版本衝突發生頻率,達到減小耗時的目的,現在發現並沒有必要,不如猛加線程來得簡單粗暴)。但是線程數一旦超過併發量,耗時沒有明顯變化。
最後,通過在elasticQueryDao#updatePrintNum()加入線程池異步調用ES的_update_by_query
的邏輯,以保證接口的快速返回。根據上面實驗可知,其實此處可以將線程池線程數設置得足夠大,以提高速度,降低耗時。
結論:
樂觀鎖(版本衝突)發生頻率過高是無法避免的,且通過線程池控制併發數,以降低樂觀鎖發生頻率也是完全沒有必要的,因爲實驗數據表明,即使樂觀鎖發生頻率很高,只要線程足夠多,printNum也能很快更新成功。對於銀行提出的反饋,我們只需要將do while語句中無用的日誌禁掉即可。
參考:
https://www.cnblogs.com/yanphet/p/5774291.html
https://www.cnblogs.com/laoyeye/p/8097684.html
https://www.cnblogs.com/sheeva/p/6366782.html
https://cloud.tencent.com/developer/article/1343143