電商防超賣(mài)的 N+1 個(gè)坑!
點(diǎn)擊關(guān)注公眾號(hào),Java干貨及時(shí)送達(dá)
今天和同事討論庫(kù)存防超賣(mài)問(wèn)題,發(fā)現(xiàn)雖然只是簡(jiǎn)單的庫(kù)存扣減場(chǎng)景,卻隱藏著很多坑,一不小心就容易翻車,讓西瓜推土機(jī)來(lái)填平這些坑。
單實(shí)例環(huán)境
一般電商體系防止庫(kù)存超賣(mài),主要有以下幾種方式:
防止庫(kù)存超賣(mài),最先想到的可能就是「鎖」,如果是一些單實(shí)例部署的庫(kù)存服務(wù),大部分情況下我們可以使用以下鎖或并發(fā)工具類:
這三個(gè)任何一個(gè)都可以保證同一單位時(shí)間只有一個(gè)線程能夠進(jìn)行庫(kù)存扣減,廢話不多說(shuō),上碼!
/**
* 庫(kù)存扣減(偽代碼 ReentrantLock )
* @param stockRequestDTO
* @return Boolean
*/
public Boolean stockHandle(StockRequestDTO stockRequestDTO) {
// 日志打印...校驗(yàn)...前置處理等...
int stock = stockMapper.getStock(stockRequestDTO.getGoodsId());
reentrantLock.lock();
try {
int result = stock > 0 ?
stockMapper.updateStock(stockRequestDTO.getGoodsId(), --stock) : 0;
return result > 0 ? true : false;
} catch (SQLException e) {
// 異常日志打印及處理...
return false;
} finally {
reentrantLock.unlock();
}
}
/**
* 庫(kù)存扣減(偽代碼 synchronized )
* @param stockRequestDTO
* @return Boolean
*/
public synchronized Boolean stockHandle(StockRequestDTO stockRequestDTO){
// 執(zhí)行業(yè)務(wù)邏輯...
}
/**
* 庫(kù)存扣減(偽代碼 Semaphore )
* @param stockRequestDTO
* @return Boolean
*/
public Boolean stockHandle(StockRequestDTO stockRequestDTO) {
try{
semaphore.acquire();
// 執(zhí)行業(yè)務(wù)邏輯...
} finally {
semaphore.release();
}
}
如果你的項(xiàng)目是單實(shí)例部署,那么使用以上鎖或并發(fā)工具中的一種,都可以有效的防止超賣(mài)出現(xiàn)。
分布式環(huán)境
但現(xiàn)在的互聯(lián)網(wǎng)公司,基本都是負(fù)載均衡的方式,訪問(wèn)集群中多個(gè)實(shí)例的,所以基于JVM級(jí)別的鎖無(wú)法發(fā)揮作用,需要引入第三方組件來(lái)解決,分布式鎖登場(chǎng)。46張PPT弄懂JVM,這個(gè)分享給你。
如果想實(shí)現(xiàn)分布式環(huán)境下的鎖機(jī)制,最簡(jiǎn)單的莫過(guò)于利用MySQL的鎖機(jī)制:
*** 使用悲觀鎖實(shí)現(xiàn) ***
begin; -- 開(kāi)啟事務(wù)
select stock_num from t_stock t_stock where goodsId = '12345' for update; -- 獲取并設(shè)置排他鎖
update t_stock set stock_num = stock_num - 1 where goodsId = '12345' ;-- 更新資源
commit; -- 提交事務(wù)并解鎖
*** 樂(lè)觀鎖實(shí)現(xiàn) ***
update t_stock set stock_num = stock_num - 1 , version = version + 1 where goodsId = '12345' and version = 7;
-- 1.更新資源時(shí)先判斷當(dāng)前數(shù)據(jù)版本號(hào)和之前獲取時(shí)是否一致
-- 2.如果版本號(hào)一致,更新資源并版本號(hào)+1
-- 3.若版本號(hào)不一致,返回錯(cuò)誤并由業(yè)務(wù)系統(tǒng)進(jìn)行自旋重試
*** 唯一索引實(shí)現(xiàn) ***
較簡(jiǎn)單,此方式實(shí)際應(yīng)用幾乎沒(méi)有,不再贅述
有一點(diǎn)要注意,樂(lè)觀鎖的自旋是需要在自己的業(yè)務(wù)邏輯中實(shí)現(xiàn)的。
使用數(shù)據(jù)庫(kù)作為分布式鎖,優(yōu)點(diǎn)是實(shí)現(xiàn)簡(jiǎn)單、不需要引入其他中間件,缺點(diǎn)是可能存在磁盤(pán)IO,性能一般。
那有沒(méi)有性能夠用、實(shí)現(xiàn)簡(jiǎn)單、且在分布式環(huán)境下能保證資源并發(fā)安全的方案呢?常規(guī)有三,Redis、Zookeeper、MQ,其中MQ的解決方案不能算分布式鎖。
今天我們介紹第一種,使用Redis實(shí)現(xiàn)分布式鎖,Redis分布式鎖的特點(diǎn)是輕松保證可重入、互斥。
Redis中提供了SetNX+Expire兩個(gè)命令,可以對(duì)指定的Key加鎖:
// redis原生命令
redis-cli 127.0.0.1:6379> SETNX KEY_NAME VALUE
redis-cli 127.0.0.1:6379> EXPlRE <key> <ttl>
spring-boot-starter-data-redis 也提供了操作Redis的模板類:
/**
* 庫(kù)存扣減 (偽代碼 spring-boot-starter-data-redis中提供的模板方法)
* @param stockRequestDTO
* @return Boolean
*/
@Transactional(rollbackFor = {RuntimeException.class, Error.class})
public Boolean stockHandle(StockRequestDTO stockRequestDTO) {
// 省略日志打印...校驗(yàn)...前置處理等...
try {
Boolean redisLock = redisTemplate
.opsForValue()
.setIfAbsent(stockLockKey, true);
if (redisLock) {
redisTemplate.expire((stockLockKey,1,TimeUnit.SECONDS);
Object stock = redisTemplate
.opsForValue()
.get(stockKeyPrefix.concat(stockRequestDTO.getGoodsId()));
if (null == stock || Integer.parseInt(stock.toString()) <= 0) {
// 庫(kù)存異常
return false;
} else {
// 扣減庫(kù)存
stock = Integer.parseInt(stock.toString()) - 1;
// 更新數(shù)據(jù)庫(kù)、緩存等...
return true;
}
} else {
return false;
}
} finally {
// 釋放鎖等等后置處理...
redisTemplate.delete(stockLockKey);
}
}
原子性問(wèn)題
以上代碼存在一個(gè)問(wèn)題,假設(shè)
所以我們需要使用原子指令:
// redis原生命令
redis-cli 127.0.0.1:6379> set key value [EX seconds] [PX milliseconds] [NX|XX]
// 方式一:省略其他代碼......
boolean redisLock = redisTemplate
.opsForValue()
.setIfAbsent(stockLockKey,true,30, TimeUnit.SECONDS);
// 方式二:使用Lua腳本進(jìn)行加鎖保證原子性(偽代碼)
static {
StringBuilder sb = new StringBuilder();
sb.append("if redis.call(\"setnx\", KEYS[1], KEYS[2]) == 1 then");
sb.append("return redis.call(\"pexpire\", KEYS[1], KEYS[3])");
sb.append("else");
sb.append("return 0");
LUA_SCRIPT = sb.toString();
}
private Long redisLockByLua(String key, int num) {
// 腳本里的KEYS參數(shù),忽略類型轉(zhuǎn)換等......
List<Object> keys = new ArrayList<>();
keys.add(stockLockKey);
keys.add(true);
keys.add(30);
return (long) redisTemplate.execute(new DefaultRedisScript(LUA_SCRIPT), keys);
}
超時(shí)&誤刪鎖問(wèn)題
雖然我們優(yōu)化了,但還是有BUG,假設(shè)現(xiàn)在A 和 B 兩個(gè)線程同時(shí)訪問(wèn)以上代碼:
這種現(xiàn)象還是比較容易發(fā)生的,對(duì)于鎖超時(shí)問(wèn)題,我們加以優(yōu)化:
對(duì)于誤刪鎖的問(wèn)題,我們也可以加強(qiáng)優(yōu)化一下:
/**
* 庫(kù)存扣減 (偽代碼 spring-boot-starter-data-redis中提供的模板方法)
* @param stockRequestDTO
* @return Boolean
*/
@Transactional(rollbackFor = {RuntimeException.class, Error.class})
public Boolean stockHandle(StockRequestDTO stockRequestDTO) {
// 省略日志打印...校驗(yàn)...前置處理等...
String nonceStr = UUID.randomUUID().toString();
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
try {
//競(jìng)爭(zhēng)鎖
boolean redisLock = redisTemplate
.opsForValue()
.setIfAbsent(stockLockKey, true, 30, TimeUnit.SECONDS);
if (redisLock) {
// 開(kāi)啟守護(hù)線程定時(shí)對(duì)鎖續(xù)命
scheduledExecutorService.scheduleWithFixedDelay(() -> {
if (redisTemplate.hasKey(stockLockKey)) {
redisTemplate.expire(stockLockKey, 30, TimeUnit.SECONDS);
}
}, 15, 15, TimeUnit.SECONDS);
// 獲取庫(kù)存
String stockKey = stockKeyPrefix.concat(stockRequestDTO.getGoodsId());
Object stock = redisTemplate
.opsForValue()
.get(stockKey);
if (null == stock || Integer.parseInt(stock.toString()) <= 0) {
// 庫(kù)存異常
return false;
} else {
// 扣減庫(kù)存
redisTemplate.opsForValue()
.set(stockKey,Integer.parseInt(stock.toString()) - 1);
// 更新數(shù)據(jù)庫(kù)等...
return true;
}
} else {
return false;
}
} finally {
// 釋放鎖等等后置處理... 也可使用lua腳本保證原子性判斷和刪除鎖
if (redisTemplate.opsForValue().get(stockLockKey).equals(nonceStr)) {
redisTemplate.delete(stockLockKey);
}
scheduledExecutorService.shutdownNow();
}
}
釋放鎖也需要原子性執(zhí)行,我們依然使用Lua腳本來(lái)保證原子:
// 解鎖 (偽代碼)
static {
StringBuilder sb = new StringBuilder();
sb.append("if redis.call(\"get\",KEYS[1]) == KEYS[2] then");
sb.append("return redis.call(\"del\",KEYS[1])");
sb.append("else");
sb.append("return -1");
LUA_SCRIPT = sb.toString();
}
private Long redisUnlockByLua(String key, int num) {
// 腳本里的KEYS參數(shù),忽略類型轉(zhuǎn)換等...
List<Object> keys = new ArrayList<>();
keys.add(stockLockKey);
keys.add("線程加鎖時(shí)生成的隨機(jī)數(shù)");
return (long) redisTemplate.execute(new DefaultRedisScript(LUA_SCRIPT), keys);
}
Redisson
對(duì)于鎖超時(shí)問(wèn)題,我們還可以使用現(xiàn)成的工具Redisson,Redisson提供了WatchDog(看門(mén)狗)機(jī)制,內(nèi)置了鎖續(xù)命機(jī)制,無(wú)需手動(dòng)實(shí)現(xiàn)。
注意,要想使用開(kāi)門(mén)狗機(jī)制Redisson加鎖時(shí)不要指定超時(shí)時(shí)間,默認(rèn)鎖超時(shí)時(shí)間30秒,看門(mén)狗每隔30秒的1/3時(shí)間也就是10秒去檢查一次鎖狀態(tài),鎖還在就進(jìn)行續(xù)命。
// 構(gòu)造Redisson Config
Config config = new Config();
config.useClusterServers().addNodeAddress(
"redis://ip1:port1","redis://ip2:port2", "redis://ip3:port3",
"redis://ip4:port4","redis://ip5:port5", "redis://ip6:port6")
.setPassword("a123456").setScanInterval(5000);
// 構(gòu)造RedissonClient
RedissonClient redissonClient = Redisson.create(config);
// 設(shè)置鎖定資源名稱
RLock rLock = redissonClient.getLock("lock_key");
// boolean isLock;
try {
// 嘗試獲取分布式鎖
// isLock = rLock.tryLock(500, 15000, TimeUnit.MILLISECONDS);
rLock.lock();
// 日志...業(yè)務(wù)處理等...
} catch (Exception e) {
// 日志等...
} finally {
// 解鎖
rLock.unlock();
}
主從數(shù)據(jù)一致性問(wèn)題
不過(guò)此時(shí)還可能出現(xiàn)一種意外情況,假設(shè)Redis主從環(huán)境:
1.)A線程在Redis Master節(jié)點(diǎn)獲得了鎖,還沒(méi)同步給Slave
2.)Master節(jié)點(diǎn)掛掉
3.)故障轉(zhuǎn)移后Slave節(jié)點(diǎn)升級(jí)為Master節(jié)點(diǎn)
4.)此時(shí)B線程將競(jìng)爭(zhēng)到鎖,至此A和B同時(shí)對(duì)加鎖任務(wù)并行執(zhí)行,業(yè)務(wù)語(yǔ)義發(fā)生錯(cuò)誤,可能導(dǎo)致各種臟數(shù)據(jù)產(chǎn)生
要解決這個(gè)問(wèn)題,可以使用Redis官方提供的RedLock算法。
簡(jiǎn)單來(lái)說(shuō)RedLock 的思想是使用多臺(tái)Redis Master,節(jié)點(diǎn)完全獨(dú)立,節(jié)點(diǎn)間不需要進(jìn)行數(shù)據(jù)同步。
假設(shè)N個(gè)節(jié)點(diǎn),在有效時(shí)間內(nèi)當(dāng)獲得鎖的數(shù)量大于 (N/2+1) 代表成功,失敗后需要向所有節(jié)點(diǎn)發(fā)送釋放鎖的消息。
RedLock的方式也有缺點(diǎn),因?yàn)樾枰獙?duì)多個(gè)節(jié)點(diǎn)操作加鎖解鎖,高并發(fā)情況下,耗時(shí)較長(zhǎng)響應(yīng)延遲,對(duì)性能有影響。
后面我們找個(gè)時(shí)間專門(mén)講一下RedLock源碼以及存在的問(wèn)題。
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://ip1:port1").setPassword("...").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://ip2:port2").setPassword("...").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);
Config config3 = new Config();
config3.useSingleServer().setAddress("redis://ip3:port3").setPassword("...").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);
String lockKey = "lock_key";
RedissonRedLock redLock = new RedissonRedLock(
redissonClient1.getLock(lockKey),
redissonClient2.getLock(lockKey),
redissonClient3.getLock(lockKey)
);
// boolean isLock;
try {
// 嘗試獲取分布式鎖
// isLock = redLock.tryLock(500, 15000, TimeUnit.MILLISECONDS);
redLock.lock();
// 日志...業(yè)務(wù)處理等...
} catch (Exception e) {
// 日志等...
} finally {
// 解鎖
redLock.unlock();
}
事務(wù)問(wèn)題
至此,是不是萬(wàn)無(wú)一失了呢?其實(shí)還沒(méi)完,還有一個(gè)隱藏問(wèn)題。
我們知道,MySQL默認(rèn)的事務(wù)隔離級(jí)別是Repeatable-Read可重復(fù)讀。
通過(guò)上述表格可以看出,Repeatable-Read這種隔離級(jí)別在同一個(gè)事務(wù)中多次讀取的數(shù)據(jù)是一致的;
另一方面,Spring聲明式事務(wù)默認(rèn)的傳播特性是Required,在調(diào)用聲明式事務(wù)修飾的方法stockHandle之前就已經(jīng)開(kāi)啟了事務(wù);
以上兩點(diǎn)會(huì)導(dǎo)致:
1.)線程Thread-A和Thread-B都執(zhí)行到該方法
2.)各自開(kāi)啟了事務(wù)Transation-A和Transation-B
3.)Transation-A先執(zhí)行加鎖、執(zhí)行、解鎖
4.)Transation-B后執(zhí)行加鎖、執(zhí)行、解鎖
5.)由于Transation-B事務(wù)的開(kāi)啟,是在Transation-A事務(wù)提交之前
6.)此時(shí)默認(rèn)隔離級(jí)別Repeatable-Read,事務(wù)Transation-B事務(wù)讀取不到Transation-A已經(jīng)提交的數(shù)據(jù)
7.)就會(huì)出現(xiàn)Transation-A和Transation-B事務(wù)開(kāi)啟后讀取到的值是一樣的,即Transation-B讀取的是Transation-A更新前的數(shù)據(jù)
要解決這種隱藏BUG,可以將庫(kù)存信息放入Redis,利用Redis的decr方法在分布式環(huán)境下原子性的扣減庫(kù)存:
// 省略其他代碼
// Redis原子扣減庫(kù)存
Long stock = redisTemplate.opsForValue().decrement(1);
if (null == stock || Integer.parseInt(stock.toString()) < 0){
// 庫(kù)存異常
return false;
}
// 更新數(shù)據(jù)庫(kù)等...
至于MQ和Zookeeper方式今天不在此介紹啦,大家感興趣的話我后面專門(mén)開(kāi)篇來(lái)講。
其實(shí)沒(méi)有最完美無(wú)缺的方案,以上方案還是會(huì)存在某些特定場(chǎng)景下的特定問(wèn)題,具體場(chǎng)景具體分析,逐步優(yōu)化,一步步思考,加固項(xiàng)目城墻之余,也夯實(shí)自身的技術(shù)壁壘。






關(guān)注Java技術(shù)??锤喔韶?/strong>


