[用事實說明兩個凡是]一個由mysql事務隔離級別形成的問題分析

背景

最近要作一個批跑服務, 基本邏輯就是定時掃描數據庫的記錄, 有知足條件的就進行處理(一條記錄表明一個任務,如下任務與記錄含義相同). 要求支持多機部署批跑服務.php

批跑支持多機部署實現方案

要實現多機部署, 只要保證每一個批跑服務實例每次只獲取一條記錄, 處理完再獲取下一條便可. 其中最種要的是避免不一樣的實例獲取到同一條記錄,即所謂搶任務.mysql

先看錶結構設計:sql

create database if not exists ae;
create table ae.task (
id int primary key,
status int);
-- status爲0說明任務可處理,其它不可處理

以上是簡化的表結構,但足以說明本文試圖說明的問題.數據庫

要避免搶任務, oracle的作法, 直接session

update ae.task set status=1 where status=0 and rownum = 1 returning id

便可.併發

mysql的要囉嗦一點:oracle

select id from ae.task where status=0; -- 獲得ID
update ae.task where id = ${id} and status=0;

這兩個sql,第一個sql用於獲取符合條件的任務, 第二個sql用戶將任務鎖定. 在併發的場景下, 有可能不一樣的批跑實例的第一個SQL會返回相同的記錄, 但第二個sql只有一個會更新成功, 經過判斷affected rows便可知道哪一個鎖定成功. 鎖定成功的繼續處理本任務, 鎖定失敗的繼續處理其它任務.post

問題現象

管理後臺提交了一個任務後, 兩個批跑實例剛好同時啓動, 進入搶任務環節. 結果發現異常, 其中一個實例成功搶到任務, 但另外一個實例則掛死了:this

搶到任務的實例:設計

2015-11-23 19:42:01|INFO|exec_task.php|40||get one task: 11
...
2015-11-23 19:42:01|INFO|exec_task.php|107||line_count: 9
2015-11-23 19:42:01|INFO|exec_task.php|147||fork child success: 8346
2015-11-23 19:42:01|INFO|exec_task.php|264||[0] pid: 8346, start: 0, stop: 0

2015-11-23 19:42:01|INFO|exec_task.php|147||fork child success: 8347
2015-11-23 19:42:01|INFO|exec_task.php|264||[1] pid: 8347, start: 1, stop: 1

2015-11-23 19:42:01|INFO|exec_task.php|147||fork child success: 8348
2015-11-23 19:42:01|INFO|exec_task.php|264||[2] pid: 8348, start: 2, stop: 2

2015-11-23 19:42:01|INFO|exec_task.php|147||fork child success: 8349
...

沒有搶到任務的實例:

2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task
2015-11-23 19:42:01|INFO|function.inc.php|100||task_id 11 is locked by another process, get next task

能夠看到沒有搶到任務的實例進入了死循環.

緣由分析

按照咱們以前的設計, 若是第二條SQL鎖定任務的時候失敗了, 獲取下一個任務. 應當不會死循環. 死循環的緣由是由於沒有搶到任務的實例, 在執行第一個SQL的時候, 一直返回了相同的記錄(id=11,實際上當時也只有一條記錄)

請注意, 搶到任務的實例搶到任務後, 會把狀態更新並提交, 按說搶不到任務的實例會看到此狀態更新,並致使第一條sql查不到數據,而後 正常退出.

而事實上搶不到任務的實例看不到此變化, 說明事務隔離級別(Transaction Isolation Level)不是"READ COMMITED", 而是其它. 經確認, 級別是"REPEATABLE-READ"

mysql> select @@TX_ISOLATION;
+-----------------+
| @@TX_ISOLATION  |
+-----------------+
| REPEATABLE-READ |

"REPEATABLE-READ" 看到的數據是事務啓動時的樣子,因此看不到搶到任務的實例對任務狀態的修改. 進而致使死循環.

