SpringBoot AOP+redis分布式鎖實現防止用戶重復發起請求


前言:分布式鎖一般有三種實現方式:1. 數據庫樂觀鎖;2. 基於Redis的分布式鎖;3. 基於ZooKeeper的分布式鎖。本文介紹的是第二種。

可靠性: 首先,為了確保分布式鎖可用,我們至少要確保鎖的實現同時滿足以下四個條件: 互斥性。在任意時刻,只有一個客戶端能持有鎖。 不會發生死鎖。即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證后續其他客戶端能加鎖。 具有容錯性。只要大部分的Redis節點正常運行,客戶端就可以加鎖和解鎖。 解鈴還須系鈴人。加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。

 

實現思路:
1.自定義注解@NoRepeatSubmit 標記所有Controller中提交的請求
2.通過AOP對所有標記了@NoRepeatSubmit 的方法進行攔截
3.在業務方法執行前,獲取當前用戶的token或者JsessionId+當前請求地址,作為一個唯一的key,去獲取redis分布式鎖,如果此時並發獲取,只有一個線程能獲取到。
4.業務執行后,釋放鎖。

 

Pom引入依賴:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <!-- 因為springboot2.0中默認是使用 Lettuce來集成Redis服務,spring-boot-starter-data-redis默認只引入了 Lettuce包,並沒有引入 jedis包支持。所以在我們需要手動引入jedis的包,並排除掉lettuce的包,
         如果不排除可能會出現錯誤:io.lettuce.core.RedisAsyncCommandsImpl cannot be cast to redis.clients.jedis.Jedis -->
    <exclusions>
        <exclusion>
            <groupId>io.lettuce</groupId>
            <artifactId>lettuce-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<!-- 依賴 Springboot aop -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>


自定義注解:

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 防止訂單重復提交
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {

    // 設置請求的鎖定時間
    int lockTime() default 5;
}

 

Redis分布式鎖:

private  final Long RELEASE_SUCCESS = 1L;
private  final String LOCK_SUCCESS = "OK";
private  final String SET_IF_NOT_EXIST = "NX";
// 當前設置 過期時間單位, EX秒,PX毫秒
private  final String SET_WITH_EXPIRE_TIME = "EX";
private  final String RELEASE_LOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
/** *加鎖 * @param lockKey 加鎖鍵 * @param clientId 加鎖客戶端唯一標識(采用UUID) * @param expireTime 鎖過期時間 * @return */ public boolean tryLock(String lockKey, String clientId, long expireTime) { return (boolean) redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> { Jedis jedis = (Jedis) redisConnection.getNativeConnection(); String result = jedis.set(lockKey, clientId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime); if (LOCK_SUCCESS.equals(result)) { return true; } return false; }); }
 
        

我們加鎖就一行代碼:jedis.set(String key, String value, String nxxx, String expx, int time),這個set()方法一共有五個形參:

  • 第一個為key,我們使用key來當鎖,因為key是唯一的。

  • 第二個為value,我們傳的是requestId,很多童鞋可能不明白,有key作為鎖不就夠了嗎,為什么還要用到value?原因就是我們在上面講到可靠性時,分布式鎖要滿足第四個條件解鈴還須系鈴人,通過給value賦值為requestId,我們就知道這把鎖是哪個請求加的了,在解鎖的時候就可以有依據。requestId可以使用UUID.randomUUID().toString()方法生成。

  • 第三個為nxxx,這個參數我們填的是NX,意思是SET IF NOT EXIST,即當key不存在時,我們進行set操作;若key已經存在,則不做任何操作;

  • 第四個為expx,這個參數我們傳的是PX,意思是我們要給這個key加一個過期的設置,具體時間由第五個參數決定。

  • 第五個為time,與第四個參數相呼應,代表key的過期時間。

總的來說,執行上面的set()方法就只會導致兩種結果:1. 當前沒有鎖(key不存在),那么就進行加鎖操作,並對鎖設置個有效期,同時value表示加鎖的客戶端。2. 已有鎖存在,不做任何操作。

