实战篇--优惠券秒杀
優惠券秒殺
全局唯一ID
當用戶搶購時,就會生成訂單并保存到tb_voucher_order這張表中,而訂單表如果使用數據庫自增ID就存在一些問題:
- id的規律性太明顯
- 受單表數據量限制
全局ID生成器,是一種在分布式系統下用來生成全局唯一ID的工具,一般要滿足以下列特性:
- 唯一性
- 高可用
- 高性能
- 遞增性
- 安全性
ID的組成部分:
- 符號位:1bit,永遠為0
- 時間戳:31bit,以秒為單位,可以使用69年
- 序列號:32bit,秒內的計數器,支持每秒產生2^32個不同ID
工具類編寫代碼實現
package com.hmdp.utils;import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component;import javax.annotation.Resource; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter;/*** @author xc* @date 2023/4/26 14:59*/ @Component public class RedisIdWorker {/*** 開始時間戳*/private static final long BEGIN_TIMESTAMP = 1640995200L;/*** 左移位數,防止以后需要修改*/private static final int COUNT_BITS = 32;private StringRedisTemplate stringRedisTemplate;public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}public long nextId(String keyPrefix) {// 1.生成時間戳LocalDateTime now = LocalDateTime.now();long end = now.toEpochSecond(ZoneOffset.UTC);long timestamp = end - BEGIN_TIMESTAMP;// 2.生成序列號// 2.1獲取當前日期,精確到天String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));Long increment = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);// 3.拼接返回return timestamp << COUNT_BITS | increment;} }實現優惠券秒殺下單
下單時需要判斷兩點:
- 秒殺是否開始或者結束,如果尚未開始或已經結束則無法下單
- 庫存是否充足
流程:
根據流程實現具體業務
@Resourceprivate ISeckillVoucherService iSeckillVoucherService;@Resourceprivate IVoucherService iVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Override@Transactionalpublic Result seckillVoucher(Long voucherId) {if (voucherId == null || voucherId < 0) {return Result.fail("請求id錯誤");}Voucher voucher = iVoucherService.getById(voucherId);if (voucher == null) {return Result.fail("當前優惠券不存在");}SeckillVoucher seckillVoucher = iSeckillVoucherService.getById(voucherId);if (seckillVoucher == null) {return Result.fail("秒殺優惠券不存在");}LocalDateTime beginTime = seckillVoucher.getBeginTime();if (LocalDateTime.now().isBefore(beginTime)) {return Result.fail("秒殺未開始");}LocalDateTime endTime = seckillVoucher.getEndTime();if (LocalDateTime.now().isAfter(endTime)) {return Result.fail("秒殺已結束");}int leftStock = seckillVoucher.getStock();if (leftStock <= 0) {return Result.fail("優惠券已被搶空");}boolean update = iSeckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();if (!update) {return Result.fail("服務器內部錯誤");}VoucherOrder voucherOrder = new VoucherOrder();long orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);voucherOrder.setUserId(UserHolder.getUser().getId());voucherOrder.setVoucherId(voucherId);voucherOrder.setPayType(voucher.getType());boolean save = this.save(voucherOrder);if (!save) {return Result.fail("服務器內部錯誤");}return Result.ok(orderId);}超賣問題
樂觀鎖
- 版本號法
使用CAS方法:
int leftStock = seckillVoucher.getStock();if (leftStock <= 0) {return Result.fail("優惠券已被搶空");}boolean update = iSeckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId)// 更新的時候判斷當前剩余庫存量是否跟開始查詢的時候相等.eq("stock",leftStock).update();弊端:
成功率很低
庫存改為大于0
int leftStock = seckillVoucher.getStock();if (leftStock <= 0) {return Result.fail("優惠券已被搶空");}boolean update = iSeckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId)// 更新的時候判斷當前剩余庫存量是否大于0.gt("stock",0).update();一人一單
在搶購前判斷數據庫是否存在已經的訂單
// 查詢秒殺優惠券,該用戶是否已經搶到int count = query().eq("user_id", userId).eq("voucher_id",voucherId).count();if (count > 0) {return Result.fail("您已經搶過了");}boolean update = iSeckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).eq("stock",leftStock).update();if (!update) {return Result.fail("服務器內部錯誤");}問題:
在多線程上會出現都走到代碼第6行,然后再一起執行更新操作,就會出現一人多單情況
解決:
對操作進行加鎖
@Overridepublic Result seckillVoucher(Long voucherId) {if (voucherId == null || voucherId < 0) {return Result.fail("請求id錯誤");}Voucher voucher = iVoucherService.getById(voucherId);if (voucher == null) {return Result.fail("當前優惠券不存在");}SeckillVoucher seckillVoucher = iSeckillVoucherService.getById(voucherId);if (seckillVoucher == null) {return Result.fail("秒殺優惠券不存在");}LocalDateTime beginTime = seckillVoucher.getBeginTime();if (LocalDateTime.now().isBefore(beginTime)) {return Result.fail("秒殺未開始");}LocalDateTime endTime = seckillVoucher.getEndTime();if (LocalDateTime.now().isAfter(endTime)) {return Result.fail("秒殺已結束");}int leftStock = seckillVoucher.getStock();if (leftStock <= 0) {return Result.fail("優惠券已被搶空");}return getResult(voucherId, voucher, leftStock);}@Transactionalpublic Result getResult(Long voucherId, Voucher voucher, int leftStock) {Long userId = UserHolder.getUser().getId();// 查詢秒殺優惠券,該用戶是否已經搶到long orderId;// 對相同用戶并發請求加鎖 new// String類型也可能出現不同對象 new // intern()的作用是返回字符串常量池中對象的地址 new synchronized (userId.toString().intern()) { int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();if (count > 0) {return Result.fail("您已經搶過了");}boolean update = iSeckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).eq("stock", leftStock).update();if (!update) {return Result.fail("服務器內部錯誤");}VoucherOrder voucherOrder = new VoucherOrder();orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);voucherOrder.setUserId(userId);voucherOrder.setVoucherId(voucherId);voucherOrder.setPayType(voucher.getType());boolean save = this.save(voucherOrder);if (!save) {return Result.fail("服務器內部錯誤");}}return Result.ok(orderId);} }此時還會出現一個問題:
- 當一個線程釋放鎖后,但是事務還沒提交,那么還是會出現一人多單的情況,所以需要對整個方法調用進行加鎖
對于spring事務管理熟悉的話,在seckillVoucher方法中調用有事務的getResult這個方法,會出現事務失效。因為相當于this.getResult,用的不是代理類。
解決方法:
@Overridepublic Result seckillVoucher(Long voucherId) {if (voucherId == null || voucherId < 0) {return Result.fail("請求id錯誤");}Voucher voucher = iVoucherService.getById(voucherId);if (voucher == null) {return Result.fail("當前優惠券不存在");}SeckillVoucher seckillVoucher = iSeckillVoucherService.getById(voucherId);if (seckillVoucher == null) {return Result.fail("秒殺優惠券不存在");}LocalDateTime beginTime = seckillVoucher.getBeginTime();if (LocalDateTime.now().isBefore(beginTime)) {return Result.fail("秒殺未開始");}LocalDateTime endTime = seckillVoucher.getEndTime();if (LocalDateTime.now().isAfter(endTime)) {return Result.fail("秒殺已結束");}int leftStock = seckillVoucher.getStock();if (leftStock <= 0) {return Result.fail("優惠券已被搶空");}Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {// 獲取當前對象的代理對象 newIVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.getResult(voucherId, voucher, leftStock);}}/*** xc* @param voucherId* @param voucher* @param leftStock* @return*/@Override@Transactionalpublic Result getResult(Long voucherId, Voucher voucher, int leftStock) {Long userId = UserHolder.getUser().getId();// 查詢秒殺優惠券,該用戶是否已經搶到long orderId;// 對相同用戶并發請求加鎖// String類型也可能出現不同對象// intern()的作用是返回字符串常量池中對象的地址int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();if (count > 0) {return Result.fail("您已經搶過了");}boolean update = iSeckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).eq("stock", leftStock).update();if (!update) {return Result.fail("服務器內部錯誤");}VoucherOrder voucherOrder = new VoucherOrder();orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);voucherOrder.setUserId(userId);voucherOrder.setVoucherId(voucherId);voucherOrder.setPayType(voucher.getType());boolean save = this.save(voucherOrder);if (!save) {return Result.fail("服務器內部錯誤");}return Result.ok(orderId);}需要導入aspectj的依賴
<dependency><groupId>org.aspectj</groupId><artifactId>aspectjweaver</artifactId></dependency>然后在主啟動類上暴露代理對象
@MapperScan("com.hmdp.mapper") @SpringBootApplication @EnableTransactionManagement @EnableAspectJAutoProxy(exposeProxy = true) public class HmDianPingApplication {public static void main(String[] args) {SpringApplication.run(HmDianPingApplication.class, args);}}分布式鎖
通過加鎖可以解決在單機情況下的一人一單安全問題,但是在集群模式下就不行了
模擬集群效果:
怎么添加idea的Serivces
在Service中添加SpringBoot項目,通過不同的端口啟動
# 在VM options中添加此段代碼,以指定端口啟動 -Dserver.port=8082前端nginx通過負載均衡訪問后端接口
在集群模式下:
有多個JVM實例的存在,所以又會出現超賣問題
使用分布式鎖解決:
流程:
基于Redis實現分布式鎖初級版本:
需求:定義一個類,實現下面接口,利用Redis實現分布式鎖功能。
public interface Ilock {/*** 嘗試獲取鎖* @param timeoutSec 過期時間* @return 獲取鎖是否成功*/boolean tryLock(long timeoutSec);void unlock(); }簡單鎖實現類
package com.hmdp.utils; import org.springframework.data.redis.core.StringRedisTemplate; import java.util.concurrent.TimeUnit;/*** @author: xc* @date: 2023/4/27 20:46*/public class SimpleRedisLock implements ILock {/*** 鎖的統一前綴*/private static final String KEY_PREFIX = "lock:";/*** 鎖的名稱*/private String name;// 因為不是spring管理的bean所以需要構造方法private StringRedisTemplate stringRedisTemplate;public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {this.name = name;this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean tryLock(long timeoutSec) {// 當前線程idLong threadId = Thread.currentThread().getId();// setIfAbsent:如果不存在就設置Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId+"", timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(success);}@Overridepublic void unlock() {// 刪除keystringRedisTemplate.delete(KEY_PREFIX + name);} }實現初級redis分布式鎖版本
Long userId = UserHolder.getUser().getId();// 因為只對同一個用戶加鎖,所以用 order:+userId 作為鎖的keySimpleRedisLock lock = new SimpleRedisLock("order:"+userId, stringRedisTemplate);// 獲取鎖boolean tryLock = lock.tryLock(LOCK_TIMEOUT);if (!tryLock) {// 獲取鎖失敗return Result.fail("不允許重復下單");}try {IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.getResult(voucherId, voucher, leftStock);} finally {// 拿到鎖的釋放鎖lock.unlock();}會出現的問題:
會釋放別人的鎖
解決方案:釋放鎖的時候先看一下是不是自己的鎖
流程:
改進Redis的分布式鎖:
-
在獲取鎖時存入線程表示(可以用UUID表示)
-
在釋放鎖時獲取線程ID,判斷是否與當前線程標示一致
-
- 如果一致則釋放鎖
- 如果不一致則不釋放鎖
修改獲取鎖和釋放鎖的邏輯
package com.hmdp.utils; import org.springframework.data.redis.core.StringRedisTemplate; import java.util.concurrent.TimeUnit; import java.util.UUID;/*** @author: xc* @date: 2023/4/27 20:46*/public class SimpleRedisLock implements ILock {/*** 鎖的統一前綴*/private static final String KEY_PREFIX = "lock:";/*** 隨機生成線程uuid的前綴*/private static final String ID_PREFIX = UUID.randomUUID() +"-";/*** 鎖的名稱*/private String name;private StringRedisTemplate stringRedisTemplate;public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {this.name = name;this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean tryLock(long timeoutSec) {long threadId = Thread.currentThread().getId();// 標識位 ID_PREFIX+threadIdBoolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, ID_PREFIX+threadId, timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(success);}@Overridepublic void unlock() {long threadId = Thread.currentThread().getId();// 判斷標識是否一致if ((ID_PREFIX+threadId).equals(stringRedisTemplate.opsForValue().get(KEY_PREFIX + name))) {stringRedisTemplate.delete(KEY_PREFIX + name);}} }出現問題:
- 線程1如果判斷完是自己的鎖后,出現gc阻塞線程知道鎖過期,此時線程2過來獲取到鎖執行自己的業務,然后線程1又阻塞完畢回到刪除鎖,就會將線程2的鎖刪除。然而又有線程3來過來獲取鎖沒獲取到,就會出現線程2和線程3同時執行代碼。
解決辦法: 保證判斷鎖和釋放鎖的原子性
使用Redis的Lua腳本:
關于redis的基本語法
執行Lua腳本
再次改進Redis的分布式鎖
總結
基于Redis的分布式鎖實現思路:
- 利用set nx ex獲取鎖,并設置過期時間,保存線程標示
- 釋放鎖時先判斷線程標示是否與自己一致,一致則刪除鎖
特性:
- 利用set nx滿足互斥性
- 利用set ex保證故障時鎖依然能釋放,避免死鎖,提高安全性
- 利用Redis集群保證高可用和高并發特性
基于Redis的分布式鎖的優化:
基于setnx實現的分布式鎖存在下面的問題:
Redisson入門
引入依賴
<dependency><groupId>org.redisson</groupId><artifactId>redisson</artifactId><version>3.13.6</version></dependency>配置Redisson客戶端
/*** @author xc* @date 2023/4/28 9:16*/ @Configuration public class RedissonConfig {public RedissonClient redissonClient() {// 配置Config config = new Config();config.setTransportMode(TransportMode.EPOLL);config.useSingleServer().setAddress("redis://127.0.0.1:6379");return Redisson.create(config);}}使用Redisson的分布式鎖
Redisson可重入鎖的原理
流程圖:
獲取鎖Lua腳本:
釋放鎖Lua腳本:
Redisson底層源碼講解(P66、P67)
Redisson分布式鎖原理:
- 可重入:利用hash結構記錄線程id和重入次數
- 可重試:利用信號量和PubSub功能實現等待、喚醒、獲取鎖失敗的重試機制
- 超時續約:利用watchDog,每個一段時間(releaseTime/3),重置超時時間
解決主從一致(P68)
Redis優化秒殺
改進秒殺業務,提高并發性能
需求:
- 新增秒殺優惠券的同時,將優惠券信息保存到Redis中
- 基于Lua腳本,判斷秒殺庫存、一人一單、決定用戶是否搶購成功
lua腳本
--- --- Generated by Luanalysis --- Created by xc. --- DateTime: 2023/4/28 11:48 --- local voucherId = ARGV[1] local userId = ARGV[2]local stockKey = 'seckill:stock:' .. voucherId local orderKey = 'seckill:order:' .. voucherIdif(tonumber(redis.call('get',stockKey)) <= 0) thenreturn 1 endif(redis.call('sismember',orderKey,userId) == 1) thenreturn 2 end redis.call('incrby',stockKey,-1) redis.call('sadd',orderKey,userId) return 0java代碼
private static final DefaultRedisScript<Long> SECKILL_SCIPT;static {SECKILL_SCIPT = new DefaultRedisScript<>();ClassPathResource pathResource = new ClassPathResource("seckill.lua");SECKILL_SCIPT.setLocation(pathResource);SECKILL_SCIPT.setResultType(Long.class);}@Overridepublic Result seckillVoucher(Long voucherId) {// 1.執行lua腳本Long userId = UserHolder.getUser().getId();long execute = stringRedisTemplate.execute(SECKILL_SCIPT,Collections.EMPTY_LIST,voucherId.toString(), userId.toString());// 2.判斷結果是否為0if (execute != 0) {// 2.1 不為0,代表沒有購買資格// 為1時庫存不足,2時重復下單return Result.fail(execute == 1 ? "庫存不足" : "重復下單");}// 2.2 為0 ,有購買資格,把下單信息保存到阻塞隊列long orderId = redisIdWorker.nextId("order"); // new ArrayBlockingQueue<>()// 3.返回訂單idreturn Result.ok(orderId);}- 如果搶購成功,將優惠券id和用戶id封裝后存入阻塞隊列
- 開啟線程任務,不斷從阻塞隊列中回去信息,實現異步下單功能
總結
秒殺業務的優化思路是什么?
- 先利用Redis完成庫存余量、一人一單判斷,完成搶單業務
- 再將下單業務放入阻塞隊列,利用獨立線程異步下單
基于阻塞隊列的異步秒殺存在哪些問題?
- 內存限制問題
- 數據安全問題
Redis消息隊列實現異步秒殺
消息隊列,字面意思就是存放消息隊列。最簡單的消息隊列模型包括3個角色
- 消息隊列:存儲和管理消息,也被稱為消息代理
- 生產者:發送消息到消息隊列
- 消費者:從消息隊列獲取消息并處理消息
基于List結構模擬消息隊列
基于List的消息隊列由哪些優缺點?
優點:
- 利用Redis存儲,不受限于JVM內存上限
- 基于Redis的持久化機制,數據安全性有保證
- 可以滿足消息有序性
缺點:
- 無法避免消息丟失
- 只支持單消費者
基于PubSub的消息隊列
基于PubSub的消息隊列由哪些優缺點?
優點:
- 采用發布訂閱模型,支持多生產、多消費
缺點:
- 不支持數據持久化
- 無法避免消息丟失
- 消息堆積有上限,超出時數據丟失
基于Stream的消息隊列
基于STREAM的消息隊列由哪些優缺點?
優點:
- 消息可回溯
- 一個消息可以被多個消費者讀取
- 可以阻塞讀取
缺點:
- 有消息漏讀的風險
基于Stream的消息隊列-消費者組
創建消費者組:
XGROUP CREATE key groupName ID [MKSTREAM]- key:隊列名稱
- groupName:消費者組名稱
- ID:起始ID標示,$代表隊列中最后一個消息,0則代表隊列中第一個消息
- MKSTREAM:隊列不存在時自動創建隊列
其它常見命令:
# 刪除指定的消費者組 XGROUP DESTORY key groupName# 給指定的消費者組添加消費者 XGROUP CREATECONSUMER key groupname consumername# 刪除消費者組中的指定消費者 XGROUP DELCONSUMER key groupname consumernameStream類型消息隊列的XREADGROUP命令特點:
- 消息可回溯
- 可以多消費者爭搶消息,加快消費速度
- 可以阻塞讀取
- 沒有消息漏讀風險
- 有消息確認機制,保證消息至少被消費一次
總結
以上是生活随笔為你收集整理的实战篇--优惠券秒杀的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 你是哪种类型的代码斗士
- 下一篇: 洪灾面前,能抗衡的很少,但能做的很少