實現高效能高併發的計數器功能

edagarli發表於2015-10-29

在專案中有很多場景需要應用到計數器的功能,我們經常需要給某些資料表新增一些需要經常更新的統計欄位,例如: 使用者的積分、檔案的下載次數、喜歡數、評論數、瀏覽數,etc. 而當這些資料更新的頻率比較頻繁的時候,資料庫的壓力也隨之增大不少.

通常在實現網站文章點選數的時候,是這麼設計資料表的,如:”article_id, menu_id, article_name, article_content, article_author, article_view...
在article_view中記錄該文章的瀏覽量, 而這僅僅試用於訪問量比較小的場景. 在訪問量大的站點應該如何設計計數器呢?

MySql計數器: 多行並行更新

對文章資訊類為主的站點,在瀏覽一個頁面的時候不但要進行大量的查(查詢上文的記錄,已經所屬分類的名字、熱門文章資訊評論、TAG等),還要進行寫操作(更新瀏覽數點選數)。把文章的詳細內容和計數器放在一張表儘管對開發很方便,但是會造成資料庫的壓力過大. 那麼,分兩張表存放就好了麼?一張表存文章詳細資訊,另一張表單獨存計數器。

CREATE TABLE article_view(
article_id int(11) NOT NULL,
view int(11) NOT NULL,
PRIMARY KEY (article_id)
)ENGINE=InnoDB;

這種方式,雖然分擔了文章表的壓力,但是每當有一個程式請求更新的時候,都會產生全域性的互斥鎖,只能序列,不能並行。在高併發下會有較長的等待時間。

另一種比較好的辦法是對每一個文章的計數器不是一行,而是多行,比如吧,一百行。每次隨機更新其中一行,該文章的瀏覽數就是所有行的和。

CREATE TABLE article_view(
article_id int(11) NOT NULL,
pond tinyint(4) NOT NULL COMMENT '池子,就是用來隨機用的',
view int(11) NOT NULL,
PRIMARY KEY (article_idpond)
)ENGINE=InnoDB;

小訪問量的隨機池子100個肯定多了,三五個足矣。每次訪問的時候,隨機一個數字(1-100)作為pond,如何該pond存在則更新view+1,否則插入,view=1。藉助DUPLICATE KEY,不然在程式裡是實現得先SELECT,判斷一下再INSERT或者UPDATE。

INSERT INTO article_view (article_idpondview) VALUES (123, RAND()*100, 1) ON DUPLICATE KEY UPDATE view=view+1

獲取指定文章的總訪問量的時候:

SELECT SUM(view) FROM article_view WHERE article_id='123'

MySql計數器: 延遲更新

延遲更新功能是指我們可以給統計欄位的更新設定一個延遲時間,在這個時間段內所有的更新會被累積快取起來,然後定時地統一更新資料庫。這比較適合某個欄位經常需要遞增或者遞減,並且對實時性要求沒有那麼嚴格的情況。

以ThinkPHP的延遲更新方法為例:http://document.thinkphp.cn/manual_3_2.html#update_data
3.2.3版本開始,setInc和setDec方法支援延遲更新,用法如下:
$Article = M("Article"); // 例項化Article物件
$Article->where('id=5')->setInc('view',1); // 文章閱讀數加1
$Article->where('id=5')->setInc('view',1,60); // 文章閱讀數加1,並且延遲60秒更新(寫入)

/ThinkPHP/Library/Think/Model.class.php

