背景介紹:
為了平衡社區成員的貢獻和索取,一起幫引入了幫幫幣。當用戶積分(幫幫點)達到一定數額之后,就會“掉落”一定數量的“幫幫幣”。為了增加趣味性,幫幫幣“掉落”之后所有用戶都可以“撿取”,誰先撿到歸誰。
但這樣就產生了一個問題,因為這個“幫幫幣”是可以買賣有價值的,所以難免會有惡意用戶用爬蟲不斷的掃描,導致這樣的情況出現:
注:經核實,喬布斯的同學 其實沒有用爬蟲,就是手工點,點出來的!還能說什么呢?只能表示佩服啊佩服……
所以我們需要一種機制,阻止這種爬蟲的行為。
大致思路:
這個問題我們有一個很便利的前提:只有注冊用戶才能夠“撿起”幫幫幣。所以,我們不需要通過“封IP”(需獲取真實IP)這種方式來阻斷爬蟲爬行,而是直接封注冊用戶,非常方便。
那么如何判斷一個請求是真實用戶,還是爬蟲呢?我們決定使用最簡單的方法:記錄訪問頻次。當某一個用戶的訪問頻次高於設定值時(比如:5分鍾10次),就判定該用戶“有爬蟲嫌疑”。
此外,為了防止誤判(確實有用戶手快),我們還應該給用戶一個“解鎖”的功能:通過輸入驗證碼來確定不是爬蟲。
細節設計:
一個最核心的問題是:用什么來記錄用戶的訪問頻次?
數據庫?感覺沒必要,這個數據又不需要長期保留,訪問一次就做一次I/O操作在性能上接受不了,所以我們決定使用內存。
但是,具體需要記錄那些數據,又用什么樣的數據結構呢?
最后我們選擇使用緩存,記錄最簡單的“用戶ID -> 訪問次數”鍵值對,來解決這個問題,因為:
- 利用緩存的自動清除(expire)特性,清除過期數據,保證記錄的訪問次數始終是在一定時間內的。
- 緩存的讀寫速度很快,性能上沒有壓力
當然,這里其實還是有那么點問題的。比如,假設緩存時間是5分鍾,最多訪問次數是10次。0:10,開始緩存訪問次數,一直累加,到0:14,共記錄訪問次數7次,沒有問題;然而,一過0:15,緩存被清空,0:16的時候,緩存里只有0:15到0:16這一分鍾的數據,沒有過去5分鍾(從0:11到0:16)的數據。所以用戶可以控制一直爬蟲,訪問9次,然后就歇着,5分鍾過后,再繼續訪問9次,然后再歇5分鍾……
唉~~真這么拼,我還真沒什么辦法?但如果這么一個頻次他能接受的話,我其實也無所謂,你就慢慢爬唄。或者,我們后台做更大的監控,把每個用戶的每次訪問都記錄下來,進行統計,找出異常。那時候可能就真的需要數據庫了(為了提高性能可以內存里放一個DataTable,定時同步到Database)。但暫時來說,沒有這個必要。
此外,還有一個問題,是不是只需要記錄用戶訪問頻次?
如果按上述方案,在緩存里記錄訪問頻次,通過緩存數據來判斷是否允許繼續訪問,會有一個問題:緩存到期失效之后,這個用戶就又可以自由訪問目標頁面了!相當於到期自動解鎖。
我覺得這還是不科學,如果認定是爬蟲,只能是人工解鎖(識別碼驗證)。所以在數據庫用戶表里添加一個“已鎖定”(Locked)字段,如果用戶被鎖定,Update其為當前時間;未鎖定時(解鎖后)為NULL。
具體實現:
為了重用,我們需要利用 Authorize Fitler,在它的OnAuthorization()方法里面進行檢查和記錄。
代碼本身應該比較簡單,if...else...的邏輯:
///1. 先根據數據庫撿查當前用戶是否被鎖定 ///2. 如果被鎖定,直接攔截。否則: ///3. 在緩存中檢查有無當前用戶的訪問次數記錄 /// 3.1 沒有,新建一條他的緩存。否則: /// 3.2 檢查該用戶已訪問次數 /// 3.2.1 如果已到達訪問次數限制,攔截並在數據庫中鎖定該用戶。否則 /// 3.2.2 累加用戶的訪問次數
精簡注釋代碼如下:
public class NeedLogOn : AuthorizeAttribute { public override void OnAuthorization(AuthorizationContext filterContext) { HttpContextBase context = filterContext.HttpContext; ///Autofac相關操作,獲取正取的ISharedService實例 ISharedService service = AutofacConfig.Container.Resolve<ISharedService>(); _NavigatorModel model = service.Get(); //從數據庫獲取當前User的信息 ///截斷式編程,減少if...else的{}嵌套 if (model.Locked.HasValue) { ///model.Locked 來自數據庫,用戶已經被鎖定,攔截 visitTooMuch(filterContext); return; } string cacheKey = CacheKey.MAX_VISIT + model.Id; ///非常有意思,不能直接使用int值類型,必須使用引用類型的 VisitCounter amount; if (context.Cache[cacheKey] == null) { amount = new VisitCounter { Value = 1 }; ///新建立一條Cache context.Cache.Add(cacheKey, amount, null, DateTime.Now.AddSeconds(Config.Seconds), Cache.NoSlidingExpiration, CacheItemPriority.Normal, null); } else { amount = context.Cache[cacheKey] as VisitCounter; if (amount.Value >= Config.MaxVisit) { ///在數據庫中鎖定該用戶 service.LockCurrentUser(); BaseService.Commit(); ///立即清除Cache context.Cache.Remove(cacheKey); visitTooMuch(filterContext); return;} else { ///不能使用:currentVisitAmount++; ///context.Cache[cacheKey] = currentVisitAmount; ///見:https://stackoverflow.com/questions/2118067/cached-item-never-expiring amount.Value++; } } } } public class VisitCounter { public int Value { get; set; } }
仔細觀察代碼,你會發現兩個問題。這就是飛哥我曾經掉的坑啊!o(╥﹏╥)o
1、為什么要引入VisitCounter類?
緩存里就存放着這個類的實例,而這個類其實就包裹一個int Value;干嘛呢,這是?為什么不直接用int呢?直接把int存到Cache里不行嗎?
不行啊!艹。
存進去,沒問題;取出來,也沒問題;但更新(累加)的時候有問題啊。你怎么更新?
//取出緩存 currentVisitAmount = Convert.ToInt32(context.Cache[cacheKey]); //累加 currentVisitAmount++; //再存進去 context.Cache[cacheKey] = currentVisitAmount;
這樣不行的,具體的解釋看這里:Cached item never expiring。
簡單的說,context.Cache[cacheKey] = currentVisitAmount; 這一句,等於重新插入了一條永不過期的緩存。萬萬沒想到啊!這個bug把飛哥都差點搞瘋了,本來cache的調試都非常麻煩,還搞個這種幺蛾子。
所以解決的辦法是什么呢?在Cache里存一個引用類型值,然后不改Cache,只改引用類實例里的值就OK了。代碼就不重復了。
2、在鎖定用戶的同時,清除該用戶的cache
這里啊,曾經走了點彎路。
我最開始是在解鎖用戶的時候清除該用戶的Cache。
[NeedLogOn] public ActionResult Unlock() { string userId = getCurrentUserId(); string cacheKey = CacheKey.MAX_VISIT + userId; HttpContext.Cache.Remove(cacheKey); return View(new ImageCodeModel()); }
結果不知道咋回事,時靈時不靈。我把本地代碼,連接服務器數據庫,開着Debug模式,一步一會的進去看,OK,沒問題;但把本地代碼發布到服務器,duang,不行了?!沒法調試,只有寫log啥的,坑得我不要不要的……
后來突然發現,這里有“壞代碼的味道”:重復。你看這個cacheKey的構建,是不是在 NeedLogOn.OnAuthorization()里構建過一次?重復使用的代碼是不是就應該封裝?所以呢,開始呢,是想弄一個方法出來獲得cacheKey,比如striing GetVisitLimitCacheKey()啥的,但這個方法要讓Controller里的UnLock()和Filter里的OnAuthorization()都能調用,放在哪里呢?
突然靈光一閃:為什么 Cache.Remove 要寫在UnLock()里面呢?
其實只要用戶被鎖定,他的緩存信息就沒用了。因為我們已經在數據庫中標明了他被Locked,所以NeedLogOn.OnAuthorization()攔截住他,不需要Cache呀!盡早的清除這個Cache,還能提高那么一點點的性能。
最關鍵的是,這樣代碼更緊湊了:cacheKe在同一個方法里被使用,cache操作在同一個方法類完成,避免了代碼分散耦合,優雅多了!
++++++++++++++++++++
最后的最后,請大家幫個小忙,我做的一個小調查:你願不願意成為“好心人”?
忘了給注冊人和邀請碼:葉飛,1786。或者直接點擊注冊。