關於elasticsearch中_update_by_query接口樂觀鎖的思考

背景:

  給某銀行做一個實時計算項目,用戶交易記錄鏈路爲oracle->ogg->kafka->flink->elasticsearch,交易記錄最終到達Elasticsearch中並對外提供查詢服務,交易記錄中有個printNum字段,用來表示某條交易記錄的打印次數,現需要提供一個接口進行打印次數的更新。

方案:

  首先想到的是,將這條記錄的printNum查詢出來然後進行+1操作,最後再寫入ES。但是,直覺告訴我事情並沒有那麼簡單。但凡涉及到+1操作,必定要考慮線程安全問題,詳情參考Integer和AtomicInteger的區別。經查詢,可以使用Elasticsearch自帶的_update_by_queryrest接口,進行查詢更新,請求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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章