秒殺場(chǎng)景下如何保證數(shù)據(jù)一致性
本文主要討論秒殺場(chǎng)景的解決方案。
什么是秒殺?
從字面意思理解,所謂秒殺,就是在極短時(shí)間內(nèi),大量的請(qǐng)求涌入,處理不當(dāng)時(shí)容易出現(xiàn)服務(wù)崩潰或數(shù)據(jù)不一致等問題的高并發(fā)場(chǎng)景。
常見的秒殺場(chǎng)景有淘寶雙十一、網(wǎng)約車司機(jī)搶單、12306搶票等等。
高并發(fā)場(chǎng)景下秒殺超賣Bug復(fù)現(xiàn)
在這里準(zhǔn)備了一個(gè)商品秒殺的小案例,
按照正常的邏輯編寫代碼,請(qǐng)求進(jìn)來先查庫(kù)存,庫(kù)存大于0時(shí)扣減庫(kù)存,然后執(zhí)行其他訂單邏輯業(yè)務(wù)代碼;
/**
* 商品秒殺
*/
@Service
public class GoodsOrderServiceImpl implements OrderService {
@Autowired
private GoodsDao goodsDao;
@Autowired
private OrderDao orderDao;
/**
* 下單
*
* @param goodsId 商品ID
* @param userId 用戶ID
* @return
*/
@Override
public boolean grab(int goodsId, int userId) {
// 查詢庫(kù)存
int stock = goodsDao.selectStock(goodsId);
try {
// 這里睡2秒是為了模擬等并發(fā)都來到這,模擬真實(shí)大量請(qǐng)求涌入
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 庫(kù)存大于0,扣件庫(kù)存,保存訂單
if (stock > 0) {
goodsDao.updateStock(goodsId, stock - 1);
orderDao.insert(goodsId, userId);
return true;
}
return false;
}
}
復(fù)制代碼@Service("grabNoLockService")
public class GrabNoLockServiceImpl implements GrabService {
@Autowired
OrderService orderService;
/**
* 無鎖的搶購(gòu)邏輯
*
* @param goodsId
* @param userId
* @return
*/
@Override
public String grabOrder(int goodsId, int userId) {
try {
System.out.println("用戶:" + userId + " 執(zhí)行搶購(gòu)邏輯");
boolean b = orderService.grab(goodsId, userId);
if (b) {
System.out.println("用戶:" + userId + " 搶購(gòu)成功");
} else {
System.out.println("用戶:" + userId + " 搶購(gòu)失敗");
}
} finally {
}
return null;
}
}
復(fù)制代碼庫(kù)存設(shè)置為2個(gè);

使用jmeter開10個(gè)線程壓測(cè)。
壓測(cè)結(jié)果


搶購(gòu)訂單:10
庫(kù)存剩余:1
出問題了!出大問題了?。?/p>
本來有兩個(gè)庫(kù)存,現(xiàn)在還剩一個(gè),而秒殺成功的卻有10個(gè),出現(xiàn)了嚴(yán)重的超賣問題!
問題分析
問題其實(shí)很簡(jiǎn)單,當(dāng)秒殺開始,10個(gè)請(qǐng)求同時(shí)進(jìn)來,同時(shí)去查庫(kù)存,發(fā)現(xiàn)庫(kù)存=2,然后都去扣減庫(kù)存,把庫(kù)存變?yōu)?,秒殺成功,共賣出商品10件,庫(kù)存減1。
那么怎么解決這個(gè)問題呢,說去來也挺簡(jiǎn)單,加鎖就行了。
單機(jī)模式下的解決方案
加JVM鎖
首先在單機(jī)模式下,服務(wù)只有一個(gè),加JVM鎖就OK,synchronized和Lock都可。
@Service("grabJvmLockService")
public class GrabJvmLockServiceImpl implements GrabService {
@Autowired
OrderService orderService;
/**
* JVM鎖的搶購(gòu)邏輯
*
* @param goodsId
* @param userId
* @return
*/
@Override
public String grabOrder(int goodsId, int userId) {
String lock = (goodsId + "");
synchronized (lock.intern()) {
try {
System.out.println("用戶:" + userId + " 執(zhí)行搶購(gòu)邏輯");
boolean b = orderService.grab(goodsId, userId);
if (b) {
System.out.println("用戶:" + userId + " 搶購(gòu)成功");
} else {
System.out.println("用戶:" + userId + " 搶購(gòu)失敗");
}
} finally {
}
}
return null;
}
}
復(fù)制代碼這里以synchronized為例,加鎖之后恢復(fù)庫(kù)存重新壓測(cè),結(jié)果:
壓測(cè)結(jié)果


