透過滑動視窗實現介面呼叫的多種限制策略

scottyzh發表於2024-05-28

前言

有個郵箱傳送的限制傳送次數需求,為了防止使用者惡意請求傳送郵件的介面,對使用者的傳送郵件次數進行限制,每個郵箱60s內只能接收一次郵件,每個小時只能接收五次郵件,24小時只能接收十次郵件,一共有三個條件的限制。

實現方案

單機方案

單機簡單實現可以用Caffeine,在Caffeine裡面Key為mail的標識,value是個存這個mail每次接收郵件的時間戳List,資料結構如下圖所示:

image-20240527165212319

  1. list小於5個:每一次有新元素入隊,都要判斷佇列裡最新的時間戳和當前時間戳是否超過60s,不超過返回60s限制。
  2. 大於等於5個,小於10,則當前佇列size-5,即往前數第五個值,取對應的value時間戳,判斷和當前時間超不超1h,超過就放入list,不超就返回超過一小時的限制。
  3. 如果數量等於10個,得先判斷24小時超不超10個,拿List裡面的第一個值,判斷和當前的時間戳是否超過24小時,不超則返回24小時限制,超再判斷1小數超不超,判斷邏輯往前數五個,如果超過,則把第一個值剔除(即最老的那個元素),加入新的元素。

透過上面的資料結構,其實也能把剩餘多少時間接觸限制一併返回到前端,在達到限制的時候,對比時間戳時間的差距即可。

