前言部分用户请求在某些情况下可能会重复发送。如果是查询操作是没有问题的,但是其中有一些涉及到写操作。一旦反复,可能会造成严重的后果。例如,如果交易接口重复请求,则可能重复下单。重复的场景可能是:黑客拦截请求并重放;前端/客户端由于某种原因重复发送请求,或者用户在短时间内重复点击;网关重新发送;...本文讨论如何在服务器端优雅统一地处理这种情况,以及如何禁止用户重复点击等客户端操作,不在本文讨论范围之内。使用唯一的请求号去重你可能会认为只要请求有唯一的请求号,就可以用Redis来做去重。只要Redis中存在这个唯一的请求号,并且证明已经处理过,就认为是重复的。代码基本如下:StringKEY="REQ12343456788";//请求一个唯一数longexpireTime=1000;//1000毫秒过期,1000ms内重复请求会被认为是重复longexpireAt=System.currentTimeMillis()+expireTime;Stringval="expireAt@"+expireAt;//如果rediskey还存在,则考虑重复请求BooleanfirstSet=stringRedisTemplate.execute((RedisCallback)connection->connection.set(KEY.getBytes(),val.getBytes(),Expiration.milliseconds(expireTime),RedisStringCommands.SetOption.SET_IF_ABSENT));finalbooleanisConsiderDup;if(firstSet!=null&&firstSet){//第一次访问isConsiderDup=false;}else{//redis值已经存在,认为是一个重复isConsiderDup=true;业务参数去重上述方案可以解决请求编号唯一的场景。例如,在写入每个请求之前,服务器会向客户端返回一个唯一的编号。客户端用这个请求号发起请求,服务端就可以完成去重拦截。但是在很多场景下,请求中并没有携带这样的唯一编号!那么我们是否可以将请求参数识别为请求呢?先考虑一个简单的场景,假设请求参数只有一个字段reqParam,我们可以通过下面的flags来判断请求是否重复。用户ID:接口名称:请求参数StringKEY="dedup:U="+userId+"M="+method+"P="+reqParam;那么当同一个用户以同一个reqParam访问同一个接口时,我们就能定位到他是重复的。但问题是我们的界面通常不是那么简单。在目前的主流中,我们的参数通常是一个JSON。那么对于这种场景,我们该如何淡化呢?1、计算请求参数的抽象作为参数标识假设我们将请求参数(JSON)按KEY升序排列,排序后放入一个字符串中作为KEY值?但是这个可能会很长,所以我们可以考虑要一个这个字符串的MD5摘要作为参数,用这个摘要来代替reqParam的位置。StringKEY="dedup:U="+userId+"M="+method+"P="+reqParamMD5;这样就标记了请求的唯一标识!注意:MD5理论上可以重复,但去重通常是在很短的时间窗口(比如一秒)内去重。同一用户的同一界面,短时间内拼出不同的参数,导致MD5相同,几乎是不可能的。可能的。2.继续优化,考虑消除上面一些时间因素的问题其实是一个很好的解决方案,但是实际投入使用的时候可能会发现一些问题:一些请求用户在短时间内重复点击(比如1000毫秒发送了三个请求),但是绕过了上面的去重判断(不同的KEY值)。原因是这些请求参数的字段有时间字段。这个字段标记了用户请求的时间,服务器可以利用这个来丢弃一些旧的请求(比如5秒前)。如下例,请求的其他参数相同,只是请求时间相差一秒://两次请求相同,但请求时间相差一秒Stringreq="{\n"+"\"requestTime\":\"20190101120001\",\n"+"\"requestValue\":\"1000\",\n"+"\"requestKey\":\"key\"\n"+"}";Stringreq2="{\n"+"\"requestTime\":\"20190101120002\",\n"+"\"requestValue\":\"1000\",\n"+"\"requestKey\":\"key\"\n"+"}";对于这种请求,我们也很可能需要屏蔽后续的重复请求。因此,在获取业务参数摘要之前,需要去除此类时间字段。类似的字段可能是GPS纬度和经度字段(重复请求之间可能略有不同)。请求去重工具类代码登陆publicclassReqDedupHelper{/****@paramreqJSON请求参数,一般是JSON*@paramexcludeKeysRequest参数去掉哪些字段然后求一个摘要*@return去掉参数的MD5摘要*/publicStringdedupParamMD5(finalStringreqJSON,String...excludeKeys){StringdecreptParam=reqJSON;TreeMapparamTreeMap=JSON.parseObject(decreptParam,TreeMap.class);if(excludeKeys!=null){ListdedupExcludeKeys=Arrays.asList(excludeKeys);如果(!dedupExcludeKeys.isEmpty()){for(StringdedupExcludeKey:dedupExcludeKeys){paramTreeMap.remove(dedupExcludeKey);}}}StringparamTreeMapJSON=JSON.toJSONString(paramTreeMap);Stringmd5deDupParam=jdkMD5(paramTreeMapJSONParam"log.debug({},excludeKeys={}{}",md5deDupParam,Arrays.deepToString(excludeKeys),paramTreeMapJSON);returnmd5deDupParam;}privatestaticStringjdkMD5(Stringsrc){Stringres=null;try{MessageDigestmessageDigest=MessageDigest.getInstance("MD5");字节[]mdBytes=我sageDigest.digest(src.getBytes());res=DatatypeConverter.printHexBinary(mdBytes);}catch(Exceptione){log.error("",e);}returnres;}}下面是一些测试日志:publicstaticvoidmain(String[]args){//两次请求相同,只是请求时间相差一秒Stringreq="{\n"+"\"requestTime\":\"20190101120001\",\n"+"\"requestValue\":\"1000\",\n"+"\"requestKey\":\"key\"\n"+"}";Stringreq2="{\n"+"\"requestTime\":\"20190101120002\",\n"+"\"requestValue\":\"1000\",\n"+"\"requestKey\":\"key\"\n"+"}";//比较所有参数,所以两个参数的MD5不一样StringdedupMD5=newReqDedupHelper().dedupParamMD5(req);StringdedupMD52=newReqDedupHelper().dedupParamMD5(req2);System.out.println("req1MD5="+dedupMD5+",req2MD5="+dedupMD52);//去掉时间参数比较,MD5相同StringdedupMD53=newReqDedupHelper().dedupParamMD5(req,"requestTime");StringdedupMD54=newReqDeduHelper().dedupParamMD5(req2,"requestTime");系统.out.println("req1MD5="+dedupMD53+",req2MD5="+dedupMD54);}日志输出:req1MD5=9E054D36439EBDD0604C5E65EB5C8267,req2MD5=A2D20BAC78551C4CA09BEF97FE468A3Freq1MD5=C2A36FED15128E9E878583CAAAFEFDE9,req2MD5=C2A36FED15128E9E878583CAAAFEFDE9日志说明:一开始两个参数由于requestTime是不同的,soyoucanfindthatthetwovalues??aredifferentwhenlookingforthededuplicationparametersummary;whencallingforthesecondtime,removetherequestTimeandthenaskforthesummary(passedin"requestTime"inthesecondparameter),youwillfindtwoThesummaryisthesame,asexpected综上所述,我们可以得到一个完整的去重方案,如下:StringuserId="12345678";//UserStringmethod="pay";//接口名StringdedupMD5=newReqDedupHelper().dedupParamMD5(req,"requestTime");//计算请求参数的汇总,排除了StringKEY="dedup:U="+userId+"M="+method+"P="+dedupMD5;longexpireTime=1000;//1000msexpires里面请求时间的干扰,repeatswithin1000ms请求会考虑重复longexpireAt=System.currentTimeMillis()+expireTime;Stringval="expireAt@"+expireAt;//注意:直接SETNX不支持过期时间,所以设置+过期不是原子操作,并且极端情况下可能会设置,但是后面相同的请求可能会被误认为是去重,所以这里使用底层API来保证SETNX+过期时间是一个原子操作。BooleanfirstSet=stringRedisTemplate.execute((RedisCallback)connection->connection.set(KEY.getBytes(),val.getBytes(),Expiration.milliseconds(expireTime),RedisStringCommands.SetOption.SET_IF_ABSENT));finalbooleanisConsiderDup;if(firstSet!=null&&firstSet){isConsiderDup=false;}else{isConsiderDup=true;}庞桂玉电话:(010)68476606]