一、背景
Binlog 是 MySQL 中一個(gè)很重要的日志,主要用于 MySQL 主從間的數(shù)據(jù)同步復(fù)制。正是因?yàn)?Binlog 的這項(xiàng)功用判帮,它也被用于 MySQL 向其它類型數(shù)據(jù)庫(kù)同步數(shù)據(jù),以及業(yè)務(wù)流程的事件驅(qū)動(dòng)設(shè)計(jì)溉箕。通過(guò)研究分析晦墙,我們發(fā)現(xiàn)使用 MySQL Binlog 實(shí)現(xiàn)事件驅(qū)動(dòng)設(shè)計(jì)并沒(méi)有想象中那么簡(jiǎn)單,所以接下來(lái)帶大家了解 MySQL 的 Binlog肴茄、Redo Log晌畅、數(shù)據(jù)更新內(nèi)部流程,并通過(guò)對(duì)這些技術(shù)原理的介紹寡痰,來(lái)分析對(duì)業(yè)務(wù)流程可能造成的問(wèn)題抗楔,以及如何避免這些問(wèn)題。希望通過(guò)本文的解析拦坠,能夠幫助大家了解到 MySQL 的一些原理连躏,從而幫助大家能夠更順利地使用 MySQL。
二贪婉、基于 Binlog 的事件驅(qū)動(dòng)
首先介紹一下系統(tǒng)設(shè)計(jì)反粥。起初,我們的訂單系統(tǒng)直接向 MQ 發(fā)送消息疲迂,通過(guò)異步消息驅(qū)動(dòng)后續(xù)業(yè)務(wù)流程,以實(shí)現(xiàn)消息驅(qū)動(dòng)的設(shè)計(jì)莫湘。大致的業(yè)務(wù)流程如下:
▼ 圖:直接發(fā)送消息的訂單事件驅(qū)動(dòng)
這種設(shè)計(jì)需要保證數(shù)據(jù)庫(kù)操作和消息操作的數(shù)據(jù)一致性尤蒿,即數(shù)據(jù)保存和消息發(fā)送要不全部成功,要不全部失敗幅垮。顯然在數(shù)據(jù)保存前和事務(wù)中進(jìn)行消息發(fā)送都是不合適的腰池。我們是在數(shù)據(jù)更新操作后,數(shù)據(jù)庫(kù)事務(wù)外發(fā)送消息。如果數(shù)據(jù)保存成功示弓,但消息發(fā)送失敗讳侨,支付系統(tǒng)需要重新通知(上圖步驟1),直至通知成功奏属。
這種設(shè)計(jì)雖然實(shí)現(xiàn)了功能和對(duì)可用性的基本要求跨跨,但存在如下缺點(diǎn):
- 業(yè)務(wù)系統(tǒng)直接依賴消息中間件:消息中間件的故障,不僅會(huì)影響支付通知的處理囱皿,也可能影響業(yè)務(wù)系統(tǒng)上的其它接口勇婴。
- 業(yè)務(wù)系統(tǒng)必須實(shí)現(xiàn)可靠的重試:不論是請(qǐng)求發(fā)起方還是請(qǐng)求接收方,都必須實(shí)現(xiàn)可靠的重試嘱腥,才能實(shí)現(xiàn)最大努力通知的目標(biāo)耕渴。
- 重試間隔增大會(huì)造成業(yè)務(wù)延遲:隨著重試次數(shù)增加,每次重試的間隔通常也越來(lái)越大齿兔,這被稱為 Exponential Backoff(指數(shù)級(jí)退避)橱脸。這種設(shè)計(jì)能夠讓請(qǐng)求接收方的故障處理更加從容,避免因密集重試造成請(qǐng)求接收方服務(wù)難以恢復(fù)分苇。但這樣做可能會(huì)使請(qǐng)求接收方在恢復(fù)服務(wù)之后很長(zhǎng)時(shí)間后才處理完積壓的消息慰技,從而造成業(yè)務(wù)延遲。我們可以采用類似 Hystrix 的自適應(yīng)設(shè)計(jì)组砚,在請(qǐng)求接收方服務(wù)恢復(fù)后回到到正常的請(qǐng)求速率吻商。但這樣的設(shè)計(jì)顯然會(huì)復(fù)雜許多。
為了解決上述問(wèn)題糟红,簡(jiǎn)化技術(shù)架構(gòu)艾帐,我們采用事件表的設(shè)計(jì)思想,將訂單表作為事件表盆偿。通過(guò)訂閱訂單表的 Binlog柒爸,生成訂單事件,驅(qū)動(dòng)后續(xù)業(yè)務(wù)流程事扭。在系統(tǒng)架構(gòu)上捎稚,業(yè)務(wù)系統(tǒng)不用直接依賴消息中間件,只需專注數(shù)據(jù)庫(kù)操作求橄。而通過(guò)引入一個(gè)接收 Binlog 的獨(dú)立的系統(tǒng)今野,將 MySQL 數(shù)據(jù)變化轉(zhuǎn)換成業(yè)務(wù)事件驅(qū)動(dòng)后續(xù)流程。具體流程如下:
▼ 圖:基于 Binlog 的訂單事件驅(qū)動(dòng)
三罐农、暗藏問(wèn)題
上文提到条霜,雖然基于 Binlog 的訂單事件驅(qū)動(dòng)設(shè)計(jì)存在諸多優(yōu)點(diǎn),但后來(lái)發(fā)現(xiàn)其實(shí)暗藏問(wèn)題涵亏。
最近在一個(gè)業(yè)務(wù)量較低的新站點(diǎn)上宰睡,我們發(fā)現(xiàn)偶爾會(huì)有訂單履約延遲蒲凶。正常流程中,訂單履約服務(wù)收到訂單支付事件后拆内,會(huì)檢查訂單狀態(tài)旋圆,如果此時(shí)訂單狀態(tài)為已支付,則進(jìn)行履約流程的處理麸恍。但對(duì)于有履約延遲的訂單灵巧,訂單履約服務(wù)收到此訂單的支付事件后,查詢數(shù)據(jù)庫(kù)發(fā)現(xiàn)此訂單并非支付狀態(tài)或南。經(jīng)過(guò)調(diào)查孩等,我們排除了數(shù)據(jù)并發(fā)覆蓋問(wèn)題,并且訂單狀態(tài)查詢是發(fā)生在主庫(kù)上采够,也不存在主從同步延遲問(wèn)題肄方。
那究竟是什么原因?qū)е聵I(yè)務(wù)系統(tǒng)收到根據(jù) Binlog 生成的訂單支付事件后,再查詢主庫(kù)得到的訂單數(shù)據(jù)卻是未支付狀態(tài)的蹬癌?
對(duì)于此問(wèn)題的原因我們先放下不談权她,先來(lái)看看 MySQL 在更新數(shù)據(jù)時(shí)的內(nèi)部原理。
四逝薪、MySQL 數(shù)據(jù)更新相關(guān)原理
本節(jié)將向大家介紹 MySQL 數(shù)據(jù)更新相關(guān)原理隅要,以及在這一過(guò)程中最重要的兩種日志:Redo Log 和 Binlog。
Redo Log 和 Binlog
先來(lái)介紹 Redo Log 和 Binary Log(Binlog):
- Redo Log:Redo Log 是 InnoDB 存儲(chǔ)引擎提供的一種物理日志結(jié)構(gòu)董济,用來(lái)描述對(duì)底層數(shù)據(jù)頁(yè)操作的具體內(nèi)容步清,主要用于實(shí)現(xiàn) crash-safe,并提升磁盤(pán)操作效率虏肾。
- Binlog:Binlog 是 MySQL 本身提供的一種邏輯日志廓啊,和具體存儲(chǔ)引擎無(wú)關(guān),描述的是數(shù)據(jù)庫(kù)所執(zhí)行的 SQL 語(yǔ)句或數(shù)據(jù)變更情況封豪,主要用于數(shù)據(jù)復(fù)制谴轮。
InnoDB 引入 Redo Log 的目的在于實(shí)現(xiàn) crash-safe 和提升數(shù)據(jù)更新效率。如果 InnoDB 每次數(shù)據(jù)寫(xiě)操作都要直接持久化到磁盤(pán)上的數(shù)據(jù)頁(yè)中吹埠,那樣會(huì)大量增加磁盤(pán)隨機(jī) IO 次數(shù)第步。引入 Redo Log 后,在對(duì)數(shù)據(jù)寫(xiě)操作時(shí)缘琅,會(huì)將部分隨機(jī) IO 寫(xiě)變?yōu)轫樞驅(qū)懻扯肌R驗(yàn)榇疟P(pán)的順序 IO 效率遠(yuǎn)高于隨機(jī) IO,因此引入 Redo Log 機(jī)制有助于提升更新數(shù)據(jù)時(shí)的性能(如何實(shí)現(xiàn) crash-safe 將在下一節(jié)介紹)胯杭。
Binlog 主要用于數(shù)據(jù)的復(fù)制驯杜,MySQL 的主從復(fù)制就是基于 Binlog 實(shí)現(xiàn)的。另外現(xiàn)在很多數(shù)據(jù)同步做个、業(yè)務(wù)事件驅(qū)動(dòng)也是基于 Binlog 實(shí)現(xiàn)的鸽心。Binlog 有三種格式:Statement、Row 和 Mixed居暖。Statement 格式的 Binlog 直接記錄 SQL 語(yǔ)句的內(nèi)容顽频,日志數(shù)據(jù)量較小,但在部分情況下可能會(huì)導(dǎo)致數(shù)據(jù)復(fù)制錯(cuò)誤太闺;而 Row 模式則是記錄變更的前后變化糯景,數(shù)據(jù)量較大,但好處是數(shù)據(jù)可以被準(zhǔn)確復(fù)制省骂;Mixed 則結(jié)合了兩者的優(yōu)點(diǎn)蟀淮。
下面的表格說(shuō)明了兩種日志的作用和它們的不同:
Redo Log | Binlog | |
---|---|---|
日志類型 | 物理日志,即數(shù)據(jù)頁(yè)中的真實(shí)二級(jí)制數(shù)據(jù)钞澳,恢復(fù)速度快 | 邏輯日志怠惶,SQL 語(yǔ)句 (statement) 或數(shù)據(jù)邏輯變化 (row),恢復(fù)速度慢 |
存儲(chǔ)格式 | 基于 InnoDB 數(shù)據(jù)頁(yè)格式進(jìn)行存儲(chǔ) | SQL 語(yǔ)句或數(shù)據(jù)變化內(nèi)容 |
用途 | 重做數(shù)據(jù)頁(yè) | 數(shù)據(jù)復(fù)制 |
層級(jí) | InnoDB 存儲(chǔ)引擎層 | MySQL Server 層 |
記錄方式 | 循環(huán)寫(xiě) | 追加寫(xiě) |
這時(shí)問(wèn)題來(lái)了轧粟,現(xiàn)在 MySQL 中存在了兩種日志結(jié)構(gòu):Redo Log 和 Binlog策治。雖然它們的結(jié)構(gòu)和功能有所不同,但卻記錄著相同的數(shù)據(jù)兰吟。如何保證這兩種日志數(shù)據(jù)的一致性通惫,以及如何實(shí)現(xiàn) crash-safe 呢?這就引出了兩階段提交設(shè)計(jì)混蔼。
兩階段提交
兩階段提交不是 Redo Log 或 InnoDB 中的設(shè)計(jì)履腋,而是 MySQL 服務(wù)器的設(shè)計(jì)(但通常說(shuō)到兩階段提交時(shí)都和 Redo Log 放在一起)。因?yàn)?MySQL 采用插件化的存儲(chǔ)引擎設(shè)計(jì)惭嚣,事務(wù)提交時(shí)遵湖,服務(wù)器本身和存儲(chǔ)引擎都需要提交數(shù)據(jù)。所以從 MySQL 服務(wù)器角度看料按,其本身就面臨著分布式事務(wù)問(wèn)題奄侠。
為解決此問(wèn)題,MySQL 引入了兩階段提交载矿。在兩階段提交過(guò)程中垄潮,Redo Log 會(huì)有兩次操作:Prepare 和 Commit。而 Binlog 寫(xiě)操作則夾在 Redo Log 的 Prepare 和 Commit 操作之間闷盔。我們可以設(shè)想一下不同失敗場(chǎng)景下兩階段提交的設(shè)計(jì)是如何保證數(shù)據(jù)一致的:
- Redo Log Prepare 成功弯洗,在寫(xiě) Binlog 前崩潰:在故障恢復(fù)后事務(wù)就會(huì)回滾。這樣 Redo Log 和 Binlog 的內(nèi)容還是一致的逢勾。這種情況比較簡(jiǎn)單牡整,比較復(fù)雜的是下一種情況,即在寫(xiě) Binlog 和 Redo Log Commit 中間崩潰時(shí)溺拱,MySQL 是如何處理的逃贝?
-
在寫(xiě) Binlog 之后谣辞,但 Redo Log 還沒(méi)有 Commit 之前崩潰
- 如果 Redo Log 有 Commit 標(biāo)識(shí),說(shuō)明 Redo Log 其實(shí)已經(jīng) Commit 成功沐扳。這時(shí)直接提交事務(wù)泥从。
- 如果 Redo Log 沒(méi)有 Commit 標(biāo)識(shí),則使用 XID(事務(wù) ID)查詢 Binlog 相應(yīng)日志沪摄,并檢查日志的完整躯嫉。如果 Binlog 是完整的,則提交事務(wù)杨拐,否則回滾祈餐。
如何判斷 Binlog 是否完整?簡(jiǎn)單來(lái)說(shuō) Statement 格式的 Binlog 最后有 Commit哄陶,或 Row 格式的 Binlog 有 XID Event帆阳,那 Binlog 就是完整的。
MySQL 數(shù)據(jù)更新流程
接下來(lái)看一下 MySQL 執(zhí)行器和 InnoDB 存儲(chǔ)引擎在執(zhí)行簡(jiǎn)單 update 語(yǔ)句 update t set n = n + 1 where id = 2
時(shí)的流程(因?yàn)榇死粓?zhí)行單條更新語(yǔ)句奕筐,所以其自身就是一個(gè)事務(wù))舱痘。
- 執(zhí)行器先找引擎取 ID=2 這一行。ID 是主鍵离赫,引擎直接用樹(shù)搜索找到這一行芭逝。如果 ID=2 這一行所在的數(shù)據(jù)頁(yè)本來(lái)就在內(nèi)存中,就直接返回給執(zhí)行器渊胸;否則旬盯,需要先從磁盤(pán)讀入內(nèi)存,然后再返回翎猛。
- 執(zhí)行器拿到引擎給的行數(shù)據(jù)胖翰,把這個(gè)值加上1,比如原來(lái)是 N切厘,現(xiàn)在就是 N+1萨咳,得到新的一行數(shù)據(jù),再調(diào)用引擎接口寫(xiě)入這行新數(shù)據(jù)疫稿。
- 引擎將這行新數(shù)據(jù)更新到內(nèi)存中培他。然后將對(duì)內(nèi)存數(shù)據(jù)頁(yè)的更新內(nèi)容記錄在 Redo Log Buffer 中(這里不詳細(xì)介紹 Redo Log Buffer。只需知道對(duì) Redo Log 的操作并不會(huì)直接寫(xiě)在文件上遗座,而是先記錄在內(nèi)存中舀凛,然后在特定時(shí)刻才會(huì)寫(xiě)入磁盤(pán))。此時(shí)完成了數(shù)據(jù)更新操作途蒋。
- 接下來(lái)要進(jìn)行事務(wù)提交的操作猛遍。事務(wù)提交時(shí),Redo Log 被標(biāo)記為 Prepare 狀態(tài)。通常此時(shí)懊烤,Redo Log 會(huì)從 Buffer 寫(xiě)入磁盤(pán)(
innodb_flush_log_at_trx_commit
梯醒,值為1時(shí),每次提交事務(wù) Redo Log 都會(huì)寫(xiě)入磁盤(pán))奸晴。然后 InnoDB 告知執(zhí)行器執(zhí)行完成冤馏,可以提交事務(wù)日麸。 - 執(zhí)行器生成本次操作的 Binlog寄啼,并把 Binlog 寫(xiě)入磁盤(pán)。
- 執(zhí)行器調(diào)用引擎的提交事務(wù)接口代箭,引擎把剛剛寫(xiě)入的 Redo Log 改成提交 Commit 狀態(tài)墩划,更新完成。
▼ 下圖描述了 update 語(yǔ)句執(zhí)行過(guò)程中 MySQL 執(zhí)行器嗡综、InnoDB乙帮,以及 Binlog、Redo Log 交互過(guò)程(圖中深綠底色的是 MySQL 執(zhí)行器負(fù)責(zé)的階段极景,淺綠底色是 InnoDB 負(fù)責(zé)的階段)
五察净、問(wèn)題原因簡(jiǎn)析
從上面對(duì) MySQL 原理的介紹我們得知,寫(xiě) Binlog 發(fā)生在事務(wù)提交階段盼樟,但是 MySQL 因?yàn)樵?Server 層和存儲(chǔ)引擎層都引入了不同的日志結(jié)構(gòu)氢卡,從而引入了兩階段提交。Binlog 的寫(xiě)入發(fā)生在存儲(chǔ)引擎真正提交事務(wù)之前晨缴,這導(dǎo)致理論上通過(guò) Binlog 同步數(shù)據(jù)的系統(tǒng)(MySQL 從庫(kù)译秦、其它數(shù)據(jù)庫(kù)或業(yè)務(wù)系統(tǒng))有可能早于 MySQL 主庫(kù)使最新提交的數(shù)據(jù)生效。
所以上面提到的訂單履約服務(wù)在收到基于 Binlog 的訂單支付事件后卻查到相應(yīng)訂單是未支付的击碗,原因很可能是訂單履約服務(wù)在查詢數(shù)據(jù)時(shí)筑悴,訂單支付數(shù)據(jù)更新操作在 MySQL 內(nèi)部尚未徹底完成事務(wù)的提交。
我們通過(guò)開(kāi)發(fā)驗(yàn)證程序重現(xiàn)了這一現(xiàn)象稍途。驗(yàn)證程序接收到事務(wù)提交完成后的完整 Binlog 時(shí)會(huì)再次在 MySQL 主庫(kù)上查詢對(duì)應(yīng)的記錄阁吝,結(jié)果會(huì)有一定概覽獲得事務(wù)提交前的數(shù)據(jù)。
另外經(jīng)過(guò)了解械拍,也有同行反映遇到過(guò)從庫(kù)早于主庫(kù)看到數(shù)據(jù)提交的問(wèn)題突勇。
六、問(wèn)題解決方法
在了解問(wèn)題背后的原因之后殊者,我們需要思考如何解決此問(wèn)題与境。目前解決此問(wèn)題有兩個(gè)方法:重試和直接使用 Binlog 數(shù)據(jù)。
重試這種做法簡(jiǎn)單粗暴猖吴,既然問(wèn)題原因是 Binlog 早于事務(wù)提交摔刁,那等一下再重試查詢自然就解決了。但在實(shí)踐中海蔽,需要考慮重試的實(shí)現(xiàn)方法共屈、以及是否會(huì)因?yàn)橹卦囘^(guò)多甚至無(wú)限重試導(dǎo)致服務(wù)異常绑谣。對(duì)于重試的實(shí)現(xiàn),可使用的方法有線程 Sleep 大法和消息重投等方式拗引。線程 Sleep 大法通常是不被推薦的借宵,因?yàn)樗鼤?huì)導(dǎo)致線程利用率降低,甚至導(dǎo)致服務(wù)無(wú)法響應(yīng)矾削。但考慮到本次問(wèn)題出現(xiàn)概率較低壤玫,我們認(rèn)為線程 Sleep 大法是可以使用的,并且此方式簡(jiǎn)單易行哼凯,可用于問(wèn)題的快速修復(fù)欲间。
第二種重試方式是消息重投,比如 RocketMQ 中 Consumer 返回 ConsumeConcurrentlyStatus.RECONSUME_LATER
即可觸發(fā)消息重投断部。但這種重試方法成本較前一種方法高猎贴,另外重試間隔也相對(duì)較大,對(duì)時(shí)間敏感的業(yè)務(wù)影響也較大蝴光,因此是否采用此方法需從業(yè)務(wù)和技術(shù)兩個(gè)角度綜合考慮她渴。
除了考慮用何種方式重試,還要考慮 ABA 問(wèn)題蔑祟,即狀態(tài)變化按照 A->B->A 的方式進(jìn)行趁耗。業(yè)務(wù)系統(tǒng)期待的狀態(tài)是 B,但實(shí)際可能沒(méi)辦法再變成 B 了做瞪。因此在用重試解決此問(wèn)題之前对粪,需要先排除業(yè)務(wù)系統(tǒng)存在 ABA 問(wèn)題的可能。對(duì)于狀態(tài) ABA 問(wèn)題装蓬,可用狀態(tài)機(jī)等方式解決著拭,這里不再展開(kāi)討論。
除了重試牍帚,另一種方法就是直接使用 Binlog儡遮。因?yàn)?Binlog (row 格式) 直接反映了數(shù)據(jù)的變化情況,其中可以記錄事務(wù)提交涉及到的完整數(shù)據(jù)暗赶,因此可直接用作業(yè)務(wù)處理鄙币。這樣還可以降低數(shù)據(jù)庫(kù) QPS。如果是新設(shè)計(jì)的系統(tǒng)蹂随,我認(rèn)為這樣做法比較理想十嘿。但對(duì)于已有系統(tǒng),這種方式改動(dòng)可能較大岳锁,是否采用需權(quán)衡成本和收益绩衷。