新聞中心
1. 準備工作
1.1 理論基礎(chǔ)
并發(fā)時寫數(shù)據(jù),需要考慮要不要上鎖,根本原因是,數(shù)據(jù)存在共享且數(shù)據(jù)會發(fā)生變化,即多線程會同時讀寫同一數(shù)據(jù)。 若數(shù)據(jù)不存在共享,即不同的線程讀寫不同的數(shù)據(jù),不需要上鎖; 若數(shù)據(jù)共享,所有線程對數(shù)據(jù)只讀不寫,也不需要上鎖 若數(shù)據(jù)共享,有的線程讀數(shù)據(jù),有的線程寫數(shù)據(jù),則需要上鎖。

當多個線程同時訪問同一數(shù)據(jù),并且至少有一個線程會寫這個數(shù)據(jù),這種情況被稱之為“數(shù)據(jù)競爭”。
并發(fā)場景下,鎖的作用,是并發(fā)改為串行,以保證數(shù)據(jù)的一致性(更具體而言,通過上鎖,解決了并發(fā)執(zhí)行程序時的原子性、可見性和有序性問題,有興趣的同學可以近一步深入相關(guān)理論,本文以實戰(zhàn)為主,不在展開)。
故,在并發(fā)場景下讀寫數(shù)據(jù),首先要分析是否存在“數(shù)據(jù)競爭”問題,若存在,需要“上鎖”。數(shù)據(jù)競爭如果發(fā)生在本地應(yīng)用中,則用本地鎖;如果發(fā)生在分布式服務(wù)間中,則使用分布式鎖;本地鎖和分布式鎖在原理上相同,不用分別討論。
鎖相關(guān)技術(shù),有“悲觀鎖”和“樂觀鎖”兩種。
(1)悲觀鎖
我們通常說的鎖,如無特殊說明,就是指“悲觀鎖”。它是通過一些技術(shù)手段,實現(xiàn)線程或服務(wù)間的互斥和同步,其使用時,有顯示(或隱式)的鎖持有/釋放操作。 由于上鎖本身要損耗性能,上鎖后并發(fā)處理變成串行,故上鎖是比較影響系統(tǒng)性能的操作;且鎖的應(yīng)用不當,會潛在死鎖/活鎖風險;故悲觀鎖的使用要慎重。
(2)樂觀鎖
樂觀鎖通常又叫“無鎖技術(shù)”,它不是通過“上鎖”把并發(fā)改串行的方式保證數(shù)據(jù)一致性,而是通過CAS(Compare And Swap)方式來實現(xiàn),由于CAS通常很快,該過程也不用“上鎖”,性能損耗少。不過,通過CAS并發(fā)寫數(shù)據(jù)時,通常伴有“自旋”,即當出現(xiàn)多個寫并發(fā)時,只有一個能寫入成功,其他要自旋后再次寫入,直至寫入成功或因超時/超過重試次數(shù)失敗。 自旋會帶來性能開銷,頻繁自旋的性能開銷會超過上鎖。故樂觀鎖通常用在并發(fā)不太激烈的場景中,且在該場景下性能比悲觀鎖要好,而在高并發(fā)場景下,建議使用悲觀鎖。
1.2 業(yè)務(wù)場景及分析
本文主題是介紹并發(fā)寫數(shù)據(jù)的幾種方案,為此,我們先確立幾個常用的業(yè)務(wù)場景,并做簡單分析。
寫數(shù)據(jù),我們討論最常見的把數(shù)據(jù)寫入數(shù)據(jù)庫的場景,主要包括新建數(shù)據(jù)和修改數(shù)據(jù)兩種具體場景,兩個場景不完全一致,分別討論。
(1) 往數(shù)據(jù)庫寫新數(shù)據(jù)
往數(shù)據(jù)庫里寫信數(shù)據(jù)時,如果數(shù)據(jù)直接相互獨立,即不存在“數(shù)據(jù)競爭”,則按照1.1節(jié)的理論,此時不需要考慮鎖的問題。
對于存在“數(shù)據(jù)競爭”的場景,我們考慮寫入流水碼的場景:假設(shè)創(chuàng)建數(shù)據(jù)有個編碼字段,形如“CON_0001”,其后半段的“0001”是流水碼,需要根據(jù)當前最大的流水碼+1 來計算待創(chuàng)建數(shù)據(jù)的流水碼。這里存在數(shù)據(jù)范圍的競爭,并發(fā)創(chuàng)建數(shù)據(jù)時,如果不做并發(fā)控制,會創(chuàng)建多個編碼相同的數(shù)據(jù)。
(2)更新數(shù)據(jù)庫記錄
并發(fā)更新數(shù)據(jù)庫記錄時,如果可以確保各并發(fā)請求要更新的數(shù)據(jù)各不相同,則不存在“數(shù)據(jù)競爭”,不需要上鎖;而并發(fā)更新同一數(shù)據(jù)記錄時,如果不做并發(fā)控制,可能出現(xiàn)一個寫請求覆蓋另一個寫請求的情況,導致最終結(jié)果錯誤。
這里我們考慮“訪問量+1”的場景,即設(shè)計一個訪問量表,每次請求給訪問量+1,如當前訪問量為5,若5個并發(fā)請求同時為該記錄+1,正確的結(jié)果為10,但如果不加并發(fā)控制,結(jié)果通常會<10。
2. 并發(fā)插入流水碼的實現(xiàn)方案
2.1 業(yè)務(wù)邏輯分析
業(yè)務(wù)邏輯如下:
- 取出當前數(shù)據(jù)庫中流水碼最大的一條記錄
- 從編碼字段中解析出當前最大流水碼
- 流水碼+1,創(chuàng)建新紀錄入庫
代碼實現(xiàn)即:
private Integer addEntity() {
ConcurrentEntity entity = dataMapper.getLatestConcurrentEntity();
int nextNumber = entity == null ? 1 : getNextNumberByCode(entity.getCode());
String code = String.format("CON_%04d", nextNumber);
return dataMapper.insertConcurrentEntity(new ConcurrentEntity(code));
}
private int getNextNumberByCode(String code) {
int index = code.lastIndexOf("_");
String number = code.substring(index + 1);
return Integer.parseInt(number) + 1;
}
該業(yè)務(wù)在并發(fā)場景下,主要存在原子性隱患,即 addEntity() 中的代碼,需要作為一個整體全部執(zhí)行完,若多個線程交替執(zhí)行執(zhí)行逐行代碼,某個線程讀取到最新流水碼后,該碼被其他線程改了,本線程不可知,導致寫入錯誤數(shù)據(jù)。
故本業(yè)務(wù)場景的并發(fā)中,主要避免原子性和可見性問題,最直接的方式,是通過上鎖解決。
2.2 實現(xiàn)方案
2.2.1 方案1:在代碼中上鎖
private final Lock lock = new ReentrantLock(false);
public Integer addEntityByLock() {
synchronized (this) {
return addEntity();
}
}
public Integer addEntityByLock2() {
lock.lock();
try {
return addEntity();
} finally {
lock.unlock();
}
}
在分布式系統(tǒng)中,lock可以用redisson或curator提供的分布式鎖進行實例化。
2.2.2 方案2: 在數(shù)據(jù)庫中上鎖
鎖除了可以在代碼中用,也可以直接用到數(shù)據(jù)庫上, select ... for update 語句即可在數(shù)據(jù)庫中上寫鎖,另由于業(yè)務(wù)執(zhí)行的原子性問題,需要把 addEntity() 中的邏輯放到同一個事務(wù)中去。
@Transactional(isolation = Isolation.REPEATABLE_READ)
public Integer addEntityByTransactionWithLock() {
return addEntity();
}
這里的事務(wù)隔離級別,選擇RC或RR均可。
注,這個實現(xiàn)中,addEntity()中對 ConcurrentEntity 的查詢,改成了加鎖讀的方式:
@Select("SELECT * FROM concurrent_entity\n" +
"ORDER BY code DESC\n" +
"LIMIT 1\n" +
"FOR UPDATE;")
ConcurrentEntity getLatestConcurrentEntityWithWriteLock();
2.2.3 性能對比
對于1000個并發(fā)請求,三種方法性能對比如下:
executeConcurrentAddByLock1: 1000 并發(fā),花費時間 1179 ms
executeConcurrentAddByLock2: 1000 并發(fā),花費時間 863 ms
executeConcurrentAddByTransactionWithLock: 1000 并發(fā),花費時間 1284 ms
2.3 其他方案
本業(yè)務(wù)場景中,由于流水碼的計算,存在數(shù)據(jù)競爭問題,所以并發(fā)時需要上鎖,如果能避免數(shù)據(jù)競爭,就可以避免并發(fā)問題。針對本案例,可以把流水碼獲取的邏輯放到redis中去,redis本身是單線程的,避免了流水碼的數(shù)據(jù)競爭,進而避免了上鎖的開銷,而redis本身又是高性能的,故這個方案理論上比上述方案的性能只高不低。
3. 并發(fā)更新訪問量的實現(xiàn)方案
3.1 業(yè)務(wù)分析
并發(fā)更新數(shù)據(jù)庫中的訪問量時,存在的“數(shù)據(jù)競爭”問題,也是“原子性”隱患。如果更新本身是一個原子操作,則不存在并發(fā)問題;如果更新操作分兩步,先讀取當前數(shù)據(jù),然后+1后重新寫入,則該操作不是原子的,需要上鎖。
3.2 實現(xiàn)方案
3.2.1 方案1: 原子更新
public Integer increaseVisitCountAtomically(int id) {
return dataMapper.increaseConcurrentVisitAtomic(id);
}
@Update("UPDATE concurrent_visit\n" +
"SET visit = visit + 1, update_time = NOW()\n" +
"WHERE id = #{id};")
Integer increaseConcurrentVisitAtomic(int id);
3.2.2 方案2: 代碼中上鎖
public Integer increaseVisitCountByLock(int id) {
synchronized (this) {
return increaseVisitCount(id);
}
}
private Integer increaseVisitCount(int id) {
ConcurrentVisit concurrentVisit = dataMapper.getConcurrentVisitObject(id);
concurrentVisit.increaseVisit().updateUpdateTime();
return dataMapper.updateConcurrentVisit(concurrentVisit);
}
3.2.3 方案3: 數(shù)據(jù)庫中上鎖
@Transactional()
public Integer increaseVisitCountByTransaction(int id) {
return increaseVisitCount(id, true);
}
private Integer increaseVisitCount(int id, boolean withLock) {
ConcurrentVisit concurrentVisit = withLock ? dataMapper.getConcurrentVisitObjectWithLock(id)
: dataMapper.getConcurrentVisitObject(id);
return dataMapper.increaseConcurrentVisit(concurrentVisit.increaseVisit().updateUpdateTime());
}
@Select("SELECT * FROM concurrent_visit\n" +
"WHERE id = #{id}\n" +
"FOR UPDATE;")
ConcurrentVisit getConcurrentVisitObjectWithLock(int id);
3.2.4 方案4: 使用樂觀鎖
使用樂觀鎖時,需要一個遞增的版本字段( version ),每次update 成功時,version都要 +1,version要作為更新前的compare字段,若當前讀到的version與數(shù)據(jù)庫中的version不一致,則更新失敗。
public Integer increaseVisitCountOptimistically(int id) {
ConcurrentVisit concurrentVisit = dataMapper.getConcurrentVisitObject(id);
return dataMapper.increaseConcurrentVisitOptimistically(concurrentVisit.increaseVisit().updateUpdateTime());
}
@Update("UPDATE concurrent_visit\n" +
"SET visit = #{visit}, update_time = #{updateTime}, version = #{version} + 1\n" +
"WHERE id = #{id} and version = #{version};")
Integer increaseConcurrentVisitOptimistically(ConcurrentVisit concurrentVisit);
使用樂觀鎖時,compare不一致,會更新失敗,這時需要自旋重試,故上述代碼可以優(yōu)化為:
public Integer increaseVisitCountOptimisticallyWithRetry(int id) {
int result = 0;
int maxRetry = 5;
long interval = 20L;
for (int i = 0; i < maxRetry; i++) {
result = increaseVisitCountOptimistically(id);
if (result > 0) {
break;
}
interval = interval + i * 50;
helper.sleep(interval);
}
return result;
}
3.2.5 性能比較
發(fā)起10000個并發(fā)更新操作,結(jié)果如下:
executeConcurrentAddAtomically: 10000 并發(fā),花費時間 2112 ms
executeConcurrentAddByLock: 10000 并發(fā),花費時間 5796 ms
executeConcurrentAddByTransaction: 10000 并發(fā),花費時間 3902 ms
executeConcurrentAddOptimisticallyWithRetry: 10000 并發(fā),花費時間 5998 ms
mysql> select * from concurrent_visit;
+----+-------------+-------+---------+---------------------+---------------------+
| id | resourceKey | visit | version | create_time | update_time |
+----+-------------+-------+---------+---------------------+---------------------+
| 1 | resource1 | 39925 | 9925 | 2022-03-31 11:42:54 | 2022-04-01 12:06:36 |
+----+-------------+-------+---------+---------------------+---------------------+
可以看到:
- 并發(fā)比較激烈時,樂觀鎖性能最差,而且有些請求,即使超過了最大重試次數(shù),也沒更新成功
- 把鎖上在數(shù)據(jù)庫中,性能比所在代碼上還要好,原因是,數(shù)據(jù)庫上的鎖是經(jīng)過充分的性能優(yōu)化的,而且鎖的顆粒度更小,而我們這個業(yè)務(wù)場景下,代碼中鎖的顆粒度已經(jīng)很難再縮小了。鎖的顆粒度,決定了并發(fā)程度,并發(fā)場景下,鎖的顆粒度越小越好
網(wǎng)站標題:并發(fā)場景下數(shù)據(jù)寫入功能的實現(xiàn)
文章來源:http://fisionsoft.com.cn/article/cohdicc.html


咨詢
建站咨詢
