問題背景: html
假設MySQL數據庫有一張會員表vip_member(InnoDB表),結構以下:python
當一個會員想續買會員(只能續買1個月、3個月或6個月)時,必須知足如下業務要求: mysql
若是end_at早於當前時間,則設置start_at爲當前時間,end_at爲當前時間加上續買的月數sql
若是end_at等於或晚於當前時間,則設置end_at=end_at+續買的月數數據庫
續買後active_status必須爲1(即被激活)併發
問題分析: 分佈式
對於上面這種狀況,咱們通常會先SELECT查出這條記錄,而後根據查出記錄的end_at再UPDATE start_at和end_at,僞代碼以下(爲uid是1001的會員續1個月):性能
vipMember = SELECT * FROM vip_member WHERE uid=1001 LIMIT 1 # 查uid爲1001的會員 if vipMember.end_at < NOW(): UPDATE vip_member SET start_at=NOW(), end_at=DATE_ADD(NOW(), INTERVAL 1 MONTH), active_status=1, updated_at=NOW() WHERE uid=1001 else: UPDATE vip_member SET end_at=DATE_ADD(end_at, INTERVAL 1 MONTH), active_status=1, updated_at=NOW() WHERE uid=1001
假如同時有兩個線程執行上面的代碼,很顯然存在「數據覆蓋」問題(即一個是續1個月,一個續2個月,但最終可能只續了2個月,而不是加起來的3個月)。 ui
解決方案: spa
A、我想到的第一種方案是把SELECT和UPDATE合成一條SQL,以下:
UPDATE vip_member SET start_at = CASE WHEN end_at < NOW() THEN NOW() ELSE start_at END, end_at = CASE WHEN end_at < NOW() THEN DATE_ADD(NOW(), INTERVAL 1 MONTH) ELSE DATE_ADD(end_at, INTERVAL 1 MONTH) END, active_status=1, updated_at=NOW() WHERE uid=#uid:BIGINT# LIMIT 1;
So easy!
B、第二種方案:事務,即用一個事務來包裹上面的SELECT+UPDATE操做。
那麼是否包上事務就萬事大吉了呢?
顯然不是。由於若是同時有兩個事務都分別SELECT到相同的vip_member記錄,那麼同樣的會發生數據覆蓋問題。那有什麼辦法能夠解決呢?難道要設置事務隔離級別爲SERIALIZABLE,考慮到性能不現實。
咱們知道InnoDB支持行鎖。查看MySQL官方文檔(innodb locking reads)瞭解到InnoDB在讀取行數據時能夠加兩種鎖:讀共享鎖和寫獨佔鎖。
讀共享鎖是經過下面這樣的SQL得到的:
SELECT * FROM parent WHERE NAME = 'Jones' LOCK IN SHARE MODE;
若是事務A得到了先得到了讀共享鎖,那麼事務B以後仍然能夠讀取加了讀共享鎖的行數據,但必須等事務A commit或者roll back以後才能夠更新或者刪除加了讀共享鎖的行數據。
寫獨佔鎖是經過SELECT...FOR UPDATE得到:
SELECT counter_field FROM child_codes FOR UPDATE; UPDATE child_codes SET counter_field = counter_field + 1;
若是事務A先得到了某行的寫獨佔鎖,那麼事務B就必須等待事務A commit或者roll back以後才能夠訪問行數據。
顯然要解決會員狀態更新問題,不能加讀共享鎖,只能加寫獨佔鎖,即將前面的SQL改寫成以下:
vipMember = SELECT * FROM vip_member WHERE uid=1001 LIMIT 1 FOR UPDATE # 查uid爲1001的會員 if vipMember.end_at < NOW(): UPDATE vip_member SET start_at=NOW(), end_at=DATE_ADD(NOW(), INTERVAL 1 MONTH), active_status=1, updated_at=NOW() WHERE uid=1001 else: UPDATE vip_member SET end_at=DATE_ADD(end_at, INTERVAL 1 MONTH), active_status=1, updated_at=NOW() WHERE uid=1001
另外這裏特別提醒下:UPDATE/DELETE SQL儘可能帶上WHERE條件並在WHERE條件中設定索引過濾條件,不然會鎖表,性能可想而知有多差了。
C、第三種方案:樂觀鎖,類CAS機制
第二種加鎖方案是一種悲觀鎖機制。並且SELECT...FOR UPDATE方式也不太經常使用,聯想到CAS實現的樂觀鎖機制,因而我想到了第三種解決方案:樂觀鎖。
具體來講也挺簡單,首先SELECT SQL不做任何修改,而後在UPDATE SQL的WHERE條件中加上SELECT出來的vip_memer的end_at條件。以下:
vipMember = SELECT * FROM vip_member WHERE uid=1001 LIMIT 1 # 查uid爲1001的會員 cur_end_at = vipMember.end_at if vipMember.end_at < NOW(): UPDATE vip_member SET start_at=NOW(), end_at=DATE_ADD(NOW(), INTERVAL 1 MONTH), active_status=1, updated_at=NOW() WHERE uid=1001 AND end_at=cur_end_at else: UPDATE vip_member SET end_at=DATE_ADD(end_at, INTERVAL 1 MONTH), active_status=1, updated_at=NOW() WHERE uid=1001 AND end_at=cur_end_at
這樣能夠根據UPDATE返回值來判斷是否更新成功,若是返回值是0則代表存在併發更新,那麼只須要重試一下就行了。
方案比較:
三種方案各自優劣也許衆說紛紜,只說說我本身的見解:
第一種方案利用一條比較複雜的SQL解決問題,不利於維護,由於把具體業務糅在SQL裏了,之後修改業務時不但須要讀懂這條SQL,還頗有可能會修改爲更復雜的SQL
第二種方案寫獨佔鎖,能夠解決問題,但不經常使用
第三種方案應該是比較中庸的解決方案,而且甚至能夠不加事務,也是我我的推薦的方案
此外,樂觀鎖和悲觀鎖的選擇通常是這樣的(參考了文末第二篇資料):
若是對讀的響應度要求很是高,好比證券交易系統,那麼適合用樂觀鎖,由於悲觀鎖會阻塞讀
若是讀遠多於寫,那麼也適合用樂觀鎖,由於用悲觀鎖會致使大量讀被少許的寫阻塞
若是寫操做頻繁而且衝突比例很高,那麼適合用悲觀寫獨佔鎖
參考資料: