前言
讀后寫是一個典型多變但又常見的場景.比如緩存更新.下單扣減庫存.這個場景下如果稍不注意就會寫出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ù)的功能:
- 如果key存在就給value加1.
- 如果key不存在就設(shè)置為1.
這個看似完美的功能在單線程的時候可以很好的工作.不會有什么問題.
但是一到并發(fā)環(huán)境.就會遇到線程安全問題.
- 如果同時有兩個線程進入了方法.
- 這個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)致什么.
- 如果DB連著更新了兩次.消息被發(fā)送到兩臺機器或一臺機器的兩個處理線程.
- 后一次更新中查出來的新數(shù)據(jù)先被寫入了緩存.
- 前一次更新的數(shù)據(jù)后被寫入了緩存.
那實際上當前的Cache是有臟數(shù)據(jù)的.最后一次更新的數(shù)據(jù)丟了.這個問題發(fā)現(xiàn)的幾率要取決于是否同key頻繁寫.頻繁寫的情況下是否會出現(xiàn)后更新的先被寫進去.所以有時候問題時隱時現(xiàn).不好排查.
解決方案中最簡單的還是加鎖.
只不過在分布式場景下需要加分布式鎖.
這個坑我就踩過.查了好久.從db寫入.到發(fā)消息.到寫緩存.所有的日志都能串起來.就是數(shù)據(jù)不對.后來偶然想到了時序才想明白.
總結(jié):多機的分布式場景下.讀后寫容易被忽略.需要重視
DB的讀后寫場景
下單扣減庫存是一個典型的讀后寫場景.
- 我們從數(shù)據(jù)庫中讀取數(shù)據(jù):select * from product where id = 123;
- 校驗庫存是否夠用 if product.getInventory() > toOrder
- 如果夠用,我們就update product set product set inventory = inventory - #{toOrder} where id = 123;
熟悉的場景,熟悉的bug.
- 如果有兩個扣減請求過來.我們將數(shù)據(jù)從DB中讀取出來后.
- 在內(nèi)存中扣減的情況,
- 可能超賣.可能更新庫存數(shù)據(jù)錯誤.
這時候.有的開發(fā)可能覺得.這好辦.數(shù)據(jù)庫我加個事務(wù).不就解決了么.真的可以解決么?看情況.這要根據(jù)數(shù)據(jù)庫的隔離級別來分析.
一般情況下,mysql的隔離級別都使用repeatable read. 你讀取的時候如果另一個扣減事務(wù)沒提交.你是無法感知的.所以加事務(wù)并不能解決問題.但是加事務(wù).確實是正確解決路上的一個步驟.
延續(xù)我們上面的方案.我們想到的辦法應(yīng)該是加鎖.那鎖怎么加.
關(guān)于數(shù)據(jù)庫的更新丟失問題.有兩種解決方案.一種叫樂觀鎖.一種叫悲觀鎖.
樂觀鎖
樂觀鎖中,我們會引入一個類似版本號的概念.比如給每一行加入一個version.
- 假定.我們查出的數(shù)據(jù)version為1
- 那我們這么update:update product set version = version + 1 where version = 1
- 如果更新成功.說明中間數(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é)
讀后寫是一個很常見的模型.這個模型下可能有很多場景.需要我們提高識別能力.寫出正確的代碼.