/**

  • 欄位值增長
  • @access public
  • @param string $field 欄位名
  • @param integer $step 增長值
  • @param integer $lazyTime 延時時間(s)
  • @return boolean */ public function setInc($field,$step=1,$lazyTime=0) { if($lazyTime>0) {// 延遲寫入 $condition = $this->options['where']; $guid = md5($this->name.''.$field.''.serialize($condition)); $step = $this->lazyWrite($guid,$step,$lazyTime); if(false === $step ) return true; // 等待下次寫入 } return $this->setField($field,array('exp',$field.'+'.$step)); }

/**

  • 延時更新檢查 返回false表示需要延時
  • 否則返回實際寫入的數值
  • @access public
  • @param string $guid 寫入標識
  • @param integer $step 寫入步進值
  • @param integer $lazyTime 延時時間(s)
  • @return false|integer */ protected function lazyWrite($guid,$step,$lazyTime) { if(false !== ($value = S($guid))) { // 存在快取寫入資料 if(NOW_TIME > S($guid.'time')+$lazyTime) { // 延時更新時間到了,刪除快取資料 並實際寫入資料庫 S($guid,NULL); S($guid.'_time',NULL); return $value+$step; }else{ // 追加資料到快取 S($guid,$value+$step); return false; } }else{ // 沒有快取資料 S($guid,$step); // 計時開始 S($guid.'time',NOW_TIME); return false; } }

/ThinkPHP/Common/functions.php

/**

  • 快取管理
  • @param mixed $name 快取名稱,如果為陣列表示進行快取設定
  • @param mixed $value 快取值
  • @param mixed $options 快取引數
  • @return mixed */ function S($name,$value='',$options=null) { static $cache = ''; if(is_array($options)){ // 快取操作的同時初始化 $type = isset($options['type'])?$options['type']:''; $cache = Think\Cache::getInstance($type,$options); }elseif(is_array($name)) { // 快取初始化 $type = isset($name['type'])?$name['type']:''; $cache = Think\Cache::getInstance($type,$name); return $cache; }elseif(empty($cache)) { // 自動初始化 $cache = Think\Cache::getInstance(); } if(''=== $value){ // 獲取快取 return $cache->get($name); }elseif(is_null($value)) { // 刪除快取 return $cache->rm($name); }else { // 快取資料 if(is_array($options)) { $expire = isset($options['expire'])?$options['expire']:NULL; }else{ $expire = is_numeric($options)?$options:NULL; } return $cache->set($name, $value, $expire); } }

MySql+Memcache計數器: 延遲更新

結合上圖,解析下流程圖:

1.資源瀏覽量,比如blog詳情頁的瀏覽量 views = mysql(資料表的瀏覽量) + memcache(瀏覽量) 每次訪客訪問blog詳情頁,瀏覽量就會+1,使用瀏覽延遲更新,僅更新memcache中的瀏覽量,並把瀏覽量快取的key hash到array中(這很重要),當memcache中的瀏覽量達到某一值,比如100時,做一次update mysql資料的瀏覽量,並即時把memcache的瀏覽量設定為0。

2.由於資源瀏覽量部分儲存在memcache中,重啟memcache,或者其他原因,瀏覽量會丟失,需要額外開發一個定時任務更新快取的瀏覽量到mysql中;

3.圖中瀏覽量定時任務(比如凌晨3點)更新memcache快取到資料庫中,這時(第一點hash的key就顯得特別重要),定時任務就變成了遍歷hash陣列,每一個hash 的value就是一組瀏覽量快取Key的集合,再遍歷這些瀏覽量的key 獲取某資源的瀏覽量,update到mysql,在更新前及時設定這個瀏覽量快取為0,以便新的瀏覽量更新到快取不受定時任務影響。

https://github.com/JingwenTian/CodeLibrary/tree/master/backend/Server/memcache

Redis計數器

$r = new Redis();
$r->connect("127.0.0.1", "6379");

$URL = $SERVER["SCRIPT_URI"];
$UA = $_SERVER["HTTP
USER_AGENT"];
$d = date("Ymd");

$userkey_ua = "stats:" . $d . ":ua:" . md5($URL);
$userkey_url = "stats:" . $d . ":url:" . md5($URL);
$userkey_glob = "stats:" . $d;

$r->sadd($userkey_ua, md5($UA));
$r->incr($userkey_url);
$r->incr($userkey_glob);

// Optionally set expire 25 hours from now one, 
// to be sure will be available until tomorrow.
$r->expire($userkey_ua, 3600 * 25);
$r->expire($userkey_url, 3600 * 25);
// we want $userkey_glob to expire in 32 days
$r->expire($userkey_glob, 3600 * 24 * 32);

...
// Somewhere at the end of the page...

echo sprintf(
"This page was visited %d times today, with %d different browsers!",
$r->get($userkey_url),
$r->scard($userkey_ua)
);

Reference

Visitor Tracking with Redis and PHP http://www.ebrueggeman.com/blog/redis-visitor-tracking
Simple realtime web counter http://redis4you.com/code.php?id=009



http://edagarli.logdown.com/posts/306223/performance-counters-for-high-concurrency-features-such-as-the-article-hits

相關文章