拼多多二面:高并發(fā)場景扣減商品庫存如何防止超賣?
數(shù)據(jù)庫扣減
我們先來看下通過數(shù)據(jù)庫方式去實(shí)現(xiàn)。
因?yàn)橐乐顾u,所以要先把庫存鎖住,避免庫存還剩最后一個(gè)時(shí),多個(gè)線程同時(shí)去扣減成負(fù)數(shù)了。
圖片
但是這種方式顯而易見效率非常低下,因?yàn)檫@里加的悲觀鎖,讀請求也被阻塞了,我們知道大部分場景下都是讀多寫少,所以如何優(yōu)化呢?
很快小白想到了,可以通過樂觀鎖的方式實(shí)現(xiàn)。
樂觀鎖:事務(wù)不會(huì)在讀取數(shù)據(jù)時(shí)加鎖,而是繼續(xù)執(zhí)行后續(xù)操作,只有在提交數(shù)據(jù)時(shí)才會(huì)檢查數(shù)據(jù)是否已經(jīng)被其他事務(wù)修改,通常通過 版本號(hào)或時(shí)間戳來實(shí)現(xiàn)。
我們可以給庫存這條記錄加一個(gè)版本號(hào)字段 version,在更新庫存時(shí)判斷版本號(hào)是否一致,這樣也不會(huì)阻塞讀請求。
UPDATE product_inventory
SET stock = stock - :quantity,
version = version + 1
WHERE product_id = :productId
AND version = :version;
這種方式能滿足一般場景,但是假設(shè)在高并發(fā)的搶購活動(dòng)下,當(dāng)你壓測時(shí)發(fā)現(xiàn) TPS 怎么也提不上來。
高并發(fā)場景下使用樂觀鎖,一是其他請求拿不到版本號(hào)導(dǎo)致線程一直自旋等待中,甚至?xí)档拖到y(tǒng)的性能。二是數(shù)據(jù)庫的性能瓶頸。
這時(shí),你在想有沒有其他更好的方式呢?
Redis 扣減
既然數(shù)據(jù)庫無法滿足高并發(fā)性能,我們知道 Redis 單節(jié)點(diǎn)理論能支持幾萬級 TPS,而且我們還可以部署集群多節(jié)點(diǎn),這樣肯定能滿足了吧。
Redis 如何實(shí)現(xiàn)庫存扣減呢?
很快,你想到了,Redis 不是有一個(gè) INCRBY 的命令嗎?可以通過這個(gè)實(shí)現(xiàn)呀。
INCRBY product:1001:stock -10
但很快,測試時(shí)你又發(fā)現(xiàn)了問題,在場景下,這個(gè)庫存會(huì)被扣成負(fù)數(shù),這顯然是不能接受的。
那再加上鎖不就好了嗎,因?yàn)槭鞘枪?jié)點(diǎn)操作,我們想到通過加分布式鎖的方式。
圖片
同一時(shí)刻只有一個(gè)線程能獲取到鎖去執(zhí)行扣減,這樣肯定不會(huì)超賣了,但這種方式因?yàn)橹挥幸粋€(gè)線程能去扣減這個(gè)商品的庫存,顯然并發(fā)性能還有待提升。
我們可以不加鎖嗎?但判斷庫存是否大于 0 和扣減庫存是兩個(gè)指令,如何保證一致性呢?
Redis Lua 扣減
Lua:Redis 支持在服務(wù)器端執(zhí)行 Lua 腳本時(shí),腳本的所有操作都是原子執(zhí)行的,即腳本中的所有命令要么全部成功,要么全部失敗。
我們可以通過 Lua 的原子性來實(shí)現(xiàn),避免加鎖。
先獲取當(dāng)前庫存,判斷是否足夠,如果足夠再進(jìn)行扣減。
local stock = redis.call('get', KEYS[1]) -- 獲取當(dāng)前庫存
if not stock then
return nil -- 如果沒有找到庫存,返回nil
end
if tonumber(stock) >= tonumber(ARGV[1]) then -- 如果庫存足夠
redis.call('decrby', KEYS[1], ARGV[1]) -- 扣減庫存
return tonumber(stock) - tonumber(ARGV[1]) -- 返回扣減后的庫存
else
return nil -- 庫存不足,返回nil
end
如果這時(shí)老板看商品賣的很好,要后臺(tái)調(diào)增庫存怎么辦?
如果要調(diào)增庫存,為了防止多個(gè)線程同時(shí)調(diào)整庫存出現(xiàn)并發(fā)問題,這里要加分布式鎖,可以通過 SETNX 實(shí)現(xiàn)。
/**
* 增加庫存,使用分布式鎖確保并發(fā)安全
* @param productId 商品ID
* @param quantity 增加的數(shù)量
* @param lockValue 鎖的值,用于解鎖時(shí)進(jìn)行驗(yàn)證
* @param lockTimeout 鎖的超時(shí)時(shí)間
* @return 是否成功增加庫存
*/
public boolean increaseInventoryWithLock(String productId, int quantity, String lockValue, int lockTimeout) {
try (Jedis jedis = jedisPool.getResource()) {
// 獲取分布式鎖
String lockKey = "product_lock:" + productId;
boolean lockAcquired = acquireLock(jedis, lockKey, lockValue, lockTimeout);
if (lockAcquired) {
try {
// 增加庫存
jedis.incrBy("product:" + productId + ":stock", quantity);
return true;
} finally {
// 釋放鎖
releaseLock(jedis, lockKey, lockValue);
}
} else {
// 如果獲取不到鎖,可以返回 false 或進(jìn)行重試等操作
return false;
}
}
}
這樣,你想應(yīng)該就萬無一失了吧。
但是,如果你的商品賣得非常好,Redis 單節(jié)點(diǎn)也扛不住了,針對這種熱點(diǎn)商品怎么辦呢?
Redis 庫存分片
莫慌,別忘了我們 Redis 是多節(jié)點(diǎn)集群部署的,我們?nèi)绻堰@個(gè)熱點(diǎn)商品庫存拆分到每個(gè)節(jié)點(diǎn)上不就解決了嗎。
怎么拆分呢?
假設(shè)我們 Redis 有 12 個(gè)節(jié)點(diǎn),我們可以把商品庫存緩存 Key 再加個(gè)后綴 0,1,2....12 分布到每一個(gè)節(jié)點(diǎn)上,扣減時(shí)如果發(fā)現(xiàn)當(dāng)前節(jié)點(diǎn)沒庫存了,再扣除下個(gè)緩存 key。
當(dāng)然,如果每次都從節(jié)點(diǎn) 1 開始,熱點(diǎn)問題并沒有解決,我們可以設(shè)置一個(gè)隨機(jī)數(shù)組把順序打散,比如[1,2,......,12],[2,12......,1]。
圖片
這樣避免了該熱點(diǎn)商品的所有請求都打到同一個(gè)節(jié)點(diǎn)上的問題了。