讀后寫與更新丟失

前言

讀后寫是一個典型多變但又常見的場景.比如緩存更新.下單扣減庫存.這個場景下如果稍不注意就會寫出bug.而且bug并不是每次都出現(xiàn).排查的時候如果沒有這方面的經(jīng)驗,可能無從下手.

Java場景下的讀后寫

Code:

public class Counter {

    private static final Map<String, Integer> counter = Maps.newHashMap();

    public static void add(String key) throws InterruptedException {
       if (counter.containsKey(key)) {
           counter.put(key, counter.get(key) + 1);
       } else {
           Thread.sleep(1000);
           counter.put(key, 1);
       }
    }
}

這是一個簡單的給key計數(shù)的功能:

  1. 如果key存在就給value加1.
  2. 如果key不存在就設(shè)置為1.

這個看似完美的功能在單線程的時候可以很好的工作.不會有什么問題.

但是一到并發(fā)環(huán)境.就會遇到線程安全問題.

  1. 如果同時有兩個線程進入了方法.
  2. 這個key并沒有在counter中.那都會走counter.put(key, 1)這個方法.

結(jié)果就是本來應(yīng)該是2.但是記錄進去的只有1.if里的邏輯同理.

我們可以測試一下上面的代碼.

    public static void main(String[] args) throws InterruptedException {
        Runnable runnable = () ->{
            try {
                add("key");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        Thread t1 = new Thread(runnable);
        Thread t2 = new Thread(runnable);

        t1.start();
        t2.start();

        t1.join();
        t2.join();
        
        System.out.println(counter);
    }

代碼很簡單.兩個線程,都去調(diào)用add方法.然后主線程等待處理完成.打印counter.

結(jié)果

{key=1}

按道理兩個線程進去,應(yīng)該是2.不過很遺憾,結(jié)果是1.這就是典型的讀后寫場景.讀就是counter.containsKey(key)
寫就是counter.put(key, 1) 和 counter.put(key, counter.get(key) + 1)

出現(xiàn)這個問題的原因?qū)嶋H上是因為我們的讀和寫并不是原子操作.原子操作是指jvm層面是一個指令(Instruction).那如何保證讀和寫的原子性.最常用的方法就是加鎖.

實現(xiàn)代碼如下:

    public static synchronized void add(String key) throws InterruptedException {
        if (counter.containsKey(key)) {
            counter.put(key, counter.get(key) + 1);
        } else {
            Thread.sleep(1000);
            counter.put(key, 1);
        }
    }

再看輸出:

{key=2}

我們通過對方法加鎖來達到保證整個操作是原子的.
當然如果這樣add方法的性能會下降.但是卻保證了安全與正確.

這個例子比較簡單,以目前Java的普及程度.大家都會在寫代碼的時候考慮到代碼中的線程安全.但是這個簡單的模型在其他環(huán)境中,可能并不那么好識別.

總結(jié):單機模式下,多線程讀后寫,存在并發(fā)更新丟失問題

分布式緩存更新場景下的讀后寫

這是一個緩存服務(wù)的更新流程.按照01~05.當寫數(shù)據(jù)庫之后發(fā)消息到MQ.MQ推到Cache的更新服務(wù)上.更新服務(wù)查數(shù)據(jù).寫入到緩存.

這個場景和上面的場景相比,更加豐富.如果經(jīng)驗不足,也不容易聯(lián)想到上面那個場景.
先看下問題.04~05兩步是典型的讀后寫.沒有保證原子操作.

這會導(dǎo)致什么.

  1. 如果DB連著更新了兩次.消息被發(fā)送到兩臺機器或一臺機器的兩個處理線程.
  2. 后一次更新中查出來的新數(shù)據(jù)先被寫入了緩存.
  3. 前一次更新的數(shù)據(jù)后被寫入了緩存.

那實際上當前的Cache是有臟數(shù)據(jù)的.最后一次更新的數(shù)據(jù)丟了.這個問題發(fā)現(xiàn)的幾率要取決于是否同key頻繁寫.頻繁寫的情況下是否會出現(xiàn)后更新的先被寫進去.所以有時候問題時隱時現(xiàn).不好排查.

解決方案中最簡單的還是加鎖.
只不過在分布式場景下需要加分布式鎖.

這個坑我就踩過.查了好久.從db寫入.到發(fā)消息.到寫緩存.所有的日志都能串起來.就是數(shù)據(jù)不對.后來偶然想到了時序才想明白.

總結(jié):多機的分布式場景下.讀后寫容易被忽略.需要重視

DB的讀后寫場景

下單扣減庫存是一個典型的讀后寫場景.

  1. 我們從數(shù)據(jù)庫中讀取數(shù)據(jù):select * from product where id = 123;
  2. 校驗庫存是否夠用 if product.getInventory() > toOrder
  3. 如果夠用,我們就update product set product set inventory = inventory - #{toOrder} where id = 123;

熟悉的場景,熟悉的bug.

  1. 如果有兩個扣減請求過來.我們將數(shù)據(jù)從DB中讀取出來后.
  2. 在內(nèi)存中扣減的情況,
  3. 可能超賣.可能更新庫存數(shù)據(jù)錯誤.

這時候.有的開發(fā)可能覺得.這好辦.數(shù)據(jù)庫我加個事務(wù).不就解決了么.真的可以解決么?看情況.這要根據(jù)數(shù)據(jù)庫的隔離級別來分析.

一般情況下,mysql的隔離級別都使用repeatable read. 你讀取的時候如果另一個扣減事務(wù)沒提交.你是無法感知的.所以加事務(wù)并不能解決問題.但是加事務(wù).確實是正確解決路上的一個步驟.

延續(xù)我們上面的方案.我們想到的辦法應(yīng)該是加鎖.那鎖怎么加.
關(guān)于數(shù)據(jù)庫的更新丟失問題.有兩種解決方案.一種叫樂觀鎖.一種叫悲觀鎖.

樂觀鎖

樂觀鎖中,我們會引入一個類似版本號的概念.比如給每一行加入一個version.

  1. 假定.我們查出的數(shù)據(jù)version為1
  2. 那我們這么update:update product set version = version + 1 where version = 1
  3. 如果更新成功.說明中間數(shù)據(jù)沒有被修改.這次更新是成功的.如果失敗.說明數(shù)據(jù)被修改過.我們需要重新讀取數(shù)據(jù).進行操作.

那在我們這個扣減庫存的場景中.

  • 我們可以不用引入版本號.而使用庫存做版本號.
  • 再進一步.我們實際上并不需要嚴格按照版本號來做.可以使用inventory - #{toOrder} > 0.我們只要判斷,扣減之后是否庫存大于0.業(yè)務(wù)上就可以滿足需求.如果失敗.就下單失敗.

總結(jié):樂觀鎖不是真的鎖.而是使用一種機制來保證讀后寫的正確性.這種方式可能會大量重試.需要根據(jù)業(yè)務(wù)場景合理使用.

悲觀鎖

悲觀鎖,則類似于我們之前的處理辦法.不過我們是使用Mysql的鎖來實現(xiàn).

Mysql的Innodb存儲引擎支持行級鎖.并且有兩種,讀共享鎖和寫?yīng)氄兼i.

