分布式技術是從單體應用到微服務演進中必須要掌握的技能,希望借《分布式》這個專欄總結一些我對這方面的學習心得,同時分享一些我對分布式的思考。
背景
在項目中經常會遇到并發場景,比如最典型的秒殺場景,假設后端有一張庫存表,當大批請求過來時,都會經歷讀庫存+減庫存兩個步驟,比如庫存為100,兩個線程先分別讀到了庫存100,然后各自執行減1并寫回數據庫,結果數據庫數據為99而不是預期的98,這種情況如果不加以技術手段進行處理,很容易導致庫存超賣。
另一個場景的例子是本人之前做過的請假系統,后端有一張表存儲著請員工的可支配假期天數,當有多條請假申請被同時審批時,它們都要去執行讀假期+減假期的操作,如果兩個線程都先讀到了假期數據,然后分別執行減假期的操作,就很容易導致假期少扣的情況。
我將對這一開發中的典型場景總結一些我自己的思考。
思路一:SQL優化
為了具體模擬秒殺這一場景,假設庫存表叫做stock,商品數量叫做num,有業務代碼計算出的新數目為new_num=num-1,原來執行的SQL為:
update stock set num=new_num where id = id=#{id};
我們可以改進SQL,讓數據庫根據自己當前的值更新數據,比如寫成下面這樣:
update stock set num=num-1 where id = #{id};
這樣雖然看似解決了問題,但是假設此時庫存的數量為1,兩個線程各自減1后,會發現庫存被扣成了-1,同樣也無法完全解決并發問題。
思路二:代碼加鎖
既然兩個線程同時拿數據還是有問題,那么有沒有辦法讓它們依次執行,于是很容易想到在查庫存之前先加上鎖,比如Java的synchronized關鍵字。
這能很好的保證串行,保證每次只有一個線程去執行查庫存和扣庫存的代碼段,但同樣缺點也非常多:
- 無法做到細粒度的控制,如果有的人秒殺商品A、有的秒殺商品B,都要走秒殺方法,相互之間不沖突但是也只能串行執行,訪問會變得很慢。
- 因為spring里的@service是單例的,所以可以這么寫,如果用python語言的django框架,則沒法在代碼層做類似的控制,不通用。
- 只支持單點(單機、服務器環境),無法做到水平擴展,現在基本都是集群部署的,僅能一臺機器生效。
也許,我們需要一種第三方的機制,去實現這樣的一把鎖。
思路三:基于MySQL的悲觀鎖
因為所有的線程都在訪問同一張表,可以在數據庫上加鎖,典型的做法有悲觀鎖和樂觀鎖兩種。
悲觀鎖利用了select...for update 語法,例如:
select * from stock where id=#{id} for update;
注意:使用悲觀鎖要把業務代碼放在事務里。
悲觀鎖獲取數據時對數據行了鎖定,其他事務要想獲取鎖,必須等原事務結束。
使用selec...for update會把數據給鎖住,MySQL InnoDB默認Row-Level Lock,所以只有「明確」地指定主鍵,MySQL 才會執行Row lock (只鎖住被選取的數據) ,否則MySQL 將會執行Table Lock (將整個數據表單給鎖住)。
使用悲觀鎖的缺點是,因為要鎖表,所以并發性不高,如果并發過高,數據庫壓力過大,會宕機。
思路四:基于MySQL的樂觀鎖
樂觀鎖相對悲觀鎖而言,樂觀鎖假設認為數據一般情況下不會造成沖突,所以在數據進行提交更新的時候,才會正式對數據的沖突與否進行檢測,如果發現沖突了,則讓返回用戶錯誤的信息,讓用戶決定如何去做。
樂觀鎖的實現有兩種方法。
使用版本號實現樂觀鎖
版本號的實現方式有兩種,一個是數據版本機制,一個是時間戳機制。具體如下。
使用數據版本(Version)記錄機制實現,這是樂觀鎖最常用的一種實現方式。何謂數據版本?即為數據增加一個版本標識,一般是通過為數據庫表增加一個數字類型的 version 字段來實現。當讀取數據時,將version字段的值一同讀出,數據每更新一次,對此version值加一。當我們提交更新的時候,判斷數據庫表對應記錄的當前版本信息與第一次取出來的version值進行比對,如果數據庫表當前版本號與第一次取出來的version值相等,則予以更新,否則認為是過期數據。
如果使用樂觀鎖的話,扣庫存的SQL變化為下:
1.查詢出商品信息
select num,version from stock where id=#{id};
- 扣減數目。new_num = num -1
- 修改數據庫。
update stock set num=new_num, version=version+1 where id=#{id} and version=#{version};
如果此時的version已經變化了,則會更新失敗,可以根據具體業務采取重試或者報錯。
使用條件限制實現樂觀鎖
這個適用于只更新是做數據安全校驗,適合庫存模型,扣份額和回滾份額,性能更高,十分方便實用。
SQL改為:
update stock SET num = num - #{buyNum} where id = #{id} and num - #{buyNum} >= 0;
注意:樂觀鎖的更新操作,最好用主鍵或者唯一索引來更新,這樣是行鎖,否則更新時會鎖表。
樂觀鎖在扛并發能力上比悲觀鎖要高,應用會更多一些。但是當并發越來越高的話,對數據庫壓力也會比較大,因為如果更新失敗去重試,可能會不斷地去查詢數據庫。
思路五:基于Redis分布式鎖
既然數據庫的鎖實現永遠繞不開并發問題,我們就求助于第三方中間件,比如Redis。
這里提一下,如果在并發不高的情況下,基于MySQL的悲觀鎖或樂觀鎖能解決問題,而且維護起來比較簡單,不需要引入額外的組件,系統可用性高。所謂做架構,其實就是在做trade-off。
簡單講下實現原理,線程A先在Redis里設置該商品的key(比如goods_1)為1,如果線程B也查到1,則不但輪詢,直到線程A釋放掉這把鎖,線程B才能再拿到這把鎖。
我這里用Python語言簡單模擬了實現一下,用了redis-py的庫,寫了一個Lock類,里面包含兩個方法,獲取鎖acquire和釋放鎖release:
import redis
class Lock:
def __init__(self, name):
self.redis_client = redis.Redis(host="127.0.0.1")
self.name = name
def acquire(self):
# 如果為空或者為None那么代表獲取到鎖
if not self.redis_client.get(self.name):
self.redis_client.set(self.name, 1)
return True
else:
# 如果不想阻塞住輪詢獲取鎖,可以直接返回False
while True:
import time
time.sleep(1)
if self.redis_client.get(self.name):
return True
def release(self):
self.redis_client.delete(self.name)
思路六:Redis分布式鎖的問題和改進
確保原子性
有經驗的小伙伴一定會發現我上面代碼的問題,當并發非常高時,可能多個線程同時進入到if not self.redis_client.get(self.name)
,于是同時獲取到了鎖,這就使得分布式鎖失效了。
所以我們要保證在讀取和設置值這兩步的原子性。
如何解決呢?Redis天然提供了setnx
命令,可以保證原子操作,命令在指定的key不存在時,為key設置指定的值。
于是我們可以把上面的Lock代碼升級為如下:
import redis
class Lock:
def __init__(self, name):
self.redis_client = redis.Redis(host="127.0.0.1")
self.name = name
def acquire(self):
if self.redis_client.setnx(self.name, 1): # 如果不存在設置并且返回1,否在返回0, 這是原子操作
return True
else:
# 如果不想阻塞住輪詢獲取鎖,可以直接返回False
while True:
import time
time.sleep(1)
if self.redis_client.setnx(self.name, 1): # 注意這個地方也要改
return True
def release(self):
self.redis_client.delete(self.name)
鎖的過期時間
我們繼續往下思考,假如一個線程拿到了鎖,但是由于某些原因(比如網絡連不上redis,代碼異常,突然斷電)并未釋放鎖,導致其他線程都拿不到鎖,處于永遠等待的狀態,造成死鎖。
比較好的解決辦法就是設置給鎖設置過期時間,我們利用redis-py庫提供的set函數,并給加上原子操作和過期參數。
self.redis_client.set(self.name, self.id, nx=True, ex=15)
鎖的續租
但是,過期設置會產生新的問題:
如果當前的線程如果在一段時間后沒有執行完,然后key過期了,會導致另一個線程拿到鎖也會繼續執行,程序不安全。
另外,另一個線程有可能先執行完,會將當前鎖給釋放掉,釋放掉了本屬于原來的線程的鎖。
所以,如果當前的線程沒有執行完,那它還應該在適當的時候去續租,將過期時間重新設置。
比較經驗性的做法是,比如過期時間是15s,那就在15s的2/3的時候去續租,也就是運行10s以后去將過期時間重新設置為15s。可以在程序里啟動一個新的線程去完成去定時的完成這個續租的過程。
刪除鎖的安全性
前面還提到了一點,別的線程有把當前線程的鎖給釋放掉的風險。
我們可以給獲取鎖設置值的時候,不用1,而用uuid,并在釋放的時候判斷是否為當前線程的uuid,防止被其他線程給釋放掉。
于是分布式鎖的代碼現在進化成了下面這樣:
import uuid
import redis
class Lock:
def __init__(self, name, id=None):
self.id = uuid.uuid4()
self.redis_client = redis.Redis(host="192.168.0.104")
self.name = name
def acquire(self):
# 設置過期時間15s, 如果不存在設置并且返回1,否在返回0, 這是原子操作
if self.redis_client.set(self.name, self.id, nx=True, ex=15):
# 啟動一個線程然后去定時的刷新這個過期 這個操作最好也是使用lua腳本來完成
return True
else:
while True:
import time
time.sleep(1)
if self.redis_client.set(self.name, self.id, nx=True, ex=15):
return True
def release(self):
# 先做一個判斷,先取出值來然后判斷當前的值和你自己的lock中的id是否一致,如果一致刪除,如果不一致報錯
# 這塊代碼不安全, 將get和delete操作原子化,但是Redis可以使用lua腳本去完成這個操作使得該操作原子化
id = self.redis_client.get(self.name)
if id == self.id:
self.redis_client.delete(self.name)
else:
print("不能刪除不屬于自己的鎖")
我上面的代碼并不完整,因為想要get和delete操作原子化,Redis并沒有相關操作指令,仍然會出現安全問題,同樣在續租的過程里也會有這樣的問題。
但是,Redis 為這樣的用戶場景提供了 lua 腳本支持,用戶可以向服務器發送 lua 腳本來執行自定義動作,獲取腳本的響應數據。Redis 服務器會單線程原子性執行 lua 腳本,保證 lua 腳本在處理的過程中不會被任意其它請求打斷。
總結Redis分布式鎖
至此,我們總結一下分布式鎖需要解決的問題:
- 互斥性:任意時刻只能有一個客戶端擁有鎖,不能同時多個客戶端獲取
- 死鎖:獲取鎖的客戶端因為某些原因而宕機,而未能釋放鎖,其他客戶端無法獲取此鎖,需要有機制來避免該類問題的發生
- 比如代碼異常,導致無法運行到release
- 當前服務器網絡出問題,比如該release時,突然訪問不了Redis了
- 服務器斷電
- 安全性:鎖只能被持有該鎖的用戶刪除,而不能被其他用戶刪除
- 容錯:當部分節點宕機,客戶端仍能獲取鎖或者釋放鎖
思路七:Python第三方的開源庫
前面提到了需要用Lua腳本解決安全問題,但是lua腳本寫起來比較麻煩,這里我推薦網絡上一個第三方的開源庫,有非常完整的Python實現Redis的分布式鎖的方法。
github地址為:https://github.com/ionelmc/python-redis-lock
具體的實現可以參看 https://github.com/ionelmc/python-redis-lock/blob/master/src/redis_lock/init.py 這個文件
我在這里貼一下這個庫的流程圖:
這個庫的總體實現還是跟我們前面講的一致,但是有些細節上的差別。通過閱讀源碼,我總結如下:
- 為了減少無意義的輪詢,會另外建一個Redis的列表,使用了Redis的
blpop
指令,移出并獲取列表的第一個元素, 如果列表沒有元素會阻塞列表直到等待超時或發現可彈出元素為止。 - 續租那里比較難讀懂,簡單來講,它會啟動一個線程去做續租,假設鎖的時間是15s,續租檢查的間隔就是15s的2/3,即10s,續租做的事情,就是重新設置鎖的超時為15s,并且在10s后繼續進行檢查。
- 如果業務代碼產生阻塞,續租時可能造成代碼無限等待,需要慎用,所以該庫提供了可選參數。
- 使用了
__enter__
,__exit__
魔術方法,客戶端調用時支持with
實現。
至此,Redis鎖的分布式鎖部分已經全部介紹完了,它的優點是使用簡單,同時性能高,而且Redis也是非常常用的組件,不需要額外去維護。
此外,由于單機Redis也有掛掉的風險,我們在生產上會使用Redis cluster或Redis sentinel保證高可用。
思路八:基于zookeeper的分布式鎖
除了使用Redis實現,zookeeper也可以實現分布式鎖。但是如果項目中并未用到zookeeper,我覺得沒必要引入額外的組件,對這種方式,我就不多做介紹了。
思路九:Java中的Redission
如果是Java代碼,可以使用Redission包來實現分布式鎖。這里不多做介紹了,推薦一篇文章:《Redis分布式鎖》
示例代碼如下:
// 1.構造redisson實現分布式鎖必要的Config
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:5379").setPassword("123456").setDatabase(0);
// 2.構造RedissonClient
RedissonClient redissonClient = Redisson.create(config);
// 3.獲取鎖對象實例(無法保證是按線程的順序獲取到)
RLock rLock = redissonClient.getLock(lockKey);
try {
/**
* 4.嘗試獲取鎖
* waitTimeout 嘗試獲取鎖的最大等待時間,超過這個值,則認為獲取鎖失敗
* leaseTime 鎖的持有時間,超過這個時間鎖會自動失效(值應設置為大于業務處理的時間,確保在鎖有效期內業務能處理完)
*/
boolean res = rLock.tryLock((long)waitTimeout, (long)leaseTime, TimeUnit.SECONDS);
if (res) {
//成功獲得鎖,在這里處理業務
}
} catch (Exception e) {
throw new RuntimeException("aquire lock fail");
}finally{
//無論如何, 最后都要解鎖
rLock.unlock();
}
后續規劃
因時間關系,這篇文章里只集中講解了分布式鎖理論,并未涉及完整項目代碼,后續我會針對這一塊做一個完整的demo,來模擬秒殺場景,歡迎持續關注,一鍵三連~