請注意執行第一個SQL查詢知足條件的任務是在一個事務內進行的. 此事務其實是業務的須要, 除了獲取到任務,還須要獲取其它資源,若是獲取不到其它 資源, 則rollback任務,以便下次處理.

ORACLE相應的事務隔離級別是"Serializable Isolation Level", 如上描述的這個場景, 在ORACLE下的反應是搶不到任務的實例在試圖更新任務狀態的 時候,會返回一個"ORA-08177: Cannot serialize access for this transaction"錯誤, 程序也能夠正常退出. 詳見<<Oracle Database Concepts 11g Release 2 (11.2) E40540-04>> 第9章"Overview of Oracle Database Transaction Isolation Levels"

mysql在"REPEATABLE-READ"的事務隔離級別上的表現是不能讓人滿意的. 查詢到的數據是事務啓動時的樣子,但更新的時候看到的數據又是其它事務提交 後的結果,而且update也沒有錯誤提示.

而"SERIALIZABLE"更糟糕, 若是同時開了兩個session, 乾脆直接鎖表了, 誰了更新不了. 這就勢必形成另外一個問題, 既然你們都更新不了,那就rollback事務, 重試唄. 可是重試也是頗有可能你們再同時開了事務,又鎖死了, 一直死循環. 爲了解決這種狀況,可能的作法是, 各自等待一個隨機時間再重試,讓隨機打破這個僵局. 不知道是否有其它辦法,歡迎指教.

解決方法

  1. 修改session的事務隔離級別
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
  1. 不斷查詢知足條件的任務不要放到一個事務裏. 發現"affected rows"爲0,更新不到數據時, 事務rollback,從新啓動事務. 即在循環裏不斷開啓事務而不是在事務裏不斷循環.

  2. 還有一個辦法是開事務而後select for update, 可是這種方法會致使鎖表, 必須等待其它事務提交後才能返回. 當初我進行設計的時候,是計劃使用select for update的方式的, 可是最終沒有使用, 如今回想, 多是沒有開事務, 結果兩個實例都查詢到了相同的記錄, 因此被我否認了. 可是看我另外一個文章 <<mysql搶單>>又彷佛多是因爲鎖表而棄用了, 緣由已經不可考了.

但從本個需求來講, 彷佛使用select for update來讓把表鎖住會更簡單.

另外一個問題

你覺得搶到任務的實例就能夠高枕無憂了嗎, 錯了! 等他高高興興處理完任務, 要把任務狀態置爲成功時, 發現這個任務竟然被沒有搶到任務的實例給鎖了, 自已只能獲得一個鎖超時的錯誤

2015-11-23 19:42:52|ERR|function.inc.php|113||SQL fail: Lock wait timeout exceeded; try restarting transaction

請期待下一個問題分析.

補充說明

今天回來確認了一下, 實際上ORACEL的update task set status = 1 where status = 0 and rownum = 1 returning taskid 這個SQL也會把表鎖住.

因此能夠用@flygogo 在30樓提出的方法模擬oracle 的returning

SET @update_id := 0; 
update ae.task set status=1, id = (SELECT @update_id := id) where status=0 limit 1; SELECT @update_id;

oracle

而postgresql的update彷佛沒有limit 1之類的限定只更新一條的寫法?

同時ORACLE和postgresql的select for update 也都會鎖表.

因此就本文所討論的範圍來講, 彷佛不能說是兩個凡是. 叉!

再補充說明

差點被繞暈了. 其實本文所指出的mysql在"REPEATABLE-READ"事務隔離級別下的表現是奇怪的,不直觀的,這點值得注意. 明明select出來的數據是可更新, 而更新時候又沒有成功, 會讓人很是疑惑. 而爲oracel在"Serializable"級別下發現數據已經被更新了以後,拋出"ORA-08177"的作法才更直觀更合適.

本文另外一個意義是分享了一種不鎖表實現隊列的方法

相關文章
相關標籤/搜索