我們的加鎖代碼滿足我們可靠性里描述的三個條件。首先,set()加入了NX參數,可以保證如果已有key存在,則函數不會調用成功,也就是只有一個客戶端能持有鎖,滿足互斥性。其次,由於我們對鎖設置了過期時間,即使鎖的持有者后續發生崩潰而沒有解鎖,鎖也會因為到了過期時間而自動解鎖(即key被刪除),不會發生死鎖。最后,因為我們將value賦值為requestId,代表加鎖的客戶端請求標識,那么在客戶端在解鎖的時候就可以進行校驗是否是同一個客戶端。由於我們只考慮Redis單機部署的場景,所以容錯性我們暫不考慮。



/**
 * 解鎖
 * @param lockKey
 * @param clientId
 * @return
 */
public boolean unLock(String lockKey, String clientId) {
    return (boolean) redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> {
        Jedis jedis = (Jedis) redisConnection.getNativeConnection();
        Object result = jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey),
                Collections.singletonList(clientId));
        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    });
}

 



我們解鎖只需要兩行代碼就搞定了!第一行代碼,我們寫了一個簡單的Lua腳本代碼,上一次見到這個編程語言還是在《黑客與畫家》里,沒想到這次居然用上了。第二行代碼,我們將Lua代碼傳到jedis.eval()方法里,並使參數KEYS[1]賦值為lockKey,ARGV[1]賦值為requestId。eval()方法是將Lua代碼交給Redis服務端執行。

那么這段Lua代碼的功能是什么呢?其實很簡單,首先獲取鎖對應的value值,檢查是否與requestId相等,如果相等則刪除鎖(解鎖)。那么為什么要使用Lua語言來實現呢?因為要確保上述操作是原子性的。關於非原子性會帶來什么問題,可以閱讀【解鎖代碼-錯誤示例2】 。那么為什么執行eval()方法可以確保原子性,源於Redis的特性,下面是官網對eval命令的部分解釋:

簡單來說,就是在eval命令執行Lua代碼的時候,Lua代碼將被當成一個命令去執行,並且直到eval命令執行完成,Redis才會執行其他命令。

 

AOP:

import com.zy.core.base.BoxOut;
import com.zy.core.model.BDic;
import com.zy.zystoreservice.annotation.NoRepeatSubmit;
import com.zy.zystoreservice.service.redis.RedisService;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.UUID;

/**
 * 防止訂單重復提交
 */
@Slf4j
@Aspect
@Component
public class RepeatSubmitAspect {

    @Autowired
    RedisService redisService;

    @Pointcut("@annotation(noRepeatSubmit)")
    public void pointcut(NoRepeatSubmit noRepeatSubmit) {}

    @Around("pointcut(noRepeatSubmit)")
    public Object around(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) throws Throwable {
        int lockSeconds = noRepeatSubmit.lockTime();
        ServletRequestAttributes ra= (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = ra.getRequest();
        Assert.notNull(request, "request can not null");
        //此處可以用token或者JSessionId
   //String jsessionid=request.getSession().getId();
   String token = request.getHeader("Authorization");
        String path = request.getServletPath();
        String key = getKey(token, path);
        String clientId = getClientId();
        boolean isSuccess = redisService.tryLock(key, clientId, lockSeconds);
        log.info("tryLock key = [{}], clientId = [{}]", key, clientId);
        if (isSuccess) {
            // 獲取鎖成功
            Object result;
            try {
                // 執行進程
                result = pjp.proceed();
            } finally {
                // 解鎖
                redisService.unLock(key, clientId);
                log.info("unLock success, key = [{}], clientId = [{}]", key, clientId);
            }
            return result;

        } else {
            // 獲取鎖失敗,認為是重復提交的請求,返回
            log.info("tryLock fail, key = [{}]", key);
            return BoxOut.build(BDic.FAIL,"重復請求,請稍后再試");
        }
    }

    private String getKey(String token, String path) {
        return token + path;
    }

    private String getClientId() {
        return UUID.randomUUID().toString();
    }

}

 

controller 調用:

/**
 * 需要防止重復請求的接口
 */
@NoRepeatSubmit
@PostMapping(value = "/userPayTemplateOrderByCombo")
public BaseOut userPayTemplateOrderByCombo(@RequestBody ContractTemplatePayIn templatePayIn){}

 

 

 
       


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM