最近公司商城訂單出現重複訂單數據問題,比較棘手,一直在找原因,沒有發現問題,太坑了,後來決定在原有的業務基礎上面加上防刷單處理和redis分佈式鎖,雙重保證應用的安全和穩定性。
一、防刷單原理:防止一個方法,在方法參數值相同的情況下,短時間頻繁調用,這裏根據spring中的AOP原理來實現的,自己定義了一個註解,這個註解主要用來判斷哪些方法上面加了這個註解,就做參數請求處理,先配置具體的aop切面路徑掃描類中的方法,處理是根據這個請求的路徑獲取相應的方法中的參數做具體分析。
實現的步驟:
定義一個註解(主要用來判斷哪些方法要做防重複提交處理)
- 通過spring中的AOP進行掃描,方法處理。
- 設置一個過期時間來處理redis分佈式鎖處理(這裏會在redis分佈式鎖中實現)
/*********定義防重複請求方法註解*********/
package com.lolaage.common.annotations;
import java.lang.annotation.*;
/**
* 定義一個註解(主要用來判斷哪些方法要做防重複提交處理)
* @Description 防止同一個方法被頻繁執行(是否需要頻繁執行看參數params是否不一樣)
* @Date 19:35 2019/4/9
* @Param
* @return
**/
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SameMethodFrequentlyRun {
/**
* @Description 當方法的參數是實體對象,對象必須對象重寫equal和hashcode方法
**/
String params() default "";
String description() default "";
/**
* @Description
**/
long milliseconds() default 30000L;
}
/*************下面是具體的方法處理請求參數過程***************/
package com.lolaage.common.aop;
import com.lolaage.base.po.JsonModel;
import com.lolaage.common.annotations.SameMethodFrequentlyRun;
import com.lolaage.helper.util.RedisLockTemplate;
import com.lolaage.util.StringUtil;
import org.apache.log4j.Logger;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
/**
* @Description 防止同一個方法被頻繁執行AOP(是否需要頻繁執行看參數params是否不一樣)
**/
@Aspect
@Component
public class SameMethodFrequentlyRunAop {
private static Logger logger = Logger.getLogger(SameMethodFrequentlyRunAop.class);
// 配置接入點,即爲所要記錄的action操作目錄
@Pointcut("execution(* com.lolaage.helper.web.controller..*.*(..))")
private void controllerAspect() {
}
@Around("controllerAspect()")
public Object around(ProceedingJoinPoint pjp) {
Object returnObj=null;
StringBuilder sb=new StringBuilder();
// 攔截的實體類,就是當前正在執行的controller
Object target = pjp.getTarget();
//獲取全類名
String className=target.getClass().getName();
// 攔截的方法名稱。當前正在執行的方法
String methodName = pjp.getSignature().getName();
// 攔截的方法參數
Object[] args = pjp.getArgs();
// 攔截的放參數類型
Signature sig = pjp.getSignature();
MethodSignature msig = (MethodSignature) sig ;
Class[] parameterTypes = msig.getMethod().getParameterTypes();
sb.append(className);
for (Object o : args) {
if(o==null){
continue;
}
int i = o.hashCode();
sb.append(":");
sb.append(i);
}
// 獲得被攔截的方法
Method method = null;
try {
method = target.getClass().getMethod(methodName, parameterTypes);
SameMethodFrequentlyRun sameMethodFrequentlyRun = method.getAnnotation(SameMethodFrequentlyRun.class);
if (sameMethodFrequentlyRun != null) {
String description = sameMethodFrequentlyRun.description();
String params = sameMethodFrequentlyRun.params();
if(StringUtil.isEmpty(params)){
params=sb.toString();
}
long milliseconds = sameMethodFrequentlyRun.milliseconds();
Boolean isGetLock = RedisLockTemplate.distributedLock_v2(params, description, milliseconds, false);
if(!isGetLock){
//提示不要重複操作
JsonModel result = new JsonModel();
return result.setErrCode(5004);
}
}
} catch (NoSuchMethodException e) {
logger.error("分佈式防重複操作異常:AOP只會攔截public方法,非public會報異常,如果你要將你的方法加入到aop攔截中,請修改方法的修飾符:"+e.getMessage());
}
try {
returnObj = pjp.proceed();
} catch (Throwable e) {
logger.error("分佈式防重複操作異常Throwable:"+e.getMessage());
e.printStackTrace();
}
return returnObj;
}
}
/**
* 分佈式鎖壓力測試,和防重複測試
* @return
*/
@SameMethodFrequentlyRun(description="查詢操作日誌",milliseconds = 10000L)
@RequestMapping("/pressureLock")
public void pressureLock(String key,QuitParam quitParam) {
System.out.println(this.hashCode()+"---"+Thread.currentThread().getName()+":測試開始");
System.out.println(this.hashCode()+"---"+Thread.currentThread().getName()+"測試結束");
}
二、redis分佈式對象鎖的原理:
解釋: 針對某種資源,需要被整個系統的各臺服務器共享訪問,但是隻允許一臺服務器同時訪問。比如說訂單服務是做成集羣的,當兩個以上結點同時收到一個相同訂單的創建指令,這時併發就產生了,系統就會重複創建訂單。而分佈式共享鎖就是解決這類問題
原理:對高併發請求的時候,我們使用redis分佈式共享鎖來處理,通過set方法設置對應的key-value和milliseconds過期時間,在規定的時間內保證鎖可以釋放出來,通過eval來解鎖。
實現代碼:
/**
* @Description 分佈式鎖模板
* @Date 10:39 2019/4/9
* @Param [key, actionLog, expireSecond]
* @return java.lang.Boolean
**/
public static Boolean distributedLock_v2(String key,String actionLog, long milliseconds,boolean isDelLock){
RedisBaseDao redisDao = RedisUtil.getRedisDao();
boolean isGetLock=false;
String requestId = UUID.randomUUID().toString();
try {
isGetLock = redisDao.getDistributedLock(key,requestId , milliseconds);
if(!isGetLock){
logger.error("分佈式鎖攔截,不能重複操作,"+key+",actionLog="+actionLog);
}
return isGetLock;
} catch (Exception e) {
e.printStackTrace();
if(e instanceof RedisException){
logger.error("redis 分佈式鎖異常,可能存在重複操作的的可能性,key="+key+",actionLog="+actionLog+",e="+e);
return true;
}
}finally {
if(isGetLock&&isDelLock){
try {
redisDao.releaseDistributedLock(key,requestId);
} catch (Exception e) {
e.printStackTrace();
logger.error("分佈式鎖釋放鎖失敗,key="+key+",actionLog="+actionLog+","+e);
}
}
}
return false;
}
/**
* 嘗試獲取分佈式鎖
* @param lockKey 鎖
* @param requestId 請求標識
* @param milliseconds 超期時間
* @return 是否獲取成功
*/
private static final Long RELEASE_SUCCESS = 1L;
public boolean getDistributedLock(String lockKey, String requestId, Long milliseconds) {
return this.setNx(lockKey, requestId, milliseconds);
}
/**
* 釋放分佈式鎖
* @param lockKey 鎖
* @param requestId 請求標識
* @return 是否釋放成功
*/
public boolean releaseDistributedLock( String lockKey, String requestId) {
return this.deleteKeyForSameValue(lockKey,requestId);
}
public Boolean setNx( String key, String value,Long expireTime) {
Boolean isSet = redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
//過期時間好處:即使服務器宕機了,也能保證鎖被正確釋放。
//setNx原子性操作,防止同一把鎖在同一時間可能被不同線程獲取到
Jedis jedis = (Jedis) redisConnection.getNativeConnection();
String result = jedis.set(key, value, "nx", "px", expireTime);
if("OK".equals(result)){
return true;
}
return false;
}
});
return isSet;
}
public Boolean deleteKeyForSameValue( String key, String value) {
return redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
Jedis jedis = (Jedis) redisConnection.getNativeConnection();
//刪除key的時候,先判斷該key對應的value是否等於先前設置的隨機值,只有當兩者相等的時候才刪除該key
//防止釋放其他客戶端獲取到的鎖
//原子性操作
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return
redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(key), Collections.singletonList(value));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
});
}
## 方案優點
> 多個服務器競爭資源,需要排隊,解決類似一個訂單被多個服務器提交問題。
## 方案缺點
- 試用與一主多從的redis集羣,如果多主多從,不能解決共享鎖問題
-這個問題解決方案[https://yq.aliyun.com/articles/674394](https://yq.aliyun.com/articles/674394),[https://blog.csdn.net/chen_kkw/article/details/81433470](https://blog.csdn.net/chen_kkw/article/details/81433470)
- 同時當一主多從服務器,主機宕機,有丟失鎖的風險,概率很小。
- **場景**
- 在Redis的master節點上拿到了鎖,但是這個加鎖的key還沒有同步到slave節點,master故障,發生故障轉移,slave節點升級爲master節點; 導致鎖丟失。概率很小,可以不考慮。