Spring Boot實現數據訪問計數器

1、數據訪問計數器

  在Spring Boot項目中,有時需要數據訪問計數器。大致有下列三種情形:

1)純計數:如登錄的密碼錯誤計數,超過門限N次,則表示計數器滿,此時可進行下一步處理,如鎖定該賬戶。

2)時間滑動窗口:設窗口寬度為T,如果窗口中尾幀時間與首幀時間差大於T,則表示計數器滿。

  例如使用redis快取時,使用key查詢redis中數據,如果有此key數據,則返回對象數據;如無此key數據,則查詢資料庫,但如果一直都無此key數據,從而反覆查詢資料庫,顯然有問題。此時,可使用時間滑動窗口,對於查詢的失敗的key,距離首幀T時間(如1分鐘)內,不再查詢資料庫,而是直接返回無此數據,直到新查詢的時間超過T,更新滑窗首幀為新時間,並執行一次查詢資料庫操作。

3)時間滑動窗口+計數:這往往在需要進行限流處理的場景使用。如T時間(如1分鐘)內,相同key的訪問次數超過超過門限N,則表示計數器滿,此時進行限流處理。

2、程式碼實現

2.1、方案說明

1)使用字典來管理不同的key,因為不同的key需要單獨計數。

2)上述三種情況,使用類型屬性區分,並在構造函數中進行設置。

3)滑動窗口使用雙向隊列Deque來實現。

4)考慮到訪問並發性,讀取或更新時,加鎖保護。

2.2、程式碼

package com.abc.example.service;

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.Map;


/**
 * @className	: DacService
 * @description	: 數據訪問計數服務類
 * @summary		:
 * @history		:
 * ------------------------------------------------------------------------------
 * date			version		modifier		remarks                   
 * ------------------------------------------------------------------------------
 * 2021/08/03	1.0.0		sheng.zheng		初版
 *
 */
public class DacService {
	
	// 計數器類型:1-數量;2-時間窗口;3-時間窗口+數量
	private int counterType; 
	
	// 計數器數量門限
	private int counterThreshold = 5;
	
	// 時間窗口長度,單位毫秒
	private int windowSize = 60000;
	
	// 對象key的訪問計數器
	private Map<String,Integer> itemMap;

	// 對象key的訪問滑動窗口
	private Map<String,Deque<Long>> itemSlideWindowMap;
	
	/**
	 * 構造函數
	 * @param counterType		: 計數器類型,值為1,2,3之一
	 * @param counterThreshold	: 計數器數量門限,如果類型為1或3,需要此值
	 * @param windowSize		: 窗口時間長度,如果為類型為2,3,需要此值
	 */
	public DacService(int counterType, int counterThreshold, int windowSize) {
		this.counterType = counterType;
		this.counterThreshold = counterThreshold;
		this.windowSize = windowSize;
		
		if (counterType == 1) {
			// 如果與計數器有關
			itemMap = new HashMap<String,Integer>();
		}else if (counterType == 2 || counterType == 3) {
			// 如果與滑動窗口有關
			itemSlideWindowMap = new HashMap<String,Deque<Long>>();
		}
	}		
		
	/**
	 * 
	 * @methodName		: isItemKeyFull
	 * @description		: 對象key的計數是否將滿
	 * @param itemKey	: 對象key
	 * @param timeMillis: 時間戳,毫秒數,如為滑窗類計數器,使用此參數值
	 * @return		: 滿返回true,否則返回false
	 * @history		:
	 * ------------------------------------------------------------------------------
	 * date			version		modifier		remarks                   
	 * ------------------------------------------------------------------------------
	 * 2021/08/03	1.0.0		sheng.zheng		初版
	 * 2021/08/08	1.0.1		sheng.zheng		支援多種類型計數器
	 *
	 */
	public boolean isItemKeyFull(String itemKey,Long timeMillis) {
		boolean bRet = false;
		
		if (this.counterType == 1) {
			// 如果為計數器類型			
			if (itemMap.containsKey(itemKey)) {
				synchronized(itemMap) {
					Integer value = itemMap.get(itemKey);
					// 如果計數器將超越門限
					if (value >= this.counterThreshold - 1) {
						bRet = true;
					}					
				}
			}else {
				// 新的對象key,視業務需要,取值true或false
				bRet = true;
			}
		}else if(this.counterType == 2){
			// 如果為滑窗類型			
			if (itemSlideWindowMap.containsKey(itemKey)) {
				Deque<Long> itemQueue = itemSlideWindowMap.get(itemKey);
				synchronized(itemQueue) {
					if (itemQueue.size() > 0) {
						Long head = itemQueue.getFirst();
						if (timeMillis - head >= this.windowSize) {
							// 如果窗口將滿
							bRet = true;
						}
					}									
				}
			}else {
				// 新的對象key,視業務需要,取值true或false
				bRet = true;				
			}			
		}else if(this.counterType == 3){
			// 如果為滑窗+數量類型
			if (itemSlideWindowMap.containsKey(itemKey)) {
				Deque<Long> itemQueue = itemSlideWindowMap.get(itemKey);
				synchronized(itemQueue) {
					if (itemQueue.size() >= this.counterThreshold -1) {
						// 如果窗口數量將滿
						bRet = true;
					}									
				}
			}else {
				// 新的對象key,視業務需要,取值true或false
				bRet = true;				
			}			
		}
		
		return bRet;		
	}
		
