目錄
? ? ? 一. 全局id生成器
1.為什么需要全局id生成器
2.傳統方式的缺陷:
3.典型全局 ID 生成方案的設計思路
二.優惠券秒殺-Redis實現全局唯一id
三.優惠券秒殺-添加優惠券
四.優惠券秒殺-實現秒殺下單
五.?一人一單問題
1.單體項目下
1,超賣問題思路分析
2.樂觀鎖解決問題方法
2.集群項目下
1.為什么需要分布式鎖
2.分布式鎖要求及對比
3. 使用及基本問題解決
1.改進1
2.改進2
3.改進3
4.改進4(基于redis分布式鎖的優化)
5.改進4(異步秒殺)
六. 消息隊列實現異步秒殺
1.為什么需要消息隊列而不是阻塞隊列
?1.解耦與部署邊界
2. 高并發應對
3. 可靠性保障
4. 功能擴展性
2. Redis消息隊列LIst
3. Redis消息隊列PubSub
4. Redis消息隊列Stream
2.基于Redis的Stream結構作為消息隊列, 實現異步秒殺
? ? ? 一. 全局id生成器
1.為什么需要全局id生成器
- 單個數據庫或服務節點可通過自增ID, 來保證局部唯一性,但不同節點的自增邏輯獨立, 必然出現ID重復, 導致業務出錯.
- 在分布式系統下, 業務流程可能跨多個服務(如訂單, 支付, 物流), 同一業務實體(如一筆訂單)的相關詩句需在多服務間關聯, 若無全局唯一標識, 數據關聯將完全失效
-
業務場景對 ID 的核心訴求需要全局生成器支撐
-
除了唯一性,實際業務往往對 ID 有更復雜的要求
1.有序性與可排序性
2.可讀性與業務關聯性
3.安全性與防猜測性
4.高可用性與性能適配
2.傳統方式的缺陷:
傳統方案 | 局限性 |
---|---|
數據庫自增 ID | 1. 依賴單庫單點,數據庫故障會導致 ID 生成中斷; 2. 高并發下數據庫寫入瓶頸明顯; 3. 跨庫分表時無法保證全局唯一。 |
UUID/GUID | 1. 無時間信息,無法通過 ID 排序; 2. 字符串格式(36 位)占用存儲空間大,索引效率低; 3. 無業務含義,可讀性差。 |
本地節點自增 | 不同節點的 ID 范圍可能重疊,無法保證全局唯一 |
3.典型全局 ID 生成方案的設計思路
-
雪花算法(Snowflake)
由 Twitter 提出,ID 結構為 “時間戳 + 機器 ID + 數據中心 ID + 序列號”,通過劃分不同節點的 ID 生成范圍保證唯一性,同時包含時間信息支持排序。- 優點:高性能、含時間戳、可定制化;
- 缺點:依賴節點 ID 配置,需避免機器 ID 沖突。
-
號段模式(Segment)
由數據庫預分配 ID 號段(如節點 A 分配1-10000
,節點 B 分配10001-20000
),節點本地生成 ID,用完后向數據庫申請新號段。- 優點:減少數據庫交互,性能高;
- 缺點:需設計號段回收機制,避免 ID 浪費。
-
Redis 自增 + 時間戳
利用 Redis 的INCR
命令生成全局遞增序列,結合時間戳拼接成 ID。- 優點:部署簡單、性能高;
- 缺點:依賴 Redis 可用性,需處理 Redis 宕機后的 ID 連續性問題。
-
分布式 UUID(如 ULID)
兼容 UUID 格式,但包含時間戳和隨機數,支持排序且唯一性更強
二.優惠券秒殺-Redis實現全局唯一id
為了防止訂單號出現重復或者暴露信息的情況,通過Redis生成全局唯一ID
RedisWorker工具類代碼:
@Component
public class RedisIdWorker {/*** 開始時間戳*/private static final long BEGIN_TIMESTAMP = 1735689600L;/*** 序列號的位數*/private static final int COUNT_BITS = 32;@Resourceprivate StringRedisTemplate stringRedisTemplate;public Long nextId(String KeyPrefix) {// 1. 生成時間戳LocalDateTime now = LocalDateTime.now();long nowsecond = now.toEpochSecond(ZoneOffset.UTC);long timestamp = nowsecond - BEGIN_TIMESTAMP;// 2. 生成序列號// 2.1 獲取當前日期, 精準到天String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));// 2.2 自增長Long count = stringRedisTemplate.opsForValue().increment("irc" + KeyPrefix + ":" + date);// 3. 拼接并返回return timestamp << COUNT_BITS | count;}
}
三.優惠券秒殺-添加優惠券
手動添加優惠券,為之后做準備, 我是使用apifox通過接口添加的.
四.優惠券秒殺-實現秒殺下單
下單時需要判斷兩點:
- 秒殺是否開始或結束,如果尚未開始或已經結束則無法下單
- 庫存是否充足,不足則無法下單
這里還有一個問題, 也就是在微服務下, 大量請求情況下如何保持一人一單(線程問題). 我們會在后面解決
VoucherOrderServiceImpl代碼:
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker; //獲取全局唯一id@Override
@Transactional //設計對兩張表進行操作,加上事務回滾,一旦出現問題可以進行事務回滾
public Result seckillVoucher(Long voucherId) {//1.查詢優惠券SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);//2.判斷秒殺是否開始if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {//尚未開始return Result.fail("秒殺尚未開始!!");}//3.判斷秒殺是否已經結束if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {//已經結束return Result.fail("秒殺已經結束!!");}//4.判斷庫存是否充足if (seckillVoucher.getStock() < 1){//庫存不足return Result.fail("庫存不足!!");}//5.扣減庫存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id",voucherId).update();if (!success){//扣減失敗return Result.fail("庫存不足");}//6.創建訂單VoucherOrder voucherOrder = new VoucherOrder();//6.1訂單idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);//6.2用戶idLong userId = UserHolder.getUser().getId();voucherOrder.setUserId(userId);//6.3代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);//7.返回訂單idreturn Result.ok(orderId);
}
在數據庫中的券訂單表中會新增秒殺券的訂單,同時數據庫中券的庫存也會減少一個
五.?一人一單問題
1.單體項目下
1,超賣問題思路分析
果我們使用Jmeter創建多個線程來搶券,庫存可能會出現負數。這是個線程安全問題。也是并發安全問題,根本原因就是多個線程在操作共享的資源并且操作資源的代碼有好多行,多個線程的代碼沒有按順序執行,而是穿插執行。解決這個問題就是采用鎖的方案。超賣問題是典型的多線程安全問題,針對這一問題的常見解決方案就是加鎖。鎖有兩種(理念),悲觀鎖和樂觀鎖。
2.樂觀鎖解決問題方法
我們在編寫代碼的時候,代碼如下:其實很簡單
// 5.1.查詢訂單
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判斷是否存在
if (count > 0) {// 用戶已經購買過了return Result.fail("用戶已經購買過一次!");
}// 6.扣減庫存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1") // set stock = stock - 1.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0.update();
if (!success) {// 扣減失敗return Result.fail("庫存不足!");
}// 7.創建訂單
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.訂單id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用戶id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);// 7.返回訂單id
return Result.ok(orderId);
但是用JMeter測試,一個用戶去搶券,秒殺券的庫存少了十個。原因就是多線程并發操作的安全問題,發生了代碼穿插執行的問題(與超賣問題原因相同)。這里沒法用樂觀鎖,因為我這里是要插入操作,不是判斷是否被修改,本來這條記錄沒有,你怎么使用樂觀鎖去判斷是否原來的數據被修改呢,所以不可以用樂觀鎖。這里只能用悲觀鎖方案。
我們把代碼分為兩部分,原來的通過查詢來判斷是否還有庫存放在上面一個函數,創建優惠券訂單放在下面一個新的函數createVoucherOrder中。
但是如果像上圖中,那樣在函數上加鎖,那么任何一個用戶來了都要加鎖,而且是同一把鎖,那整個方法只能被串行執行了,性能會很差。一人一單應該是同一個用戶來了,才去判斷并發安全問題。我們應該對用戶(ID)進行加鎖
@Transactional
public Result createVoucherOrder(Long voucherId) {// 5.一人一單Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {//每一次請求來Id對象都會改變,我們要對值進行加鎖(toString也會new一個字符串對象,所以加上個intern())// 5.1.查詢訂單int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();// 5.2.判斷是否存在if (count > 0) {// 用戶已經購買過了return Result.fail("用戶已經購買過一次!");}// 6.扣減庫存boolean success = seckillVoucherService.update().setSql("stock = stock - 1") // set stock = stock - 1.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0.update();if (!success) {// 扣減失敗return Result.fail("庫存不足!");}// 7.創建訂單VoucherOrder voucherOrder = new VoucherOrder();// 7.1.訂單idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 7.2.用戶idvoucherOrder.setUserId(userId);// 7.3.代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);// 7.返回訂單idreturn Result.ok(orderId);}
}
又出現了一個新的問題,就是我們現在用了事務,我再整個方法的代碼運行完畢,鎖也釋放了的時候,springboot才對事務進行提交(把數據寫入數據庫),可能還沒來得及提交,已經有新的線程拿到了鎖,開始查數據庫了,發現沒有針對同一個用戶Id的訂單,他也會繼續執行針對相同用戶Id創建訂單的代碼,這時候又會出現線程安全問題。這時候就是鎖的范圍太小了,我們應該在整個方法的外面加鎖。優化方法如下
這時候雖然已經線程安全了,但是還存在一個事務的問題。
this.createVoucherOrder(voucherId);他是沒有事務功能的。因為我們是通過注解,對方法生成一個代理對象,來實現事務。你用了this就是使用(java類中的)它本身,不是它的代理對象。我們需要獲得代理對象,代碼如下。還需要暴露代理對象,之前還要引入依賴。
在集群下synchronized并不能保證線程的并發安全。因為在集群模式下,每一個節點都是一個全新的JVM,每個JVM都有自己的鎖。鎖監視器只能在當前JVM的范圍內,監視線程實現互斥。
需要實現多個JVM(多個Tomcat)使用相同的鎖監視器,需要一把跨進程、跨JVM的鎖(Redis剛好可以勝任)。
2.集群項目下
1.為什么需要分布式鎖
一個JVM有一個鎖監視器,? 只會有一個線程獲取鎖, 可以實現線程互斥, 而在集群下, 有多個JVM同時運行, 也就是多個線程并發, 獲取到鎖, 無法實現線程互斥. 這要求多個JVM需要同一個鎖監視器.
2.分布式鎖要求及對比
集群下對分布式鎖的要求:
常見方法的對比:
由圖可知redis勝出
3. 使用及基本問題解決
1.改進1
將鎖的基本操作封裝一個工具類
使用redis鎖替代 synchronized ,解決鎖同一個用戶的多個進程方法是在鎖上加上用戶id
但是也存在線程安全問題
2.改進2
問題分析:當線程A獲取鎖, 進行業務處理時, 發生了阻塞, 因等待超時釋放鎖,;而線程B獲取鎖, 進行業務邏輯, 此時, 線程A脫離阻塞, 完成業務, 釋放了不屬于線程A的鎖, 也就是線程B的鎖.
解決辦法: 可以給每個線程的鎖添加上標識, 在釋放鎖時判斷是否為自己的鎖
解決方案實現:
由于線程id是一個jvm內部遞增的數字,集群條件下多個jvm可能導致線程id重復,因此要確保線程標識唯一
對封裝的鎖操作進行改寫
3.改進3
問題分析:判斷鎖標識和釋放鎖之間發生阻塞(原子性問題)
在某個線程判斷鎖標識和釋放鎖之間發生阻塞, 其他線程就可以乘虛而入, 出現線程安全問題
解決辦法:判斷鎖和刪除鎖應該具有原子性,一氣呵成
有redis事務和lua腳本兩種可選擇, 以下對比:
-
Redis 事務:
基于MULTI
、EXEC
、DISCARD
、WATCH
等命令實現,本質是將多個命令打包成一個序列,一次性提交執行。事務執行期間,其他客戶端的命令不會插入到事務隊列中,保證了操作的 “隔離性”,但不保證嚴格的 “原子性”(單個命令失敗不影響其他命令執行)。 -
Lua 腳本:
基于 Redis 內置的 Lua 解釋器,允許將復雜邏輯編寫為 Lua 腳本,通過EVAL
或EVALSHA
命令執行。腳本中的所有 Redis 命令會被視為一個不可分割的原子操作,執行期間不會被其他請求打斷,且腳本本身可以包含條件判斷、循環等復雜邏輯。
由此可見Redis事務無法保證原子性, Lua腳本勝出.
編寫Lua腳本
lua腳本執行是 java調用redis,redis調用lua腳本
在spring資源目錄下 創建一個lua腳本,名為unlock.lua
修改封裝的釋放鎖方法:
4.改進4(基于redis分布式鎖的優化)
由于redis分布式鎖無法實現重入, 重試獲取等方法, 不夠完善, 引入redisson
可重入鎖原理
一個線程多次獲取到鎖,稱為可重入鎖。自定義獲取鎖的方式是采用 setNx lock thread,當此線程想要再次獲取鎖時,還是通過setNx lock thread,導致獲取失敗。對此線程和其他線程不加判別一律失敗。
而 Redission 采用的是 setNx lock thread 1這種hash結構,key 是鎖的標識 filed是線程的標識,value是獲取的次數。當此線程再次獲取時,會對線程進行判斷,然后value值+1,在釋放時,同樣進行判斷然后 value-1 直到為0,釋放鎖
流程圖
有多次查詢判斷和數據操作不是同時進行,為防止線程安全問題,保證原子性,使用lua腳本
獲取鎖時:
釋放鎖時:
重試原理
redission并沒有一味的循環嘗試,而是根據自己的剩余的時間和鎖
RedissonLock 類
第一次獲取,若失敗,則在自己剩余的時間里,監聽鎖的釋放時間
若監聽到鎖的釋放卻沒有爭取到,則開始再次進行訂閱,在自己剩余的時間里,進行循環
流程圖
鎖釋放原理
在獲取鎖時,若傳遞鎖過期釋放時間,則過期就釋放。若不傳遞,采用看門狗方式,初始為30s,每隔10s刷新過期時間,重新設置10s,即使鎖重入也是如此。在釋放鎖時取消刷新
判斷是否是新創建的鎖,若是,給添加上定時刷新任務
主從一致原理
當redis以主從模式,讀寫分離下,主節點負責寫,然后將數據同步給從節點,從節點負責讀。如果創建鎖后,在同步給從節點一瞬間主節點宕機,將導致鎖的數據的丟失,其他線程還能創建鎖,導致并發安全問題
redission 解決方案:
設置三個及以上的主節點,同時向各個節點保存鎖的標識。只要有一個節點沒來得及同步就宕機,會導致獲取鎖失敗
缺點: 運維成本高, 實現復雜
5.改進4(異步秒殺)
問題分析: 客戶端發送請求, NGINX負載均衡到Tomcat, Tomcat內部進行業務處理, 大家可以看到其業務時串行執行, 整個業務耗時是每一步的耗時之和, 還有對數據庫的讀寫操作和加上了分布式鎖, 導致耗時非常長.
解決方案:?將判斷與數據庫處理分離, redis處理的快, 而Tomcat中數據庫的讀寫操作很慢, 讓reids先行處理, 可以增加吞吐量, 只需要Tomcat不斷從隊列中獲取,?
在實現秒殺庫存時, key為該優惠券編號,存入庫存,可以選用String類型; key為該優惠券編號,值為購買過的用戶,且不能重復。因此需要采用Set類型
實現步驟:
- 將對redis的查詢、判斷、數據操作寫入一個lua腳本,防止線程并發安全問題
2. 在java中調用redis,使得redis調用該腳本,同時將信息返回用戶
3. 開啟阻塞隊列 ,將信息放入隊列
4.?新開辟一個線程,從阻塞隊列中獲取信息后,執行數據庫操作
當前問題:
- 內存限制 若不加以限制,可能導致內存溢出;若限制,隊列中滿了可能會丟失數據
- 數據安全問題:
- 基于內存儲存,如果突然宕機,使得任務丟失
- 從隊列取出后,處理發生異常,導致任務丟失
六. 消息隊列實現異步秒殺
1.為什么需要消息隊列而不是阻塞隊列
?1.解耦與部署邊界
- 消息隊列跨進程 / 服務,生產者、消費者可分布在不同機器,秒殺場景中前端接收請求、后端處理訂單能徹底解耦,獨立擴容維護;
- 阻塞隊列局限單進程,線程間通信,無法支撐分布式秒殺架構,耦合性高。
2. 高并發應對
- 消息隊列異步非阻塞,秒殺時生產者發消息即返回,快速承接瞬時流量;
- 阻塞隊列滿 / 空時阻塞線程,高并發下生產者 / 消費者線程掛起,拖慢響應、壓垮系統。
3. 可靠性保障
- 消息隊列支持持久化(如 Redis 持久化、Kafka 磁盤存儲 ),是JVM以外的, 不受JVM內存限制, 服務器故障也能留存秒殺請求;
- 阻塞隊列依賴內存,進程重啟 / 故障則消息全丟,無法保障秒殺訂單完整性。
4. 功能擴展性
- 消息隊列自帶重試、死信、優先級等機制,適配秒殺的訂單重試、插隊(如 VIP 優先)等復雜需求;
- 阻塞隊列功能單一,僅基礎隊列操作,難滿足秒殺場景多樣化邏輯。
簡單說:消息隊列能跨進程解耦、扛高并發、保消息不丟、靈活擴展,天然適配秒殺;阻塞隊列受限于單進程、易阻塞、無持久化,撐不起秒殺的復雜場景 。
2. Redis消息隊列LIst
3. Redis消息隊列PubSub
4. Redis消息隊列Stream
單消費者模式:
消費者組:
2.基于Redis的Stream結構作為消息隊列, 實現異步秒殺
使用MQ的Stream方式結合group機制 代替之前的JVM的阻塞隊列
- 為每個異步任務創建一個消息隊列異步秒殺任務,創建一個隊列名為 stream.order.voucher
采用創建組時,如果指定的隊列key不存在將隊列也進行創建 使用 mkstream
xGroup create stream.order.voucher g1 0 mkstream - 修改lua腳本,成功后直接向redis隊列中發送訂單信息 免去java自己去指定阻塞隊列,自己去放入數據,取出數據
- 父線程先去生成訂單編號作為參數,執行腳本 根據腳本結果進行判斷返回前臺,如果創建成功,腳本會自動將訂單信息發送到隊列中
- 子線程從redis指定消息隊列中獲取消息,完成真正下單任務
從隊列取出進行處理
從失敗隊列取出再次處理
真正操作數據庫的方法
通過redisMq處理異步消息,使得前臺返回速度加快,后臺消息不會丟失,且集群環境下能夠一起處理,合理分配任務且不會重復