大家好,歡迎來到小菜同學的個人 solo 學堂,知識免費,不吝吸收!關注免費,不吝動手!
本文主要介紹
分布式事務
如有需要,可以參考
如有幫助,不忘 點贊 ❥
微信公眾號已開啟,小菜良記,沒關注的同學們記得關注哦!
生活可能對你耍無賴,但科技不能
我去小賣部買東西,付完了錢,老板轉身抽了口煙,卻忘記了我付完錢?這種情況怎么辦,發生在日常生活並不奇怪。但是你在網上下單,付完了錢,剛要查看訂單,卻提示你待支付,心中幾萬只草泥馬跑過也不得而知!所以防止這種情況的發生,分布式事務也變得尤為重要。
有人納悶了,付不付錢跟分布式事務有什么關系,這不是程序耍無賴嗎?但是耍無賴的背后卻是因為分布式事務在作祟!如果還不明白,那可能你還沒明白什么是事務,什么是分布式事務~
分布式事務
定義
事務提供一種機制將一個活動涉及的所有操作都納入到一個不可分割的執行單元,組成事務的祝所有操作只有在操作均正常執行的情況下才能提交,只要其中任一操作執行失敗,都會導致整個事務的回滾。簡單來說,要么做,要么不做
。
聽起來有點 man
,不要迷戀,先來了解一下事務的四大特性:ACID
- A(Atomic):原子性,構成事物的所有操作,要么全部執行完成,要么全部不執行,不可能出現部分成功部分失敗的情況。
- C(Consistency):一致性,在事務執行前后,數據庫的一致性約束沒有被破壞。一旦所有事務動作完成,事務就被提交,數據和資源處於一種滿足業務規則的一致性狀態中。比如上面說的,我向商店老板付了錢,我這邊扣除了100,而老板增加了100,這種就稱為一致性。
- I(Isolation):隔離性,數據庫中的事務一般都是並發的,隔離性是指並發的兩個事務互不干擾,一個事務不能看到其他事務運行過程的中間狀態,通過配置事務隔離級別可以避免在臟讀、幻讀、不可重復讀等問題。
- D(Durability):持久性,事務完成之后,該事務對數據的更改會持久化到數據庫中,並且不會被回滾
單體事務
早期我們使用的還是單體架構,像是一個大家族,其樂融融的生活在一起日夜耕作。
時間久了,各種各樣的問題自然而然的也出現了:復雜性高,部署頻率低,可靠性差,擴展能力受限...
承受了太多不該承受的流言蜚語,而大家也逐漸找尋新的出路, 那微服務架構也便受應出現:易於開發、擴展、理解和維護,不會受限於任何技術棧,易於和第三方應用系統集成...
太多太多的優點,讓單體系統也逐漸淡出人們的視角,好像如果現在不用微服務架構開發項目,就與社會脫節了~ 好處很多,但是問題也會變得更加復雜。這節我們不講別的,就來看看分布式事務是咋回事。
事務無論在單體還是微服務中都肯定是存在,但是在 單體 架構中,我們通常是怎么解決事務的呢? @Transactional
,單靠這個注解就可以開啟事務來保證整個操作的 原子性
。
分布式事務
微服務架構,其實就是將傳統的單體拆分成多個服務,然后多個服務之間相互配合,來完成業務需求。
分布式事務就是指事務的參與者,支持事務的服務器,資源服務器以及事務管理器分別位於不同的分布式系統的不同節點之上。
既然說到分布式事務了,我們不妨一起了解一下微服務中的 CAP理論
- C(Consistency):一致性。服務A、B、C三個節點都存儲了用戶數據,三個節點的數據都需要保持同一時刻數據一致性
- A(Availability):可用性。服務A、B、C三個節點,其中一個節點如果宕機了,不能影響整個集群對外提供服務。
- P(Partition Tolerance):分區容錯性就是允許系統通過網絡協同工作,分區容錯性要解決由於網絡分區導致數據的不完整及無法訪問等問題。
我們都知道魚和熊掌不可兼得,三者不能兼備擇兩者是也!CAP 目前來說無法都兼備,因此當前微服務策略中要么 CA
,要么CP
,不然就是AP
。而這個時候又有一個理論出現了,那就是 BASE理論 。它是用來對 CAP理論 進行一些補充,它值得是:
- BA(Basically Available):基本可用
- S(Soft State):軟狀態
- E(Eventually Consistent):最終一致性
這個理論的核心思想便是:如果我們如法做到強一致性,那么每個應用都應該根據自身的業務特點,采用適當的方式來使系統達到最終一致性。
出現場景
讓我們回到分布式事務中來,什么時候會出現分布式事務呢?
場景1: 雖然時單體的架構服務,但由於在分庫的情況下,依然會導致分布式事務的情況,因此單體服務不會出現分布式事務的這種說法,破~
場景2: 分布式架構下,兩個服務之間相互調用,雖然使用的是同一個數據庫,但是還是會出現分布式事務。誰讓你使用的是分布式架構呢~
場景3: 分布式架構下,兩個服務之間相互調用,使用的是不用的數據庫,這種情況下肯定會出現分布式事務的問題,想都不用想!
解決方法
有問題的地方便會有方法,當然也不一定,但是在這里,分布式事務問題確實有解決的方法, 如果沒有,小菜也不會寫這篇文章來自討苦吃了!
方法一:全局事務
不知道這里該說 全局事務 會讓你比較熟悉,還是 兩階段提交(2PC) 會讓你比較熟悉,還是說都不熟悉~,不熟悉也沒關系,小菜帶你熟悉熟悉!
全局事務是基於DTP模型實現的,它規定了要實現分布式事務需要三種角色:
- AP(Application):應用系統(微服務)
- TM(Transaction Manager):事務管理器(全局事務管理)
- RM(Resource Manager):資源管理器(數據庫)
除了 AP 這個角色,我們多認識了其他兩個同學分別是事務管理器
和資源管理器
,那么他們起到什么作用呢,那我們就得看,這個兩階段提交是哪兩階段了!
階段1 :表決階段
所有參與者都將自己的事務進行預提交,並將能否成功的信息反饋給協調者
- 事務管理器發一個
prepare
指令給 A 和 B 兩個服務器 - A 和 B 兩個服務器收到消息后,根據自身情況,判斷自己是否可以提交事務
- 將處理結果記錄到資源管理器中
- 將處理結果返回給事務管理器
階段2 :執行階段
協調者根據所有參與者的反饋,通知所有參與者,步調一致地執行提交或者回滾
- 事務管理器向 A 和 B 兩個服務器發送提交指令
- A 和 B 兩個服務器收到指令后,將自己本身事務提交
- 將處理結果記錄到資源管理器
- 將處理結果返回給事務管理器
這就是兩階段提交的大致過程,它提高了數據一致性的概率,實現成本較低。但是這種實現方式帶來的缺點也是很明顯的!
- 單點故障:如果事務管理器出現了故障,整個系統將不可用
- 同步阻塞:延遲了提交事件,加長了資源阻塞事件,不適合高並發的場景
- 數據不一致:如果執行到第二階段,依然存在commit結果未知的情況,只有部分參與者接收到 commit 消息,部分沒有收到,那也只有部分參與者提交了事務,依然會導致數據不一致問題
方法二:三階段提交
既然兩階段提交解決不了問題,那我們就來三階段提交。三階段提交相對於兩階段提交來說增加了 ConCommit
階段和超時機制。在一段規定時間內,如果服務器參與者沒有接受到來自事務管理器的提交執行,那他們就會自己自動提交,這樣子就能解決兩階段中單體故障問題。
我們來看看三階段提交是哪三階段:
-
CanCommit:准備階段。這個階段要做的事就和兩階段提交一樣,先去詢問參與者是否有條件接收這個事務,這樣子不會太暴力,一開始就直接干活鎖死資源。
-
PreCommit:這個階段是事務管理器向各個參加者發送准備提交請求,各個參與者接到請求或,將處理結果記錄到自己的資源管理器中,如果准備好了,就會想協調者反饋
ACK
表示我已經准備好提交了。 -
DoCommit:這個就斷就是從 預提交狀態 轉為 提交狀態。事務管理器向各個參與者發送 提交 請求,參與者接收到請求后,就會各自執行自己事務的提交操作。將處理結果記錄到自己的資源管理器中,並向協調者反饋
ACK
表示自己已經完成事務,如果有一個參與者未完成PreCommit的反饋或者反饋超時,那么協調者都會向所有的參與者節點發送abort請求,從而中斷事務。
其實三階段提交看起來就是把兩階段提交中的提交階段
變成了 預提交階段 和 提交階段。
那其實從上面可以看到,三階段提交解決的只是兩階段提交中 單體故障 的問題,因為加入了超時機制,這里的超時的機制作用於 預提交階段 和 提交階段。如果等待 預提交請求 超時,那參與者相當於說啥都沒干,直接回到准備階段之前。如果等到提交請求超時,那參與者就會提交事務了。
所以可以看到其實 三階段提交還是沒根本解決問題,雖然比兩階段提交進步了一點點~
方法三:TCC
TCC(Try Confirm Cancel) ,它是屬於補償型分布式事務。它的核心思想是 針對每個操作,都要注冊一個與其對應的確認和補償(撤銷)操作。TCC 實現分布式事務一共有三個步驟:
- Try:嘗試待執行的業務
這個過程並未執行業務,只是完成所有業務的一致性檢查,並預留好執行所需的所有資源
- Confirm:確認執行業務
確認執行業務的操作,不做任何業務檢查,只使用Try階段預留的業務資源。通常情況下,采用TCC則會認為 Confirm 階段是不會出錯的。只要 Try 成功,則 Confirm 一定成功。如果 Confirm 出錯了,則需要引入重試機制或人工處理
- Cancel:取消待執行的業務
取消 Try 階段預留的業務資源。通常情況下,采用 TCC 則認為 Cancel 階段也是一定能成功的,若 Cancel 階段真的出錯了,也要引入重試機制或人工處理
TCC 是業務層面的分布式事務,最終一致性,不會一直持有資源的鎖。它的優缺點如下:
優點: 吧數據庫層的二階段提交上提到了應用層來實現,規避了數據庫的 2PC 性能低下問題
缺點:TCC 的 Try、Confirm 和 Cancel 操作功能需業務提供,開發成本高。TCC 對業務的侵入較大和業務緊耦合,需要根據特定的場景和業務邏輯來設計相應的操作
方法五:可靠消息事務
消息事務的原理是 將兩個事務通過消息中間件來進行異步解耦。基於可靠消息服務的方案是通過消息中間件來保證上、下游應用數據操作的一致性。假設有 A、B兩個服務,分布可以處理 A、B兩個任務,此時需要存在一個業務流程,將任務 A和B 放到同一個事物中處理,這種方式就可以借助消息中間件來實現。
整體上可以分為兩個大的步驟:A服務向消息中間件發布消息
和 消息向B服務投遞消息
步驟一: A 服務向消息中間件發布消息
- 在服務A處理任務A前,首先向消息中間件發送一條半信息
- 消息中間件收到后將該消息持久化,但不進行投遞。持久化成功后,向A服務返回確認應答
- 服務A收到確認應答后,便可以開始處理任務A
- 任務A處理完成后,服務A便會向消息中間件發送Commit 或者 Rollback 請求,該請求發送完成后,服務A的工作任務就結束了,該事務的處理過程也就結束了
- 在消息中間件收到 Commit 后,便會向 B 服務投遞消息,如果收到 Rollback 便會直接丟棄消息
如果消息中間件在最后的過程中,長時間沒有收到服務A 發送的 Commit 或 Rollback 指令,這個時候就需要依靠 超時詢問機制
超時詢問機制:
服務A除了實現正常的業務流程之外,還是需要提供一個可供消息中間件事務詢問的接口。在消息中間件第一次收到消息后便會開始計時,如果超過規定的時間沒有收到后續的指令,就會主動調用服務A提供的事務詢問接口,詢問當前服務的狀態,通常來說該接口會返回三種結果,中間件需要根據這三種不同的結果做出不同的處理:
- 提交:直接將該消息投遞給服務B
- 回滾:直接將該消息丟棄
- 處理中:繼續等待,重新計時
步驟二: 消息中間件向B服務投遞消息
消息中間件收到A服務的提交 Commit指令后便會將該消息投遞給B服務,然后將自己的狀態置為阻塞等待狀態。B服務收到消息中間件發送的消息后便開始處理任務B,處理完成后便會向消息中間件發出回應。但是在消息中間件阻塞等待的時候同樣會出現問題
- 正常情況:消息中間件投遞完消息后,進入阻塞等待狀態,在收到確認應答后便認為事務處理完成,該流程結束
- 等待超時情況:在等待確認應答超時之后就會重新進行投遞,直到B服務器返回消費成功響應為止。而消息重試的次數和時間間隔都可以設置,如果最終還是不能成功進行投遞,則需要人工干預。
可靠消息服務方案是實現了 最終一致性。對比本地消息表實現方案,不需要再建立消息表。不用依賴本地數據庫事務,適用於高並發的場景。RocketMQ 就很好的支持了消息事務。
方法四:最大努力通知
最大努力通知也成為定期校對,是對可靠消息服務的進一步優化。它引入了本地消息表來記錄錯誤消息,然后加入失敗消息的定期校對功能,來進一步保證消息會被下游服務消費。
同樣的這個跟消息事務一樣可以分為兩步:
步驟一: 服務A向消息中間件發送消息
- 在處理業務的同一個事務中,向本地消息表寫入一條記錄
- 消息發送者不斷取出本地消息表中的消息發送到消息中間件,如果發送失敗則進行重試
步驟二: 消息中間件向服務B投遞消息
- 消息中間件收到消息后便會將消息投遞到下游服務B,服務B收到消息后便會執行自己的業務
- 當服務B業務處理成功后,便會向消息中間件返回反饋應答,消息中間件便可將該消息刪除,該流程結束
- 如果消息中間件向服務B投遞消息失敗,便會嘗試重試,如果重試失敗,便會將該消息接入失敗消息表中
- 消息中間件同樣需要提供查詢失敗消息的接口,服務B 定期查詢失敗信息,並進行消費
最大努力通知的方案實現比較簡單,適用於一些最終一致性要求比較低的業務。
Seata
Seata概念
既然分布式事務處理起來這么麻煩,那能不能讓分布式事務處理起來像本地事務那么簡單。當然這是我們的願景。當然這個願景是所有開發人員所希望的。而阿里巴巴團隊就為這個願景做出了行動,發起了開源項目 Seata(Simple Extensible Autonomous Transaction Architecture) 。這是一套分布式事務解決方案,意在解決開發人員遇到的分布式事務各方面的難題。
Seata 的設計目標是對業務無侵入,因此它是從業務無侵入的兩階段提交(全局事務)着手,在傳統的兩階段上進行改進,他把一個分布式事務理解成一個包含了若干分支事務的全局事務。而全局事務的職責是協調它管理的分支事務達成一致性,要么一起成功提交,要么一起失敗回滾。也就是一榮俱榮一損俱損~
Seata 組成
我們看下 Seata 中存在幾種重要角色:
- TC(Transaction Coordinator):事務協調者。管理全局的分支事務的狀態,用於全局性事務的提交和回滾。
- TM(Transaction Manager):事務管理者。用於開啟、提交或回滾事務。
- RM(Resource Manager):資源管理器。用於分支事務上的資源管理,向 TC 注冊分支事務,上報分支事務的狀態,接收 TC 的命令來提交或者回滾分支事務。
這是一種很巧妙的設計,我們來看圖:
執行流程是這樣的:
- 服務A中的 TM 向 TC 申請開啟一個全局事務,TC 就會創建一個全局事務並返回一個唯一的 XID
- 服務A中的 RM 向 TC 注冊分支事務,然后將這個分支事務納入 XID 對應的全局事務管轄中
- 服務A開始執行分支事務
- 服務A開始遠程調用B服務,此時 XID 會根據調用鏈傳播
- 服務B中的 RM 也向 TC 注冊分支事務,然后將這個分支事務納入 XID 對應的全局事務管轄中
- 服務B開始執行分支事務
- 全局事務調用處理結束后,TM 會根據有誤異常情況,向 TC 發起全局事務的提交或回滾
- TC 協調其管轄之下的所有分支事務,決定是提交還是回滾
Seata使用
我們從上面了解到了 Seata 的組成和執行流程,我們接下來就來實際的使用下 Seata。
示例演示
我們簡單創建了一個微服務項目,其中有訂單服務和庫存服務。
我們這里采用了 nacos 作為注冊中心,分別啟動兩個服務,我們在nacos控制台可以看到兩個已經注冊的服務:
號外:如果對nacos還不熟悉的小伙伴可以跳轉查看 nacos講解:微服務新秀之Nacos
我們接着創建了一個數據庫,其中有兩張表:c_order
和 c_product
,其中商品表中有一條數據,而訂單表中還未有數據,接下來我們將要對其進行操作!
我們現在模擬一個下單的過程:
- 請求進來,通過商品 pid 往數據庫中查商品的信息
- 創建一條該商品的訂單
- 對應扣減該商品的庫存量
- 流程結束
我們接下來就進入代碼演示一下:
注意:這里 ProductService 並非是庫存服務里面的類,而是利用 Feign 遠程調用庫存服務的接口
代碼三步走,正常請況下肯定是沒有問題的:
訂單生成,庫存也對應減少,感覺自己代碼可以上線進入正軌的時候,我們來模擬一下庫存中的異常,庫存商品數量歸為 100,訂單表清空:
我們繼續發送下單請求,可以看到庫存服務已經拋出了異常
正常來說這個時候,庫存表數量不應該減少,訂單表不應該插入訂單數據,但是事實真的是這樣的嗎?我們看數據:
庫存數量沒減,但是訂單卻增加了。好了,到這里,你就已經見識到了分布式事務的災難性危害。接下來主角登場!
Seata 安裝
我們首先需要點擊下載地址進行下載 Seata。
由於我們是使用 nacos 作為服務中心和配置中心,因此我們下載解壓后需要做一些修改操作
- 進入
conf
目錄編輯registry.conf
和file.conf
兩個文件,編輯后內容如下:
- 由於新版 Seata 中沒有
nacos-conf.sh
和config.txt
兩個文件,因此我們需要獨立下載:
我們需要將 config.txt
文件放到 seata 目錄下,而非 conf 目錄下,並且需要修改 config.txt
內容
config.txt
就是seata各種詳細的配置,執行nacos-config.sh
即可將這些配置導入到nacos,這樣就不需要將file.conf
和registry.conf
放到我們的項目中了,需要什么配置就直接從nacos中讀取。
- 執行導入
在 conf 目錄下打開 git bash 窗口,執行以下命令:
sh nacos-config.sh -h localhost -p 8848 -g SEATA_GROUP -t namespace-id(需要替換) -u nacos -w nacos
操作結束后,我們便可以在 nacos 控制台中看到配置列表,日后配置有需要修改便可以直接從這邊修改,而不用修改目錄文件:
- 數據庫配置
在 1.4.1 最新版中依然沒有 sql 文件,所以我們還是需要另外下載:sql 下載地址
在 seata 數據中執行這個文件,生成三張表:
在我們的業務數據庫中執行 undo_log 這張表:
CREATE TABLE `undo_log`
(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`branch_id` BIGINT(20) NOT NULL,
`xid` VARCHAR(100) NOT NULL,
`context` VARCHAR(128) NOT NULL,
`rollback_info` LONGBLOB NOT NULL,
`log_status` INT(11) NOT NULL,
`log_created` DATETIME NOT NULL,
`log_modified` DATETIME NOT NULL,
`ext` VARCHAR(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = INNODB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8;
- 添加 log 文件
如果我們沒有log輸出文件,啟動 seata 可能會報錯,因此我們需要在 seata 目錄下創建 logs 文件夾,在 logs 文件下創建 seata_gc.log
文件
- 啟動
做好了以上准備,我們便可以啟動 seata 了,直接在 bin 目錄下 cmd 執行 bat 腳本即可,啟動結束便可在 nacos 中看到 seata 服務:
Seata 集成
在 Seata 安裝的步驟中我們便完成了 Seata 服務端 的啟動安裝,接下來就是在項目中集成 Seata 客戶端
- 第一步:我們需要在 pom.xml 文件中添加兩個依賴:
seata 依賴
和nacos 配置依賴
<!--nacos-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<exclusions>
<!-- 排除依賴 指定版本和服務器端一致 -->
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-all</artifactId>
</exclusion>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-all</artifactId>
<version>1.4.1</version>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.4.1</version>
</dependency>
注意: 這里需要排除 spring-cloud-starter-alibaba-seata
自帶的 seata 依賴,然后引入我們自己需要的 seata 版本,如果版本不一致啟動時可能會造成 no available server to connect
錯誤!
- 第二步:我們需要把
restry.conf
文件復制到項目的 resource 目錄下
- 第三步:需要自己配置seata代理數據源
@Configuration
public class DataSourceProxyConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DruidDataSource druidDataSource() {
return new DruidDataSource();
}
@Primary
@Bean
public DataSourceProxy dataSource(DruidDataSource druidDataSource) {
return new DataSourceProxy(druidDataSource);
}
}
配置完數據源我們得在啟動類的 SpringBootApplication
上排除Druid數據源依賴,否則可能會出現循環依賴的錯誤:
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
- 第四步:在 nacos 的配置文件控制台中加入我們服務的事務組項:
service.vgroupMapping + 服務名稱 = default
group為: SEATA_GROUP
- 第五步:項目中配置修改
- 第六步:開啟全局事務
這步就是最終一步了,在我們需要開啟事務的方法上添加 @GlobalTransactional
注解,類似於我們單體事務添加的@Transactional
Seata 測試
我們現在回到項目中,在上面的示例演示中,我們已經知道了如果庫存服務發生異常,會出現的情況是,庫存沒有減少,而訂單依然會生成。那我們如果增加了 Seata 來管理全局事務,情況是否會有所改變?我們測試如下:
庫存服務已經了異常:
看下數據庫數據:
看樣子我們全局事務已經生效了,事務也已經完美的控制住了!
而我們創建的 undo_log 這張表在管理事務中也啟動了重要的作用:
看完了以上操作,我們趁熱打鐵來梳理一下其中的執行流程,讓你印象更加深刻些~
相信看完這張圖,你對 Seata 執行事務的流程也更加熟悉了吧!
這還沒結束,我們接着來看看其中的一些要點:
- 每個 RM 都需要使用 DataSourceProxy 連接數據庫,這樣是為了使用 ConnectionProxy,使用數據源和數據連接代理的目的就是在第一階段將 undo_log 和業務數據放在一個本地事務提交,這樣就保存了只要有業務操作就一定有 undo_log 產生!
- 在第一階段的 undo_log 中存放了數據修改前和修改后的值,為事務回滾做好准備,所以第一階段就已經將分支事務提交,也就釋放了鎖資源!
- TM 開啟全局事務后,便會將 XID 放入全局事務的上下文中,我們通過 feign 調用也會將 XID 傳入下游服務中,每個分支事務都會將自己的 Branch ID 與 XID 相關聯!
- 第二階段如果全局事務是正常提交,那么TC 會通知各分支參與者提交分支事務,各參與者只需要刪除對應的 undo_log 即可,並且可以異步執行!
- 第二階段如果全局事務需要回滾,那么 TC 會通知各分支事務參與者回滾分支事務,通過 XID 和 Branch ID 找到相應的 undo_log 日志,通過回滾日志生成反向 SQL 並執行,完成事務提交之前的狀態,如果回滾失敗便會重試回滾操作!
END
到這里,一篇分布式事務就講完了,我們回顧下,從分布式事務的五種解決方案到引出 Seata 的使用,小菜同學真是用心良苦~ 言歸正傳,看完后吸收了多少,動動小手,寫寫代碼,讓知識與你更親近~
今天的你多努力一點,明天的你就能少說一句求人的話!
我是小菜,一個和你一起學習的男人。
💋
微信公眾號已開啟,小菜良記,沒關注的同學們記得關注哦!