前言
知不足而奮進,望遠山而前行
13.商城業務-分散式事務
13.1本地事務在分散式下的問題
主要步驟:
- 遠端服務其實成功了,由於網路故障沒有返回,導致訂單回滾,存庫成功扣減
- 遠端服務執行完成,下面的其他方法出現問題,導致已執行的遠端請求不能回滾
13.2本地事務隔離級別&傳播行為等複習
事務的基本性質
資料庫事務的幾個特性:原子性(Atomicity )、一致性( Consistency )、隔離性或獨立性( Isolation) 和永續性(Durabilily),簡稱就是 ACID
- 原子性:一系列的操作整體不可拆分,要麼同時成功,要麼同時失敗
- 一致性:資料在事務的前後,業務整體一致。
- 隔離性:事務之間互相隔離。
- 永續性:一旦事務成功,資料一定會落盤在資料庫。
事務的隔離級別
- READ UNCOMMITTED(讀未提交):該隔離級別的事務會讀到其它未提交事務的資料,此現象也稱之為髒讀。
- READ COMMITTED(讀提交:一個事務可以讀取另一個已提交的事務,多次讀取會造成不一樣的結果,此現象稱為不可重 復讀問題,Oracle 和 SQL Server 的預設隔離級別。
- REPEATABLE READ(可重複讀):該隔離級別是 MySQL 預設的隔離級別,在同一個事務裡,select 的結果是事務開始時時間 點的狀態,因此,同樣的 select 操作讀到的結果會是一致的,但是,會有幻讀現象。MySQL 的 InnoDB 引擎可以透過 next-key locks 機制(參考下文"行鎖的演算法"一節)來避免幻讀。
- SERIALIZABLE(序列化):在該隔離級別下事務都是序列順序執行的,MySQL 資料庫的 InnoDB 引擎會給讀操作隱式 加一把讀共享鎖,從而避免了髒讀、不可重讀復讀和幻讀問題。
事務的傳播行為
-
1、PROPAGATION_REQUIRED:如果當前沒有事務,就建立一個新事務,如果當前存在事務, 就加入該事務,該設定是最常用的設定。
-
2、PROPAGATION_SUPPORTS:支援當前事務,如果當前存在事務,就加入該事務,如果當 前不存在事務,就以非事務執行。
-
3、PROPAGATION_MANDATORY:支援當前事務,如果當前存在事務,就加入該事務,如果 當前不存在事務,就丟擲異常。
-
4、PROPAGATION_REQUIRES_NEW:建立新事務,無論當前存不存在事務,都建立新事務。
-
5、PROPAGATION_NOT_SUPPORTED:以非事務方式執行操作,如果當前存在事務,就把當 前事務掛起。
-
6、PROPAGATION_NEVER:以非事務方式執行,如果當前存在事務,則丟擲異常。
-
7、PROPAGATION_NESTED:如果當前存在事務,則在巢狀事務內執行。如果當前沒有事務, 則執行與 PROPAGATION_REQUIRED 類似的操作。
SpringBoot 事務關鍵點
事務的自動配置:TransactionAutoConfiguration
事務的坑:在同一個類裡面,編寫兩個方法,內部呼叫的時候,會導致事務設定失效。原因是沒有用到代理物件的緣故。
解決:
-
0)、匯入 spring-boot-starter-aop
-
1)、@EnableTransactionManagement(proxyTargetClass = true)
-
2)、@EnableAspectJAutoProxy(exposeProxy=true)
-
3)、AopContext.currentProxy() 呼叫方法
使用代理物件來呼叫事務方法
13.3分散式CAP&Raft原理
為什麼有分散式事務
分散式系統經常出現的異常 機器當機、網路異常、訊息丟失、訊息亂序、資料錯誤、不可靠的 TCP、儲存資料丟失...
分散式事務是企業整合中的一個技術難點,也是每一個分散式系統架構中都會涉及到的一個 東西,特別是在微服務架構中,幾乎可以說是無法避免。
CAP 定理與 BASE 理論
CAP 定理
CAP 原則又稱 CAP 定理,指的是在一個分散式系統中
- 一致性(Consistency):
- 在分散式系統中的所有資料備份,在同一時刻是否同樣的值。(等同於所有節點訪 問同一份最新的資料副本)
- 可用性(Availability):
- 在叢集中一部分節點故障後,叢集整體是否還能響應客戶端的讀寫請求。(對資料 更新具備高可用性)
- 分割槽容錯性(Partition tolerance):
- 大多數分散式系統都分佈在多個子網路。每個子網路就叫做一個區(partition)。 分割槽容錯的意思是,區間通訊可能失敗。比如,一臺伺服器放在中國,另一臺服務 器放在美國,這就是兩個區,它們之間可能無法通訊。
CAP 原則指的是,這三個要素最多隻能同時實現兩點,不可能三者兼顧。
一般來說,分割槽容錯無法避免,因此可以認為 CAP 的 P 總是成立。CAP 定理告訴我們, 剩下的 C 和 A 無法同時做到。
raft 演算法
分散式系統中實現一致性的 raft 演算法、paxos演算法
Raft (thesecretlivesofdata.com)
13.4BASE
面臨的問題
對於多數大型網際網路應用的場景,主機眾多、部署分散,而且現在的叢集規模越來越大,所 以節點故障、網路故障是常態,而且要保證服務可用性達到 99.99999%(N 個 9),即保證 P 和 A,捨棄 C
BASE 理論
是對 CAP 理論的延伸,思想是即使無法做到強一致性(CAP 的一致性就是強一致性),但可 以採用適當的採取弱一致性,即最終一致性。
BASE 是指
- 基本可用(Basically Available)
- 基本可用是指分散式系統在出現故障的時候,允許損失部分可用性(例如響應時間、 功能上的可用性),允許損失部分可用性。需要注意的是,基本可用絕不等價於系 統不可用。
- 響應時間上的損失:正常情況下搜尋引擎需要在 0.5 秒之內返回給使用者相應的 查詢結果,但由於出現故障(比如系統部分機房發生斷電或斷網故障),查詢 結果的響應時間增加到了 1~2 秒
- 功能上的損失:購物網站在購物高峰(如雙十一)時,為了保護系統的穩定性, 部分消費者可能會被引導到一個降級頁面。
- 基本可用是指分散式系統在出現故障的時候,允許損失部分可用性(例如響應時間、 功能上的可用性),允許損失部分可用性。需要注意的是,基本可用絕不等價於系 統不可用。
- 軟狀態( Soft State)
- 軟狀態是指允許系統存在中間狀態,而該中間狀態不會影響系統整體可用性。分佈 式儲存中一般一份資料會有多個副本,允許不同副本同步的延時就是軟狀態的體 現。mysql replication 的非同步複製也是一種體現。
- 最終一致性( Eventual Consistency)
- 最終一致性是指系統中的所有資料副本經過一定時間後,最終能夠達到一致的狀 態。弱一致性和強一致性相反,最終一致性是弱一致性的一種特殊情況。
強一致性、弱一致性、最終一致性
從客戶端角度,多程序併發訪問時,更新過的資料在不同程序如何獲取的不同策略,決定了 不同的一致性。對於關係型資料庫,要求更新過的資料能被後續的訪問都能看到,這是強一 致性。如果能容忍後續的部分或者全部訪問不到,則是弱一致性。如果經過一段時間後要求 能訪問到更新後的資料,則是最終一致性。
13.5分散式事務常見解決方案
1.2PC 模式
資料庫支援的 2PC【2 phase commit 二階提交】,又叫做 XA Transactions。 MySQL 從 5.5 版本開始支援,SQL Server 2005 開始支援,Oracle 7 開始支援。 其中,XA 是一個兩階段提交協議,該協議分為以下兩個階段:
第一階段:事務協調器要求每個涉及到事務的資料庫預提交(precommit)此操作,並反映是 否可以提交.
第二階段:事務協調器要求每個資料庫提交資料。 其中,如果有任何一個資料庫否決此次提交,那麼所有資料庫都會被要求回滾它們在此事務 中的那部分資訊。
-
XA 協議比較簡單,而且一旦商業資料庫實現了 XA 協議,使用分散式事務的成本也比較 低。
-
XA 效能不理想,特別是在交易下單鏈路,往往併發量很高,XA 無法滿足高併發場景
-
XA 目前在商業資料庫支援的比較理想,在 mysql 資料庫中支援的不太理想,mysql 的 XA 實現,沒有記錄 prepare 階段日誌,主備切換回導致主庫與備庫資料不一致。
-
許多 nosql 也沒有支援 XA,這讓 XA 的應用場景變得非常狹隘。
-
也有 3PC,引入了超時機制(無論協調者還是參與者,在向對方傳送請求後,若長時間 未收到回應則做出相應處理)
2.柔性事務-TCC 事務補償型
剛性事務:遵循 ACID 原則,強一致性。
柔性事務:遵循 BASE 理論,最終一致性;
與剛性事務不同,柔性事務允許一定時間內,不同節點的資料不一致,但要求最終一致。
一階段 prepare 行為:呼叫 自定義 的 prepare 邏輯。
二階段 commit 行為:呼叫 自定義 的 commit 邏輯。
三階段 rollback 行為:呼叫 自定義 的 rollback 邏輯。
所謂 TCC 模式,是指支援把 自定義 的分支事務納入到全域性事務的管理
3.柔性事務-最大努力通知型方案
按規律進行通知,不保證資料一定能通知成功,但會提供可查詢操作介面進行核對。這種 方案主要用在與第三方系統通訊時,比如:呼叫微信或支付寶支付後的支付結果通知。這種 方案也是結合 MQ 進行實現,例如:透過 MQ 傳送 http 請求,設定最大通知次數。達到通 知次數後即不再通知。 案例:銀行通知、商戶通知等(各大交易業務平臺間的商戶通知:多次通知、查詢校對、對 賬檔案),支付寶的支付成功非同步回撥。
4.柔性事務-可靠訊息+最終一致性方案(非同步確保型)
實現:業務處理服務在業務事務提交之前,向實時訊息服務請求傳送訊息,實時訊息服務只 記錄訊息資料,而不是真正的傳送。業務處理服務在業務事務提交之後,向實時訊息服務確 認傳送。只有在得到確認傳送指令後,實時訊息服務才會真正傳送。
防止訊息丟失:
/**
*1、做好訊息確認機制(pulisher,consumer【手動 ack】)
*2、每一個傳送的訊息都在資料庫做好記錄。定期將失敗的訊息再次傳送一 遍
*/
13.6Seata&環境準備
主要步驟:
- 匯入
seata
資料庫 - 部署
seata
服務 - 給所有的微服務建立
undo_log
表
官網:https://seata.apache.org/zh-cn/
服務下載地址:https://seata.apache.org/zh-cn/unversioned/release-history/seata-server
原始碼地址:https://github.com/apache/incubator-seata/blob/1.5.2/server/src/main/resources/application.yml
配置application.yml
匯入seata
資料庫
# 把sql檔案複製到mysql的docker容器裡
docker cp seata-tc.sql mysql:/seata-tc.sql
# 進入mysql容器
docker exec -it mysql bash
# 連線mysql
mysql -uroot -p
# 執行sql檔案
source /seata-tc.sql
# 執行完成的sql檔案可以刪除
rm -f seata-tc.sql
部署seata
如果下載太慢,可以使用docker load -i
從壓縮包載入 Docker 映象
# 載入映象
docker load -i seata-1.5.2.tar
我是使用docekrcompose
部署的
seata:
image: seataio/seata-server:1.5.2
container_name: seata
privileged: true # 設定容器的特權模式為 true
environment:
SEATA_IP: 192.168.188.180
ports:
- "8099:8099" # 對映主機的7091埠到容器的7091埠
- "7099:7099" # 對映主機的8091埠到容器的8091埠
depends_on:
- mysql # 啟動順序,先啟動 mysql 服務
- nacos # 啟動順序,先啟動 nacos 服務
volumes:
- ./seata:/seata-server/resources # 掛載本地 seata 目錄到容器的 /seata-server/resources
networks:
- mall-net # 指定連線的網路
restart: always
訪問http://192.168.188.180:7099/
使用者名稱密碼是application.yml
配置的
給所有的微服務建立undo_log
表
13.7Seata分散式事務體驗
主要步驟:
- 訂單服務
gulimall-order
、購物車服務gulimall-cart
、商品服務gulimall-product
、庫存服務gulimall-ware
都要匯入seata
,並且新增seata
配置 - 測試下單服務
訂單服務gulimall-order
、購物車服務gulimall-cart
、商品服務gulimall-product
、庫存服務gulimall-ware
都要匯入seata
,並且新增seata
配置
匯入seata
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
新增seata
配置
seata:
# data-source-proxy-mode: XA
registry: # TC服務註冊中心的配置,微服務根據這些資訊去註冊中心獲取tc服務地址
type: nacos # 註冊中心型別 nacos
nacos:
server-addr: 192.168.188.180:8848 # nacos地址
namespace: "" # namespace,預設為空
group: DEFAULT_GROUP # 分組,預設是DEFAULT_GROUP
application: seata-server # seata服務名稱
username: nacos
password: nacos
tx-service-group: peng # 事務組名稱
service:
vgroup-mapping: # 事務組與tc叢集的對映關係
peng: "default"
然後執行專案,seata
日誌顯示gulimall-order
、gulimall-product
、gulimall-ware
、gulimall-cart
加入成功
再次新增購物車,我選了一個沒有庫存的商品,然後提交訂單
沒有訂單生成,也沒有鎖定庫存,分散式事務回滾成功
13.8最終一致性庫存解鎖邏輯
seata
分散式事務不適合高併發場景
也不考慮2PC
模式和TCC
模式
建議最大努力通知和可靠訊息+最終一致性方案
14.商城業務-訂單服務
14.1RabbitMQ延時佇列
RabbitMQ延時佇列(實現定時任務)
場景: 比如未付款訂單,超過一定時間後,系統自動取消訂單並釋放佔有物品。
常用解決方案: spring的 schedule
缺點: 消耗系統記憶體、增加了資料庫的壓力、存在較大的時間誤差
解決:rabbitmq的訊息TTL和死信Exchange結合
訊息的TTL(Time To Live)
-
訊息的TTL就是訊息的存活時間。
-
RabbitMQ可以對佇列和訊息分別設定TTL。
- 對佇列設定就是佇列沒有消費者連著的保留時間,也可以對每一個單獨的訊息做單獨的 設定。超過了這個時間,我們認為這個訊息就死了,稱之為死信。
- 如果佇列設定了,訊息也設定了,那麼會取小的。所以一個訊息如果被路由到不同的隊 列中,這個訊息死亡的時間有可能不一樣(不同的佇列設定)。這裡單講單個訊息的 TTL,因為它才是實現延遲任務的關鍵。可以透過設定訊息的expiration欄位或者x- message-ttl屬性來設定時間,兩者是一樣的效果。
Dead Letter Exchanges(DLX)
- 一個訊息在滿足如下條件下,會進死信路由,記住這裡是路由而不是佇列, 一個路由可以對應很多佇列。(什麼是死信)
- 一個訊息被Consumer拒收了,並且reject方法的引數裡requeue是false。也就是說不 會被再次放在佇列裡,被其他消費者使用。(basic.reject/ basic.nack)requeue=false
- 上面的訊息的TTL到了,訊息過期了。
- 佇列的長度限制滿了。排在前面的訊息會被丟棄或者扔到死信路由上
- Dead Letter Exchange其實就是一種普通的exchange,和建立其他 exchange沒有兩樣。只是在某一個設定Dead Letter Exchange的佇列中有 訊息過期了,會自動觸發訊息的轉發,傳送到Dead Letter Exchange中去。
- 我們既可以控制訊息在一段時間後變成死信,又可以控制變成死信的訊息 被路由到某一個指定的交換機,結合二者,其實就可以實現一個延時佇列
- 手動ack&異常訊息統一放在一個佇列處理建議的兩種方式
- catch異常後,手動傳送到指定佇列,然後使用channel給rabbitmq確認訊息已消費
- 給Queue繫結死信佇列,使用nack(requque為false)確認訊息消費失敗
延時佇列實現-1
延時佇列實現-2
14.2延時佇列定時關單模擬
主要步驟:
- 建立交換機
order-event-exchange
- 建立延時佇列
order.delay.queue
,arguments.put("x-dead-letter-exchange", "order-event-exchange");
:繫結死信交換機arguments.put("x-dead-letter-routing-key", "order.release.order");
:設定死信路由routing-key
arguments.put("x-message-ttl", 20000)
:設定過期時間
- 建立普通佇列
order.release.order.queue
- 兩個佇列都繫結
order-event-exchange
,當訊息過期帶上routing-key=order.release.order
轉發給order-event-exchange
,最後轉發給order.release.order.queue
第一種方式
設定訊息過期時間,1分鐘後轉發給user.order.exchange
,客戶端監聽user.order.exchange
進行消費
第二種方式
生產者生產訊息設定訊息過期時間和routingkey=order.create.order
轉發給order-event-exchange
,1分鐘後帶上routingkey=order.release.order
轉發給order-event-exchange
,order-event-exchange
根據路由轉發給order.release.order.queue
,客戶端監聽order.release.order.queue
進行消費
SpringBoot中使用延時佇列
使用Bean
建立交換機、佇列,我這裡設定的過期時間是20s
建立繫結關係
監聽order.release.order.queue
佇列
@RabbitListener(queues = "order.release.order.queue")
public void listener(OrderEntity entity, Channel channel, Message message) throws IOException {
System.out.println("收到過期的訂單資訊:準備關閉訂單" + entity.getOrderSn());
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
}
模擬建立訂單的訊息
@ResponseBody
@GetMapping("/test/createorder")
public String createOrderTest() {
// 訂單下單成功
OrderEntity entity = new OrderEntity();
entity.setOrderSn(UUID.randomUUID().toString());
entity.setModifyTime(new Date());
// 給MQ傳送訊息。
rabbitTemplate.convertAndSend("order-event-exchange", "order.create.order", entity);
return "ok";
}
傳送建立訂單請求
http://order.gulimall.com/test/createorder
檢視控制檯,發現20s後order.delay.queue
收到訊息並消費
14.3建立業務交換機&佇列
主要步驟:
- 建立交換機
stock-event-exchange
- 建立延時佇列
stock.delay.queue
arguments.put("x-dead-letter-exchange", "stock-event-exchange");
:繫結死信交換機arguments.put("x-dead-letter-routing-key", "stock.release");
:設定死信路由routing-key
arguments.put("x-message-ttl", 120000);
:設定過期時間2分鐘
- 建立普通佇列
stock.release.stock.queue
- 兩個佇列都繫結
stock-event-exchange
,當訊息過期帶上routing-key=stock.release
轉發給stock-event-exchange
,最後轉發給stock.release.stock.queue
14.4監聽庫存解鎖
庫存解鎖場景
這裡主要解決第二種場景
- 1.下訂單成功,訂單過期沒有支付被系統自動取消、被使用者手動取消。都要解鎖庫存
- 2.下訂單成功,庫存鎖定成功,接下來的業務呼叫失敗,導致訂單回滾。
- 之前鎖定的庫存就要自動解鎖。
主要步驟:
- 資料庫
mall_wms.wms_ware_order_task_detail
新增欄位倉庫idware_id
、鎖定狀態lock_status
- 如果每一個商品都鎖定成功,將當前商品鎖定了幾件的工作單記錄發給MQ
- 鎖定失敗。前面儲存的工作單資訊都回滾了。傳送出去的訊息,即使要解鎖庫存,由於在資料庫查不到指定的id,所有就不用解鎖
- 監聽
stock.release.stock.queue
,成功解鎖傳送ack
,不成功就reject
,訊息重新放回佇列,讓別人消費
資料庫mall_wms.wms_ware_order_task_detail
新增欄位倉庫idwareId
、鎖定狀態lockStatus
如果每一個商品都鎖定成功,將當前商品鎖定了幾件的工作單記錄發給MQ
監聽stock.release.stock.queue
,成功解鎖傳送ack
,不成功就reject
,訊息重新放回佇列,讓別人消費
14.5庫存解鎖邏輯
主要步驟:
- 遠端呼叫訂單服務
gulimall-order
根據orderSn
查詢訂單資訊 - 解鎖庫存
- 訂單不存在
- 訂單已關閉
- 庫存工作單已鎖定
解鎖
14.6庫存自動解鎖完成
主要步驟:
-
gulimall-order
攔截器呼叫/order/order/status/**
時不需要進行登入驗證 -
最佳化解鎖庫存程式碼,單獨建立監聽類
StockReleaseListener
gulimall-order
攔截器呼叫/order/order/status/**
時不需要進行登入驗證
最佳化解鎖庫存程式碼,單獨建立監聽類StockReleaseListener
14.7測試庫存自動解鎖
清除相關表資料
truncate table mall_oms.oms_order;
truncate table mall_oms.oms_order_item;
truncate table mall_wms.wms_ware_order_task;
truncate table mall_wms.wms_ware_order_task_detail;
主要步驟:
- 1.建立訂單
submitOrder
時,在呼叫gulimall-ware
的方法orderLockStock
鎖定庫存成功後,模擬異常,這樣建立訂單就會失敗,建立訂單會回滾(oms_order
、oms_order_item
),但是庫存已經成功扣除 - 2.
gulimall-ware
的方法orderLockStock
鎖定庫存方法成功時向mq
延時佇列stock.delay.queue
新增訊息,並新增溯源資料(wms_ware_order_task
、wms_ware_order_task_detail
),訊息會在20s(設定的過期時間)時轉發給普通佇列stock.release.stock.queue
- 3.監聽普通佇列
stock.release.stock.queue
,根據orderSn
獲取訂單狀態,在訂單狀態已關閉或者訂單不存在和當前庫存工作單詳情狀態已鎖定(1),可以進行解鎖,解鎖完成更新庫存工作單詳情狀態已解鎖
建立訂單submitOrder
時,在呼叫gulimall-ware
的方法orderLockStock
鎖定庫存成功後,模擬異常,這樣建立訂單就會失敗,建立訂單會回滾(oms_order
、oms_order_item
),但是庫存已經成功扣除
gulimall-ware
的方法orderLockStock
鎖定庫存方法成功時向mq
延時佇列stock.delay.queue
新增訊息,並新增溯源資料(wms_ware_order_task
、wms_ware_order_task_detail
),訊息會在20s(設定的過期時間)時轉發給普通佇列stock.release.stock.queue
監聽普通佇列stock.release.stock.queue
,根據orderSn
獲取訂單狀態,在訂單狀態已關閉或者訂單不存在和當前庫存工作單詳情狀態已鎖定(1),可以進行解鎖
解鎖完成更新庫存工作單詳情狀態已解鎖
14.8定時關單完成
主要步驟:
-
order-event-exchange
交換機和stock.release.stock.queue
佇列建立繫結關係,關閉訂單後向stock.release.stock.queue
傳送訊息解鎖庫存 -
gulimall-order
建立訂單submitOrder
完成時向延時佇列order.delay.queue
傳送訊息,20s到期轉發給普通佇列order.delay.queue
,模擬20s後關閉訂單 -
gulimall-ware
監聽關閉訂單後傳送的訊息,然後解鎖庫存,這是主動解鎖 -
gulimall-ware
解鎖庫存時傳送的延時訊息是被動解鎖
order-event-exchange
交換機和stock.release.stock.queue
佇列建立繫結關係,關閉訂單後向stock.release.stock.queue
傳送訊息解鎖庫存
gulimall-order
建立訂單submitOrder
完成時向延時佇列order.delay.queue
傳送訊息,30s到期轉發給普通佇列order.delay.queue
,模擬30s後關閉訂單
gulimall-ware
監聽關閉訂單後傳送的訊息,然後解鎖庫存,這是主動解鎖
gulimall-ware
解鎖庫存時傳送的延時訊息是被動解鎖
建立訂單30s收到延時佇列的訊息關閉訂單
關閉訂單後向普通佇列stock.release.stock.queue
傳送訊息,gulimall-ware
進行主動解鎖
鎖定庫存時向延時佇列stock.delay.queue
傳送訊息,並設定過期時間30s,主動解鎖後再進行被動解鎖
主動解鎖是建立訂單後20s,被動解鎖時鎖定庫存後30s
14.9訊息丟失、積壓、重複等解決方案
訊息丟失
- 訊息傳送出去,由於網路問題沒有抵達伺服器
- 做好容錯方法(try-catch),傳送訊息可能會網路失敗,失敗後要有重試機 制,可記錄到資料庫,採用定期掃描重發的方式
- 做好日誌記錄,每個訊息狀態是否都被伺服器收到都應該記錄
- 做好定期重發,如果訊息沒有傳送成功,定期去資料庫掃描未成功的訊息進 行重發
- 訊息抵達Broker,Broker要將訊息寫入磁碟(持久化)才算成功。此時Broker尚 未持久化完成,當機。
- publisher也必須加入確認回撥機制,確認成功的訊息,修改資料庫訊息狀態。
- 自動ACK的狀態下。消費者收到訊息,但沒來得及訊息然後當機
- 一定開啟手動ACK,消費成功才移除,失敗或者沒來得及處理就noAck並重 新入隊
訊息重複
- 訊息消費成功,事務已經提交,ack時,機器當機。導致沒有ack成功,Broker的訊息 重新由unack變為ready,併傳送給其他消費者
- 訊息消費失敗,由於重試機制,自動又將訊息傳送出去
- 成功消費,ack時當機,訊息由unack變為ready,Broker又重新傳送
- 消費者的業務消費介面應該設計為冪等性的。比如扣庫存有 工作單的狀態標誌
- 使用防重表(redis/mysql),傳送訊息每一個都有業務的唯 一標識,處理過就不用處理
- rabbitMQ的每一個訊息都有redelivered欄位,可以獲取是否 是被重新投遞過來的,而不是第一次投遞過來的
沒有解鎖的庫存才進行解鎖,保證方法冪等性
防重表
CREATE TABLE `mq_message` (
`message_id` char(32) NOT NULL,
`content` json,
`to_exchane` varchar(255) DEFAULT NULL,
`routing_key` varchar(255) DEFAULT NULL,
`class_type` varchar(255) DEFAULT NULL,
`message_status` int(1) DEFAULT '0' COMMENT '0-新建 1-已傳送 2-錯誤抵達 3-已抵達',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`message_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
rabbitMQ的每一個訊息都有redelivered欄位,可以獲取是否 是被重新投遞過來的,而不是第一次投遞過來的
//當前訊息是否被第二次及以後(重新)派發過來了
Boolean redelivered = message.getMessageProperties().getRedelivered();
訊息積壓
- 消費者當機積壓
- 消費者消費能力不足積壓
- 傳送者傳送流量太大
- 上線更多的消費者,進行正常消費
- 上線專門的佇列消費服務,將訊息先批次取出來,記錄資料庫,離線慢慢處理
15.商城業務-支付
15.1支付寶沙箱&程式碼
文件地址 https://open.alipay.com/
開發者文件 https://openhome.alipay.com/docCenter/docCenter.htm
全部文件=>電腦網站支付文件;下載 demo https://opendocs.alipay.com/open/270/106291/
15.2RSA、加密加簽、金鑰等
資料地址
沙箱環境:https://openhome.alipay.com/develop/sandbox/account
demo下載地址:https://opendocs.alipay.com/open/270/106291/
金鑰工具下載地址:https://opendocs.alipay.com/common/02kipk?pathHash=0d20b438
idea執行demo,參考https://blog.csdn.net/lovoo/article/details/118770354
買家賬號
kalnhn3954@sandbox.com
登入密碼
111111
公鑰、私鑰、加密、簽名和驗籤
公鑰私鑰
- 公鑰和私鑰是一個相對概念
- 它們的公私性是相對於生成者來說的。
- 一對金鑰生成後,儲存在生成者手裡的就是私鑰,
- 生成者釋出出去大家用的就是公鑰
加密和數字簽名
加密
- 我們使用一對公私鑰中的一個金鑰來對資料進行加密,而使用另一個金鑰來進行解 密的技術。
- 公鑰和私鑰都可以用來加密,也都可以用來解密。
- 但這個加解密必須是一對金鑰之間的互相加解密,否則不能成功。
- 加密的目的是: 為了確保資料傳輸過程中的不可讀性,就是不想讓別人看到。
簽名
- 給我們將要傳送的資料,做上一個唯一簽名(類似於指紋)
- 用來互相驗證接收方和傳送方的身份
- 在驗證身份的基礎上再驗證一下傳遞的資料是否被篡改過。因此使用數字簽名可以 用來達到資料的明文傳輸。
驗籤
- 支付寶為了驗證請求的資料是否商戶本人發的
- 商戶為了驗證響應的資料是否支付寶發的
下載demo並開啟
下載demo
然後解壓
使用idea開啟
選擇Eclipse,剩下一路下一步即可
移除紅色模組
在Facet
設定web.xml
在工件新增war
包
配置tomcat
地址和埠
配置tomcat
訪問路徑
下載支付寶開放平臺金鑰工具
安裝路徑記得不要有空格
配置應用和支付寶的公鑰、私鑰、APPID
,執行demo
生成金鑰
在沙盒應用中選擇自定義金鑰,然後點選檢視
請複製“應用公鑰”至支付寶開放平臺,進而獲取支付寶公鑰
在專案中配置應用私鑰
把沙盒裡支付公鑰配置到專案中
配置APPID
執行專案
點選付款,跳轉登入介面,輸入沙盒賬號裡的買家資訊賬號、密碼和支付密碼
支付成功
15.3內網穿透
內網穿透工具有很多,我這裡使用的是OpenFrp
首次註冊需要實名認證,可能話費1-2元
地址:https://console.openfrp.net/helpcenter
下載客戶端並安裝
首先建立一個隧道,本地埠就是你demo執行埠,然後隨機生成遠端埠即可
建立完成後,開啟客戶端,開啟隧道,並複製域名用於訪問
再次訪問支付demo,能正常執行即可
16.商城業務-訂單服務
16.1整合支付前需要注意的問題
保證所有專案編碼都是utf-8
16.2整合支付
主要步驟:
- 匯入支付寶
SDK
,application.yaml
新增支付配置 - 封裝支付寶支付幫助類
- 根據
orderSn
查詢訂單,並把訂單資料傳入支付功能 - 前端請求支付寶支付介面
匯入支付寶SDK
,application.yaml
新增支付配置
封裝支付寶支付幫助類
根據orderSn
查詢訂單,並把訂單資料傳入支付功能
前端請求支付寶支付介面
16.3支付成功同步回撥
主要步驟:
- 修改
gulimall-order
支付成功頁面跳轉連結,跳轉到會員服務gulimall-member
,顯示訂單列表 gulimall-member
匯入SpringSession
依賴,application.yaml
新增thymeleaf
、redis
、session
的配置gulimall-member
新增SpringSession
配置gulimall-member
新增登入攔截器,放行OpenFiegn
遠端呼叫介面- 將訂單頁靜態資源上傳
nginx
- 將
index.html
複製到``gulimall-member的
src/main/resources/templates目錄下,並改名
orderList.html,修改
orderList.html頁面靜態資源地址,新增
thymeleaf`名稱空間 - 管理員啟動
SwitchHosts
,新增gulimall-member
的域名對映 gulimall-gateway
閘道器服務新增gulimall-member
會員服務的閘道器地址gulimall-member
新增MemberWebController
顯示orderList.html
修改gulimall-order
支付成功頁面跳轉連結,跳轉到會員服務gulimall-member
,顯示訂單列表
支付完成跳轉到訂單列表頁
gulimall-member
匯入SpringSession
依賴,application.yaml
新增thymeleaf
、redis
、session
的配置
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
gulimall-member
新增SpringSession
配置
gulimall-member
新增登入攔截器,放行OpenFiegn
遠端呼叫介面
將訂單頁靜態資源上傳nginx
將index.html
複製到``gulimall-member的
src/main/resources/templates目錄下,並改名
orderList.html,修改
orderList.html頁面靜態資源地址,新增
thymeleaf`名稱空間
href="
href="/static/member/
src="
src="/static/member/
管理員啟動SwitchHosts
,新增gulimall-member
的域名對映
192.168.188.180 member.gulimall.com
gulimall-gateway
閘道器服務新增gulimall-member
會員服務的閘道器地址
- id: gulimall-member_route
uri: lb://gulimall-member
predicates:
- Host=member.gulimall.com
gulimall-member
新增MemberWebController
顯示orderList.html
@GetMapping(value = "/orderList.html")
public String memberOrderPage(@RequestParam(value = "pageNum",required = false,defaultValue = "0") Integer pageNum,
Model model, HttpServletRequest request) {
return "orderList";
}
16.4訂單列表頁渲染完成
主要步驟:
gulimall-member
新增OpenFeign
配置gulimall-member
新增gulimall-order
的遠端呼叫,獲取使用者所有訂單列表gulimall-order
實現queryPageWithItem
分頁獲取使用者所有訂單列表
gulimall-member
新增OpenFeign
配置
gulimall-member
新增gulimall-order
的遠端呼叫,獲取使用者所有訂單列表
gulimall-order
實現queryPageWithItem
分頁獲取使用者所有訂單列表
@Override
public PageUtils queryPageWithItem(Map<String, Object> params) {
MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();
IPage<OrderEntity> page = this.page(
new Query<OrderEntity>().getPage(params),
new QueryWrapper<OrderEntity>() .eq("member_id",memberResponseVo.getId()).orderByDesc("create_time")
);
//遍歷所有訂單集合
List<OrderEntity> orderEntityList = page.getRecords().stream().map(order -> {
//根據訂單號查詢訂單項裡的資料
List<OrderItemEntity> orderItemEntities = orderItemService.list(new QueryWrapper<OrderItemEntity>()
.eq("order_sn", order.getOrderSn()));
order.setOrderItemEntityList(orderItemEntities);
return order;
}).collect(Collectors.toList());
page.setRecords(orderEntityList);
return new PageUtils(page);
}
16.5非同步通知內網穿透環境搭建
支付寶非同步通知
支付寶非同步通知文件:https://opendocs.alipay.com/open/270/105902/
使用nps作內網穿透,無法使用域名必須使用IP:PORT,所以會造成nginx無法根據訪問的域名gulimall.com來匹配請求
解決:修改nginx
配置檔案gulimall.conf
監聽server_name 124.223.7.41(這是自己的遠端地址)
新增以上域名監聽後,訪問124.223.7.41:8888(這是自己的遠端地址))出現404異常
原因:
- 閘道器88未攔截到請求
解決:
- 方案一:在閘道器增加攔截規則,攔截124.223.7.41,將請求傳送到order.gulimall.com
- 方案二:在nginx轉發時,設定host=order.gulimall.com,使閘道器可以正確攔截【推薦】
- 方案三:內網穿透的地址直接配成192.168.56.1:9000【缺點:沒有負載均衡了】
bug1:
修改gulimall.conf
server {
listen 80;
server_name gulimall.com *.gulimall.com 124.223.7.41;
location /static/ {
root /usr/share/nginx/html;
}
location / {
proxy_set_header Host $host;
proxy_pass http://gulimall;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
bug2:
方案一:
- id: gulimall_order_route2
uri: lb://gulimall-order
predicates:
- Host=124.223.7.41
方案二:
修改gulimall.conf
server {
listen 80;
server_name gulimall.com *.gulimall.com 124.223.7.41;
location /static/ {
root /usr/share/nginx/html;
}
location /payed/ {
proxy_set_header Host order.gulimall.com;
proxy_pass http://gulimall;
}
location / {
proxy_set_header Host $host;
proxy_pass http://gulimall;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
主要步驟:
- 配置本地連結為自己的本地服務虛擬接
ip
,遠端埠可隨機 - 修改
nginx
配置檔案gulimall.conf
監聽server_name
111.199.237.178(自己的遠端內網穿透地址) - 在
nginx
轉發時,設定host=order.gulimall.com
,使閘道器可以正確攔截【推薦】 gulimall-order
配置支付成功非同步回撥地址gulimall-order
登入攔截放行支付寶的非同步回撥通知gulimall-order
新增支付寶的非同步回撥通知介面
配置本地連結為自己的本地服務虛擬接ip
,遠端埠可隨機
修改nginx
配置檔案gulimall.conf
監聽server_name
111.199.237.178(自己的遠端內網穿透地址)
在nginx
轉發時,設定host=order.gulimall.com
,使閘道器可以正確攔截【推薦】
gulimall-order
配置支付成功非同步回撥地址
boolean match = antPathMatcher.match("/order/order/status/**", uri);
boolean match1 = antPathMatcher.match("/payed/notify", uri);
if (match || match1) {
return true;
}
gulimall-order
登入攔截放行支付寶的非同步回撥通知
@RestController
public class OrderPayedListener {
@PostMapping("/payed/notify")
public String handleAlipayed(HttpServletRequest request) {
// 只要我們收到了支付寶給我們非同步的通知,告訴我們訂單支付成功。返回success,支付寶就再也不通知
Map<String, String[]> requestParams = request.getParameterMap();
System.out.println("支付寶通知到位了..資料:" + requestParams);
return "success";
}
}
gulimall-order
新增支付寶的非同步回撥通知介面
測試
16.6支付完成
主要步驟:
- 支付寶驗籤
- 處理支付寶的支付結果
- 儲存交易流水
- 更新支付狀態
- 配置
spring.mvc
日期格式化
支付寶驗籤
@PostMapping(value = "/payed/notify")
public String handleAlipayed(PayAsyncVo asyncVo, HttpServletRequest request) throws AlipayApiException, UnsupportedEncodingException {
// 只要收到支付寶的非同步通知,返回 success 支付寶便不再通知
// 獲取支付寶POST過來反饋資訊
//TODO 需要驗籤
Map<String, String> params = new HashMap<>();
Map<String, String[]> requestParams = request.getParameterMap();
for (String name : requestParams.keySet()) {
String[] values = requestParams.get(name);
String valueStr = "";
for (int i = 0; i < values.length; i++) {
valueStr = (i == values.length - 1) ? valueStr + values[i]
: valueStr + values[i] + ",";
}
//亂碼解決,這段程式碼在出現亂碼時使用
// valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
params.put(name, valueStr);
}
boolean signVerified = AlipaySignature.rsaCheckV1(params, alipayTemplate.getAlipay_public_key(),
alipayTemplate.getCharset(), alipayTemplate.getSign_type()); //呼叫SDK驗證簽名
if (signVerified) {
System.out.println("簽名驗證成功...");
//去修改訂單狀態
String result = orderService.handlePayResult(asyncVo);
return result;
} else {
System.out.println("簽名驗證失敗...");
return "error";
}
}
處理支付寶的支付結果
- 儲存交易流水
- 更新支付狀態
/**
* 處理支付寶的支付結果
* @param asyncVo
* @return
*/
@Override
public String handlePayResult(PayAsyncVo asyncVo) {
//儲存交易流水資訊
PaymentInfoEntity paymentInfo = new PaymentInfoEntity();
paymentInfo.setOrderSn(asyncVo.getOut_trade_no());
paymentInfo.setAlipayTradeNo(asyncVo.getTrade_no());
paymentInfo.setTotalAmount(new BigDecimal(asyncVo.getBuyer_pay_amount()));
paymentInfo.setSubject(asyncVo.getBody());
paymentInfo.setPaymentStatus(asyncVo.getTrade_status());
paymentInfo.setCreateTime(new Date());
paymentInfo.setCallbackTime(asyncVo.getNotify_time());
//新增到資料庫中
this.paymentInfoService.save(paymentInfo);
//修改訂單狀態
//獲取當前狀態
String tradeStatus = asyncVo.getTrade_status();
if (tradeStatus.equals("TRADE_SUCCESS") || tradeStatus.equals("TRADE_FINISHED")) {
//支付成功狀態
String orderSn = asyncVo.getOut_trade_no(); //獲取訂單號
this.updateOrderStatus(orderSn,OrderStatusEnum.PAYED.getCode(), PayConstant.ALIPAY);
}
return "success";
}
/**
* 修改訂單狀態
* @param orderSn
* @param code
*/
private void updateOrderStatus(String orderSn, Integer code,Integer payType) {
this.baseMapper.updateOrderStatus(orderSn,code,payType);
}
mall_oms.oms_payment_info
的order_sn
欄位改為char(64)
配置spring.mvc
日期格式化
spring:
mvc:
format:
date: yyyy-MM-dd HH:mm:ss
16.7收單
支付寶支付介面
https://opendocs.alipay.com/open/cd12c885_alipay.trade.app.pay?pathHash=ab686e33&ref=api&scene=20
主要步驟:
- 訂單超時,不允許支付
- 解決:支付時設定超時時間:應該設定訂單絕對超時時間,而不是30m,按照建立訂單+30m來算截止時間
time_expire
- 解決:支付時設定超時時間:應該設定訂單絕對超時時間,而不是30m,按照建立訂單+30m來算截止時間
- 訂單解鎖完成,非同步通知才到
- 解決:釋放庫存的時候,手動呼叫收單功能(參照官方demo的實現)
- 對賬:每晚啟動定時任務和請求支付包介面進行對賬
訂單超時,不允許支付
訂單解鎖完成,非同步通知才到
關閉交易:https://opendocs.alipay.com/open/ce0b4954_alipay.trade.close?pathHash=7b0fdae1&ref=api&scene=common
對賬:每晚啟動定時任務和請求支付包介面進行對賬
https://opendocs.alipay.com/open/82ea786a_alipay.trade.query?pathHash=0745ecea&ref=api&scene=23
創作不易,感謝支援。