索引
一、使用樂觀鎖的目的
二、樂觀鎖實現的方法
三、thinkphp3.2中樂觀鎖的實現
四、優化thinkphp3.2中的樂觀鎖
簡單的來說,使用樂觀鎖的目的就是保證數據不會被錯誤的寫入,並且在保護寫入的過程中,並不影響其他用戶對這個數據的讀取(樂觀的去讀,認爲我讀的數據都是別人沒有改過的)。
樂觀鎖實現的方法
樂觀鎖實現的方法,換句話說就是如何保護數據不被錯誤的寫入?
舉個錯誤的寫入例子,一個教務系統中,某個同學的考試成績總分數算錯了,需要科目A老師扣除總分中的10分,需要科目B的老師扣除總分中的20分;這時科目A的老師和科目B的老師都查閱了該學生的分數是180分,然後科目A的老師扣除10分,輸入170分完成修改;科目B的老師扣除20分後輸入160完成修改;整個過程完成後,這位同學的分數就變成了160分,實際正確的修改應該是150分纔對(180-10-20)。
那如何保證這位同學的分數被正確的寫入呢?這個就是樂觀鎖實現的方法:提交版本必須大於記錄當前版本才能執行更新。爲這位同學的分數加個數據版本,這個數據版本就是一個數字,記錄當前是第幾次修改(數據庫保存)。
php中的實現:每次查詢成功時,記錄當前數據的版本號,可以使用一個隱藏表單記錄(如果是api接口就拿個變量存儲一下),再提交修改時,將這個值+1操作過後一起提交過去(數據表要新增一個數據版本字段,用於記錄版本),提交過去就存在以下情況:
- 提交的數據版本大於數據庫版本,滿足 “ 提交版本必須大於記錄當前版本才能執行更新 “ 的樂觀鎖策略,執行更新的操作;
- 提交的數據版本小於或者等於當前的數據版本時,不滿足提交版本必須大於記錄當前版本才能執行更新 “ 的樂觀鎖策略,說明數據已經發送過更改了,就不允許用戶修改,程序提示數據已過期,請重新讀取後再操作。
thinkphp3.2中樂觀鎖的實現
thinkphp3.2中,樂觀鎖實現的對應代碼的位置是:simplewind/Core/Library/Think/Model/AdvModel.class.php
具體代碼就不貼出了,簡單說一下tp3.2實現的過程:
- 新數據插入時,自動插入數據版本對應的值,方法入口:
// 寫入前的回調方法
protected function _before_insert(&$data,$options='') {
// 記錄樂觀鎖
$data = $this->recordLockVersion($data);
//..............
}
數據插入完成後就用了初始的版本號:0
2.每次查詢成功的回調中,緩存當前查詢結果行的數據版本值,方法入口:
// 查詢成功後的回調方法
protected function _after_find(&$result,$options='') {
//..............
// 緩存樂觀鎖
$this->cacheLockVersion($result);
}
噹噹前的查詢,包含數據版本這個字段時,這個方法就將當前行的主鍵ID形成一個唯一key,記錄數據版本,儲存到session中
3.每次執行更新前,檢測session是否存在對應的數據版本key,方法入口:
// 更新前的回調方法
protected function _before_update(&$data,$options='') {
// 檢查樂觀鎖
$pk = $this->getPK();
if(isset($options['where'][$pk])){
$id = $options['where'][$pk];
if(!$this->checkLockVersion($id,$data)) {
return false;
}
}
//................
}
checkLockVersion方法中,就是判斷session中一開始查詢成功時儲存的當前行的數據版本是否與當前模型對應數據庫中的數據版本是否一致,當一致時再繼續進行更新的操作,並且將數據版本+1提交到修改中;如果不一致就直接返回false,中止修改。
tp中樂觀鎖檢測的部分:
/**
* 檢查樂觀鎖
* @access protected
* @param inteter $id 當前主鍵
* @param array $data 當前數據
* @return mixed
*/
protected function checkLockVersion($id,&$data) {
// 檢查樂觀鎖
$identify = $this->name.'_'.$id.'_lock_version';
if($this->optimLock && isset($_SESSION[$identify])) {
$lock_version = $_SESSION[$identify];
$vo = $this->field($this->optimLock)->find($id);
$_SESSION[$identify] = $lock_version;
$curr_version = $vo[$this->optimLock];
if(isset($curr_version)) {
if($curr_version>0 && $lock_version != $curr_version) {
// 記錄已經更新
$this->error = L('_RECORD_HAS_UPDATE_');
return false;
}else{
// 更新樂觀鎖
$save_version = $data[$this->optimLock];
if($save_version != $lock_version+1) {
$data[$this->optimLock] = $lock_version+1;
}
$_SESSION[$identify] = $lock_version+1;
}
}
}
return true;
}
可能存在的問題1:
樂觀鎖驗證通過後,立馬將session的值改變了,代碼:$_SESSION[$identify] = $lock_version+1;
;
可能存在的問題2:由於更新時,並不具備原子性,可能兩個併發的更新會同時執行,均符合樂觀鎖的版本判斷。(併發時,讀取出來的版本和數據庫均一致,都提交了更新操作)
優化:
解決問題1:
不使用session進行記錄,這樣操作會使已經讀取的記錄版本發生混亂,應該將記錄值和提交的數據綁定在一起,例如是一次表單提交,查詢成功後就使用一個hidden表單,記錄當前查詢的版本值,和其他數據一併提交;再或者是一個api接口,提交更新時,查詢當前行的版本號,和其他數據一併提交;然後再檢測樂觀鎖部分,也就是checkLockVersion($id,&$data)
中取出,data中的版本號字段和數據庫的版本再進行對比。
解決問題2:
每次執行修改前,加一個redis的鎖,可以使用set($redisLock,1,['NX', 'EX' => 10]))
達到原子性,其中的NX參數表示,只有當key不存在時,才設置成功,設置成功纔可以進行修改操作的提交,這個操作是具有原子性的,並發進來的修改只有一個修改能成功;如果設置不成功,說明這行記錄已經再進行修改的過程中了,我們就可以選擇直接結束這個請求,或者讓這個請求進行等待;然後再執行完操作過後,刪除這個redis鎖del($redisLock);
即可;
if (!$this->redis->set($redisLock,1,['NX', 'EX' => $this->redisLockExpire])) { //設置失敗,進行重試
addDebugLog('設置redis鎖失敗;開始重試;重試次數'.($retry_count+1).'數據表:'.$this->name.';數據行:'.$id.';本次操作版本:'.$lock_version);
usleep($this->waitTime * 1000000);
$retry_count+=1;
return $this->checkLockVersion($id,$data,$retry_count,$lock_version);
}
優化總結:成功後的回調不必記錄版本號的值,再提交修改時,把當前的數據版本一起提交過去,在修改回調中取出這個提交修改的版本號的值,與數據庫對比;每次執行檢測時,使用redis中set方法的NX參數,實現更改的原子性。