前言
有個郵箱傳送的限制傳送次數需求,為了防止使用者惡意請求傳送郵件的介面,對使用者的傳送郵件次數進行限制,每個郵箱60s內只能接收一次郵件,每個小時只能接收五次郵件,24小時只能接收十次郵件,一共有三個條件的限制。
實現方案
單機方案
單機簡單實現可以用Caffeine,在Caffeine裡面Key為mail的標識,value是個存這個mail每次接收郵件的時間戳List,資料結構如下圖所示:
- list小於5個:每一次有新元素入隊,都要判斷佇列裡最新的時間戳和當前時間戳是否超過60s,不超過返回60s限制。
- 大於等於5個,小於10,則當前佇列size-5,即往前數第五個值,取對應的value時間戳,判斷和當前時間超不超1h,超過就放入list,不超就返回超過一小時的限制。
- 如果數量等於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小時以內。
- 每次有新請求,先將時間戳位於視窗外的元素清除掉。
- set大小大於等於10,不放行,返回超過24小時限制。
- 判斷set排名最大的元素的時間戳和當前時間戳是否超過60s,超過則放行,不超過返回60s限制。
- 判斷set大小是否小於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();
}
}