caffeine單機方案程式碼

 public boolean isMailCanSend(String mail){
        // 先判斷快取是否存在 不存在 則建立
        ArrayList<Long> mailTimeStampList = caffeineTemplate.getMailTimeStampFromCache(CacheKeyConstant.TIME_STAMP_LIST_FOR_MAIL + mail);
        if (mailTimeStampList == null) {
            ArrayList<Long> timeList = new ArrayList<>();
            timeList.add(System.currentTimeMillis());
            caffeineTemplate.addToMailTimeStampCache(CacheKeyConstant.TIME_STAMP_LIST_FOR_MAIL + mail, timeList);
            return true;
        } else {
            // 快取存在
            // 存在先查60s
            Long timeStamp = mailTimeStampList.get(mailTimeStampList.size() - 1);
            // 判斷與當前時間相差是否超過60s
            if (System.currentTimeMillis() - timeStamp > 60000) {
                // 再查數量是否小於5,滿足直接加入快取
                if (mailTimeStampList.size() < 5) {
                    mailTimeStampList.add(System.currentTimeMillis());
                    caffeineTemplate.addToMailTimeStampCache(CacheKeyConstant.TIME_STAMP_LIST_FOR_MAIL + mail, mailTimeStampList);
                    return true;
                } else {
                    // 大於等於5數量小於10
                    if (mailTimeStampList.size() < 10) {
                        // 則判斷前面第五個是否滿足一個小時
                        if (System.currentTimeMillis() - mailTimeStampList.get(mailTimeStampList.size() - 5) < 3600000) {
                            // 不滿足大於一個小時 則不可傳送
                            throw new EmailException(ResultCodeEnum.MAIL_ONE_HOUR_REQUEST_FREQUENT_ERROR, 3600000L - (System.currentTimeMillis() - mailTimeStampList.get(mailTimeStampList.size() - 5)));
                        } else {
                            mailTimeStampList.add(System.currentTimeMillis());
                            caffeineTemplate.addToMailTimeStampCache(CacheKeyConstant.TIME_STAMP_LIST_FOR_MAIL + mail, mailTimeStampList);
                            return true;
                        }
                    } else {
                        // 數量為 10的時候
                        // 等於10 判斷大於24小時是否滿足
                        if (System.currentTimeMillis() - mailTimeStampList.get(0) > 86400000) {
                            // 則判斷前面第五個是否滿足一個小時
                            if (System.currentTimeMillis() - mailTimeStampList.get(mailTimeStampList.size() - 5) < 3600000) {
                                // 不滿足一個小時 則不可傳送
                                throw new EmailException(ResultCodeEnum.MAIL_ONE_HOUR_REQUEST_FREQUENT_ERROR, 3600000L - (System.currentTimeMillis() - mailTimeStampList.get(mailTimeStampList.size() - 5)));
                            } else {
                                // 移除第一個
                                mailTimeStampList.remove(0);
                                mailTimeStampList.add(System.currentTimeMillis());
                                caffeineTemplate.addToMailTimeStampCache(CacheKeyConstant.TIME_STAMP_LIST_FOR_MAIL + mail, mailTimeStampList);
                                return true;
                            }
                        } else {
                            throw new EmailException(ResultCodeEnum.MAIL_24_HOUR_REQUEST_FREQUENT_ERROR, 86400000L - (System.currentTimeMillis() - mailTimeStampList.get(0)));
                        }
                    }
                }
            } else {
                throw new EmailException(ResultCodeEnum.MAIL_ONE_MIN_REQUEST_FREQUENT_ERROR, 60000L - (System.currentTimeMillis() - timeStamp));
            }


        }

分散式方案

分散式方案可以使用redis的zset資料結構來實現,同樣是維護一個set,score存放的是時間戳,視窗元素都是24小時以內。

  1. 每次有新請求,先將時間戳位於視窗外的元素清除掉。
  2. set大小大於等於10,不放行,返回超過24小時限制。
  3. 判斷set排名最大的元素的時間戳和當前時間戳是否超過60s,超過則放行,不超過返回60s限制。
  4. 判斷set大小是否小於5,小於5則放行,並放入新元素。
  5. set大小小於10,大於等於5,取當前的set排名往前數5,即ZRANGE key size-5 size-5,拿出排行倒數第五的元素,判斷是否超過一個小時,超過一個小時則可以放行,不超過返回1小時限制。

上述的執行應該以原子形式進行,防止出現不準確情況,這裡採用lua指令碼

lua指令碼

local key = KEYS[1]
local limit1 = tonumber(ARGV[1])
local limit2 = tonumber(ARGV[2])
local windowStart = tonumber(ARGV[3])
local currentTime = tonumber(ARGV[4])

-- 清除視窗外的元素
redis.call('zremrangebyscore', key, 0 , windowStart)

-- 獲取當前集合大小
local currentSize = tonumber(redis.call('zcard', key))

if currentSize >= limit2 then
    -- 集合大小大於等於 limit2,不放行,返回超過24小時限制
    return 0
end

-- 判斷集合中最大元素與當前時間間隔是否超過60秒
local oldestTimestamp = tonumber(redis.call('zrange', key, -1, -1, 'WITHSCORES')[2])
if (currentTime - oldestTimestamp) < 60000 then
    -- 未超過60秒限制,返回60秒限制
    return 0
end

if currentSize < limit1 then
    -- 集合大小小於 limit1,放行請求並新增新元素
    redis.call('zadd', key, currentTime, currentTime)
    return 1
else
    -- 集合大小小於 limit2 且大於等於 limit1,判斷是否超過1小時限制
    local hourAgoTimestamp = currentTime - 3600000          
    local fifthTimestamp = tonumber(redis.call('zrange', key, currentSize - limit1, currentSize - limit1, 'WITHSCORES')[2])
    if fifthTimestamp < hourAgoTimestamp then
        -- 未超過1小時限制,放行請求並新增新元素
        redis.call('zadd', key, currentTime, currentTime)
        return 1
    else
        -- 已超過1小時限制,返回1小時限制
        return 0
    end
end

java程式碼

import org.redisson.Redisson;
import org.redisson.api.RScript;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class RedisLuaScriptExample {

    public static void main(String[] args) {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        RedissonClient redisson = Redisson.create(config);

        // Lua 指令碼
        String luaScript =
            "local key = KEYS[1] " +
            "local limit1 = tonumber(ARGV[1]) " +
            "local limit2 = tonumber(ARGV[2]) " +
            "local windowStart = tonumber(ARGV[3]) " +
            "local currentTime = tonumber(ARGV[4]) " +
            "redis.call('zremrangebyscore', key, '-inf', windowStart) " +
            "local currentSize = tonumber(redis.call('zcard', key)) " +
            "if currentSize >= limit2 then " +
            "  return 0 " +
            "end " +
            "local oldestTimestamp = tonumber(redis.call('zrange', key, -1, -1, 'WITHSCORES')[2]) " +
            "if (currentTime - oldestTimestamp) < 60000 then " +
            "  return 0 " +
            "end " +
            "if currentSize < limit1 then " +
            "  redis.call('zadd', key, currentTime, currentTime) " +
            "  return 1 " +
            "else " +
            "  local hourAgoTimestamp = currentTime - 3600000 " +
            "  local fifthTimestamp = tonumber(redis.call('zrange', key, currentSize - limit1 , currentSize - limit1, 'WITHSCORES')[2]) " +
            "  if fifthTimestamp < hourAgoTimestamp then " +
            "    redis.call('zadd', key, currentTime, currentTime) " +
            "    return 1 " +
            "  else " +
            "    return 0 " +
            "  end " +
            "end";

        RScript script = redisson.getScript();
        // 執行 Lua 指令碼
        Long result = script.eval(RScript.Mode.READ_WRITE, luaScript, RScript.ReturnType.INTEGER,
                                  "your_key", // 這裡替換成你的鍵
                                  "5",        // 替換成 limit1 的值
                                  "10",       // 替換成 limit2 的值
                                  String.valueOf(System.currentTimeMillis() - 86400000), // 24小時前的時間戳
                                  String.valueOf(System.currentTimeMillis()));
        System.out.println("Result: " + result);

        redisson.shutdown();
    }
}

相關文章