搶購(gòu)訂單:2
庫(kù)存剩余:0
大功告成!
JVM鎖在集群模式下還有效果嗎?
單機(jī)模式下的問題解決了,那么在集群模式下,加JVM級(jí)別的鎖還有效嗎?
這里起了兩個(gè)服務(wù),并且加了一層網(wǎng)關(guān),用來做負(fù)載均衡,重新壓測(cè),
壓測(cè)結(jié)果


搶購(gòu)訂單:4
庫(kù)存剩余:0
答案是顯而易見的,鎖無效??!
集群模式下的解決方案
問題分析
出現(xiàn)這種問題的原因是,JVM級(jí)別的鎖在兩個(gè)服務(wù)中是不同的兩把鎖,兩個(gè)服務(wù)各拿個(gè)的,各賣各的,不具有互斥性。

那怎么辦呢?也好辦,把鎖獨(dú)立出來就好了,讓兩個(gè)服務(wù)去拿同一把鎖,也就是分布式鎖。

分布式鎖
分布式鎖是控制分布式系統(tǒng)之間同步訪問共享資源的一種方式。
在分布式系統(tǒng)中,常常需要協(xié)調(diào)他們的動(dòng)作。如果不同的系統(tǒng)或是同一個(gè)系統(tǒng)的不同主機(jī)之間共享了一個(gè)或一組資源,那么訪問這些資源的時(shí)候,往往需要互斥來防止彼此干擾來保證一致性,這個(gè)時(shí)候,便需要使用到分布式鎖。
常見的分布式鎖的實(shí)現(xiàn)方式有MySQL、Redis、Zookeeper等。
分布式鎖--MySQL
MySQL實(shí)現(xiàn)鎖的方案是:準(zhǔn)備一張表作為鎖,
加鎖時(shí)將要搶購(gòu)的商品ID作為
主鍵或者唯一索引插入作為鎖的表中,這樣其他線程來加鎖時(shí)就會(huì)插入失敗,從而保證互斥性;解鎖時(shí)將這條記錄刪除,其他線程可以繼續(xù)加鎖。
按照上面的方案,編寫的部分代碼:
鎖
/**
* MySQL寫的分布式鎖
*/
@Service
@Data
public class MysqlLock implements Lock {
@Autowired
private GoodsLockDao goodsLockDao;
private ThreadLocal goodsLockThreadLocal;
@Override
public void lock() {
// 1、嘗試加鎖
if (tryLock()) {
System.out.println("嘗試加鎖");
return;
}
// 2.休眠
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 3.遞歸再次調(diào)用
lock();
}
/**
* 非阻塞式加鎖,成功,就成功,失敗就失敗。直接返回
*/
@Override
public boolean tryLock() {
try {
GoodsLock goodsLock = goodsLockThreadLocal.get();
goodsLockDao.insert(goodsLock);
System.out.println("加鎖對(duì)象:" + goodsLockThreadLocal.get());
return true;
} catch (Exception e) {
return false;
}
}
@Override
public void unlock() {
goodsLockDao.delete(goodsLockThreadLocal.get().getGoodsId());
System.out.println("解鎖對(duì)象:" + goodsLockThreadLocal.get());
goodsLockThreadLocal.remove();
}
@Override
public void lockInterruptibly() throws InterruptedException {
// TODO Auto-generated method stub
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
// TODO Auto-generated method stub
return false;
}
@Override
public Condition newCondition() {
// TODO Auto-generated method stub
return null;
}
}
復(fù)制代碼 搶購(gòu)邏輯
@Service("grabMysqlLockService")
public class GrabMysqlLockServiceImpl implements GrabService {
@Autowired
private MysqlLock lock;
@Autowired
OrderService orderService;
ThreadLocal goodsLock = new ThreadLocal<>();
@Override
public String grabOrder(int goodsId, int userId) {
// 生成key
GoodsLock gl = new GoodsLock();
gl.setGoodsId(goodsId);
gl.setUserId(userId);
goodsLock.set(gl);
lock.setGoodsLockThreadLocal(goodsLock);
// lock
lock.lock();
// 執(zhí)行業(yè)務(wù)
try {
System.out.println("用戶:"+userId+" 執(zhí)行搶購(gòu)邏輯");
boolean b = orderService.grab(goodsId, userId);
if(b) {
System.out.println("用戶:"+userId+" 搶購(gòu)成功");
}else {
System.out.println("用戶:"+userId+" 搶購(gòu)失敗");
}
} finally {
// 釋放鎖
lock.unlock();
}
return null;
}
}
復(fù)制代碼 恢復(fù)庫(kù)存后繼續(xù)壓測(cè),結(jié)果符合預(yù)期,數(shù)據(jù)一致。
壓測(cè)結(jié)果


