?????作者簡介:一位大四、研0學生,正在努力準備大四暑假的實習
??上期文章:Redis:原理速成+項目實戰(zhàn)——Redis實戰(zhàn)6(封裝緩存工具(高級寫法)&&緩存總結(jié))
??訂閱專欄:Redis:原理速成+項目實戰(zhàn)
希望文章對你們有所幫助
這篇文章寫了很久。我自己在邊實現(xiàn)、邊用jmeter來測試、邊根據(jù)結(jié)果來優(yōu)化我的代碼,對于那些線程并發(fā)的問題,我大致是可以靠自己來解決,但是為了寫好這篇文章,為了做好線程并發(fā)問題的分析,我在獨立實現(xiàn)完之后,還是按黑馬程序員的進度走了一下,他埋坑的地方其實都是線程并發(fā)問題的坑,我也自己掉一掉,并在這篇文章中進行總結(jié)。
文章中會涉及一些java面試常見的問題:常量池、Spring代理失效。如果沒有印象大家可以專門找一下這些面經(jīng)去了解一下。
聊到電商,一定離不開秒殺,而Redis在整個秒殺的業(yè)務中的作用是非常巨大的,接下來將會利用Redis實現(xiàn)全局ID,并實現(xiàn)秒殺,并且解決超賣問題、實現(xiàn)一人一單,逐漸優(yōu)化業(yè)務。
全局唯一ID
每個店鋪都可以發(fā)布優(yōu)惠券(代金券),當用戶搶購的時候,就會生成訂單并且保存到tb_voucher_order這張表中:
可以發(fā)現(xiàn),我們的主鍵ID沒有使用自增長,這是因為如果使用數(shù)據(jù)庫自增ID就會存在一些問題:
1、ID的規(guī)律性太明顯,容易讓別人猜測到信息
2、受單表數(shù)據(jù)量的限制(訂單可能數(shù)據(jù)非常大,可能會分多表進行存儲,但表的自增長相互之間不受影響,所以不同表之間可能會出現(xiàn)ID相同的情況,也就是說這種時候會違背ID的唯一性,這顯然是不可以的)
而全局ID生成器,是一種分布式系統(tǒng)下用來生成全部唯一ID的工具,一般滿足以下特性:
1、唯一性
2、高可用
3、高性能
4、遞增性
5、安全性
除了第5點,Redis及其數(shù)據(jù)結(jié)構(gòu)已經(jīng)可以直接滿足前4點的要求了,為了增加ID的安全性,不要直接使用Redis自增的數(shù)值,而是拼接一些其他信息,最終我們將ID組成定義為64位的二進制數(shù),分別是1位符號位,31位時間戳,32位序列號:
1、符號位:1bit,永遠為0
2、時間戳:31bit,以秒為單位,可以用69年
3、序列號:32bit,秒內(nèi)的計數(shù)器,支持每秒產(chǎn)生2^32個不同的ID,這是用來處理相同秒內(nèi)(時間戳相同)的多個業(yè)務
這樣的結(jié)構(gòu)是可以大幅度提高安全性的,不同時間下的ID一定不同,相同時間的情況下,也會因為32位的序列號而導致ID不同。
Redis實現(xiàn)全局唯一ID
我們在utils包下創(chuàng)建RedisIdWorker類:
@Component
public class RedisIdWorker {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 開始時間戳由main函數(shù)運行得到
*/
public static final long BEGIN_TIMESTAMP = 1704499200L;
/**
* 序列號的位數(shù)
*/
public static final int COUNT_BITS = 32;
public long nextId(String keyPrefix){
//獲得當前時間
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
//生成時間戳
long timestamp = nowSecond - BEGIN_TIMESTAMP;
/**
* 接下來生成序列號
* 我們的key的設置除了加上icr表示是自增長的,還需要在最后拼接一個日期字符串
* 這是因為我們的序列號上限是2^32,并不大,如果每天的key都是一樣的,這是很有可能超過上限的
* 在后面拼接一個日期字符串,可以保證每一天的key都是不一樣的,而且一天內(nèi)也基本不可能到達2^32的上限
* 這樣做還有一個好處,我們以后可以根據(jù)每天或者每月來查看value值,起到統(tǒng)計效果
*/
//獲取當前日期,精確到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//ID自增長,這里最好用基本類型而不是包裝類,因為后面還會做運算
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
//拼接并返回,這里靈活用位運算
return timestamp << COUNT_BITS | count;
}
public static void main(String[] args) {
//定義時間為2024年1月1日00:00:00
LocalDateTime time = LocalDateTime.of(2024, 1, 6, 0, 0, 0);
//將時間變成變成秒數(shù)的形式
long second = time.toEpochSecond(ZoneOffset.UTC);
//在這里運行出來的時間作為BEGIN_TIMESETAMP
System.out.println(second);
}
}
編寫測試代碼:
@Resource
private RedisIdWorker redisIdWorker;
//性能池
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
void testIdWorker() throws InterruptedException {
//因為線程池是異步的,因此我們要用CountDownLatch去截斷,這樣才能正常計時
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();
};
//將任務提交300次,并進行計時
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
es.submit(task);
}
latch.await();//等待所有的countDown結(jié)束
long end = System.currentTimeMillis();
System.out.println("time = " + (end - begin));
}
運行后可以發(fā)現(xiàn),id各不重復,估計id生成的花費時間差不多只有2秒(id的打印也是會花時間的)
打開Redis客戶端,可以發(fā)現(xiàn)我成功的生成了3萬條的id:
優(yōu)惠券秒殺下單
每個店鋪都可以發(fā)布優(yōu)惠券,分為平價券和特價券,平價券可以任意購買,而特價券需要秒殺搶購,表關(guān)系如下:
1、tb_voucher:優(yōu)惠券基本信息(金額,規(guī)則等)
上面的type可以表示標識出是平價券還是特價券,如果是特價券我們也需要一些特定的信息,因此我們會專門拓展出一張表。
2、tb_seckill_voucher:優(yōu)惠券庫存、開始搶購時間、結(jié)束搶購時間(特價券需要此表)
添加優(yōu)惠券
在VoucherController中提供一個接口,調(diào)用就可以實現(xiàn)添加秒殺優(yōu)惠券:
雖然我們傳入的參數(shù)只有Voucher,但是它也同樣可以用來保存需要秒殺的券:
真正的添加不是客戶來做的,要給后臺來做,我們可以使用postman:
可以發(fā)現(xiàn)我們的數(shù)據(jù)庫中已經(jīng)存儲了這個秒殺券:
實現(xiàn)秒殺下單
點擊限時搶購,查看請求URL:
說明 | |
---|---|
請求方式 | POST |
請求路徑 | voucher-order/seckill/{id} |
請求參數(shù) | id,優(yōu)惠券id |
返回值 | 訂單id |
下單的時候我們需要判斷2點:
1、秒殺是否開始或結(jié)束
2、庫存是否充足
業(yè)務流程:
controller:
serviceimpl:
/**
* <p>
* 服務實現(xiàn)類
* </p>
*
* @author 王雄俊
* @since 2024-01-06
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
//注入秒殺優(yōu)惠券的service
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
//查詢優(yōu)惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//判斷秒殺是否開始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒殺尚未開始");
}
//判斷秒殺是否結(jié)束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒殺已經(jīng)結(jié)束");
}
//判斷庫存是否充足
if (voucher.getStock() < 1) {
return Result.fail("庫存不足");
}
//扣減庫存,用mybatis-plus來寫
boolean success = seckillVoucherService.update().
setSql("stock = stock - 1").
eq("voucher_id", voucherId).update();//where條件
if (!success){
return Result.fail("庫存不足");
}
System.out.println("啊啊啊啊啊");
//創(chuàng)建訂單,需要訂單id、用戶id、代金券id
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
Long userId = UserHolder.getUser().getId();//用戶Id去ThreadLocal中取
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//返回訂單ID
return Result.ok(orderId);
}
}
這邊實現(xiàn)了最基礎的訂單秒殺,但是它存在很多問題
庫存超賣問題
既然是秒殺,那每秒鐘很可能會有成千上萬的用戶進行訪問,那么這就對我們的并行化要求非常高,線程安全問題肯定是很重要的,上面的代碼肯定是會存在線程安全問題的,我們可以用jmeter來做測試,為了方便我們到時候觀察測試結(jié)果,我們?nèi)?shù)據(jù)庫手動把優(yōu)惠券數(shù)量調(diào)回100,接著在jmeter中用200個線程來進行搶購:
這里設置的請求頭則表示200個線程全部都由這一個用戶來執(zhí)行:
運行后可以看到有些請求成功,有些請求失?。?br>
預期是有100個線程失敗的,但是打開聚合報告可以發(fā)現(xiàn)失敗的線程數(shù)量不到一半:
說明有些線程意外成功了,打開數(shù)據(jù)庫,發(fā)現(xiàn)票數(shù)為-9,說明發(fā)生了超賣:
這會給商家?guī)頁p失。
庫存超賣問題分析
假設庫存容量為1(相當于一種臨界資源),高并發(fā)的時候可能出現(xiàn)的異常情況:
也就是說,我們在某一時段會同時有多個線程查詢庫存的時候,得到的庫存量為1,這時候都會進行扣減操作,造成超賣。
針對這種線程安全問題,常見解決方法就是直接加鎖,可以分為悲觀鎖和樂觀鎖:
悲觀鎖:認為線程安全問題一定會發(fā)生,因此在操作數(shù)據(jù)之前先獲取鎖,確保線程串行執(zhí)行。(Synchronized、Lock等)
樂觀鎖:認為線程安全問題不一定會發(fā)生,因此不加鎖,只是在更新數(shù)據(jù)時去判斷有沒有其它線程對數(shù)據(jù)做了修改。(如果沒有修改,那就是安全的;如果已經(jīng)被其他線程修改說明發(fā)生了安全問題,此時可以重試或異常)
顯然樂觀鎖的性能會好很多,但是實現(xiàn)起來會更復雜,我們要處理好關(guān)鍵的一點,那就是更新數(shù)據(jù)的時候,該如何去判斷有沒有其它線程對數(shù)據(jù)做了修改。
樂觀鎖的實現(xiàn)方式有2種方法(其實思想相同):
1、版本號法:
給數(shù)據(jù)增加一個字段version,初始值為1,每次我們要修改庫存量之前都需要先查詢庫存量與版本號,然后線程執(zhí)行SQL語句,執(zhí)行SQL語句必須要確定數(shù)據(jù)庫中的這條數(shù)據(jù)的版本號就是查詢出來的版本號,如果不相同說明有其他線程修改了數(shù)據(jù),導致當前數(shù)據(jù)的版本號與之前查詢的不一樣:
2、CAS法
上面的方法加一個版本號其實是一種標識,但是我們不一定要借助version,實際上我們可以直接依靠庫存量來做標識,在對數(shù)據(jù)庫進行修改的時候,我們要首先判斷當前數(shù)據(jù)的庫存量與之前線程查詢出來的庫存量是否相同,不相同則說明發(fā)生線程安全問題,不能修改:
樂觀鎖解決超賣
我們選用CAS法來解決超賣,根據(jù)上述思想,我們只需要在SQL語句那增加一個判斷庫存量的條件:
測試一下上面的代碼,先把數(shù)據(jù)庫做還原,把訂單數(shù)據(jù)刪光,并還原stock為100,然后測試jmeter,可以發(fā)現(xiàn)jmeter中顯示大量的失敗,數(shù)據(jù)庫中也顯示沒有超賣:
超賣問題確實沒有出現(xiàn)了,但是這顯然是不合常理的,200個線程搶100張票,票居然只能賣出20張。這說明樂觀鎖有弊端。
我們對于樂觀鎖的分析,是拿stock=1的情況來說的,所以當線程查詢出來的stock與數(shù)據(jù)庫的stock不一致的時候,足以說明票已經(jīng)賣完了。
假設stock=100,當線程查詢出來的stock與數(shù)據(jù)庫的stock不一致的時候,并不能說明票賣完了,理論上庫存量大概率不為0,該線程還是應該要能夠?qū)崿F(xiàn)買票操作,但全都因為查詢的stock與數(shù)據(jù)庫不一致導致有大量線程買票失敗。
傳統(tǒng)樂觀鎖太謹慎了!我們應該要對其進行改進!
我們不再判斷查詢條件,而只需要查詢數(shù)據(jù)庫中的stock是否大于0:
再次打開jmeter進行測試,異常率50%,解決了上述問題:
但是這不代表樂觀鎖就是完美的,很顯然代碼邏輯中要操作數(shù)據(jù)庫,大量的線程就會給數(shù)據(jù)庫帶來壓力,僅僅使用樂觀鎖在更高并發(fā)的場景下還是不太夠的。
實現(xiàn)一人一單功能
我們在jmeter中的測試,200個線程全部都由一個用戶來執(zhí)行,因此打開訂單表,我們可以發(fā)現(xiàn)訂單全部被同一個用戶買了:
商家做優(yōu)惠券就是為了吸引更多的用戶,一人多單可能會導致商家變相虧本。
其實思路是很簡單的,我們只需要判斷當前嘗試搶優(yōu)惠券的線程,其用戶id在訂單表中是否已經(jīng)存在了,如果存在則不允許下單:
我們在庫存修改的代碼之前加上這一部分邏輯:
//一人一單
Long userId = UserHolder.getUser().getId();
//查詢訂單
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
//判斷是否存在
if (count > 0){
return Result.fail("您已購買過一次!");
}
再次測試jmeter:
數(shù)據(jù)庫顯示這個用戶買了10張優(yōu)惠券,一人多單的問題有所緩解,但依舊存在:
這是因為上面的那一串邏輯還是存在了并發(fā)安全問題,在某一時刻還是會有很多的線程(同一個用戶)進入了這部分邏輯,判斷了count為0,因此進行了刪減庫存的操作。
這里我們肯定也要加鎖,由于這一串邏輯并沒有涉及到修改數(shù)據(jù)庫的操作,所以我們只能加悲觀鎖。
@Override
public Result seckillVoucher(Long voucherId) {
//查詢優(yōu)惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//判斷秒殺是否開始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒殺尚未開始");
}
//判斷秒殺是否結(jié)束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒殺已經(jīng)結(jié)束");
}
//判斷庫存是否充足
if (voucher.getStock() < 1) {
return Result.fail("庫存不足");
}
//返回訂單ID
return createVoucherOrder(voucherId);
}
@Transactional //事務回滾放到這個函數(shù)
public Result createVoucherOrder(Long voucherId) {
//一人一單
Long userId = UserHolder.getUser().getId();
/**
* userId值一樣的,我們用同一把鎖,但是每個請求一來,我們的id對象都是全新的
* Long類型會存在這個問題,所以我們要用toString方法
* 但是toString方法其實是對long類型new了一個字符串,所以每調(diào)用一個toString都是一個全新對象
* 所以要加上intern()方法,從常量池中返回字符串的規(guī)范表示
*/
synchronized (userId.toString().intern()) {
//查詢訂單
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
//判斷是否存在
if (count > 0) {
return Result.fail("您已購買過一次!");
}
//扣減庫存
boolean success = seckillVoucherService.update().
setSql("stock = stock - 1").
eq("voucher_id", voucherId).
gt("stock", 0).
update();
if (!success) {
return Result.fail("庫存不足");
}
//創(chuàng)建訂單,需要訂單id、用戶id、代金券id
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//返回訂單ID
return Result.ok(orderId);
}
}
需要注意一個細節(jié),上面代碼還是會發(fā)生并發(fā)安全問題:
我們這邊的整個函數(shù)已經(jīng)是被Spring托管了,所以事務的提交會在函數(shù)執(zhí)行完畢之后,也就是說我們會先釋放鎖,再提交事務,當我們事務還沒有提交完成,修改數(shù)據(jù)還沒寫入數(shù)據(jù)庫,卻又有其他線程進來了,再次發(fā)生線程并發(fā)問題。
所以,鎖的范圍太小了,我們應該要把整個函數(shù)都鎖起來:
但,依舊有問題!直接調(diào)用createVoucherOrder方法是不行的,因為它相當于調(diào)用了this.createVoucherOrder,然而當前類并不是代理對象,這會導致Sping代理失效!
所以我們要先獲得當前對象的代理對象,然后再去調(diào)用這個函數(shù):
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
需要引入依賴:
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
并且在啟動類中需要暴露代理對象:
運行項目,打開jmeter進行測試:
完美解決!
注意我沒有在createVoucherOrder這個函數(shù)上面直接加鎖,不然所有進行操作的線程都串行執(zhí)行實在太影響效率了!
集群下的線程并發(fā)安全問題
現(xiàn)在已經(jīng)通過加鎖解決一人一單問題安全,但是這只能解決單機情況的,集群模式依舊不行,在這里試著模擬一下集群的方式來進行測試。
1、將服務啟動2份,端口分別為8081與8082:
重啟形成2個機子的集群:
2、修改nginx的conf目錄下的nginx.conf文件,配置反向代理、負載均衡:
最后重新加載一下Nginx并重啟:
最后訪問網(wǎng)址,并連續(xù)刷新2次:
http://localhost:8080/api/voucher/list/1
查看后臺可以發(fā)現(xiàn)兩個啟動服務都可以接受到信息,因為api(8080)包括了8081與8082,訪問是以輪轉(zhuǎn)的方式進行的:
這樣就實現(xiàn)了負載均衡。
測試大家只需要在鎖那里打個斷點,并且在postman里面分別搶券(都用同一個用戶)來進行優(yōu)惠券搶購,可以發(fā)現(xiàn)只用1個用戶信息,數(shù)據(jù)庫中卻少了2張券,說明又一次發(fā)生了并發(fā)問題。
從頭分析一下:
1、對于一個服務中的2個線程,可能發(fā)生下面的并發(fā)問題:
2、我們解決方法是加鎖:
之所以這樣能實現(xiàn),是因為我們鎖住的對象是userId.toString().intern(),也就是從這臺Tomcat常量池中取出userId.toString(),同一個userId之間肯定是相同的,因此可以鎖住,防止并發(fā)。文章來源:http://www.zghlxwxcb.cn/news/detail-819676.html
3、但如果我們部署另外一臺Tomcat,這是鎖的鎖監(jiān)視器,其監(jiān)視的內(nèi)容和之前鎖中的監(jiān)視器內(nèi)容是不一樣的,那么新Tomcat的線程獲取鎖就會成功(獲取的userId.toString()是不一樣的,不理解的可以去看toString方法的源碼),并成功的操作數(shù)據(jù)庫,因此才會造成線程并行問題。
如下圖,線程1、3發(fā)生了線程安全問題:
因此我們只能保證單個JVM下的線程安全,卻無法保證集群中多個JVM的線程安全,我們需要在集群中加鎖,也就是分布式鎖,將在后續(xù)講解。文章來源地址http://www.zghlxwxcb.cn/news/detail-819676.html
到了這里,關(guān)于Redis:原理速成+項目實戰(zhàn)——Redis實戰(zhàn)7(優(yōu)惠券秒殺+細節(jié)解決超賣、一人一單問題)的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!