攝影網(wǎng)站開(kāi)發(fā)的背景企業(yè)網(wǎng)站建設(shè)哪家好
👨?🎓作者簡(jiǎn)介:一位大四、研0學(xué)生,正在努力準(zhǔn)備大四暑假的實(shí)習(xí)
🌌上期文章:Redis:原理速成+項(xiàng)目實(shí)戰(zhàn)——Redis實(shí)戰(zhàn)8(基于Redis的分布式鎖及優(yōu)化)
📚訂閱專(zhuān)欄:Redis:原理速成+項(xiàng)目實(shí)戰(zhàn)
希望文章對(duì)你們有所幫助
簡(jiǎn)單回顧一下之前實(shí)現(xiàn)秒殺的思路,其實(shí)無(wú)非就是2點(diǎn):
1、庫(kù)存夠不夠,該用戶(hù)有沒(méi)有買(mǎi)過(guò)
2、操作數(shù)據(jù)庫(kù)(扣庫(kù)存、創(chuàng)建訂單)
之前的業(yè)務(wù)由于涉及了大量數(shù)據(jù)庫(kù)的操作,所以性能并不是太好。
秒殺優(yōu)化
- 異步秒殺思路
- Redis實(shí)現(xiàn)秒殺資格的判斷
- 分析
- 實(shí)現(xiàn)
- 基于阻塞隊(duì)列實(shí)現(xiàn)秒殺異步下單
- 總結(jié)及發(fā)現(xiàn)問(wèn)題
異步秒殺思路
之前的業(yè)務(wù),可以用下圖來(lái)表示:
可以發(fā)現(xiàn),Tomcat中順序執(zhí)行的操作里面有4個(gè)需要對(duì)數(shù)據(jù)庫(kù)進(jìn)行查詢(xún)或修改操作,而MySQL本身的并發(fā)能力就是很差的,時(shí)間是所有時(shí)間之和,這種解決方式并不好。
因此,我們需要將操作數(shù)據(jù)庫(kù)的步驟分給多個(gè)線程來(lái)做,而庫(kù)存量判斷、是否具有購(gòu)買(mǎi)資格的判斷,我們也可以將其交給Redis,從而提高效率。
既然是將步驟分給多個(gè)線程來(lái)做,我們要開(kāi)辟線程,并且要使得開(kāi)辟的線程能夠正確執(zhí)行業(yè)務(wù):
整個(gè)業(yè)務(wù)的流程被分開(kāi)了,所需要的時(shí)間不再是所有的過(guò)程時(shí)間之和(完成下單的操作是異步執(zhí)行的),而且整個(gè)業(yè)務(wù)基于Redis,將會(huì)大大提升業(yè)務(wù)的性能。
但這需要考慮2個(gè)難點(diǎn)問(wèn)題:如何在Redis里面完成判斷庫(kù)存以及一人一單的校驗(yàn)?如何基于阻塞隊(duì)列實(shí)現(xiàn)秒殺異步下單?
Redis實(shí)現(xiàn)秒殺資格的判斷
分析
要在Redis里面判斷庫(kù)存量以及一人一單,我們肯定需要將優(yōu)惠券的庫(kù)存信息以及相關(guān)訂單信息緩存到Redis中去,我們需要選取合適的數(shù)據(jù)結(jié)構(gòu)來(lái)保存這兩個(gè)信息。
庫(kù)存很容易,因?yàn)橹话藥?kù)存量這個(gè)信息,直接使用String類(lèi)型進(jìn)行存儲(chǔ)即可。
但要實(shí)現(xiàn)一人一單,我們要判斷這個(gè)優(yōu)惠券被哪些用戶(hù)購(gòu)買(mǎi)過(guò)。所以這個(gè)數(shù)據(jù)結(jié)構(gòu)需要能夠保存多個(gè)值,而又因?yàn)橐蝗艘粏?#xff0c;所以我們要保存的用戶(hù)的id顯然是不能重復(fù)的。所以,set是很適合的。
因此業(yè)務(wù)流程可以很容易知道:
可以發(fā)現(xiàn)業(yè)務(wù)的步驟還是有很多步的,因此我們要保證步驟的原子性,因此上述的內(nèi)容應(yīng)當(dāng)用Lua腳本來(lái)寫(xiě)。
實(shí)現(xiàn)
需求:
1、新增秒殺優(yōu)惠券的同時(shí),將優(yōu)惠券的信息保存到Redis中
2、基于Lua腳本,判斷秒殺庫(kù)存、一人一單、決定用戶(hù)是否搶購(gòu)成功
3、若成功,將優(yōu)惠券id與用戶(hù)id進(jìn)行封裝,再存入阻塞隊(duì)列
4、開(kāi)啟線程任務(wù),不斷從阻塞隊(duì)列中獲取信息,實(shí)現(xiàn)異步下單
1、在VoucherServiceImpl類(lèi)中新增優(yōu)惠券,同時(shí)保存到Redis中:
@Resourceprivate StringRedisTemplate stringRedisTemplate;@Override@Transactionalpublic void addSeckillVoucher(Voucher voucher) {// 保存優(yōu)惠券save(voucher);// 保存秒殺信息SeckillVoucher seckillVoucher = new SeckillVoucher();seckillVoucher.setVoucherId(voucher.getId());seckillVoucher.setStock(voucher.getStock());seckillVoucher.setBeginTime(voucher.getBeginTime());seckillVoucher.setEndTime(voucher.getEndTime());seckillVoucherService.save(seckillVoucher);//保存秒殺庫(kù)存到Redis中, SECKILL_STOCK_KEY = "seckill:stock:"stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());}
打開(kāi)postman,進(jìn)行測(cè)試:
添加成功:
2、在resources下創(chuàng)建seckill.lua,決定用戶(hù)能否搶券成功:
-- 1 參數(shù)列表
-- 1.1 優(yōu)惠券id
local voucherId = ARGV[1]
-- 1.2 用戶(hù)id
local userId = ARGV[2]-- 2 數(shù)據(jù)key
-- 2.1 庫(kù)存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 訂單key
local orderKey = 'seckill:order:' .. voucherId-- 3 腳本業(yè)務(wù)
-- 3.1 判斷庫(kù)存是夠充足
if(tonumber(redis.call('get', stockKey)) <= 0) then-- 3.2 庫(kù)存不足,返回1return 1
end
--3.2 判斷用戶(hù)是否下單,即判斷用戶(hù)id是不是這個(gè)set集合的成員
if(redis.call('sismember', orderKey, userId) == 1) then-- 3.2 存在,說(shuō)明重復(fù)下單return 2
end
-- 3.4 扣庫(kù)存
redis.call('incrby', stockKey, -1)
-- 3.5 下單(保存用戶(hù))
redis.call('sadd', orderKey, userId)
return 0
3、在VoucherOrderServiceImpl中修改seckillVoucher,修改業(yè)務(wù),先調(diào)用Lua腳本執(zhí)行,返回0即可將下單信息存入阻塞隊(duì)列(存入阻塞隊(duì)列的代碼編寫(xiě)較為麻煩,暫時(shí)放著):
//秒殺優(yōu)化,調(diào)用Lua的代碼@Overridepublic Result seckillVoucher(Long voucherId) {//獲取用戶(hù)Long userId = UserHolder.getUser().getId();//執(zhí)行Lua腳本,這里使用了靜態(tài)代碼塊Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,Collections.emptyList(), //這里我們沒(méi)有key傳,只需要傳送一個(gè)空集合即可voucherId.toString(), userId.toString()//傳其他類(lèi)型的參數(shù));//判斷結(jié)果是否為0int r = result.intValue();if(r != 0){//不為0,沒(méi)有購(gòu)買(mǎi)資格return Result.fail(r == 1 ? "庫(kù)存不足" : "不能重復(fù)下單");}//為0,有購(gòu)買(mǎi)資格,將下單信息保存到阻塞隊(duì)列long orderId = redisIdWorker.nextId("order");//TODO 保存阻塞隊(duì)列,這邊先空著,下一部分單獨(dú)編寫(xiě)//返回訂單idreturn Result.ok(orderId);}
打開(kāi)postman進(jìn)行測(cè)試,連續(xù)下單兩次,證明下單資格判斷的可行性:
當(dāng)然數(shù)據(jù)庫(kù)中的數(shù)據(jù)還是沒(méi)有改變的, 異步下單還沒(méi)做。
如果要進(jìn)行壓力測(cè)試的話(huà),大家要自己構(gòu)建出幾百個(gè)用戶(hù),然后這些用戶(hù)分別占一個(gè)線程進(jìn)行下單,用jmeter進(jìn)行測(cè)試,完成這些測(cè)試還是很繁瑣的,但是因?yàn)檫@些操作都是基于Redis的,容易知道吞吐率肯定是變大了不少的。
基于阻塞隊(duì)列實(shí)現(xiàn)秒殺異步下單
對(duì)于用戶(hù)資格的判斷已經(jīng)完成了,假設(shè)用戶(hù)具有秒殺的資格,這時(shí)候我們只需要獨(dú)立開(kāi)辟一個(gè)線程,去異步實(shí)現(xiàn)下單。因?yàn)橛脩?hù)只要具有這個(gè)資格,直接返回訂單的id從而讓用戶(hù)去執(zhí)行付款即可,而將訂單的信息存入數(shù)據(jù)庫(kù)并不需要嚴(yán)格的時(shí)效性,因此業(yè)務(wù)可行:
接下來(lái),開(kāi)啟線程任務(wù),不斷從阻塞隊(duì)列中獲取信息,實(shí)現(xiàn)下單,這是整體性能變高的關(guān)鍵。
代碼的實(shí)現(xiàn),我們之所以選擇阻塞隊(duì)列,是因?yàn)樽枞?duì)列具有一個(gè)重要的性質(zhì):當(dāng)線程嘗試獲取阻塞隊(duì)列的元素時(shí),若隊(duì)列中沒(méi)有元素,該線程就會(huì)被阻塞,直到隊(duì)列中獲取到這個(gè)元素了。
另外代碼中因?yàn)樯婕傲碎_(kāi)辟獨(dú)立線程去實(shí)現(xiàn)異步下單,因此我們需要準(zhǔn)備好線程池與線程任務(wù)。
最終業(yè)務(wù)的全部代碼實(shí)現(xiàn)如下:
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {//注入秒殺優(yōu)惠券的service@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Resourceprivate StringRedisTemplate stringRedisTemplate;@Resourceprivate RedissonClient redissonClient;public static final DefaultRedisScript<Long> SECKILL_SCRIPT;static {SECKILL_SCRIPT = new DefaultRedisScript<>();SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));//設(shè)置腳本位置SECKILL_SCRIPT.setResultType(Long.class);//配置返回值}//阻塞隊(duì)列:當(dāng)線程嘗試從這個(gè)隊(duì)列中獲取元素,如果沒(méi)有元素,那么該線程就會(huì)被阻塞,直到隊(duì)列中獲取到元素private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);//線程池private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();//線程任務(wù),用戶(hù)隨時(shí)都能搶單,所以應(yīng)該要在這個(gè)類(lèi)被初始化的時(shí)候馬上開(kāi)始執(zhí)行@PostConstruct //該注解表示在當(dāng)前類(lèi)初始化完畢以后立即執(zhí)行private void init(){SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());}private class VoucherOrderHandler implements Runnable{@Overridepublic void run() {//不斷從隊(duì)列中取訂單信息while (true){try {VoucherOrder voucherOrder = orderTasks.take();//創(chuàng)建訂單,流程里面無(wú)須再加鎖,加個(gè)鎖就是做個(gè)兜底handleVoucherOrder(voucherOrder);} catch (Exception e) {//e.printStackTrace();log.error("創(chuàng)建訂單異常", e);}}}}IVoucherOrderService proxy;//秒殺優(yōu)化,調(diào)用Lua的代碼@Overridepublic Result seckillVoucher(Long voucherId) {//獲取用戶(hù)Long userId = UserHolder.getUser().getId();//執(zhí)行Lua腳本,這里使用了靜態(tài)代碼塊Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,Collections.emptyList(), //這里我們沒(méi)有key傳,只需要傳送一個(gè)空集合即可voucherId.toString(), userId.toString()//傳其他類(lèi)型的參數(shù));//判斷結(jié)果是否為0int r = result.intValue();if(r != 0){//不為0,沒(méi)有購(gòu)買(mǎi)資格return Result.fail(r == 1 ? "庫(kù)存不足" : "不能重復(fù)下單");}//為0,有購(gòu)買(mǎi)資格,將下單信息保存到阻塞隊(duì)列long orderId = redisIdWorker.nextId("order");//TODO 保存阻塞隊(duì)列//先將用戶(hù)id與訂單id封裝起來(lái)VoucherOrder voucherOrder = new VoucherOrder();voucherOrder.setId(orderId);voucherOrder.setUserId(userId);voucherOrder.setVoucherId(voucherId);//放入阻塞隊(duì)列orderTasks.add(voucherOrder);//獲取代理對(duì)象proxy = (IVoucherOrderService) AopContext.currentProxy();//返回訂單idreturn Result.ok(orderId);}private void handleVoucherOrder(VoucherOrder voucherOrder) {//獲取用戶(hù),用戶(hù)id不能再?gòu)腢serHolder中取了,因?yàn)楝F(xiàn)在是從線程池獲取的全新線程,不是主線程Long userId = voucherOrder.getUserId();//創(chuàng)建鎖對(duì)象RLock lock = redissonClient.getLock("lock:order:" + userId);//獲取鎖boolean isLock = lock.tryLock();//判斷是否獲取鎖成功if(!isLock){log.error("不允許重復(fù)下單");//理論上不會(huì)發(fā)生return;}try {//這是主線程的一個(gè)子線程,無(wú)法直接獲得代理對(duì)象,代理對(duì)象需要在主線程中獲取,并設(shè)置成成員變量使得該子線程能夠獲取//createVoucherOrder的方法體直接修改,不再需要重新根據(jù)id創(chuàng)建訂單,而是直接將訂單傳進(jìn)去proxy.createVoucherOrder(voucherOrder);} finally {lock.unlock();}}@Transactionalpublic void createVoucherOrder(VoucherOrder voucherOrder) {Long userId = voucherOrder.getUserId();//查詢(xún)訂單int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();//判斷是否存在if (count > 0) {log.error("不可重復(fù)購(gòu)買(mǎi)");return;}//扣減庫(kù)存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0).update();if (!success) {log.error("庫(kù)存不足");return;}//不需要再創(chuàng)建訂單,直接savesave(voucherOrder);}
}
總結(jié)及發(fā)現(xiàn)問(wèn)題
1、秒殺業(yè)務(wù)的優(yōu)化思路
(1)先用Redis完成庫(kù)存量以及一人一單判斷,完成搶單
(2)下單的業(yè)務(wù)(操作數(shù)據(jù)庫(kù)的業(yè)務(wù))放入阻塞隊(duì)列,并用獨(dú)立線程完成異步下單
2、基于阻塞隊(duì)列的異步秒殺存在的問(wèn)題
(1)內(nèi)存限制問(wèn)題:我們使用了JDK的阻塞隊(duì)列,占用的是JVM內(nèi)存,若不加以限制,會(huì)有很多的訂單對(duì)象創(chuàng)建線程并且將大量信息放入阻塞隊(duì)列,可能會(huì)內(nèi)存溢出
(2)數(shù)據(jù)安全問(wèn)題:要用于下單的業(yè)務(wù)信息都存到了內(nèi)存中,萬(wàn)一服務(wù)宕機(jī),那么用戶(hù)完成了搶單,但是數(shù)據(jù)庫(kù)卻沒(méi)有做出相應(yīng)的修改,將會(huì)導(dǎo)致數(shù)據(jù)不一致
解決的方法將在下一節(jié)進(jìn)行分析