搶購(gòu)成功:2
剩余庫(kù)存:0
問題與解決方案
由于突然斷網(wǎng)等原因,導(dǎo)致鎖沒有釋放成功怎么辦?
答:在作為鎖的表中加開始時(shí)間、結(jié)束時(shí)間兩個(gè)字段作為鎖的有效期,由于各種原因?qū)е骆i沒有及時(shí)釋放時(shí),可以根據(jù)有效期進(jìn)行判斷鎖是否有效。
給鎖加了有效期后,若有效期結(jié)束,線程任務(wù)還沒有執(zhí)行完畢怎么辦?
答:可以引入watch dog機(jī)制,在任務(wù)未執(zhí)行結(jié)束前,給鎖續(xù)期,這個(gè)在后面再詳細(xì)解釋。
分布式鎖--Redis
在一些中小型項(xiàng)目中可以使用MySQL方案,在大型項(xiàng)目中,給MySQL的配置加上去也可以使用,但用的最多的還是Redis。
Redis加鎖的實(shí)現(xiàn)方式是使用setnx命令,格式:setnx key value。
setnx是「set if not exists」的縮寫;若key不存在,則將key的值設(shè)置為value;當(dāng)key存在時(shí),不做任何操作。
加鎖:
setnx key value解鎖:
del key
Redis分布式鎖--死鎖問題
產(chǎn)生原因
已經(jīng)加鎖的服務(wù)在執(zhí)行過程中掛掉了,沒有來得及釋放鎖,鎖一直存在在Redis中,導(dǎo)致其他服務(wù)無法加鎖。
解決方案
設(shè)置key的過期時(shí)間,讓key自動(dòng)過期,過期后,key就不存在了,其他服務(wù)就能繼續(xù)加鎖。
要注意的是,添加過期時(shí)間時(shí),
不能使用這種方式:
setnx key value;
expire key time_in_second;
復(fù)制代碼這種方式也可能在第一句setnx成功后掛掉,過期時(shí)間沒有設(shè)置,導(dǎo)致死鎖。
有效的方案是通過一行命令加鎖并設(shè)置過期時(shí)間,格式如下:
set key value nx ex time_in_second;
復(fù)制代碼這種方式在 Redis 2.6.12 版本開始支持,老版本的Redis可以使用LuaScript。
過期時(shí)間引發(fā)的問題
問題一:假設(shè)鎖過期時(shí)間設(shè)置為10秒,服務(wù)1加鎖后執(zhí)行10秒還未結(jié)束,此時(shí)鎖過期了,服務(wù)2來加鎖也能成功,導(dǎo)致兩個(gè)服務(wù)同時(shí)拿到鎖。
問題二:服務(wù)1在執(zhí)行了14秒后結(jié)束去釋放鎖,會(huì)把服務(wù)2加的鎖釋放掉,此時(shí)服務(wù)3又能加鎖成功。
解決方案
問題二容易解決,在釋放鎖的時(shí)候判斷一下是不是自己加的鎖,如果是自己加的鎖,就釋放;如果不是則略過。
問題一解決方案:就是上面說的 Watch Dog(看門狗)機(jī)制
簡(jiǎn)單的理解就是另起一個(gè)子線程(看門狗),幫主線程看著過期時(shí)間,當(dāng)主線程在執(zhí)行業(yè)務(wù)邏輯沒有結(jié)束時(shí),過期時(shí)間每過三分之一,子線程(看門狗)就把過期時(shí)間續(xù)滿,從而保證主線程沒有結(jié)束,鎖就不會(huì)過期。
Watch Dog(看門狗)機(jī)制的實(shí)現(xiàn)
@Service
public class RenewGrabLockServiceImpl implements RenewGrabLockService {
@Autowired
private RedisTemplate redisTemplate;
@Override
@Async
public void renewLock(String key, String value, int time) {
System.out.println("續(xù)命"+key+" "+value);
String v = redisTemplate.opsForValue().get(key);
// 寫成死循環(huán),加判斷
if (StringUtils.isNotBlank(v) && v.equals(value)){
int sleepTime = time / 3;
try {
Thread.sleep(sleepTime * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
redisTemplate.expire(key,time,TimeUnit.SECONDS);
renewLock(key,value,time);
}
}
復(fù)制代碼 Redis單節(jié)點(diǎn)故障
如果執(zhí)行過程中Redis掛掉了,所有服務(wù)來加鎖都加不上鎖,這就是單節(jié)點(diǎn)故障問題。

解決方案
使用多臺(tái)Redis。
首先來分析一個(gè)問題,多臺(tái)Redis之間可以做主從嗎?

Redis主從問題
當(dāng)一個(gè)線程加鎖成功后,key還沒有被同步過去,Redis Master節(jié)點(diǎn)掛了,此時(shí)Slave節(jié)點(diǎn)中沒有key的存在,另一個(gè)服務(wù)來加鎖依然可以加鎖成功。

所以,不能使用主從方案。
還有一種方案是紅鎖。
紅鎖
紅鎖方案也是使用多臺(tái)Redis,但是多臺(tái)Redis之間沒有任何關(guān)系,就是獨(dú)立的Redis。
加鎖時(shí),在一臺(tái)Redis上加鎖成功后,馬上去下一臺(tái)Redis上加鎖,最終若在過半的Redis上加鎖成功,則加鎖成功,否則加鎖失敗。

紅鎖會(huì)不會(huì)出現(xiàn)超賣問題?
會(huì)!。
如果運(yùn)維小哥很勤快,做了自動(dòng)化,Redis掛掉之后,馬上重啟了一臺(tái),那么重啟的Redis里沒有之前加鎖的key,其他線程依然能夠加鎖成功,這就導(dǎo)致兩個(gè)線程同時(shí)拿到鎖。

解決方案:延遲重啟掛掉的
Redis,延遲一天啟動(dòng)也沒有問題,重啟太快才會(huì)有問題。
終極問題
到現(xiàn)在為止程序已經(jīng)完美了嗎?
并沒有!
當(dāng)程序在執(zhí)行的時(shí)候,鎖也加上了,狗(watch dog)也開始不停的續(xù)期,一切看似很美好,但是Java里還有一個(gè)終極問題--STW(Stop The World)。
當(dāng)遇到FullGC時(shí),JVM會(huì)發(fā)生STW(Stop The World),此時(shí),世界被按下了暫停鍵,執(zhí)行任務(wù)的主線程暫停了,用來續(xù)期的狗(watch dog)也不會(huì)再續(xù)期,Redis中的鎖會(huì)慢慢過期,當(dāng)鎖過期之后,其他JVM又可以來成功加鎖,原來的問題又出現(xiàn)了,同時(shí)有兩個(gè)服務(wù)拿到鎖。

解決方案
方案一:鴕鳥算法
方案二:終極方案 -- Zookeeper+MySQL樂觀鎖
分布式鎖--Zookeeper+MySQL樂觀鎖
Zookeeper是怎么解決STW問題的呢?
加鎖時(shí),在
zookeeper中創(chuàng)建一個(gè)臨時(shí)順序節(jié)點(diǎn),創(chuàng)建成功后zookeeper會(huì)生成一個(gè)序號(hào),將這個(gè)序號(hào)存到MySQL中的verson字段做校驗(yàn);如果鎖未釋放,發(fā)生了
STW,緊接著鎖過期,其他服務(wù)去加鎖后,會(huì)將MySQL中的version字段變掉;解鎖時(shí),驗(yàn)證
version字段是否是自己加鎖時(shí)的內(nèi)容如果是,刪除節(jié)點(diǎn),釋放鎖;
如果不是,說明自己已經(jīng)昏睡過了,執(zhí)行失敗。
世界變得清靜了。
相關(guān)代碼
gitee: distributed-lock
作者:三生oo
鏈接:https://juejin.cn/post/6944967816562884644
來源:稀土掘金
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請(qǐng)聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請(qǐng)注明出處。
