文章轉自“荒野七叔 ” 鏈接 https://juejin.im/post/5c3aa3c86fb9a04a0e2d6c9f
來源平台 掘金
最近因為個人網站的文章瀏覽量計數在Chrome瀏覽器下有BUG,所以打算重新實現這個功能。
原本的實現很簡單,每次點擊文章詳情頁的時候,前端會發送一個GET請求articles/id
獲取一篇文章詳情。這個時候,會把這篇文章的瀏覽量+1,再存進數據庫里。
這個實現原本可以實現這個功能,但是后來我才發現,我犯了一個很致命的錯誤:在GET請求的業務邏輯里進行了數據的寫操作!
原則來講,GET請求應該具有冪等性,即短時間內同時兩個一模一樣的GET請求,返回的結果也應該是一樣的。而我原本的實現就破壞了GET請求的冪等性。
恰好,在Chrome瀏覽器里,我的文章詳情頁會發送兩次GET請求。這疑似Chrome瀏覽器和nuxt服務端渲染之間的一個BUG,目前還沒有定位到具體原因。
但無論如何,后端應該是可以避免這樣的BUG,即使某用戶短時間內請求兩次或者多次,也應該只增加一次瀏覽量計數。
由於最近在學習高並發方面的知識,所以這里也考慮一下,如果一個高並發的文章瀏覽量計數系統,應該如何設計?
先來理一下需求。
需求
- 用戶可以是匿名的,不需要登錄
- 每當一個用戶點擊了一個文章的詳情頁面,這個文章的瀏覽量應該+1
- 用戶應該能立即看到自己點擊文章后瀏覽量+1的反饋
- 瀏覽量這個數據存在Mysql和ElasticSearch里面,要最終一致(不要求強一致)
- 作者可能在后台編輯文章,然后保存文章。如果在這期間有瀏覽量的增加,保存文章的時候不應該覆蓋掉這段時間的瀏覽量增量。
- 應該在服務端對用戶的請求去重,防止用戶不斷刷新或者使用爬蟲不斷請求某個API(建議通過IP)
- 要過濾掉百度和谷歌的爬蟲請求(根據User-Agent頭判斷,可以先不做)
- 要高性能地實現“查看瀏覽最多文章列表”的功能。
- 盡可能優化性能,滿足多個用戶的高並發需求。
設計思路
如果要滿足高並發,那首先考慮用異步和緩存。所以考慮使用多線程加Redis的解決方案。
請求流程:
- 用戶點擊某篇文章詳情頁
- 前端發送一個
PUT
請求/articles/{id:\\d+}/view
。 - 后端使用線程池執行一個異步任務,立即返回給前端
200
響應。 - 前端得到
200
響應后,立即把當前文章的瀏覽量+1,滿足需求3。

后端主要邏輯:
后端的主要思路是暫時把增加的瀏覽量(假設某篇文章為n)放進Redis里,然后每隔一段時間刷新到Mysql數據庫和ElasticSearch存儲里,讓這篇文章的瀏覽量在現有的基礎上加n,然后把Redis這篇文章的瀏覽量清零。
- 后端首先判斷redis里時候有沒有當前ip對這篇文章的瀏覽記錄,這個key為:
isViewd:articleId:ip
。如果有,就說明之前瀏覽過,就什么也不做,直接返回。如果沒有,就加上這個key。時間可以設置為1小時過期,防止占用過多內存。這里使用Redis的string類型。 - 如果第5步的結果是沒有,那就在Redis里給這篇文章的瀏覽量+1。Redis的這個支持原子操作,所以不用擔心並發問題。key為
viewCount:articleId
,value為緩存的瀏覽量。完成后當前線程任務就結束了。這里使用Redis的string類型。這些key應該沒有過期時間。 - 弄一個定時任務,比如每5分鍾,去Redis里拿緩存的瀏覽量,拿到后就更新到數據庫和ElasticSearch里,並把Redis的數據清零。為了防止並發帶來的問題,這里應該是拿到m,就在Redis里減去m,而不是直接設置為0。
- 為了節約內存,應該刪除不必要的key,按照業務邏輯來看,如果一篇文章長時間沒有人瀏覽,可能這篇文章比較“舊”了,我們可以考慮刪除它在Redis里面的key。所以我們可以在第6步,每次在Redis里進行瀏覽量+1操作時,記錄下一個時間戳。所以Redis可以使用hash類型,一個字段存最后操作時間,一個字段存瀏覽量。而在第7步里,我們可以順便刪除掉最后操作時間小於十天前的key。
- 保存更新文章的時候,應該只更新其它字段,而不更新瀏覽量這個字段。或者執行一遍第7步的邏輯。由於Redis加減操作的原子性,這里不用擔心並發問題。如果當前線程把一篇文章的瀏覽量在Redis里減了m,那定時任務線程應該得到的是減了m之后的結果,所以數據會是一致的。
- 關於需求8,在並發量不算特別大的時候,我們還是去取數據庫里面的數據,根據數據庫里面的瀏覽量來排序,只是可以在應用里面給它加一個緩存,緩存時間應該與第7步定時任務一致,這里設置為5分鍾。
如果並發量特別大,可以考慮不把瀏覽量存在數據庫里,而僅存在Redis里,這樣可以得到近乎實時的瀏覽量存儲,而且需求8排序也是實時的(使用zset),但這樣可能會耗費大量的內存資源。
