MySQL中SELECT+UPDATE併發更新問題

問題背景: 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

  • 第二種方案寫獨佔鎖,能夠解決問題,但不經常使用

  • 第三種方案應該是比較中庸的解決方案,而且甚至能夠不加事務,也是我我的推薦的方案

此外,樂觀鎖和悲觀鎖的選擇通常是這樣的(參考了文末第二篇資料):

  • 若是對讀的響應度要求很是高,好比證券交易系統,那麼適合用樂觀鎖,由於悲觀鎖會阻塞讀

  • 若是讀遠多於寫,那麼也適合用樂觀鎖,由於用悲觀鎖會致使大量讀被少許的寫阻塞

  • 若是寫操做頻繁而且衝突比例很高,那麼適合用悲觀寫獨佔鎖


參考資料:

innodb locking reads

MVCC在分佈式系統中的應用

相關文章
相關標籤/搜索