	/**
	 * 
	 * @methodName		: resetItemKey
	 * @description		: 複位對象key的計數 
	 * @param itemKey	: 對象key
	 * @history		:
	 * ------------------------------------------------------------------------------
	 * date			version		modifier		remarks                   
	 * ------------------------------------------------------------------------------
	 * 2021/08/03	1.0.0		sheng.zheng		初版
	 * 2021/08/08	1.0.1		sheng.zheng		支援多種類型計數器
	 *
	 */
	public void resetItemKey(String itemKey) {
		if (this.counterType == 1) {
			// 如果為計數器類型
			if (itemMap.containsKey(itemKey)) {
				// 更新值,加鎖保護
				synchronized(itemMap) {
					itemMap.put(itemKey, 0);
				}			
			}		
		}else if(this.counterType == 2){
			// 如果為滑窗類型
			// 清空
			if (itemSlideWindowMap.containsKey(itemKey)) {
				Deque<Long> itemQueue = itemSlideWindowMap.get(itemKey);
				if (itemQueue.size() > 0) {
					// 加鎖保護
					synchronized(itemQueue) {
						// 先清空
						itemQueue.clear();
					}								
				}
			}						
		}else if(this.counterType == 3){
			// 如果為滑窗+數量類型
			if (itemSlideWindowMap.containsKey(itemKey)) {
				Deque<Long> itemQueue = itemSlideWindowMap.get(itemKey);
				synchronized(itemQueue) {
					// 清空
					itemQueue.clear();
				}
			}
		}
	}
	
	/**
	 * 
	 * @methodName		: putItemkey
	 * @description		: 更新對象key的計數
	 * @param itemKey	: 對象key
	 * @param timeMillis    : 時間戳,毫秒數,如為滑窗類計數器,使用此參數值
	 * @history		:
	 * ------------------------------------------------------------------------------
	 * date			version		modifier		remarks                   
	 * ------------------------------------------------------------------------------
	 * 2021/08/03	1.0.0		sheng.zheng		初版
	 * 2021/08/08	1.0.1		sheng.zheng		支援多種類型計數器
	 *
	 */
	public void putItemkey(String itemKey,Long timeMillis) {
		if (this.counterType == 1) {
			// 如果為計數器類型
			if (itemMap.containsKey(itemKey)) {
				// 更新值,加鎖保護
				synchronized(itemMap) {
					Integer value = itemMap.get(itemKey);
					// 計數器+1
					value ++;
					itemMap.put(itemKey, value);
				}
			}else {
				// 新key值,加鎖保護
				synchronized(itemMap) {
					itemMap.put(itemKey, 1);
				}			
			}
		}else if(this.counterType == 2){
			// 如果為滑窗類型	
			if (itemSlideWindowMap.containsKey(itemKey)) {
				Deque<Long> itemQueue = itemSlideWindowMap.get(itemKey);				
				// 加鎖保護
				synchronized(itemQueue) {
					// 加入
					itemQueue.add(timeMillis);
				}								
			}else {
				// 新key值,加鎖保護
				Deque<Long> itemQueue = new ArrayDeque<Long>();
				synchronized(itemSlideWindowMap) {
					// 加入映射表
					itemSlideWindowMap.put(itemKey, itemQueue);
					itemQueue.add(timeMillis);
				}
			}
		}else if(this.counterType == 3){
			// 如果為滑窗+數量類型
			if (itemSlideWindowMap.containsKey(itemKey)) {
				Deque<Long> itemQueue = itemSlideWindowMap.get(itemKey);				
				// 加鎖保護
				synchronized(itemQueue) {
					Long head = 0L;
					// 循環處理頭部數據
					while(true) {
						// 取得頭部數據
						head = itemQueue.peekFirst();
						if (head == null || timeMillis - head <= this.windowSize) {
							break;
						}
						// 移除頭部
						itemQueue.remove();
					}
					// 加入新數據
					itemQueue.add(timeMillis);					
				}								
			}else {
				// 新key值,加鎖保護
				Deque<Long> itemQueue = new ArrayDeque<Long>();
				synchronized(itemSlideWindowMap) {
					// 加入映射表
					itemSlideWindowMap.put(itemKey, itemQueue);
					itemQueue.add(timeMillis);
				}
			}			
		}				
	}
		
