文章目錄
引入
今天在知乎突然看到了這麼一個問題:
微信如何扛住 10 億用戶同時修改微信號?
微信號邏輯上應該是每一個微信賬號的 unique key,在以往的場景下寫入負載不是很高所以問題不大,但是現在允許修改微信號之後應該怎麼處理這麼大的併發寫入並且保證不衝突?
這是個很有趣的問題,題目裏的場景雖然在現實中不會出現,但是給了我們一個很好的啓示。
首先來看,題目主要就是說,在高併發下,有沒有可能有兩個用戶同時修改成了相同的用戶名?這個情況,如果不採用鎖的方式,在多線程下一定是會發生的。修改一個用戶名的過程是這樣的:
- 查找數據庫裏是否有一個用戶名B,如果有不讓修改,如果沒有就可以修改。
- 將用戶的用戶名由A修改成B。
我們知道,這是一個很經典的幻讀場景,事務在插入或者更新已經檢查過不存在的記錄時,驚奇的發現這些數據已經存在了。數據庫解決幻讀的方式很粗暴:Seriliazable_read,也就是採用串行的方式。
很顯然,在高併發下使用串行來解決幻讀是不可行的,那麼是否還有其他的方法呢?
一個方式就是限流:
只存賬號id和新微信號的key-value,全扔redis裏慢慢存數據庫,對外美名曰審覈若干工作日。
另一個很自然的方式就是使用鎖。當然在高併發下使用表鎖是不可行的。
只能是修改鎖的粒度。一個我想到最簡單的解決方案是增加一個Redis,用來修改:
- 查找數據庫裏是否有一個用戶名B,如果有不讓修改,如果沒有就可以修改。
- 將可以修改的用戶名B存入Redis中,如果Redis已經有了這個用戶名了,那麼就不可以修改了。如果Redis沒有這個用戶名B,以一個Expire_time存入Redis中。
- 將用戶的用戶名由A修改成B。
這樣做相當於一個行鎖了,也就是用Redis的原子性做一個保障。
當然上面只是我的設想,實際效用如何我也不知道。
修改鎖的粒度還有一種解決方案,那就是鎖分段技術。 比如,比如張三想修改A0爲B1,李四想修改C2爲D3。批量提交的數據,A0改爲B1的請求提交到 pair<A, B> 池子裏,C2改爲D3的請求提交到pair<C, D> 池子裏,每個池子一把鎖,相互獨立,於是就可以並行操作了。
要理解這個鎖分段技術,可以從HashMap到ConcurrentHashMap的轉變說起。
從HashMap到ConcurrentHashMap的轉變:理解鎖分段技術
從HashMap到ConcurrentHashMap,其實是因爲在併發情況下HashMap 出現了死循環,才導致必須得用帶鎖的ConcurrentHashMap(HashTable鎖住整個數組效率太低)。
那麼併發條件下,HashMap是怎麼出現死循環的呢?
HashMap的死循環
HashMap是非線程安全的,在併發場景中如果不保持足夠的同步,就有可能在執行HashMap.get時進入死循環,將CPU的消耗到100%。這個過程中,其實是HashMap的單鏈表產生了一個閉環,從而變成了循環鏈表。
具體一點說,是因爲 在多線程併發的情況下,在put操作的時候,如果size > initialCapacity * loadFactor,hash表進行擴容,那麼這時候HashMap就會進行rehash操作,隨之HashMap的結構就會很大的變化。很有可能就是在兩個線程在這個時候同時觸發了rehash操作,產生了閉合的迴路。然後通過get操作的時候,觸發了死循環。
下面是詳細的底層代碼原理的分析:
Java的HashMap的擴容源代碼。
void resize(int newCapacity)
{
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
......
//創建一個新的Hash Table
Entry[] newTable = new Entry[newCapacity];
//將Old Hash Table上的數據遷移到New Hash Table上
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
上面的代碼沒有什麼好說的,就是創建了一個新的Entry數組,然後調用了transfer函數,重新計算Hash,將舊的數據遷移到新的數組上,所以關鍵還是要看transfer()
。
void transfer(Entry[] newTable)
{
Entry[] src = table;
int newCapacity = newTable.length;
//下面這段代碼的意思是:
// 從OldTable裏摘一個元素出來,然後放到NewTable中
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
在正常情況下,這個函數應該是這樣運作的:
注意上圖裏的當前值。
而在併發下的Rehash可能產生下面的情況:
假設我們有兩個線程,我們再回頭看一下我們的 transfer代碼中的這個細節:
do {
Entry<K,V> next = e.next; // <--假設線程一執行到這裏就被調度掛起了
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
而我們的線程二執行完成了。於是我們有下面的這個樣子。
注意,因爲Thread1的 e 指向了key(3),而next指向了key(7),其在線程二rehash後,指向了線程二重組後的鏈表,而不是原來指向的位置(比如上圖,之前指向的是哈希桶1的e和next,現在指向了哈希桶3的e和next)。
線程二執行完畢後,線程一被調度回來執行。
- 先是執行 newTalbe[i] = e;
- 然後是e = next,導致了e指向了key(7),
- 而下一次循環的next = e.next導致了next指向了key(3)
- 線程一接着工作。把key(7)摘下來,放到newTable[i]的第一個,然後把e和next往下移。
- 環形鏈接出現。
e.next = newTable[i] 導致 key(3).next 指向了 key(7)
注意:此時的key(7).next 已經指向了key(3), 環形鏈表就這樣出現了。
於是,當我們的線程一調用到,HashTable.get(11)時,悲劇就出現了——Infinite Loop。
ConcurrentHashMap的實現方式—鎖桶(或段)
ConcurrentHashMap的讀取併發,因爲在讀取的大多數時候都沒有用到鎖定,所以讀取操作幾乎是完全的併發操作,而寫操作鎖定的粒度又非常細。只有在求size等操作時才需要鎖定整個表。
而在迭代時,ConcurrentHashMap使用了不同於傳統集合的快速失敗迭代器的弱一致迭代器。在這種迭代方式中,當iterator被創建後集合再發生改變就不再是拋出ConcurrentModificationException,取而代之的是在改變時new新的數據從而不影響原有的數據,iterator完成後再將頭指針替換爲新的數據,這樣iterator線程可以使用原來老的數據,而寫線程也可以併發的完成改變,更重要的,這保證了多個線程併發執行的連續性和擴展性,是性能提升的關鍵。
HashTable容器使用synchronized來保證線程安全,但在線程競爭激烈的情況下HashTable的效率非常低下。因爲當一個線程訪問HashTable的同步方法時,其他線程訪問HashTable的同步方法時,可能會進入阻塞或輪詢狀態。如線程1使用put進行添加元素,線程2不但不能使用put方法添加元素,並且也不能使用get方法來獲取元素,所以競爭越激烈效率越低。
HashTable容器在競爭激烈的併發環境下表現出效率低下的原因,是因爲所有訪問HashTable的線程都必須競爭同一把鎖,那假如容器裏有多把鎖,每一把鎖用於鎖容器其中一部分數據,那麼當多線程訪問容器裏不同數據段的數據時,線程間就不會存在鎖競爭,從而可以有效的提高併發訪問效率。
這就是ConcurrentHashMap所使用的鎖分段技術,首先將數據分成一段一段的存儲,然後給每一段數據配一把鎖,當一個線程佔用鎖訪問其中一個段數據的時候,其他段的數據也能被其他線程訪問。
從上面看出,ConcurrentHashMap定位一個元素的過程需要進行兩次Hash操作,第一次Hash定位到Segment,第二次Hash定位到元素所在的鏈表的頭部,因此,這一種結構的帶來的副作用是Hash的過程要比普通的HashMap要長,但是帶來的好處是寫操作的時候可以只對元素所在的Segment進行加鎖即可,不會影響到其他的Segment。
ConcurrentHashMap的真實結構
ConcurrentHashMap是由Segment數組結構和HashEntry數組結構組成。
ConcurrentHashMap的主幹是個Segment數組。Segment繼承了ReentrantLock,所以它就是一種可重入鎖(ReentrantLock)。在ConcurrentHashMap,一個Segment就是一個子哈希表,Segment裏維護了一個HashEntry數組,併發環境下,對於不同Segment的數據進行操作是不用考慮鎖競爭的。
CurrentHashMap的初始化一共有三個參數,一個initialCapacity,表示初始的容量,一個loadFactor,表示負載參數,最後一個是concurrentLevel,代表ConcurrentHashMap內部的Segment的數量,ConcurrentLevel一經指定,不可改變,後續如果ConcurrentHashMap的元素數量增加導致ConrruentHashMap需要擴容,ConcurrentHashMap不會增加Segment的數量,而只會增加Segment中鏈表數組的容量大小,這樣的好處是擴容過程不需要對整個ConcurrentHashMap做rehash,而只需要對Segment裏面的元素做一次rehash就可以了。
Segment數組:
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
/**
* 在本 segment 範圍內,包含的 HashEntry 元素的個數
* 該變量被聲明爲 volatile 型,保證每次讀取到最新的數據
*/
transient volatile int count;
/**
*table 被更新的次數
*/
transient int modCount;
/**
* 當 table 中包含的 HashEntry 元素的個數超過本變量值時,觸發 table 的再散列
*/
transient int threshold;
/**
* table 是由 HashEntry 對象組成的數組
* 如果散列時發生碰撞,碰撞的 HashEntry 對象就以鏈表的形式鏈接成一個鏈表
* table 數組的數組成員代表散列映射表的一個桶
* 每個 table 守護整個 ConcurrentHashMap 包含桶總數的一部分
* 如果併發級別爲 16,table 則守護 ConcurrentHashMap 包含的桶總數的 1/16
*/
transient volatile HashEntry<K,V>[] table;
/**
* 裝載因子
*/
final float loadFactor;
}
HashEntry:
static final class HashEntry<K,V> {
final K key; // 聲明 key 爲 final 型
final int hash; // 聲明 hash 值爲 final 型
volatile V value; // 聲明 value 爲 volatile 型
final HashEntry<K,V> next; // 聲明 next 爲 final 型
HashEntry(K key, int hash, HashEntry<K,V> next, V value) {
this.key = key;
this.hash = hash;
this.next = next;
this.value = value;
}
}
可以看到除了value不是final的,其它值都是final的。
這意味着不能從hash鏈的中間或尾部添加或刪除節點,因爲這需要修改next引用值,所有的節點的修改只能從頭部創建一個新的HashEntry開始(next指向之前的頭部)。
- 對於put操作,可以一律添加到Hash鏈的頭部。
- 但是對於remove操作,可能需要從中間刪除一個節點,這就需要將要刪除節點的前面所有節點整個複製一遍,最後一個節點指向要刪除結點的下一個結點,爲了確保讀操作能夠看到最新的值,將value設置成volatile,這避免了加鎖。
分段鎖優化超賣問題
之前在Java架構直通車——訂單扣庫存問題一文中使用了分佈式鎖。
@Transactional(propagation=Propagation.REQUIRED)
@Override
public void decreaseItemSpecStock(String specId,Integer buysCounts){
lockUtil().getLock(); //--分佈式加鎖
//1.查詢庫存
int stock=10;//假設查詢數據庫後,其值爲10.
//2.判斷庫存,是否能夠扣除
if(stock<buysCounts){
//提示用戶庫存不夠
}
//3.扣庫存
...
lockUtil().unLock();//--分佈式解鎖
}
分佈式鎖一旦加了之後,對同一個商品的下單請求,會導致所有客戶端都必須對同一個商品的庫存鎖key進行加鎖。這樣會導致對同一個商品的下單請求,就必須串行化,一個接一個的處理。
假設加鎖之後,釋放鎖之前,查庫存 -> 創建訂單 -> 扣減庫存,這個過程性能很高吧,算他全過程20毫秒,這應該不錯了。那麼1秒是1000毫秒,只能容納50個對這個商品的請求依次串行完成處理。
這種方案,要是應對那種低併發、無秒殺場景的普通小電商系統,可能還可以接受。但是對於高併發,顯得不足了。
對分佈式鎖進行高併發優化,就可以使用分段鎖的思路。
假如你現在iphone有1000個庫存,那麼你完全可以給拆成20個庫存段,要是你願意,可以在數據庫的表裏建20個庫存字段,每個庫存段是50件庫存,比如stock_01對應50件庫存,stock_02對應50件庫存。類似這樣的,也可以在redis之類的地方放20個庫存key。
接着,1000個/s 請求,用一個簡單的隨機算法,每個請求都是隨機在20個分段庫存裏,選擇一個進行加鎖。
一旦對某個數據做了分段處理之後,有一個坑一定要注意:就是如果某個下單請求,咔嚓加鎖,然後發現這個分段庫存裏的庫存不足了,此時咋辦?
這時你得自動釋放鎖,然後立馬換下一個分段庫存,再次嘗試加鎖後嘗試處理。 這個過程一定要實現。
分佈式鎖併發優化方案的不足
最大的不足,很不方便,實現太複雜。
首先,你得對一個數據分段存儲,一個庫存字段本來好好的,現在要分爲20個分段庫存字段;
其次,你在每次處理庫存的時候,還得自己寫隨機算法,隨機挑選一個分段來處理;
最後,如果某個分段中的數據不足了,你還得自動切換到下一個分段數據去處理。
這個過程都是要手動寫代碼實現的,還是有點工作量,挺麻煩的。
不過我們確實在一些業務場景裏,因爲用到了分佈式鎖,然後又必須要進行鎖併發的優化,又進一步用到了分段加鎖的技術方案,效果當然是很好的了,一下子併發性能可以增長几十倍。
以我們本文所說的庫存超賣場景爲例,你要是這麼玩,會把自己搞的很痛苦!
再次強調,我們這裏的庫存超賣場景,僅僅只是作爲演示場景而已,以後有機會,再單獨聊聊高併發秒殺系統架構下的庫存超賣的其他解決方案。