高併發文章瀏覽量計數系統設計

荒野七叔發表於2019-02-25

最近因為個人網站的文章瀏覽量計數在Chrome瀏覽器下有BUG,所以打算重新實現這個功能。

原本的實現很簡單,每次點選文章詳情頁的時候,前端會傳送一個GET請求articles/id獲取一篇文章詳情。這個時候,會把這篇文章的瀏覽量+1,再存進資料庫裡。

這個實現原本可以實現這個功能,但是後來我才發現,我犯了一個很致命的錯誤:在GET請求的業務邏輯裡進行了資料的寫操作!

原則來講,GET請求應該具有冪等性,即短時間內同時兩個一模一樣的GET請求,返回的結果也應該是一樣的。而我原本的實現就破壞了GET請求的冪等性。

恰好,在Chrome瀏覽器裡,我的文章詳情頁會傳送兩次GET請求。這疑似Chrome瀏覽器和nuxt服務端渲染之間的一個BUG,目前還沒有定位到具體原因。

但無論如何,後端應該是可以避免這樣的BUG,即使某使用者短時間內請求兩次或者多次,也應該只增加一次瀏覽量計數。

由於最近在學習高併發方面的知識,所以這裡也考慮一下,如果一個高併發的文章瀏覽量計數系統,應該如何設計?

先來理一下需求。

需求

  1. 使用者可以是匿名的,不需要登入
  2. 每當一個使用者點選了一個文章的詳情頁面,這個文章的瀏覽量應該+1
  3. 使用者應該能立即看到自己點選文章後瀏覽量+1的反饋
  4. 瀏覽量這個資料存在Mysql和ElasticSearch裡面,要最終一致(不要求強一致)
  5. 作者可能在後臺編輯文章,然後儲存文章。如果在這期間有瀏覽量的增加,儲存文章的時候不應該覆蓋掉這段時間的瀏覽量增量。
  6. 應該在服務端對使用者的請求去重,防止使用者不斷重新整理或者使用爬蟲不斷請求某個API(建議通過IP)
  7. 要過濾掉百度和谷歌的爬蟲請求(根據User-Agent頭判斷,可以先不做)
  8. 要高效能地實現“檢視瀏覽最多文章列表”的功能。
  9. 儘可能優化效能,滿足多個使用者的高併發需求。

設計思路

如果要滿足高併發,那首先考慮用非同步和快取。所以考慮使用多執行緒加Redis的解決方案。

請求流程:

  1. 使用者點選某篇文章詳情頁
  2. 前端傳送一個PUT請求/articles/{id:\d+}/view
  3. 後端使用執行緒池執行一個非同步任務,立即返回給前端200響應。
  4. 前端得到200響應後,立即把當前文章的瀏覽量+1,滿足需求3。
請求流程.png

後端主要邏輯:

後端的主要思路是暫時把增加的瀏覽量(假設某篇文章為n)放進Redis裡,然後每隔一段時間重新整理到Mysql資料庫和ElasticSearch儲存裡,讓這篇文章的瀏覽量在現有的基礎上加n,然後把Redis這篇文章的瀏覽量清零。

  1. 後端首先判斷redis裡時候有沒有當前ip對這篇文章的瀏覽記錄,這個key為:isViewd:articleId:ip。如果有,就說明之前瀏覽過,就什麼也不做,直接返回。如果沒有,就加上這個key。時間可以設定為1小時過期,防止佔用過多記憶體。這裡使用Redis的string型別。
  2. 如果第5步的結果是沒有,那就在Redis裡給這篇文章的瀏覽量+1。Redis的這個支援原子操作,所以不用擔心併發問題。key為viewCount:articleId,value為快取的瀏覽量。完成後當前執行緒任務就結束了。這裡使用Redis的string型別。這些key應該沒有過期時間。
  3. 弄一個定時任務,比如每5分鐘,去Redis裡拿快取的瀏覽量,拿到後就更新到資料庫和ElasticSearch裡,並把Redis的資料清零。為了防止併發帶來的問題,這裡應該是拿到m,就在Redis裡減去m,而不是直接設定為0。
  4. 為了節約記憶體,應該刪除不必要的key,按照業務邏輯來看,如果一篇文章長時間沒有人瀏覽,可能這篇文章比較“舊”了,我們可以考慮刪除它在Redis裡面的key。所以我們可以在第6步,每次在Redis裡進行瀏覽量+1操作時,記錄下一個時間戳。所以Redis可以使用hash型別,一個欄位存最後操作時間,一個欄位存瀏覽量。而在第7步裡,我們可以順便刪除掉最後操作時間小於十天前的key。
  5. 儲存更新文章的時候,應該只更新其它欄位,而不更新瀏覽量這個欄位。或者執行一遍第7步的邏輯。由於Redis加減操作的原子性,這裡不用擔心併發問題。如果當前執行緒把一篇文章的瀏覽量在Redis裡減了m,那定時任務執行緒應該得到的是減了m之後的結果,所以資料會是一致的。
  6. 關於需求8,在併發量不算特別大的時候,我們還是去取資料庫裡面的資料,根據資料庫裡面的瀏覽量來排序,只是可以在應用裡面給它加一個快取,快取時間應該與第7步定時任務一致,這裡設定為5分鐘。

如果併發量特別大,可以考慮不把瀏覽量存在資料庫裡,而僅存在Redis裡,這樣可以得到近乎實時的瀏覽量儲存,而且需求8排序也是實時的(使用zset),但這樣可能會耗費大量的記憶體資源。

後端邏輯.png

後記

雖然最後權衡了併發量和複雜性,我的個人網站的文章瀏覽邏輯並沒有完全按照上述設計思路來做,但上述思路是我對一個高併發文章瀏覽量計數系統設計的思考,以後如果有機會可以寫一個開源的版本。

可能實現起來會更復雜,根據併發量的不同,程式碼也會有一些差別,以上思路僅供參考。

相關文章