當前位置: 妍妍網 > 碼農

Java實作10萬,並行去重,優雅地處理重復請求!

2024-03-05碼農

對於一些使用者請求,在某些情況下是可能重復發送的,如果是查詢類操作並無大礙,但其中有些是涉及寫入操作的,一旦重復了,可能會導致很嚴重的後果,例如交易的介面如果重復請求可能會重復下單。

重復的場景有可能是:

  • 黑客攔截了請求,重放

  • 前端/客戶端因為某些原因請求重復發送了,或者使用者在很短的時間內重復點選

  • 閘道器重發

  • ….

  • 本文討論的是如何在伺服端優雅地統一處理這種情況,如何禁止使用者重復點選等客戶端操作不在本文的討論範疇。

    可能會想到的是,只要請求有唯一的請求編號,那麽就能借用 Redis 做這個去重——只要這個唯一請求編號在redis存在,證明處理過,那麽就認為是重復的

    程式碼大概如下:

    String KEY = "REQ12343456788";//請求唯一編號 long expireTime = 1000;// 1000毫秒過期,1000ms內的重復請求會認為重復 long expireAt = System.currentTimeMillis() + expireTime; String val = "expireAt@" + expireAt;//redis key還存在的話要就認為請求是重復的Boolean firstSet = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> connection.set(KEY.getBytes(), val.getBytes(), Expiration.milliseconds(expireTime), RedisStringCommands.SetOption.SET_IF_ABSENT));final boolean isConsiderDup;if (firstSet != null && firstSet) {// 第一次存取 isConsiderDup = false; } else {// redis值已存在,認為是重復了 isConsiderDup = true; }

    上面的方案能解決具備唯一請求編號的場景,例如每次寫請求之前都是伺服端返回一個唯一編號給客戶端,客戶端帶著這個請求號做請求,伺服端即可完成去重攔截。

    但是,很多的場景下,請求並不會帶這樣的唯一編號!那麽我們能否針對請求的參數作為一個請求的標識呢?

    先考慮簡單的場景,假設請求參數只有一個欄位reqParam,我們可以利用以下標識去判斷這個請求是否重復。 使用者ID:介面名:請求參數

    String KEY = "dedup:U="+userId + "M=" + method + "P=" + reqParam;

    那麽當同一個使用者存取同一個介面,帶著同樣的reqParam過來,我們就能定位到他是重復的了。

    但是問題是,我們的介面通常不是這麽簡單,以目前的主流,我們的參數通常是一個JSON。那麽針對這種場景,我們怎麽去重呢?

    計算請求參數的摘要作為參數標識

    假設我們把請求參數(JSON)按KEY做升序排序,排序後拼成一個字串,作為KEY值呢?但這可能非常的長,所以我們可以考慮對這個字串求一個MD5作為參數的摘要,以這個摘要去取代reqParam的位置。

    String KEY = "dedup:U="+userId + "M=" + method + "P=" + reqParamMD5;

    這樣,請求的唯一標識就打上了!

    註: MD5理論上可能會重復,但是去重通常是短時間視窗內的去重(例如一秒),一個短時間內同一個使用者同樣的介面能拼出不同的參數導致一樣的MD5幾乎是不可能的。

    繼續最佳化,考慮剔除部份時間因子。

    上面的問題其實已經是一個很不錯的解決方案了,但是實際投入使用的時候可能發現有些問題:某些請求使用者短時間內重復的點選了(例如1000毫秒發送了三次請求),但繞過了上面的去重判斷(不同的KEY值)。

    原因是這些請求參數的欄位裏面,是帶時間欄位的,這個欄位標記使用者請求的時間,伺服端可以借此丟棄掉一些老的請求(例如5秒前)。如下面的例子,請求的其他參數是一樣的,除了請求時間相差了一秒:

    //兩個請求一樣,但是請求時間差一秒 String req = "{\n" +"\"requestTime\" :\"20190101120001\",\n" +"\"requestValue\" :\"1000\",\n" +"\"requestKey\" :\"key\"\n" +"}"; String req2 = "{\n" +"\"requestTime\" :\"20190101120002\",\n" +"\"requestValue\" :\"1000\",\n" +"\"requestKey\" :\"key\"\n" +"}";

    這種請求,我們也很可能需要擋住後面的重復請求。所以求業務參數摘要之前,需要剔除這類時間欄位。還有類似的欄位可能是GPS的經緯度欄位(重復請求間可能有極小的差別)。

    public class ReqDedupHelper {/** * * @param reqJSON 請求的參數,這裏通常是JSON * @param excludeKeys 請求參數裏面要去除哪些欄位再求摘要 * @return 去除參數的MD5摘要 */publicString dedupParamMD5(final String reqJSON, String... excludeKeys) {String decreptParam = reqJSON; TreeMap paramTreeMap = JSON.parseObject(decreptParam, TreeMap. class);if (excludeKeys!=null) { List<String> dedupExcludeKeys = Arrays.asList(excludeKeys);if (!dedupExcludeKeys.isEmpty()) {for (String dedupExcludeKey : dedupExcludeKeys) { paramTreeMap.remove(dedupExcludeKey); } } }String paramTreeMapJSON = JSON.toJSONString(paramTreeMap);String md5deDupParam = jdkMD5(paramTreeMapJSON); log.debug("md5deDupParam = {}, excludeKeys = {} {}", md5deDupParam, Arrays.deepToString(excludeKeys), paramTreeMapJSON);return md5deDupParam; }privatestaticString jdkMD5(String src) {String res = null;try { MessageDigest messageDigest = MessageDigest.getInstance("MD5"); byte[] mdBytes = messageDigest.digest(src.getBytes()); res = DatatypeConverter.printHexBinary(mdBytes); } catch (Exception e) { log.error("",e); }return res; }}

    下面是一些測試日誌:

    publicstaticvoid main(String[] args) {//兩個請求一樣,但是請求時間差一秒String req = "{\n" +"\"requestTime\" :\"20190101120001\",\n" +"\"requestValue\" :\"1000\",\n" +"\"requestKey\" :\"key\"\n" +"}";String req2 = "{\n" +"\"requestTime\" :\"20190101120002\",\n" +"\"requestValue\" :\"1000\",\n" +"\"requestKey\" :\"key\"\n" +"}";//全參數比對,所以兩個參數MD5不同String dedupMD5 = new ReqDedupHelper().dedupParamMD5(req);String dedupMD52 = new ReqDedupHelper().dedupParamMD5(req2); System.out.println("req1MD5 = "+ dedupMD5+" , req2MD5="+dedupMD52);//去除時間參數比對,MD5相同String dedupMD53 = new ReqDedupHelper().dedupParamMD5(req,"requestTime");String dedupMD54 = new ReqDedupHelper().dedupParamMD5(req2,"requestTime"); System.out.println("req1MD5 = "+ dedupMD53+" , req2MD5="+dedupMD54);}

    日誌輸出:

    req1MD5 = 9E054D36439EBDD0604C5E65EB5C8267 , req2MD5=A2D20BAC78551C4CA09BEF97FE468A3Freq1MD5 = C2A36FED15128E9E878583CAAAFEFDE9 , req2MD5=C2A36FED15128E9E878583CAAAFEFDE9

    日誌說明:

  • 一開始兩個參數由於requestTime是不同的,所以求去重參數摘要的時候可以發現兩個值是不一樣的。

  • 第二次呼叫的時候,去除了requestTime再求摘要(第二個參數中傳入了」requestTime」),則發現兩個摘要是一樣的,符合預期。

  • 至此,我們可以得到完整的去重解決方案,如下:

    String userId= "12345678";//使用者String method = "pay";//介面名String dedupMD5 = new ReqDedupHelper().dedupParamMD5(req,"requestTime");//計算請求參數摘要,其中剔除裏面請求時間的幹擾String KEY = "dedup:U=" + userId + "M=" + method + "P=" + dedupMD5;long expireTime = 1000;// 1000毫秒過期,1000ms內的重復請求會認為重復long expireAt = System.currentTimeMillis() + expireTime;String val = "expireAt@" + expireAt;// NOTE:直接SETNX不支持帶過期時間,所以設定+過期不是原子操作,極端情況下可能設定了就不過期了,後面相同請求可能會誤以為需要去重,所以這裏使用底層API,保證SETNX+過期時間是原子操作Boolean firstSet = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> connection.set(KEY.getBytes(), val.getBytes(), Expiration.milliseconds(expireTime), RedisStringCommands.SetOption.SET_IF_ABSENT));final boolean isConsiderDup;if (firstSet != null && firstSet) { isConsiderDup = false;} else { isConsiderDup = true;}

    作者:Jaskey Lam

    https://jaskey.github.io/blog/2020/05/19/handle-duplicate-request/

    >>

    END

    精品資料,超贊福利,免費領

    微信掃碼/長按辨識 添加【技術交流群

    群內每天分享精品學習資料

    最近開發整理了一個用於速刷面試題的小程式;其中收錄了上千道常見面試題及答案(包含基礎並行JVMMySQLRedisSpringSpringMVCSpringBootSpringCloud訊息佇列等多個型別),歡迎您的使用。

    👇👇

    👇點選"閱讀原文",獲取更多資料(持續更新中