	/**
	 * 
	 * @methodName	: clear
	 * @description	: 清空字典
	 * @history		:
	 * ------------------------------------------------------------------------------
	 * date			version		modifier		remarks                   
	 * ------------------------------------------------------------------------------
	 * 2021/08/03	1.0.0		sheng.zheng		初版
	 * 2021/08/08	1.0.1		sheng.zheng		支援多種類型計數器
	 *
	 */
	public void clear() {
		if (this.counterType == 1) {
			// 如果為計數器類型
			synchronized(this) {
				itemMap.clear();
			}				
		}else if(this.counterType == 2){
			// 如果為滑窗類型	
			synchronized(this) {
				itemSlideWindowMap.clear();
			}				
		}else if(this.counterType == 3){
			// 如果為滑窗+數量類型
			synchronized(this) {
				itemSlideWindowMap.clear();
			}				
		}			
	}
}

2.3、調用

  要調用計數器,只需在應用類中添加DacService對象,如:

public class DataCommonService {
	// 數據訪問計數服務類,時間滑動窗口,窗口寬度60秒
	protected DacService dacService = new DacService(2,0,60000);

	/**
	 * 
	 * @methodName		: procNoClassData
	 * @description		: 對象組key對應的數據不存在時的處理
	 * @param classKey	: 對象組key
	 * @return		: 數據載入成功,返回true,否則為false
	 * @history		:
	 * ------------------------------------------------------------------------------
	 * date			version		modifier		remarks                   
	 * ------------------------------------------------------------------------------
	 * 2021/08/08	1.0.0		sheng.zheng		初版
	 *
	 */
	protected boolean procNoClassData(Object classKey) {
		boolean bRet = false;
		String key = getCombineKey(null,classKey);
		Long currentTime = System.currentTimeMillis();
		// 判斷計數器是否將滿
		if (dacService.isItemKeyFull(key,currentTime)) {
			// 如果計數將滿
			// 複位
			dacService.resetItemKey(key);
			// 從資料庫載入分組數據項
			bRet = loadGroupItems(classKey);
		}
		dacService.putItemkey(key,currentTime);
		return bRet;
	}
	
	/**
	 * 
	 * @methodName		: procNoItemData
	 * @description		: 對象key對應的數據不存在時的處理
	 * @param itemKey	: 對象key
	 * @param classKey	: 對象組key
	 * @return		: 數據載入成功,返回true,否則為false
	 * @history		:
	 * ------------------------------------------------------------------------------
	 * date			version		modifier		remarks                   
	 * ------------------------------------------------------------------------------
	 * 2021/08/08	1.0.0		sheng.zheng		初版
	 *
	 */
	protected boolean procNoItemData(Object itemKey, Object classKey) {
		// 如果itemKey不存在
		boolean bRet = false;
		String key = getCombineKey(itemKey,classKey);
		
		Long currentTime = System.currentTimeMillis();
		if (dacService.isItemKeyFull(key,currentTime)) {
			// 如果計數將滿
			// 複位
			dacService.resetItemKey(key);
			// 從資料庫載入數據項
			bRet = loadItem(itemKey, classKey);
		}
		dacService.putItemkey(key,currentTime);			
		return bRet;
	}

	/**
	 * 
	 * @methodName		: getCombineKey
	 * @description		: 獲取組合key值
	 * @param itemKey	: 對象key
	 * @param classKey	: 對象組key
	 * @return		: 組合key
	 * @history		:
	 * ------------------------------------------------------------------------------
	 * date			version		modifier		remarks                   
	 * ------------------------------------------------------------------------------
	 * 2021/08/08	1.0.0		sheng.zheng		初版
	 *
	 */
	protected String getCombineKey(Object itemKey, Object classKey) {
		String sItemKey = (itemKey == null ? "" : itemKey.toString());
		String sClassKey = (classKey == null ? "" : classKey.toString());
		String key = "";
		if (!sClassKey.isEmpty()) {
			key = sClassKey;
		}
		if (!sItemKey.isEmpty()) {
			if (!key.isEmpty()) {
				key += "-" + sItemKey;
			}else {
				key = sItemKey;
			}
		}
		return key;
	}
}

  procNoClassData方法:分組數據不存在時的處理。procNoItemData方法:單個數據項不存在時的處理。

  主從關係在資料庫中,較為常見,因此針對分組數據和單個對象key分別編寫了方法;如果key的個數超過2個,可以類似處理。