讀共享鎖在這個場景下并沒有用.所以直接看寫?yīng)氄兼i.

寫?yīng)氄兼i使用也很簡單.只需要在select 的語句后加上for update即可.

那我們的查詢sql就修改為

select * from product where id = 123 for update;

然后我們進行更新.提交事務(wù)就可以安全的完成這個工作了.

需要注意的是.整個操作要加事務(wù).

總結(jié):悲觀鎖是由數(shù)據(jù)庫的鎖來保證讀后寫的正確性

總結(jié)

讀后寫是一個很常見的模型.這個模型下可能有很多場景.需要我們提高識別能力.寫出正確的代碼.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末酝枢,一起剝皮案震驚了整個濱河市畜埋,隨后出現(xiàn)的幾起案子檩赢,更是在濱河造成了極大的恐慌,老刑警劉巖甩骏,帶你破解...
    沈念sama閱讀 211,743評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件愚臀,死亡現(xiàn)場離奇詭異瓦胎,居然都是意外死亡秃踩,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,296評論 3 385
  • 文/潘曉璐 我一進店門买喧,熙熙樓的掌柜王于貴愁眉苦臉地迎上來捻悯,“玉大人匆赃,你說我怎么就攤上這事〗窀浚” “怎么了算柳?”我有些...
    開封第一講書人閱讀 157,285評論 0 348
  • 文/不壞的土叔 我叫張陵,是天一觀的道長荚斯。 經(jīng)常有香客問我埠居,道長,這世上最難降的妖魔是什么事期? 我笑而不...
    開封第一講書人閱讀 56,485評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮纸颜,結(jié)果婚禮上兽泣,老公的妹妹穿的比我還像新娘。我一直安慰自己胁孙,他們只是感情好唠倦,可當我...
    茶點故事閱讀 65,581評論 6 386
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著涮较,像睡著了一般稠鼻。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上狂票,一...
    開封第一講書人閱讀 49,821評論 1 290
  • 那天候齿,我揣著相機與錄音,去河邊找鬼闺属。 笑死慌盯,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的掂器。 我是一名探鬼主播亚皂,決...
    沈念sama閱讀 38,960評論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼国瓮!你這毒婦竟也來了灭必?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,719評論 0 266
  • 序言:老撾萬榮一對情侶失蹤乃摹,失蹤者是張志新(化名)和其女友劉穎禁漓,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體峡懈,經(jīng)...
    沈念sama閱讀 44,186評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡璃饱,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,516評論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了肪康。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片荚恶。...
    茶點故事閱讀 38,650評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡撩穿,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出谒撼,到底是詐尸還是另有隱情食寡,我是刑警寧澤,帶...
    沈念sama閱讀 34,329評論 4 330
  • 正文 年R本政府宣布廓潜,位于F島的核電站抵皱,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏辩蛋。R本人自食惡果不足惜呻畸,卻給世界環(huán)境...
    茶點故事閱讀 39,936評論 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望悼院。 院中可真熱鬧伤为,春花似錦、人聲如沸据途。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,757評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽颖医。三九已至位衩,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間熔萧,已是汗流浹背糖驴。 一陣腳步聲響...
    開封第一講書人閱讀 31,991評論 1 266
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留哪痰,地道東北人遂赠。 一個月前我還...
    沈念sama閱讀 46,370評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像晌杰,于是被迫代替她去往敵國和親跷睦。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 43,527評論 2 349

推薦閱讀更多精彩內(nèi)容